commit a0df89c6933b6020d6d00d271dadc4018a556b33 Author: wehub-resource-sync Date: Mon Jul 13 12:08:39 2026 +0800 chore: import upstream snapshot with attribution diff --git a/.air.toml b/.air.toml new file mode 100644 index 0000000..6e7c00b --- /dev/null +++ b/.air.toml @@ -0,0 +1,52 @@ +root = "." +testdata_dir = "testdata" +tmp_dir = ".tmp" + +[build] + args_bin = [] + bin = "./dist/portainer" + cmd = "SKIP_GO_GET=true make build-server" + delay = 1000 + exclude_dir = [] + exclude_file = [] + exclude_regex = ["_test.go"] + exclude_unchanged = false + follow_symlink = false + full_bin = "./dist/portainer --log-level=DEBUG" + include_dir = ["api"] + include_ext = ["go"] + include_file = [] + kill_delay = "0s" + log = "build-errors.log" + poll = false + poll_interval = 0 + post_cmd = [] + pre_cmd = [] + rerun = false + rerun_delay = 500 + send_interrupt = false + stop_on_error = false + +[color] + app = "" + build = "yellow" + main = "magenta" + runner = "green" + watcher = "cyan" + +[log] + main_only = false + silent = false + time = false + +[misc] + clean_on_exit = false + +[proxy] + app_port = 0 + enabled = false + proxy_port = 0 + +[screen] + clear_on_rebuild = false + keep_scroll = true diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8b26d6a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +* +!dist +!build +!metadata.json +!docker-extension/build diff --git a/.env.defaults b/.env.defaults new file mode 100644 index 0000000..18cd58c --- /dev/null +++ b/.env.defaults @@ -0,0 +1 @@ +PORTAINER_EDITION=CE \ No newline at end of file diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 0000000..678766d --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,8 @@ +# prettier +cf5056d9c03b62d91a25c3b9127caac838695f98 + +# prettier v2 +42e7db0ae7897d3cb72b0ea1ecf57ee2dd694169 + +# tailwind prettier +58d66d3142950bb90a7d85511c034ac9fabba9ba \ No newline at end of file diff --git a/.github/DISCUSSION_TEMPLATE/help.yaml b/.github/DISCUSSION_TEMPLATE/help.yaml new file mode 100644 index 0000000..8a7aa13 --- /dev/null +++ b/.github/DISCUSSION_TEMPLATE/help.yaml @@ -0,0 +1,11 @@ +body: + - type: markdown + attributes: + value: | + Before asking a question, make sure it hasn't been already asked and answered. You can search our [discussions](https://github.com/orgs/portainer/discussions) and [bug reports](https://github.com/portainer/portainer/issues) in GitHub. Also, be sure to check our [knowledge base](https://portal.portainer.io/knowledge) and [documentation](https://docs.portainer.io/) first. + + - type: textarea + attributes: + label: Ask a Question! + validations: + required: true diff --git a/.github/DISCUSSION_TEMPLATE/ideas.yaml b/.github/DISCUSSION_TEMPLATE/ideas.yaml new file mode 100644 index 0000000..9dd504b --- /dev/null +++ b/.github/DISCUSSION_TEMPLATE/ideas.yaml @@ -0,0 +1,38 @@ +body: + - type: markdown + attributes: + value: | + # Welcome! + + Thanks for suggesting an idea for Portainer! + + Before opening a new idea or feature request, make sure that we do not have any duplicates already open. You can ensure this by [searching this discussion category](https://github.com/orgs/portainer/discussions/categories/ideas). If there is a duplicate, please add a comment to the existing idea instead. + + Also, be sure to check our [knowledge base](https://portal.portainer.io/knowledge) and [documentation](https://docs.portainer.io) as they may point you toward a solution. + + **DO NOT FILE DUPLICATE REQUESTS.** + + - type: textarea + attributes: + label: Is your feature request related to a problem? Please describe + description: Short list of what the feature request aims to address. + validations: + required: true + - type: textarea + attributes: + label: Describe the solution you'd like + description: A clear and concise description of what you want to happen. + validations: + required: true + - type: textarea + attributes: + label: Describe alternatives you've considered + description: A clear and concise description of any alternative solutions or features you've considered. + validations: + required: true + - type: textarea + attributes: + label: Additional context + description: Add any other context or screenshots about the feature request here. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 0000000..156bf90 --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,42 @@ + + +**Description** + + + +**Steps to reproduce the issue:** + +1. 2. 3. + +Any other info e.g. Why do you consider this to be a bug? What did you expect to happen instead? + +**Technical details:** + +- Portainer version: +- Target Docker version (the host/cluster you manage): +- Platform (windows/linux): +- Command used to start Portainer (`docker run -p 9443:9443 portainer/portainer`): +- Target Swarm version (if applicable): +- Browser: diff --git a/.github/ISSUE_TEMPLATE/Custom.md.old b/.github/ISSUE_TEMPLATE/Custom.md.old new file mode 100644 index 0000000..e17510f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/Custom.md.old @@ -0,0 +1,25 @@ +--- +name: Question +about: Ask us a question about Portainer usage or deployment +title: '' +labels: '' +assignees: '' +--- + +Before you start, we need a little bit more information from you: + +Use Case (delete as appropriate): Using Portainer at Home, Using Portainer in a Commercial setup. + +Have you reviewed our technical documentation and knowledge base? Yes/No + + + +**Question**: +How can I deploy Portainer on... ? diff --git a/.github/ISSUE_TEMPLATE/Feature_request.md.old b/.github/ISSUE_TEMPLATE/Feature_request.md.old new file mode 100644 index 0000000..de27fd9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/Feature_request.md.old @@ -0,0 +1,33 @@ +--- +name: Feature request +about: Suggest a feature/enhancement that should be added in Portainer +title: '' +labels: '' +assignees: '' +--- + + + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..f452836 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,180 @@ +name: Bug Report +description: Create a report to help us improve. +labels: kind/bug,bug/need-confirmation +body: + - type: markdown + attributes: + value: | + # Welcome! + + The issue tracker is for reporting bugs. If you have an [idea for a new feature](https://github.com/orgs/portainer/discussions/categories/ideas) or a [general question about Portainer](https://github.com/orgs/portainer/discussions/categories/help) please post in our [GitHub Discussions](https://github.com/orgs/portainer/discussions). + + You can also ask for help in our [community Slack channel](https://join.slack.com/t/portainer/shared_invite/zt-txh3ljab-52QHTyjCqbe5RibC2lcjKA). + + Please note that we only provide support for current versions of Portainer. You can find a list of supported versions in our [lifecycle policy](https://docs.portainer.io/start/lifecycle). + + **DO NOT FILE ISSUES FOR GENERAL SUPPORT QUESTIONS**. + + - type: checkboxes + id: terms + attributes: + label: Before you start please confirm the following. + options: + - label: Yes, I've searched similar issues on [GitHub](https://github.com/portainer/portainer/issues). + required: true + - label: Yes, I've checked whether this issue is covered in the Portainer [documentation](https://docs.portainer.io). + required: true + + - type: markdown + attributes: + value: | + # About your issue + + Tell us a bit about the issue you're having. + + How to write a good bug report: + + - Respect the issue template as much as possible. + - Summarize the issue so that we understand what is going wrong. + - Describe what you would have expected to have happened, and what actually happened instead. + - Provide easy to follow steps to reproduce the issue. + - Remain clear and concise. + - Format your messages to help the reader focus on what matters and understand the structure of your message, use [Markdown syntax](https://help.github.com/articles/github-flavored-markdown). + + - type: textarea + attributes: + label: Problem Description + description: A clear and concise description of what the bug is. + validations: + required: true + + - type: textarea + attributes: + label: Expected Behavior + description: A clear and concise description of what you expected to happen. + validations: + required: true + + - type: textarea + attributes: + label: Actual Behavior + description: A clear and concise description of what actually happens. + validations: + required: true + + - type: textarea + attributes: + label: Steps to Reproduce + description: Please be as detailed as possible when providing steps to reproduce. + placeholder: | + 1. Go to '...' + 2. Click on '....' + 3. Scroll down to '....' + 4. See error + validations: + required: true + + - type: textarea + attributes: + label: Portainer logs or screenshots + description: Provide Portainer container logs or any screenshots related to the issue. + validations: + required: false + + - type: markdown + attributes: + value: | + # About your environment + + Tell us a bit about your Portainer environment. + + - type: dropdown + attributes: + label: Portainer version + description: We only provide support for current versions of Portainer as per the lifecycle policy linked above. If you are on an older version of Portainer we recommend [updating first](https://docs.portainer.io/start/upgrade) in case your bug has already been fixed. + multiple: false + options: + - '2.43.0' + - '2.42.0' + - '2.41.1' + - '2.41.0' + - '2.40.0' + - '2.39.4' + - '2.39.3' + - '2.39.2' + - '2.39.1' + - '2.39.0' + - '2.38.1' + - '2.38.0' + - '2.37.0' + - '2.36.0' + - '2.35.0' + - '2.34.0' + - '2.33.8' + - '2.33.7' + - '2.33.6' + - '2.33.5' + - '2.33.4' + - '2.33.3' + - '2.33.2' + - '2.33.1' + - '2.33.0' + + validations: + required: true + + - type: dropdown + attributes: + label: Portainer Edition + multiple: false + options: + - 'Business Edition (BE/EE) with 5NF / 3NF license' + - 'Business Edition (BE/EE) with Home & Student license' + - 'Business Edition (BE/EE) with Starter license' + - 'Business Edition (BE/EE) with Professional or Enterprise license' + - 'Community Edition (CE)' + validations: + required: true + + - type: input + attributes: + label: Platform and Version + description: | + Enter your container management platform (Docker | Swarm | Kubernetes) along with the version. + Example: Docker 24.0.3 | Docker Swarm 24.0.3 | Kubernetes 1.26 + You can find our supported platforms [in our documentation](https://docs.portainer.io/start/requirements-and-prerequisites). + validations: + required: true + + - type: input + attributes: + label: OS and Architecture + description: | + Enter your Operating System, Version and Architecture. Example: Ubuntu 22.04, AMD64 | Raspbian OS, ARM64 + validations: + required: true + + - type: input + attributes: + label: Browser + description: | + Enter your browser and version. Example: Google Chrome 114.0 + validations: + required: false + + - type: textarea + attributes: + label: What command did you use to deploy Portainer? + description: | + Example: `docker run -d -p 8000:8000 -p 9443:9443 --name portainer --restart=always -v /var/run/docker.sock:/var/run/docker.sock -v portainer_data:/data portainer/portainer-ce:latest` + If you deployed Portainer using a compose file or manifest you can provide this here as well. + render: bash + validations: + required: false + + - type: textarea + attributes: + label: Additional Information + description: Any additional information about your environment, the bug, or anything else you think might be helpful. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..69207c9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,11 @@ +blank_issues_enabled: false +contact_links: + - name: Question + url: https://github.com/orgs/portainer/discussions/new?category=help + about: Ask us a question about Portainer usage or deployment. + - name: Idea or Feature Request + url: https://github.com/orgs/portainer/discussions/new?category=ideas + about: Suggest an idea or feature/enhancement that should be added in Portainer. + - name: Portainer Business Edition - Get 3 Nodes Free + url: https://www.portainer.io/take-3 + about: Portainer Business Edition has more features, more support and you can now get 3 nodes free for as long as you want. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..c79aa50 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,4 @@ +closes #0 +closes [CE-0] + +### Changes: diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3797a9d --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +node_modules +bower_components +dist +portainer-checksum.txt +api/cmd/portainer/portainer* +storybook-static +debug-storybook.log +.tmp +**/.vscode/settings.json +**/.vscode/tasks.json +.vscode +*.DS_Store + +.eslintcache +__debug_bin* + +.idea +.env +go.work.sum + +.vitest + diff --git a/.golangci-forward.yaml b/.golangci-forward.yaml new file mode 100644 index 0000000..d1b1125 --- /dev/null +++ b/.golangci-forward.yaml @@ -0,0 +1,13 @@ +version: '2' +linters: + default: none + enable: + - forbidigo + settings: + forbidigo: + forbid: + - pattern: ^dataservices.DataStore.(EdgeGroup|EdgeJob|EdgeStack|EndpointRelation|Endpoint|GitCredential|Registry|ResourceControl|Role|Settings|Snapshot|SSLSettings|Stack|Tag|User)$ + msg: Use a transaction instead + - pattern: ^(filepath|path)\.Join$ + msg: Use filesystem.JoinPaths() from github.com/portainer/portainer/api/filesystem to prevent path traversal attacks + analyze-types: true diff --git a/.golangci.yaml b/.golangci.yaml new file mode 100644 index 0000000..0bdc00e --- /dev/null +++ b/.golangci.yaml @@ -0,0 +1,126 @@ +version: '2' + +run: + allow-parallel-runners: true +linters: + default: none + enable: + - gocritic + - bodyclose + - copyloopvar + - depguard + - errcheck + - errorlint + - forbidigo + - govet + - ineffassign + - intrange + - perfsprint + - staticcheck + - unused + - mirror + - durationcheck + - errorlint + - govet + - usetesting + - zerologlint + - testifylint + - modernize + - unconvert + - unused + - zerologlint + - exptostd + settings: + staticcheck: + checks: ['all', '-ST1003', '-ST1005', '-ST1016', '-SA1019', '-QF1003'] + depguard: + rules: + main: + files: + - '!**/*_test.go' + - '!**/base.go' + - '!**/base_tx.go' + deny: + - pkg: encoding/json + desc: use github.com/segmentio/encoding/json + - pkg: golang.org/x/exp + desc: exp is not allowed + - pkg: github.com/portainer/libcrypto + desc: use github.com/portainer/portainer/pkg/libcrypto + - pkg: github.com/portainer/libhttp + desc: use github.com/portainer/portainer/pkg/libhttp + - pkg: golang.org/x/crypto + desc: golang.org/x/crypto is not allowed because of FIPS mode + - pkg: github.com/ProtonMail/go-crypto/openpgp + desc: github.com/ProtonMail/go-crypto/openpgp is not allowed because of FIPS mode + - pkg: github.com/cosi-project/runtime + desc: github.com/cosi-project/runtime is not allowed because of FIPS mode + - pkg: gopkg.in/yaml.v2 + desc: use go.yaml.in/yaml/v3 instead + - pkg: gopkg.in/yaml.v3 + desc: use go.yaml.in/yaml/v3 instead + - pkg: github.com/golang-jwt/jwt/v4 + desc: use github.com/golang-jwt/jwt/v5 instead + - pkg: github.com/mitchellh/mapstructure + desc: use github.com/go-viper/mapstructure/v2 instead + - pkg: gopkg.in/alecthomas/kingpin.v2 + desc: use github.com/alecthomas/kingpin/v2 instead + - pkg: github.com/jcmturner/gokrb5$ + desc: use github.com/jcmturner/gokrb5/v8 instead + - pkg: github.com/gofrs/uuid + desc: use github.com/google/uuid + - pkg: github.com/Masterminds/semver$ + desc: use github.com/Masterminds/semver/v3 + - pkg: github.com/blang/semver + desc: use github.com/Masterminds/semver/v3 + - pkg: github.com/coreos/go-semver + desc: use github.com/Masterminds/semver/v3 + - pkg: github.com/hashicorp/go-version + desc: use github.com/Masterminds/semver/v3 + gocritic: + disable-all: true + enabled-checks: + - ruleguard + settings: + ruleguard: + rules: './analysis/ssrf.go,./analysis/git.go' + forbidigo: + forbid: + - pattern: ^tls\.Config$ + msg: Use crypto.CreateTLSConfiguration() instead + - pattern: ^tls\.Config\.(InsecureSkipVerify|MinVersion|MaxVersion|CipherSuites|CurvePreferences)$ + msg: Do not set this field directly, use crypto.CreateTLSConfiguration() instead + - pattern: ^object\.(Commit|Tag)\.Verify$ + msg: 'Not allowed because of FIPS mode' + - pattern: ^(types\.SystemContext\.)?(DockerDaemonInsecureSkipTLSVerify|DockerInsecureSkipTLSVerify|OCIInsecureSkipTLSVerify)$ + msg: 'Not allowed because of FIPS mode' + - pattern: ^git\.PlainClone(Context|WithOptions)?$ + msg: Use git.CloneContext with NewNoSymlinkFS to prevent symlink traversal attacks + analyze-types: true + exclusions: + generated: lax + presets: + - comments + - common-false-positives + - legacy + rules: + - path: pkg/libhttp/ssrf + linters: + - gocritic + text: ruleguard + - path: pkg/libhttp/ssrf/builder\.go + linters: + - forbidigo + paths: + - third_party$ + - builtin$ + - examples$ +formatters: + enable: + - gofmt + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000..2adb9da --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,4 @@ +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" + +cd $(dirname -- "$0") && pnpm lint-staged \ No newline at end of file diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..6241c7e --- /dev/null +++ b/.prettierignore @@ -0,0 +1,5 @@ +dist +api/datastore/test_data +coverage + +pnpm-lock.yaml diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..cc4553a --- /dev/null +++ b/.prettierrc @@ -0,0 +1,22 @@ +{ + "printWidth": 180, + "singleQuote": true, + "htmlWhitespaceSensitivity": "strict", + "trailingComma": "es5", + "overrides": [ + { + "files": ["*.html"], + "options": { + "parser": "angular" + } + }, + { + "files": ["*.{j,t}sx", "*.ts"], + "options": { + "printWidth": 80 + } + } + ], + "plugins": ["prettier-plugin-tailwindcss"], + "tailwindFunctions": ["clsx"] +} diff --git a/.storybook/main.ts b/.storybook/main.ts new file mode 100644 index 0000000..af328a0 --- /dev/null +++ b/.storybook/main.ts @@ -0,0 +1,115 @@ +// This file has been automatically migrated to valid ESM format by Storybook. +import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; +import path, { dirname } from 'path'; + +import { StorybookConfig } from '@storybook/react-webpack5'; + +import { Configuration } from 'webpack'; +import postcss from 'postcss'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const require = createRequire(import.meta.url); + +const config: StorybookConfig = { + stories: ['../app/**/*.stories.@(ts|tsx)'], + addons: [ + '@storybook/addon-links', + '@storybook/addon-webpack5-compiler-swc', + '@chromatic-com/storybook', + { + name: '@storybook/addon-styling-webpack', + + options: { + rules: [ + { + test: /\.css$/, + sideEffects: true, + use: [ + require.resolve('style-loader'), + { + loader: require.resolve('css-loader'), + options: { + importLoaders: 1, + modules: { + localIdentName: '[path][name]__[local]', + auto: true, + exportLocalsConvention: 'camelCaseOnly', + }, + }, + }, + { + loader: require.resolve('postcss-loader'), + options: { + implementation: postcss, + }, + }, + ], + }, + ], + }, + }, + '@storybook/addon-docs', + ], + webpackFinal: (config) => { + const rules = config?.module?.rules || []; + + const imageRule = rules.find((rule) => { + const test = (rule as { test: RegExp }).test; + + if (!test) { + return false; + } + + return test.test('.svg'); + }) as { [key: string]: any }; + + imageRule.exclude = /\.svg$/; + + rules.unshift({ + test: /\.svg$/i, + type: 'asset', + resourceQuery: { + not: [/c/], + }, // exclude react component if *.svg?url + }); + + rules.unshift({ + test: /\.svg$/i, + issuer: /\.(js|ts)(x)?$/, + resourceQuery: /c/, + // *.svg?c + use: [ + { + loader: '@svgr/webpack', + options: { + icon: true, + }, + }, + ], + }); + return { + ...config, + resolve: { + ...config.resolve, + tsconfig: path.resolve(__dirname, '..', 'tsconfig.json'), + }, + module: { + ...config.module, + rules, + }, + } satisfies Configuration; + }, + staticDirs: ['./public'], + typescript: { + reactDocgen: 'react-docgen', + }, + framework: { + name: '@storybook/react-webpack5', + options: {}, + }, + docs: {}, +}; + +export default config; diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx new file mode 100644 index 0000000..ec1a36c --- /dev/null +++ b/.storybook/preview.tsx @@ -0,0 +1,86 @@ +import { useEffect } from 'react'; +import '../app/assets/css'; +import { pushStateLocationPlugin, UIRouter } from '@uirouter/react'; +import { initialize as initMSW, mswLoader } from 'msw-storybook-addon'; +import { handlers } from '../app/setup-tests/server-handlers'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { Preview } from '@storybook/react-webpack5'; + +initMSW( + { + onUnhandledRequest: ({ method, url }) => { + if (url.startsWith('/api')) { + console.error(`Unhandled ${method} request to ${url}. + + This exception has been only logged in the console, however, it's strongly recommended to resolve this error as you don't want unmocked data in Storybook stories. + + If you wish to mock an error response, please refer to this guide: https://mswjs.io/docs/recipes/mocking-error-responses + `); + } + }, + }, + handlers +); + +const testQueryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, +}); + +const preview: Preview = { + globalTypes: { + theme: { + description: 'Portainer color theme', + toolbar: { + title: 'Theme', + icon: 'paintbrush', + items: [ + { value: 'light', title: 'Light', icon: 'sun' }, + { value: 'dark', title: 'Dark', icon: 'moon' }, + { value: 'highcontrast', title: 'High Contrast', icon: 'eye' }, + ], + dynamicTitle: true, + }, + }, + }, + initialGlobals: { + theme: 'light', + }, + decorators: (Story, context) => { + const theme = context.globals.theme; + + useEffect(() => { + if (theme === 'light') { + document.documentElement.removeAttribute('theme'); + } else { + document.documentElement.setAttribute('theme', theme); + } + }, [theme]); + + return ( + + + + + + ); + }, + loaders: [mswLoader], + parameters: { + options: { + storySort: { + order: ['Design System', 'Components', '*'], + }, + }, + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/, + }, + }, + msw: { + handlers, + }, + }, +}; + +export default preview; diff --git a/.storybook/public/mockServiceWorker.js b/.storybook/public/mockServiceWorker.js new file mode 100644 index 0000000..71d697e --- /dev/null +++ b/.storybook/public/mockServiceWorker.js @@ -0,0 +1,336 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker. + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + */ + +const PACKAGE_VERSION = '2.12.10'; +const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'; +const IS_MOCKED_RESPONSE = Symbol('isMockedResponse'); +const activeClientIds = new Set(); + +addEventListener('install', function () { + self.skipWaiting(); +}); + +addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()); +}); + +addEventListener('message', async function (event) { + const clientId = Reflect.get(event.source || {}, 'id'); + + if (!clientId || !self.clients) { + return; + } + + const client = await self.clients.get(clientId); + + if (!client) { + return; + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }); + + switch (event.data) { + case 'KEEPALIVE_REQUEST': { + sendToClient(client, { + type: 'KEEPALIVE_RESPONSE', + }); + break; + } + + case 'INTEGRITY_CHECK_REQUEST': { + sendToClient(client, { + type: 'INTEGRITY_CHECK_RESPONSE', + payload: { + packageVersion: PACKAGE_VERSION, + checksum: INTEGRITY_CHECKSUM, + }, + }); + break; + } + + case 'MOCK_ACTIVATE': { + activeClientIds.add(clientId); + + sendToClient(client, { + type: 'MOCKING_ENABLED', + payload: { + client: { + id: client.id, + frameType: client.frameType, + }, + }, + }); + break; + } + + case 'CLIENT_CLOSED': { + activeClientIds.delete(clientId); + + const remainingClients = allClients.filter((client) => { + return client.id !== clientId; + }); + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister(); + } + + break; + } + } +}); + +addEventListener('fetch', function (event) { + const requestInterceptedAt = Date.now(); + + // Bypass navigation requests. + if (event.request.mode === 'navigate') { + return; + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if (event.request.cache === 'only-if-cached' && event.request.mode !== 'same-origin') { + return; + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been terminated (still remains active until the next reload). + if (activeClientIds.size === 0) { + return; + } + + const requestId = crypto.randomUUID(); + event.respondWith(handleRequest(event, requestId, requestInterceptedAt)); +}); + +/** + * @param {FetchEvent} event + * @param {string} requestId + * @param {number} requestInterceptedAt + */ +async function handleRequest(event, requestId, requestInterceptedAt) { + const client = await resolveMainClient(event); + const requestCloneForEvents = event.request.clone(); + const response = await getResponse(event, client, requestId, requestInterceptedAt); + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + const serializedRequest = await serializeRequest(requestCloneForEvents); + + // Clone the response so both the client and the library could consume it. + const responseClone = response.clone(); + + sendToClient( + client, + { + type: 'RESPONSE', + payload: { + isMockedResponse: IS_MOCKED_RESPONSE in response, + request: { + id: requestId, + ...serializedRequest, + }, + response: { + type: responseClone.type, + status: responseClone.status, + statusText: responseClone.statusText, + headers: Object.fromEntries(responseClone.headers.entries()), + body: responseClone.body, + }, + }, + }, + responseClone.body ? [serializedRequest.body, responseClone.body] : [] + ); + } + + return response; +} + +/** + * Resolve the main client for the given event. + * Client that issues a request doesn't necessarily equal the client + * that registered the worker. It's with the latter the worker should + * communicate with during the response resolving phase. + * @param {FetchEvent} event + * @returns {Promise} + */ +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId); + + if (activeClientIds.has(event.clientId)) { + return client; + } + + if (client?.frameType === 'top-level') { + return client; + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }); + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === 'visible'; + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id); + }); +} + +/** + * @param {FetchEvent} event + * @param {Client | undefined} client + * @param {string} requestId + * @param {number} requestInterceptedAt + * @returns {Promise} + */ +async function getResponse(event, client, requestId, requestInterceptedAt) { + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const requestClone = event.request.clone(); + + function passthrough() { + // Cast the request headers to a new Headers instance + // so the headers can be manipulated with. + const headers = new Headers(requestClone.headers); + + // Remove the "accept" header value that marked this request as passthrough. + // This prevents request alteration and also keeps it compliant with the + // user-defined CORS policies. + const acceptHeader = headers.get('accept'); + if (acceptHeader) { + const values = acceptHeader.split(',').map((value) => value.trim()); + const filteredValues = values.filter((value) => value !== 'msw/passthrough'); + + if (filteredValues.length > 0) { + headers.set('accept', filteredValues.join(', ')); + } else { + headers.delete('accept'); + } + } + + return fetch(requestClone, { headers }); + } + + // Bypass mocking when the client is not active. + if (!client) { + return passthrough(); + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return passthrough(); + } + + // Notify the client that a request has been intercepted. + const serializedRequest = await serializeRequest(event.request); + const clientMessage = await sendToClient( + client, + { + type: 'REQUEST', + payload: { + id: requestId, + interceptedAt: requestInterceptedAt, + ...serializedRequest, + }, + }, + [serializedRequest.body] + ); + + switch (clientMessage.type) { + case 'MOCK_RESPONSE': { + return respondWithMock(clientMessage.data); + } + + case 'PASSTHROUGH': { + return passthrough(); + } + } + + return passthrough(); +} + +/** + * @param {Client} client + * @param {any} message + * @param {Array} transferrables + * @returns {Promise} + */ +function sendToClient(client, message, transferrables = []) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel(); + + channel.port1.onmessage = (event) => { + if (event.data && event.data.error) { + return reject(event.data.error); + } + + resolve(event.data); + }; + + client.postMessage(message, [channel.port2, ...transferrables.filter(Boolean)]); + }); +} + +/** + * @param {Response} response + * @returns {Response} + */ +function respondWithMock(response) { + // Setting response status code to 0 is a no-op. + // However, when responding with a "Response.error()", the produced Response + // instance will have status code set to 0. Since it's not possible to create + // a Response instance with status code 0, handle that use-case separately. + if (response.status === 0) { + return Response.error(); + } + + const mockedResponse = new Response(response.body, response); + + Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { + value: true, + enumerable: true, + }); + + return mockedResponse; +} + +/** + * @param {Request} request + */ +async function serializeRequest(request) { + return { + url: request.url, + mode: request.mode, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: await request.arrayBuffer(), + keepalive: request.keepalive, + }; +} diff --git a/ATTRIBUTIONS.md b/ATTRIBUTIONS.md new file mode 100644 index 0000000..8758a54 --- /dev/null +++ b/ATTRIBUTIONS.md @@ -0,0 +1,32 @@ +# Open Source License Attribution + +This application uses Open Source components. You can find the source +code of their open source projects along with license information below. +We acknowledge and are grateful to these developers for their contributions +to open source. + +### [angular-json-tree](https://github.com/awendland/angular-json-tree) + +by [Alex Wendland](https://github.com/awendland) is licensed under [CC BY 4.0 License](https://creativecommons.org/licenses/by/4.0/) + +### [caniuse-db](https://github.com/Fyrd/caniuse) + +by [caniuse.com](caniuse.com) is licensed under [CC BY 4.0 License](https://creativecommons.org/licenses/by/4.0/) + +### [caniuse-lite](https://github.com/ben-eb/caniuse-lite) + +by [caniuse.com](caniuse.com) is licensed under [CC BY 4.0 License](https://creativecommons.org/licenses/by/4.0/) + +### [spdx-exceptions](https://github.com/jslicense/spdx-exceptions.json) + +by Kyle Mitchell using [SPDX](https://spdx.dev/) from Linux Foundation licensed under [CC BY 3.0 License](https://creativecommons.org/licenses/by/3.0/) + +### [fontawesome-free](https://github.com/FortAwesome/Font-Awesome) Icons + +by [Fort Awesome](https://fortawesome.com/) is licensed under [CC BY 4.0 License](https://creativecommons.org/licenses/by/4.0/) + +Portainer also contains the following code, which is licensed under the [MIT license](https://opensource.org/licenses/MIT): + +UI For Docker: Copyright (c) 2013-2016 Michael Crosby (crosbymichael.com), Kevan Ahlquist (kevanahlquist.com), Anthony Lapenna (portainer.io) + +rdash-angular: Copyright (c) [2014][elliot hesp] diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..38369d6 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,59 @@ +# Portainer Community Edition + +Open-source container management platform with full Docker and Kubernetes support. + +## Project Structure + +For a detailed breakdown of frontend and backend directory layout, feature locations, and common development tasks, see [docs/guidelines/project-structure.md](../../docs/guidelines/project-structure.md). + +## Frontend Guidelines + +- [docs/guidelines/frontend-conventions.md](../../docs/guidelines/frontend-conventions.md) — component structure, React Query patterns, shared components, forms, theming +- [docs/guidelines/typescript-conventions.md](../../docs/guidelines/typescript-conventions.md) — types, anti-patterns, union types, named constants +- [docs/guidelines/frontend-unit-testing.md](../../docs/guidelines/frontend-unit-testing.md) — Vitest, React Testing Library + +## Backend Guidelines + +- [docs/guidelines/go-conventions.md](../../docs/guidelines/go-conventions.md) — error handling, naming, testing, code style +- [docs/guidelines/server-architecture.md](../../docs/guidelines/server-architecture.md) — Clean Architecture layers, transactions, CE/EE sharing patterns +- [docs/guidelines/logging.md](../../docs/guidelines/logging.md) — zerolog usage, log levels, message style +- [docs/guidelines/backend-code-reusability.md](../../docs/guidelines/backend-code-reusability.md) — how CE and EE share backend code + +## Package Manager + +- **PNPM** 10+ (for frontend) +- **Go** 1.26.1 (for backend) + +## Build Commands + +```bash +# Full build +make build # Build both client and server +make build-client # Build React/AngularJS frontend +make build-server # Build Go binary +make build-image # Build Docker image + +# Development +make dev # Run both in dev mode +make dev-client # Start webpack-dev-server (port 8999) +make dev-server # Run containerized Go server + +# Frontend +pnpm dev # Webpack dev server +pnpm build # Build frontend with webpack +pnpm typecheck # Run typecheck for frontend (with tsc) +pnpm lint # lint frontend (with eslint) +pnpm test # test frontend (with vitest) +pnpm format # format frontend (with prettier) + +# Testing +make test # All tests (backend + frontend) +make test-server # Backend tests only +make lint # Lint all code +make format # Format code +``` + +## Development Servers + +- Frontend: http://localhost:8999 +- Backend: http://localhost:9000 (HTTP) / https://localhost:9443 (HTTPS) diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..382ee49 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,46 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment include: + +- Using welcoming and inclusive language +- Being respectful of differing viewpoints and experiences +- Gracefully accepting constructive criticism +- Focusing on what is best for the community +- Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +- The use of sexualized language or imagery and unwelcome sexual attention or advances +- Trolling, insulting/derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or electronic address, without explicit permission +- Other conduct which could reasonably be considered inappropriate in a professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at contribute@portainer.io. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] + +[homepage]: http://contributor-covenant.org +[version]: http://contributor-covenant.org/version/1/4/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..1b70daf --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,158 @@ +# Contributing Guidelines + +Some basic conventions for contributing to this project. + +## General + +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 + +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`. + +## Issues open to contribution + +Want to contribute but don't know where to start? Have a look at the issues labeled with the `good first issue` label: https://github.com/portainer/portainer/issues?q=is%3Aopen+is%3Aissue+label%3A%22good+first+issue%22 + +## Commit Message Format + +Each commit message should include a **type**, a **scope** and a **subject**: + +``` + (): +``` + +Lines should not exceed 100 characters. This allows the message to be easier to read on GitHub as well as in various git tools and produces a nice, neat commit log ie: + +``` + #271 feat(containers): add exposed ports in the containers view + #270 fix(templates): fix a display issue in the templates view + #269 style(dashboard): update dashboard with new layout +``` + +### Type + +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 + 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 + generation + +### Scope + +The scope could be anything specifying place of the commit change. For example `networks`, +`containers`, `images` etc... +You can use the **area** label tag associated on the issue here (for `area/containers` use `containers` as a scope...) + +### Subject + +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 + +## Contribution process + +Our contribution process is described below. Some of the steps can be visualized inside GitHub via specific `status/` labels, such as `status/1-functional-review` or `status/2-technical-review`. + +### Bug report + +![portainer_bugreport_workflow](https://user-images.githubusercontent.com/5485061/45727219-50190a00-bbf5-11e8-9fe8-3a563bb8d5d7.png) + +### Feature request + +The feature request process is similar to the bug report process but has an extra functional validation before the technical validation as well as a documentation validation before the testing phase. + +![portainer_featurerequest_workflow](https://user-images.githubusercontent.com/5485061/45727229-5ad39f00-bbf5-11e8-9550-16ba66c50615.png) + +## Build and run Portainer locally + +Ensure you have Docker, Node.js, pnpm, and Golang installed in the correct versions. + +Install dependencies: + +```sh +$ make deps +``` + +Then build and run the project in a Docker container: + +```sh +$ make dev +``` + +Portainer server can now be accessed at . and UI dev server runs on . + +if you want to build the project you can run: + +```sh +make build-all +``` + +For additional make commands, run `make help`. + +Find more detailed steps at . + +### Build customization + +You can customize the following settings: + +- `PORTAINER_DATA`: The host dir or volume name used by portainer (default is `/tmp/portainer`, which won't persist over reboots). +- `PORTAINER_PROJECT`: The root dir of the repository - `${portainerRoot}/dist/` is imported into the container to get the build artifacts and external tools (defaults to `your current dir`). +- `PORTAINER_FLAGS`: a list of flags to be used on the portainer commandline, in the form `--admin-password= --feat fdo=false --feat open-amt` (default: `""`). + +## Testing your build + +The `--log-level=DEBUG` flag can be passed to the Portainer container in order to provide additional debug output which may be useful when troubleshooting your builds. Please note that this flag was originally intended for internal use and as such the format, functionality and output may change between releases without warning. + +## Adding api docs + +When adding a new resource (or a route handler), we should add a new tag to api/http/handler/handler.go#L136 like this: + +``` +// @tag.name +// @tag.description a short description +``` + +When adding a new route to an existing handler use the following as a template (you can use `swapi` snippet if you're using vscode): + +``` +// @id +// @summary +// @description +// @description **Access policy**: +// @tags +// @security ApiKeyAuth +// @security jwt +// @accept json +// @produce json +// @param id path int true "identifier" +// @param body body Object true "details" +// @success 200 {object} portainer. "Success" +// @success 204 "Success" +// @failure 400 "Invalid request" +// @failure 403 "Permission denied" +// @failure 404 " not found" +// @failure 500 "Server error" +// @router /{id} [get] +``` + +explanation about each line can be found [here](https://github.com/swaggo/swag#api-operation) + +After changing these annotations, regenerate the TypeScript API client and types — see [Generating API types](./README.md#generating-api-types). + +## Licensing + +See the [LICENSE](https://github.com/portainer/portainer/blob/develop/LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution. + +We may ask you to sign a [Contributor License Agreement (CLA)](http://en.wikipedia.org/wiki/Contributor_License_Agreement) for larger changes. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..231ab8b --- /dev/null +++ b/LICENSE @@ -0,0 +1,17 @@ +Copyright (c) 2018 Portainer.io + +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would be + appreciated but is not required. +2. Altered source versions must be plainly marked as such, and must not be + misrepresented as being the original software. +3. This notice may not be removed or altered from any source distribution. \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..355526d --- /dev/null +++ b/Makefile @@ -0,0 +1,156 @@ +# build target, can be one of "production", "testing", "development" +ENV=development +WEBPACK_CONFIG=webpack/webpack.$(ENV).js +TAG=local + +SWAG=go run github.com/swaggo/swag/cmd/swag@v1.16.6 +GOTESTSUM_VERSION?=v1.13.0 +GOTESTSUM=go run gotest.tools/gotestsum@$(GOTESTSUM_VERSION) +GOLANGCI_LINT_VERSION := $(shell cat $(shell git rev-parse --show-toplevel)/.golangci-version) + +# Don't change anything below this line unless you know what you're doing +.DEFAULT_GOAL := help + + +##@ Building +.PHONY: all init-dist build-storybook build build-client build-server build-image devops +init-dist: + @mkdir -p dist + +all: tidy deps build-server build-client ## Build the client, server and download external dependancies (doesn't build an image) + +build-all: all ## Alias for the 'all' target (used by CI) + +build-client: init-dist ## Build the client + export NODE_ENV=$(ENV) && pnpm run build --config $(WEBPACK_CONFIG) + +build-server: init-dist ## Build the server binary + ./build/build_binary.sh "$(PLATFORM)" "$(ARCH)" + +build-image: build-all ## Build the Portainer image locally + docker buildx build --load -t portainerci/portainer-ce:$(TAG) -f build/linux/Dockerfile . + +build-storybook: ## Build and serve the storybook files + pnpm run storybook:build + +##@ Build dependencies +.PHONY: deps server-deps client-deps tidy +deps: server-deps client-deps ## Download all client and server build dependancies + +## This is empty because the pipeline requires it but ce has no server deps +server-deps: init-dist ## Download dependant server binaries + +client-deps: ## Install client dependencies + pnpm install + +tidy: ## Tidy up the go.mod file + @go mod tidy + +##@ Cleanup +.PHONY: clean +clean: ## Remove all build and download artifacts + @echo "Clearing the dist directory..." + @rm -rf dist/* + +##@ Testing +.PHONY: test test-client test-server +test: test-server test-client ## Run all tests + +test-client: ## Run client tests + pnpm run test $(ARGS) --coverage + +TEST_PACKAGES?=./... + +test-server: ## Run server tests + $(GOTESTSUM) --format pkgname-and-test-fails --format-hide-empty-pkg --hide-summary skipped -- -cover -covermode=atomic -coverprofile=coverage.out $(TEST_PACKAGES) + +##@ Dev +.PHONY: dev dev-client dev-server +dev: ## Run both the client and server in development mode + make dev-server + make dev-client + +dev-client: ## Run the client in development mode + pnpm install && pnpm run dev + +dev-server: build-server ## Run the server in development mode + @./dev/run_container.sh + +dev-server-podman: build-server ## Run the server in development mode + @./dev/run_container_podman.sh + +##@ Format +.PHONY: format format-client format-server + +format: format-client format-server ## Format all code + +format-client: ## Format client code + pnpm run format + +format-server: ## Format server code + go fmt ./... + +##@ Lint +.PHONY: lint lint-client lint-server check-lint-version +lint: lint-client lint-server ## Lint all code + +lint-client: ## Lint client code + pnpm run lint + +check-lint-version: + @installed=v$$(golangci-lint --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1); \ + if [ "$$installed" = "v" ]; then \ + echo "ERROR: golangci-lint not found, need $(GOLANGCI_LINT_VERSION)"; \ + echo "Install: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION)"; \ + exit 1; \ + elif [ "$$installed" != "$(GOLANGCI_LINT_VERSION)" ]; then \ + echo "ERROR: golangci-lint $$installed installed, need $(GOLANGCI_LINT_VERSION)"; \ + echo "Install: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION)"; \ + exit 1; \ + fi + +lint-server: tidy check-lint-version ## Lint server code + golangci-lint run --timeout=10m -c .golangci.yaml + golangci-lint run --timeout=10m --new-from-rev=HEAD~ -c .golangci-forward.yaml + +##@ Extension +.PHONY: dev-extension +dev-extension: build-server build-client ## Run the extension in development mode + make local -f build/docker-extension/Makefile + +##@ Docs +.PHONY: docs-build docs-validate docs-sync-check docs-clean docs-validate-clean +docs-build: ## Build docs + go mod download + mkdir -p api/docs + cd api && $(SWAG) init -o "./docs" -ot "yaml" -g ./http/handler/handler.go --parseDependency --parseInternal --parseDepth 2 -p pascalcase --markdownFiles ./ --overridesFile .swaggo + +docs-validate: docs-build ## Validate docs + pnpm swagger2openapi --warnOnly api/docs/swagger.yaml -o api/docs/openapi.yaml + pnpm swagger-cli validate api/docs/openapi.yaml + +docs-sync-check: docs-build ## Check if committed API spec is in sync with Go annotations; fail if not + @if ! git diff --exit-code api/docs/swagger.yaml > /dev/null 2>&1; then \ + echo ""; \ + echo "ERROR: API spec is out of sync with Go annotations."; \ + echo "Run 'make generate-api' in package/server-ce and commit the result."; \ + echo ""; \ + git diff --stat api/docs/swagger.yaml; \ + exit 1; \ + fi + +.PHONY: docs-serve +docs-serve: docs-build ## Serve docs locally with Swagger UI on port 8080 + docker run -p 8080:8080 \ + -e SWAGGER_JSON=/foo/swagger.yaml \ + -v $(PWD)/api/docs:/foo \ + swaggerapi/swagger-ui + +.PHONY: generate-api +generate-api: docs-validate ## Generate API client and types from OpenAPI spec + pnpm generate-api + +##@ Helpers +.PHONY: help +help: ## Display this help + @awk 'BEGIN {FS = ":.*##"; printf "Usage:\n make \033[36m\033[0m\n"} /^[a-zA-Z_-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) diff --git a/README.md b/README.md new file mode 100644 index 0000000..883e418 --- /dev/null +++ b/README.md @@ -0,0 +1,95 @@ +

+ +

+ +**Portainer Community Edition** is a lightweight service delivery platform for containerized applications that can be used to manage Docker, Swarm, Kubernetes and ACI environments. It is designed to be as simple to deploy as it is to use. The application allows you to manage all your orchestrator resources (containers, images, volumes, networks and more) through a ‘smart’ GUI and/or an extensive API. + +Portainer consists of a single container that can run on any cluster. It can be deployed as a Linux container or a Windows native container. + +**Portainer Business Edition** builds on the open-source base and includes a range of advanced features and functions (like RBAC and Support) that are specific to the needs of business users. + +- [Compare Portainer CE and Compare Portainer BE](https://www.portainer.io/features) +- [Take3 – get 3 free nodes of Portainer Business for as long as you want them](https://www.portainer.io/take-3) +- [Portainer BE install guide](https://academy.portainer.io/install/) + +## Latest Version + +Portainer CE is updated regularly. We aim to do an update release every couple of months. + +[![latest version](https://img.shields.io/github/v/release/portainer/portainer?color=%2344cc11&label=Latest%20release&style=for-the-badge)](https://github.com/portainer/portainer/releases/latest) + +## Getting started + +- [Deploy Portainer](https://docs.portainer.io/start/install-ce) +- [Documentation](https://docs.portainer.io) +- [Contribute to the project](https://docs.portainer.io/contribute/contribute) + +## Features & Functions + +View [this](https://www.portainer.io/features) table to see all of the Portainer CE functionality and compare to Portainer Business. + +## Getting help + +Portainer CE is an open source project and is supported by the community. You can buy a supported version of Portainer at portainer.io + +Learn more about Portainer's community support channels [here.](https://www.portainer.io/resources/get-help/get-support) + +- Issues: https://github.com/portainer/portainer/issues +- Slack (chat): [https://portainer.io/slack](https://portainer.io/slack) + +You can join the Portainer Community by visiting [https://www.portainer.io/join-our-community](https://www.portainer.io/join-our-community). This will give you advance notice of events, content and other related Portainer content. + +## Reporting bugs and contributing + +- Want to report a bug or request a feature? Please open [an issue](https://github.com/portainer/portainer/issues/new). +- Want to help us build **_portainer_**? Follow our [contribution guidelines](https://docs.portainer.io/contribute/contribute) to build it locally and make a pull request. + +## Generating API types + +The frontend consumes a TypeScript API client (SDK functions and request/response types) that is generated from the Go API's Swagger annotations. Regenerate it after any API change — a new endpoint, a changed request/response shape, or a removed endpoint: + +```bash +make generate-api +``` + +This runs the following pipeline: + +``` +Go Swagger annotations + → dist/docs/swagger.yaml (make docs-build, via swaggo/swag) + → dist/docs/openapi.yaml (swagger2openapi + validation) + → app/react/portainer/generated-api/portainer/ (hey-api/openapi-ts) +``` + +The generator is configured in [`openapi-ts.config.ts`](./openapi-ts.config.ts), which controls the output path, plugins, and tag filters (for example, `deprecated` endpoints and `edge_agent`-tagged routes are excluded). + +The generated files live in `app/react/portainer/generated-api/portainer/` and must **not** be edited by hand — your changes would be overwritten on the next run. Import the generated SDK functions and types instead of writing direct HTTP calls: + +- `@api/sdk.gen` — SDK functions +- `@api/types.gen` — request/response types + +See [Adding api docs](./CONTRIBUTING.md#adding-api-docs) for how to annotate handlers so they are picked up by the generator. + +## Security + +For information about reporting security vulnerabilities, please see our [Security Policy](SECURITY.md). + +## Work for us + +If you are a developer, and our code in this repo makes sense to you, we would love to hear from you. We are always on the hunt for awesome devs, either freelance or employed. Drop us a line to success@portainer.io with your details and/or visit our [careers page](https://apply.workable.com/portainer/). + +## Privacy + +**To make sure we focus our development effort in the right places we need to know which features get used most often. To give us this information we use [Matomo Analytics](https://matomo.org/), which is hosted in Germany and is fully GDPR compliant.** + +When Portainer first starts, you are given the option to DISABLE analytics. If you **don't** choose to disable it, we collect anonymous usage as per [our privacy policy](https://www.portainer.io/legal/privacy-policy). **Please note**, there is no personally identifiable information sent or stored at any time and we only use the data to help us improve Portainer. + +## Limitations + +Portainer supports "Current - 2 docker versions only. Prior versions may operate, however these are not supported. + +## Licensing + +Portainer is licensed under the zlib license. See [LICENSE](./LICENSE) for reference. + +Portainer also contains code from open source projects. See [ATTRIBUTIONS.md](./ATTRIBUTIONS.md) for a list. diff --git a/README.wehub.md b/README.wehub.md new file mode 100644 index 0000000..b1a7dfc --- /dev/null +++ b/README.wehub.md @@ -0,0 +1,7 @@ +# WeHub 来源说明 + +- 原始项目:`portainer/portainer` +- 原始仓库:https://github.com/portainer/portainer +- 导入方式:上游默认分支的最新快照 +- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准 +- 本文件仅用于记录来源,不代表 WeHub 是原项目作者 diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..beeeff8 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,60 @@ +# Security Policy + +## Supported Versions + +Portainer maintains both Short-Term Support (STS) and Long-Term Support (LTS) versions in accordance with our official [Portainer Lifecycle Policy](https://docs.portainer.io/start/lifecycle). + +| Version Type | Support Status | +| ------------------------ | ------------------------------------------- | +| LTS (Long-Term Support) | Supported for critical security fixes | +| STS (Short-Term Support) | Supported until the next STS or LTS release | +| Legacy / EOL | Not supported | + +For a detailed breakdown of current versions and their specific End of Life (EOL) dates, +please refer to the [Portainer Lifecycle Policy](https://docs.portainer.io/start/lifecycle). + +## Reporting a Vulnerability + +The Portainer team takes the security of our products seriously. If you believe you have found a security vulnerability in any Portainer-owned repository, please report it to us responsibly. + +**Please do not report security vulnerabilities via public GitHub issues.** + +### Disclosure Process + +1. **Report**: You can report in one of two ways: + + - **GitHub**: Use the **Report a vulnerability** button on the **Security** tab of this repository. + + - **Email**: Send your findings to security@portainer.io. + +2. **Details**: To help us verify the issue, please include: + + - A description of the vulnerability and its potential impact. + + - Step-by-step instructions to reproduce the issue (e.g. proof-of-concept code, scripts, or screenshots). + + - The version of the software and the environment in which it was found. + +3. **Acknowledge**: We will acknowledge receipt of your report and provide an initial assessment. + +4. **Resolution**: We will work to resolve the issue as quickly as possible. We request that you do not disclose the vulnerability publicly until we have released a fix and notified affected users. + +## Our Commitment + +If you follow the responsible disclosure process, we will: + +- Respond to your report in a timely manner. + +- Provide an estimated timeline for remediation. + +- Notify you when the vulnerability has been patched. + +- Give credit for the discovery (if desired) once the fix is public. + +We will make every effort to promptly address any security weaknesses. Security advisories and fixes will be published through GitHub Security Advisories and other channels as needed. + +Thank you for helping keep Portainer and our community secure. + +## Resources + +- [Contributing to Portainer](https://docs.portainer.io/contribute/contribute#contributing-to-the-portainer-ce-codebase) diff --git a/__mocks__/@reach/menu-button.tsx b/__mocks__/@reach/menu-button.tsx new file mode 100644 index 0000000..1755687 --- /dev/null +++ b/__mocks__/@reach/menu-button.tsx @@ -0,0 +1,118 @@ +import { + Children, + useState, + useEffect, + useRef, + useContext, + createContext, + ReactNode, +} from 'react'; + +type MenuCtxType = { + isOpen: boolean; + setOpen: (v: boolean) => void; + menuRef: React.RefObject; + label: string; + setLabel: (v: string) => void; +}; + +const MenuCtx = createContext(null); + +export function Menu({ children }: { children?: ReactNode }) { + const [isOpen, setOpen] = useState(false); + const [label, setLabel] = useState(''); + const menuRef = useRef(null); + + useEffect(() => { + function handleDocDown(e: MouseEvent) { + const target = e.target as Node | null; + if ( + isOpen && + menuRef.current && + target && + !menuRef.current.contains(target) + ) { + setOpen(false); + } + } + + document.addEventListener('mousedown', handleDocDown); + return () => document.removeEventListener('mousedown', handleDocDown); + }, [isOpen]); + + return ( + +
{children}
+
+ ); +} + +export function MenuButton({ + children, + onClick: externalOnClick, + ...props +}: { + children?: ReactNode; + onClick?: () => void; + [key: string]: unknown; +}) { + const ctx = useContext(MenuCtx); + + useEffect(() => { + const firstText = Children.toArray(children).find( + (c) => typeof c === 'string' + ); + if (firstText) ctx?.setLabel(firstText as string); + }); + + function handleClick() { + externalOnClick?.(); + ctx?.setOpen(!ctx.isOpen); + } + + return ( + + ); +} + +export function MenuList({ + children, + className, +}: { + children?: ReactNode; + className?: string; +}) { + const ctx = useContext(MenuCtx); + if (!ctx?.isOpen) return null; + return ( +
+ {children} +
+ ); +} + +export function MenuItem({ + children, + onSelect, + className, +}: { + children?: ReactNode; + onSelect?: () => void; + className?: string; +}) { + const ctx = useContext(MenuCtx); + + function handleClick() { + onSelect?.(); + ctx?.setOpen(false); + } + + return ( + // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/interactive-supports-focus +
+ {children} +
+ ); +} diff --git a/analysis/git.go b/analysis/git.go new file mode 100644 index 0000000..37d5e40 --- /dev/null +++ b/analysis/git.go @@ -0,0 +1,18 @@ +//go:build ignore + +package gorules + +import "github.com/quasilyte/go-ruleguard/dsl" + +// inMemoryCloneWithWorktree flags git clone calls that use memory.NewStorage() as +// the storer while also writing files to a real worktree. This holds all git objects +// in heap for the duration of the clone, which is unbounded for user-supplied repos. +func inMemoryCloneWithWorktree(m dsl.Matcher) { + m.Match(`git.CloneContext($_, memory.NewStorage(), $wt, $_)`). + Where(m["wt"].Text != "nil"). + Report(`git.CloneContext with memory.NewStorage() holds all git objects in heap; use gogitfs.NewStorage with a filesystem storer instead`) + + m.Match(`git.Clone(memory.NewStorage(), $wt, $_)`). + Where(m["wt"].Text != "nil"). + Report(`git.Clone with memory.NewStorage() holds all git objects in heap; use gogitfs.NewStorage with a filesystem storer instead`) +} diff --git a/analysis/ssrf.go b/analysis/ssrf.go new file mode 100644 index 0000000..b183a7f --- /dev/null +++ b/analysis/ssrf.go @@ -0,0 +1,75 @@ +//go:build ignore + +package gorules + +import "github.com/quasilyte/go-ruleguard/dsl" + +// unwrappedHTTPTransport flags any bare http.Transport composite literal. +// All transports must be created via ssrf.NewTransport or ssrf.NewInternalTransport, +// which clone http.DefaultTransport and handle SSRF protection internally. +func unwrappedHTTPTransport(m dsl.Matcher) { + m.Match(`$f(&http.Transport{$*_})`). + Report(`$f receives a bare *http.Transport; use ssrf.NewTransport(tlsConfig) or ssrf.NewInternalTransport(tlsConfig) instead`) + + m.Match(`$_ := &http.Transport{$*_}`). + Report(`bare *http.Transport variable; use ssrf.NewTransport(tlsConfig) or ssrf.NewInternalTransport(tlsConfig) instead`) + + m.Match(`$_.Transport = &http.Transport{$*_}`). + Report(`bare *http.Transport field assignment; use ssrf.NewTransport(tlsConfig) or ssrf.NewInternalTransport(tlsConfig) instead`) +} + +// helmGetterTransport flags getter.WithTransport calls that receive a bare *http.Transport. +// Helm v4 installs its own transport and bypasses http.DefaultTransport, so the transport +// passed here must be created via ssrf.NewTransport. +func helmGetterTransport(m dsl.Matcher) { + m.Match(`getter.WithTransport(&http.Transport{$*_})`). + Report(`getter.WithTransport called with a bare *http.Transport; use ssrf.NewTransport(tlsConfig) as Helm v4 bypasses http.DefaultTransport`) +} + +// cloneDefaultTransport flags direct clones of *http.Transport outside main.go. +// The one legitimate clone is in main.go where http.DefaultTransport is globally +// wrapped with SSRF protection at server startup. +func cloneDefaultTransport(m dsl.Matcher) { + m.Match(`$_.(*http.Transport).Clone()`). + Where(!m.File().Name.Matches(`^main\.go$`)). + Report(`cloning *http.Transport directly is forbidden; use ssrf.NewTransport(tlsConfig) or ssrf.NewInternalTransport(tlsConfig) instead`) +} + +// internalTransportMisuse flags calls to NewInternalTransport outside the proxy +// factory files where Chisel-tunnel and in-cluster K8s destinations are valid exemptions. +func internalTransportMisuse(m dsl.Matcher) { + m.Match(`ssrf.NewInternalTransport($*_)`). + Where( + !(m.File().PkgPath.Matches(`proxy/factory`) && + m.File().Name.Matches(`^(docker|agent|local_transport|edge_transport|docker_unix|docker_windows)\.go$`))). + Report(`NewInternalTransport bypasses SSRF validation; only valid in the proxy factory files for local sockets and internally-routed endpoints`) +} + +// dialerOverride flags direct assignments to any of the dialer fields on a transport. +// The only valid assignments are in docker_unix.go and docker_windows.go where a +// custom dialer is required for unix sockets and named pipes. +func dialerOverride(m dsl.Matcher) { + m.Match(`$_.DialContext = $*_`). + Where( + !(m.File().PkgPath.Matches(`proxy/factory`) && + m.File().Name.Matches(`^(docker_unix|docker_windows)\.go$`))). + Report(`direct DialContext assignment replaces the transport dialer; use ssrf.NewTransport or ssrf.NewInternalTransport instead`) + + m.Match(`$_.Dial = $*_`). + Where( + !(m.File().PkgPath.Matches(`proxy/factory`) && + m.File().Name.Matches(`^(docker_unix|docker_windows)\.go$`))). + Report(`direct Dial assignment replaces the transport dialer; use ssrf.NewTransport or ssrf.NewInternalTransport instead`) + + m.Match(`$_.DialTLSContext = $*_`). + Where( + !(m.File().PkgPath.Matches(`proxy/factory`) && + m.File().Name.Matches(`^(docker_unix|docker_windows)\.go$`))). + Report(`direct DialTLSContext assignment replaces the transport dialer; use ssrf.NewTransport or ssrf.NewInternalTransport instead`) + + m.Match(`$_.DialTLS = $*_`). + Where( + !(m.File().PkgPath.Matches(`proxy/factory`) && + m.File().Name.Matches(`^(docker_unix|docker_windows)\.go$`))). + Report(`direct DialTLS assignment replaces the transport dialer; use ssrf.NewTransport or ssrf.NewInternalTransport instead`) +} diff --git a/analysis/tools.go b/analysis/tools.go new file mode 100644 index 0000000..55f01d8 --- /dev/null +++ b/analysis/tools.go @@ -0,0 +1,5 @@ +//go:build tools + +package gorules + +import _ "github.com/quasilyte/go-ruleguard/dsl" diff --git a/api/.swaggo b/api/.swaggo new file mode 100644 index 0000000..3f60894 --- /dev/null +++ b/api/.swaggo @@ -0,0 +1 @@ +replace k8s.io/apimachinery/pkg/apis/meta/v1.Duration string diff --git a/api/adminmonitor/admin_monitor.go b/api/adminmonitor/admin_monitor.go new file mode 100644 index 0000000..e309959 --- /dev/null +++ b/api/adminmonitor/admin_monitor.go @@ -0,0 +1,116 @@ +package adminmonitor + +import ( + "context" + "net/http" + "strings" + "sync" + "time" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + + "github.com/rs/zerolog/log" +) + +const RedirectReasonAdminInitTimeout string = "AdminInitTimeout" + +type Monitor struct { + timeout time.Duration + datastore dataservices.DataStore + cancellationFunc context.CancelFunc + mu sync.RWMutex + adminInitDisabled bool +} + +// New creates a monitor that when started will wait for the timeout duration and then shutdown the application unless it has been initialized. +func New(timeout time.Duration, datastore dataservices.DataStore) *Monitor { + return &Monitor{ + timeout: timeout, + datastore: datastore, + adminInitDisabled: false, + } +} + +// Start starts the monitor. The monitor will stop when ctx is cancelled, or when Stop is called. +func (m *Monitor) Start(ctx context.Context) { + m.mu.Lock() + defer m.mu.Unlock() + + if m.cancellationFunc != nil { + return + } + + cancellationCtx, cancellationFunc := context.WithCancel(ctx) + m.cancellationFunc = cancellationFunc + + go func() { + log.Debug().Msg("start initialization monitor") + + select { + case <-time.After(m.timeout): + initialized, err := m.WasInitialized() + if err != nil { + log.Error().Err(err).Msg("AdminMonitor failed to determine if Portainer is Initialized") + return + } + + if !initialized { + log.Info().Msg("the Portainer instance timed out for security purposes, to re-enable your Portainer instance, you will need to restart Portainer") + + m.mu.Lock() + defer m.mu.Unlock() + + m.adminInitDisabled = true + return + } + case <-cancellationCtx.Done(): + log.Debug().Msg("canceling initialization monitor") + } + }() +} + +// Stop stops monitor. Safe to call even if monitor wasn't started. +func (m *Monitor) Stop() { + m.mu.Lock() + defer m.mu.Unlock() + + if m.cancellationFunc == nil { + return + } + + m.cancellationFunc() + m.cancellationFunc = nil +} + +// WasInitialized is a system initialization check +func (m *Monitor) WasInitialized() (bool, error) { + users, err := m.datastore.User().UsersByRole(portainer.AdministratorRole) + if err != nil { + return false, err + } + + return len(users) > 0, nil +} + +func (m *Monitor) WasInstanceDisabled() bool { + m.mu.RLock() + defer m.mu.RUnlock() + + return m.adminInitDisabled +} + +// WithRedirect checks whether administrator initialisation timeout. If so, it will return the error with redirect reason. +// Otherwise, it will pass through the request to next +func (m *Monitor) WithRedirect(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if m.WasInstanceDisabled() && strings.HasPrefix(r.RequestURI, "/api") && r.RequestURI != "/api/status" && r.RequestURI != "/api/settings/public" { + w.Header().Set("redirect-reason", RedirectReasonAdminInitTimeout) + httperror.WriteError(w, http.StatusSeeOther, "Administrator initialization timeout", nil) + return + } + + next.ServeHTTP(w, r) + }) +} diff --git a/api/adminmonitor/admin_monitor_test.go b/api/adminmonitor/admin_monitor_test.go new file mode 100644 index 0000000..6195d49 --- /dev/null +++ b/api/adminmonitor/admin_monitor_test.go @@ -0,0 +1,63 @@ +package adminmonitor + +import ( + "testing" + "testing/synctest" + "time" + + portainer "github.com/portainer/portainer/api" + i "github.com/portainer/portainer/api/internal/testhelpers" + "github.com/stretchr/testify/assert" +) + +func Test_stopWithoutStarting(t *testing.T) { + t.Parallel() + monitor := New(1*time.Minute, nil) + monitor.Stop() +} + +func Test_stopCouldBeCalledMultipleTimes(t *testing.T) { + t.Parallel() + monitor := New(1*time.Minute, nil) + monitor.Stop() + monitor.Stop() +} + +func Test_startOrStopCouldBeCalledMultipleTimesConcurrently(t *testing.T) { + t.Parallel() + synctest.Test(t, test_startOrStopCouldBeCalledMultipleTimesConcurrently) +} + +func test_startOrStopCouldBeCalledMultipleTimesConcurrently(t *testing.T) { + monitor := New(1*time.Minute, nil) + + go monitor.Start(t.Context()) + monitor.Start(t.Context()) + + go monitor.Stop() + monitor.Stop() + + time.Sleep(2 * time.Second) +} + +func Test_canStopStartedMonitor(t *testing.T) { + t.Parallel() + monitor := New(1*time.Minute, nil) + monitor.Start(t.Context()) + assert.NotNil(t, monitor.cancellationFunc, "cancellation function is missing in started monitor") + + monitor.Stop() + assert.Nil(t, monitor.cancellationFunc, "cancellation function should absent in stopped monitor") +} + +func Test_start_shouldDisableInstanceAfterTimeout_ifNotInitialized(t *testing.T) { + t.Parallel() + timeout := 10 * time.Millisecond + + datastore := i.NewDatastore(i.WithUsers([]portainer.User{})) + monitor := New(timeout, datastore) + monitor.Start(t.Context()) + + <-time.After(20 * timeout) + assert.True(t, monitor.WasInstanceDisabled(), "monitor should have been timeout and instance is disabled") +} diff --git a/api/agent/version.go b/api/agent/version.go new file mode 100644 index 0000000..ff61d03 --- /dev/null +++ b/api/agent/version.go @@ -0,0 +1,80 @@ +package agent + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "time" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/url" + "github.com/portainer/portainer/pkg/libhttp/ssrf" + + "github.com/rs/zerolog/log" +) + +// GetAgentVersionAndPlatform returns the agent version and platform +// +// it sends a ping to the agent and parses the version and platform from the headers +func GetAgentVersionAndPlatform(endpointUrl string, tlsConfig *tls.Config) (portainer.AgentPlatform, string, error) { //nolint:forbidigo + if err := ssrf.CheckURL(context.Background(), endpointUrl); err != nil { + return 0, "", err + } + + httpCli := &http.Client{Timeout: 3 * time.Second} + + if tlsConfig != nil { + httpCli.Transport = ssrf.NewTransport(tlsConfig) + } + + parsedURL, err := url.ParseURL(endpointUrl + "/ping") + if err != nil { + return 0, "", err + } + + parsedURL.Scheme = "https" + + req, err := http.NewRequest(http.MethodGet, parsedURL.String(), nil) + if err != nil { + return 0, "", err + } + + resp, err := httpCli.Do(req) + if err != nil { + return 0, "", err + } + + _, _ = io.Copy(io.Discard, resp.Body) + if err := resp.Body.Close(); err != nil { + log.Warn().Err(err).Msg("failed to close response body") + } + + if resp.StatusCode != http.StatusNoContent { + return 0, "", fmt.Errorf("Failed request with status %d", resp.StatusCode) + } + + version := resp.Header.Get(portainer.PortainerAgentHeader) + if version == "" { + return 0, "", errors.New("Version Header is missing") + } + + 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), version, nil +} diff --git a/api/agent/version_test.go b/api/agent/version_test.go new file mode 100644 index 0000000..ce51c2f --- /dev/null +++ b/api/agent/version_test.go @@ -0,0 +1,119 @@ +package agent + +import ( + "net/http" + "net/http/httptest" + "strconv" + "testing" + + portainer "github.com/portainer/portainer/api" + + "github.com/stretchr/testify/require" +) + +func tlsServer(t *testing.T, handler http.HandlerFunc) *httptest.Server { + t.Helper() + srv := httptest.NewTLSServer(handler) + t.Cleanup(srv.Close) + + return srv +} + +func TestGetAgentVersionAndPlatform_Success(t *testing.T) { + t.Parallel() + + srv := tlsServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set(portainer.PortainerAgentHeader, "2.19.0") + w.Header().Set(portainer.HTTPResponseAgentPlatform, "1") + w.WriteHeader(http.StatusNoContent) + }) + + tlsCfg := srv.Client().Transport.(*http.Transport).TLSClientConfig + platform, version, err := GetAgentVersionAndPlatform(srv.URL, tlsCfg) + require.NoError(t, err) + require.Equal(t, portainer.AgentPlatformDocker, platform) + require.Equal(t, "2.19.0", version) +} + +func TestGetAgentVersionAndPlatform_NonOKStatus(t *testing.T) { + t.Parallel() + + srv := tlsServer(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + }) + + tlsCfg := srv.Client().Transport.(*http.Transport).TLSClientConfig + _, _, err := GetAgentVersionAndPlatform(srv.URL, tlsCfg) + require.Error(t, err) +} + +func TestGetAgentVersionAndPlatform_MissingVersionHeader(t *testing.T) { + t.Parallel() + + srv := tlsServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set(portainer.HTTPResponseAgentPlatform, "1") + w.WriteHeader(http.StatusNoContent) + }) + + tlsCfg := srv.Client().Transport.(*http.Transport).TLSClientConfig + _, _, err := GetAgentVersionAndPlatform(srv.URL, tlsCfg) + require.Error(t, err) +} + +func TestGetAgentVersionAndPlatform_MissingPlatformHeader(t *testing.T) { + t.Parallel() + + srv := tlsServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set(portainer.PortainerAgentHeader, "2.19.0") + w.WriteHeader(http.StatusNoContent) + }) + + tlsCfg := srv.Client().Transport.(*http.Transport).TLSClientConfig + _, _, err := GetAgentVersionAndPlatform(srv.URL, tlsCfg) + require.Error(t, err) +} + +func TestGetAgentVersionAndPlatform_InvalidPlatformZero(t *testing.T) { + t.Parallel() + + srv := tlsServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set(portainer.PortainerAgentHeader, "2.19.0") + w.Header().Set(portainer.HTTPResponseAgentPlatform, "0") + w.WriteHeader(http.StatusNoContent) + }) + + tlsCfg := srv.Client().Transport.(*http.Transport).TLSClientConfig + _, _, err := GetAgentVersionAndPlatform(srv.URL, tlsCfg) + require.Error(t, err) +} + +func TestGetAgentVersionAndPlatform_NonNumericPlatform(t *testing.T) { + t.Parallel() + + srv := tlsServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set(portainer.PortainerAgentHeader, "2.19.0") + w.Header().Set(portainer.HTTPResponseAgentPlatform, "docker") + w.WriteHeader(http.StatusNoContent) + }) + + tlsCfg := srv.Client().Transport.(*http.Transport).TLSClientConfig + _, _, err := GetAgentVersionAndPlatform(srv.URL, tlsCfg) + require.Error(t, err) +} + +func TestGetAgentVersionAndPlatform_PingPathAppended(t *testing.T) { + t.Parallel() + + var gotPath string + srv := tlsServer(t, func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + w.Header().Set(portainer.PortainerAgentHeader, "2.19.0") + w.Header().Set(portainer.HTTPResponseAgentPlatform, strconv.Itoa(int(portainer.AgentPlatformKubernetes))) + w.WriteHeader(http.StatusNoContent) + }) + + tlsCfg := srv.Client().Transport.(*http.Transport).TLSClientConfig + _, _, err := GetAgentVersionAndPlatform(srv.URL, tlsCfg) + require.NoError(t, err) + require.Equal(t, "/ping", gotPath) +} diff --git a/api/api.md b/api/api.md new file mode 100644 index 0000000..8e53c40 --- /dev/null +++ b/api/api.md @@ -0,0 +1,61 @@ +The Portainer API is an HTTP API served by Portainer. It is used by the Portainer UI, and anything you can do in the UI can also be done via the HTTP API. + +API examples are available in the [Portainer documentation](https://documentation.portainer.io/api/api-examples/) + +You can find out more about Portainer [on our website](http://portainer.io) and get some support on [Slack](http://portainer.io/slack/). + +# Authentication + +Most of the API endpoints require authentication, as well as some level of authorization. +Portainer uses JSON Web Tokens to manage authentication. You must provide a token in the **Authorization** header of each request using the **Bearer** scheme. + +Example: + +``` +Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidXNlcm5hbWUiOiJhZG1pbiIsInJvbGUiOjEsImV4cCI6MTQ5OTM3NjE1NH0.NJ6vE8FY1WG6jsRQzfMqeatJ4vh2TWAeeYfDhP71YEE +``` + +# Security + +Each API endpoint has an associated access policy, documented in its description. + +The following policies are available: + +- Public access +- Authenticated access +- Restricted access +- Administrator access + +### Public access + +No authentication is required. + +### Authenticated access + +Authentication is required. + +### Restricted access + +Authentication is required. Additional checks may apply to verify access to the resource, and returned data may be filtered. + +### Administrator access + +Authentication and an administrator role are both required. + +# Execute Docker requests + +Portainer does not expose dedicated endpoints for managing Docker resources (create a container, remove a volume, etc). + +Instead, it acts as a reverse-proxy to the Docker HTTP API, allowing you to execute Docker requests via the Portainer HTTP API. + +To do so, use the `/endpoints/{id}/docker` endpoint. Note that this endpoint is not documented below due to Swagger limitations. It has a restricted access policy, so authentication is still required. Any request made to this endpoint is proxied to the Docker API of the associated environment - request and response objects are identical to those in the [Docker official documentation](https://docs.docker.com/engine/api). + +# Private Registry + +When using a private registry, include a Base64-encoded JSON string in the request header. The header parameter name is `X-Registry-Auth` and the value should encode the following structure: ‘{"registryId":\}’ where `` is the ID of the registry where the repository was created. + +Example encoded value: + +``` +eyJyZWdpc3RyeUlkIjoxfQ== +``` diff --git a/api/apikey/apikey.go b/api/apikey/apikey.go new file mode 100644 index 0000000..de70b32 --- /dev/null +++ b/api/apikey/apikey.go @@ -0,0 +1,17 @@ +package apikey + +import ( + portainer "github.com/portainer/portainer/api" +) + +// APIKeyService represents a service for managing API keys. +type APIKeyService interface { + HashRaw(rawKey string) string + GenerateApiKey(user portainer.User, description string) (string, *portainer.APIKey, error) + GetAPIKey(apiKeyID portainer.APIKeyID) (*portainer.APIKey, error) + GetAPIKeys(userID portainer.UserID) ([]portainer.APIKey, error) + GetDigestUserAndKey(digest string) (portainer.User, portainer.APIKey, error) + UpdateAPIKey(apiKey *portainer.APIKey) error + DeleteAPIKey(apiKeyID portainer.APIKeyID) error + InvalidateUserKeyCache(userId portainer.UserID) bool +} diff --git a/api/apikey/apikey_test.go b/api/apikey/apikey_test.go new file mode 100644 index 0000000..b85c9ce --- /dev/null +++ b/api/apikey/apikey_test.go @@ -0,0 +1,53 @@ +package apikey + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_generateRandomKey(t *testing.T) { + t.Parallel() + is := assert.New(t) + + tests := []struct { + name string + wantLength int + }{ + { + name: "Generate a random key of length 16", + wantLength: 16, + }, + { + name: "Generate a random key of length 32", + wantLength: 32, + }, + { + name: "Generate a random key of length 64", + wantLength: 64, + }, + { + name: "Generate a random key of length 128", + wantLength: 128, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := GenerateRandomKey(tt.wantLength) + is.Len(got, tt.wantLength) + }) + } + + t.Run("Generated keys are unique", func(t *testing.T) { + keys := make(map[string]bool) + + for range 100 { + key := GenerateRandomKey(8) + _, ok := keys[string(key)] + is.False(ok) + + keys[string(key)] = true + } + }) +} diff --git a/api/apikey/cache.go b/api/apikey/cache.go new file mode 100644 index 0000000..b5d336a --- /dev/null +++ b/api/apikey/cache.go @@ -0,0 +1,79 @@ +package apikey + +import ( + portainer "github.com/portainer/portainer/api" + + lru "github.com/hashicorp/golang-lru" +) + +const DefaultAPIKeyCacheSize = 1024 + +// entry is a tuple containing the user and API key associated to an API key digest +type entry[T any] struct { + user T + apiKey portainer.APIKey +} + +type UserCompareFn[T any] func(T, portainer.UserID) bool + +// ApiKeyCache is a concurrency-safe, in-memory cache which primarily exists for to reduce database roundtrips. +// We store the api-key digest (keys) and the associated user and key-data (values) in the cache. +// This is required because HTTP requests will contain only the api-key digest in the x-api-key request header; +// digest value must be mapped to a portainer user (and respective key data) for validation. +// This cache is used to avoid multiple database queries to retrieve these user/key associated to the digest. +type ApiKeyCache[T any] struct { + // cache type [string]entry cache (key: string(digest), value: user/key entry) + // note: []byte keys are not supported by golang-lru Cache + cache *lru.Cache + userCmpFn UserCompareFn[T] +} + +// NewAPIKeyCache creates a new cache for API keys +func NewAPIKeyCache[T any](cacheSize int, userCompareFn UserCompareFn[T]) *ApiKeyCache[T] { + cache, _ := lru.New(cacheSize) + + return &ApiKeyCache[T]{cache: cache, userCmpFn: userCompareFn} +} + +// Get returns the user/key associated to an api-key's digest +// This is required because HTTP requests will contain the digest of the API key in header, +// the digest value must be mapped to a portainer user. +func (c *ApiKeyCache[T]) Get(digest string) (T, portainer.APIKey, bool) { + val, ok := c.cache.Get(digest) + if !ok { + var t T + + return t, portainer.APIKey{}, false + } + + tuple := val.(entry[T]) + + return tuple.user, tuple.apiKey, true +} + +// Set persists a user/key entry to the cache +func (c *ApiKeyCache[T]) Set(digest string, user T, apiKey portainer.APIKey) { + c.cache.Add(digest, entry[T]{ + user: user, + apiKey: apiKey, + }) +} + +// Delete evicts a digest's user/key entry key from the cache +func (c *ApiKeyCache[T]) Delete(digest string) { + c.cache.Remove(digest) +} + +// InvalidateUserKeyCache loops through all the api-keys associated to a user and removes them from the cache +func (c *ApiKeyCache[T]) InvalidateUserKeyCache(userId portainer.UserID) bool { + present := false + + for _, k := range c.cache.Keys() { + user, _, _ := c.Get(k.(string)) + if c.userCmpFn(user, userId) { + present = c.cache.Remove(k) || present + } + } + + return present +} diff --git a/api/apikey/cache_test.go b/api/apikey/cache_test.go new file mode 100644 index 0000000..b6ccb86 --- /dev/null +++ b/api/apikey/cache_test.go @@ -0,0 +1,186 @@ +package apikey + +import ( + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/stretchr/testify/assert" +) + +func Test_apiKeyCacheGet(t *testing.T) { + t.Parallel() + is := assert.New(t) + + keyCache := NewAPIKeyCache(10, compareUser) + + // pre-populate cache + keyCache.cache.Add(string("foo"), entry[portainer.User]{user: portainer.User{}, apiKey: portainer.APIKey{}}) + keyCache.cache.Add(string(""), entry[portainer.User]{user: portainer.User{}, apiKey: portainer.APIKey{}}) + + tests := []struct { + digest string + found bool + }{ + { + digest: "foo", + found: true, + }, + { + digest: "", + found: true, + }, + { + digest: "bar", + found: false, + }, + } + + for _, test := range tests { + t.Run(test.digest, func(t *testing.T) { + _, _, found := keyCache.Get(test.digest) + is.Equal(test.found, found) + }) + } +} + +func Test_apiKeyCacheSet(t *testing.T) { + t.Parallel() + is := assert.New(t) + + keyCache := NewAPIKeyCache(10, compareUser) + + // pre-populate cache + keyCache.Set("bar", portainer.User{ID: 2}, portainer.APIKey{}) + keyCache.Set("foo", portainer.User{ID: 1}, portainer.APIKey{}) + + // overwrite existing entry + keyCache.Set("foo", portainer.User{ID: 3}, portainer.APIKey{}) + + val, ok := keyCache.cache.Get(string("bar")) + is.True(ok) + + tuple := val.(entry[portainer.User]) + is.Equal(portainer.User{ID: 2}, tuple.user) + + val, ok = keyCache.cache.Get(string("foo")) + is.True(ok) + + tuple = val.(entry[portainer.User]) + is.Equal(portainer.User{ID: 3}, tuple.user) +} + +func Test_apiKeyCacheDelete(t *testing.T) { + t.Parallel() + is := assert.New(t) + + keyCache := NewAPIKeyCache(10, compareUser) + + t.Run("Delete an existing entry", func(t *testing.T) { + keyCache.cache.Add(string("foo"), entry[portainer.User]{user: portainer.User{ID: 1}, apiKey: portainer.APIKey{}}) + keyCache.Delete("foo") + + _, ok := keyCache.cache.Get(string("foo")) + is.False(ok) + }) + + t.Run("Delete a non-existing entry", func(t *testing.T) { + nonPanicFunc := func() { keyCache.Delete("non-existent-key") } + is.NotPanics(nonPanicFunc) + }) +} + +func Test_apiKeyCacheLRU(t *testing.T) { + t.Parallel() + is := assert.New(t) + + tests := []struct { + name string + cacheLen int + key []string + foundKeys []string + evictedKeys []string + }{ + { + name: "Cache length is 1, add 2 keys", + cacheLen: 1, + key: []string{"foo", "bar"}, + foundKeys: []string{"bar"}, + evictedKeys: []string{"foo"}, + }, + { + name: "Cache length is 1, add 3 keys", + cacheLen: 1, + key: []string{"foo", "bar", "baz"}, + foundKeys: []string{"baz"}, + evictedKeys: []string{"foo", "bar"}, + }, + { + name: "Cache length is 2, add 3 keys", + cacheLen: 2, + key: []string{"foo", "bar", "baz"}, + foundKeys: []string{"bar", "baz"}, + evictedKeys: []string{"foo"}, + }, + { + name: "Cache length is 2, add 4 keys", + cacheLen: 2, + key: []string{"foo", "bar", "baz", "qux"}, + foundKeys: []string{"baz", "qux"}, + evictedKeys: []string{"foo", "bar"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + keyCache := NewAPIKeyCache(test.cacheLen, compareUser) + + for _, key := range test.key { + keyCache.Set(key, portainer.User{ID: 1}, portainer.APIKey{}) + } + + for _, key := range test.foundKeys { + _, _, found := keyCache.Get(key) + is.True(found, "Key %s not found", key) + } + + for _, key := range test.evictedKeys { + _, _, found := keyCache.Get(key) + is.False(found, "key %s should have been evicted", key) + } + }) + } +} + +func Test_apiKeyCacheInvalidateUserKeyCache(t *testing.T) { + t.Parallel() + is := assert.New(t) + + keyCache := NewAPIKeyCache(10, compareUser) + + t.Run("Removes users keys from cache", func(t *testing.T) { + keyCache.cache.Add(string("foo"), entry[portainer.User]{user: portainer.User{ID: 1}, apiKey: portainer.APIKey{}}) + + ok := keyCache.InvalidateUserKeyCache(1) + is.True(ok) + + _, ok = keyCache.cache.Get(string("foo")) + is.False(ok) + }) + + t.Run("Does not affect other keys", func(t *testing.T) { + keyCache.cache.Add(string("foo"), entry[portainer.User]{user: portainer.User{ID: 1}, apiKey: portainer.APIKey{}}) + keyCache.cache.Add(string("bar"), entry[portainer.User]{user: portainer.User{ID: 2}, apiKey: portainer.APIKey{}}) + + ok := keyCache.InvalidateUserKeyCache(1) + is.True(ok) + + ok = keyCache.InvalidateUserKeyCache(1) + is.False(ok) + + _, ok = keyCache.cache.Get(string("foo")) + is.False(ok) + + _, ok = keyCache.cache.Get(string("bar")) + is.True(ok) + }) +} diff --git a/api/apikey/service.go b/api/apikey/service.go new file mode 100644 index 0000000..88b4e86 --- /dev/null +++ b/api/apikey/service.go @@ -0,0 +1,143 @@ +package apikey + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "fmt" + "io" + "time" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + + "github.com/pkg/errors" +) + +const portainerAPIKeyPrefix = "ptr_" + +var ErrInvalidAPIKey = errors.New("Invalid API key") + +type apiKeyService struct { + apiKeyRepository dataservices.APIKeyRepository + userRepository dataservices.UserService + cache *ApiKeyCache[portainer.User] +} + +// GenerateRandomKey generates a random key of specified length +// source: https://github.com/gorilla/securecookie/blob/master/securecookie.go#L515 +func GenerateRandomKey(length int) []byte { + k := make([]byte, length) + if _, err := io.ReadFull(rand.Reader, k); err != nil { + return nil + } + + return k +} + +func compareUser(u portainer.User, id portainer.UserID) bool { + return u.ID == id +} + +func NewAPIKeyService(apiKeyRepository dataservices.APIKeyRepository, userRepository dataservices.UserService) *apiKeyService { + return &apiKeyService{ + apiKeyRepository: apiKeyRepository, + userRepository: userRepository, + cache: NewAPIKeyCache(DefaultAPIKeyCacheSize, compareUser), + } +} + +// HashRaw computes a hash digest of provided raw API key. +func (a *apiKeyService) HashRaw(rawKey string) string { + hashDigest := sha256.Sum256([]byte(rawKey)) + + return base64.StdEncoding.EncodeToString(hashDigest[:]) +} + +// GenerateApiKey generates a raw API key for a user (for one-time display). +// The generated API key is stored in the cache and database. +func (a *apiKeyService) GenerateApiKey(user portainer.User, description string) (string, *portainer.APIKey, error) { + randKey := GenerateRandomKey(32) + encodedRawAPIKey := base64.StdEncoding.EncodeToString(randKey) + prefixedAPIKey := portainerAPIKeyPrefix + encodedRawAPIKey + hashDigest := a.HashRaw(prefixedAPIKey) + + apiKey := &portainer.APIKey{ + UserID: user.ID, + Description: description, + Prefix: prefixedAPIKey[:7], + DateCreated: time.Now().Unix(), + Digest: hashDigest, + } + + if err := a.apiKeyRepository.Create(apiKey); err != nil { + return "", nil, errors.Wrap(err, "Unable to create API key") + } + + // persist api-key to cache + a.cache.Set(apiKey.Digest, user, *apiKey) + + return prefixedAPIKey, apiKey, nil +} + +// GetAPIKey returns an API key by its ID. +func (a *apiKeyService) GetAPIKey(apiKeyID portainer.APIKeyID) (*portainer.APIKey, error) { + return a.apiKeyRepository.Read(apiKeyID) +} + +// GetAPIKeys returns all the API keys associated to a user. +func (a *apiKeyService) GetAPIKeys(userID portainer.UserID) ([]portainer.APIKey, error) { + return a.apiKeyRepository.GetAPIKeysByUserID(userID) +} + +// GetDigestUserAndKey returns the user and api-key associated to a specified hash digest. +// A cache lookup is performed first; if the user/api-key is not found in the cache, respective database lookups are performed. +func (a *apiKeyService) GetDigestUserAndKey(digest string) (portainer.User, portainer.APIKey, error) { + cachedUser, cachedKey, ok := a.cache.Get(digest) + if ok { + return cachedUser, cachedKey, nil + } + + apiKey, err := a.apiKeyRepository.GetAPIKeyByDigest(digest) + if err != nil { + return portainer.User{}, portainer.APIKey{}, errors.Wrap(err, "Unable to retrieve API key") + } + + user, err := a.userRepository.Read(apiKey.UserID) + if err != nil { + return portainer.User{}, portainer.APIKey{}, errors.Wrap(err, "Unable to retrieve digest user") + } + + // persist api-key to cache - for quicker future lookups + a.cache.Set(apiKey.Digest, *user, *apiKey) + + return *user, *apiKey, nil +} + +// UpdateAPIKey updates an API key and in cache and database. +func (a *apiKeyService) UpdateAPIKey(apiKey *portainer.APIKey) error { + user, _, err := a.GetDigestUserAndKey(apiKey.Digest) + if err != nil { + return errors.Wrap(err, "Unable to retrieve API key") + } + + a.cache.Set(apiKey.Digest, user, *apiKey) + + return a.apiKeyRepository.Update(apiKey.ID, apiKey) +} + +// DeleteAPIKey deletes an API key and removes the digest/api-key entry from the cache. +func (a *apiKeyService) DeleteAPIKey(apiKeyID portainer.APIKeyID) error { + apiKey, err := a.apiKeyRepository.Read(apiKeyID) + if err != nil { + return errors.Wrap(err, fmt.Sprintf("Unable to retrieve API key: %d", apiKeyID)) + } + + a.cache.Delete(apiKey.Digest) + + return a.apiKeyRepository.Delete(apiKeyID) +} + +func (a *apiKeyService) InvalidateUserKeyCache(userId portainer.UserID) bool { + return a.cache.InvalidateUserKeyCache(userId) +} diff --git a/api/apikey/service_test.go b/api/apikey/service_test.go new file mode 100644 index 0000000..b86468c --- /dev/null +++ b/api/apikey/service_test.go @@ -0,0 +1,315 @@ +package apikey + +import ( + "crypto/sha256" + "encoding/base64" + "fmt" + "strings" + "testing" + "time" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/datastore" + + "github.com/rs/zerolog/log" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_SatisfiesAPIKeyServiceInterface(t *testing.T) { + t.Parallel() + is := assert.New(t) + is.Implements((*APIKeyService)(nil), NewAPIKeyService(nil, nil)) +} + +func Test_GenerateApiKey(t *testing.T) { + t.Parallel() + is := assert.New(t) + + _, store := datastore.MustNewTestStore(t, true, true) + + service := NewAPIKeyService(store.APIKeyRepository(), store.User()) + + t.Run("Successfully generates API key", func(t *testing.T) { + desc := "test-1" + rawKey, apiKey, err := service.GenerateApiKey(portainer.User{ID: 1}, desc) + require.NoError(t, err) + is.NotEmpty(rawKey) + is.NotEmpty(apiKey) + is.Equal(desc, apiKey.Description) + }) + + t.Run("Api key prefix is 7 chars", func(t *testing.T) { + rawKey, apiKey, err := service.GenerateApiKey(portainer.User{ID: 1}, "test-2") + require.NoError(t, err) + + is.Equal(rawKey[:7], apiKey.Prefix) + is.Len(apiKey.Prefix, 7) + }) + + t.Run("Api key has 'ptr_' as prefix", func(t *testing.T) { + rawKey, _, err := service.GenerateApiKey(portainer.User{ID: 1}, "test-x") + require.NoError(t, err) + + is.Equal(portainerAPIKeyPrefix, "ptr_") + is.True(strings.HasPrefix(rawKey, "ptr_")) + }) + + t.Run("Successfully caches API key", func(t *testing.T) { + user := portainer.User{ID: 1} + _, apiKey, err := service.GenerateApiKey(user, "test-3") + require.NoError(t, err) + + userFromCache, apiKeyFromCache, ok := service.cache.Get(apiKey.Digest) + is.True(ok) + is.Equal(user, userFromCache) + is.Equal(apiKey, &apiKeyFromCache) + }) + + t.Run("Decoded raw api-key digest matches generated digest", func(t *testing.T) { + rawKey, apiKey, err := service.GenerateApiKey(portainer.User{ID: 1}, "test-4") + require.NoError(t, err) + + generatedDigest := sha256.Sum256([]byte(rawKey)) + + is.Equal(apiKey.Digest, base64.StdEncoding.EncodeToString(generatedDigest[:])) + }) +} + +func Test_GetAPIKey(t *testing.T) { + t.Parallel() + is := assert.New(t) + + _, store := datastore.MustNewTestStore(t, true, true) + + service := NewAPIKeyService(store.APIKeyRepository(), store.User()) + + t.Run("Successfully returns all API keys", func(t *testing.T) { + user := portainer.User{ID: 1} + _, apiKey, err := service.GenerateApiKey(user, "test-1") + require.NoError(t, err) + + apiKeyGot, err := service.GetAPIKey(apiKey.ID) + require.NoError(t, err) + + is.Equal(apiKey, apiKeyGot) + }) +} + +func Test_GetAPIKeys(t *testing.T) { + t.Parallel() + is := assert.New(t) + + _, store := datastore.MustNewTestStore(t, true, true) + + service := NewAPIKeyService(store.APIKeyRepository(), store.User()) + + t.Run("Successfully returns all API keys", func(t *testing.T) { + user := portainer.User{ID: 1} + _, _, err := service.GenerateApiKey(user, "test-1") + require.NoError(t, err) + _, _, err = service.GenerateApiKey(user, "test-2") + require.NoError(t, err) + + keys, err := service.GetAPIKeys(user.ID) + require.NoError(t, err) + is.Len(keys, 2) + }) +} + +func Test_GetDigestUserAndKey(t *testing.T) { + t.Parallel() + is := assert.New(t) + + _, store := datastore.MustNewTestStore(t, true, true) + + service := NewAPIKeyService(store.APIKeyRepository(), store.User()) + + t.Run("Successfully returns user and api key associated to digest", func(t *testing.T) { + user := portainer.User{ID: 1} + _, apiKey, err := service.GenerateApiKey(user, "test-1") + require.NoError(t, err) + + userGot, apiKeyGot, err := service.GetDigestUserAndKey(apiKey.Digest) + require.NoError(t, err) + is.Equal(user, userGot) + is.Equal(*apiKey, apiKeyGot) + }) + + t.Run("Successfully caches user and api key associated to digest", func(t *testing.T) { + user := portainer.User{ID: 1} + _, apiKey, err := service.GenerateApiKey(user, "test-1") + require.NoError(t, err) + + userGot, apiKeyGot, err := service.GetDigestUserAndKey(apiKey.Digest) + require.NoError(t, err) + is.Equal(user, userGot) + is.Equal(*apiKey, apiKeyGot) + + userFromCache, apiKeyFromCache, ok := service.cache.Get(apiKey.Digest) + is.True(ok) + is.Equal(userGot, userFromCache) + is.Equal(apiKeyGot, apiKeyFromCache) + }) +} + +func Test_UpdateAPIKey(t *testing.T) { + t.Parallel() + is := assert.New(t) + + _, store := datastore.MustNewTestStore(t, true, true) + + service := NewAPIKeyService(store.APIKeyRepository(), store.User()) + + t.Run("Successfully updates the api-key LastUsed time", func(t *testing.T) { + user := portainer.User{ID: 1} + + err := store.User().Create(&user) + require.NoError(t, err) + + _, apiKey, err := service.GenerateApiKey(user, "test-x") + require.NoError(t, err) + + apiKey.LastUsed = time.Now().UTC().Unix() + err = service.UpdateAPIKey(apiKey) + require.NoError(t, err) + + _, apiKeyGot, err := service.GetDigestUserAndKey(apiKey.Digest) + require.NoError(t, err) + + log.Debug().Str("wanted", fmt.Sprintf("%+v", apiKey)).Str("got", fmt.Sprintf("%+v", apiKeyGot)).Msg("") + + is.Equal(apiKey.LastUsed, apiKeyGot.LastUsed) + }) + + t.Run("Successfully updates api-key in cache upon api-key update", func(t *testing.T) { + _, apiKey, err := service.GenerateApiKey(portainer.User{ID: 1}, "test-x2") + require.NoError(t, err) + + _, apiKeyFromCache, ok := service.cache.Get(apiKey.Digest) + is.True(ok) + is.Equal(*apiKey, apiKeyFromCache) + + apiKey.LastUsed = time.Now().UTC().Unix() + is.NotEqual(*apiKey, apiKeyFromCache) + + err = service.UpdateAPIKey(apiKey) + require.NoError(t, err) + + _, updatedAPIKeyFromCache, ok := service.cache.Get(apiKey.Digest) + is.True(ok) + is.Equal(*apiKey, updatedAPIKeyFromCache) + }) +} + +func Test_DeleteAPIKey(t *testing.T) { + t.Parallel() + is := assert.New(t) + + _, store := datastore.MustNewTestStore(t, true, true) + + service := NewAPIKeyService(store.APIKeyRepository(), store.User()) + + t.Run("Successfully updates the api-key", func(t *testing.T) { + user := portainer.User{ID: 1} + _, apiKey, err := service.GenerateApiKey(user, "test-1") + require.NoError(t, err) + + _, apiKeyGot, err := service.GetDigestUserAndKey(apiKey.Digest) + require.NoError(t, err) + is.Equal(*apiKey, apiKeyGot) + + err = service.DeleteAPIKey(apiKey.ID) + require.NoError(t, err) + + _, _, err = service.GetDigestUserAndKey(apiKey.Digest) + require.Error(t, err) + }) + + t.Run("Successfully removes api-key from cache upon deletion", func(t *testing.T) { + user := portainer.User{ID: 1} + _, apiKey, err := service.GenerateApiKey(user, "test-1") + require.NoError(t, err) + + _, apiKeyFromCache, ok := service.cache.Get(apiKey.Digest) + is.True(ok) + is.Equal(*apiKey, apiKeyFromCache) + + err = service.DeleteAPIKey(apiKey.ID) + require.NoError(t, err) + + _, _, ok = service.cache.Get(apiKey.Digest) + is.False(ok) + }) +} + +func Test_InvalidateUserKeyCache(t *testing.T) { + t.Parallel() + is := assert.New(t) + + _, store := datastore.MustNewTestStore(t, true, true) + + service := NewAPIKeyService(store.APIKeyRepository(), store.User()) + + t.Run("Successfully updates evicts keys from cache", func(t *testing.T) { + // generate api keys + user := portainer.User{ID: 1} + _, apiKey1, err := service.GenerateApiKey(user, "test-1") + require.NoError(t, err) + + _, apiKey2, err := service.GenerateApiKey(user, "test-2") + require.NoError(t, err) + + // verify api keys are present in cache + _, apiKeyFromCache, ok := service.cache.Get(apiKey1.Digest) + is.True(ok) + is.Equal(*apiKey1, apiKeyFromCache) + + _, apiKeyFromCache, ok = service.cache.Get(apiKey2.Digest) + is.True(ok) + is.Equal(*apiKey2, apiKeyFromCache) + + // evict cache + ok = service.InvalidateUserKeyCache(user.ID) + is.True(ok) + + // verify users keys have been flushed from cache + _, _, ok = service.cache.Get(apiKey1.Digest) + is.False(ok) + + _, _, ok = service.cache.Get(apiKey2.Digest) + is.False(ok) + }) + + t.Run("User key eviction does not affect other users keys", func(t *testing.T) { + // generate keys for 2 users + user1 := portainer.User{ID: 1} + _, apiKey1, err := service.GenerateApiKey(user1, "test-1") + require.NoError(t, err) + + user2 := portainer.User{ID: 2} + _, apiKey2, err := service.GenerateApiKey(user2, "test-2") + require.NoError(t, err) + + // verify keys in cache + _, apiKeyFromCache, ok := service.cache.Get(apiKey1.Digest) + is.True(ok) + is.Equal(*apiKey1, apiKeyFromCache) + + _, apiKeyFromCache, ok = service.cache.Get(apiKey2.Digest) + is.True(ok) + is.Equal(*apiKey2, apiKeyFromCache) + + // evict key of single user from cache + ok = service.cache.InvalidateUserKeyCache(user1.ID) + is.True(ok) + + // verify user1 key has been flushed from cache + _, _, ok = service.cache.Get(apiKey1.Digest) + is.False(ok) + + // verify user2 key is still in cache + _, _, ok = service.cache.Get(apiKey2.Digest) + is.True(ok) + }) +} diff --git a/api/archive/tar.go b/api/archive/tar.go new file mode 100644 index 0000000..9643199 --- /dev/null +++ b/api/archive/tar.go @@ -0,0 +1,70 @@ +package archive + +import ( + "archive/tar" + "bytes" +) + +// TarFileInBuffer will create a tar archive containing a single file named via fileName and using the content +// specified in fileContent. Returns the archive as a byte array. +func TarFileInBuffer(fileContent []byte, fileName string, mode int64) ([]byte, error) { + var buffer bytes.Buffer + tarWriter := tar.NewWriter(&buffer) + + header := &tar.Header{ + Name: fileName, + Mode: mode, + Size: int64(len(fileContent)), + } + + if err := tarWriter.WriteHeader(header); err != nil { + return nil, err + } + + if _, err := tarWriter.Write(fileContent); err != nil { + return nil, err + } + + if err := tarWriter.Close(); err != nil { + return nil, err + } + + return buffer.Bytes(), nil +} + +// tarFileInBuffer represents a tar archive buffer. +type tarFileInBuffer struct { + b *bytes.Buffer + w *tar.Writer +} + +func NewTarFileInBuffer() *tarFileInBuffer { + var b bytes.Buffer + return &tarFileInBuffer{b: &b, w: tar.NewWriter(&b)} +} + +// Put puts a single file to tar archive buffer. +func (t *tarFileInBuffer) Put(fileContent []byte, fileName string, mode int64) error { + hdr := &tar.Header{ + Name: fileName, + Mode: mode, + Size: int64(len(fileContent)), + } + + if err := t.w.WriteHeader(hdr); err != nil { + return err + } + + _, err := t.w.Write(fileContent) + + return err +} + +// Bytes returns the archive as a byte array. +func (t *tarFileInBuffer) Bytes() []byte { + return t.b.Bytes() +} + +func (t *tarFileInBuffer) Close() error { + return t.w.Close() +} diff --git a/api/archive/targz.go b/api/archive/targz.go new file mode 100644 index 0000000..2f34963 --- /dev/null +++ b/api/archive/targz.go @@ -0,0 +1,132 @@ +package archive + +import ( + "archive/tar" + "compress/gzip" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/portainer/portainer/api/filesystem" + "github.com/portainer/portainer/api/logs" +) + +// TarGzDir creates a tar.gz archive and returns it's path. +// abosolutePath should be an absolute path to a directory. +// Archive name will be .tar.gz and will be placed next to the directory. +func TarGzDir(absolutePath string) (string, error) { + targzPath := filepath.Join(absolutePath, filepath.Base(absolutePath)+".tar.gz") + outFile, err := os.Create(targzPath) + if err != nil { + return "", err + } + defer logs.CloseAndLogErr(outFile) + + zipWriter := gzip.NewWriter(outFile) + defer logs.CloseAndLogErr(zipWriter) + + tarWriter := tar.NewWriter(zipWriter) + defer logs.CloseAndLogErr(tarWriter) + + err = filepath.Walk(absolutePath, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + if path == targzPath { + return nil // skip archive file + } + + pathInArchive := filepath.Clean(strings.TrimPrefix(path, absolutePath)) + if pathInArchive == "" { + return nil // skip root dir + } + + return addToArchive(tarWriter, pathInArchive, path, info) + }) + + return targzPath, err +} + +func addToArchive(tarWriter *tar.Writer, pathInArchive string, path string, info os.FileInfo) error { + if info.IsDir() { + return nil + } + + file, err := os.Open(path) + if err != nil { + return err + } + + stat, err := file.Stat() + if err != nil { + return err + } + + header, err := tar.FileInfoHeader(stat, stat.Name()) + if err != nil { + return err + } + header.Name = pathInArchive // use relative paths in archive + + err = tarWriter.WriteHeader(header) + if err != nil { + return err + } + if stat.IsDir() { + return nil + } + + _, err = io.Copy(tarWriter, file) + return err +} + +// ExtractTarGz reads a .tar.gz archive from the reader and extracts it into outputDirPath directory +func ExtractTarGz(r io.Reader, outputDirPath string) error { + zipReader, err := gzip.NewReader(r) + if err != nil { + return err + } + defer logs.CloseAndLogErr(zipReader) + + tarReader := tar.NewReader(zipReader) + + for { + header, err := tarReader.Next() + + if errors.Is(err, io.EOF) { + break + } + + if err != nil { + return err + } + + switch header.Typeflag { + case tar.TypeDir: + // skip, dir will be created with a file + case tar.TypeReg: + p := filesystem.JoinPaths(outputDirPath, header.Name) + if err := os.MkdirAll(filepath.Dir(p), 0o744); err != nil { + return fmt.Errorf("Failed to extract dir %s", filepath.Dir(p)) + } + outFile, err := os.Create(p) + if err != nil { + return fmt.Errorf("Failed to create file %s", header.Name) + } + if _, err := io.Copy(outFile, tarReader); err != nil { + return fmt.Errorf("Failed to extract file %s", header.Name) + } + logs.CloseAndLogErr(outFile) + default: + return fmt.Errorf("tar: unknown type: %v in %s", + header.Typeflag, + header.Name) + } + } + + return nil +} diff --git a/api/archive/targz_test.go b/api/archive/targz_test.go new file mode 100644 index 0000000..08d579b --- /dev/null +++ b/api/archive/targz_test.go @@ -0,0 +1,168 @@ +package archive + +import ( + "archive/tar" + "compress/gzip" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/portainer/portainer/api/filesystem" + "github.com/rs/zerolog/log" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func listFiles(dir string) []string { + items := make([]string, 0) + + if err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if path == dir { + return nil + } + + items = append(items, path) + + return nil + }); err != nil { + log.Warn().Err(err).Msg("failed to list files in directory") + } + + return items +} + +func Test_shouldCreateArchive(t *testing.T) { + t.Parallel() + tmpdir := t.TempDir() + content := []byte("content") + + err := os.WriteFile(filesystem.JoinPaths(tmpdir, "outer"), content, 0600) + require.NoError(t, err) + + err = os.MkdirAll(filesystem.JoinPaths(tmpdir, "dir"), 0700) + require.NoError(t, err) + + err = os.WriteFile(filesystem.JoinPaths(tmpdir, "dir", ".dotfile"), content, 0600) + require.NoError(t, err) + + err = os.WriteFile(filesystem.JoinPaths(tmpdir, "dir", "inner"), content, 0600) + require.NoError(t, err) + + gzPath, err := TarGzDir(tmpdir) + require.NoError(t, err) + assert.Equal(t, filesystem.JoinPaths(tmpdir, filepath.Base(tmpdir)+".tar.gz"), gzPath) + + extractionDir := t.TempDir() + cmd := exec.Command("tar", "-xzf", gzPath, "-C", extractionDir) + if err := cmd.Run(); err != nil { + t.Fatal("Failed to extract archive: ", err) + } + extractedFiles := listFiles(extractionDir) + + wasExtracted := func(p string) { + fullpath := filesystem.JoinPaths(extractionDir, p) + assert.Contains(t, extractedFiles, fullpath) + copyContent, err := os.ReadFile(fullpath) + require.NoError(t, err) + assert.Equal(t, content, copyContent) + } + + wasExtracted("outer") + wasExtracted("dir/inner") + wasExtracted("dir/.dotfile") +} + +func Test_shouldCreateArchive2(t *testing.T) { + t.Parallel() + tmpdir := t.TempDir() + content := []byte("content") + + err := os.WriteFile(filesystem.JoinPaths(tmpdir, "outer"), content, 0600) + require.NoError(t, err) + + err = os.MkdirAll(filesystem.JoinPaths(tmpdir, "dir"), 0700) + require.NoError(t, err) + + err = os.WriteFile(filesystem.JoinPaths(tmpdir, "dir", ".dotfile"), content, 0600) + require.NoError(t, err) + + err = os.WriteFile(filesystem.JoinPaths(tmpdir, "dir", "inner"), content, 0600) + require.NoError(t, err) + + gzPath, err := TarGzDir(tmpdir) + require.NoError(t, err) + assert.Equal(t, filesystem.JoinPaths(tmpdir, filepath.Base(tmpdir)+".tar.gz"), gzPath) + + extractionDir := t.TempDir() + r, _ := os.Open(gzPath) + if err := ExtractTarGz(r, extractionDir); err != nil { + t.Fatal("Failed to extract archive: ", err) + } + extractedFiles := listFiles(extractionDir) + + wasExtracted := func(p string) { + fullpath := filesystem.JoinPaths(extractionDir, p) + assert.Contains(t, extractedFiles, fullpath) + copyContent, _ := os.ReadFile(fullpath) + assert.Equal(t, content, copyContent) + } + + wasExtracted("outer") + wasExtracted("dir/inner") + wasExtracted("dir/.dotfile") +} + +func TestExtractTarGzPathTraversal(t *testing.T) { + t.Parallel() + testDir := t.TempDir() + + // Create an evil file with a path traversal attempt + tarPath := filesystem.JoinPaths(testDir, "evil.tar.gz") + + evilFile, err := os.Create(tarPath) + require.NoError(t, err) + + gzWriter := gzip.NewWriter(evilFile) + tarWriter := tar.NewWriter(gzWriter) + + content := []byte("evil content") + + header := &tar.Header{ + Name: "../evil.txt", + Mode: 0600, + Size: int64(len(content)), + Typeflag: tar.TypeReg, + } + + err = tarWriter.WriteHeader(header) + require.NoError(t, err) + + _, err = tarWriter.Write(content) + require.NoError(t, err) + + err = tarWriter.Close() + require.NoError(t, err) + + err = gzWriter.Close() + require.NoError(t, err) + + err = evilFile.Close() + require.NoError(t, err) + + // Attempt to extract the evil file + extractionDir := filesystem.JoinPaths(testDir, "extraction") + err = os.Mkdir(extractionDir, 0700) + require.NoError(t, err) + + tarFile, err := os.Open(tarPath) + require.NoError(t, err) + + // Check that the file didn't escape + err = ExtractTarGz(tarFile, extractionDir) + require.NoError(t, err) + require.NoFileExists(t, filesystem.JoinPaths(testDir, "evil.txt")) + + err = tarFile.Close() + require.NoError(t, err) +} diff --git a/api/archive/testdata/sample_archive.zip b/api/archive/testdata/sample_archive.zip new file mode 100644 index 0000000..de28924 Binary files /dev/null and b/api/archive/testdata/sample_archive.zip differ diff --git a/api/archive/zip.go b/api/archive/zip.go new file mode 100644 index 0000000..331b2ce --- /dev/null +++ b/api/archive/zip.go @@ -0,0 +1,73 @@ +package archive + +import ( + "archive/zip" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/portainer/portainer/api/logs" + + "github.com/pkg/errors" +) + +// UnzipFile will decompress a zip archive, moving all files and folders +// within the zip file (parameter 1) to an output directory (parameter 2). +func UnzipFile(src string, dest string) error { + r, err := zip.OpenReader(src) + if err != nil { + return err + } + defer logs.CloseAndLogErr(r) + + for _, f := range r.File { + p := filepath.Join(dest, f.Name) + + // Check for ZipSlip. More Info: http://bit.ly/2MsjAWE + if !strings.HasPrefix(p, filepath.Clean(dest)+string(os.PathSeparator)) { + return fmt.Errorf("%s: illegal file path", p) + } + + if f.FileInfo().IsDir() { + // Make Folder + if err := os.MkdirAll(p, os.ModePerm); err != nil { + return err + } + + continue + } + + if err := unzipFile(f, p); err != nil { + return err + } + } + + return nil +} + +func unzipFile(f *zip.File, p string) error { + // Make File + if err := os.MkdirAll(filepath.Dir(p), os.ModePerm); err != nil { + return errors.Wrapf(err, "unzipFile: can't make a path %s", p) + } + + outFile, err := os.OpenFile(p, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode()) + if err != nil { + return errors.Wrapf(err, "unzipFile: can't create file %s", p) + } + defer logs.CloseAndLogErr(outFile) + + rc, err := f.Open() + if err != nil { + return errors.Wrapf(err, "unzipFile: can't open zip file %s in the archive", f.Name) + } + defer logs.CloseAndLogErr(rc) + + if _, err = io.Copy(outFile, rc); err != nil { + return errors.Wrapf(err, "unzipFile: can't copy an archived file content") + } + + return nil +} diff --git a/api/archive/zip_test.go b/api/archive/zip_test.go new file mode 100644 index 0000000..52e5917 --- /dev/null +++ b/api/archive/zip_test.go @@ -0,0 +1,32 @@ +package archive + +import ( + "testing" + + "github.com/portainer/portainer/api/filesystem" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestUnzipFile(t *testing.T) { + t.Parallel() + dir := t.TempDir() + /* + Archive structure. + ├── 0 + │ ├── 1 + │ │ └── 2.txt + │ └── 1.txt + └── 0.txt + */ + + err := UnzipFile("./testdata/sample_archive.zip", dir) + + require.NoError(t, err) + archiveDir := dir + "/sample_archive" + assert.FileExists(t, filesystem.JoinPaths(archiveDir, "0.txt")) + assert.FileExists(t, filesystem.JoinPaths(archiveDir, "0", "1.txt")) + assert.FileExists(t, filesystem.JoinPaths(archiveDir, "0", "1", "2.txt")) + +} diff --git a/api/aws/ecr/authorization_token.go b/api/aws/ecr/authorization_token.go new file mode 100644 index 0000000..d211462 --- /dev/null +++ b/api/aws/ecr/authorization_token.go @@ -0,0 +1,61 @@ +package ecr + +import ( + "context" + "encoding/base64" + "errors" + "strings" + "time" +) + +func (s *Service) GetEncodedAuthorizationToken(ctx context.Context) (token *string, expiry *time.Time, err error) { + getAuthorizationTokenOutput, err := s.client.GetAuthorizationToken(ctx, nil) + if err != nil { + return + } + + if len(getAuthorizationTokenOutput.AuthorizationData) == 0 { + err = errors.New("AuthorizationData is empty") + return + } + + authData := getAuthorizationTokenOutput.AuthorizationData[0] + + token = authData.AuthorizationToken + expiry = authData.ExpiresAt + + return +} + +func (s *Service) GetAuthorizationToken(ctx context.Context) (token *string, expiry *time.Time, err error) { + tokenEncodedStr, expiry, err := s.GetEncodedAuthorizationToken(ctx) + if err != nil { + return + } + + tokenByte, err := base64.StdEncoding.DecodeString(*tokenEncodedStr) + if err != nil { + return + } + tokenStr := string(tokenByte) + token = &tokenStr + + return +} + +func (s *Service) ParseAuthorizationToken(token string) (username string, password string, err error) { + if len(token) == 0 { + return + } + + splitToken := strings.Split(token, ":") + if len(splitToken) < 2 { + err = errors.New("invalid ECR authorization token") + return + } + + username = splitToken[0] + password = splitToken[1] + + return +} diff --git a/api/aws/ecr/ecr.go b/api/aws/ecr/ecr.go new file mode 100644 index 0000000..0fc81db --- /dev/null +++ b/api/aws/ecr/ecr.go @@ -0,0 +1,41 @@ +package ecr + +import ( + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/ecr" +) + +// Registry represents an ECR registry endpoint information. +// This struct is used to parse and validate ECR endpoint URLs. +type Registry struct { + ID string // AWS account ID (empty for accountless endpoints like "ecr-fips.us-west-1.amazonaws.com") + FIPS bool // Whether this is a FIPS endpoint (contains "-fips" in the URL) + Region string // AWS region (e.g., "us-east-1", "us-gov-west-1") + Public bool // Whether this is ecr-public.aws.com +} + +type ( + Service struct { + accessKey string + secretKey string + region string + client *ecr.Client + } +) + +func NewService(accessKey, secretKey, region string) *Service { + options := ecr.Options{ + Region: region, + Credentials: aws.NewCredentialsCache(credentials.NewStaticCredentialsProvider(accessKey, secretKey, "")), + } + + client := ecr.New(options) + + return &Service{ + accessKey: accessKey, + secretKey: secretKey, + region: region, + client: client, + } +} diff --git a/api/aws/ecr/parse_endpoints.go b/api/aws/ecr/parse_endpoints.go new file mode 100644 index 0000000..7c942c0 --- /dev/null +++ b/api/aws/ecr/parse_endpoints.go @@ -0,0 +1,70 @@ +package ecr + +import ( + "fmt" + "net/url" + "regexp" + "strings" +) + +// ecrEndpointPattern matches all valid ECR endpoints including account-prefixed and accountless formats. +// Based on AWS ECR credential helper regex but extended to support accountless endpoints. +// +// Supported formats: +// - Account-prefixed: 123456789012.dkr.ecr-fips.us-east-1.amazonaws.com +// - Account-prefixed (hyphen): 123456789012.dkr-ecr-fips.us-west-1.on.aws +// - Accountless service: ecr-fips.us-west-1.amazonaws.com +// - Accountless API: ecr-fips.us-east-1.api.aws +// - Non-FIPS variants: All formats above without "-fips" +// +// Regex groups: +// - Group 1: Full account prefix (optional) - e.g., "123456789012.dkr." or "123456789012.dkr-" +// - Group 2: Account ID (optional) - e.g., "123456789012" +// - Group 3: FIPS flag (optional) - either "-fips" or empty string +// - Group 4: Region - e.g., "us-east-1", "us-gov-west-1" +// - Group 5: Domain suffix - e.g., "amazonaws.com", "api.aws" +var ecrEndpointPattern = regexp.MustCompile( + `^((\d{12})\.dkr[\.\-])?ecr(\-fips)?\.([a-zA-Z0-9][a-zA-Z0-9-_]*)\.(amazonaws\.(?:com(?:\.cn)?|eu)|api\.aws|on\.(?:aws|amazonwebservices\.com\.cn)|sc2s\.sgov\.gov|c2s\.ic\.gov|cloud\.adc-e\.uk|csp\.hci\.ic\.gov)$`, +) + +// ParseECREndpoint parses an ECR registry URL and extracts registry information. + +// This function replaces the AWS ECR credential helper library's ExtractRegistry function, +// which only supports account-prefixed endpoints. +// +// Reference: https://docs.aws.amazon.com/general/latest/gr/ecr.html +func ParseECREndpoint(urlStr string) (*Registry, error) { + // Normalize URL by adding https:// prefix if not present + if !strings.HasPrefix(urlStr, "https://") && !strings.HasPrefix(urlStr, "http://") { + urlStr = "https://" + urlStr + } + + u, err := url.Parse(urlStr) + if err != nil { + return nil, fmt.Errorf("invalid URL: %w", err) + } + + hostname := u.Hostname() + + // Special case: ECR Public + // ECR Public uses a different domain and doesn't have FIPS variant + if hostname == "ecr-public.aws.com" { + return &Registry{ + FIPS: false, + Public: true, + }, nil + } + + // Parse standard ECR endpoints using regex + matches := ecrEndpointPattern.FindStringSubmatch(hostname) + if len(matches) == 0 { + return nil, fmt.Errorf("not a valid ECR endpoint: %s", hostname) + } + + return &Registry{ + ID: matches[2], // Account ID (may be empty for accountless endpoints) + FIPS: matches[3] == "-fips", // Check if "-fips" is present + Region: matches[4], // AWS region + Public: false, + }, nil +} diff --git a/api/aws/ecr/parse_endpoints_test.go b/api/aws/ecr/parse_endpoints_test.go new file mode 100644 index 0000000..4f25697 --- /dev/null +++ b/api/aws/ecr/parse_endpoints_test.go @@ -0,0 +1,254 @@ +package ecr + +import ( + "testing" +) + +func TestParseECREndpoint(t *testing.T) { + t.Parallel() + tests := []struct { + name string + url string + want *Registry + wantError bool + }{ + // Standard AWS Commercial - Account-prefixed FIPS + { + name: "account-prefixed FIPS us-east-1", + url: "123456789012.dkr.ecr-fips.us-east-1.amazonaws.com", + want: &Registry{ + ID: "123456789012", + FIPS: true, + Region: "us-east-1", + Public: false, + }, + }, + { + name: "account-prefixed FIPS us-west-2", + url: "123456789012.dkr.ecr-fips.us-west-2.amazonaws.com", + want: &Registry{ + ID: "123456789012", + FIPS: true, + Region: "us-west-2", + Public: false, + }, + }, + + // Accountless FIPS service endpoints + { + name: "accountless FIPS us-west-1", + url: "ecr-fips.us-west-1.amazonaws.com", + want: &Registry{ + ID: "", + FIPS: true, + Region: "us-west-1", + Public: false, + }, + }, + { + name: "accountless FIPS us-east-2", + url: "ecr-fips.us-east-2.amazonaws.com", + want: &Registry{ + ID: "", + FIPS: true, + Region: "us-east-2", + Public: false, + }, + }, + + // Accountless FIPS API endpoints + { + name: "accountless FIPS API us-west-1", + url: "ecr-fips.us-west-1.api.aws", + want: &Registry{ + ID: "", + FIPS: true, + Region: "us-west-1", + Public: false, + }, + }, + { + name: "accountless FIPS API us-east-1", + url: "ecr-fips.us-east-1.api.aws", + want: &Registry{ + ID: "", + FIPS: true, + Region: "us-east-1", + Public: false, + }, + }, + + // on.aws domain with hyphen separator + { + name: "account-prefixed FIPS hyphen us-west-1", + url: "123456789012.dkr-ecr-fips.us-west-1.on.aws", + want: &Registry{ + ID: "123456789012", + FIPS: true, + Region: "us-west-1", + Public: false, + }, + }, + { + name: "account-prefixed FIPS hyphen us-east-2", + url: "123456789012.dkr-ecr-fips.us-east-2.on.aws", + want: &Registry{ + ID: "123456789012", + FIPS: true, + Region: "us-east-2", + Public: false, + }, + }, + + // AWS GovCloud + { + name: "account-prefixed FIPS us-gov-east-1", + url: "123456789012.dkr.ecr-fips.us-gov-east-1.amazonaws.com", + want: &Registry{ + ID: "123456789012", + FIPS: true, + Region: "us-gov-east-1", + Public: false, + }, + }, + { + name: "account-prefixed FIPS us-gov-west-1", + url: "123456789012.dkr.ecr-fips.us-gov-west-1.amazonaws.com", + want: &Registry{ + ID: "123456789012", + FIPS: true, + Region: "us-gov-west-1", + Public: false, + }, + }, + { + name: "accountless FIPS us-gov-west-1", + url: "ecr-fips.us-gov-west-1.amazonaws.com", + want: &Registry{ + ID: "", + FIPS: true, + Region: "us-gov-west-1", + Public: false, + }, + }, + { + name: "accountless FIPS API us-gov-east-1", + url: "ecr-fips.us-gov-east-1.api.aws", + want: &Registry{ + ID: "", + FIPS: true, + Region: "us-gov-east-1", + Public: false, + }, + }, + + // ECR Public + { + name: "ecr-public", + url: "ecr-public.aws.com", + want: &Registry{ + ID: "", + FIPS: false, + Region: "", + Public: true, + }, + }, + + // Non-FIPS endpoints (valid ECR but FIPS=false) + { + name: "account-prefixed non-FIPS us-east-1", + url: "123456789012.dkr.ecr.us-east-1.amazonaws.com", + want: &Registry{ + ID: "123456789012", + FIPS: false, + Region: "us-east-1", + Public: false, + }, + }, + { + name: "accountless non-FIPS us-west-1", + url: "ecr.us-west-1.amazonaws.com", + want: &Registry{ + ID: "", + FIPS: false, + Region: "us-west-1", + Public: false, + }, + }, + { + name: "accountless non-FIPS API us-east-2", + url: "ecr.us-east-2.api.aws", + want: &Registry{ + ID: "", + FIPS: false, + Region: "us-east-2", + Public: false, + }, + }, + + // URLs with https:// prefix + { + name: "with https prefix", + url: "https://ecr-fips.us-west-1.amazonaws.com", + want: &Registry{ + ID: "", + FIPS: true, + Region: "us-west-1", + Public: false, + }, + }, + + // Invalid endpoints + { + name: "not an ECR URL", + url: "not-an-ecr-url.com", + wantError: true, + }, + { + name: "invalid account ID length", + url: "123.dkr.ecr-fips.us-east-1.amazonaws.com", + wantError: true, + }, + { + name: "empty string", + url: "", + wantError: true, + }, + { + name: "docker hub", + url: "docker.io", + wantError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseECREndpoint(tt.url) + + if tt.wantError { + if err == nil { + t.Errorf("ParseECREndpoint() expected error but got none") + } + return + } + + if err != nil { + t.Errorf("ParseECREndpoint() unexpected error: %v", err) + return + } + + if got.ID != tt.want.ID { + t.Errorf("ParseECREndpoint() ID = %v, want %v", got.ID, tt.want.ID) + } + if got.FIPS != tt.want.FIPS { + t.Errorf("ParseECREndpoint() FIPS = %v, want %v", got.FIPS, tt.want.FIPS) + } + if got.Region != tt.want.Region { + t.Errorf("ParseECREndpoint() Region = %v, want %v", got.Region, tt.want.Region) + } + if got.Public != tt.want.Public { + t.Errorf("ParseECREndpoint() Public = %v, want %v", got.Public, tt.want.Public) + } + }) + } +} diff --git a/api/backup/backup.go b/api/backup/backup.go new file mode 100644 index 0000000..3d4bb68 --- /dev/null +++ b/api/backup/backup.go @@ -0,0 +1,110 @@ +package backup + +import ( + "fmt" + "os" + "path" + "path/filepath" + "time" + + "github.com/portainer/portainer/api/archive" + "github.com/portainer/portainer/api/crypto" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/filesystem" + "github.com/portainer/portainer/api/http/offlinegate" + "github.com/portainer/portainer/api/logs" + + "github.com/pkg/errors" + "github.com/rs/zerolog/log" +) + +const rwxr__r__ os.FileMode = 0o744 + +var filesToBackup = []string{ + "certs", + "chisel", + "compose", + "config.json", + "custom_templates", + "edge_jobs", + "edge_stacks", + "extensions", + "portainer.key", + "portainer.pub", + "tls", +} + +// Creates a tar.gz system archive and encrypts it if password is not empty. Returns a path to the archive file. +func CreateBackupArchive(password string, gate *offlinegate.OfflineGate, datastore dataservices.DataStore, filestorePath string) (string, error) { + backupDirPath, err := backupDatabaseAndFilesystem(gate, datastore, filestorePath) + if err != nil { + return "", err + } + + archivePath, err := archive.TarGzDir(backupDirPath) + if err != nil { + return "", errors.Wrap(err, "Failed to make an archive") + } + + if password != "" { + archivePath, err = encrypt(archivePath, password) + if err != nil { + return "", errors.Wrap(err, "Failed to encrypt backup with the password") + } + } + + return archivePath, nil +} + +func backupDatabaseAndFilesystem(gate *offlinegate.OfflineGate, datastore dataservices.DataStore, filestorePath string) (string, error) { + unlock := gate.Lock() + defer unlock() + + backupDirPath := filepath.Join(filestorePath, "backup", time.Now().Format("2006-01-02_15-04-05")) + if err := os.MkdirAll(backupDirPath, rwxr__r__); err != nil { + return "", errors.Wrap(err, "Failed to create backup dir") + } + + // new export + exportFilename := path.Join(backupDirPath, fmt.Sprintf("export-%d.json", time.Now().Unix())) + + if err := datastore.Export(exportFilename); err != nil { + log.Error().Err(err).Str("filename", exportFilename).Msg("failed to export") + } else { + log.Debug().Str("filename", exportFilename).Msg("file exported") + } + + if err := backupDb(backupDirPath, datastore); err != nil { + return "", errors.Wrap(err, "Failed to backup database") + } + + for _, filename := range filesToBackup { + if err := filesystem.CopyPath(filepath.Join(filestorePath, filename), backupDirPath); err != nil { + return "", errors.Wrap(err, "Failed to create backup file") + } + } + + return backupDirPath, nil +} + +func backupDb(backupDirPath string, datastore dataservices.DataStore) error { + dbFileName := datastore.Connection().GetDatabaseFileName() + _, err := datastore.Backup(filepath.Join(backupDirPath, dbFileName)) + return err +} + +func encrypt(path string, passphrase string) (string, error) { + in, err := os.Open(path) + if err != nil { + return "", err + } + defer logs.CloseAndLogErr(in) + + outFileName := path + ".encrypted" + out, err := os.Create(outFileName) + if err != nil { + return "", err + } + + return outFileName, crypto.AesEncrypt(in, out, []byte(passphrase)) +} diff --git a/api/backup/backup_test.go b/api/backup/backup_test.go new file mode 100644 index 0000000..43889df --- /dev/null +++ b/api/backup/backup_test.go @@ -0,0 +1,274 @@ +package backup + +import ( + "bytes" + "context" + "io" + "os" + "path/filepath" + "testing" + + "github.com/portainer/portainer/api/archive" + "github.com/portainer/portainer/api/crypto" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/filesystem" + "github.com/portainer/portainer/api/http/offlinegate" + "github.com/portainer/portainer/pkg/fips" + + "github.com/stretchr/testify/require" +) + +func init() { + fips.InitFIPS(false) +} + +func TestGetRestoreSourcePath_DBAtRoot(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + err := os.WriteFile(filesystem.JoinPaths(dir, "portainer.db"), []byte("db"), 0o600) + require.NoError(t, err) + + result, err := getRestoreSourcePath(dir) + require.NoError(t, err) + require.Equal(t, dir, result) +} + +func TestGetRestoreSourcePath_EncryptedDBAtRoot(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + err := os.WriteFile(filesystem.JoinPaths(dir, "portainer.edb"), []byte("db"), 0o600) + require.NoError(t, err) + + result, err := getRestoreSourcePath(dir) + require.NoError(t, err) + require.Equal(t, dir, result) +} + +func TestGetRestoreSourcePath_DBInSubdirectory(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + sub := filesystem.JoinPaths(dir, "backup-2024-01-01") + err := os.Mkdir(sub, 0o700) + require.NoError(t, err) + + err = os.WriteFile(filesystem.JoinPaths(sub, "portainer.db"), []byte("db"), 0o600) + require.NoError(t, err) + + result, err := getRestoreSourcePath(dir) + require.NoError(t, err) + require.Equal(t, sub, result) +} + +func TestGetRestoreSourcePath_NoDBFile(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + err := os.WriteFile(filesystem.JoinPaths(dir, "other.file"), []byte("data"), 0o600) + require.NoError(t, err) + + result, err := getRestoreSourcePath(dir) + require.NoError(t, err) + require.Equal(t, dir, result) +} + +func TestGetRestoreSourcePath_EmptyDir(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + + result, err := getRestoreSourcePath(dir) + require.NoError(t, err) + require.Equal(t, dir, result) +} + +func TestEncryptDecrypt_RoundTrip(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + plaintext := []byte("sensitive portainer backup data") + + srcPath := filesystem.JoinPaths(dir, "archive.tar.gz") + err := os.WriteFile(srcPath, plaintext, 0o600) + require.NoError(t, err) + + encryptedPath, err := encrypt(srcPath, "mysecretpassword") + require.NoError(t, err) + require.Equal(t, srcPath+".encrypted", encryptedPath) + + encryptedData, err := os.ReadFile(encryptedPath) + require.NoError(t, err) + + decryptedReader, err := crypto.AesDecrypt(bytes.NewReader(encryptedData), []byte("mysecretpassword")) + require.NoError(t, err) + + decrypted, err := io.ReadAll(decryptedReader) + require.NoError(t, err) + require.Equal(t, plaintext, decrypted) +} + +func TestEncryptDecrypt_WrongPassword(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + + srcPath := filesystem.JoinPaths(dir, "archive.tar.gz") + err := os.WriteFile(srcPath, []byte("data"), 0o600) + require.NoError(t, err) + + encryptedPath, err := encrypt(srcPath, "correctpassword") + require.NoError(t, err) + + encryptedData, err := os.ReadFile(encryptedPath) + require.NoError(t, err) + + _, err = crypto.AesDecrypt(bytes.NewReader(encryptedData), []byte("wrongpassword")) + require.Error(t, err) +} + +func TestCreateBackupArchive_NoPassword(t *testing.T) { + t.Parallel() + + _, store := datastore.MustNewTestStore(t, true, false) + storePath := store.GetConnection().GetStorePath() + gate := offlinegate.NewOfflineGate() + + archivePath, err := CreateBackupArchive("", gate, store, storePath) + require.NoError(t, err) + + f, err := os.Open(archivePath) + require.NoError(t, err) + t.Cleanup(func() { + err := f.Close() + require.NoError(t, err) + }) + + extractDir := t.TempDir() + err = archive.ExtractTarGz(f, extractDir) + require.NoError(t, err) + + dbFound := false + err = filepath.Walk(extractDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.Name() == "portainer.db" { + dbFound = true + } + + return nil + }) + require.NoError(t, err) + require.True(t, dbFound, "archive should contain portainer.db") +} + +func TestCreateBackupArchive_WithPassword(t *testing.T) { + t.Parallel() + + _, store := datastore.MustNewTestStore(t, true, false) + storePath := store.GetConnection().GetStorePath() + gate := offlinegate.NewOfflineGate() + + archivePath, err := CreateBackupArchive("backup-secret", gate, store, storePath) + require.NoError(t, err) + require.Contains(t, archivePath, ".encrypted") + + encryptedData, err := os.ReadFile(archivePath) + require.NoError(t, err) + + decryptedReader, err := crypto.AesDecrypt(bytes.NewReader(encryptedData), []byte("backup-secret")) + require.NoError(t, err) + + extractDir := t.TempDir() + err = archive.ExtractTarGz(decryptedReader, extractDir) + require.NoError(t, err) + + dbFound := false + err = filepath.Walk(extractDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.Name() == "portainer.db" { + dbFound = true + } + + return nil + }) + require.NoError(t, err) + require.True(t, dbFound, "decrypted archive should contain portainer.db") +} + +func TestRestoreArchive_NoPassword(t *testing.T) { + t.Parallel() + + _, store1 := datastore.MustNewTestStore(t, true, false) + storePath1 := store1.GetConnection().GetStorePath() + gate := offlinegate.NewOfflineGate() + + archivePath, err := CreateBackupArchive("", gate, store1, storePath1) + require.NoError(t, err) + + archiveData, err := os.ReadFile(archivePath) + require.NoError(t, err) + + _, store2 := datastore.MustNewTestStore(t, true, false) + storePath2 := store2.GetConnection().GetStorePath() + + ctx, cancel := context.WithCancel(t.Context()) + err = RestoreArchive(bytes.NewReader(archiveData), "", storePath2, gate, store2, cancel) + require.NoError(t, err) + + require.ErrorIs(t, ctx.Err(), context.Canceled) + + _, err = os.Stat(filesystem.JoinPaths(storePath2, "portainer.db")) + require.NoError(t, err) +} + +func TestRestoreArchive_WithPassword(t *testing.T) { + t.Parallel() + + _, store1 := datastore.MustNewTestStore(t, true, false) + storePath1 := store1.GetConnection().GetStorePath() + gate := offlinegate.NewOfflineGate() + + archivePath, err := CreateBackupArchive("restore-secret", gate, store1, storePath1) + require.NoError(t, err) + + archiveData, err := os.ReadFile(archivePath) + require.NoError(t, err) + + _, store2 := datastore.MustNewTestStore(t, true, false) + storePath2 := store2.GetConnection().GetStorePath() + + ctx, cancel := context.WithCancel(t.Context()) + err = RestoreArchive(bytes.NewReader(archiveData), "restore-secret", storePath2, gate, store2, cancel) + require.NoError(t, err) + + require.ErrorIs(t, ctx.Err(), context.Canceled) + + _, err = os.Stat(filesystem.JoinPaths(storePath2, "portainer.db")) + require.NoError(t, err) +} + +func TestRestoreArchive_WrongPassword(t *testing.T) { + t.Parallel() + + _, store1 := datastore.MustNewTestStore(t, true, false) + storePath1 := store1.GetConnection().GetStorePath() + gate := offlinegate.NewOfflineGate() + + archivePath, err := CreateBackupArchive("correct-password", gate, store1, storePath1) + require.NoError(t, err) + + archiveData, err := os.ReadFile(archivePath) + require.NoError(t, err) + + _, store2 := datastore.MustNewTestStore(t, true, false) + storePath2 := store2.GetConnection().GetStorePath() + + _, cancel := context.WithCancel(t.Context()) + err = RestoreArchive(bytes.NewReader(archiveData), "wrong-password", storePath2, gate, store2, cancel) + require.Error(t, err) +} diff --git a/api/backup/restore.go b/api/backup/restore.go new file mode 100644 index 0000000..ccfe082 --- /dev/null +++ b/api/backup/restore.go @@ -0,0 +1,121 @@ +package backup + +import ( + "context" + "io" + "io/fs" + "os" + "path/filepath" + "regexp" + "time" + + "github.com/pkg/errors" + "github.com/portainer/portainer/api/archive" + "github.com/portainer/portainer/api/crypto" + "github.com/portainer/portainer/api/database/boltdb" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/filesystem" + "github.com/portainer/portainer/api/http/offlinegate" + + "github.com/rs/zerolog/log" +) + +var filesToRestore = append(filesToBackup, "portainer.db") + +// Restores system state from backup archive, will trigger system shutdown, when finished. +func RestoreArchive(archive io.Reader, password string, filestorePath string, gate *offlinegate.OfflineGate, datastore dataservices.DataStore, shutdownTrigger context.CancelFunc) error { + var err error + if password != "" { + archive, err = decrypt(archive, password) + if err != nil { + return errors.Wrap(err, "failed to decrypt the archive. Please ensure the password is correct and try again") + } + } + + restorePath := filepath.Join(filestorePath, "restore", time.Now().Format("20060102150405")) + defer func() { + if err := os.RemoveAll(filepath.Dir(restorePath)); err != nil { + log.Warn().Err(err).Msg("failed to clean up restore files") + } + }() + + if err := extractArchive(archive, restorePath); err != nil { + return errors.Wrap(err, "cannot extract files from the archive. Please ensure the password is correct and try again") + } + + unlock := gate.Lock() + defer unlock() + + if err := datastore.Close(); err != nil { + return errors.Wrap(err, "Failed to stop db") + } + + // At some point, backups were created containing a subdirectory, now we need to handle both + restorePath, err = getRestoreSourcePath(restorePath) + if err != nil { + return errors.Wrap(err, "failed to restore from backup. Portainer database missing from backup file") + } + + if err := restoreFiles(restorePath, filestorePath); err != nil { + return errors.Wrap(err, "failed to restore the system state") + } + + shutdownTrigger() + return nil +} + +func decrypt(r io.Reader, password string) (io.Reader, error) { + return crypto.AesDecrypt(r, []byte(password)) +} + +func extractArchive(r io.Reader, destinationDirPath string) error { + return archive.ExtractTarGz(r, destinationDirPath) +} + +func getRestoreSourcePath(dir string) (string, error) { + // find portainer.db or portainer.edb file. Return the parent directory + var portainerdbRegex = regexp.MustCompile(`^portainer.e?db$`) + + backupDirPath := dir + err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + if portainerdbRegex.MatchString(d.Name()) { + backupDirPath = filepath.Dir(path) + return filepath.SkipDir + } + return nil + }) + + return backupDirPath, err +} + +func restoreFiles(srcDir string, destinationDir string) error { + for _, filename := range filesToRestore { + if err := filesystem.CopyPath(filepath.Join(srcDir, filename), destinationDir); err != nil { + return err + } + } + + // TODO: This is very boltdb module specific once again due to the filename. Move to bolt module? Refactor for another day + + // Prevent the possibility of having both databases. Remove any default new instance + if err := os.Remove(filepath.Join(destinationDir, boltdb.DatabaseFileName)); err != nil && !os.IsNotExist(err) { + return err + } + + if err := os.Remove(filepath.Join(destinationDir, boltdb.EncryptedDatabaseFileName)); err != nil && !os.IsNotExist(err) { + return err + } + + // Now copy the database. It'll be either portainer.db or portainer.edb + + // Note: CopyPath does not return an error if the source file doesn't exist + if err := filesystem.CopyPath(filepath.Join(srcDir, boltdb.EncryptedDatabaseFileName), destinationDir); err != nil { + return err + } + + return filesystem.CopyPath(filepath.Join(srcDir, boltdb.DatabaseFileName), destinationDir) +} diff --git a/api/chisel/crypto/crypto.go b/api/chisel/crypto/crypto.go new file mode 100644 index 0000000..78ac4ac --- /dev/null +++ b/api/chisel/crypto/crypto.go @@ -0,0 +1,61 @@ +package crypto + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/x509" + "encoding/pem" + "fmt" + "io" + "math/big" + + chshare "github.com/jpillora/chisel/share" +) + +var one = new(big.Int).SetInt64(1) + +// GenerateGo119CompatibleKey This function is basically copied from chshare.GenerateKey. +func GenerateGo119CompatibleKey(seed string) ([]byte, error) { + r := chshare.NewDetermRand([]byte(seed)) + priv, err := ecdsaGenerateKey(elliptic.P256(), r) + if err != nil { + return nil, err + } + b, err := x509.MarshalECPrivateKey(priv) + if err != nil { + return nil, fmt.Errorf("Unable to marshal ECDSA private key: %w", err) + } + return pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: b}), nil +} + +// This function is copied from Go1.19 +func randFieldElement(c elliptic.Curve, rand io.Reader) (k *big.Int, err error) { + params := c.Params() + // Note that for P-521 this will actually be 63 bits more than the order, as + // division rounds down, but the extra bit is inconsequential. + b := make([]byte, params.N.BitLen()/8+8) + _, err = io.ReadFull(rand, b) + if err != nil { + return + } + + k = new(big.Int).SetBytes(b) + n := new(big.Int).Sub(params.N, one) + k.Mod(k, n) + k.Add(k, one) + return +} + +// This function is copied from Go1.19 +func ecdsaGenerateKey(c elliptic.Curve, rand io.Reader) (*ecdsa.PrivateKey, error) { + k, err := randFieldElement(c, rand) + if err != nil { + return nil, err + } + + priv := new(ecdsa.PrivateKey) + priv.Curve = c + priv.D = k + priv.X, priv.Y = c.ScalarBaseMult(k.Bytes()) + return priv, nil +} diff --git a/api/chisel/crypto/crypto_test.go b/api/chisel/crypto/crypto_test.go new file mode 100644 index 0000000..652fd42 --- /dev/null +++ b/api/chisel/crypto/crypto_test.go @@ -0,0 +1,38 @@ +package crypto + +import ( + "reflect" + "testing" +) + +func TestGenerateGo119CompatibleKey(t *testing.T) { + t.Parallel() + type args struct { + seed string + } + tests := []struct { + name string + args args + want []byte + wantErr bool + }{ + { + name: "Generate Go 1.19 compatible private key with a given seed", + args: args{seed: "94qh17MCIk8BOkiI"}, + want: []byte("-----BEGIN EC PRIVATE KEY-----\nMHcCAQEEIHeohwk0Gy3RHVVViaHz7pz/HOiqA7fkv1FTM3mGgfT3oAoGCCqGSM49\nAwEHoUQDQgAEN7riX06xDsLNPuUmOvYFluNEakcFwZZRVvOcIYk/9VYnanDzW0Km\n8/BUUiKyJDuuGdS4fj9SlQ4iL8yBK01uKg==\n-----END EC PRIVATE KEY-----\n"), + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := GenerateGo119CompatibleKey(tt.args.seed) + if (err != nil) != tt.wantErr { + t.Errorf("GenerateGo119CompatibleKey() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("GenerateGo119CompatibleKey()\ngot: Z %v\nwant: %v", got, tt.want) + } + }) + } +} diff --git a/api/chisel/key.go b/api/chisel/key.go new file mode 100644 index 0000000..7629f20 --- /dev/null +++ b/api/chisel/key.go @@ -0,0 +1,24 @@ +package chisel + +import ( + "encoding/base64" + "fmt" + "strconv" + "strings" +) + +// GenerateEdgeKey will generate a key that can be used by an Edge agent to register with a Portainer instance. +// The key represents the following data in this particular format: +// portainer_instance_url|tunnel_server_addr|tunnel_server_fingerprint|endpoint_ID +// The key returned by this function is a base64 encoded version of the data. +func (service *Service) GenerateEdgeKey(url, host string, endpointIdentifier int) string { + keyInformation := []string{ + url, + fmt.Sprintf("%s:%s", host, service.serverPort), + service.serverFingerprint, + strconv.Itoa(endpointIdentifier), + } + + key := strings.Join(keyInformation, "|") + return base64.RawStdEncoding.EncodeToString([]byte(key)) +} diff --git a/api/chisel/service.go b/api/chisel/service.go new file mode 100644 index 0000000..10996d4 --- /dev/null +++ b/api/chisel/service.go @@ -0,0 +1,339 @@ +package chisel + +import ( + "context" + "fmt" + "io" + "net/http" + "sync" + "time" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/http/proxy" + "github.com/portainer/portainer/pkg/schedule" + + chserver "github.com/jpillora/chisel/server" + "github.com/jpillora/chisel/share/ccrypto" + "github.com/rs/zerolog/log" +) + +const ( + tunnelCleanupInterval = 10 * time.Second + activeTimeout = 4*time.Minute + 30*time.Second + pingTimeout = 8 * time.Second + tunnelKeepAliveInterval = 25 * time.Second +) + +// Service represents a service to manage the state of multiple reverse tunnels. +// 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 + activeTunnels map[portainer.EndpointID]*portainer.TunnelDetails + edgeJobs map[portainer.EndpointID][]portainer.EdgeJob + dataStore dataservices.DataStore + snapshotService portainer.SnapshotService + chiselServer *chserver.Server + shutdownCtx context.Context + ProxyManager *proxy.Manager + mu sync.RWMutex + fileService portainer.FileService + defaultCheckinInterval int +} + +// NewService returns a pointer to a new instance of Service +func NewService(dataStore dataservices.DataStore, shutdownCtx context.Context, fileService portainer.FileService) *Service { + defaultCheckinInterval := portainer.DefaultEdgeAgentCheckinIntervalInSeconds + + settings, err := dataStore.Settings().Settings() + if err == nil { + defaultCheckinInterval = settings.EdgeAgentCheckinInterval + } else { + log.Error().Err(err).Msg("unable to retrieve the settings from the database") + } + + return &Service{ + activeTunnels: make(map[portainer.EndpointID]*portainer.TunnelDetails), + edgeJobs: make(map[portainer.EndpointID][]portainer.EdgeJob), + dataStore: dataStore, + shutdownCtx: shutdownCtx, + fileService: fileService, + defaultCheckinInterval: defaultCheckinInterval, + } +} + +// pingAgent ping the given agent so that the agent can keep the tunnel alive +func (service *Service) pingAgent(endpointID portainer.EndpointID) error { + endpoint, err := service.dataStore.Endpoint().Endpoint(endpointID) + if err != nil { + return err + } + + tunnelAddr, err := service.TunnelAddr(endpoint) + if err != nil { + return err + } + + requestURL := fmt.Sprintf("http://%s/ping", tunnelAddr) + req, err := http.NewRequest(http.MethodHead, requestURL, nil) + if err != nil { + return err + } + + httpClient := &http.Client{ + Timeout: pingTimeout, + } + + resp, err := httpClient.Do(req) + if err != nil { + return err + } + + _, _ = io.Copy(io.Discard, resp.Body) + return resp.Body.Close() +} + +// KeepTunnelAlive keeps the tunnel of the given environment for maxAlive duration, or until ctx is done +func (service *Service) KeepTunnelAlive(endpointID portainer.EndpointID, ctx context.Context, maxAlive time.Duration) { + go service.keepTunnelAlive(endpointID, ctx, maxAlive) +} + +func (service *Service) keepTunnelAlive(endpointID portainer.EndpointID, ctx context.Context, maxAlive time.Duration) { + log.Debug(). + Int("endpoint_id", int(endpointID)). + Float64("max_alive_minutes", maxAlive.Minutes()). + Msg("KeepTunnelAlive: start") + + maxAliveTicker := time.NewTicker(maxAlive) + defer maxAliveTicker.Stop() + + pingTicker := time.NewTicker(tunnelCleanupInterval) + defer pingTicker.Stop() + + for { + select { + case <-pingTicker.C: + service.UpdateLastActivity(endpointID) + + if err := service.pingAgent(endpointID); err != nil { + log.Debug(). + Int("endpoint_id", int(endpointID)). + Err(err). + Msg("KeepTunnelAlive: ping agent") + } + case <-maxAliveTicker.C: + log.Debug(). + Int("endpoint_id", int(endpointID)). + Float64("timeout_minutes", maxAlive.Minutes()). + Msg("KeepTunnelAlive: tunnel keep alive timeout") + + return + case <-ctx.Done(): + err := ctx.Err() + log.Debug(). + Int("endpoint_id", int(endpointID)). + Err(err). + Msg("KeepTunnelAlive: tunnel stop") + + return + } + } +} + +// StartTunnelServer starts a tunnel server on the specified addr and port. +// It uses a seed to generate a new private/public key pair. If the seed cannot +// 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, snapshotService portainer.SnapshotService) error { + privateKeyFile, err := service.retrievePrivateKeyFile() + if err != nil { + return err + } + + config := &chserver.Config{ + Reverse: true, + KeyFile: privateKeyFile, + KeepAlive: tunnelKeepAliveInterval, + } + + chiselServer, err := chserver.NewServer(config) + if err != nil { + return err + } + + service.serverFingerprint = chiselServer.GetFingerprint() + service.serverPort = port + + if err := chiselServer.Start(addr, port); err != nil { + return err + } + + service.chiselServer = chiselServer + + // TODO: work-around Chisel default behavior. + // By default, Chisel will allow anyone to connect if no user exists. + username, password := generateRandomCredentials() + if err = service.chiselServer.AddUser(username, password, "127.0.0.1"); err != nil { + return err + } + + service.snapshotService = snapshotService + + go service.startTunnelVerificationLoop() + + return nil +} + +// StopTunnelServer stops tunnel http server +func (service *Service) StopTunnelServer() error { + return service.chiselServer.Close() +} + +func (service *Service) retrievePrivateKeyFile() (string, error) { + privateKeyFile := service.fileService.GetDefaultChiselPrivateKeyPath() + + if exists, _ := service.fileService.FileExists(privateKeyFile); exists { + log.Info(). + Str("private-key", privateKeyFile). + Msg("found Chisel private key file on disk") + + return privateKeyFile, nil + } + + log.Debug(). + Str("private-key", privateKeyFile). + Msg("chisel private key file does not exist") + + privateKey, err := ccrypto.GenerateKey("") + if err != nil { + log.Error(). + Err(err). + Msg("failed to generate chisel private key") + + return "", err + } + + if err = service.fileService.StoreChiselPrivateKey(privateKey); err != nil { + log.Error(). + Err(err). + Msg("failed to save Chisel private key to disk") + + return "", err + } + + log.Info(). + Str("private-key", privateKeyFile). + Msg("generated a new Chisel private key file") + + return privateKeyFile, nil +} + +func (service *Service) startTunnelVerificationLoop() { + log.Debug(). + Float64("check_interval_seconds", tunnelCleanupInterval.Seconds()). + Msg("starting tunnel management process") + + schedule.RunOnInterval(service.shutdownCtx, tunnelCleanupInterval, service.checkTunnels, func() { + log.Debug().Msg("shutting down tunnel service") + + if err := service.StopTunnelServer(); err != nil { + log.Debug().Err(err).Msg("stopped tunnel service") + } + }) +} + +// checkTunnels finds tunnels that need snapshots and processes them one at a time. +// For active tunnels missing an initial snapshot, it takes one without closing the tunnel. +// For tunnels idle past activeTimeout, it snapshots and closes them. +func (service *Service) checkTunnels() { + service.mu.RLock() + + for endpointID, tunnel := range service.activeTunnels { + elapsed := time.Since(tunnel.LastActivity) + log.Debug(). + Int("endpoint_id", int(endpointID)). + Float64("last_activity_seconds", elapsed.Seconds()). + Msg("environment tunnel monitoring") + + tunnelPort := tunnel.Port + + if !tunnel.HasSnapshot && elapsed < activeTimeout { + service.mu.RUnlock() + + if endpointHasSnapshot(service.dataStore, endpointID) { + service.markSnapshotTaken(endpointID) + + return + } + + log.Debug(). + Int("endpoint_id", int(endpointID)). + Msg("taking initial snapshot for active Edge environment") + + if service.snapshotAndLog(endpointID, tunnelPort) { + service.markSnapshotTaken(endpointID) + } + + return + } + + if tunnel.Status == portainer.EdgeAgentManagementRequired && elapsed < activeTimeout { + continue + } + + service.mu.RUnlock() + + log.Debug(). + Int("endpoint_id", int(endpointID)). + Float64("last_activity_seconds", elapsed.Seconds()). + Float64("timeout_seconds", activeTimeout.Seconds()). + Msg("last activity timeout exceeded") + + service.snapshotAndLog(endpointID, tunnelPort) + service.close(endpointID) + + return + } + + service.mu.RUnlock() +} + +func (service *Service) snapshotAndLog(endpointID portainer.EndpointID, tunnelPort int) bool { + if err := service.snapshotEnvironment(endpointID, tunnelPort); err != nil { + log.Error(). + Int("endpoint_id", int(endpointID)). + Err(err). + Msg("unable to snapshot Edge environment") + + if service.dataStore.IsErrObjectNotFound(err) { + service.close(endpointID) + } + + return false + } + + return true +} + +func (service *Service) markSnapshotTaken(endpointID portainer.EndpointID) { + service.mu.Lock() + defer service.mu.Unlock() + + if tun, ok := service.activeTunnels[endpointID]; ok { + tun.HasSnapshot = true + } +} + +func (service *Service) snapshotEnvironment(endpointID portainer.EndpointID, tunnelPort int) error { + endpoint, err := service.dataStore.Endpoint().Endpoint(endpointID) + if err != nil { + return err + } + + endpoint.URL = fmt.Sprintf("tcp://127.0.0.1:%d", tunnelPort) + + return service.snapshotService.SnapshotEndpoint(endpoint) +} diff --git a/api/chisel/service_test.go b/api/chisel/service_test.go new file mode 100644 index 0000000..fb923b8 --- /dev/null +++ b/api/chisel/service_test.go @@ -0,0 +1,238 @@ +package chisel + +import ( + "context" + "errors" + "net" + "net/http" + "testing" + "time" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/pkg/fips" + + "github.com/stretchr/testify/require" +) + +func init() { + fips.InitFIPS(false) +} + +type mockSnapshotService struct { + snapshotFn func(endpoint *portainer.Endpoint) error +} + +func (m *mockSnapshotService) Start(_ context.Context) {} + +func (m *mockSnapshotService) SetSnapshotInterval(_ string) error { return nil } + +func (m *mockSnapshotService) SnapshotEndpoint(endpoint *portainer.Endpoint) error { + if m.snapshotFn != nil { + return m.snapshotFn(endpoint) + } + + return nil +} + +func (m *mockSnapshotService) FillSnapshotData(_ *portainer.Endpoint, _ bool) error { return nil } + +func newEdgeEndpoint(id portainer.EndpointID) *portainer.Endpoint { + return &portainer.Endpoint{ + ID: id, + EdgeID: "test-edge-id", + Type: portainer.EdgeAgentOnDockerEnvironment, + UserTrusted: true, + } +} + +func TestPingAgentPanic(t *testing.T) { + t.Parallel() + endpoint := newEdgeEndpoint(1) + + _, store := datastore.MustNewTestStore(t, false, true) + + s := NewService(store, nil, nil) + + defer func() { + require.Nil(t, recover()) + }() + + mux := http.NewServeMux() + mux.HandleFunc("/ping", func(w http.ResponseWriter, r *http.Request) { + time.Sleep(pingTimeout + 1*time.Second) + }) + + ln, err := net.ListenTCP("tcp", &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0}) + require.NoError(t, err) + + srv := &http.Server{Handler: mux} + + errCh := make(chan error) + go func() { + errCh <- srv.Serve(ln) + }() + + err = s.Open(endpoint) + require.NoError(t, err) + s.activeTunnels[endpoint.ID].Port = ln.Addr().(*net.TCPAddr).Port + + require.Error(t, s.pingAgent(endpoint.ID)) + require.NoError(t, srv.Shutdown(t.Context())) + require.ErrorIs(t, <-errCh, http.ErrServerClosed) +} + +func TestOpenDefaultsHasSnapshotToFalse(t *testing.T) { + t.Parallel() + + endpoint := newEdgeEndpoint(1) + _, store := datastore.MustNewTestStore(t, false, true) + + s := NewService(store, nil, nil) + + err := s.Open(endpoint) + require.NoError(t, err) + + require.False(t, s.activeTunnels[endpoint.ID].HasSnapshot) +} + +func TestCheckTunnelsSetsHasSnapshotWhenSnapshotExists(t *testing.T) { + t.Parallel() + + endpoint := newEdgeEndpoint(2) + _, store := datastore.MustNewTestStore(t, false, true) + + err := store.Endpoint().Create(endpoint) + require.NoError(t, err) + + snap := &portainer.Snapshot{ + EndpointID: endpoint.ID, + Docker: &portainer.DockerSnapshot{}, + } + err = store.Snapshot().Create(snap) + require.NoError(t, err) + + s := NewService(store, nil, nil) + s.activeTunnels[endpoint.ID] = &portainer.TunnelDetails{ + Status: portainer.EdgeAgentManagementRequired, + Port: 50003, + LastActivity: time.Now(), + } + + s.checkTunnels() + + require.NotNil(t, s.activeTunnels[endpoint.ID], "tunnel must remain open") + require.True(t, s.activeTunnels[endpoint.ID].HasSnapshot) +} + +func TestCheckTunnelsSnapshotsActiveEnvironmentAndKeepsTunnelAlive(t *testing.T) { + t.Parallel() + + endpoint := newEdgeEndpoint(3) + _, store := datastore.MustNewTestStore(t, false, true) + + err := store.Endpoint().Create(endpoint) + require.NoError(t, err) + + snapshotCalled := false + svc := &mockSnapshotService{ + snapshotFn: func(_ *portainer.Endpoint) error { + snapshotCalled = true + + return nil + }, + } + + s := NewService(store, nil, nil) + s.snapshotService = svc + s.activeTunnels[endpoint.ID] = &portainer.TunnelDetails{ + Status: portainer.EdgeAgentManagementRequired, + Port: 50000, + LastActivity: time.Now(), + } + + s.checkTunnels() + + require.True(t, snapshotCalled) + require.NotNil(t, s.activeTunnels[endpoint.ID], "tunnel must remain open after snapshot") + require.True(t, s.activeTunnels[endpoint.ID].HasSnapshot) +} + +func TestCheckTunnelsKeepsHasSnapshotFalseOnSnapshotFailure(t *testing.T) { + t.Parallel() + + endpoint := newEdgeEndpoint(4) + _, store := datastore.MustNewTestStore(t, false, true) + + err := store.Endpoint().Create(endpoint) + require.NoError(t, err) + + svc := &mockSnapshotService{ + snapshotFn: func(_ *portainer.Endpoint) error { + return errors.New("snapshot failed") + }, + } + + s := NewService(store, nil, nil) + s.snapshotService = svc + s.activeTunnels[endpoint.ID] = &portainer.TunnelDetails{ + Status: portainer.EdgeAgentManagementRequired, + Port: 50001, + LastActivity: time.Now(), + } + + s.checkTunnels() + + require.NotNil(t, s.activeTunnels[endpoint.ID], "tunnel must remain open after failed snapshot") + require.False(t, s.activeTunnels[endpoint.ID].HasSnapshot, "HasSnapshot must stay false after failure") +} + +func TestCheckTunnelsClosesStaleEntryForDeletedEndpoint(t *testing.T) { + t.Parallel() + + _, store := datastore.MustNewTestStore(t, false, true) + + // Endpoint is not created in the store, simulates deletion while tunnel stays open. + s := NewService(store, nil, nil) + s.activeTunnels[1] = &portainer.TunnelDetails{ + Status: portainer.EdgeAgentManagementRequired, + Port: 50010, + LastActivity: time.Now(), + } + + s.checkTunnels() + + require.Nil(t, s.activeTunnels[1], "stale tunnel for deleted endpoint must be removed immediately") +} + +func TestCheckTunnelsClosesIdleTunnelAndSnapshots(t *testing.T) { + t.Parallel() + + endpoint := newEdgeEndpoint(5) + _, store := datastore.MustNewTestStore(t, false, true) + + err := store.Endpoint().Create(endpoint) + require.NoError(t, err) + + snapshotCalled := false + svc := &mockSnapshotService{ + snapshotFn: func(_ *portainer.Endpoint) error { + snapshotCalled = true + + return nil + }, + } + + s := NewService(store, nil, nil) + s.snapshotService = svc + s.activeTunnels[endpoint.ID] = &portainer.TunnelDetails{ + Status: portainer.EdgeAgentManagementRequired, + Port: 50002, + LastActivity: time.Now().Add(-(activeTimeout + time.Second)), + } + + s.checkTunnels() + + require.True(t, snapshotCalled) + require.Nil(t, s.activeTunnels[endpoint.ID], "tunnel must be closed after idle timeout") +} diff --git a/api/chisel/tunnel.go b/api/chisel/tunnel.go new file mode 100644 index 0000000..fa321d7 --- /dev/null +++ b/api/chisel/tunnel.go @@ -0,0 +1,256 @@ +package chisel + +import ( + "encoding/base64" + "errors" + "fmt" + "net" + "strings" + "time" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/internal/edge" + "github.com/portainer/portainer/api/internal/edge/cache" + "github.com/portainer/portainer/api/internal/endpointutils" + "github.com/portainer/portainer/pkg/libcrypto" + "github.com/portainer/portainer/pkg/librand" + + "github.com/dchest/uniuri" + "github.com/rs/zerolog/log" +) + +const ( + minAvailablePort = 49152 + maxAvailablePort = 65535 +) + +var ( + ErrNonEdgeEnv = errors.New("cannot open a tunnel for non-edge environments") + ErrAsyncEnv = errors.New("cannot open a tunnel for async edge environments") + ErrInvalidEnv = errors.New("cannot open a tunnel for an invalid environment") +) + +// Open will mark the tunnel as REQUIRED so the agent opens it +func (s *Service) Open(endpoint *portainer.Endpoint) error { + if !endpointutils.IsEdgeEndpoint(endpoint) { + return ErrNonEdgeEnv + } + + if endpoint.Edge.AsyncMode { + return ErrAsyncEnv + } + + if endpoint.ID == 0 || endpoint.EdgeID == "" || !endpoint.UserTrusted { + return ErrInvalidEnv + } + + s.mu.Lock() + defer s.mu.Unlock() + + if _, ok := s.activeTunnels[endpoint.ID]; ok { + return nil + } + + defer cache.Del(endpoint.ID) + + tun := &portainer.TunnelDetails{ + Status: portainer.EdgeAgentManagementRequired, + Port: s.getUnusedPort(), + LastActivity: time.Now(), + } + + username, password := generateRandomCredentials() + + if s.chiselServer != nil { + authorizedRemote := fmt.Sprintf("^R:0.0.0.0:%d$", tun.Port) + + if err := s.chiselServer.AddUser(username, password, authorizedRemote); err != nil { + return err + } + } + + credentials, err := encryptCredentials(username, password, endpoint.EdgeID) + if err != nil { + return err + } + + tun.Credentials = credentials + + s.activeTunnels[endpoint.ID] = tun + + return nil +} + +// close removes the tunnel from the map so the agent will close it. +// The lock is released before cleaning up the chisel user and proxy to avoid +// blocking Config/Open callers while DeleteUser interacts with chisel internals. +func (s *Service) close(endpointID portainer.EndpointID) { + s.mu.Lock() + + tun, ok := s.activeTunnels[endpointID] + if !ok { + s.mu.Unlock() + return + } + + delete(s.activeTunnels, endpointID) + cache.Del(endpointID) + + s.mu.Unlock() + + if s.chiselServer != nil { + user, _, _ := strings.Cut(tun.Credentials, ":") + s.chiselServer.DeleteUser(user) + } + + if s.ProxyManager != nil { + s.ProxyManager.DeleteEndpointProxy(endpointID) + } +} + +// Config returns the tunnel details needed for the agent to connect +func (s *Service) Config(endpointID portainer.EndpointID) portainer.TunnelDetails { + s.mu.RLock() + defer s.mu.RUnlock() + + if tun, ok := s.activeTunnels[endpointID]; ok { + return *tun + } + + return portainer.TunnelDetails{Status: portainer.EdgeAgentIdle} +} + +// TunnelAddr returns the address of the local tunnel, including the port, it +// will block until the tunnel is ready +func (s *Service) TunnelAddr(endpoint *portainer.Endpoint) (string, error) { + if err := s.Open(endpoint); err != nil { + return "", err + } + + tun := s.Config(endpoint.ID) + checkinInterval := time.Duration(s.tryEffectiveCheckinInterval(endpoint)) * time.Second + + for t0 := time.Now(); ; { + if time.Since(t0) > 2*checkinInterval { + return "", errors.New("unable to open the tunnel") + } + + // Check if the tunnel is established + conn, err := net.DialTCP("tcp", nil, &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: tun.Port}) + if err != nil { + time.Sleep(checkinInterval / 100) + + continue + } + + if err := conn.Close(); err != nil { + log.Warn().Err(err).Msg("failed to close tcp connection") + } + + break + } + + s.UpdateLastActivity(endpoint.ID) + + return fmt.Sprintf("127.0.0.1:%d", tun.Port), nil +} + +// tryEffectiveCheckinInterval avoids a potential deadlock by returning a +// previous known value after a timeout +func (s *Service) tryEffectiveCheckinInterval(endpoint *portainer.Endpoint) int { + ch := make(chan int, 1) + + go func() { + ch <- edge.EffectiveCheckinInterval(s.dataStore, endpoint) + }() + + select { + case <-time.After(50 * time.Millisecond): + s.mu.RLock() + defer s.mu.RUnlock() + + return s.defaultCheckinInterval + case i := <-ch: + s.mu.Lock() + s.defaultCheckinInterval = i + s.mu.Unlock() + + return i + } +} + +// UpdateLastActivity sets the current timestamp to avoid the tunnel timeout +func (s *Service) UpdateLastActivity(endpointID portainer.EndpointID) { + s.mu.Lock() + defer s.mu.Unlock() + + if tun, ok := s.activeTunnels[endpointID]; ok { + tun.LastActivity = time.Now() + } +} + +// NOTE: it needs to be called with the lock acquired +// getUnusedPort is used to generate an unused random port in the dynamic port range. +// Dynamic ports (also called private ports) are 49152 to 65535. +func (service *Service) getUnusedPort() int { + port := randomInt(minAvailablePort, maxAvailablePort) + + for _, tunnel := range service.activeTunnels { + if tunnel.Port == port { + return service.getUnusedPort() + } + } + + conn, err := net.DialTCP("tcp", nil, &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: port}) + if err == nil { + if err := conn.Close(); err != nil { + log.Warn().Msg("failed to close tcp connection that checks if port is free") + } + + log.Debug(). + Int("port", port). + Msg("selected port is in use, trying a different one") + + return service.getUnusedPort() + } + + return port +} + +func randomInt(min, max int) int { + return min + librand.Intn(max-min) +} + +func generateRandomCredentials() (string, string) { + username := uniuri.NewLen(8) + password := uniuri.NewLen(8) + + return username, password +} + +func encryptCredentials(username, password, key string) (string, error) { + credentials := fmt.Sprintf("%s:%s", username, password) + + encryptedCredentials, err := libcrypto.Encrypt([]byte(credentials), []byte(key)) + if err != nil { + return "", err + } + + return base64.RawStdEncoding.EncodeToString(encryptedCredentials), nil +} + +func endpointHasSnapshot(dataStore dataservices.DataStore, endpointID portainer.EndpointID) bool { + var hasSnapshot bool + _ = dataStore.ViewTx(func(tx dataservices.DataStoreTx) error { + s, err := tx.Snapshot().Read(endpointID) + if err != nil { + return err + } + + hasSnapshot = s.Docker != nil || s.Kubernetes != nil + return nil + }) + + return hasSnapshot +} diff --git a/api/chisel/tunnel_test.go b/api/chisel/tunnel_test.go new file mode 100644 index 0000000..079d5db --- /dev/null +++ b/api/chisel/tunnel_test.go @@ -0,0 +1,80 @@ +package chisel + +import ( + "net" + "strings" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" +) + +type testSettingsService struct { + dataservices.SettingsService +} + +func (s *testSettingsService) Settings() (*portainer.Settings, error) { + return &portainer.Settings{ + EdgeAgentCheckinInterval: 1, + }, nil +} + +type testStore struct { + dataservices.DataStore +} + +func (s *testStore) Settings() dataservices.SettingsService { + return &testSettingsService{} +} + +func TestGetUnusedPort(t *testing.T) { + t.Parallel() + testCases := []struct { + name string + existingTunnels map[portainer.EndpointID]*portainer.TunnelDetails + expectedError error + }{ + { + name: "simple case", + }, + { + name: "existing tunnels", + existingTunnels: map[portainer.EndpointID]*portainer.TunnelDetails{ + portainer.EndpointID(1): { + Port: 53072, + }, + portainer.EndpointID(2): { + Port: 63072, + }, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + store := &testStore{} + s := NewService(store, nil, nil) + s.activeTunnels = tc.existingTunnels + port := s.getUnusedPort() + + if port < 49152 || port > 65535 { + t.Fatalf("Expected port to be inbetween 49152 and 65535 but got %d", port) + } + + for _, tun := range tc.existingTunnels { + if tun.Port == port { + t.Fatalf("returned port %d already has an existing tunnel", port) + } + } + + conn, err := net.DialTCP("tcp", nil, &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: port}) + if err == nil { + // Ignore error + _ = conn.Close() + t.Fatalf("expected port %d to be unused", port) + } else if !strings.Contains(err.Error(), "connection refused") { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} diff --git a/api/cli/cli.go b/api/cli/cli.go new file mode 100644 index 0000000..d7fa93c --- /dev/null +++ b/api/cli/cli.go @@ -0,0 +1,220 @@ +package cli + +import ( + "errors" + "os" + "path/filepath" + "strings" + "time" + + portainer "github.com/portainer/portainer/api" + + "github.com/alecthomas/kingpin/v2" + "github.com/rs/zerolog/log" +) + +// Service implements the CLIService interface +type Service struct{} + +var ( + ErrInvalidEndpointProtocol = errors.New("Invalid environment 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") +) + +func CLIFlags() *portainer.CLIFlags { + return &portainer.CLIFlags{ + Addr: kingpin.Flag("bind", "Address and port to serve Portainer").Default(defaultBindAddress).Short('p').String(), + AddrHTTPS: kingpin.Flag("bind-https", "Address and port to serve Portainer via https").Default(defaultHTTPSBindAddress).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", "Environment URL").Short('H').String(), + FeatureFlags: kingpin.Flag("feat", "List of feature flags").Envar(portainer.FeatureFlagEnvVar).Strings(), + EnableEdgeComputeFeatures: kingpin.Flag("edge-compute", "Enable Edge Compute features").Bool(), + NoAnalytics: kingpin.Flag("no-analytics", "Disable Analytics in app (deprecated)").Bool(), + TLSSkipVerify: kingpin.Flag("tlsskipverify", "Disable TLS server verification").Default(defaultTLSSkipVerify).Bool(), + HTTPDisabled: kingpin.Flag("http-disabled", "Serve portainer only on https").Default(defaultHTTPDisabled).Bool(), + HTTPEnabled: kingpin.Flag("http-enabled", "Serve portainer on http").Default(defaultHTTPEnabled).Bool(), + Rollback: kingpin.Flag("rollback", "Rollback the database to the previous backup").Bool(), + SnapshotInterval: kingpin.Flag("snapshot-interval", "Duration between each environment snapshot job").String(), + AdminPassword: kingpin.Flag("admin-password", "Set admin password with provided hash").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(), + BaseURL: kingpin.Flag("base-url", "Base URL parameter such as portainer if running portainer as http://yourdomain.com/portainer/.").Short('b').Default(defaultBaseURL).String(), + InitialMmapSize: kingpin.Flag("initial-mmap-size", "Initial mmap size of the database in bytes").Int(), + MaxBatchSize: kingpin.Flag("max-batch-size", "Maximum size of a batch").Int(), + MaxBatchDelay: kingpin.Flag("max-batch-delay", "Maximum delay before a batch starts").Duration(), + SecretKeyName: kingpin.Flag("secret-key-name", "Secret key name for encryption and will be used as /run/secrets/.").Default(defaultSecretKeyName).String(), + LogLevel: kingpin.Flag("log-level", "Set the minimum logging level to show").Default("INFO").Enum("DEBUG", "INFO", "WARN", "ERROR"), + LogMode: kingpin.Flag("log-mode", "Set the logging output mode").Default("PRETTY").Enum("NOCOLOR", "PRETTY", "JSON"), + PullLimitCheckDisabled: kingpin.Flag("pull-limit-check-disabled", "Pull limit check").Envar(portainer.PullLimitCheckDisabledEnvVar).Default(defaultPullLimitCheckDisabled).Bool(), + TrustedOrigins: kingpin.Flag("trusted-origins", "List of trusted origins for CSRF protection. Separate multiple origins with a comma.").Envar(portainer.TrustedOriginsEnvVar).String(), + CSP: kingpin.Flag("csp", "Content Security Policy (CSP) header").Envar(portainer.CSPEnvVar).Default("true").Bool(), + CompactDB: kingpin.Flag("compact-db", "Enable database compaction on startup").Envar(portainer.CompactDBEnvVar).Default("false").Bool(), + NoSetupToken: kingpin.Flag("no-setup-token", "Disable the setup token requirement for admin initialization and restore on an uninitialized instance").Envar(portainer.NoSetupTokenEnvVar).Bool(), + SetupToken: kingpin.Flag("setup-token", "Set a custom setup token for admin initialization and restore on an uninitialized instance (overrides auto-generation)").Envar(portainer.SetupTokenEnvVar).String(), + } +} + +// ParseFlags parse the CLI flags and return a portainer.Flags struct +func (Service) ParseFlags(version string) (*portainer.CLIFlags, error) { + kingpin.Version(version) + + var hasSSLFlag, hasSSLCertFlag, hasSSLKeyFlag bool + sslFlag := kingpin.Flag( + "ssl", + "Secure Portainer instance using SSL (deprecated)", + ).Default(defaultSSL).IsSetByUser(&hasSSLFlag) + ssl := sslFlag.Bool() + sslCertFlag := kingpin.Flag( + "sslcert", + "Path to the SSL certificate used to secure the Portainer instance", + ).IsSetByUser(&hasSSLCertFlag) + sslCert := sslCertFlag.String() + sslKeyFlag := kingpin.Flag( + "sslkey", + "Path to the SSL key used to secure the Portainer instance", + ).IsSetByUser(&hasSSLKeyFlag) + sslKey := sslKeyFlag.String() + + flags := CLIFlags() + + var hasTLSFlag, hasTLSCertFlag, hasTLSKeyFlag bool + tlsFlag := kingpin.Flag("tlsverify", "TLS support").Default(defaultTLS).IsSetByUser(&hasTLSFlag) + flags.TLS = tlsFlag.Bool() + tlsCertFlag := kingpin.Flag( + "tlscert", + "Path to the TLS certificate file", + ).Default(defaultTLSCertPath).IsSetByUser(&hasTLSCertFlag) + flags.TLSCert = tlsCertFlag.String() + tlsKeyFlag := kingpin.Flag("tlskey", "Path to the TLS key").Default(defaultTLSKeyPath).IsSetByUser(&hasTLSKeyFlag) + flags.TLSKey = tlsKeyFlag.String() + flags.TLSCacert = kingpin.Flag("tlscacert", "Path to the CA").Default(defaultTLSCACertPath).String() + + var hasKubectlShellImageFlag bool + kubectlShellImageFlag := kingpin.Flag( + "kubectl-shell-image", + "Kubectl shell image", + ).Envar(portainer.KubectlShellImageEnvVar). + Default(portainer.DefaultKubectlShellImage). + IsSetByUser(&hasKubectlShellImageFlag) + flags.KubectlShellImage = kubectlShellImageFlag.String() + + kingpin.Parse() + + _, kubectlShellImageEnvVarSet := os.LookupEnv(portainer.KubectlShellImageEnvVar) + flags.KubectlShellImageSet = hasKubectlShellImageFlag || kubectlShellImageEnvVarSet + + if !filepath.IsAbs(*flags.Assets) { + ex, err := os.Executable() + if err != nil { + panic(err) + } + + *flags.Assets = filepath.Join(filepath.Dir(ex), *flags.Assets) + } + + // If the user didn't provide a tls flag remove the defaults to match previous behaviour + if !hasTLSFlag { + if !hasTLSCertFlag { + *flags.TLSCert = "" + } + + if !hasTLSKeyFlag { + *flags.TLSKey = "" + } + } + + if hasSSLFlag { + log.Warn().Msgf("the %q flag is deprecated. use %q instead.", sslFlag.Model().Name, tlsFlag.Model().Name) + + if !hasTLSFlag { + flags.TLS = ssl + } + } + + if hasSSLCertFlag { + log.Warn().Msgf("the %q flag is deprecated. use %q instead.", sslCertFlag.Model().Name, tlsCertFlag.Model().Name) + + if !hasTLSCertFlag { + flags.TLSCert = sslCert + } + } + + if hasSSLKeyFlag { + log.Warn().Msgf("the %q flag is deprecated. use %q instead.", sslKeyFlag.Model().Name, tlsKeyFlag.Model().Name) + + if !hasTLSKeyFlag { + flags.TLSKey = sslKey + } + } + + return flags, nil +} + +// ValidateFlags validates the values of the flags. +func (Service) ValidateFlags(flags *portainer.CLIFlags) error { + displayDeprecationWarnings(flags) + + if err := ValidateEndpointURL(*flags.EndpointURL); err != nil { + return err + } + + if err := ValidateSnapshotInterval(*flags.SnapshotInterval); err != nil { + return err + } + + if *flags.AdminPassword != "" && *flags.AdminPasswordFile != "" { + return ErrAdminPassExcludeAdminPassFile + } + + return nil +} + +func displayDeprecationWarnings(flags *portainer.CLIFlags) { + if *flags.NoAnalytics { + log.Warn().Msg("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") + } +} + +func ValidateEndpointURL(endpointURL string) error { + if endpointURL == "" { + return nil + } + + if !strings.HasPrefix(endpointURL, "unix://") && !strings.HasPrefix(endpointURL, "tcp://") && !strings.HasPrefix(endpointURL, "npipe://") { + return ErrInvalidEndpointProtocol + } + + if strings.HasPrefix(endpointURL, "unix://") || strings.HasPrefix(endpointURL, "npipe://") { + socketPath := strings.TrimPrefix(endpointURL, "unix://") + socketPath = strings.TrimPrefix(socketPath, "npipe://") + + if _, err := os.Stat(socketPath); err != nil { + if os.IsNotExist(err) { + return ErrSocketOrNamedPipeNotFound + } + + return err + } + } + + return nil +} + +func ValidateSnapshotInterval(snapshotInterval string) error { + if snapshotInterval == "" { + return nil + } + + if _, err := time.ParseDuration(snapshotInterval); err != nil { + return ErrInvalidSnapshotInterval + } + + return nil +} diff --git a/api/cli/cli_test.go b/api/cli/cli_test.go new file mode 100644 index 0000000..1480fb9 --- /dev/null +++ b/api/cli/cli_test.go @@ -0,0 +1,263 @@ +package cli + +import ( + "io" + "os" + "strings" + "testing" + + portainer "github.com/portainer/portainer/api" + zerolog "github.com/rs/zerolog/log" + "github.com/stretchr/testify/require" +) + +func TestOptionParser(t *testing.T) { + p := Service{} + require.NotNil(t, p) + + a := os.Args + defer func() { os.Args = a }() + + os.Args = []string{"portainer", "--edge-compute"} + + opts, err := p.ParseFlags("2.34.5") + require.NoError(t, err) + + require.False(t, *opts.HTTPDisabled) + require.True(t, *opts.EnableEdgeComputeFeatures) +} + +func TestParseKubectlShellImageFlag(t *testing.T) { + tests := []struct { + name string + args []string + envVars map[string]string + expectedKubectlShellImageSet bool + expectedKubectlShellFlag string + }{ + { + name: "no flag, no env var", + expectedKubectlShellImageSet: false, + expectedKubectlShellFlag: portainer.DefaultKubectlShellImage, + }, + { + name: "explicit flag", + args: []string{"portainer", "--kubectl-shell-image=myimage:v2"}, + expectedKubectlShellImageSet: true, + expectedKubectlShellFlag: "myimage:v2", + }, + { + name: "env var", + envVars: map[string]string{portainer.KubectlShellImageEnvVar: "myimage:v3"}, + expectedKubectlShellImageSet: true, + expectedKubectlShellFlag: "myimage:v3", + }, + { + name: "both env var and flag set", + args: []string{"portainer", "--kubectl-shell-image=myimage:v2"}, + envVars: map[string]string{portainer.KubectlShellImageEnvVar: "myimage:v3"}, + expectedKubectlShellImageSet: true, + expectedKubectlShellFlag: "myimage:v2", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if tc.args == nil { + tc.args = []string{"portainer"} + } + setOsArgs(t, tc.args) + + for k, v := range tc.envVars { + t.Setenv(k, v) + } + + flags, err := Service{}.ParseFlags("test-version") + require.NoError(t, err) + require.Equal(t, tc.expectedKubectlShellImageSet, flags.KubectlShellImageSet) + require.Equal(t, tc.expectedKubectlShellFlag, *flags.KubectlShellImage) + }) + } +} + +func TestParseTLSFlags(t *testing.T) { + testCases := []struct { + name string + args []string + expectedTLSFlag bool + expectedTLSCertFlag string + expectedTLSKeyFlag string + expectedLogMessages []string + }{ + { + name: "no flags", + expectedTLSFlag: false, + expectedTLSCertFlag: "", + expectedTLSKeyFlag: "", + }, + { + name: "only ssl flag", + args: []string{ + "portainer", + "--ssl", + }, + expectedTLSFlag: true, + expectedTLSCertFlag: "", + expectedTLSKeyFlag: "", + }, + { + name: "only tls flag", + args: []string{ + "portainer", + "--tlsverify", + }, + expectedTLSFlag: true, + expectedTLSCertFlag: defaultTLSCertPath, + expectedTLSKeyFlag: defaultTLSKeyPath, + }, + { + name: "partial ssl flags", + args: []string{ + "portainer", + "--ssl", + "--sslcert=ssl-cert-flag-value", + }, + expectedTLSFlag: true, + expectedTLSCertFlag: "ssl-cert-flag-value", + expectedTLSKeyFlag: "", + }, + { + name: "partial tls flags", + args: []string{ + "portainer", + "--tlsverify", + "--tlscert=tls-cert-flag-value", + }, + expectedTLSFlag: true, + expectedTLSCertFlag: "tls-cert-flag-value", + expectedTLSKeyFlag: defaultTLSKeyPath, + }, + { + name: "partial tls and ssl flags", + args: []string{ + "portainer", + "--tlsverify", + "--tlscert=tls-cert-flag-value", + "--sslkey=ssl-key-flag-value", + }, + expectedTLSFlag: true, + expectedTLSCertFlag: "tls-cert-flag-value", + expectedTLSKeyFlag: "ssl-key-flag-value", + }, + { + name: "partial tls and ssl flags 2", + args: []string{ + "portainer", + "--ssl", + "--tlscert=tls-cert-flag-value", + "--sslkey=ssl-key-flag-value", + }, + expectedTLSFlag: true, + expectedTLSCertFlag: "tls-cert-flag-value", + expectedTLSKeyFlag: "ssl-key-flag-value", + }, + { + name: "ssl flags", + args: []string{ + "portainer", + "--ssl", + "--sslcert=ssl-cert-flag-value", + "--sslkey=ssl-key-flag-value", + }, + expectedTLSFlag: true, + expectedTLSCertFlag: "ssl-cert-flag-value", + expectedTLSKeyFlag: "ssl-key-flag-value", + expectedLogMessages: []string{ + "the \\\"ssl\\\" flag is deprecated. use \\\"tlsverify\\\" instead.", + "the \\\"sslcert\\\" flag is deprecated. use \\\"tlscert\\\" instead.", + "the \\\"sslkey\\\" flag is deprecated. use \\\"tlskey\\\" instead.", + }, + }, + { + name: "tls flags", + args: []string{ + "portainer", + "--tlsverify", + "--tlscert=tls-cert-flag-value", + "--tlskey=tls-key-flag-value", + }, + expectedTLSFlag: true, + expectedTLSCertFlag: "tls-cert-flag-value", + expectedTLSKeyFlag: "tls-key-flag-value", + }, + { + name: "tls and ssl flags", + args: []string{ + "portainer", + "--tlsverify", + "--tlscert=tls-cert-flag-value", + "--tlskey=tls-key-flag-value", + "--ssl", + "--sslcert=ssl-cert-flag-value", + "--sslkey=ssl-key-flag-value", + }, + expectedTLSFlag: true, + expectedTLSCertFlag: "tls-cert-flag-value", + expectedTLSKeyFlag: "tls-key-flag-value", + expectedLogMessages: []string{ + "the \\\"ssl\\\" flag is deprecated. use \\\"tlsverify\\\" instead.", + "the \\\"sslcert\\\" flag is deprecated. use \\\"tlscert\\\" instead.", + "the \\\"sslkey\\\" flag is deprecated. use \\\"tlskey\\\" instead.", + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + var logOutput strings.Builder + setupLogOutput(t, &logOutput) + + if tc.args == nil { + tc.args = []string{"portainer"} + } + setOsArgs(t, tc.args) + + s := Service{} + flags, err := s.ParseFlags("test-version") + if err != nil { + t.Fatalf("error parsing flags: %v", err) + } + + if flags.TLS == nil { + t.Fatal("TLS flag was nil") + } + + require.Equal(t, tc.expectedTLSFlag, *flags.TLS, "tlsverify flag didn't match") + require.Equal(t, tc.expectedTLSCertFlag, *flags.TLSCert, "tlscert flag didn't match") + require.Equal(t, tc.expectedTLSKeyFlag, *flags.TLSKey, "tlskey flag didn't match") + + for _, expectedLogMessage := range tc.expectedLogMessages { + require.Contains(t, logOutput.String(), expectedLogMessage, "Log didn't contain expected message") + } + }) + } +} + +func setOsArgs(t *testing.T, args []string) { + t.Helper() + previousArgs := os.Args + os.Args = args + t.Cleanup(func() { + os.Args = previousArgs + }) +} + +func setupLogOutput(t *testing.T, w io.Writer) { + t.Helper() + + oldLogger := zerolog.Logger + zerolog.Logger = zerolog.Output(w) + t.Cleanup(func() { + zerolog.Logger = oldLogger + }) +} diff --git a/api/cli/confirm.go b/api/cli/confirm.go new file mode 100644 index 0000000..0681f5c --- /dev/null +++ b/api/cli/confirm.go @@ -0,0 +1,23 @@ +package cli + +import ( + "bufio" + "fmt" + "os" + "strings" +) + +// Confirm starts a rollback db cli application +func Confirm(message string) (bool, error) { + fmt.Printf("%s [y/N] ", message) + + reader := bufio.NewReader(os.Stdin) + + answer, err := reader.ReadString('\n') + if err != nil { + return false, err + } + + answer = strings.ReplaceAll(answer, "\n", "") + return strings.EqualFold(answer, "y") || strings.EqualFold(answer, "yes"), nil +} diff --git a/api/cli/defaults.go b/api/cli/defaults.go new file mode 100644 index 0000000..23af32a --- /dev/null +++ b/api/cli/defaults.go @@ -0,0 +1,23 @@ +//go:build !windows + +package cli + +const ( + defaultBindAddress = ":9000" + defaultHTTPSBindAddress = ":9443" + defaultTunnelServerAddress = "0.0.0.0" + defaultTunnelServerPort = "8000" + defaultDataDirectory = "/data" + defaultAssetsDirectory = "./" + defaultTLS = "false" + defaultTLSSkipVerify = "false" + defaultTLSCACertPath = "/certs/ca.pem" + defaultTLSCertPath = "/certs/cert.pem" + defaultTLSKeyPath = "/certs/key.pem" + defaultHTTPDisabled = "false" + defaultHTTPEnabled = "false" + defaultSSL = "false" + defaultBaseURL = "/" + defaultSecretKeyName = "portainer" + defaultPullLimitCheckDisabled = "false" +) diff --git a/api/cli/defaults_windows.go b/api/cli/defaults_windows.go new file mode 100644 index 0000000..a9d02da --- /dev/null +++ b/api/cli/defaults_windows.go @@ -0,0 +1,22 @@ +package cli + +const ( + defaultBindAddress = ":9000" + defaultHTTPSBindAddress = ":9443" + defaultTunnelServerAddress = "0.0.0.0" + defaultTunnelServerPort = "8000" + defaultDataDirectory = "C:\\data" + defaultAssetsDirectory = "./" + defaultTLS = "false" + defaultTLSSkipVerify = "false" + defaultTLSCACertPath = "C:\\certs\\ca.pem" + defaultTLSCertPath = "C:\\certs\\cert.pem" + defaultTLSKeyPath = "C:\\certs\\key.pem" + defaultHTTPDisabled = "false" + defaultHTTPEnabled = "false" + defaultSSL = "false" + defaultSnapshotInterval = "5m" + defaultBaseURL = "/" + defaultSecretKeyName = "portainer" + defaultPullLimitCheckDisabled = "false" +) diff --git a/api/cli/pairlist.go b/api/cli/pairlist.go new file mode 100644 index 0000000..ded08bf --- /dev/null +++ b/api/cli/pairlist.go @@ -0,0 +1,41 @@ +package cli + +import ( + portainer "github.com/portainer/portainer/api" + + "fmt" + "strings" + + "github.com/alecthomas/kingpin/v2" +) + +type pairList []portainer.Pair + +// Set implementation for a list of portainer.Pair +func (l *pairList) Set(value string) error { + parts := strings.SplitN(value, "=", 2) + if len(parts) != 2 { + return fmt.Errorf("expected NAME=VALUE got '%s'", value) + } + p := new(portainer.Pair) + p.Name = parts[0] + p.Value = parts[1] + *l = append(*l, *p) + return nil +} + +// String implementation for a list of pair +func (l *pairList) String() string { + return "" +} + +// IsCumulative implementation for a list of pair +func (l *pairList) IsCumulative() bool { + return true +} + +func pairs(s kingpin.Settings) (target *[]portainer.Pair) { + target = new([]portainer.Pair) + s.SetValue((*pairList)(target)) + return +} diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go new file mode 100644 index 0000000..d5104db --- /dev/null +++ b/api/cmd/portainer/main.go @@ -0,0 +1,728 @@ +package main + +import ( + "cmp" + "context" + "crypto/sha256" + nethttp "net/http" + "os" + "path" + "strings" + "time" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/apikey" + "github.com/portainer/portainer/api/chisel" + "github.com/portainer/portainer/api/cli" + "github.com/portainer/portainer/api/crypto" + "github.com/portainer/portainer/api/database" + "github.com/portainer/portainer/api/database/boltdb" + "github.com/portainer/portainer/api/database/models" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/datastore/migrator" + "github.com/portainer/portainer/api/datastore/postinit" + "github.com/portainer/portainer/api/docker" + dockerclient "github.com/portainer/portainer/api/docker/client" + "github.com/portainer/portainer/api/exec" + "github.com/portainer/portainer/api/filesystem" + "github.com/portainer/portainer/api/git" + "github.com/portainer/portainer/api/http" + "github.com/portainer/portainer/api/http/proxy" + kubeproxy "github.com/portainer/portainer/api/http/proxy/factory/kubernetes" + "github.com/portainer/portainer/api/http/security/setuptoken" + "github.com/portainer/portainer/api/internal/authorization" + "github.com/portainer/portainer/api/internal/edge/edgestacks" + "github.com/portainer/portainer/api/internal/endpointutils" + "github.com/portainer/portainer/api/internal/snapshot" + "github.com/portainer/portainer/api/internal/ssl" + "github.com/portainer/portainer/api/internal/upgrade" + "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/logs" + "github.com/portainer/portainer/api/oauth" + "github.com/portainer/portainer/api/pendingactions" + "github.com/portainer/portainer/api/pendingactions/actions" + "github.com/portainer/portainer/api/pendingactions/handlers" + "github.com/portainer/portainer/api/platform" + "github.com/portainer/portainer/api/scheduler" + "github.com/portainer/portainer/api/stacks/deployments" + "github.com/portainer/portainer/pkg/build" + "github.com/portainer/portainer/pkg/featureflags" + "github.com/portainer/portainer/pkg/fips" + "github.com/portainer/portainer/pkg/libhelm" + "github.com/portainer/portainer/pkg/libhttp/ssrf" + "github.com/portainer/portainer/pkg/libstack/compose" + libswarm "github.com/portainer/portainer/pkg/libstack/swarm" + "github.com/portainer/portainer/pkg/validate" + + gogitclient "github.com/go-git/go-git/v5/plumbing/transport/client" + gogitraw "github.com/go-git/go-git/v5/plumbing/transport/git" + gogithttp "github.com/go-git/go-git/v5/plumbing/transport/http" + gogitssh "github.com/go-git/go-git/v5/plumbing/transport/ssh" + "github.com/google/uuid" + "github.com/rs/zerolog/log" +) + +func initCLI() *portainer.CLIFlags { + cliService := cli.Service{} + + flags, err := cliService.ParseFlags(portainer.APIVersion) + if err != nil { + log.Fatal().Err(err).Msg("failed parsing flags") + } + + if err := cliService.ValidateFlags(flags); err != nil { + log.Fatal().Err(err).Msg("failed validating flags") + } + + return flags +} + +func initFileService(dataStorePath string) portainer.FileService { + fileService, err := filesystem.NewService(dataStorePath, "") + if err != nil { + log.Fatal().Err(err).Msg("failed creating file service") + } + + return fileService +} + +func initDataStore(flags *portainer.CLIFlags, secretKey []byte, fileService portainer.FileService, shutdownCtx context.Context) dataservices.DataStore { + connection, err := database.NewDatabase("boltdb", *flags.Data, secretKey, *flags.CompactDB) + if err != nil { + log.Fatal().Err(err).Msg("failed creating database connection") + } + + if bconn, ok := connection.(*boltdb.DbConnection); ok { + bconn.MaxBatchSize = *flags.MaxBatchSize + bconn.MaxBatchDelay = *flags.MaxBatchDelay + bconn.InitialMmapSize = *flags.InitialMmapSize + } else { + log.Fatal().Msg("failed creating database connection: expecting a boltdb database type but a different one was received") + } + + store := datastore.NewStore(flags, fileService, connection) + + isNew, err := store.Open() + if err != nil { + log.Fatal().Err(err).Msg("failed opening store") + } + + if *flags.Rollback { + if err := store.Rollback(false); err != nil { + log.Fatal().Err(err).Msg("failed rolling back") + } + + log.Info().Msg("exiting rollback") + os.Exit(0) + } + + // Init sets some defaults - it's basically a migration + if err := store.Init(); err != nil { + log.Fatal().Err(err).Msg("failed initializing data store") + } + + if isNew { + instanceId, err := uuid.NewRandom() + if err != nil { + log.Fatal().Err(err).Msg("failed generating instance id") + } + + migratorInstance := migrator.NewMigrator(&migrator.MigratorParameters{Flags: flags}) + migratorCount := migratorInstance.GetMigratorCountOfCurrentAPIVersion() + + // from MigrateData + v := models.Version{ + SchemaVersion: portainer.APIVersion, + Edition: int(portainer.PortainerCE), + InstanceID: instanceId.String(), + MigratorCount: migratorCount, + } + + if err := store.VersionService.UpdateVersion(&v); err != nil { + log.Fatal().Err(err).Msg("failed to update version") + } + + if err := updateSettingsFromFlags(store, flags); err != nil { + log.Fatal().Err(err).Msg("failed updating settings from flags") + } + } else if err := store.MigrateData(); err != nil { + log.Fatal().Err(err).Msg("failed migration") + } + + if err := updateSettingsFromFlags(store, flags); err != nil { + log.Fatal().Err(err).Msg("failed updating settings from flags") + } + + // this is for the db restore functionality - needs more tests. + go func() { + <-shutdownCtx.Done() + + defer logs.CloseAndLogErr(connection) + }() + + return store +} + +// checkDBSchemaServerVersionMatch checks if the server version matches the db scehma version +func checkDBSchemaServerVersionMatch(dbStore dataservices.DataStore, serverVersion string, serverEdition int) bool { + v, err := dbStore.Version().Version() + if err != nil { + return false + } + + return v.SchemaVersion == serverVersion && v.Edition == serverEdition +} + +func initKubernetesDeployer(kubernetesTokenCacheManager *kubeproxy.TokenCacheManager, kubernetesClientFactory *kubecli.ClientFactory, dataStore dataservices.DataStore, reverseTunnelService portainer.ReverseTunnelService, signatureService portainer.DigitalSignatureService, proxyManager *proxy.Manager) portainer.KubernetesDeployer { + return exec.NewKubernetesDeployer(kubernetesTokenCacheManager, kubernetesClientFactory, dataStore, reverseTunnelService, signatureService, proxyManager) +} + +func initAPIKeyService(datastore dataservices.DataStore) apikey.APIKeyService { + return apikey.NewAPIKeyService(datastore.APIKeyRepository(), datastore.User()) +} + +func initJWTService(userSessionTimeout string, dataStore dataservices.DataStore) (portainer.JWTService, error) { + if userSessionTimeout == "" { + userSessionTimeout = portainer.DefaultUserSessionTimeout + } + + return jwt.NewService(userSessionTimeout, dataStore) +} + +func initDigitalSignatureService() portainer.DigitalSignatureService { + return crypto.NewECDSAService(os.Getenv("AGENT_SECRET")) +} + +func initSSLService(addr, certPath, keyPath string, fileService portainer.FileService, dataStore dataservices.DataStore, shutdownTrigger context.CancelFunc) (*ssl.Service, error) { + slices := strings.Split(addr, ":") + + host := slices[0] + if host == "" { + host = "0.0.0.0" + } + + sslService := ssl.NewService(fileService, dataStore, shutdownTrigger) + + if err := sslService.Init(host, certPath, keyPath); err != nil { + return nil, err + } + + return sslService, nil +} + +func initSnapshotService( + snapshotIntervalFromFlag string, + dataStore dataservices.DataStore, + dockerClientFactory *dockerclient.ClientFactory, + kubernetesClientFactory *kubecli.ClientFactory, + pendingActionsService *pendingactions.PendingActionsService, +) (portainer.SnapshotService, error) { + dockerSnapshotter := docker.NewSnapshotter(dockerClientFactory) + kubernetesSnapshotter := kubernetes.NewSnapshotter(kubernetesClientFactory) + + snapshotService, err := snapshot.NewService(snapshotIntervalFromFlag, dataStore, dockerSnapshotter, kubernetesSnapshotter, pendingActionsService) + if err != nil { + return nil, err + } + + return snapshotService, nil +} + +func resolveSetupToken(tx dataservices.DataStoreTx, providedToken string) (string, error) { + admins, err := tx.User().UsersByRole(portainer.AdministratorRole) + if err != nil { + return "", err + } + if len(admins) > 0 { + return "", nil + } + + if providedToken != "" { + log.Info().Msg("using custom setup token; admin initialization and backup restore require this token in the X-Setup-Token header") + return providedToken, nil + } + + token, err := setuptoken.Generate() + if err != nil { + return "", err + } + + log.Info(). + Str("setup_token", token). + Msg("no administrator account configured; admin initialization and backup restore require this setup token in the X-Setup-Token header. Start with --no-setup-token to disable.") + + return token, nil +} + +func initStatus(instanceID string) *portainer.Status { + return &portainer.Status{ + Version: portainer.APIVersion, + InstanceID: instanceID, + } +} + +func updateSettingsFromFlags(dataStore dataservices.DataStore, flags *portainer.CLIFlags) error { + settings, err := dataStore.Settings().Settings() + if err != nil { + return err + } + + settings.SnapshotInterval = cmp.Or(*flags.SnapshotInterval, settings.SnapshotInterval) + settings.LogoURL = cmp.Or(*flags.Logo, settings.LogoURL) + settings.EnableEdgeComputeFeatures = cmp.Or(*flags.EnableEdgeComputeFeatures, settings.EnableEdgeComputeFeatures) + settings.TemplatesURL = cmp.Or(*flags.Templates, settings.TemplatesURL) + + if flags.KubectlShellImageSet { + settings.KubectlShellImage = *flags.KubectlShellImage + } + + if *flags.Labels != nil { + settings.BlackListedLabels = *flags.Labels + } + + settings.AgentSecret = "" + if agentKey, ok := os.LookupEnv("AGENT_SECRET"); ok { + settings.AgentSecret = agentKey + } + + if err := dataStore.Settings().UpdateSettings(settings); err != nil { + return err + } + + sslSettings, err := dataStore.SSLSettings().Settings() + if err != nil { + return err + } + + if *flags.HTTPDisabled { + sslSettings.HTTPEnabled = false + } else if *flags.HTTPEnabled { + sslSettings.HTTPEnabled = true + } + + return dataStore.SSLSettings().UpdateSettings(sslSettings) +} + +func loadAndParseKeyPair(fileService portainer.FileService, signatureService portainer.DigitalSignatureService) error { + private, public, err := fileService.LoadKeyPair() + if err != nil { + return err + } + + return signatureService.ParseKeyPair(private, public) +} + +func generateAndStoreKeyPair(fileService portainer.FileService, signatureService portainer.DigitalSignatureService) error { + private, public, err := signatureService.GenerateKeyPair() + if err != nil { + return err + } + + privateHeader, publicHeader := signatureService.PEMHeaders() + + return fileService.StoreKeyPair(private, public, privateHeader, publicHeader) +} + +func initKeyPair(fileService portainer.FileService, signatureService portainer.DigitalSignatureService) error { + existingKeyPair, err := fileService.KeyPairFilesExist() + if err != nil { + log.Fatal().Err(err).Msg("failed checking for existing key pair") + } + + if existingKeyPair { + return loadAndParseKeyPair(fileService, signatureService) + } + + return generateAndStoreKeyPair(fileService, signatureService) +} + +// dbSecretPath build the path to the file that contains the db encryption +// secret. Normally in Docker this is built from the static path inside +// /run/secrets for example: /run/secrets/ but for ease of +// use outside Docker it also accepts an absolute path +func dbSecretPath(keyFilenameFlag string) string { + if path.IsAbs(keyFilenameFlag) { + return keyFilenameFlag + } + return path.Join("/run/secrets", keyFilenameFlag) +} + +func loadEncryptionSecretKey(keyfilename string) []byte { + content, err := os.ReadFile(keyfilename) + if err != nil { + if os.IsNotExist(err) { + log.Info().Str("filename", keyfilename).Msg("encryption key file not present") + } else { + log.Info().Err(err).Msg("error reading encryption key file") + } + + return nil + } + + // return a 32 byte hash of the secret (required for AES) + // fips compliant version of this is not implemented in -ce + hash := sha256.Sum256(content) + + return hash[:] +} + +func buildServer(flags *portainer.CLIFlags, shutdownCtx context.Context, shutdownTrigger context.CancelFunc) portainer.Server { + if flags.FeatureFlags != nil { + featureflags.Parse(*flags.FeatureFlags, portainer.SupportedFeatureFlags) + } + + trustedOrigins := []string{} + if *flags.TrustedOrigins != "" { + // validate if the trusted origins are valid urls + for origin := range strings.SplitSeq(*flags.TrustedOrigins, ",") { + if !validate.IsTrustedOrigin(origin) { + log.Fatal().Str("trusted_origin", origin).Msg("invalid trusted origin: must be scheme://host or scheme://host:port (e.g. https://example.com)") + } + + trustedOrigins = append(trustedOrigins, origin) + } + } + + // -ce can not ever be run in FIPS mode + fips.InitFIPS(false) + + fileService := initFileService(*flags.Data) + encryptionKey := loadEncryptionSecretKey(dbSecretPath(*flags.SecretKeyName)) + if encryptionKey == nil { + log.Info().Msg("proceeding without encryption key") + } + + dataStore := initDataStore(flags, encryptionKey, fileService, shutdownCtx) + + if err := dataStore.CheckCurrentEdition(); err != nil { + log.Fatal().Err(err).Msg("") + } + + // check if the db schema version matches with server version + if !checkDBSchemaServerVersionMatch(dataStore, portainer.APIVersion, int(portainer.Edition)) { + log.Fatal().Msg("The database schema version does not align with the server version. Please consider reverting to the previous server version or addressing the database migration issue.") + } + + if err := ssrf.Configure(dataStore.AllowList()); err != nil { + log.Fatal().Err(err).Msg("failed initializing ssrf service") + } + + if !ssrf.WrapDefaultTransport() { + log.Fatal().Msg("failed to wrap default HTTP transport with SSRF protection") + } + + gogithttp.DefaultClient = gogithttp.NewClient(&nethttp.Client{Transport: nethttp.DefaultTransport}) + gogitclient.InstallProtocol("git", git.NewSSRFGitTransport(gogitraw.DefaultClient)) + gogitclient.InstallProtocol("ssh", git.NewSSRFGitTransport(gogitssh.DefaultClient)) + gogitclient.InstallProtocol("file", nil) + + instanceID, err := dataStore.Version().InstanceID() + if err != nil { + log.Fatal().Err(err).Msg("failed getting instance id") + } + + apiKeyService := initAPIKeyService(dataStore) + + settings, err := dataStore.Settings().Settings() + if err != nil { + log.Fatal().Err(err).Msg("") + } + + jwtService, err := initJWTService(settings.UserSessionTimeout, dataStore) + if err != nil { + log.Fatal().Err(err).Msg("failed initializing JWT service") + } + + ldapService := ldap.Service{} + + oauthService := oauth.NewService() + + gitService := git.NewService(shutdownCtx) + + cryptoService := crypto.Service{} + + signatureService := initDigitalSignatureService() + + edgeStacksService := edgestacks.NewService(dataStore) + + sslService, err := initSSLService(*flags.AddrHTTPS, *flags.TLSCert, *flags.TLSKey, fileService, dataStore, shutdownTrigger) + if err != nil { + log.Fatal().Err(err).Msg("") + } + + sslSettings, err := sslService.GetSSLSettings() + if err != nil { + log.Fatal().Err(err).Msg("failed to get SSL settings") + } + + if err := initKeyPair(fileService, signatureService); err != nil { + log.Fatal().Err(err).Msg("failed initializing key pair") + } + + reverseTunnelService := chisel.NewService(dataStore, shutdownCtx, fileService) + + dockerClientFactory := dockerclient.NewClientFactory(signatureService, reverseTunnelService) + + kubernetesClientFactory, err := kubecli.NewClientFactory(signatureService, reverseTunnelService, dataStore, instanceID, *flags.AddrHTTPS, settings.UserSessionTimeout) + if err != nil { + log.Fatal().Err(err).Msg("failed initializing Kubernetes Client Factory service") + } + + authorizationService := authorization.NewService(dataStore) + authorizationService.K8sClientFactory = kubernetesClientFactory + + kubernetesTokenCacheManager := kubeproxy.NewTokenCacheManager() + + kubeClusterAccessService := kubernetes.NewKubeClusterAccessService(*flags.BaseURL, *flags.AddrHTTPS, sslSettings.CertPath) + + proxyManager := proxy.NewManager(kubernetesClientFactory) + + reverseTunnelService.ProxyManager = proxyManager + + composeDeployer := compose.NewComposeDeployer() + + composeStackManager := exec.NewComposeStackManager(composeDeployer, proxyManager) + + swarmStackManager := exec.NewSwarmStackManager(libswarm.NewSwarmDeployer(), proxyManager) + + kubernetesDeployer := initKubernetesDeployer(kubernetesTokenCacheManager, kubernetesClientFactory, dataStore, reverseTunnelService, signatureService, proxyManager) + + pendingActionsService := pendingactions.NewService(dataStore, kubernetesClientFactory) + pendingActionsService.RegisterHandler(actions.CleanNAPWithOverridePolicies, handlers.NewHandlerCleanNAPWithOverridePolicies(authorizationService, dataStore)) + pendingActionsService.RegisterHandler(actions.DeletePortainerK8sRegistrySecrets, handlers.NewHandlerDeleteRegistrySecrets(authorizationService, dataStore, kubernetesClientFactory)) + pendingActionsService.RegisterHandler(actions.PostInitMigrateEnvironment, handlers.NewHandlerPostInitMigrateEnvironment(authorizationService, dataStore, kubernetesClientFactory, dockerClientFactory, *flags.Assets, kubernetesDeployer)) + + snapshotService, err := initSnapshotService(*flags.SnapshotInterval, dataStore, dockerClientFactory, kubernetesClientFactory, pendingActionsService) + if err != nil { + log.Fatal().Err(err).Msg("failed initializing snapshot service") + } + + snapshotService.Start(shutdownCtx) + + proxyManager.NewProxyFactory(dataStore, signatureService, reverseTunnelService, dockerClientFactory, kubernetesClientFactory, kubernetesTokenCacheManager, gitService, snapshotService, jwtService) + + helmPackageManager := libhelm.NewHelmPackageManager() + + applicationStatus := initStatus(instanceID) + + // channel to control when the admin user is created + adminCreationDone := make(chan struct{}, 1) + + go endpointutils.InitEndpoint(shutdownCtx, adminCreationDone, flags, dataStore, snapshotService) + + adminPasswordHash := "" + + if *flags.AdminPasswordFile != "" { + content, err := fileService.GetFileContent(*flags.AdminPasswordFile, "") + if err != nil { + log.Fatal().Err(err).Msg("failed getting admin password file") + } + + adminPasswordHash, err = cryptoService.Hash(strings.TrimSuffix(string(content), "\n")) + if err != nil { + log.Fatal().Err(err).Msg("failed hashing admin password") + } + } else if *flags.AdminPassword != "" { + adminPasswordHash = *flags.AdminPassword + } + + if adminPasswordHash != "" { + users, err := dataStore.User().UsersByRole(portainer.AdministratorRole) + if err != nil { + log.Fatal().Err(err).Msg("failed getting admin user") + } + + if len(users) == 0 { + log.Info().Msg("created admin user with the given password.") + + user := &portainer.User{ + Username: "admin", + Role: portainer.AdministratorRole, + Password: adminPasswordHash, + } + + if err := dataStore.User().Create(user); err != nil { + log.Fatal().Err(err).Msg("failed creating admin user") + } + + // notify the admin user is created, the endpoint initialization can start + adminCreationDone <- struct{}{} + } else { + log.Info().Msg("instance already has an administrator user defined, skipping admin password related flags.") + } + } + + setupToken := "" + if adminPasswordHash == "" && !*flags.NoSetupToken { + if err := dataStore.ViewTx(func(tx dataservices.DataStoreTx) error { + var txErr error + setupToken, txErr = resolveSetupToken(tx, *flags.SetupToken) + return txErr + }); err != nil { + log.Fatal().Err(err).Msg("failed initializing setup token") + } + } + + if err := reverseTunnelService.StartTunnelServer(*flags.TunnelAddr, *flags.TunnelPort, snapshotService); err != nil { + log.Fatal().Err(err).Msg("failed starting tunnel server") + } + + scheduler := scheduler.NewScheduler(shutdownCtx) + stackDeployer := deployments.NewStackDeployer(swarmStackManager, composeStackManager, kubernetesDeployer, dockerClientFactory, dataStore) + if err := deployments.StartStackSchedules(scheduler, stackDeployer, dataStore, gitService); err != nil { + log.Fatal().Err(err).Msg("failed to start stack scheduler") + } + + sslDBSettings, err := dataStore.SSLSettings().Settings() + if err != nil { + log.Fatal().Msg("failed to fetch SSL settings from DB") + } + + platformService := platform.NewService(dataStore) + + upgradeService, err := upgrade.NewService( + *flags.Assets, + kubernetesClientFactory, + dockerClientFactory, + composeStackManager, + dataStore, + fileService, + stackDeployer, + ) + if err != nil { + log.Fatal().Err(err).Msg("failed initializing upgrade service") + } + + // Our normal migrations run as part of the database initialization + // but some more complex migrations require access to a kubernetes or docker + // client. Therefore we run a separate migration process just before + // starting the server. + postInitMigrator := postinit.NewPostInitMigrator( + kubernetesClientFactory, + dockerClientFactory, + dataStore, + *flags.Assets, + kubernetesDeployer, + ) + if err := postInitMigrator.PostInitMigrate(); err != nil { + log.Fatal().Err(err).Msg("failure during post init migrations") + } + + if err := dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + return recoverStaleDeployingStacks(tx) + }); err != nil { + log.Info().Err(err). + Msg("Error recovering stale deploying stacks") + } + + return &http.Server{ + AuthorizationService: authorizationService, + ReverseTunnelService: reverseTunnelService, + Status: applicationStatus, + BindAddress: *flags.Addr, + BindAddressHTTPS: *flags.AddrHTTPS, + CSP: *flags.CSP, + HTTPEnabled: sslDBSettings.HTTPEnabled, + AssetsPath: *flags.Assets, + DataStore: dataStore, + EdgeStacksService: edgeStacksService, + SwarmStackManager: swarmStackManager, + ComposeStackManager: composeStackManager, + KubernetesDeployer: kubernetesDeployer, + HelmPackageManager: helmPackageManager, + APIKeyService: apiKeyService, + CryptoService: cryptoService, + JWTService: jwtService, + FileService: fileService, + LDAPService: ldapService, + OAuthService: oauthService, + GitService: gitService, + ProxyManager: proxyManager, + KubernetesTokenCacheManager: kubernetesTokenCacheManager, + KubeClusterAccessService: kubeClusterAccessService, + SignatureService: signatureService, + SnapshotService: snapshotService, + SSLService: sslService, + DockerClientFactory: dockerClientFactory, + KubernetesClientFactory: kubernetesClientFactory, + Scheduler: scheduler, + ShutdownTrigger: shutdownTrigger, + StackDeployer: stackDeployer, + UpgradeService: upgradeService, + AdminCreationDone: adminCreationDone, + PendingActionsService: pendingActionsService, + PlatformService: platformService, + PullLimitCheckDisabled: *flags.PullLimitCheckDisabled, + TrustedOrigins: trustedOrigins, + SetupToken: setupToken, + } +} + +func main() { + logs.ConfigureLogger() + logs.SetLoggingMode("PRETTY") + + flags := initCLI() + + logs.SetLoggingLevel(*flags.LogLevel) + logs.SetLoggingMode(*flags.LogMode) + + for { + shutdownCtx, shutdownTrigger := context.WithCancel(context.Background()) + server := buildServer(flags, shutdownCtx, shutdownTrigger) + + log.Info(). + Str("version", portainer.APIVersion). + Str("build_number", build.BuildNumber). + Str("image_tag", build.ImageTag). + Str("nodejs_version", build.NodejsVersion). + Str("pnpm_version", build.PnpmVersion). + Str("webpack_version", build.WebpackVersion). + Str("go_version", build.GoVersion). + Msg("starting Portainer") + + err := server.Start(shutdownCtx) + + log.Info().Err(err).Msg("HTTP server exited") + } +} + +// recoverStaleDeployingStacks resets any stack that was left in the Deploying state +// (e.g. because the server was restarted mid-deployment) to the Error state so the +// user can retry. +func recoverStaleDeployingStacks(tx dataservices.DataStoreTx) error { + stacks, err := tx.Stack().ReadAll(func(s portainer.Stack) bool { + return s.Status == portainer.StackStatusDeploying + }) + if err != nil { + return err + } + + for _, stack := range stacks { + stack.Status = portainer.StackStatusError + stack.DeploymentStatus = append(stack.DeploymentStatus, portainer.StackDeploymentStatus{ + Status: portainer.StackStatusError, + Time: time.Now().Unix(), + Message: "Deployment interrupted by server restart", + }) + + if err := tx.Stack().Update(stack.ID, &stack); err != nil { + log.Warn().Err(err). + Int("stack_id", int(stack.ID)). + Str("context", "RecoverStaleDeployingStacks"). + Msg("Unable to recover stale deploying stack") + continue + } + log.Debug(). + Int("stack_id", int(stack.ID)). + Str("stack_name", stack.Name). + Str("context", "RecoverStaleDeployingStacks"). + Msg("Recovered stale deploying stack to error state") + } + + return nil +} diff --git a/api/cmd/portainer/main_test.go b/api/cmd/portainer/main_test.go new file mode 100644 index 0000000..de9e4c1 --- /dev/null +++ b/api/cmd/portainer/main_test.go @@ -0,0 +1,159 @@ +package main + +import ( + "os" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/filesystem" + "github.com/portainer/portainer/api/internal/testhelpers" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_resolveSetupToken(t *testing.T) { + t.Parallel() + + t.Run("admin already exists — returns empty token", func(t *testing.T) { + admin := portainer.User{Role: portainer.AdministratorRole} + store := testhelpers.NewDatastore(testhelpers.WithUsers([]portainer.User{admin})) + token, err := resolveSetupToken(store, "") + require.NoError(t, err) + assert.Empty(t, token) + }) + + t.Run("no admin — generates a 64-char hex token", func(t *testing.T) { + store := testhelpers.NewDatastore(testhelpers.WithUsers([]portainer.User{})) + token, err := resolveSetupToken(store, "") + require.NoError(t, err) + assert.Len(t, token, 64) + + token2, err := resolveSetupToken(store, "") + require.NoError(t, err) + assert.NotEqual(t, token, token2) + }) + + t.Run("no admin — uses provided token", func(t *testing.T) { + store := testhelpers.NewDatastore(testhelpers.WithUsers([]portainer.User{})) + token, err := resolveSetupToken(store, "mysecrettoken") + require.NoError(t, err) + assert.Equal(t, "mysecrettoken", token) + }) + + t.Run("admin already exists — ignores provided token", func(t *testing.T) { + admin := portainer.User{Role: portainer.AdministratorRole} + store := testhelpers.NewDatastore(testhelpers.WithUsers([]portainer.User{admin})) + token, err := resolveSetupToken(store, "mysecrettoken") + require.NoError(t, err) + assert.Empty(t, token) + }) +} + +const secretFileName = "secret.txt" + +func createPasswordFile(t *testing.T, secretPath, password string) string { + err := os.WriteFile(secretPath, []byte(password), 0o600) + require.NoError(t, err) + return secretPath +} + +func TestLoadEncryptionSecretKey(t *testing.T) { + t.Parallel() + tempDir := t.TempDir() + secretPath := filesystem.JoinPaths(tempDir, secretFileName) + + // first pointing to file that does not exist, gives nil hash (no encryption) + encryptionKey := loadEncryptionSecretKey(secretPath) + require.Nil(t, encryptionKey) + + // point to a directory instead of a file + encryptionKey = loadEncryptionSecretKey(tempDir) + require.Nil(t, encryptionKey) + + password := "portainer@1234" + createPasswordFile(t, secretPath, password) + + encryptionKey = loadEncryptionSecretKey(secretPath) + require.NotNil(t, encryptionKey) + // should be 32 bytes for aes256 encryption + require.Len(t, encryptionKey, 32) +} + +func TestUpdateSettingsFromFlags_KubectlShellImage(t *testing.T) { + const existingImage = "existing-image:v1" + const newImage = "new-image:v2" + + emptyString := "" + falseBool := false + var emptyLabels []portainer.Pair + + tests := []struct { + name string + imageSet bool + flagImage string + expectedKubectlShellImage string + }{ + { + name: "flag not set — DB image unchanged", + imageSet: false, + flagImage: portainer.DefaultKubectlShellImage, + expectedKubectlShellImage: existingImage, + }, + { + name: "flag set — DB image updated", + imageSet: true, + flagImage: newImage, + expectedKubectlShellImage: newImage, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + store := testhelpers.NewDatastore( + testhelpers.WithSettingsService(&portainer.Settings{ + KubectlShellImage: existingImage, + }), + testhelpers.WithSSLSettingsService(&portainer.SSLSettings{}), + ) + + flags := &portainer.CLIFlags{ + SnapshotInterval: &emptyString, + Logo: &emptyString, + EnableEdgeComputeFeatures: &falseBool, + Templates: &emptyString, + Labels: &emptyLabels, + HTTPDisabled: &falseBool, + HTTPEnabled: &falseBool, + } + flags.KubectlShellImage = &tc.flagImage + flags.KubectlShellImageSet = tc.imageSet + + err := updateSettingsFromFlags(store, flags) + require.NoError(t, err) + + settings, err := store.Settings().Settings() + require.NoError(t, err) + require.Equal(t, tc.expectedKubectlShellImage, settings.KubectlShellImage) + }) + } +} + +func TestDBSecretPath(t *testing.T) { + t.Parallel() + tests := []struct { + keyFilenameFlag string + expected string + }{ + {keyFilenameFlag: "secret.txt", expected: "/run/secrets/secret.txt"}, + {keyFilenameFlag: "/tmp/secret.txt", expected: "/tmp/secret.txt"}, + {keyFilenameFlag: "/run/secrets/secret.txt", expected: "/run/secrets/secret.txt"}, + {keyFilenameFlag: "./secret.txt", expected: "/run/secrets/secret.txt"}, + {keyFilenameFlag: "../secret.txt", expected: "/run/secret.txt"}, + {keyFilenameFlag: "foo/bar/secret.txt", expected: "/run/secrets/foo/bar/secret.txt"}, + } + + for _, test := range tests { + assert.Equal(t, test.expected, dbSecretPath(test.keyFilenameFlag)) + } +} diff --git a/api/concurrent/concurrent.go b/api/concurrent/concurrent.go new file mode 100644 index 0000000..e588732 --- /dev/null +++ b/api/concurrent/concurrent.go @@ -0,0 +1,148 @@ +// Package concurrent provides utilities for running multiple functions concurrently in Go. +// For example, many kubernetes calls can take a while to fulfill. Oftentimes in Portainer +// we need to get a list of objects from multiple kubernetes REST APIs. We can often call these +// apis concurrently to speed up the response time. +// This package provides a clean way to do just that. +// +// Examples: +// The ConfigMaps and Secrets function converted using concurrent.Run. +/* + +// GetConfigMapsAndSecrets gets all the ConfigMaps AND all the Secrets for a +// given namespace in a k8s endpoint. The result is a list of both config maps +// and secrets. The IsSecret boolean property indicates if a given struct is a +// secret or configmap. +func (kcl *KubeClient) GetConfigMapsAndSecrets(namespace string) ([]models.K8sConfigMapOrSecret, error) { + + // use closures to capture the current kube client and namespace by declaring wrapper functions + // that match the interface signature for concurrent.Func + + listConfigMaps := func(ctx context.Context) (any, error) { + return kcl.cli.CoreV1().ConfigMaps(namespace).List(context.Background(), meta.ListOptions{}) + } + + listSecrets := func(ctx context.Context) (any, error) { + return kcl.cli.CoreV1().Secrets(namespace).List(context.Background(), meta.ListOptions{}) + } + + // run the functions concurrently and wait for results. We can also pass in a context to cancel. + // e.g. Deadline timer. + results, err := concurrent.Run(context.TODO(), listConfigMaps, listSecrets) + if err != nil { + return nil, err + } + + var configMapList *core.ConfigMapList + var secretList *core.SecretList + for _, r := range results { + switch v := r.Result.(type) { + case *core.ConfigMapList: + configMapList = v + case *core.SecretList: + secretList = v + } + } + + // TODO: Applications + var combined []models.K8sConfigMapOrSecret + for _, m := range configMapList.Items { + var cm models.K8sConfigMapOrSecret + cm.UID = string(m.UID) + cm.Name = m.Name + cm.Namespace = m.Namespace + cm.Annotations = m.Annotations + cm.Data = m.Data + cm.CreationDate = m.CreationTimestamp.Time.UTC().Format(time.RFC3339) + combined = append(combined, cm) + } + + for _, s := range secretList.Items { + var secret models.K8sConfigMapOrSecret + secret.UID = string(s.UID) + secret.Name = s.Name + secret.Namespace = s.Namespace + secret.Annotations = s.Annotations + secret.Data = msbToMss(s.Data) + secret.CreationDate = s.CreationTimestamp.Time.UTC().Format(time.RFC3339) + secret.IsSecret = true + secret.SecretType = string(s.Type) + combined = append(combined, secret) + } + + return combined, nil +} + +*/ + +package concurrent + +import ( + "context" + "sync" +) + +// Result contains the result and any error returned from running a client task function +type Result struct { + Result any // the result of running the task function + Err error // any error that occurred while running the task function +} + +// Func is a function returns a result or error +type Func func(ctx context.Context) (any, error) + +// Run runs a list of functions returns the results +func Run(ctx context.Context, maxConcurrency int, tasks ...Func) ([]Result, error) { + var wg sync.WaitGroup + + resultsChan := make(chan Result, len(tasks)) + taskChan := make(chan Func, len(tasks)) + + localCtx, cancelCtx := context.WithCancel(ctx) + defer cancelCtx() + + runTask := func() { + defer wg.Done() + + for fn := range taskChan { + result, err := fn(localCtx) + resultsChan <- Result{Result: result, Err: err} + } + } + + // Set maxConcurrency to the number of tasks if zero or negative + if maxConcurrency <= 0 { + maxConcurrency = len(tasks) + } + + // Start worker goroutines + for range maxConcurrency { + wg.Add(1) + go runTask() + } + + // Add tasks to the task channel + for _, fn := range tasks { + taskChan <- fn + } + + // Close the task channel to signal workers to stop when all tasks are done + close(taskChan) + + // Wait for all workers to complete + wg.Wait() + close(resultsChan) + + // Collect the results and cancel on error + results := make([]Result, 0, len(tasks)) + for r := range resultsChan { + if r.Err != nil { + cancelCtx() + + return nil, r.Err + } + + results = append(results, r) + } + + return results, nil +} diff --git a/api/concurrent/concurrent_test.go b/api/concurrent/concurrent_test.go new file mode 100644 index 0000000..5809e3a --- /dev/null +++ b/api/concurrent/concurrent_test.go @@ -0,0 +1,149 @@ +package concurrent + +import ( + "context" + "errors" + "sync/atomic" + "testing" + "testing/synctest" + "time" + + "github.com/stretchr/testify/require" +) + +func TestRun_AllSucceed(t *testing.T) { + t.Parallel() + + fn1 := func(ctx context.Context) (any, error) { return "one", nil } + fn2 := func(ctx context.Context) (any, error) { return "two", nil } + fn3 := func(ctx context.Context) (any, error) { return "three", nil } + + results, err := Run(t.Context(), 0, fn1, fn2, fn3) + + require.NoError(t, err) + require.Len(t, results, 3) + + values := make([]string, 0, len(results)) + for _, r := range results { + values = append(values, r.Result.(string)) + } + require.ElementsMatch(t, []string{"one", "two", "three"}, values) +} + +func TestRun_OneError(t *testing.T) { + t.Parallel() + + sentinel := errors.New("task failed") + + fn1 := func(ctx context.Context) (any, error) { return "ok", nil } + fn2 := func(ctx context.Context) (any, error) { return nil, sentinel } + + _, err := Run(t.Context(), 0, fn1, fn2) + + require.ErrorIs(t, err, sentinel) +} + +func TestRun_NoTasks(t *testing.T) { + t.Parallel() + + results, err := Run(t.Context(), 0) + + require.NoError(t, err) + require.Empty(t, results) +} + +func TestRun_MaxConcurrency(t *testing.T) { + t.Parallel() + + const numTasks = 10 + var peak atomic.Int32 + var active atomic.Int32 + + task := func(ctx context.Context) (any, error) { + current := active.Add(1) + if current > peak.Load() { + peak.Store(current) + } + + time.Sleep(10 * time.Millisecond) + active.Add(-1) + + return nil, nil + } + + tasks := make([]Func, numTasks) + for i := range tasks { + tasks[i] = task + } + + synctest.Test(t, func(t *testing.T) { + results, err := Run(t.Context(), 3, tasks...) + require.NoError(t, err) + require.Len(t, results, numTasks) + require.LessOrEqual(t, peak.Load(), int32(3)) + }) +} + +func TestRun_ZeroConcurrencyUsesAllTasks(t *testing.T) { + t.Parallel() + + const numTasks = 5 + var peak atomic.Int32 + var active atomic.Int32 + + task := func(ctx context.Context) (any, error) { + current := active.Add(1) + if current > peak.Load() { + peak.Store(current) + } + + time.Sleep(20 * time.Millisecond) + active.Add(-1) + + return nil, nil + } + + tasks := make([]Func, numTasks) + for i := range tasks { + tasks[i] = task + } + + synctest.Test(t, func(t *testing.T) { + results, err := Run(t.Context(), 0, tasks...) + require.NoError(t, err) + require.Len(t, results, numTasks) + require.Equal(t, int32(numTasks), peak.Load()) + }) +} + +func TestRun_ContextCancelledBeforeStart(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + called := atomic.Bool{} + fn := func(ctx context.Context) (any, error) { + called.Store(true) + return nil, ctx.Err() + } + + _, err := Run(ctx, 1, fn, fn, fn) + require.Error(t, err) +} + +func TestRun_ContextPassedToTasks(t *testing.T) { + t.Parallel() + + type key struct{} + ctx := context.WithValue(t.Context(), key{}, "testvalue") + + fn := func(ctx context.Context) (any, error) { + return ctx.Value(key{}), nil + } + + results, err := Run(ctx, 0, fn) + + require.NoError(t, err) + require.Equal(t, "testvalue", results[0].Result) +} diff --git a/api/connection.go b/api/connection.go new file mode 100644 index 0000000..7a31e26 --- /dev/null +++ b/api/connection.go @@ -0,0 +1,56 @@ +package portainer + +import ( + "io" +) + +type ReadTransaction interface { + GetObject(bucketName string, key []byte, object any) error + GetRawBytes(bucketName string, key []byte) ([]byte, error) + GetAll(bucketName string, obj any, append func(o any) (any, error)) error + GetAllWithKeyPrefix(bucketName string, keyPrefix []byte, obj any, append func(o any) (any, error)) error + KeyExists(bucketName string, key []byte) (bool, error) +} + +type Transaction interface { + ReadTransaction + + SetServiceName(bucketName string) error + UpdateObject(bucketName string, key []byte, object any) error + DeleteObject(bucketName string, key []byte) error + CreateObject(bucketName string, fn func(uint64) (int, any)) error + CreateObjectWithId(bucketName string, id int, obj any) error + CreateObjectWithStringId(bucketName string, id []byte, obj any) error + DeleteAllObjects(bucketName string, obj any, matching func(o any) (id int, ok bool)) error + GetNextIdentifier(bucketName string) int +} + +type Connection interface { + Transaction + + Open() error + Close() error + + UpdateTx(fn func(Transaction) error) error + ViewTx(fn func(Transaction) error) error + + // write the db contents to filename as json (the schema needs defining) + ExportRaw(filename string) error + + // TODO: this one is very database specific atm + BackupTo(w io.Writer) error + GetDatabaseFileName() string + GetDatabaseFilePath() string + GetStorePath() string + GetDatabaseFileSize() (int64, error) + + IsEncryptedStore() bool + NeedsEncryptionMigration() (bool, error) + SetEncrypted(encrypted bool) error + + BackupMetadata() (map[string]any, error) + RestoreMetadata(s map[string]any) error + + UpdateObjectFunc(bucketName string, key []byte, object any, updateFn func()) error + ConvertToKey(v int) []byte +} diff --git a/api/crypto/aes.go b/api/crypto/aes.go new file mode 100644 index 0000000..d20e716 --- /dev/null +++ b/api/crypto/aes.go @@ -0,0 +1,410 @@ +package crypto + +import ( + "bufio" + "bytes" + "crypto/aes" + "crypto/cipher" + "crypto/pbkdf2" + "crypto/rand" + "crypto/sha256" + "errors" + "fmt" + "io" + "strings" + + "github.com/portainer/portainer/pkg/fips" + + // Not allowed in FIPS mode + "golang.org/x/crypto/argon2" //nolint:depguard + "golang.org/x/crypto/scrypt" //nolint:depguard +) + +const ( + // AES GCM settings + aesGcmHeader = "AES256-GCM" // The encrypted file header + aesGcmBlockSize = 1024 * 1024 // 1MB block for aes gcm + + aesGcmFIPSHeader = "FIPS-AES256-GCM" + aesGcmFIPSBlockSize = 16 * 1024 * 1024 // 16MB block for aes gcm + + // Argon2 settings + // Recommended settings lower memory hardware according to current OWASP recommendations + // Considering some people run portainer on a NAS I think it's prudent not to assume we're on server grade hardware + // https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#argon2id + argon2MemoryCost = 12 * 1024 + argon2TimeCost = 3 + argon2Threads = 1 + argon2KeyLength = 32 + + pbkdf2Iterations = 600_000 // use recommended iterations from https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#pbkdf2 a little overkill for this use + pbkdf2SaltLength = 32 +) + +// AesEncrypt reads from input, encrypts with AES-256 and writes to output. passphrase is used to generate an encryption key +func AesEncrypt(input io.Reader, output io.Writer, passphrase []byte) error { + if fips.FIPSMode() { + if err := aesEncryptGCMFIPS(input, output, passphrase); err != nil { + return fmt.Errorf("error encrypting file: %w", err) + } + } else { + if err := aesEncryptGCM(input, output, passphrase); err != nil { + return fmt.Errorf("error encrypting file: %w", err) + } + } + + return nil +} + +// AesDecrypt reads from input, decrypts with AES-256 and returns the reader to read the decrypted content from +func AesDecrypt(input io.Reader, passphrase []byte) (io.Reader, error) { + return aesDecrypt(input, passphrase, fips.FIPSMode()) +} + +func aesDecrypt(input io.Reader, passphrase []byte, fipsMode bool) (io.Reader, error) { + // Read file header to determine how it was encrypted + inputReader := bufio.NewReader(input) + header, err := inputReader.Peek(len(aesGcmFIPSHeader)) + if err != nil { + return nil, fmt.Errorf("error reading encrypted backup file header: %w", err) + } + + if strings.HasPrefix(string(header), aesGcmFIPSHeader) { + if !fipsMode { + return nil, errors.New("fips encrypted file detected but fips mode is not enabled") + } + + reader, err := aesDecryptGCMFIPS(inputReader, passphrase) + if err != nil { + return nil, fmt.Errorf("error decrypting file: %w", err) + } + + return reader, nil + } + + if strings.HasPrefix(string(header), aesGcmHeader) { + if fipsMode { + return nil, errors.New("fips mode is enabled but non-fips encrypted file detected") + } + + reader, err := aesDecryptGCM(inputReader, passphrase) + if err != nil { + return nil, fmt.Errorf("error decrypting file: %w", err) + } + + return reader, nil + } + + // Use the previous decryption routine which has no header (to support older archives) + reader, err := aesDecryptOFB(inputReader, passphrase) + if err != nil { + return nil, fmt.Errorf("error decrypting legacy file backup: %w", err) + } + + return reader, nil +} + +// aesEncryptGCM reads from input, encrypts with AES-256 and writes to output. passphrase is used to generate an encryption key. +func aesEncryptGCM(input io.Reader, output io.Writer, passphrase []byte) error { + // Derive key using argon2 with a random salt + salt := make([]byte, 16) // 16 bytes salt + if _, err := io.ReadFull(rand.Reader, salt); err != nil { + return err + } + + key := argon2.IDKey(passphrase, salt, argon2TimeCost, argon2MemoryCost, argon2Threads, 32) + block, err := aes.NewCipher(key) + if err != nil { + return err + } + + aesgcm, err := cipher.NewGCM(block) + if err != nil { + return err + } + + // Generate nonce + nonce, err := NewRandomNonce(aesgcm.NonceSize()) + if err != nil { + return err + } + + // write the header + if _, err := output.Write([]byte(aesGcmHeader)); err != nil { + return err + } + + // Write nonce and salt to the output file + if _, err := output.Write(salt); err != nil { + return err + } + if _, err := output.Write(nonce.Value()); err != nil { + return err + } + + // Buffer for reading plaintext blocks + buf := make([]byte, aesGcmBlockSize) // Adjust buffer size as needed + ciphertext := make([]byte, len(buf)+aesgcm.Overhead()) + + // Encrypt plaintext in blocks + for { + n, err := io.ReadFull(input, buf) + if n == 0 { + break // end of plaintext input + } + + if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) { + return err + } + + // Seal encrypts the plaintext using the nonce returning the updated slice. + ciphertext = aesgcm.Seal(ciphertext[:0], nonce.Value(), buf[:n], nil) + + if _, err := output.Write(ciphertext); err != nil { + return err + } + + if err := nonce.Increment(); err != nil { + return err + } + } + + return nil +} + +// aesDecryptGCM reads from input, decrypts with AES-256 and returns the reader to read the decrypted content from. +func aesDecryptGCM(input io.Reader, passphrase []byte) (io.Reader, error) { + // Reader & verify header + header := make([]byte, len(aesGcmHeader)) + if _, err := io.ReadFull(input, header); err != nil { + return nil, err + } + + if string(header) != aesGcmHeader { + return nil, errors.New("invalid header") + } + + // Read salt + salt := make([]byte, 16) // Salt size + if _, err := io.ReadFull(input, salt); err != nil { + return nil, err + } + + key := argon2.IDKey(passphrase, salt, argon2TimeCost, argon2MemoryCost, argon2Threads, 32) + + // Initialize AES cipher block + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + + // Create GCM mode with the cipher block + aesgcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + + // Read nonce from the input reader + nonce := NewNonce(aesgcm.NonceSize()) + if err := nonce.Read(input); err != nil { + return nil, err + } + + // Initialize a buffer to store decrypted data + buf := bytes.Buffer{} + plaintext := make([]byte, aesGcmBlockSize) + + // Decrypt the ciphertext in blocks + for { + // Read a block of ciphertext from the input reader + ciphertextBlock := make([]byte, aesGcmBlockSize+aesgcm.Overhead()) // Adjust block size as needed + n, err := io.ReadFull(input, ciphertextBlock) + if n == 0 { + break // end of ciphertext + } + + if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) { + return nil, err + } + + // Decrypt the block of ciphertext + plaintext, err = aesgcm.Open(plaintext[:0], nonce.Value(), ciphertextBlock[:n], nil) + if err != nil { + return nil, err + } + + if _, err := buf.Write(plaintext); err != nil { + return nil, err + } + + if err := nonce.Increment(); err != nil { + return nil, err + } + } + + return &buf, nil +} + +// aesEncryptGCMFIPS reads from input, encrypts with AES-256 in a fips compliant +// way and writes to output. passphrase is used to generate an encryption key. +func aesEncryptGCMFIPS(input io.Reader, output io.Writer, passphrase []byte) error { + salt := make([]byte, pbkdf2SaltLength) + if _, err := io.ReadFull(rand.Reader, salt); err != nil { + return err + } + + key, err := pbkdf2.Key(sha256.New, string(passphrase), salt, pbkdf2Iterations, 32) + if err != nil { + return fmt.Errorf("error deriving key: %w", err) + } + + block, err := aes.NewCipher(key) + if err != nil { + return err + } + + // write the header + if _, err := output.Write([]byte(aesGcmFIPSHeader)); err != nil { + return err + } + + // Write nonce and salt to the output file + if _, err := output.Write(salt); err != nil { + return err + } + + // Buffer for reading plaintext blocks + buf := make([]byte, aesGcmFIPSBlockSize) + + // Encrypt plaintext in blocks + for { + // new random nonce for each block + aesgcm, err := cipher.NewGCMWithRandomNonce(block) + if err != nil { + return fmt.Errorf("error creating gcm: %w", err) + } + + n, err := io.ReadFull(input, buf) + if n == 0 { + break // end of plaintext input + } + + if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) { + return err + } + + // Seal encrypts the plaintext + ciphertext := aesgcm.Seal(nil, nil, buf[:n], nil) + + if _, err := output.Write(ciphertext); err != nil { + return err + } + } + + return nil +} + +// aesDecryptGCMFIPS reads from input, decrypts with AES-256 in a fips compliant +// way and returns the reader to read the decrypted content from. +func aesDecryptGCMFIPS(input io.Reader, passphrase []byte) (io.Reader, error) { + // Reader & verify header + header := make([]byte, len(aesGcmFIPSHeader)) + if _, err := io.ReadFull(input, header); err != nil { + return nil, err + } + + if string(header) != aesGcmFIPSHeader { + return nil, errors.New("invalid header") + } + + // Read salt + salt := make([]byte, pbkdf2SaltLength) + if _, err := io.ReadFull(input, salt); err != nil { + return nil, err + } + + key, err := pbkdf2.Key(sha256.New, string(passphrase), salt, pbkdf2Iterations, 32) + if err != nil { + return nil, fmt.Errorf("error deriving key: %w", err) + } + + // Initialize AES cipher block + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + + // Initialize a buffer to store decrypted data + buf := bytes.Buffer{} + + // Decrypt the ciphertext in blocks + for { + // Create GCM mode with the cipher block + aesgcm, err := cipher.NewGCMWithRandomNonce(block) + if err != nil { + return nil, err + } + + // Read a block of ciphertext from the input reader + ciphertextBlock := make([]byte, aesGcmFIPSBlockSize+aesgcm.Overhead()) + n, err := io.ReadFull(input, ciphertextBlock) + if n == 0 { + break // end of ciphertext + } + + if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) { + return nil, err + } + + // Decrypt the block of ciphertext + plaintext, err := aesgcm.Open(nil, nil, ciphertextBlock[:n], nil) + if err != nil { + return nil, err + } + + if _, err := buf.Write(plaintext); err != nil { + return nil, err + } + } + + return &buf, nil +} + +// aesDecryptOFB reads from input, decrypts with AES-256 and returns the reader to a read decrypted content from. +// passphrase is used to generate an encryption key. +// note: This function used to decrypt files that were encrypted without a header i.e. old archives +func aesDecryptOFB(input io.Reader, passphrase []byte) (io.Reader, error) { + // making a 32 bytes key that would correspond to AES-256 + // don't necessarily need a salt, so just kept in empty + key, err := scrypt.Key(passphrase, nil, 32768, 8, 1, 32) + if err != nil { + return nil, err + } + + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + + // If the key is unique for each ciphertext, then it's ok to use a zero IV. + var iv [aes.BlockSize]byte + stream := cipher.NewOFB(block, iv[:]) + reader := &cipher.StreamReader{S: stream, R: input} + + return reader, nil +} + +// HasEncryptedHeader checks if the data has an encrypted header, note that fips +// mode changes this behavior and so will only recognize data encrypted by the +// same mode (fips enabled or disabled) +func HasEncryptedHeader(data []byte) bool { + return hasEncryptedHeader(data, fips.FIPSMode()) +} + +func hasEncryptedHeader(data []byte, fipsMode bool) bool { + if fipsMode { + return bytes.HasPrefix(data, []byte(aesGcmFIPSHeader)) + } + + return bytes.HasPrefix(data, []byte(aesGcmHeader)) +} diff --git a/api/crypto/aes_test.go b/api/crypto/aes_test.go new file mode 100644 index 0000000..dbff93e --- /dev/null +++ b/api/crypto/aes_test.go @@ -0,0 +1,449 @@ +package crypto + +import ( + "crypto/aes" + "crypto/cipher" + "io" + "math/rand" + "os" + "testing" + + "github.com/portainer/portainer/api/filesystem" + "github.com/portainer/portainer/api/logs" + "github.com/portainer/portainer/pkg/fips" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/scrypt" +) + +func init() { + fips.InitFIPS(false) +} + +const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + +func randBytes(n int) []byte { + b := make([]byte, n) + for i := range b { + b[i] = letterBytes[rand.Intn(len(letterBytes))] + } + + return b +} + +type encryptFunc func(input io.Reader, output io.Writer, passphrase []byte) error +type decryptFunc func(input io.Reader, passphrase []byte) (io.Reader, error) + +func Test_encryptAndDecrypt_withTheSamePassword(t *testing.T) { + const passphrase = "passphrase" + + testFunc := func(t *testing.T, encrypt encryptFunc, decrypt decryptFunc, decryptShouldSucceed bool) { + tmpdir := t.TempDir() + + var ( + originFilePath = filesystem.JoinPaths(tmpdir, "origin") + encryptedFilePath = filesystem.JoinPaths(tmpdir, "encrypted") + decryptedFilePath = filesystem.JoinPaths(tmpdir, "decrypted") + ) + + content := randBytes(1024*1024*100 + 523) + err := os.WriteFile(originFilePath, content, 0600) + require.NoError(t, err) + + originFile, _ := os.Open(originFilePath) + defer logs.CloseAndLogErr(originFile) + + encryptedFileWriter, _ := os.Create(encryptedFilePath) + + err = encrypt(originFile, encryptedFileWriter, []byte(passphrase)) + require.NoError(t, err, "Failed to encrypt a file") + logs.CloseAndLogErr(encryptedFileWriter) + + encryptedContent, err := os.ReadFile(encryptedFilePath) + require.NoError(t, err, "Couldn't read encrypted file") + assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted") + + encryptedFileReader, err := os.Open(encryptedFilePath) + require.NoError(t, err) + defer logs.CloseAndLogErr(encryptedFileReader) + + decryptedFileWriter, err := os.Create(decryptedFilePath) + require.NoError(t, err) + defer logs.CloseAndLogErr(decryptedFileWriter) + + decryptedReader, err := decrypt(encryptedFileReader, []byte(passphrase)) + if !decryptShouldSucceed { + require.Error(t, err, "Failed to decrypt file as indicated by decryptShouldSucceed") + } else { + require.NoError(t, err, "Failed to decrypt file indicated by decryptShouldSucceed") + + _, err = io.Copy(decryptedFileWriter, decryptedReader) + require.NoError(t, err) + + decryptedContent, err := os.ReadFile(decryptedFilePath) + require.NoError(t, err) + assert.Equal(t, content, decryptedContent, "Original and decrypted content should match") + } + } + + t.Run("fips", func(t *testing.T) { + testFunc(t, aesEncryptGCMFIPS, aesDecryptGCMFIPS, true) + }) + + t.Run("non_fips", func(t *testing.T) { + testFunc(t, aesEncryptGCM, aesDecryptGCM, true) + }) + + t.Run("system_fips_mode_public_entry_points", func(t *testing.T) { + // use the init mode, public entry points + testFunc(t, AesEncrypt, AesDecrypt, true) + }) + + t.Run("fips_encrypted_file_header_fails_in_non_fips_mode", func(t *testing.T) { + // use aesDecrypt which checks the header, confirm that it fails + decrypt := func(input io.Reader, passphrase []byte) (io.Reader, error) { + return aesDecrypt(input, passphrase, false) + } + + testFunc(t, aesEncryptGCMFIPS, decrypt, false) + }) + + t.Run("non_fips_encrypted_file_header_fails_in_fips_mode", func(t *testing.T) { + // use aesDecrypt which checks the header, confirm that it fails + decrypt := func(input io.Reader, passphrase []byte) (io.Reader, error) { + return aesDecrypt(input, passphrase, true) + } + + testFunc(t, aesEncryptGCM, decrypt, false) + }) + + t.Run("fips_encrypted_file_fails_in_non_fips_mode", func(t *testing.T) { + testFunc(t, aesEncryptGCMFIPS, aesDecryptGCM, false) + }) + + t.Run("non_fips_encrypted_file_with_fips_mode_should_fail", func(t *testing.T) { + testFunc(t, aesEncryptGCM, aesDecryptGCMFIPS, false) + }) + + t.Run("fips_with_base_aesDecrypt", func(t *testing.T) { + // maximize coverage, use the base aesDecrypt function with valid fips mode + decrypt := func(input io.Reader, passphrase []byte) (io.Reader, error) { + return aesDecrypt(input, passphrase, true) + } + + testFunc(t, aesEncryptGCMFIPS, decrypt, true) + }) + + t.Run("legacy", func(t *testing.T) { + testFunc(t, legacyAesEncrypt, aesDecryptOFB, true) + }) +} + +func Test_encryptAndDecrypt_withStrongPassphrase(t *testing.T) { + t.Parallel() + const passphrase = "A strong passphrase with special characters: !@#$%^&*()_+" + + testFunc := func(t *testing.T, encrypt encryptFunc, decrypt decryptFunc) { + tmpdir := t.TempDir() + + var ( + originFilePath = filesystem.JoinPaths(tmpdir, "origin2") + encryptedFilePath = filesystem.JoinPaths(tmpdir, "encrypted2") + decryptedFilePath = filesystem.JoinPaths(tmpdir, "decrypted2") + ) + + content := randBytes(500) + + err := os.WriteFile(originFilePath, content, 0600) + require.NoError(t, err) + + originFile, err := os.Open(originFilePath) + require.NoError(t, err) + defer logs.CloseAndLogErr(originFile) + + encryptedFileWriter, _ := os.Create(encryptedFilePath) + + err = encrypt(originFile, encryptedFileWriter, []byte(passphrase)) + require.NoError(t, err, "Failed to encrypt a file") + logs.CloseAndLogErr(encryptedFileWriter) + + encryptedContent, err := os.ReadFile(encryptedFilePath) + require.NoError(t, err, "Couldn't read encrypted file") + assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted") + + encryptedFileReader, err := os.Open(encryptedFilePath) + require.NoError(t, err) + defer logs.CloseAndLogErr(encryptedFileReader) + + decryptedFileWriter, err := os.Create(decryptedFilePath) + require.NoError(t, err) + defer logs.CloseAndLogErr(decryptedFileWriter) + + decryptedReader, err := decrypt(encryptedFileReader, []byte(passphrase)) + require.NoError(t, err, "Failed to decrypt file") + + _, err = io.Copy(decryptedFileWriter, decryptedReader) + require.NoError(t, err) + + decryptedContent, err := os.ReadFile(decryptedFilePath) + require.NoError(t, err) + assert.Equal(t, content, decryptedContent, "Original and decrypted content should match") + } + + t.Run("fips", func(t *testing.T) { + testFunc(t, aesEncryptGCMFIPS, aesDecryptGCMFIPS) + }) + + t.Run("non_fips", func(t *testing.T) { + testFunc(t, aesEncryptGCM, aesDecryptGCM) + }) +} + +func Test_encryptAndDecrypt_withTheSamePasswordSmallFile(t *testing.T) { + t.Parallel() + testFunc := func(t *testing.T, encrypt encryptFunc, decrypt decryptFunc) { + tmpdir := t.TempDir() + + var ( + originFilePath = filesystem.JoinPaths(tmpdir, "origin2") + encryptedFilePath = filesystem.JoinPaths(tmpdir, "encrypted2") + decryptedFilePath = filesystem.JoinPaths(tmpdir, "decrypted2") + ) + + content := randBytes(500) + err := os.WriteFile(originFilePath, content, 0600) + require.NoError(t, err) + + originFile, err := os.Open(originFilePath) + require.NoError(t, err) + defer logs.CloseAndLogErr(originFile) + + encryptedFileWriter, err := os.Create(encryptedFilePath) + require.NoError(t, err) + + err = encrypt(originFile, encryptedFileWriter, []byte("passphrase")) + require.NoError(t, err, "Failed to encrypt a file") + logs.CloseAndLogErr(encryptedFileWriter) + + encryptedContent, err := os.ReadFile(encryptedFilePath) + require.NoError(t, err, "Couldn't read encrypted file") + assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted") + + encryptedFileReader, err := os.Open(encryptedFilePath) + require.NoError(t, err) + defer logs.CloseAndLogErr(encryptedFileReader) + + decryptedFileWriter, err := os.Create(decryptedFilePath) + require.NoError(t, err) + defer logs.CloseAndLogErr(decryptedFileWriter) + + decryptedReader, err := decrypt(encryptedFileReader, []byte("passphrase")) + require.NoError(t, err, "Failed to decrypt file") + + _, err = io.Copy(decryptedFileWriter, decryptedReader) + require.NoError(t, err) + + decryptedContent, err := os.ReadFile(decryptedFilePath) + require.NoError(t, err) + assert.Equal(t, content, decryptedContent, "Original and decrypted content should match") + } + + t.Run("fips", func(t *testing.T) { + testFunc(t, aesEncryptGCMFIPS, aesDecryptGCMFIPS) + }) + + t.Run("non_fips", func(t *testing.T) { + testFunc(t, aesEncryptGCM, aesDecryptGCM) + }) +} + +func Test_encryptAndDecrypt_withEmptyPassword(t *testing.T) { + t.Parallel() + testFunc := func(t *testing.T, encrypt encryptFunc, decrypt decryptFunc) { + tmpdir := t.TempDir() + + var ( + originFilePath = filesystem.JoinPaths(tmpdir, "origin") + encryptedFilePath = filesystem.JoinPaths(tmpdir, "encrypted") + decryptedFilePath = filesystem.JoinPaths(tmpdir, "decrypted") + ) + + content := randBytes(1024 * 50) + err := os.WriteFile(originFilePath, content, 0600) + require.NoError(t, err) + + originFile, err := os.Open(originFilePath) + require.NoError(t, err) + defer logs.CloseAndLogErr(originFile) + + encryptedFileWriter, err := os.Create(encryptedFilePath) + require.NoError(t, err) + defer logs.CloseAndLogErr(encryptedFileWriter) + + err = encrypt(originFile, encryptedFileWriter, []byte("")) + require.NoError(t, err, "Failed to encrypt a file") + + encryptedContent, err := os.ReadFile(encryptedFilePath) + require.NoError(t, err, "Couldn't read encrypted file") + assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted") + + encryptedFileReader, err := os.Open(encryptedFilePath) + require.NoError(t, err) + defer logs.CloseAndLogErr(encryptedFileReader) + + decryptedFileWriter, err := os.Create(decryptedFilePath) + require.NoError(t, err) + defer logs.CloseAndLogErr(decryptedFileWriter) + + decryptedReader, err := decrypt(encryptedFileReader, []byte("")) + require.NoError(t, err, "Failed to decrypt file") + + _, err = io.Copy(decryptedFileWriter, decryptedReader) + require.NoError(t, err) + + decryptedContent, err := os.ReadFile(decryptedFilePath) + require.NoError(t, err) + assert.Equal(t, content, decryptedContent, "Original and decrypted content should match") + } + + t.Run("fips", func(t *testing.T) { + testFunc(t, aesEncryptGCMFIPS, aesDecryptGCMFIPS) + }) + + t.Run("non_fips", func(t *testing.T) { + testFunc(t, aesEncryptGCM, aesDecryptGCM) + }) +} + +func Test_decryptWithDifferentPassphrase_shouldProduceWrongResult(t *testing.T) { + t.Parallel() + testFunc := func(t *testing.T, encrypt encryptFunc, decrypt decryptFunc) { + tmpdir := t.TempDir() + + var ( + originFilePath = filesystem.JoinPaths(tmpdir, "origin") + encryptedFilePath = filesystem.JoinPaths(tmpdir, "encrypted") + decryptedFilePath = filesystem.JoinPaths(tmpdir, "decrypted") + ) + + content := randBytes(1034) + err := os.WriteFile(originFilePath, content, 0600) + require.NoError(t, err) + + originFile, err := os.Open(originFilePath) + require.NoError(t, err) + defer logs.CloseAndLogErr(originFile) + + encryptedFileWriter, err := os.Create(encryptedFilePath) + require.NoError(t, err) + defer logs.CloseAndLogErr(encryptedFileWriter) + + err = encrypt(originFile, encryptedFileWriter, []byte("passphrase")) + require.NoError(t, err, "Failed to encrypt a file") + encryptedContent, err := os.ReadFile(encryptedFilePath) + require.NoError(t, err, "Couldn't read encrypted file") + assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted") + + encryptedFileReader, err := os.Open(encryptedFilePath) + require.NoError(t, err) + defer logs.CloseAndLogErr(encryptedFileReader) + + decryptedFileWriter, err := os.Create(decryptedFilePath) + require.NoError(t, err) + defer logs.CloseAndLogErr(decryptedFileWriter) + + _, err = decrypt(encryptedFileReader, []byte("garbage")) + require.Error(t, err, "Should not allow decrypt with wrong passphrase") + } + + t.Run("fips", func(t *testing.T) { + testFunc(t, aesEncryptGCMFIPS, aesDecryptGCMFIPS) + }) + + t.Run("non_fips", func(t *testing.T) { + testFunc(t, aesEncryptGCM, aesDecryptGCM) + }) +} + +func legacyAesEncrypt(input io.Reader, output io.Writer, passphrase []byte) error { + key, err := scrypt.Key(passphrase, nil, 32768, 8, 1, 32) + if err != nil { + return err + } + + block, err := aes.NewCipher(key) + if err != nil { + return err + } + + var iv [aes.BlockSize]byte + stream := cipher.NewOFB(block, iv[:]) + + writer := &cipher.StreamWriter{S: stream, W: output} + if _, err := io.Copy(writer, input); err != nil { + return err + } + + return nil +} + +func Test_hasEncryptedHeader(t *testing.T) { + t.Parallel() + tests := []struct { + name string + data []byte + fipsMode bool + want bool + }{ + { + name: "non-FIPS mode with valid header", + data: []byte("AES256-GCM" + "some encrypted data"), + fipsMode: false, + want: true, + }, + { + name: "non-FIPS mode with FIPS header", + data: []byte("FIPS-AES256-GCM" + "some encrypted data"), + fipsMode: false, + want: false, + }, + { + name: "FIPS mode with valid header", + data: []byte("FIPS-AES256-GCM" + "some encrypted data"), + fipsMode: true, + want: true, + }, + { + name: "FIPS mode with non-FIPS header", + data: []byte("AES256-GCM" + "some encrypted data"), + fipsMode: true, + want: false, + }, + { + name: "invalid header", + data: []byte("INVALID-HEADER" + "some data"), + fipsMode: false, + want: false, + }, + { + name: "empty data", + data: []byte{}, + fipsMode: false, + want: false, + }, + { + name: "nil data", + data: nil, + fipsMode: false, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := hasEncryptedHeader(tt.data, tt.fipsMode) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/api/crypto/ecdsa.go b/api/crypto/ecdsa.go new file mode 100644 index 0000000..e7eabdd --- /dev/null +++ b/api/crypto/ecdsa.go @@ -0,0 +1,135 @@ +package crypto + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/base64" + "encoding/hex" + + "github.com/portainer/portainer/pkg/libcrypto" +) + +const ( + // PrivateKeyPemHeader represents the header that is appended to the PEM file when + // storing the private key. + PrivateKeyPemHeader = "EC PRIVATE KEY" + // PublicKeyPemHeader represents the header that is appended to the PEM file when + // storing the public key. + PublicKeyPemHeader = "ECDSA PUBLIC KEY" +) + +// ECDSAService is a service used to create digital signatures when communicating with +// an agent based environment(endpoint). It will automatically generates a key pair using ECDSA or +// can also reuse an existing ECDSA key pair. +type ECDSAService struct { + privateKey *ecdsa.PrivateKey + publicKey *ecdsa.PublicKey + encodedPubKey string + secret string +} + +// NewECDSAService returns a pointer to a ECDSAService. +// An optional secret can be specified +func NewECDSAService(secret string) *ECDSAService { + return &ECDSAService{ + secret: secret, + } +} + +// EncodedPublicKey returns the encoded version of the public that can be used +// to be shared with other services. It's the hexadecimal encoding of the public key +// content. +func (service *ECDSAService) EncodedPublicKey() string { + return service.encodedPubKey +} + +// PEMHeaders returns the ECDSA PEM headers. +func (service *ECDSAService) PEMHeaders() (string, string) { + return PrivateKeyPemHeader, PublicKeyPemHeader +} + +// ParseKeyPair parses existing private/public key pair content and associate +// the parsed keys to the service. +func (service *ECDSAService) ParseKeyPair(private, public []byte) error { + privateKey, err := x509.ParseECPrivateKey(private) + if err != nil { + return err + } + + service.privateKey = privateKey + + encodedKey := hex.EncodeToString(public) + service.encodedPubKey = encodedKey + + publicKey, err := x509.ParsePKIXPublicKey(public) + if err != nil { + return err + } + + service.publicKey = publicKey.(*ecdsa.PublicKey) + + return nil +} + +// GenerateKeyPair will create a new key pair using ECDSA. +func (service *ECDSAService) GenerateKeyPair() ([]byte, []byte, error) { + pubkeyCurve := elliptic.P256() + + privatekey, err := ecdsa.GenerateKey(pubkeyCurve, rand.Reader) + if err != nil { + return nil, nil, err + } + + service.privateKey = privatekey + service.publicKey = &privatekey.PublicKey + + private, err := x509.MarshalECPrivateKey(service.privateKey) + if err != nil { + return nil, nil, err + } + + public, err := x509.MarshalPKIXPublicKey(service.publicKey) + if err != nil { + return nil, nil, err + } + + encodedKey := hex.EncodeToString(public) + service.encodedPubKey = encodedKey + + return private, public, nil +} + +// CreateSignature creates a digital signature. +// It automatically hash a specific message using MD5 and creates a signature from +// that hash. +// If a secret is associated to the service, it will be used instead of the specified +// message. +// It then encodes the generated signature in base64. +func (service *ECDSAService) CreateSignature(message string) (string, error) { + if service.secret != "" { + message = service.secret + } + + hash := libcrypto.InsecureHashFromBytes([]byte(message)) + + r, s, err := ecdsa.Sign(rand.Reader, service.privateKey, hash) + if err != nil { + return "", err + } + + keyBytes := service.privateKey.Params().BitSize / 8 + + rBytes := r.Bytes() + rBytesPadded := make([]byte, keyBytes) + copy(rBytesPadded[keyBytes-len(rBytes):], rBytes) + + sBytes := s.Bytes() + sBytesPadded := make([]byte, keyBytes) + copy(sBytesPadded[keyBytes-len(sBytes):], sBytes) + + signature := append(rBytesPadded, sBytesPadded...) + + return base64.RawStdEncoding.EncodeToString(signature), nil +} diff --git a/api/crypto/ecdsa_test.go b/api/crypto/ecdsa_test.go new file mode 100644 index 0000000..a45e83a --- /dev/null +++ b/api/crypto/ecdsa_test.go @@ -0,0 +1,23 @@ +package crypto + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCreateSignature(t *testing.T) { + t.Parallel() + var s = NewECDSAService("secret") + + privKey, pubKey, err := s.GenerateKeyPair() + require.NoError(t, err) + require.NotEmpty(t, privKey) + require.NotEmpty(t, pubKey) + + m := "test message" + r, err := s.CreateSignature(m) + require.NoError(t, err) + require.NotEqual(t, r, m) + require.NotEmpty(t, r) +} diff --git a/api/crypto/hash.go b/api/crypto/hash.go new file mode 100644 index 0000000..e8b222a --- /dev/null +++ b/api/crypto/hash.go @@ -0,0 +1,24 @@ +package crypto + +import ( + // Not allowed in FIPS mode + "golang.org/x/crypto/bcrypt" //nolint:depguard +) + +// Service represents a service for encrypting/hashing data. +type Service struct{} + +// Hash hashes a string using the bcrypt algorithm +func (Service) Hash(data string) (string, error) { + bytes, err := bcrypt.GenerateFromPassword([]byte(data), bcrypt.DefaultCost) + if err != nil { + return "", err + } + + return string(bytes), err +} + +// CompareHashAndData compares a hash to clear data and returns an error if the comparison fails. +func (Service) CompareHashAndData(hash string, data string) error { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(data)) +} diff --git a/api/crypto/hash_test.go b/api/crypto/hash_test.go new file mode 100644 index 0000000..b5e5857 --- /dev/null +++ b/api/crypto/hash_test.go @@ -0,0 +1,65 @@ +package crypto + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestService_Hash(t *testing.T) { + t.Parallel() + var s = Service{} + + type args struct { + hash string + data string + } + tests := []struct { + name string + args args + expect bool + }{ + { + name: "Empty", + args: args{ + hash: "", + data: "", + }, + expect: false, + }, + { + name: "Matching", + args: args{ + hash: "$2a$10$6BFGd94oYx8k0bFNO6f33uPUpcpAJyg8UVX.akLe9EthF/ZBTXqcy", + data: "Passw0rd!", + }, + expect: true, + }, + { + name: "Not matching", + args: args{ + hash: "$2a$10$ltKrUZ7492xyutHOb0/XweevU4jyw7QO66rP32jTVOMb3EX3JxA/a", + data: "Passw0rd!", + }, + expect: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + + err := s.CompareHashAndData(tt.args.hash, tt.args.data) + if (err != nil) == tt.expect { + t.Errorf("Service.CompareHashAndData() = %v", err) + } + }) + } +} + +func TestHash(t *testing.T) { + t.Parallel() + s := Service{} + + hash, err := s.Hash("Passw0rd!") + require.NoError(t, err) + require.NotEmpty(t, hash) +} diff --git a/api/crypto/nonce.go b/api/crypto/nonce.go new file mode 100644 index 0000000..4753374 --- /dev/null +++ b/api/crypto/nonce.go @@ -0,0 +1,62 @@ +package crypto + +import ( + "crypto/rand" + "errors" + "io" + "slices" +) + +type Nonce struct { + val []byte +} + +func NewNonce(size int) *Nonce { + return &Nonce{val: make([]byte, size)} +} + +// NewRandomNonce generates a new initial nonce with the lower byte set to a random value +// This ensures there are plenty of nonce values available before rolling over +// Based on ideas from the Secure Programming Cookbook for C and C++ by John Viega, Matt Messier +// https://www.oreilly.com/library/view/secure-programming-cookbook/0596003943/ch04s09.html +func NewRandomNonce(size int) (*Nonce, error) { + randomBytes := 1 + if size <= randomBytes { + return nil, errors.New("nonce size must be greater than the number of random bytes") + } + + randomPart := make([]byte, randomBytes) + if _, err := rand.Read(randomPart); err != nil { + return nil, err + } + + zeroPart := make([]byte, size-randomBytes) + nonceVal := append(randomPart, zeroPart...) + return &Nonce{val: nonceVal}, nil +} + +func (n *Nonce) Read(stream io.Reader) error { + _, err := io.ReadFull(stream, n.val) + return err +} + +func (n *Nonce) Value() []byte { + return n.val +} + +func (n *Nonce) Increment() error { + // Start incrementing from the least significant byte + for i := range slices.Backward(n.val) { + // Increment the current byte + n.val[i]++ + + // Check for overflow + if n.val[i] != 0 { + // No overflow, nonce is successfully incremented + return nil + } + } + + // If we reach here, it means the nonce has overflowed + return errors.New("nonce overflow") +} diff --git a/api/crypto/tls.go b/api/crypto/tls.go new file mode 100644 index 0000000..df717a3 --- /dev/null +++ b/api/crypto/tls.go @@ -0,0 +1,124 @@ +package crypto + +import ( + "crypto/tls" + "crypto/x509" + "os" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/pkg/fips" +) + +// CreateTLSConfiguration creates a basic tls.Config with recommended TLS settings +func CreateTLSConfiguration(insecureSkipVerify bool) *tls.Config { //nolint:forbidigo + return createTLSConfiguration(fips.FIPSMode(), insecureSkipVerify) +} + +func createTLSConfiguration(fipsEnabled bool, insecureSkipVerify bool) *tls.Config { //nolint:forbidigo + if fipsEnabled { + return &tls.Config{ //nolint:forbidigo + MinVersion: tls.VersionTLS12, + MaxVersion: tls.VersionTLS13, + CipherSuites: []uint16{ + tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, + tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, + tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, + tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, + }, + CurvePreferences: []tls.CurveID{tls.CurveP256, tls.CurveP384, tls.CurveP521}, + } + } + + return &tls.Config{ //nolint:forbidigo + 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, + tls.TLS_RSA_WITH_AES_128_GCM_SHA256, + tls.TLS_RSA_WITH_AES_256_GCM_SHA384, + tls.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA, + tls.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA, + tls.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA, + tls.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA, + tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256, + tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256, + }, + InsecureSkipVerify: insecureSkipVerify, //nolint:forbidigo + } +} + +// CreateTLSConfigurationFromBytes initializes a tls.Config using a CA certificate, a certificate and a key +// loaded from memory. +func CreateTLSConfigurationFromBytes(useTLS bool, caCert, cert, key []byte, skipClientVerification, skipServerVerification bool) (*tls.Config, error) { //nolint:forbidigo + return createTLSConfigurationFromBytes(fips.FIPSMode(), useTLS, caCert, cert, key, skipClientVerification, skipServerVerification) +} + +func createTLSConfigurationFromBytes(fipsEnabled, useTLS bool, caCert, cert, key []byte, skipClientVerification, skipServerVerification bool) (*tls.Config, error) { //nolint:forbidigo + if !useTLS { + return nil, nil + } + + config := createTLSConfiguration(fipsEnabled, skipServerVerification) + + if !skipClientVerification || fipsEnabled { + certificate, err := tls.X509KeyPair(cert, key) + if err != nil { + return nil, err + } + + config.Certificates = []tls.Certificate{certificate} + } + + if !skipServerVerification || fipsEnabled { + caCertPool := x509.NewCertPool() + caCertPool.AppendCertsFromPEM(caCert) + config.RootCAs = caCertPool + } + + return config, nil +} + +// CreateTLSConfigurationFromDisk initializes a tls.Config using a CA certificate, a certificate and a key +// loaded from disk. +func CreateTLSConfigurationFromDisk(config portainer.TLSConfiguration) (*tls.Config, error) { //nolint:forbidigo + return createTLSConfigurationFromDisk(fips.FIPSMode(), config) +} + +func createTLSConfigurationFromDisk(fipsEnabled bool, config portainer.TLSConfiguration) (*tls.Config, error) { //nolint:forbidigo + if !config.TLS && fipsEnabled { + return nil, fips.ErrTLSRequired + } else if !config.TLS { + return nil, nil + } + + tlsConfig := createTLSConfiguration(fipsEnabled, config.TLSSkipVerify) + + if config.TLSCertPath != "" && config.TLSKeyPath != "" { + cert, err := tls.LoadX509KeyPair(config.TLSCertPath, config.TLSKeyPath) + if err != nil { + return nil, err + } + + tlsConfig.Certificates = []tls.Certificate{cert} + } + + if !tlsConfig.InsecureSkipVerify && config.TLSCACertPath != "" { //nolint:forbidigo + caCert, err := os.ReadFile(config.TLSCACertPath) + if err != nil { + return nil, err + } + + caCertPool := x509.NewCertPool() + caCertPool.AppendCertsFromPEM(caCert) + tlsConfig.RootCAs = caCertPool + } + + return tlsConfig, nil +} diff --git a/api/crypto/tls_test.go b/api/crypto/tls_test.go new file mode 100644 index 0000000..52c643a --- /dev/null +++ b/api/crypto/tls_test.go @@ -0,0 +1,92 @@ +package crypto + +import ( + "crypto/tls" + "testing" + + portainer "github.com/portainer/portainer/api" + + "github.com/stretchr/testify/require" +) + +func TestCreateTLSConfiguration(t *testing.T) { + t.Parallel() + // InsecureSkipVerify = false + config := CreateTLSConfiguration(false) + require.Equal(t, config.MinVersion, uint16(tls.VersionTLS12)) //nolint:forbidigo + require.False(t, config.InsecureSkipVerify) //nolint:forbidigo + + // InsecureSkipVerify = true + config = CreateTLSConfiguration(true) + require.Equal(t, config.MinVersion, uint16(tls.VersionTLS12)) //nolint:forbidigo + require.True(t, config.InsecureSkipVerify) //nolint:forbidigo +} + +func TestCreateTLSConfigurationFIPS(t *testing.T) { + t.Parallel() + fips := true + + fipsCipherSuites := []uint16{ + tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, + tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, + tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, + tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, + } + + fipsCurvePreferences := []tls.CurveID{tls.CurveP256, tls.CurveP384, tls.CurveP521} + + config := createTLSConfiguration(fips, false) + require.Equal(t, config.MinVersion, uint16(tls.VersionTLS12)) //nolint:forbidigo + require.Equal(t, config.MaxVersion, uint16(tls.VersionTLS13)) //nolint:forbidigo + require.Equal(t, config.CipherSuites, fipsCipherSuites) //nolint:forbidigo + require.Equal(t, config.CurvePreferences, fipsCurvePreferences) //nolint:forbidigo + require.False(t, config.InsecureSkipVerify) //nolint:forbidigo +} + +func TestCreateTLSConfigurationFromBytes(t *testing.T) { + t.Parallel() + // No TLS + config, err := CreateTLSConfigurationFromBytes(false, nil, nil, nil, false, false) + require.NoError(t, err) + require.Nil(t, config) + + // Skip TLS client/server verifications + config, err = CreateTLSConfigurationFromBytes(true, nil, nil, nil, true, true) + require.NoError(t, err) + require.NotNil(t, config) + + // Empty TLS + config, err = CreateTLSConfigurationFromBytes(true, nil, nil, nil, false, false) + require.Error(t, err) + require.Nil(t, config) +} + +func TestCreateTLSConfigurationFromDisk(t *testing.T) { + t.Parallel() + // No TLS + config, err := CreateTLSConfigurationFromDisk(portainer.TLSConfiguration{}) + require.NoError(t, err) + require.Nil(t, config) + + // Skip TLS verifications + config, err = CreateTLSConfigurationFromDisk(portainer.TLSConfiguration{ + TLS: true, + TLSSkipVerify: true, + }) + require.NoError(t, err) + require.NotNil(t, config) +} + +func TestCreateTLSConfigurationFromDiskFIPS(t *testing.T) { + t.Parallel() + fips := true + + // Skipping TLS verifications cannot be done in FIPS mode + config, err := createTLSConfigurationFromDisk(fips, portainer.TLSConfiguration{ + TLS: true, + TLSSkipVerify: true, + }) + require.NoError(t, err) + require.NotNil(t, config) + require.False(t, config.InsecureSkipVerify) //nolint:forbidigo +} diff --git a/api/database/boltdb/db.go b/api/database/boltdb/db.go new file mode 100644 index 0000000..adfbdeb --- /dev/null +++ b/api/database/boltdb/db.go @@ -0,0 +1,503 @@ +package boltdb + +import ( + "crypto/aes" + "crypto/cipher" + "encoding/binary" + "errors" + "fmt" + "io" + "math" + "os" + "path" + "strconv" + "time" + + portainer "github.com/portainer/portainer/api" + dserrors "github.com/portainer/portainer/api/dataservices/errors" + + "github.com/rs/zerolog/log" + bolt "go.etcd.io/bbolt" +) + +const ( + DatabaseFileName = "portainer.db" + EncryptedDatabaseFileName = "portainer.edb" + + txMaxSize = 65536 + compactedSuffix = ".compacted" +) + +var ( + ErrHaveEncryptedAndUnencrypted = errors.New("Portainer has detected both an encrypted and un-encrypted database and cannot start. Only one database should exist") + ErrHaveEncryptedWithNoKey = errors.New("The portainer database is encrypted, but no secret was loaded") +) + +type DbConnection struct { + Path string + MaxBatchSize int + MaxBatchDelay time.Duration + InitialMmapSize int + EncryptionKey []byte + isEncrypted bool + Compact bool + + gcm cipher.AEAD + + *bolt.DB +} + +// GetDatabaseFileName get the database filename +func (connection *DbConnection) GetDatabaseFileName() string { + if connection.IsEncryptedStore() { + return EncryptedDatabaseFileName + } + + return DatabaseFileName +} + +// GetDataseFilePath get the path + filename for the database file +func (connection *DbConnection) GetDatabaseFilePath() string { + if connection.IsEncryptedStore() { + return path.Join(connection.Path, EncryptedDatabaseFileName) + } + + return path.Join(connection.Path, DatabaseFileName) +} + +// GetStorePath get the filename and path for the database file +func (connection *DbConnection) GetStorePath() string { + return connection.Path +} + +func (connection *DbConnection) GetDatabaseFileSize() (int64, error) { + file, err := os.Stat(connection.GetDatabaseFilePath()) + if err != nil { + return 0, fmt.Errorf("Failed to stat database file path: %s err: %w", connection.GetDatabaseFilePath(), err) + } + + return file.Size(), nil +} + +func (connection *DbConnection) SetEncrypted(flag bool) error { + connection.isEncrypted = flag + + if !flag || connection.EncryptionKey == nil { + connection.gcm = nil + + return nil + } + + block, err := aes.NewCipher(connection.EncryptionKey) + if err != nil { + return fmt.Errorf("creating AES cipher for database encryption: %w", err) + } + + gcm, err := cipher.NewGCMWithRandomNonce(block) + if err != nil { + return fmt.Errorf("creating GCM cipher for database encryption: %w", err) + } + + connection.gcm = gcm + + return nil +} + +// Return true if the database is encrypted +func (connection *DbConnection) IsEncryptedStore() bool { + return connection.getEncryptionKey() != nil +} + +// NeedsEncryptionMigration returns true if database encryption is enabled and +// we have an un-encrypted DB that requires migration to an encrypted DB +func (connection *DbConnection) NeedsEncryptionMigration() (bool, error) { + // Cases: Note, we need to check both portainer.db and portainer.edb + // to determine if it's a new store. We only need to differentiate between cases 2,3 and 5 + + // 1) portainer.edb + key => False + // 2) portainer.edb + no key => ERROR Fatal! + // 3) portainer.db + key => True (needs migration) + // 4) portainer.db + no key => False + // 5) NoDB (new) + key => False + // 6) NoDB (new) + no key => False + // 7) portainer.db & portainer.edb => ERROR Fatal! + + // If we have a loaded encryption key, always set encrypted + if connection.EncryptionKey != nil { + if err := connection.SetEncrypted(true); err != nil { + return false, err + } + } + + // Check for portainer.db + dbFile := path.Join(connection.Path, DatabaseFileName) + _, err := os.Stat(dbFile) + haveDbFile := err == nil + + // Check for portainer.edb + edbFile := path.Join(connection.Path, EncryptedDatabaseFileName) + _, err = os.Stat(edbFile) + haveEdbFile := err == nil + + if haveDbFile && haveEdbFile { + // 7 - encrypted and unencrypted db? + return false, ErrHaveEncryptedAndUnencrypted + } + + if haveDbFile && connection.EncryptionKey != nil { + // 3 - needs migration + return true, nil + } + + if haveEdbFile && connection.EncryptionKey == nil { + // 2 - encrypted db, but no key? + return false, ErrHaveEncryptedWithNoKey + } + + // 1, 4, 5, 6 + return false, nil +} + +// Open opens and initializes the BoltDB database. +func (connection *DbConnection) Open() error { + log.Info().Str("filename", connection.GetDatabaseFileName()).Msg("loading PortainerDB") + + databasePath := connection.GetDatabaseFilePath() + db, err := bolt.Open(databasePath, 0600, connection.boltOptions(connection.Compact)) + if err != nil { + return err + } + + db.MaxBatchSize = connection.MaxBatchSize + db.MaxBatchDelay = connection.MaxBatchDelay + connection.DB = db + + if connection.Compact { + log.Info().Msg("compacting database") + if err := connection.compact(); err != nil { + log.Error().Err(err).Msg("failed to compact database") + + // Close the read-only database and re-open in read-write mode + if err := connection.Close(); err != nil { + log.Warn().Err(err).Msg("failure to close the database after failed compaction") + } + + connection.Compact = false + + return connection.Open() + } else { + log.Info().Msg("database compaction completed") + } + } + + return nil +} + +// Close closes the BoltDB database. +// Safe to being called multiple times. +func (connection *DbConnection) Close() error { + log.Info().Msg("closing PortainerDB") + + if connection.DB != nil { + return connection.DB.Close() + } + + return nil +} + +func (connection *DbConnection) txFn(fn func(portainer.Transaction) error) func(*bolt.Tx) error { + return func(tx *bolt.Tx) error { + return fn(&DbTransaction{conn: connection, tx: tx}) + } +} + +// UpdateTx executes the given function inside a read-write transaction +func (connection *DbConnection) UpdateTx(fn func(portainer.Transaction) error) error { + if connection.MaxBatchDelay > 0 && connection.MaxBatchSize > 1 { + return connection.Batch(connection.txFn(fn)) + } + + return connection.Update(connection.txFn(fn)) +} + +// ViewTx executes the given function inside a read-only transaction +func (connection *DbConnection) ViewTx(fn func(portainer.Transaction) error) error { + return connection.View(connection.txFn(fn)) +} + +// BackupTo backs up db to a provided writer. +// It does hot backup and doesn't block other database reads and writes +func (connection *DbConnection) BackupTo(w io.Writer) error { + return connection.View(func(tx *bolt.Tx) error { + _, err := tx.WriteTo(w) + + return err + }) +} + +func (connection *DbConnection) ExportRaw(filename string) error { + databasePath := connection.GetDatabaseFilePath() + if _, err := os.Stat(databasePath); err != nil { + return fmt.Errorf("stat on %s failed, error: %w", databasePath, err) + } + + b, err := connection.ExportJSON(databasePath, true) + if err != nil { + return err + } + + return os.WriteFile(filename, b, 0600) +} + +// ConvertToKey returns an 8-byte big endian representation of v. +// This function is typically used for encoding integer IDs to byte slices +// so that they can be used as BoltDB keys. +func (connection *DbConnection) ConvertToKey(v int) []byte { + b := make([]byte, 8) + binary.BigEndian.PutUint64(b, uint64(v)) + + return b +} + +// keyToString Converts a key to a string value suitable for logging +func keyToString(b []byte) string { + if len(b) != 8 { + return string(b) + } + + v := binary.BigEndian.Uint64(b) + if v <= math.MaxInt32 { + return strconv.FormatUint(v, 10) + } + + return string(b) +} + +// CreateBucket is a generic function used to create a bucket inside a database. +func (connection *DbConnection) SetServiceName(bucketName string) error { + return connection.UpdateTx(func(tx portainer.Transaction) error { + return tx.SetServiceName(bucketName) + }) +} + +// GetObject is a generic function used to retrieve an unmarshalled object from a database. +func (connection *DbConnection) GetObject(bucketName string, key []byte, object any) error { + return connection.ViewTx(func(tx portainer.Transaction) error { + return tx.GetObject(bucketName, key, object) + }) +} + +func (connection *DbConnection) GetRawBytes(bucketName string, key []byte) ([]byte, error) { + var value []byte + + err := connection.ViewTx(func(tx portainer.Transaction) error { + var err error + value, err = tx.GetRawBytes(bucketName, key) + + return err + }) + + return value, err +} + +func (connection *DbConnection) KeyExists(bucketName string, key []byte) (bool, error) { + var exists bool + + err := connection.ViewTx(func(tx portainer.Transaction) error { + var err error + exists, err = tx.KeyExists(bucketName, key) + + return err + }) + + return exists, err +} + +func (connection *DbConnection) getEncryptionKey() []byte { + if !connection.isEncrypted { + return nil + } + + return connection.EncryptionKey +} + +// UpdateObject is a generic function used to update an object inside a database. +func (connection *DbConnection) UpdateObject(bucketName string, key []byte, object any) error { + return connection.UpdateTx(func(tx portainer.Transaction) error { + return tx.UpdateObject(bucketName, key, object) + }) +} + +// UpdateObjectFunc is a generic function used to update an object safely without race conditions. +func (connection *DbConnection) UpdateObjectFunc(bucketName string, key []byte, object any, updateFn func()) error { + return connection.Batch(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(bucketName)) + + data := bucket.Get(key) + if data == nil { + return fmt.Errorf("%w (bucket=%s, key=%s)", dserrors.ErrObjectNotFound, bucketName, keyToString(key)) + } + + err := connection.UnmarshalObject(data, object) + if err != nil { + return err + } + + updateFn() + + data, err = connection.MarshalObject(object) + if err != nil { + return err + } + + return bucket.Put(key, data) + }) +} + +// DeleteObject is a generic function used to delete an object inside a database. +func (connection *DbConnection) DeleteObject(bucketName string, key []byte) error { + return connection.UpdateTx(func(tx portainer.Transaction) error { + return tx.DeleteObject(bucketName, key) + }) +} + +// DeleteAllObjects delete all objects where matching() returns (id, ok). +// TODO: think about how to return the error inside (maybe change ok to type err, and use "notfound"? +func (connection *DbConnection) DeleteAllObjects(bucketName string, obj any, matching func(o any) (id int, ok bool)) error { + return connection.UpdateTx(func(tx portainer.Transaction) error { + return tx.DeleteAllObjects(bucketName, obj, matching) + }) +} + +// GetNextIdentifier is a generic function that returns the specified bucket identifier incremented by 1. +func (connection *DbConnection) GetNextIdentifier(bucketName string) int { + var identifier int + + _ = connection.UpdateTx(func(tx portainer.Transaction) error { + identifier = tx.GetNextIdentifier(bucketName) + return nil + }) + + return identifier +} + +// CreateObject creates a new object in the bucket, using the next bucket sequence id +func (connection *DbConnection) CreateObject(bucketName string, fn func(uint64) (int, any)) error { + return connection.UpdateTx(func(tx portainer.Transaction) error { + return tx.CreateObject(bucketName, fn) + }) +} + +// CreateObjectWithId creates a new object in the bucket, using the specified id +func (connection *DbConnection) CreateObjectWithId(bucketName string, id int, obj any) error { + return connection.UpdateTx(func(tx portainer.Transaction) error { + return tx.CreateObjectWithId(bucketName, id, obj) + }) +} + +// CreateObjectWithStringId creates a new object in the bucket, using the specified id +func (connection *DbConnection) CreateObjectWithStringId(bucketName string, id []byte, obj any) error { + return connection.UpdateTx(func(tx portainer.Transaction) error { + return tx.CreateObjectWithStringId(bucketName, id, obj) + }) +} + +func (connection *DbConnection) GetAll(bucketName string, obj any, appendFn func(o any) (any, error)) error { + return connection.ViewTx(func(tx portainer.Transaction) error { + return tx.GetAll(bucketName, obj, appendFn) + }) +} + +func (connection *DbConnection) GetAllWithKeyPrefix(bucketName string, keyPrefix []byte, obj any, appendFn func(o any) (any, error)) error { + return connection.ViewTx(func(tx portainer.Transaction) error { + return tx.GetAllWithKeyPrefix(bucketName, keyPrefix, obj, appendFn) + }) +} + +// BackupMetadata will return a copy of the boltdb sequence numbers for all buckets. +func (connection *DbConnection) BackupMetadata() (map[string]any, error) { + buckets := map[string]any{} + + err := connection.View(func(tx *bolt.Tx) error { + return tx.ForEach(func(name []byte, bucket *bolt.Bucket) error { + bucketName := string(name) + seqId := bucket.Sequence() + buckets[bucketName] = int(seqId) + + return nil + }) + }) + + return buckets, err +} + +// RestoreMetadata will restore the boltdb sequence numbers for all buckets. +func (connection *DbConnection) RestoreMetadata(s map[string]any) error { + var err error + + for bucketName, v := range s { + id, ok := v.(float64) // JSON ints are unmarshalled to interface as float64. See: https://pkg.go.dev/encoding/json#Decoder.Decode + if !ok { + log.Error().Str("bucket", bucketName).Msg("failed to restore metadata to bucket, skipped") + + continue + } + + err = connection.Batch(func(tx *bolt.Tx) error { + bucket, err := tx.CreateBucketIfNotExists([]byte(bucketName)) + if err != nil { + return err + } + + return bucket.SetSequence(uint64(id)) + }) + } + + return err +} + +// compact attempts to compact the database and replace it iff it succeeds +func (connection *DbConnection) compact() (err error) { + compactedPath := connection.GetDatabaseFilePath() + compactedSuffix + + if err := os.Remove(compactedPath); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("failure to remove an existing compacted database: %w", err) + } + + compactedDB, err := bolt.Open(compactedPath, 0o600, connection.boltOptions(false)) + if err != nil { + return fmt.Errorf("failure to create the compacted database: %w", err) + } + + compactedDB.MaxBatchSize = connection.MaxBatchSize + compactedDB.MaxBatchDelay = connection.MaxBatchDelay + + if err := bolt.Compact(compactedDB, connection.DB, txMaxSize); err != nil { + return fmt.Errorf("failure to compact the database: %w", + errors.Join(err, compactedDB.Close(), os.Remove(compactedPath))) + } + + if err := os.Rename(compactedPath, connection.GetDatabaseFilePath()); err != nil { + return fmt.Errorf("failure to move the compacted database: %w", + errors.Join(err, compactedDB.Close(), os.Remove(compactedPath))) + } + + if err := connection.Close(); err != nil { + log.Warn().Err(err).Msg("failure to close the database after compaction") + } + + connection.DB = compactedDB + + return nil +} + +func (connection *DbConnection) boltOptions(readOnly bool) *bolt.Options { + return &bolt.Options{ + Timeout: 1 * time.Second, + InitialMmapSize: connection.InitialMmapSize, + FreelistType: bolt.FreelistMapType, + NoFreelistSync: true, + ReadOnly: readOnly, + NoStatistics: true, + } +} diff --git a/api/database/boltdb/db_test.go b/api/database/boltdb/db_test.go new file mode 100644 index 0000000..15d9abe --- /dev/null +++ b/api/database/boltdb/db_test.go @@ -0,0 +1,252 @@ +package boltdb + +import ( + "os" + "testing" + + "github.com/portainer/portainer/api/filesystem" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.etcd.io/bbolt" +) + +func Test_NeedsEncryptionMigration(t *testing.T) { + t.Parallel() + // Test the specific scenarios mentioned in NeedsEncryptionMigration + + // i.e. + // Cases: Note, we need to check both portainer.db and portainer.edb + // to determine if it's a new store. We only need to differentiate between cases 2,3 and 5 + + // 1) portainer.edb + key => False + // 2) portainer.edb + no key => ERROR Fatal! + // 3) portainer.db + key => True (needs migration) + // 4) portainer.db + no key => False + // 5) NoDB (new) + key => False + // 6) NoDB (new) + no key => False + // 7) portainer.db & portainer.edb (key not important) => ERROR Fatal! + + is := assert.New(t) + dir := t.TempDir() + + cases := []struct { + name string + dbname string + key bool + expectError error + expectResult bool + }{ + { + name: "portainer.edb + key", + dbname: EncryptedDatabaseFileName, + key: true, + expectError: nil, + expectResult: false, + }, + { + name: "portainer.db + key (migration needed)", + dbname: DatabaseFileName, + key: true, + expectError: nil, + expectResult: true, + }, + { + name: "portainer.db + no key", + dbname: DatabaseFileName, + key: false, + expectError: nil, + expectResult: false, + }, + { + name: "NoDB (new) + key", + dbname: "", + key: false, + expectError: nil, + expectResult: false, + }, + { + name: "NoDB (new) + no key", + dbname: "", + key: false, + expectError: nil, + expectResult: false, + }, + + // error tests + { + name: "portainer.edb + no key", + dbname: EncryptedDatabaseFileName, + key: false, + expectError: ErrHaveEncryptedWithNoKey, + expectResult: false, + }, + { + name: "portainer.db & portainer.edb", + dbname: "both", + key: true, + expectError: ErrHaveEncryptedAndUnencrypted, + expectResult: false, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + connection := DbConnection{Path: dir} + + if tc.dbname == "both" { + // Special case. If portainer.db and portainer.edb exist. + dbFile1 := filesystem.JoinPaths(connection.Path, DatabaseFileName) + f, _ := os.Create(dbFile1) + + err := f.Close() + require.NoError(t, err) + + defer func() { + err := os.Remove(dbFile1) + require.NoError(t, err) + }() + + dbFile2 := filesystem.JoinPaths(connection.Path, EncryptedDatabaseFileName) + f, _ = os.Create(dbFile2) + + err = f.Close() + require.NoError(t, err) + + defer func() { + err := os.Remove(dbFile2) + require.NoError(t, err) + }() + } else if tc.dbname != "" { + dbFile := filesystem.JoinPaths(connection.Path, tc.dbname) + f, _ := os.Create(dbFile) + + err := f.Close() + require.NoError(t, err) + + defer func() { + err := os.Remove(dbFile) + require.NoError(t, err) + }() + } + + if tc.key { + connection.EncryptionKey = secretToEncryptionKey("secret") + } + + result, err := connection.NeedsEncryptionMigration() + + is.Equal(tc.expectError, err, "Fatal Error failure. Test: %s", tc.name) + is.Equal(result, tc.expectResult, "Failed test: %s", tc.name) + }) + } +} + +func TestSetEncrypted_InvalidKeyReturnsError(t *testing.T) { + t.Parallel() + + conn := DbConnection{EncryptionKey: []byte("bad")} + err := conn.SetEncrypted(true) + require.Error(t, err) + require.Nil(t, conn.gcm) +} + +func TestSetEncrypted_NilKeyDoesNotSetGCM(t *testing.T) { + t.Parallel() + + conn := DbConnection{} + err := conn.SetEncrypted(true) + require.NoError(t, err) + require.Nil(t, conn.gcm) +} + +func TestSetEncrypted_EnableThenDisableStopsEncryption(t *testing.T) { + t.Parallel() + + key := secretToEncryptionKey(passphrase) + conn := DbConnection{EncryptionKey: key} + + err := conn.SetEncrypted(true) + require.NoError(t, err) + require.NotNil(t, conn.gcm) + + err = conn.SetEncrypted(false) + require.NoError(t, err) + require.Nil(t, conn.gcm) + + // MarshalObject must return plaintext after encryption is disabled + data, err := conn.MarshalObject("hello") + require.NoError(t, err) + require.Equal(t, "hello", string(data)) +} + +func TestNeedsEncryptionMigration_InvalidKeyError(t *testing.T) { + t.Parallel() + + conn := DbConnection{ + Path: t.TempDir(), + EncryptionKey: []byte("bad"), + } + + result, err := conn.NeedsEncryptionMigration() + require.Error(t, err) + require.False(t, result) +} + +func TestDBCompaction(t *testing.T) { + t.Parallel() + db := &DbConnection{Path: t.TempDir()} + + err := db.Open() + require.NoError(t, err) + + err = db.Update(func(tx *bbolt.Tx) error { + b, err := tx.CreateBucketIfNotExists([]byte("testbucket")) + if err != nil { + return err + } + + err = b.Put([]byte("key"), []byte("value")) + require.NoError(t, err) + + return nil + }) + require.NoError(t, err) + + err = db.Close() + require.NoError(t, err) + + // Reopen the DB to trigger compaction + db.Compact = true + err = db.Open() + require.NoError(t, err) + + // Check that the data is still there + err = db.View(func(tx *bbolt.Tx) error { + b := tx.Bucket([]byte("testbucket")) + if b == nil { + return nil + } + + val := b.Get([]byte("key")) + require.Equal(t, []byte("value"), val) + + return nil + }) + require.NoError(t, err) + + err = db.Close() + require.NoError(t, err) + + // Failures + compactedPath := db.GetDatabaseFilePath() + compactedSuffix + err = os.Mkdir(compactedPath, 0o755) + require.NoError(t, err) + + f, err := os.Create(filesystem.JoinPaths(compactedPath, "somefile")) + require.NoError(t, err) + require.NoError(t, f.Close()) + + err = db.Open() + require.NoError(t, err) +} diff --git a/api/database/boltdb/export.go b/api/database/boltdb/export.go new file mode 100644 index 0000000..e5a56a8 --- /dev/null +++ b/api/database/boltdb/export.go @@ -0,0 +1,108 @@ +package boltdb + +import ( + "time" + + "github.com/portainer/portainer/api/logs" + "github.com/rs/zerolog/log" + "github.com/segmentio/encoding/json" + bolt "go.etcd.io/bbolt" +) + +func backupMetadata(connection *bolt.DB) (map[string]any, error) { + buckets := map[string]any{} + + err := connection.View(func(tx *bolt.Tx) error { + err := tx.ForEach(func(name []byte, bucket *bolt.Bucket) error { + bucketName := string(name) + seqId := bucket.Sequence() + buckets[bucketName] = int(seqId) + return nil + }) + + return err + }) + + return buckets, err +} + +// ExportJSON creates a JSON representation from a DbConnection. You can include +// the database's metadata or ignore it. Ensure the database is closed before +// using this function. +// inspired by github.com/konoui/boltdb-exporter (which has no license) +// but very much simplified, based on how we use boltdb +func (c *DbConnection) ExportJSON(databasePath string, metadata bool) ([]byte, error) { + log.Debug().Str("databasePath", databasePath).Msg("exportJson") + + connection, err := bolt.Open(databasePath, 0600, &bolt.Options{Timeout: 1 * time.Second, ReadOnly: true, NoStatistics: true}) + if err != nil { + return []byte("{}"), err + } + defer logs.CloseAndLogErr(connection) + + backup := make(map[string]any) + if metadata { + meta, err := backupMetadata(connection) + if err != nil { + log.Error().Err(err).Msg("failed exporting metadata") + } + + backup["__metadata"] = meta + } + + if err := connection.View(func(tx *bolt.Tx) error { + return tx.ForEach(func(name []byte, bucket *bolt.Bucket) error { + bucketName := string(name) + var list []any + version := make(map[string]string) + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + if v == nil { + continue + } + + var obj any + err := c.UnmarshalObject(v, &obj) + if err != nil { + log.Error(). + Str("bucket", bucketName). + Str("object", string(v)). + Err(err). + Msg("failed to unmarshal") + + obj = v + } + + if bucketName == "version" { + version[string(k)] = string(v) + } else { + list = append(list, obj) + } + } + + if bucketName == "version" { + backup[bucketName] = version + return nil + } + + if bucketName == "ssl" || + bucketName == "settings" || + bucketName == "tunnel_server" { + backup[bucketName] = nil + if len(list) > 0 { + backup[bucketName] = list[0] + } + + return nil + } + + backup[bucketName] = list + + return nil + }) + }); err != nil { + return []byte("{}"), err + } + + return json.MarshalIndent(backup, "", " ") +} diff --git a/api/database/boltdb/json.go b/api/database/boltdb/json.go new file mode 100644 index 0000000..094c8ab --- /dev/null +++ b/api/database/boltdb/json.go @@ -0,0 +1,80 @@ +package boltdb + +import ( + "bytes" + "crypto/cipher" + + "github.com/pkg/errors" + "github.com/segmentio/encoding/json" +) + +var errEncryptedStringTooShort = errors.New("encrypted string too short") + +// MarshalObject encodes an object to binary format +func (connection *DbConnection) MarshalObject(object any) ([]byte, error) { + buf := &bytes.Buffer{} + + // Special case for the VERSION bucket. Here we're not using json + if v, ok := object.(string); ok { + buf.WriteString(v) + } else { + enc := json.NewEncoder(buf) + enc.SetSortMapKeys(false) + enc.SetAppendNewline(false) + + if err := enc.Encode(object); err != nil { + return nil, err + } + } + + if connection.gcm == nil { + return buf.Bytes(), nil + } + + return encrypt(buf.Bytes(), connection.gcm), nil +} + +// UnmarshalObject decodes an object from binary data +func (connection *DbConnection) UnmarshalObject(data []byte, object any) error { + var err error + if connection.gcm != nil { + data, err = decrypt(data, connection.gcm) + if err != nil { + return errors.Wrap(err, "Failed decrypting object") + } + } + + if err := json.Unmarshal(data, object); err != nil { + // Special case for the VERSION bucket. Here we're not using json + // So we need to return it as a string + s, ok := object.(*string) + if !ok { + return errors.Wrap(err, "Failed unmarshalling object") + } + + *s = string(data) + } + + return err +} + +func encrypt(plaintext []byte, gcm cipher.AEAD) []byte { + return gcm.Seal(nil, nil, plaintext, nil) +} + +func decrypt(encrypted []byte, gcm cipher.AEAD) ([]byte, error) { + if string(encrypted) == "false" { + return []byte("false"), nil + } + + if len(encrypted) < gcm.Overhead() { + return encrypted, errEncryptedStringTooShort + } + + plaintextByte, err := gcm.Open(nil, nil, encrypted, nil) + if err != nil { + return encrypted, errors.Wrap(err, "Error decrypting text") + } + + return plaintextByte, nil +} diff --git a/api/database/boltdb/json_test.go b/api/database/boltdb/json_test.go new file mode 100644 index 0000000..4cd6049 --- /dev/null +++ b/api/database/boltdb/json_test.go @@ -0,0 +1,391 @@ +package boltdb + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "fmt" + "io" + "testing" + + "github.com/google/uuid" + "github.com/pkg/errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + jsonobject = `{"LogoURL":"","BlackListedLabels":[],"AuthenticationMethod":1,"InternalAuthSettings": {"RequiredPasswordLength": 12}"LDAPSettings":{"AnonymousMode":true,"ReaderDN":"","URL":"","TLSConfig":{"TLS":false,"TLSSkipVerify":false},"StartTLS":false,"SearchSettings":[{"BaseDN":"","Filter":"","UserNameAttribute":""}],"GroupSearchSettings":[{"GroupBaseDN":"","GroupFilter":"","GroupAttribute":""}],"AutoCreateUsers":true},"OAuthSettings":{"ClientID":"","AccessTokenURI":"","AuthorizationURI":"","ResourceURI":"","RedirectURI":"","UserIdentifier":"","Scopes":"","OAuthAutoCreateUsers":false,"DefaultTeamID":0,"SSO":true,"LogoutURI":"","KubeSecretKey":"j0zLVtY/lAWBk62ByyF0uP80SOXaitsABP0TTJX8MhI="},"FeatureFlagSettings":{},"SnapshotInterval":"5m","TemplatesURL":"https://raw.githubusercontent.com/portainer/templates/master/templates-2.0.json","EdgeAgentCheckinInterval":5,"EnableEdgeComputeFeatures":false,"UserSessionTimeout":"8h","KubeconfigExpiry":"0","HelmRepositoryURL":"https://charts.bitnami.com/bitnami","KubectlShellImage":"portainer/kubectl-shell","DisplayDonationHeader":false,"DisplayExternalContributors":false,"EnableHostManagementFeatures":false,"AllowVolumeBrowserForRegularUsers":false,"AllowBindMountsForRegularUsers":false,"AllowPrivilegedModeForRegularUsers":false,"AllowHostNamespaceForRegularUsers":false,"AllowStackManagementForRegularUsers":false,"AllowDeviceMappingForRegularUsers":false,"AllowContainerCapabilitiesForRegularUsers":false}` + passphrase = "my secret key" +) + +func secretToEncryptionKey(passphrase string) []byte { + hash := sha256.Sum256([]byte(passphrase)) + return hash[:] +} + +func Test_MarshalObjectUnencrypted(t *testing.T) { + t.Parallel() + is := assert.New(t) + + uuid := uuid.New() + + tests := []struct { + object any + expected string + }{ + { + object: nil, + expected: `null`, + }, + { + object: true, + expected: `true`, + }, + { + object: false, + expected: `false`, + }, + { + object: 123, + expected: `123`, + }, + { + object: "456", + expected: "456", + }, + { + object: uuid, + expected: "\"" + uuid.String() + "\"", + }, + { + object: uuid.String(), + expected: uuid.String(), + }, + { + object: map[string]any{"key": "value"}, + expected: `{"key":"value"}`, + }, + { + object: []bool{true, false}, + expected: `[true,false]`, + }, + { + object: []int{1, 2, 3}, + expected: `[1,2,3]`, + }, + { + object: []string{"1", "2", "3"}, + expected: `["1","2","3"]`, + }, + { + object: []map[string]any{{"key1": "value1"}, {"key2": "value2"}}, + expected: `[{"key1":"value1"},{"key2":"value2"}]`, + }, + { + object: []any{1, "2", false, map[string]any{"key1": "value1"}}, + expected: `[1,"2",false,{"key1":"value1"}]`, + }, + } + + conn := DbConnection{} + + for _, test := range tests { + t.Run(fmt.Sprintf("%s -> %s", test.object, test.expected), func(t *testing.T) { + data, err := conn.MarshalObject(test.object) + require.NoError(t, err) + is.Equal(test.expected, string(data)) + }) + } +} + +func Test_UnMarshalObjectUnencrypted(t *testing.T) { + t.Parallel() + is := assert.New(t) + + // Based on actual data entering and what we expect out of the function + + tests := []struct { + object []byte + expected string + }{ + { + object: []byte(""), + expected: "", + }, + { + object: []byte("35"), + expected: "35", + }, + { + // An unmarshalled byte string should return the same without error + object: []byte("9ca4a1dd-a439-4593-b386-a7dfdc2e9fc6"), + expected: "9ca4a1dd-a439-4593-b386-a7dfdc2e9fc6", + }, + { + // An un-marshalled json object string should return the same as a string without error also + object: []byte(jsonobject), + expected: jsonobject, + }, + } + + conn := DbConnection{} + + for _, test := range tests { + t.Run(fmt.Sprintf("%s -> %s", test.object, test.expected), func(t *testing.T) { + var object string + err := conn.UnmarshalObject(test.object, &object) + require.NoError(t, err) + is.Equal(test.expected, object) + }) + } +} + +func Test_ObjectMarshallingEncrypted(t *testing.T) { + t.Parallel() + is := assert.New(t) + + // Based on actual data entering and what we expect out of the function + + tests := []struct { + object []byte + expected string + }{ + { + object: []byte(""), + }, + { + object: []byte("35"), + }, + { + // An unmarshalled byte string should return the same without error + object: []byte("9ca4a1dd-a439-4593-b386-a7dfdc2e9fc6"), + }, + { + // An un-marshalled json object string should return the same as a string without error also + object: []byte(jsonobject), + }, + } + + key := secretToEncryptionKey(passphrase) + conn := DbConnection{EncryptionKey: key} + err := conn.SetEncrypted(true) + require.NoError(t, err) + + for _, test := range tests { + t.Run(fmt.Sprintf("%s -> %s", test.object, test.expected), func(t *testing.T) { + + data, err := conn.MarshalObject(test.object) + require.NoError(t, err) + + var object []byte + err = conn.UnmarshalObject(data, &object) + + require.NoError(t, err) + is.Equal(test.object, object) + }) + } +} + +func Test_NonceSources(t *testing.T) { + t.Parallel() + // ensure that the new go 1.24 NewGCMWithRandomNonce works correctly with + // the old way of creating and including the nonce + + encryptOldFn := func(plaintext []byte, passphrase []byte) (encrypted []byte, err error) { + block, _ := aes.NewCipher(passphrase) + gcm, err := cipher.NewGCM(block) + if err != nil { + return encrypted, err + } + + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return encrypted, err + } + + return gcm.Seal(nonce, nonce, plaintext, nil), nil + } + + decryptOldFn := func(encrypted []byte, passphrase []byte) (plaintext []byte, err error) { + block, err := aes.NewCipher(passphrase) + if err != nil { + return encrypted, errors.Wrap(err, "Error creating cypher block") + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return encrypted, errors.Wrap(err, "Error creating GCM") + } + + nonceSize := gcm.NonceSize() + if len(encrypted) < nonceSize { + return encrypted, errEncryptedStringTooShort + } + + nonce, ciphertextByteClean := encrypted[:nonceSize], encrypted[nonceSize:] + + plaintext, err = gcm.Open(nil, nonce, ciphertextByteClean, nil) + if err != nil { + return encrypted, errors.Wrap(err, "Error decrypting text") + } + + return plaintext, err + } + + passphrase := make([]byte, 32) + _, err := io.ReadFull(rand.Reader, passphrase) + require.NoError(t, err) + + block, err := aes.NewCipher(passphrase) + require.NoError(t, err) + + gcm, err := cipher.NewGCMWithRandomNonce(block) + require.NoError(t, err) + + junk := make([]byte, 1024) + _, err = io.ReadFull(rand.Reader, junk) + require.NoError(t, err) + + junkEnc := make([]byte, base64.StdEncoding.EncodedLen(len(junk))) + base64.StdEncoding.Encode(junkEnc, junk) + + cases := [][]byte{ + []byte("test"), + []byte("35"), + []byte("9ca4a1dd-a439-4593-b386-a7dfdc2e9fc6"), + []byte(jsonobject), + passphrase, + junk, + junkEnc, + } + + for _, plain := range cases { + var enc, dec []byte + var err error + + enc, err = encryptOldFn(plain, passphrase) + require.NoError(t, err) + + dec, err = decrypt(enc, gcm) + require.NoError(t, err) + + require.Equal(t, plain, dec) + + enc = encrypt(plain, gcm) + + dec, err = decryptOldFn(enc, passphrase) + require.NoError(t, err) + + require.Equal(t, plain, dec) + } +} + +func TestDecrypt_FalseStringBypassesDecryption(t *testing.T) { + t.Parallel() + + key := secretToEncryptionKey(passphrase) + block, err := aes.NewCipher(key) + require.NoError(t, err) + + gcm, err := cipher.NewGCMWithRandomNonce(block) + require.NoError(t, err) + + result, err := decrypt([]byte("false"), gcm) + require.NoError(t, err) + require.Equal(t, []byte("false"), result) +} + +func TestDecrypt_ShortDataReturnsError(t *testing.T) { + t.Parallel() + + key := secretToEncryptionKey(passphrase) + block, err := aes.NewCipher(key) + require.NoError(t, err) + + gcm, err := cipher.NewGCMWithRandomNonce(block) + require.NoError(t, err) + + short := []byte("short") + result, err := decrypt(short, gcm) + require.ErrorIs(t, err, errEncryptedStringTooShort) + require.Equal(t, short, result) +} + +func TestDecrypt_CorruptDataReturnsError(t *testing.T) { + t.Parallel() + + key := secretToEncryptionKey(passphrase) + block, err := aes.NewCipher(key) + require.NoError(t, err) + + gcm, err := cipher.NewGCMWithRandomNonce(block) + require.NoError(t, err) + + // 30 bytes passes the length check but fails authentication + corrupted := make([]byte, 30) + _, err = io.ReadFull(rand.Reader, corrupted) + require.NoError(t, err) + + result, err := decrypt(corrupted, gcm) + require.Error(t, err) + require.Equal(t, corrupted, result) +} + +// BenchmarkEncryptCachedCipher measures the new approach: cipher created once and reused. +func BenchmarkEncryptCachedCipher(b *testing.B) { + key := secretToEncryptionKey(passphrase) + conn := DbConnection{EncryptionKey: key} + err := conn.SetEncrypted(true) + require.NoError(b, err) + + data := []byte(jsonobject) + + b.ResetTimer() + + for b.Loop() { + _ = encrypt(data, conn.gcm) + } +} + +// BenchmarkEncryptPerCallCipher measures the old approach: cipher created on every call. +func BenchmarkEncryptPerCallCipher(b *testing.B) { + key := secretToEncryptionKey(passphrase) + data := []byte(jsonobject) + + b.ResetTimer() + + for b.Loop() { + block, err := aes.NewCipher(key) + if err != nil { + b.Fatal(err) + } + + gcm, err := cipher.NewGCMWithRandomNonce(block) + if err != nil { + b.Fatal(err) + } + + _ = gcm.Seal(nil, nil, data, nil) + } +} + +// BenchmarkEncryptCachedCipherParallel verifies the cached cipher is safe for concurrent use. +func BenchmarkEncryptCachedCipherParallel(b *testing.B) { + key := secretToEncryptionKey(passphrase) + conn := DbConnection{EncryptionKey: key} + err := conn.SetEncrypted(true) + require.NoError(b, err) + + data := []byte(jsonobject) + + b.ResetTimer() + + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + _ = encrypt(data, conn.gcm) + } + }) +} diff --git a/api/database/boltdb/tx.go b/api/database/boltdb/tx.go new file mode 100644 index 0000000..3e8e9ca --- /dev/null +++ b/api/database/boltdb/tx.go @@ -0,0 +1,179 @@ +package boltdb + +import ( + "bytes" + "fmt" + + dserrors "github.com/portainer/portainer/api/dataservices/errors" + + "github.com/pkg/errors" + "github.com/rs/zerolog/log" + bolt "go.etcd.io/bbolt" +) + +type DbTransaction struct { + conn *DbConnection + tx *bolt.Tx +} + +func (tx *DbTransaction) SetServiceName(bucketName string) error { + _, err := tx.tx.CreateBucketIfNotExists([]byte(bucketName)) + return err +} + +func (tx *DbTransaction) GetObject(bucketName string, key []byte, object any) error { + bucket := tx.tx.Bucket([]byte(bucketName)) + + value := bucket.Get(key) + if value == nil { + return fmt.Errorf("%w (bucket=%s, key=%s)", dserrors.ErrObjectNotFound, bucketName, keyToString(key)) + } + + return tx.conn.UnmarshalObject(value, object) +} + +func (tx *DbTransaction) GetRawBytes(bucketName string, key []byte) ([]byte, error) { + bucket := tx.tx.Bucket([]byte(bucketName)) + + value := bucket.Get(key) + if value == nil { + return nil, fmt.Errorf("%w (bucket=%s, key=%s)", dserrors.ErrObjectNotFound, bucketName, keyToString(key)) + } + + if tx.conn.gcm != nil { + var err error + + if value, err = decrypt(value, tx.conn.gcm); err != nil { + return value, errors.Wrap(err, "Failed decrypting object") + } + } + + return value, nil +} + +func (tx *DbTransaction) KeyExists(bucketName string, key []byte) (bool, error) { + bucket := tx.tx.Bucket([]byte(bucketName)) + + value := bucket.Get(key) + + return value != nil, nil +} + +func (tx *DbTransaction) UpdateObject(bucketName string, key []byte, object any) error { + data, err := tx.conn.MarshalObject(object) + if err != nil { + return err + } + + bucket := tx.tx.Bucket([]byte(bucketName)) + return bucket.Put(key, data) +} + +func (tx *DbTransaction) DeleteObject(bucketName string, key []byte) error { + bucket := tx.tx.Bucket([]byte(bucketName)) + return bucket.Delete(key) +} + +func (tx *DbTransaction) DeleteAllObjects(bucketName string, obj any, matchingFn func(o any) (id int, ok bool)) error { + var ids []int + + bucket := tx.tx.Bucket([]byte(bucketName)) + + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + err := tx.conn.UnmarshalObject(v, &obj) + if err != nil { + return err + } + + if id, ok := matchingFn(obj); ok { + ids = append(ids, id) + } + } + + for _, id := range ids { + if err := bucket.Delete(tx.conn.ConvertToKey(id)); err != nil { + return err + } + } + + return nil +} + +func (tx *DbTransaction) GetNextIdentifier(bucketName string) int { + bucket := tx.tx.Bucket([]byte(bucketName)) + + id, err := bucket.NextSequence() + if err != nil { + log.Error().Err(err).Str("bucket", bucketName).Msg("failed to get the next identifier") + + return 0 + } + + return int(id) +} + +func (tx *DbTransaction) CreateObject(bucketName string, fn func(uint64) (int, any)) error { + bucket := tx.tx.Bucket([]byte(bucketName)) + + seqId, _ := bucket.NextSequence() + id, obj := fn(seqId) + + data, err := tx.conn.MarshalObject(obj) + if err != nil { + return err + } + + return bucket.Put(tx.conn.ConvertToKey(id), data) +} + +func (tx *DbTransaction) CreateObjectWithId(bucketName string, id int, obj any) error { + bucket := tx.tx.Bucket([]byte(bucketName)) + data, err := tx.conn.MarshalObject(obj) + if err != nil { + return err + } + + return bucket.Put(tx.conn.ConvertToKey(id), data) +} + +func (tx *DbTransaction) CreateObjectWithStringId(bucketName string, id []byte, obj any) error { + bucket := tx.tx.Bucket([]byte(bucketName)) + data, err := tx.conn.MarshalObject(obj) + if err != nil { + return err + } + + return bucket.Put(id, data) +} + +func (tx *DbTransaction) GetAll(bucketName string, obj any, appendFn func(o any) (any, error)) error { + bucket := tx.tx.Bucket([]byte(bucketName)) + + return bucket.ForEach(func(k []byte, v []byte) error { + err := tx.conn.UnmarshalObject(v, obj) + if err == nil { + obj, err = appendFn(obj) + } + + return err + }) +} + +func (tx *DbTransaction) GetAllWithKeyPrefix(bucketName string, keyPrefix []byte, obj any, appendFn func(o any) (any, error)) error { + cursor := tx.tx.Bucket([]byte(bucketName)).Cursor() + + for k, v := cursor.Seek(keyPrefix); k != nil && bytes.HasPrefix(k, keyPrefix); k, v = cursor.Next() { + err := tx.conn.UnmarshalObject(v, obj) + if err != nil { + return err + } + + obj, err = appendFn(obj) + if err != nil { + return err + } + } + + return nil +} diff --git a/api/database/boltdb/tx_test.go b/api/database/boltdb/tx_test.go new file mode 100644 index 0000000..6bbfb2f --- /dev/null +++ b/api/database/boltdb/tx_test.go @@ -0,0 +1,160 @@ +package boltdb + +import ( + "errors" + "strconv" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/stretchr/testify/require" +) + +const testBucketName = "test-bucket" +const testId = 1234 + +type testStruct struct { + Key string + Value string +} + +func TestTxs(t *testing.T) { + t.Parallel() + conn := DbConnection{Path: t.TempDir()} + + err := conn.Open() + require.NoError(t, err) + t.Cleanup(func() { + err := conn.Close() + require.NoError(t, err) + }) + + // Error propagation + err = conn.UpdateTx(func(tx portainer.Transaction) error { + return errors.New("this is an error") + }) + require.Error(t, err) + + // Create an object + newObj := testStruct{Key: "key", Value: "value"} + + err = conn.UpdateTx(func(tx portainer.Transaction) error { + if err := tx.SetServiceName(testBucketName); err != nil { + return err + } + + return tx.CreateObjectWithId(testBucketName, testId, newObj) + }) + require.NoError(t, err) + + obj := testStruct{} + err = conn.ViewTx(func(tx portainer.Transaction) error { + return tx.GetObject(testBucketName, conn.ConvertToKey(testId), &obj) + }) + require.NoError(t, err) + + if obj.Key != newObj.Key || obj.Value != newObj.Value { + t.Fatalf("expected %s:%s, got %s:%s instead", newObj.Key, newObj.Value, obj.Key, obj.Value) + } + + // Update an object + updatedObj := testStruct{Key: "updated-key", Value: "updated-value"} + + err = conn.UpdateTx(func(tx portainer.Transaction) error { + return tx.UpdateObject(testBucketName, conn.ConvertToKey(testId), &updatedObj) + }) + require.NoError(t, err) + + err = conn.ViewTx(func(tx portainer.Transaction) error { + return tx.GetObject(testBucketName, conn.ConvertToKey(testId), &obj) + }) + require.NoError(t, err) + + if obj.Key != updatedObj.Key || obj.Value != updatedObj.Value { + t.Fatalf("expected %s:%s, got %s:%s instead", updatedObj.Key, updatedObj.Value, obj.Key, obj.Value) + } + + // Delete an object + err = conn.UpdateTx(func(tx portainer.Transaction) error { + return tx.DeleteObject(testBucketName, conn.ConvertToKey(testId)) + }) + require.NoError(t, err) + + err = conn.ViewTx(func(tx portainer.Transaction) error { + return tx.GetObject(testBucketName, conn.ConvertToKey(testId), &obj) + }) + require.True(t, dataservices.IsErrObjectNotFound(err)) + + // Get next identifier + err = conn.UpdateTx(func(tx portainer.Transaction) error { + id1 := tx.GetNextIdentifier(testBucketName) + id2 := tx.GetNextIdentifier(testBucketName) + + if id1+1 != id2 { + return errors.New("unexpected identifier sequence") + } + + return nil + }) + require.NoError(t, err) + + // Try to write in a read transaction + err = conn.ViewTx(func(tx portainer.Transaction) error { + return tx.CreateObjectWithId(testBucketName, testId, newObj) + }) + require.Error(t, err) +} + +func BenchmarkGetAll(b *testing.B) { + const endpointBucket = "endpoints" + const n = 10000 + + conn := DbConnection{Path: b.TempDir()} + + err := conn.Open() + require.NoError(b, err) + b.Cleanup(func() { + err := conn.Close() + require.NoError(b, err) + }) + + err = conn.UpdateTx(func(tx portainer.Transaction) error { + if err := tx.SetServiceName(endpointBucket); err != nil { + return err + } + + for i := 1; i <= n; i++ { + ep := portainer.Endpoint{ + ID: portainer.EndpointID(i), + Name: "env-" + strconv.Itoa(i), + Type: portainer.DockerEnvironment, + URL: "tcp://192.168.1." + strconv.Itoa(i%254+1) + ":2375", + PublicURL: "https://env-" + strconv.Itoa(i) + ".example.com", + GroupID: portainer.EndpointGroupID(i%10 + 1), + TagIDs: []portainer.TagID{portainer.TagID(i%5 + 1), portainer.TagID(i%3 + 1)}, + LastCheckInDate: int64(i) * 1000, + EdgeID: "edge-" + strconv.Itoa(i), + } + + if err := tx.CreateObjectWithId(endpointBucket, i, &ep); err != nil { + return err + } + } + + return nil + }) + require.NoError(b, err) + + b.ResetTimer() + b.ReportAllocs() + + for b.Loop() { + var collection []portainer.Endpoint + + if err := conn.ViewTx(func(tx portainer.Transaction) error { + return tx.GetAll(endpointBucket, new(portainer.Endpoint), dataservices.AppendFn(&collection)) + }); err != nil { + b.Fatal(err) + } + } +} diff --git a/api/database/database.go b/api/database/database.go new file mode 100644 index 0000000..c3c3cc2 --- /dev/null +++ b/api/database/database.go @@ -0,0 +1,21 @@ +package database + +import ( + "fmt" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/database/boltdb" +) + +// NewDatabase should use config options to return a connection to the requested database +func NewDatabase(storeType, storePath string, encryptionKey []byte, compact bool) (connection portainer.Connection, err error) { + if storeType == "boltdb" { + return &boltdb.DbConnection{ + Path: storePath, + EncryptionKey: encryptionKey, + Compact: compact, + }, nil + } + + return nil, fmt.Errorf("Unknown storage database: %s", storeType) +} diff --git a/api/database/database_test.go b/api/database/database_test.go new file mode 100644 index 0000000..340f69c --- /dev/null +++ b/api/database/database_test.go @@ -0,0 +1,25 @@ +package database + +import ( + "testing" + + "github.com/portainer/portainer/api/database/boltdb" + "github.com/portainer/portainer/api/filesystem" + + "github.com/stretchr/testify/require" +) + +func TestNewDatabase(t *testing.T) { + t.Parallel() + dbPath := filesystem.JoinPaths(t.TempDir(), "test.db") + connection, err := NewDatabase("boltdb", dbPath, nil, false) + require.NoError(t, err) + require.NotNil(t, connection) + + _, ok := connection.(*boltdb.DbConnection) + require.True(t, ok) + + connection, err = NewDatabase("unknown", dbPath, nil, false) + require.Error(t, err) + require.Nil(t, connection) +} diff --git a/api/database/models/version.go b/api/database/models/version.go new file mode 100644 index 0000000..a84e04e --- /dev/null +++ b/api/database/models/version.go @@ -0,0 +1,8 @@ +package models + +type Version struct { + SchemaVersion string + MigratorCount int + Edition int + InstanceID string +} diff --git a/api/dataservices/allowlist/allowlist.go b/api/dataservices/allowlist/allowlist.go new file mode 100644 index 0000000..adaa885 --- /dev/null +++ b/api/dataservices/allowlist/allowlist.go @@ -0,0 +1,131 @@ +package allowlist + +import ( + "fmt" + + lru "github.com/hashicorp/golang-lru" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/pkg/libhttp/ssrf" +) + +const ( + BucketName = "allowlist" +) + +type Service struct { + baseService dataservices.BaseDataService[portainer.AllowList, portainer.AllowListKey] + cache *lru.Cache +} + +func (service *Service) BucketName() string { + return service.baseService.BucketName() +} + +func NewService(connection portainer.Connection) (*Service, error) { + err := connection.SetServiceName(BucketName) + if err != nil { + return nil, err + } + + service := &Service{ + baseService: dataservices.BaseDataService[portainer.AllowList, portainer.AllowListKey]{ + Bucket: BucketName, + Connection: connection, + }, + } + + err = service.populateCache() + + return service, err +} + +func (service *Service) populateCache() error { + allowListKeys := []portainer.AllowListKey{portainer.AllowListSSRF} + cache, err := lru.New(len(allowListKeys)) + if err != nil { + return err + } + + for _, k := range allowListKeys { + allowList, err := service.baseService.Read(k) + if dataservices.IsErrObjectNotFound(err) { + allowList = &portainer.AllowList{ + ID: k, + Mode: portainer.SSRFModeOff, + Entries: []string{}, + } + } else if err != nil { + return err + } + + parsedAllowList := ssrf.ParseAllowedHosts(allowList.Entries) + parsedAllowList.Mode = allowList.Mode + + cache.Add(k, &parsedAllowList) + } + + service.cache = cache + + return nil +} + +func (service *Service) Tx(tx portainer.Transaction) *ServiceTx { + return &ServiceTx{ + baseService: service.baseService.Tx(tx), + cache: service.cache, + } +} + +func (service *Service) Read(id portainer.AllowListKey) (*portainer.AllowList, error) { + var result *portainer.AllowList + if err := service.baseService.Connection.ViewTx(func(tx portainer.Transaction) error { + var err error + result, err = service.Tx(tx).Read(id) + return err + }); err != nil { + return nil, err + } + + return result, nil +} + +func (service *Service) ReadAll() ([]portainer.AllowList, error) { + var result []portainer.AllowList + if err := service.baseService.Connection.ViewTx(func(tx portainer.Transaction) error { + var err error + result, err = service.Tx(tx).ReadAll() + return err + }); err != nil { + return nil, err + } + + return result, nil +} + +func (service *Service) ReadParsed(id portainer.AllowListKey) (*portainer.ParsedAllowList, error) { + allowListAny, ok := service.cache.Get(id) + if ok { + allowList, ok := allowListAny.(*portainer.ParsedAllowList) + if !ok { + return nil, fmt.Errorf("expected ParsedAllowList in cache but got %T", allowListAny) + } + + return allowList, nil + } + + var result *portainer.ParsedAllowList + err := service.baseService.Connection.ViewTx(func(tx portainer.Transaction) error { + var err error + result, err = service.Tx(tx).ReadParsed(id) + return err + }) + + return result, err +} + +func (service *Service) Update(id portainer.AllowListKey, allowList *portainer.AllowList) error { + return service.baseService.Connection.UpdateTx(func(tx portainer.Transaction) error { + return service.Tx(tx).Update(id, allowList) + }) +} diff --git a/api/dataservices/allowlist/allowlist_test.go b/api/dataservices/allowlist/allowlist_test.go new file mode 100644 index 0000000..480f369 --- /dev/null +++ b/api/dataservices/allowlist/allowlist_test.go @@ -0,0 +1,89 @@ +package allowlist_test + +import ( + "net" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/datastore" + "github.com/stretchr/testify/require" +) + +func TestAllowListReadEmpty(t *testing.T) { + t.Parallel() + _, ds := datastore.MustNewTestStore(t, false, false) + got, err := ds.AllowList().Read(portainer.AllowListSSRF) + expected := &portainer.AllowList{ + ID: portainer.AllowListSSRF, + Mode: portainer.SSRFModeOff, + Entries: []string{}, + } + require.NoError(t, err) + require.Equal(t, expected, got) +} + +func TestAllowListUpdate(t *testing.T) { + t.Parallel() + _, ds := datastore.MustNewTestStore(t, false, false) + + expected := &portainer.AllowList{ + ID: portainer.AllowListSSRF, + Mode: portainer.SSRFModeEnforce, + Entries: []string{"example.com", "10.0.0.0/8"}, + } + + require.NoError(t, ds.AllowList().Update(portainer.AllowListSSRF, expected)) + + got, err := ds.AllowList().Read(portainer.AllowListSSRF) + require.NoError(t, err) + require.Equal(t, expected, got) +} + +func TestAllowListReadAllEmpty(t *testing.T) { + t.Parallel() + _, ds := datastore.MustNewTestStore(t, false, false) + + got, err := ds.AllowList().ReadAll() + require.NoError(t, err) + require.Equal(t, []portainer.AllowList{}, got) +} + +func TestAllowListReadAllAfterUpdate(t *testing.T) { + t.Parallel() + _, ds := datastore.MustNewTestStore(t, false, false) + + expected := portainer.AllowList{ + ID: portainer.AllowListSSRF, + Mode: portainer.SSRFModeEnforce, + Entries: []string{"example.com", "10.0.0.0/8"}, + } + + require.NoError(t, ds.AllowList().Update(portainer.AllowListSSRF, &expected)) + + got, err := ds.AllowList().ReadAll() + require.NoError(t, err) + require.Equal(t, []portainer.AllowList{expected}, got) +} + +func TestAllowListReadParsedAfterUpdate(t *testing.T) { + t.Parallel() + _, ds := datastore.MustNewTestStore(t, false, false) + + require.NoError(t, ds.AllowList().Update(portainer.AllowListSSRF, &portainer.AllowList{ + ID: portainer.AllowListSSRF, + Mode: portainer.SSRFModeEnforce, + Entries: []string{"example.com"}, + })) + + expected := &portainer.ParsedAllowList{ + Mode: portainer.SSRFModeEnforce, + Nets: []*net.IPNet{}, + Hosts: map[string]bool{ + "example.com": true, + }, + } + + got, err := ds.AllowList().ReadParsed(portainer.AllowListSSRF) + require.NoError(t, err) + require.Equal(t, expected, got) +} diff --git a/api/dataservices/allowlist/tx.go b/api/dataservices/allowlist/tx.go new file mode 100644 index 0000000..dcf49a5 --- /dev/null +++ b/api/dataservices/allowlist/tx.go @@ -0,0 +1,77 @@ +package allowlist + +import ( + "fmt" + + lru "github.com/hashicorp/golang-lru" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/pkg/libhttp/ssrf" +) + +type ServiceTx struct { + baseService dataservices.BaseDataServiceTx[portainer.AllowList, portainer.AllowListKey] + cache *lru.Cache +} + +func (service *ServiceTx) BucketName() string { + return service.baseService.BucketName() +} + +func (service *ServiceTx) ReadParsed(id portainer.AllowListKey) (*portainer.ParsedAllowList, error) { + allowListAny, ok := service.cache.Get(id) + if ok { + allowList, ok := allowListAny.(*portainer.ParsedAllowList) + if !ok { + return nil, fmt.Errorf("expected ParsedAllowList in cache but got %T", allowListAny) + } + + return allowList, nil + } + + allowList, err := service.Read(id) + if err != nil { + return nil, err + } + + parsed := ssrf.ParseAllowedHosts(allowList.Entries) + parsed.Mode = allowList.Mode + service.cache.Add(id, &parsed) + + return &parsed, nil +} + +func (service *ServiceTx) Read(id portainer.AllowListKey) (*portainer.AllowList, error) { + allowList, err := service.baseService.Read(id) + if dataservices.IsErrObjectNotFound(err) { + allowList = &portainer.AllowList{ + ID: id, + Mode: portainer.SSRFModeOff, + Entries: []string{}, + } + } else if err != nil { + return nil, err + } + + return allowList, nil +} + +func (service *ServiceTx) ReadAll() ([]portainer.AllowList, error) { + allowLists, err := service.baseService.ReadAll() + if err != nil && !dataservices.IsErrObjectNotFound(err) { + return nil, err + } + + return allowLists, nil +} + +func (service *ServiceTx) Update(id portainer.AllowListKey, allowList *portainer.AllowList) error { + if err := service.baseService.Update(id, allowList); err != nil { + return err + } + + parsed := ssrf.ParseAllowedHosts(allowList.Entries) + parsed.Mode = allowList.Mode + service.cache.Add(id, &parsed) + return nil +} diff --git a/api/dataservices/allowlist/tx_test.go b/api/dataservices/allowlist/tx_test.go new file mode 100644 index 0000000..3b00345 --- /dev/null +++ b/api/dataservices/allowlist/tx_test.go @@ -0,0 +1,92 @@ +package allowlist_test + +import ( + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/datastore" + "github.com/stretchr/testify/require" +) + +func TestAllowListReadTx(t *testing.T) { + t.Parallel() + _, ds := datastore.MustNewTestStore(t, false, false) + + var got *portainer.AllowList + require.NoError(t, ds.ViewTx(func(tx dataservices.DataStoreTx) error { + var err error + got, err = tx.AllowList().Read(portainer.AllowListSSRF) + return err + })) + + expected := &portainer.AllowList{ + ID: portainer.AllowListSSRF, + Mode: portainer.SSRFModeOff, + Entries: []string{}, + } + + require.Equal(t, expected, got) +} + +func TestAllowListReadAllEmptyTx(t *testing.T) { + t.Parallel() + _, ds := datastore.MustNewTestStore(t, false, false) + + var got []portainer.AllowList + require.NoError(t, ds.ViewTx(func(tx dataservices.DataStoreTx) error { + var err error + got, err = tx.AllowList().ReadAll() + return err + })) + + require.Equal(t, []portainer.AllowList{}, got) +} + +func TestAllowListReadAllAfterUpdateTx(t *testing.T) { + t.Parallel() + _, ds := datastore.MustNewTestStore(t, false, false) + + expected := portainer.AllowList{ + ID: portainer.AllowListSSRF, + Mode: portainer.SSRFModeEnforce, + Entries: []string{"example.com"}, + } + + require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error { + return tx.AllowList().Update(portainer.AllowListSSRF, &expected) + })) + + var got []portainer.AllowList + require.NoError(t, ds.ViewTx(func(tx dataservices.DataStoreTx) error { + var err error + got, err = tx.AllowList().ReadAll() + return err + })) + + require.Equal(t, []portainer.AllowList{expected}, got) +} + +func TestAllowListUpdateTx(t *testing.T) { + t.Parallel() + _, ds := datastore.MustNewTestStore(t, false, false) + + expected := &portainer.AllowList{ + ID: portainer.AllowListSSRF, + Mode: portainer.SSRFModeEnforce, + Entries: []string{"example.com"}, + } + + require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error { + return tx.AllowList().Update(portainer.AllowListSSRF, expected) + })) + + var got *portainer.AllowList + require.NoError(t, ds.ViewTx(func(tx dataservices.DataStoreTx) error { + var err error + got, err = tx.AllowList().Read(portainer.AllowListSSRF) + return err + })) + + require.Equal(t, expected, got) +} diff --git a/api/dataservices/apikeyrepository/apikeyrepository.go b/api/dataservices/apikeyrepository/apikeyrepository.go new file mode 100644 index 0000000..1b03585 --- /dev/null +++ b/api/dataservices/apikeyrepository/apikeyrepository.go @@ -0,0 +1,82 @@ +package apikeyrepository + +import ( + "errors" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + dserrors "github.com/portainer/portainer/api/dataservices/errors" +) + +// BucketName represents the name of the bucket where this service stores data. +const BucketName = "api_key" + +// Service represents a service for managing api-key data. +type Service struct { + dataservices.BaseDataService[portainer.APIKey, portainer.APIKeyID] +} + +// NewService creates a new instance of a service. +func NewService(connection portainer.Connection) (*Service, error) { + if err := connection.SetServiceName(BucketName); err != nil { + return nil, err + } + + return &Service{ + BaseDataService: dataservices.BaseDataService[portainer.APIKey, portainer.APIKeyID]{ + Bucket: BucketName, + Connection: connection, + }, + }, nil +} + +// GetAPIKeysByUserID returns a slice containing all the APIKeys a user has access to. +func (service *Service) GetAPIKeysByUserID(userID portainer.UserID) ([]portainer.APIKey, error) { + result := make([]portainer.APIKey, 0) + + err := service.Connection.GetAll( + BucketName, + &portainer.APIKey{}, + dataservices.FilterFn(&result, func(record portainer.APIKey) bool { + return record.UserID == userID + }), + ) + + return result, err +} + +// GetAPIKeyByDigest returns the API key for the associated digest. +// Note: there is a 1-to-1 mapping of api-key and digest +func (service *Service) GetAPIKeyByDigest(digest string) (*portainer.APIKey, error) { + var found portainer.APIKey + + err := service.Connection.GetAll( + BucketName, + &portainer.APIKey{}, + dataservices.FirstFn(&found, func(key portainer.APIKey) bool { + return key.Digest == digest + }), + ) + + if errors.Is(err, dataservices.ErrStop) { + return &found, nil + } + + if err == nil { + return nil, dserrors.ErrObjectNotFound + } + + return nil, err +} + +// Create creates a new APIKey object. +func (service *Service) Create(record *portainer.APIKey) error { + return service.Connection.CreateObject( + BucketName, + func(id uint64) (int, any) { + record.ID = portainer.APIKeyID(id) + + return int(record.ID), record + }, + ) +} diff --git a/api/dataservices/base.go b/api/dataservices/base.go new file mode 100644 index 0000000..18839b6 --- /dev/null +++ b/api/dataservices/base.go @@ -0,0 +1,81 @@ +package dataservices + +import ( + portainer "github.com/portainer/portainer/api" + + "golang.org/x/exp/constraints" +) + +type BaseCRUD[T any, I constraints.Integer] interface { + Create(element *T) error + Read(ID I) (*T, error) + Exists(ID I) (bool, error) + ReadAll(predicates ...func(T) bool) ([]T, error) + Update(ID I, element *T) error + Delete(ID I) error +} + +type BaseDataService[T any, I constraints.Integer] struct { + Bucket string + Connection portainer.Connection +} + +func (s *BaseDataService[T, I]) BucketName() string { + return s.Bucket +} + +func (service *BaseDataService[T, I]) Tx(tx portainer.Transaction) BaseDataServiceTx[T, I] { + return BaseDataServiceTx[T, I]{ + Bucket: service.Bucket, + Connection: service.Connection, + Tx: tx, + } +} + +func (service BaseDataService[T, I]) Read(ID I) (*T, error) { + var element *T + + return element, service.Connection.ViewTx(func(tx portainer.Transaction) error { + var err error + element, err = service.Tx(tx).Read(ID) + + return err + }) +} + +func (service BaseDataService[T, I]) Exists(ID I) (bool, error) { + var exists bool + + err := service.Connection.ViewTx(func(tx portainer.Transaction) error { + var err error + exists, err = service.Tx(tx).Exists(ID) + + return err + }) + + return exists, err +} + +// ReadAll retrieves all the elements that satisfy all the provided predicates. +func (service BaseDataService[T, I]) ReadAll(predicates ...func(T) bool) ([]T, error) { + var collection = make([]T, 0) + + return collection, service.Connection.ViewTx(func(tx portainer.Transaction) error { + var err error + collection, err = service.Tx(tx).ReadAll(predicates...) + + return err + }) +} + +func (service BaseDataService[T, I]) Update(ID I, element *T) error { + return service.Connection.UpdateTx(func(tx portainer.Transaction) error { + return service.Tx(tx).Update(ID, element) + }) +} + +func (service BaseDataService[T, I]) Delete(ID I) error { + return service.Connection.UpdateTx(func(tx portainer.Transaction) error { + return service.Tx(tx).Delete(ID) + }) +} diff --git a/api/dataservices/base_test.go b/api/dataservices/base_test.go new file mode 100644 index 0000000..da849b4 --- /dev/null +++ b/api/dataservices/base_test.go @@ -0,0 +1,92 @@ +package dataservices + +import ( + "strconv" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/slicesx" + + "github.com/stretchr/testify/require" +) + +type testObject struct { + ID int + Value int +} + +type mockConnection struct { + store map[int]testObject + + portainer.Connection +} + +func (m mockConnection) UpdateObject(bucket string, key []byte, value any) error { + obj := value.(*testObject) + + m.store[obj.ID] = *obj + + return nil +} + +func (m mockConnection) GetAll(bucketName string, obj any, appendFn func(o any) (any, error)) error { + for _, v := range m.store { + if _, err := appendFn(&v); err != nil { + return err + } + } + + return nil +} + +func (m mockConnection) UpdateTx(fn func(portainer.Transaction) error) error { + return fn(m) +} + +func (m mockConnection) ViewTx(fn func(portainer.Transaction) error) error { + return fn(m) +} + +func (m mockConnection) ConvertToKey(v int) []byte { + return []byte(strconv.Itoa(v)) +} +func TestReadAll(t *testing.T) { + t.Parallel() + service := BaseDataService[testObject, int]{ + Bucket: "testBucket", + Connection: mockConnection{store: make(map[int]testObject)}, + } + + data := []testObject{ + {ID: 1, Value: 1}, + {ID: 2, Value: 2}, + {ID: 3, Value: 3}, + {ID: 4, Value: 4}, + {ID: 5, Value: 5}, + } + + for _, item := range data { + err := service.Update(item.ID, &item) + require.NoError(t, err) + } + + // ReadAll without predicates + result, err := service.ReadAll() + require.NoError(t, err) + + expected := append([]testObject{}, data...) + + require.ElementsMatch(t, expected, result) + + // ReadAll with predicates + hasLowID := func(obj testObject) bool { return obj.ID < 3 } + isEven := func(obj testObject) bool { return obj.Value%2 == 0 } + + result, err = service.ReadAll(hasLowID, isEven) + require.NoError(t, err) + + expected = slicesx.Filter(expected, hasLowID) + expected = slicesx.Filter(expected, isEven) + + require.ElementsMatch(t, expected, result) +} diff --git a/api/dataservices/base_tx.go b/api/dataservices/base_tx.go new file mode 100644 index 0000000..fb5b4ad --- /dev/null +++ b/api/dataservices/base_tx.go @@ -0,0 +1,84 @@ +package dataservices + +import ( + portainer "github.com/portainer/portainer/api" + + "golang.org/x/exp/constraints" +) + +type BaseDataServiceTx[T any, I constraints.Integer] struct { + Bucket string + Connection portainer.Connection + Tx portainer.Transaction +} + +func (service BaseDataServiceTx[T, I]) BucketName() string { + return service.Bucket +} + +func (service BaseDataServiceTx[T, I]) Read(ID I) (*T, error) { + var element T + identifier := service.Connection.ConvertToKey(int(ID)) + + err := service.Tx.GetObject(service.Bucket, identifier, &element) + if err != nil { + return nil, err + } + + return &element, nil +} + +func (service BaseDataServiceTx[T, I]) Exists(ID I) (bool, error) { + identifier := service.Connection.ConvertToKey(int(ID)) + + return service.Tx.KeyExists(service.Bucket, identifier) +} + +// ReadAll retrieves all the elements that satisfy all the provided predicates. +func (service BaseDataServiceTx[T, I]) ReadAll(predicates ...func(T) bool) ([]T, error) { + var collection = make([]T, 0) + + if len(predicates) == 0 { + return collection, service.Tx.GetAll( + service.Bucket, + new(T), + AppendFn(&collection), + ) + } + + filterFn := func(element T) bool { + for _, p := range predicates { + if !p(element) { + return false + } + } + + return true + } + + return collection, service.Tx.GetAll( + service.Bucket, + new(T), + FilterFn(&collection, filterFn), + ) +} + +func (service BaseDataServiceTx[T, I]) Update(ID I, element *T) error { + identifier := service.Connection.ConvertToKey(int(ID)) + return service.Tx.UpdateObject(service.Bucket, identifier, element) +} + +func (service BaseDataServiceTx[T, I]) Delete(ID I) error { + identifier := service.Connection.ConvertToKey(int(ID)) + return service.Tx.DeleteObject(service.Bucket, identifier) +} + +func Read[T any](tx portainer.Transaction, bucket string, key []byte) (*T, error) { + var element T + + if err := tx.GetObject(bucket, key, &element); err != nil { + return nil, err + } + + return &element, nil +} diff --git a/api/dataservices/customtemplate/customtemplate.go b/api/dataservices/customtemplate/customtemplate.go new file mode 100644 index 0000000..de23948 --- /dev/null +++ b/api/dataservices/customtemplate/customtemplate.go @@ -0,0 +1,39 @@ +package customtemplate + +import ( + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" +) + +// BucketName represents the name of the bucket where this service stores data. +const BucketName = "customtemplates" + +// Service represents a service for managing custom template data. +type Service struct { + dataservices.BaseDataService[portainer.CustomTemplate, portainer.CustomTemplateID] +} + +// NewService creates a new instance of a service. +func NewService(connection portainer.Connection) (*Service, error) { + err := connection.SetServiceName(BucketName) + if err != nil { + return nil, err + } + + return &Service{ + BaseDataService: dataservices.BaseDataService[portainer.CustomTemplate, portainer.CustomTemplateID]{ + Bucket: BucketName, + Connection: connection, + }, + }, nil +} + +func (service *Service) GetNextIdentifier() int { + return service.Connection.GetNextIdentifier(BucketName) +} + +func (service *Service) Create(customTemplate *portainer.CustomTemplate) error { + return service.Connection.UpdateTx(func(tx portainer.Transaction) error { + return service.Tx(tx).Create(customTemplate) + }) +} diff --git a/api/dataservices/customtemplate/customtemplate_test.go b/api/dataservices/customtemplate/customtemplate_test.go new file mode 100644 index 0000000..b72ece6 --- /dev/null +++ b/api/dataservices/customtemplate/customtemplate_test.go @@ -0,0 +1,20 @@ +package customtemplate_test + +import ( + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/datastore" + "github.com/stretchr/testify/require" +) + +func TestCustomTemplateCreate(t *testing.T) { + t.Parallel() + _, ds := datastore.MustNewTestStore(t, false, false) + require.NotNil(t, ds) + + require.NoError(t, ds.CustomTemplate().Create(&portainer.CustomTemplate{ID: 1})) + e, err := ds.CustomTemplate().Read(1) + require.NoError(t, err) + require.Equal(t, portainer.CustomTemplateID(1), e.ID) +} diff --git a/api/dataservices/customtemplate/tx.go b/api/dataservices/customtemplate/tx.go new file mode 100644 index 0000000..60b0120 --- /dev/null +++ b/api/dataservices/customtemplate/tx.go @@ -0,0 +1,31 @@ +package customtemplate + +import ( + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" +) + +// Service represents a service for managing custom template data. +type ServiceTx struct { + dataservices.BaseDataServiceTx[portainer.CustomTemplate, portainer.CustomTemplateID] +} + +func (service *Service) Tx(tx portainer.Transaction) ServiceTx { + return ServiceTx{ + BaseDataServiceTx: dataservices.BaseDataServiceTx[portainer.CustomTemplate, portainer.CustomTemplateID]{ + Bucket: BucketName, + Connection: service.Connection, + Tx: tx, + }, + } +} + +func (service ServiceTx) GetNextIdentifier() int { + return service.Tx.GetNextIdentifier(BucketName) +} + +// CreateCustomTemplate uses the existing id and saves it. +// TODO: where does the ID come from, and is it safe? +func (service ServiceTx) Create(customTemplate *portainer.CustomTemplate) error { + return service.Tx.CreateObjectWithId(BucketName, int(customTemplate.ID), customTemplate) +} diff --git a/api/dataservices/customtemplate/tx_test.go b/api/dataservices/customtemplate/tx_test.go new file mode 100644 index 0000000..8f87cf0 --- /dev/null +++ b/api/dataservices/customtemplate/tx_test.go @@ -0,0 +1,29 @@ +package customtemplate_test + +import ( + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/datastore" + "github.com/stretchr/testify/require" +) + +func TestCustomTemplateCreateTx(t *testing.T) { + t.Parallel() + _, ds := datastore.MustNewTestStore(t, false, false) + require.NotNil(t, ds) + + require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error { + return tx.CustomTemplate().Create(&portainer.CustomTemplate{ID: 1}) + })) + + var template *portainer.CustomTemplate + require.NoError(t, ds.ViewTx(func(tx dataservices.DataStoreTx) error { + var err error + template, err = tx.CustomTemplate().Read(1) + return err + })) + + require.Equal(t, portainer.CustomTemplateID(1), template.ID) +} diff --git a/api/dataservices/dockerhub/dockerhub.go b/api/dataservices/dockerhub/dockerhub.go new file mode 100644 index 0000000..3d0b772 --- /dev/null +++ b/api/dataservices/dockerhub/dockerhub.go @@ -0,0 +1,49 @@ +package dockerhub + +import ( + portainer "github.com/portainer/portainer/api" +) + +const ( + // BucketName represents the name of the bucket where this service stores data. + BucketName = "dockerhub" + dockerHubKey = "DOCKERHUB" +) + +// Service represents a service for managing Dockerhub data. +type Service struct { + connection portainer.Connection +} + +func (service *Service) BucketName() string { + return BucketName +} + +// NewService creates a new instance of a service. +func NewService(connection portainer.Connection) (*Service, error) { + err := connection.SetServiceName(BucketName) + if err != nil { + return nil, err + } + + return &Service{ + connection: connection, + }, nil +} + +// DockerHub returns the DockerHub object. +func (service *Service) DockerHub() (*portainer.DockerHub, error) { + var dockerhub portainer.DockerHub + + err := service.connection.GetObject(BucketName, []byte(dockerHubKey), &dockerhub) + if err != nil { + return nil, err + } + + return &dockerhub, nil +} + +// UpdateDockerHub updates a DockerHub object. +func (service *Service) UpdateDockerHub(dockerhub *portainer.DockerHub) error { + return service.connection.UpdateObject(BucketName, []byte(dockerHubKey), dockerhub) +} diff --git a/api/dataservices/edgegroup/edgegroup.go b/api/dataservices/edgegroup/edgegroup.go new file mode 100644 index 0000000..315587e --- /dev/null +++ b/api/dataservices/edgegroup/edgegroup.go @@ -0,0 +1,60 @@ +package edgegroup + +import ( + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" +) + +// BucketName represents the name of the bucket where this service stores data. +const BucketName = "edgegroups" + +// Service represents a service for managing Edge group data. +type Service struct { + dataservices.BaseDataService[portainer.EdgeGroup, portainer.EdgeGroupID] +} + +func (service *Service) BucketName() string { + return BucketName +} + +// NewService creates a new instance of a service. +func NewService(connection portainer.Connection) (*Service, error) { + err := connection.SetServiceName(BucketName) + if err != nil { + return nil, err + } + + return &Service{ + BaseDataService: dataservices.BaseDataService[portainer.EdgeGroup, portainer.EdgeGroupID]{ + Bucket: BucketName, + Connection: connection, + }, + }, nil +} + +func (service *Service) Tx(tx portainer.Transaction) ServiceTx { + return ServiceTx{ + BaseDataServiceTx: dataservices.BaseDataServiceTx[portainer.EdgeGroup, portainer.EdgeGroupID]{ + Bucket: BucketName, + Connection: service.Connection, + Tx: tx, + }, + } +} + +// Deprecated: UpdateEdgeGroupFunc updates an edge group inside a transaction avoiding data races. +func (service *Service) UpdateEdgeGroupFunc(ID portainer.EdgeGroupID, updateFunc func(edgeGroup *portainer.EdgeGroup)) error { + id := service.Connection.ConvertToKey(int(ID)) + edgeGroup := &portainer.EdgeGroup{} + + return service.Connection.UpdateObjectFunc(BucketName, id, edgeGroup, func() { + updateFunc(edgeGroup) + }) +} + +// CreateEdgeGroup assign an ID to a new Edge group and saves it. +func (service *Service) Create(group *portainer.EdgeGroup) error { + return service.Connection.UpdateTx(func(tx portainer.Transaction) error { + return service.Tx(tx).Create(group) + }) +} diff --git a/api/dataservices/edgegroup/tx.go b/api/dataservices/edgegroup/tx.go new file mode 100644 index 0000000..2fba688 --- /dev/null +++ b/api/dataservices/edgegroup/tx.go @@ -0,0 +1,45 @@ +package edgegroup + +import ( + "errors" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" +) + +type ServiceTx struct { + dataservices.BaseDataServiceTx[portainer.EdgeGroup, portainer.EdgeGroupID] +} + +// UpdateEdgeGroupFunc is a no-op inside a transaction. +func (service ServiceTx) UpdateEdgeGroupFunc(ID portainer.EdgeGroupID, updateFunc func(edgeGroup *portainer.EdgeGroup)) error { + return errors.New("cannot be called inside a transaction") +} + +func (service ServiceTx) Create(group *portainer.EdgeGroup) error { + es := group.Endpoints + group.Endpoints = nil // Clear deprecated field + + err := service.Tx.CreateObject( + BucketName, + func(id uint64) (int, any) { + group.ID = portainer.EdgeGroupID(id) + return int(group.ID), group + }, + ) + + group.Endpoints = es // Restore endpoints after create + + return err +} + +func (service ServiceTx) Update(ID portainer.EdgeGroupID, group *portainer.EdgeGroup) error { + es := group.Endpoints + group.Endpoints = nil // Clear deprecated field + + err := service.BaseDataServiceTx.Update(ID, group) + + group.Endpoints = es // Restore endpoints after update + + return err +} diff --git a/api/dataservices/edgejob/edgejob.go b/api/dataservices/edgejob/edgejob.go new file mode 100644 index 0000000..1c3c907 --- /dev/null +++ b/api/dataservices/edgejob/edgejob.go @@ -0,0 +1,70 @@ +package edgejob + +import ( + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" +) + +// BucketName represents the name of the bucket where this service stores data. +const BucketName = "edgejobs" + +// Service represents a service for managing edge jobs data. +type Service struct { + dataservices.BaseDataService[portainer.EdgeJob, portainer.EdgeJobID] +} + +// NewService creates a new instance of a service. +func NewService(connection portainer.Connection) (*Service, error) { + err := connection.SetServiceName(BucketName) + if err != nil { + return nil, err + } + + return &Service{ + BaseDataService: dataservices.BaseDataService[portainer.EdgeJob, portainer.EdgeJobID]{ + Bucket: BucketName, + Connection: connection, + }, + }, nil +} + +func (service *Service) Tx(tx portainer.Transaction) ServiceTx { + return ServiceTx{ + BaseDataServiceTx: dataservices.BaseDataServiceTx[portainer.EdgeJob, portainer.EdgeJobID]{ + Bucket: BucketName, + Connection: service.Connection, + Tx: tx, + }, + } +} + +// Create creates a new EdgeJob +func (service *Service) Create(edgeJob *portainer.EdgeJob) error { + return service.CreateWithID(portainer.EdgeJobID(service.GetNextIdentifier()), edgeJob) +} + +// CreateWithID creates a new EdgeJob +func (service *Service) CreateWithID(ID portainer.EdgeJobID, edgeJob *portainer.EdgeJob) error { + edgeJob.ID = ID + + return service.Connection.CreateObjectWithId( + BucketName, + int(edgeJob.ID), + edgeJob, + ) +} + +// UpdateEdgeJobFunc updates an edge job inside a transaction avoiding data races. +func (service *Service) UpdateEdgeJobFunc(ID portainer.EdgeJobID, updateFunc func(edgeJob *portainer.EdgeJob)) error { + id := service.Connection.ConvertToKey(int(ID)) + edgeJob := &portainer.EdgeJob{} + + return service.Connection.UpdateObjectFunc(BucketName, id, edgeJob, func() { + updateFunc(edgeJob) + }) +} + +// GetNextIdentifier returns the next identifier for an environment(endpoint). +func (service *Service) GetNextIdentifier() int { + return service.Connection.GetNextIdentifier(BucketName) +} diff --git a/api/dataservices/edgejob/tx.go b/api/dataservices/edgejob/tx.go new file mode 100644 index 0000000..6a8111f --- /dev/null +++ b/api/dataservices/edgejob/tx.go @@ -0,0 +1,34 @@ +package edgejob + +import ( + "errors" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" +) + +type ServiceTx struct { + dataservices.BaseDataServiceTx[portainer.EdgeJob, portainer.EdgeJobID] +} + +// Create creates a new EdgeJob +func (service ServiceTx) Create(edgeJob *portainer.EdgeJob) error { + return service.CreateWithID(portainer.EdgeJobID(service.GetNextIdentifier()), edgeJob) +} + +// CreateWithID creates a new EdgeJob +func (service ServiceTx) CreateWithID(ID portainer.EdgeJobID, edgeJob *portainer.EdgeJob) error { + edgeJob.ID = ID + + return service.Tx.CreateObjectWithId(BucketName, int(edgeJob.ID), edgeJob) +} + +// UpdateEdgeJobFunc is a no-op inside a transaction. +func (service ServiceTx) UpdateEdgeJobFunc(ID portainer.EdgeJobID, updateFunc func(edgeJob *portainer.EdgeJob)) error { + return errors.New("cannot be called inside a transaction") +} + +// GetNextIdentifier returns the next identifier for an environment(endpoint). +func (service ServiceTx) GetNextIdentifier() int { + return service.Tx.GetNextIdentifier(BucketName) +} diff --git a/api/dataservices/edgestack/edgestack.go b/api/dataservices/edgestack/edgestack.go new file mode 100644 index 0000000..aa65610 --- /dev/null +++ b/api/dataservices/edgestack/edgestack.go @@ -0,0 +1,176 @@ +package edgestack + +import ( + "sync" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" +) + +// BucketName represents the name of the bucket where this service stores data. +const BucketName = "edge_stack" + +// Service represents a service for managing Edge stack data. +type Service struct { + connection portainer.Connection + idxVersion map[portainer.EdgeStackID]int + mu sync.RWMutex + cacheInvalidationFn func(portainer.Transaction, portainer.EdgeStackID) +} + +func (service *Service) BucketName() string { + return BucketName +} + +// NewService creates a new instance of a service. +func NewService(connection portainer.Connection, cacheInvalidationFn func(portainer.Transaction, portainer.EdgeStackID)) (*Service, error) { + err := connection.SetServiceName(BucketName) + if err != nil { + return nil, err + } + + s := &Service{ + connection: connection, + idxVersion: make(map[portainer.EdgeStackID]int), + cacheInvalidationFn: cacheInvalidationFn, + } + + if s.cacheInvalidationFn == nil { + s.cacheInvalidationFn = func(portainer.Transaction, portainer.EdgeStackID) {} + } + + es, err := s.EdgeStacks() + if err != nil { + return nil, err + } + + for _, e := range es { + s.idxVersion[e.ID] = e.Version + } + + return s, nil +} + +func (service *Service) Tx(tx portainer.Transaction) ServiceTx { + return ServiceTx{ + service: service, + tx: tx, + } +} + +// EdgeStacks returns an array containing all edge stacks +func (service *Service) EdgeStacks() ([]portainer.EdgeStack, error) { + var stacks = make([]portainer.EdgeStack, 0) + + return stacks, service.connection.GetAll( + BucketName, + &portainer.EdgeStack{}, + dataservices.AppendFn(&stacks), + ) +} + +// EdgeStack returns an Edge stack by ID. +func (service *Service) EdgeStack(ID portainer.EdgeStackID) (*portainer.EdgeStack, error) { + var stack portainer.EdgeStack + identifier := service.connection.ConvertToKey(int(ID)) + + err := service.connection.GetObject(BucketName, identifier, &stack) + if err != nil { + return nil, err + } + + return &stack, nil +} + +// EdgeStackVersion returns the version of the given edge stack ID directly from an in-memory index +func (service *Service) EdgeStackVersion(ID portainer.EdgeStackID) (int, bool) { + service.mu.RLock() + v, ok := service.idxVersion[ID] + service.mu.RUnlock() + + return v, ok +} + +// CreateEdgeStack saves an Edge stack object to db. +func (service *Service) Create(id portainer.EdgeStackID, edgeStack *portainer.EdgeStack) error { + edgeStack.ID = id + + err := service.connection.CreateObjectWithId( + BucketName, + int(edgeStack.ID), + edgeStack, + ) + if err != nil { + return err + } + + service.mu.Lock() + service.idxVersion[id] = edgeStack.Version + service.cacheInvalidationFn(service.connection, id) + service.mu.Unlock() + + return nil +} + +// Deprecated: Use UpdateEdgeStackFunc instead. +func (service *Service) UpdateEdgeStack(ID portainer.EdgeStackID, edgeStack *portainer.EdgeStack) error { + service.mu.Lock() + defer service.mu.Unlock() + + identifier := service.connection.ConvertToKey(int(ID)) + + err := service.connection.UpdateObject(BucketName, identifier, edgeStack) + if err != nil { + return err + } + + service.idxVersion[ID] = edgeStack.Version + service.cacheInvalidationFn(service.connection, ID) + + return nil +} + +// UpdateEdgeStackFunc updates an Edge stack inside a transaction avoiding data races. +func (service *Service) UpdateEdgeStackFunc(ID portainer.EdgeStackID, updateFunc func(edgeStack *portainer.EdgeStack)) error { + id := service.connection.ConvertToKey(int(ID)) + edgeStack := &portainer.EdgeStack{} + + service.mu.Lock() + defer service.mu.Unlock() + + return service.connection.UpdateObjectFunc(BucketName, id, edgeStack, func() { + updateFunc(edgeStack) + + service.idxVersion[ID] = edgeStack.Version + service.cacheInvalidationFn(service.connection, ID) + }) +} + +// UpdateEdgeStackFuncTx is a helper function used to call UpdateEdgeStackFunc inside a transaction. +func (service *Service) UpdateEdgeStackFuncTx(tx portainer.Transaction, ID portainer.EdgeStackID, updateFunc func(edgeStack *portainer.EdgeStack)) error { + return service.Tx(tx).UpdateEdgeStackFunc(ID, updateFunc) +} + +// DeleteEdgeStack deletes an Edge stack. +func (service *Service) DeleteEdgeStack(ID portainer.EdgeStackID) error { + service.mu.Lock() + defer service.mu.Unlock() + + identifier := service.connection.ConvertToKey(int(ID)) + + err := service.connection.DeleteObject(BucketName, identifier) + if err != nil { + return err + } + + delete(service.idxVersion, ID) + + service.cacheInvalidationFn(service.connection, ID) + + return nil +} + +// GetNextIdentifier returns the next identifier for an environment(endpoint). +func (service *Service) GetNextIdentifier() int { + return service.connection.GetNextIdentifier(BucketName) +} diff --git a/api/dataservices/edgestack/edgestack_test.go b/api/dataservices/edgestack/edgestack_test.go new file mode 100644 index 0000000..82413b0 --- /dev/null +++ b/api/dataservices/edgestack/edgestack_test.go @@ -0,0 +1,52 @@ +package edgestack + +import ( + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/database/boltdb" + "github.com/portainer/portainer/api/logs" + + "github.com/stretchr/testify/require" +) + +func TestUpdate(t *testing.T) { + t.Parallel() + var conn portainer.Connection = &boltdb.DbConnection{Path: t.TempDir()} + err := conn.Open() + require.NoError(t, err) + + defer logs.CloseAndLogErr(conn) + + service, err := NewService(conn, func(portainer.Transaction, portainer.EdgeStackID) {}) + require.NoError(t, err) + + const edgeStackID = 1 + edgeStack := &portainer.EdgeStack{ + ID: edgeStackID, + Name: "Test Stack", + } + + err = service.Create(edgeStackID, edgeStack) + require.NoError(t, err) + + err = service.UpdateEdgeStackFunc(edgeStackID, func(edgeStack *portainer.EdgeStack) { + edgeStack.Name = "Updated Stack" + }) + require.NoError(t, err) + + updatedStack, err := service.EdgeStack(edgeStackID) + require.NoError(t, err) + require.Equal(t, "Updated Stack", updatedStack.Name) + + err = conn.UpdateTx(func(tx portainer.Transaction) error { + return service.UpdateEdgeStackFuncTx(tx, edgeStackID, func(edgeStack *portainer.EdgeStack) { + edgeStack.Name = "Updated Stack Again" + }) + }) + require.NoError(t, err) + + updatedStack, err = service.EdgeStack(edgeStackID) + require.NoError(t, err) + require.Equal(t, "Updated Stack Again", updatedStack.Name) +} diff --git a/api/dataservices/edgestack/tx.go b/api/dataservices/edgestack/tx.go new file mode 100644 index 0000000..a2ec6b3 --- /dev/null +++ b/api/dataservices/edgestack/tx.go @@ -0,0 +1,121 @@ +package edgestack + +import ( + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" +) + +type ServiceTx struct { + service *Service + tx portainer.Transaction +} + +func (service ServiceTx) BucketName() string { + return BucketName +} + +// EdgeStacks returns an array containing all edge stacks +func (service ServiceTx) EdgeStacks() ([]portainer.EdgeStack, error) { + var stacks = make([]portainer.EdgeStack, 0) + + err := service.tx.GetAll( + BucketName, + &portainer.EdgeStack{}, + dataservices.AppendFn(&stacks), + ) + + return stacks, err +} + +// EdgeStack returns an Edge stack by ID. +func (service ServiceTx) EdgeStack(ID portainer.EdgeStackID) (*portainer.EdgeStack, error) { + var stack portainer.EdgeStack + identifier := service.service.connection.ConvertToKey(int(ID)) + + if err := service.tx.GetObject(BucketName, identifier, &stack); err != nil { + return nil, err + } + + return &stack, nil +} + +// EdgeStackVersion returns the version of the given edge stack ID directly from an in-memory index +func (service ServiceTx) EdgeStackVersion(ID portainer.EdgeStackID) (int, bool) { + service.service.mu.RLock() + v, ok := service.service.idxVersion[ID] + service.service.mu.RUnlock() + + return v, ok +} + +// CreateEdgeStack saves an Edge stack object to db. +func (service ServiceTx) Create(id portainer.EdgeStackID, edgeStack *portainer.EdgeStack) error { + edgeStack.ID = id + + if err := service.tx.CreateObjectWithId( + BucketName, + int(edgeStack.ID), + edgeStack, + ); err != nil { + return err + } + + service.service.mu.Lock() + service.service.idxVersion[id] = edgeStack.Version + service.service.cacheInvalidationFn(service.tx, id) + service.service.mu.Unlock() + + return nil +} + +// UpdateEdgeStack updates an Edge stack. +func (service ServiceTx) UpdateEdgeStack(ID portainer.EdgeStackID, edgeStack *portainer.EdgeStack) error { + service.service.mu.Lock() + defer service.service.mu.Unlock() + + identifier := service.service.connection.ConvertToKey(int(ID)) + + if err := service.tx.UpdateObject(BucketName, identifier, edgeStack); err != nil { + return err + } + + service.service.idxVersion[ID] = edgeStack.Version + service.service.cacheInvalidationFn(service.tx, ID) + + return nil +} + +// Deprecated: use UpdateEdgeStack inside a transaction instead. +func (service ServiceTx) UpdateEdgeStackFunc(ID portainer.EdgeStackID, updateFunc func(edgeStack *portainer.EdgeStack)) error { + edgeStack, err := service.EdgeStack(ID) + if err != nil { + return err + } + + updateFunc(edgeStack) + + return service.UpdateEdgeStack(ID, edgeStack) +} + +// DeleteEdgeStack deletes an Edge stack. +func (service ServiceTx) DeleteEdgeStack(ID portainer.EdgeStackID) error { + service.service.mu.Lock() + defer service.service.mu.Unlock() + + identifier := service.service.connection.ConvertToKey(int(ID)) + + if err := service.tx.DeleteObject(BucketName, identifier); err != nil { + return err + } + + delete(service.service.idxVersion, ID) + + service.service.cacheInvalidationFn(service.tx, ID) + + return nil +} + +// GetNextIdentifier returns the next identifier for an environment(endpoint). +func (service ServiceTx) GetNextIdentifier() int { + return service.tx.GetNextIdentifier(BucketName) +} diff --git a/api/dataservices/edgestackstatus/edgestackstatus.go b/api/dataservices/edgestackstatus/edgestackstatus.go new file mode 100644 index 0000000..8fbec25 --- /dev/null +++ b/api/dataservices/edgestackstatus/edgestackstatus.go @@ -0,0 +1,95 @@ +package edgestackstatus + +import ( + "encoding/binary" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" +) + +var _ dataservices.EdgeStackStatusService = &Service{} + +const BucketName = "edge_stack_status" + +type Service struct { + conn portainer.Connection +} + +func (service *Service) BucketName() string { + return BucketName +} + +func NewService(connection portainer.Connection) (*Service, error) { + if err := connection.SetServiceName(BucketName); err != nil { + return nil, err + } + + return &Service{conn: connection}, nil +} + +func (s *Service) Tx(tx portainer.Transaction) ServiceTx { + return ServiceTx{ + service: s, + tx: tx, + } +} + +func (s *Service) Create(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID, status *portainer.EdgeStackStatusForEnv) error { + return s.conn.UpdateTx(func(tx portainer.Transaction) error { + return s.Tx(tx).Create(edgeStackID, endpointID, status) + }) +} + +func (s *Service) Read(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID) (*portainer.EdgeStackStatusForEnv, error) { + var element *portainer.EdgeStackStatusForEnv + + return element, s.conn.ViewTx(func(tx portainer.Transaction) error { + var err error + element, err = s.Tx(tx).Read(edgeStackID, endpointID) + + return err + }) +} + +func (s *Service) ReadAll(edgeStackID portainer.EdgeStackID) ([]portainer.EdgeStackStatusForEnv, error) { + var collection = make([]portainer.EdgeStackStatusForEnv, 0) + + return collection, s.conn.ViewTx(func(tx portainer.Transaction) error { + var err error + collection, err = s.Tx(tx).ReadAll(edgeStackID) + + return err + }) +} + +func (s *Service) Update(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID, status *portainer.EdgeStackStatusForEnv) error { + return s.conn.UpdateTx(func(tx portainer.Transaction) error { + return s.Tx(tx).Update(edgeStackID, endpointID, status) + }) +} + +func (s *Service) Delete(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID) error { + return s.conn.UpdateTx(func(tx portainer.Transaction) error { + return s.Tx(tx).Delete(edgeStackID, endpointID) + }) +} + +func (s *Service) DeleteAll(edgeStackID portainer.EdgeStackID) error { + return s.conn.UpdateTx(func(tx portainer.Transaction) error { + return s.Tx(tx).DeleteAll(edgeStackID) + }) +} + +func (s *Service) Clear(edgeStackID portainer.EdgeStackID, relatedEnvironmentsIDs []portainer.EndpointID) error { + return s.conn.UpdateTx(func(tx portainer.Transaction) error { + return s.Tx(tx).Clear(edgeStackID, relatedEnvironmentsIDs) + }) +} + +func (s *Service) key(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID) []byte { + k := make([]byte, 16) + binary.BigEndian.PutUint64(k[:8], uint64(edgeStackID)) + binary.BigEndian.PutUint64(k[8:], uint64(endpointID)) + + return k +} diff --git a/api/dataservices/edgestackstatus/tx.go b/api/dataservices/edgestackstatus/tx.go new file mode 100644 index 0000000..b0dc148 --- /dev/null +++ b/api/dataservices/edgestackstatus/tx.go @@ -0,0 +1,95 @@ +package edgestackstatus + +import ( + "fmt" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" +) + +var _ dataservices.EdgeStackStatusService = &Service{} + +type ServiceTx struct { + service *Service + tx portainer.Transaction +} + +func (service ServiceTx) Create(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID, status *portainer.EdgeStackStatusForEnv) error { + identifier := service.service.key(edgeStackID, endpointID) + return service.tx.CreateObjectWithStringId(BucketName, identifier, status) +} + +func (s ServiceTx) Read(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID) (*portainer.EdgeStackStatusForEnv, error) { + var status portainer.EdgeStackStatusForEnv + identifier := s.service.key(edgeStackID, endpointID) + + if err := s.tx.GetObject(BucketName, identifier, &status); err != nil { + return nil, err + } + + return &status, nil +} + +func (s ServiceTx) ReadAll(edgeStackID portainer.EdgeStackID) ([]portainer.EdgeStackStatusForEnv, error) { + keyPrefix := s.service.conn.ConvertToKey(int(edgeStackID)) + + statuses := make([]portainer.EdgeStackStatusForEnv, 0) + + if err := s.tx.GetAllWithKeyPrefix(BucketName, keyPrefix, &portainer.EdgeStackStatusForEnv{}, dataservices.AppendFn(&statuses)); err != nil { + return nil, fmt.Errorf("unable to retrieve EdgeStackStatus for EdgeStack %d: %w", edgeStackID, err) + } + + return statuses, nil +} + +func (s ServiceTx) Update(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID, status *portainer.EdgeStackStatusForEnv) error { + identifier := s.service.key(edgeStackID, endpointID) + return s.tx.UpdateObject(BucketName, identifier, status) +} + +func (s ServiceTx) Delete(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID) error { + identifier := s.service.key(edgeStackID, endpointID) + return s.tx.DeleteObject(BucketName, identifier) +} + +func (s ServiceTx) DeleteAll(edgeStackID portainer.EdgeStackID) error { + keyPrefix := s.service.conn.ConvertToKey(int(edgeStackID)) + + statuses := make([]portainer.EdgeStackStatusForEnv, 0) + + if err := s.tx.GetAllWithKeyPrefix(BucketName, keyPrefix, &portainer.EdgeStackStatusForEnv{}, dataservices.AppendFn(&statuses)); err != nil { + return fmt.Errorf("unable to retrieve EdgeStackStatus for EdgeStack %d: %w", edgeStackID, err) + } + + for _, status := range statuses { + if err := s.tx.DeleteObject(BucketName, s.service.key(edgeStackID, status.EndpointID)); err != nil { + return fmt.Errorf("unable to delete EdgeStackStatus for EdgeStack %d and Endpoint %d: %w", edgeStackID, status.EndpointID, err) + } + } + + return nil +} + +func (s ServiceTx) Clear(edgeStackID portainer.EdgeStackID, relatedEnvironmentsIDs []portainer.EndpointID) error { + for _, envID := range relatedEnvironmentsIDs { + existingStatus, err := s.Read(edgeStackID, envID) + if err != nil && !dataservices.IsErrObjectNotFound(err) { + return fmt.Errorf("unable to retrieve status for environment %d: %w", envID, err) + } + + var deploymentInfo portainer.StackDeploymentInfo + if existingStatus != nil { + deploymentInfo = existingStatus.DeploymentInfo + } + + if err := s.Update(edgeStackID, envID, &portainer.EdgeStackStatusForEnv{ + EndpointID: envID, + Status: []portainer.EdgeStackDeploymentStatus{}, + DeploymentInfo: deploymentInfo, + }); err != nil { + return err + } + } + + return nil +} diff --git a/api/dataservices/endpoint/endpoint.go b/api/dataservices/endpoint/endpoint.go new file mode 100644 index 0000000..e8ae096 --- /dev/null +++ b/api/dataservices/endpoint/endpoint.go @@ -0,0 +1,194 @@ +package endpoint + +import ( + "sync" + "time" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + + "github.com/rs/zerolog/log" +) + +// BucketName represents the name of the bucket where this service stores data. +const BucketName = "endpoints" + +// Service represents a service for managing environment(endpoint) data. +type Service struct { + connection portainer.Connection + mu sync.RWMutex + idxEdgeID map[string]portainer.EndpointID + heartbeats sync.Map +} + +func (service *Service) BucketName() string { + return BucketName +} + +// NewService creates a new instance of a service. +func NewService(connection portainer.Connection) (*Service, error) { + err := connection.SetServiceName(BucketName) + if err != nil { + return nil, err + } + + s := &Service{ + connection: connection, + idxEdgeID: make(map[string]portainer.EndpointID), + } + + es, err := s.endpoints() + if err != nil { + return nil, err + } + + for _, e := range es { + if len(e.EdgeID) > 0 { + s.idxEdgeID[e.EdgeID] = e.ID + } + + s.heartbeats.Store(e.ID, e.LastCheckInDate) + } + + return s, nil +} + +func (service *Service) Tx(tx portainer.Transaction) ServiceTx { + return ServiceTx{ + service: service, + tx: tx, + } +} + +// Endpoint returns an environment(endpoint) by ID. +func (service *Service) Endpoint(ID portainer.EndpointID) (*portainer.Endpoint, error) { + var endpoint *portainer.Endpoint + var err error + + err = service.connection.ViewTx(func(tx portainer.Transaction) error { + endpoint, err = service.Tx(tx).Endpoint(ID) + return err + }) + if err != nil { + return nil, err + } + + endpoint.LastCheckInDate, _ = service.Heartbeat(ID) + + return endpoint, nil +} + +// UpdateEndpoint updates an environment(endpoint). +func (service *Service) UpdateEndpoint(ID portainer.EndpointID, endpoint *portainer.Endpoint) error { + return service.connection.UpdateTx(func(tx portainer.Transaction) error { + return service.Tx(tx).UpdateEndpoint(ID, endpoint) + }) +} + +// DeleteEndpoint deletes an environment(endpoint). +func (service *Service) DeleteEndpoint(ID portainer.EndpointID) error { + return service.connection.UpdateTx(func(tx portainer.Transaction) error { + return service.Tx(tx).DeleteEndpoint(ID) + }) +} + +func (service *Service) endpoints() ([]portainer.Endpoint, error) { + var endpoints []portainer.Endpoint + var err error + + err = service.connection.ViewTx(func(tx portainer.Transaction) error { + endpoints, err = service.Tx(tx).Endpoints() + return err + }) + + return endpoints, err +} + +// Endpoints return an array containing all the environments(endpoints). +func (service *Service) Endpoints() ([]portainer.Endpoint, error) { + endpoints, err := service.endpoints() + if err != nil { + return nil, err + } + + for i, e := range endpoints { + t, _ := service.Heartbeat(e.ID) + endpoints[i].LastCheckInDate = t + } + + return endpoints, nil +} + +// ReadAll retrieves all the elements that satisfy all the provided predicates. +func (service *Service) ReadAll(predicates ...func(endpoint portainer.Endpoint) bool) ([]portainer.Endpoint, error) { + var endpoints []portainer.Endpoint + var err error + + err = service.connection.ViewTx(func(tx portainer.Transaction) error { + endpoints, err = service.Tx(tx).ReadAll(predicates...) + return err + }) + + return endpoints, err +} + +// EndpointIDByEdgeID returns the EndpointID from the given EdgeID using an in-memory index +func (service *Service) EndpointIDByEdgeID(edgeID string) (portainer.EndpointID, bool) { + service.mu.RLock() + endpointID, ok := service.idxEdgeID[edgeID] + service.mu.RUnlock() + + return endpointID, ok +} + +func (service *Service) Heartbeat(endpointID portainer.EndpointID) (int64, bool) { + if t, ok := service.heartbeats.Load(endpointID); ok { + return t.(int64), true + } + + return 0, false +} + +func (service *Service) UpdateHeartbeat(endpointID portainer.EndpointID) { + service.heartbeats.Store(endpointID, time.Now().Unix()) +} + +// CreateEndpoint assign an ID to a new environment(endpoint) and saves it. +func (service *Service) Create(endpoint *portainer.Endpoint) error { + return service.connection.UpdateTx(func(tx portainer.Transaction) error { + return service.Tx(tx).Create(endpoint) + }) +} + +func (service *Service) EndpointsByTeamID(teamID portainer.TeamID) ([]portainer.Endpoint, error) { + var endpoints = make([]portainer.Endpoint, 0) + + return endpoints, service.connection.GetAll( + BucketName, + &portainer.Endpoint{}, + dataservices.FilterFn(&endpoints, func(e portainer.Endpoint) bool { + for t := range e.TeamAccessPolicies { + if t == teamID { + return true + } + } + + return false + }), + ) +} + +// GetNextIdentifier returns the next identifier for an environment(endpoint). +func (service *Service) GetNextIdentifier() int { + var identifier int + + if err := service.connection.UpdateTx(func(tx portainer.Transaction) error { + identifier = service.Tx(tx).GetNextIdentifier() + + return nil + }); err != nil { + log.Error().Err(err).Str("bucket", BucketName).Msg("could not get the next identifier") + } + + return identifier +} diff --git a/api/dataservices/endpoint/tx.go b/api/dataservices/endpoint/tx.go new file mode 100644 index 0000000..d06c717 --- /dev/null +++ b/api/dataservices/endpoint/tx.go @@ -0,0 +1,151 @@ +package endpoint + +import ( + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/internal/edge/cache" + + "github.com/rs/zerolog/log" +) + +type ServiceTx struct { + service *Service + tx portainer.Transaction +} + +func (service ServiceTx) BucketName() string { + return BucketName +} + +// Endpoint returns an environment(endpoint) by ID. +func (service ServiceTx) Endpoint(ID portainer.EndpointID) (*portainer.Endpoint, error) { + var endpoint portainer.Endpoint + + identifier := service.service.connection.ConvertToKey(int(ID)) + + if err := service.tx.GetObject(BucketName, identifier, &endpoint); err != nil { + return nil, err + } + + endpoint.LastCheckInDate, _ = service.service.Heartbeat(ID) + + return &endpoint, nil +} + +// UpdateEndpoint updates an environment(endpoint). +func (service ServiceTx) UpdateEndpoint(ID portainer.EndpointID, endpoint *portainer.Endpoint) error { + identifier := service.service.connection.ConvertToKey(int(ID)) + + if err := service.tx.UpdateObject(BucketName, identifier, endpoint); err != nil { + return err + } + + service.service.mu.Lock() + if len(endpoint.EdgeID) > 0 { + service.service.idxEdgeID[endpoint.EdgeID] = ID + } + + service.service.heartbeats.Store(ID, endpoint.LastCheckInDate) + service.service.mu.Unlock() + + cache.Del(endpoint.ID) + + return nil +} + +// DeleteEndpoint deletes an environment(endpoint). +func (service ServiceTx) DeleteEndpoint(ID portainer.EndpointID) error { + identifier := service.service.connection.ConvertToKey(int(ID)) + + if err := service.tx.DeleteObject(BucketName, identifier); err != nil { + return err + } + + service.service.mu.Lock() + for edgeID, endpointID := range service.service.idxEdgeID { + if endpointID == ID { + delete(service.service.idxEdgeID, edgeID) + + break + } + } + + service.service.heartbeats.Delete(ID) + service.service.mu.Unlock() + + cache.Del(ID) + + return nil +} + +// Endpoints return an array containing all the environments(endpoints). +func (service ServiceTx) Endpoints() ([]portainer.Endpoint, error) { + var endpoints = make([]portainer.Endpoint, 0) + + return endpoints, service.tx.GetAll( + BucketName, + &portainer.Endpoint{}, + dataservices.AppendFn(&endpoints), + ) +} + +// ReadAll retrieves all the elements that satisfy all the provided predicates. +func (service ServiceTx) ReadAll(predicates ...func(endpoint portainer.Endpoint) bool) ([]portainer.Endpoint, error) { + return dataservices.BaseDataServiceTx[portainer.Endpoint, portainer.EndpointID]{Bucket: BucketName, Connection: service.service.connection, Tx: service.tx}.ReadAll(predicates...) +} + +func (service ServiceTx) EndpointIDByEdgeID(edgeID string) (portainer.EndpointID, bool) { + log.Error().Str("func", "EndpointIDByEdgeID").Msg("cannot be called inside a transaction") + + return 0, false +} + +func (service ServiceTx) Heartbeat(endpointID portainer.EndpointID) (int64, bool) { + log.Error().Str("func", "Heartbeat").Msg("cannot be called inside a transaction") + + return 0, false +} + +func (service ServiceTx) UpdateHeartbeat(endpointID portainer.EndpointID) { + log.Error().Str("func", "UpdateHeartbeat").Msg("cannot be called inside a transaction") +} + +// CreateEndpoint assign an ID to a new environment(endpoint) and saves it. +func (service ServiceTx) Create(endpoint *portainer.Endpoint) error { + if err := service.tx.CreateObjectWithId(BucketName, int(endpoint.ID), endpoint); err != nil { + return err + } + + service.service.mu.Lock() + if len(endpoint.EdgeID) > 0 { + service.service.idxEdgeID[endpoint.EdgeID] = endpoint.ID + } + + service.service.heartbeats.Store(endpoint.ID, endpoint.LastCheckInDate) + service.service.mu.Unlock() + + return nil +} + +func (service ServiceTx) EndpointsByTeamID(teamID portainer.TeamID) ([]portainer.Endpoint, error) { + var endpoints = make([]portainer.Endpoint, 0) + + return endpoints, service.tx.GetAll( + BucketName, + &portainer.Endpoint{}, + dataservices.FilterFn(&endpoints, func(e portainer.Endpoint) bool { + for t := range e.TeamAccessPolicies { + if t == teamID { + return true + } + } + + return false + }), + ) +} + +// GetNextIdentifier returns the next identifier for an environment(endpoint). +func (service ServiceTx) GetNextIdentifier() int { + return service.tx.GetNextIdentifier(BucketName) +} diff --git a/api/dataservices/endpointgroup/endpointgroup.go b/api/dataservices/endpointgroup/endpointgroup.go new file mode 100644 index 0000000..2aef980 --- /dev/null +++ b/api/dataservices/endpointgroup/endpointgroup.go @@ -0,0 +1,49 @@ +package endpointgroup + +import ( + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" +) + +const BucketName = "endpoint_groups" + +// Service represents a service for managing environment(endpoint) data. +type Service struct { + dataservices.BaseDataService[portainer.EndpointGroup, portainer.EndpointGroupID] +} + +// NewService creates a new instance of a service. +func NewService(connection portainer.Connection) (*Service, error) { + err := connection.SetServiceName(BucketName) + if err != nil { + return nil, err + } + + return &Service{ + BaseDataService: dataservices.BaseDataService[portainer.EndpointGroup, portainer.EndpointGroupID]{ + Bucket: BucketName, + Connection: connection, + }, + }, nil +} + +func (service *Service) Tx(tx portainer.Transaction) ServiceTx { + return ServiceTx{ + BaseDataServiceTx: dataservices.BaseDataServiceTx[portainer.EndpointGroup, portainer.EndpointGroupID]{ + Bucket: BucketName, + Connection: service.Connection, + Tx: tx, + }, + } +} + +// CreateEndpointGroup assign an ID to a new environment(endpoint) group and saves it. +func (service *Service) Create(endpointGroup *portainer.EndpointGroup) error { + return service.Connection.CreateObject( + BucketName, + func(id uint64) (int, any) { + endpointGroup.ID = portainer.EndpointGroupID(id) + return int(endpointGroup.ID), endpointGroup + }, + ) +} diff --git a/api/dataservices/endpointgroup/tx.go b/api/dataservices/endpointgroup/tx.go new file mode 100644 index 0000000..65c3dc3 --- /dev/null +++ b/api/dataservices/endpointgroup/tx.go @@ -0,0 +1,21 @@ +package endpointgroup + +import ( + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" +) + +type ServiceTx struct { + dataservices.BaseDataServiceTx[portainer.EndpointGroup, portainer.EndpointGroupID] +} + +// CreateEndpointGroup assign an ID to a new environment(endpoint) group and saves it. +func (service ServiceTx) Create(endpointGroup *portainer.EndpointGroup) error { + return service.Tx.CreateObject( + BucketName, + func(id uint64) (int, any) { + endpointGroup.ID = portainer.EndpointGroupID(id) + return int(endpointGroup.ID), endpointGroup + }, + ) +} diff --git a/api/dataservices/endpointrelation/endpointrelation.go b/api/dataservices/endpointrelation/endpointrelation.go new file mode 100644 index 0000000..509def8 --- /dev/null +++ b/api/dataservices/endpointrelation/endpointrelation.go @@ -0,0 +1,114 @@ +package endpointrelation + +import ( + "sync" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/internal/edge/cache" +) + +// BucketName represents the name of the bucket where this service stores data. +const BucketName = "endpoint_relations" + +// Service represents a service for managing environment(endpoint) relation data. +type Service struct { + connection portainer.Connection + updateStackFnTx func(tx portainer.Transaction, ID portainer.EdgeStackID, updateFunc func(edgeStack *portainer.EdgeStack)) error + endpointRelationsCache []portainer.EndpointRelation + mu sync.Mutex +} + +var _ dataservices.EndpointRelationService = &Service{} + +func (service *Service) BucketName() string { + return BucketName +} + +func (service *Service) RegisterUpdateStackFunction( + updateFuncTx func(portainer.Transaction, portainer.EdgeStackID, func(*portainer.EdgeStack)) error, +) { + service.mu.Lock() + defer service.mu.Unlock() + + service.updateStackFnTx = updateFuncTx +} + +// NewService creates a new instance of a service. +func NewService(connection portainer.Connection) (*Service, error) { + if err := connection.SetServiceName(BucketName); err != nil { + return nil, err + } + + return &Service{ + connection: connection, + }, nil +} + +func (service *Service) Tx(tx portainer.Transaction) ServiceTx { + return ServiceTx{ + service: service, + tx: tx, + } +} + +// EndpointRelations returns an array of all EndpointRelations +func (service *Service) EndpointRelations() ([]portainer.EndpointRelation, error) { + var all = make([]portainer.EndpointRelation, 0) + + return all, service.connection.GetAll( + BucketName, + &portainer.EndpointRelation{}, + dataservices.AppendFn(&all), + ) +} + +// EndpointRelation returns a Environment(Endpoint) relation object by EndpointID +func (service *Service) EndpointRelation(endpointID portainer.EndpointID) (*portainer.EndpointRelation, error) { + var endpointRelation portainer.EndpointRelation + identifier := service.connection.ConvertToKey(int(endpointID)) + + if err := service.connection.GetObject(BucketName, identifier, &endpointRelation); err != nil { + return nil, err + } + + return &endpointRelation, nil +} + +// CreateEndpointRelation saves endpointRelation +func (service *Service) Create(endpointRelation *portainer.EndpointRelation) error { + err := service.connection.CreateObjectWithId(BucketName, int(endpointRelation.EndpointID), endpointRelation) + cache.Del(endpointRelation.EndpointID) + + service.mu.Lock() + service.endpointRelationsCache = nil + service.mu.Unlock() + + return err +} + +// UpdateEndpointRelation updates an Environment(Endpoint) relation object +func (service *Service) UpdateEndpointRelation(endpointID portainer.EndpointID, endpointRelation *portainer.EndpointRelation) error { + return service.connection.UpdateTx(func(tx portainer.Transaction) error { + return service.Tx(tx).UpdateEndpointRelation(endpointID, endpointRelation) + }) +} + +func (service *Service) AddEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStack *portainer.EdgeStack) error { + return service.connection.UpdateTx(func(tx portainer.Transaction) error { + return service.Tx(tx).AddEndpointRelationsForEdgeStack(endpointIDs, edgeStack) + }) +} + +func (service *Service) RemoveEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error { + return service.connection.UpdateTx(func(tx portainer.Transaction) error { + return service.Tx(tx).RemoveEndpointRelationsForEdgeStack(endpointIDs, edgeStackID) + }) +} + +// DeleteEndpointRelation deletes an Environment(Endpoint) relation object +func (service *Service) DeleteEndpointRelation(endpointID portainer.EndpointID) error { + return service.connection.UpdateTx(func(tx portainer.Transaction) error { + return service.Tx(tx).DeleteEndpointRelation(endpointID) + }) +} diff --git a/api/dataservices/endpointrelation/endpointrelation_test.go b/api/dataservices/endpointrelation/endpointrelation_test.go new file mode 100644 index 0000000..c906150 --- /dev/null +++ b/api/dataservices/endpointrelation/endpointrelation_test.go @@ -0,0 +1,144 @@ +package endpointrelation + +import ( + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/database/boltdb" + "github.com/portainer/portainer/api/dataservices/edgestack" + "github.com/portainer/portainer/api/internal/edge/cache" + "github.com/portainer/portainer/api/logs" + + "github.com/stretchr/testify/require" +) + +func TestUpdateRelation(t *testing.T) { + t.Parallel() + const endpointID = 1 + const edgeStackID1 = 1 + const edgeStackID2 = 2 + + var conn portainer.Connection = &boltdb.DbConnection{Path: t.TempDir()} + err := conn.Open() + require.NoError(t, err) + + defer logs.CloseAndLogErr(conn) + + service, err := NewService(conn) + require.NoError(t, err) + + updateStackFnTxCalled := false + + edgeStacks := make(map[portainer.EdgeStackID]portainer.EdgeStack) + edgeStacks[edgeStackID1] = portainer.EdgeStack{ID: edgeStackID1} + edgeStacks[edgeStackID2] = portainer.EdgeStack{ID: edgeStackID2} + + service.RegisterUpdateStackFunction(func(tx portainer.Transaction, ID portainer.EdgeStackID, updateFunc func(edgeStack *portainer.EdgeStack)) error { + updateStackFnTxCalled = true + + s, ok := edgeStacks[ID] + require.True(t, ok) + + updateFunc(&s) + edgeStacks[ID] = s + + return nil + }) + + // Nil relation + + cache.Set(endpointID, []byte("value")) + + err = service.UpdateEndpointRelation(endpointID, nil) + _, cacheKeyExists := cache.Get(endpointID) + require.NoError(t, err) + require.False(t, updateStackFnTxCalled) + require.False(t, cacheKeyExists) + + // Add a relation to two edge stacks + + cache.Set(endpointID, []byte("value")) + + err = service.UpdateEndpointRelation(endpointID, &portainer.EndpointRelation{ + EndpointID: endpointID, + EdgeStacks: map[portainer.EdgeStackID]bool{ + edgeStackID1: true, + edgeStackID2: true, + }, + }) + _, cacheKeyExists = cache.Get(endpointID) + require.NoError(t, err) + require.True(t, updateStackFnTxCalled) + require.False(t, cacheKeyExists) + require.Equal(t, 1, edgeStacks[edgeStackID1].NumDeployments) + require.Equal(t, 1, edgeStacks[edgeStackID2].NumDeployments) + + // Remove a relation to one edge stack + + updateStackFnTxCalled = false + cache.Set(endpointID, []byte("value")) + + err = service.UpdateEndpointRelation(endpointID, &portainer.EndpointRelation{ + EndpointID: endpointID, + EdgeStacks: map[portainer.EdgeStackID]bool{ + 2: true, + }, + }) + _, cacheKeyExists = cache.Get(endpointID) + require.NoError(t, err) + require.True(t, updateStackFnTxCalled) + require.False(t, cacheKeyExists) + require.Equal(t, 0, edgeStacks[edgeStackID1].NumDeployments) + require.Equal(t, 1, edgeStacks[edgeStackID2].NumDeployments) + + // Delete the relation + + updateStackFnTxCalled = false + cache.Set(endpointID, []byte("value")) + + err = service.DeleteEndpointRelation(endpointID) + + _, cacheKeyExists = cache.Get(endpointID) + require.NoError(t, err) + require.True(t, updateStackFnTxCalled) + require.False(t, cacheKeyExists) + require.Equal(t, 0, edgeStacks[edgeStackID1].NumDeployments) + require.Equal(t, 0, edgeStacks[edgeStackID2].NumDeployments) +} + +func TestAddEndpointRelationsForEdgeStack(t *testing.T) { + t.Parallel() + var conn portainer.Connection = &boltdb.DbConnection{Path: t.TempDir()} + err := conn.Open() + require.NoError(t, err) + + defer logs.CloseAndLogErr(conn) + + service, err := NewService(conn) + require.NoError(t, err) + + edgeStackService, err := edgestack.NewService(conn, func(t portainer.Transaction, esi portainer.EdgeStackID) {}) + require.NoError(t, err) + + service.RegisterUpdateStackFunction(edgeStackService.UpdateEdgeStackFuncTx) + require.NoError(t, edgeStackService.Create(1, &portainer.EdgeStack{})) + require.NoError(t, service.Create(&portainer.EndpointRelation{EndpointID: 1, EdgeStacks: map[portainer.EdgeStackID]bool{}})) + require.NoError(t, service.AddEndpointRelationsForEdgeStack([]portainer.EndpointID{1}, &portainer.EdgeStack{ID: 1})) +} + +func TestEndpointRelations(t *testing.T) { + t.Parallel() + var conn portainer.Connection = &boltdb.DbConnection{Path: t.TempDir()} + err := conn.Open() + require.NoError(t, err) + + defer logs.CloseAndLogErr(conn) + + service, err := NewService(conn) + require.NoError(t, err) + + require.NoError(t, service.Create(&portainer.EndpointRelation{EndpointID: 1})) + rels, err := service.EndpointRelations() + require.NoError(t, err) + require.Len(t, rels, 1) +} diff --git a/api/dataservices/endpointrelation/tx.go b/api/dataservices/endpointrelation/tx.go new file mode 100644 index 0000000..36639a4 --- /dev/null +++ b/api/dataservices/endpointrelation/tx.go @@ -0,0 +1,238 @@ +package endpointrelation + +import ( + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/internal/edge/cache" + + "github.com/rs/zerolog/log" +) + +type ServiceTx struct { + service *Service + tx portainer.Transaction +} + +var _ dataservices.EndpointRelationService = &ServiceTx{} + +func (service ServiceTx) BucketName() string { + return BucketName +} + +// EndpointRelations returns an array of all EndpointRelations +func (service ServiceTx) EndpointRelations() ([]portainer.EndpointRelation, error) { + var all = make([]portainer.EndpointRelation, 0) + + return all, service.tx.GetAll( + BucketName, + &portainer.EndpointRelation{}, + dataservices.AppendFn(&all), + ) +} + +// EndpointRelation returns an Environment(Endpoint) relation object by EndpointID +func (service ServiceTx) EndpointRelation(endpointID portainer.EndpointID) (*portainer.EndpointRelation, error) { + var endpointRelation portainer.EndpointRelation + identifier := service.service.connection.ConvertToKey(int(endpointID)) + + if err := service.tx.GetObject(BucketName, identifier, &endpointRelation); err != nil { + return nil, err + } + + return &endpointRelation, nil +} + +// CreateEndpointRelation saves endpointRelation +func (service ServiceTx) Create(endpointRelation *portainer.EndpointRelation) error { + err := service.tx.CreateObjectWithId(BucketName, int(endpointRelation.EndpointID), endpointRelation) + cache.Del(endpointRelation.EndpointID) + + service.service.mu.Lock() + service.service.endpointRelationsCache = nil + service.service.mu.Unlock() + + return err +} + +// UpdateEndpointRelation updates an Environment(Endpoint) relation object +func (service ServiceTx) UpdateEndpointRelation(endpointID portainer.EndpointID, endpointRelation *portainer.EndpointRelation) error { + previousRelationState, _ := service.EndpointRelation(endpointID) + + identifier := service.service.connection.ConvertToKey(int(endpointID)) + err := service.tx.UpdateObject(BucketName, identifier, endpointRelation) + cache.Del(endpointID) + if err != nil { + return err + } + + updatedRelationState, _ := service.EndpointRelation(endpointID) + + service.service.mu.Lock() + service.service.endpointRelationsCache = nil + service.service.mu.Unlock() + + service.updateEdgeStacksAfterRelationChange(previousRelationState, updatedRelationState) + + return nil +} + +func (service ServiceTx) AddEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStack *portainer.EdgeStack) error { + for _, endpointID := range endpointIDs { + rel, err := service.EndpointRelation(endpointID) + if err != nil { + return err + } + + rel.EdgeStacks[edgeStack.ID] = true + + identifier := service.service.connection.ConvertToKey(int(endpointID)) + err = service.tx.UpdateObject(BucketName, identifier, rel) + cache.Del(endpointID) + if err != nil { + return err + } + } + + service.service.mu.Lock() + service.service.endpointRelationsCache = nil + service.service.mu.Unlock() + + if err := service.service.updateStackFnTx(service.tx, edgeStack.ID, func(es *portainer.EdgeStack) { + es.NumDeployments += len(endpointIDs) + + // sync changes in `edgeStack` in case it is re-persisted after `AddEndpointRelationsForEdgeStack` call + // to avoid overriding with the previous values + edgeStack.NumDeployments = es.NumDeployments + }); err != nil { + log.Error().Err(err).Msg("could not update the number of deployments") + } + + return nil +} + +func (service ServiceTx) RemoveEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error { + for _, endpointID := range endpointIDs { + rel, err := service.EndpointRelation(endpointID) + if err != nil { + return err + } + + delete(rel.EdgeStacks, edgeStackID) + + identifier := service.service.connection.ConvertToKey(int(endpointID)) + err = service.tx.UpdateObject(BucketName, identifier, rel) + cache.Del(endpointID) + if err != nil { + return err + } + } + + service.service.mu.Lock() + service.service.endpointRelationsCache = nil + service.service.mu.Unlock() + + if err := service.service.updateStackFnTx(service.tx, edgeStackID, func(edgeStack *portainer.EdgeStack) { + edgeStack.NumDeployments -= len(endpointIDs) + }); err != nil { + log.Error().Err(err).Msg("could not update the number of deployments") + } + + return nil +} + +// DeleteEndpointRelation deletes an Environment(Endpoint) relation object +func (service ServiceTx) DeleteEndpointRelation(endpointID portainer.EndpointID) error { + deletedRelation, _ := service.EndpointRelation(endpointID) + + identifier := service.service.connection.ConvertToKey(int(endpointID)) + err := service.tx.DeleteObject(BucketName, identifier) + cache.Del(endpointID) + if err != nil { + return err + } + + service.service.mu.Lock() + service.service.endpointRelationsCache = nil + service.service.mu.Unlock() + + service.updateEdgeStacksAfterRelationChange(deletedRelation, nil) + + return nil +} + +func (service ServiceTx) InvalidateEdgeCacheForEdgeStack(edgeStackID portainer.EdgeStackID) { + rels, err := service.cachedEndpointRelations() + if err != nil { + log.Error().Err(err).Msg("cannot retrieve endpoint relations") + return + } + + for _, rel := range rels { + if _, ok := rel.EdgeStacks[edgeStackID]; ok { + cache.Del(rel.EndpointID) + } + } +} + +func (service ServiceTx) cachedEndpointRelations() ([]portainer.EndpointRelation, error) { + service.service.mu.Lock() + defer service.service.mu.Unlock() + + if service.service.endpointRelationsCache == nil { + var err error + service.service.endpointRelationsCache, err = service.EndpointRelations() + if err != nil { + return nil, err + } + } + + return service.service.endpointRelationsCache, nil +} + +func (service ServiceTx) updateEdgeStacksAfterRelationChange(previousRelationState *portainer.EndpointRelation, updatedRelationState *portainer.EndpointRelation) { + if previousRelationState != nil { + for stackId, enabled := range previousRelationState.EdgeStacks { + // flag stack for update if stack is not in the updated relation state + // = stack has been removed for this relation + // or this relation has been deleted + if enabled && (updatedRelationState == nil || !updatedRelationState.EdgeStacks[stackId]) { + if err := service.service.updateStackFnTx(service.tx, stackId, func(edgeStack *portainer.EdgeStack) { + // Sanity check + if edgeStack.NumDeployments <= 0 { + log.Error(). + Int("edgestack_id", int(edgeStack.ID)). + Int("endpoint_id", int(previousRelationState.EndpointID)). + Int("num_deployments", edgeStack.NumDeployments). + Msg("cannot decrement the number of deployments for an edge stack with zero deployments") + + return + } + + edgeStack.NumDeployments-- + }); err != nil { + log.Error().Err(err).Msg("could not update the number of deployments") + } + + cache.Del(previousRelationState.EndpointID) + } + } + } + + if updatedRelationState == nil { + return + } + + for stackId, enabled := range updatedRelationState.EdgeStacks { + // flag stack for update if stack is not in the previous relation state + // = stack has been added for this relation + if enabled && (previousRelationState == nil || !previousRelationState.EdgeStacks[stackId]) { + if err := service.service.updateStackFnTx(service.tx, stackId, func(edgeStack *portainer.EdgeStack) { + edgeStack.NumDeployments++ + }); err != nil { + log.Error().Err(err).Msg("could not update the number of deployments") + } + + cache.Del(updatedRelationState.EndpointID) + } + } +} diff --git a/api/dataservices/errors/errors.go b/api/dataservices/errors/errors.go new file mode 100644 index 0000000..f8fbe84 --- /dev/null +++ b/api/dataservices/errors/errors.go @@ -0,0 +1,12 @@ +package errors + +import ( + "errors" +) + +var ( + ErrObjectNotFound = errors.New("object not found inside the database") + ErrWrongDBEdition = errors.New("the Portainer database is set for Portainer Business Edition, please follow the instructions in our documentation to downgrade it: https://docs.portainer.io/faqs/upgrading/can-i-downgrade-from-portainer-business-to-portainer-ce") + ErrDBImportFailed = errors.New("importing backup failed") + ErrDatabaseIsUpdating = errors.New("database is currently in updating state. Failed prior upgrade. Please restore from backup or delete the database and restart Portainer") +) diff --git a/api/dataservices/extension/extension.go b/api/dataservices/extension/extension.go new file mode 100644 index 0000000..2af6fac --- /dev/null +++ b/api/dataservices/extension/extension.go @@ -0,0 +1,66 @@ +package extension + +import ( + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" +) + +// BucketName represents the name of the bucket where this service stores data. +const BucketName = "extension" + +// Service represents a service for managing environment(endpoint) data. +type Service struct { + connection portainer.Connection +} + +func (service *Service) BucketName() string { + return BucketName +} + +// NewService creates a new instance of a service. +func NewService(connection portainer.Connection) (*Service, error) { + err := connection.SetServiceName(BucketName) + if err != nil { + return nil, err + } + + return &Service{ + connection: connection, + }, nil +} + +// Extension returns a extension by ID +func (service *Service) Extension(ID portainer.ExtensionID) (*portainer.Extension, error) { + var extension portainer.Extension + identifier := service.connection.ConvertToKey(int(ID)) + + err := service.connection.GetObject(BucketName, identifier, &extension) + if err != nil { + return nil, err + } + + return &extension, nil +} + +// Extensions return an array containing all the extensions. +func (service *Service) Extensions() ([]portainer.Extension, error) { + var extensions = make([]portainer.Extension, 0) + + return extensions, service.connection.GetAll( + BucketName, + &portainer.Extension{}, + dataservices.AppendFn(&extensions), + ) + +} + +// Persist persists a extension inside the database. +func (service *Service) Persist(extension *portainer.Extension) error { + return service.connection.CreateObjectWithId(BucketName, int(extension.ID), extension) +} + +// DeleteExtension deletes a Extension. +func (service *Service) DeleteExtension(ID portainer.ExtensionID) error { + identifier := service.connection.ConvertToKey(int(ID)) + return service.connection.DeleteObject(BucketName, identifier) +} diff --git a/api/dataservices/helmuserrepository/helmuserrepository.go b/api/dataservices/helmuserrepository/helmuserrepository.go new file mode 100644 index 0000000..0b2dbcf --- /dev/null +++ b/api/dataservices/helmuserrepository/helmuserrepository.go @@ -0,0 +1,53 @@ +package helmuserrepository + +import ( + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" +) + +// BucketName represents the name of the bucket where this service stores data. +const BucketName = "helm_user_repository" + +// Service represents a service for managing environment(endpoint) data. +type Service struct { + dataservices.BaseDataService[portainer.HelmUserRepository, portainer.HelmUserRepositoryID] +} + +// NewService creates a new instance of a service. +func NewService(connection portainer.Connection) (*Service, error) { + err := connection.SetServiceName(BucketName) + if err != nil { + return nil, err + } + + return &Service{ + BaseDataService: dataservices.BaseDataService[portainer.HelmUserRepository, portainer.HelmUserRepositoryID]{ + Bucket: BucketName, + Connection: connection, + }, + }, nil +} + +// HelmUserRepositoryByUserID return an array containing all the HelmUserRepository objects where the specified userID is present. +func (service *Service) HelmUserRepositoryByUserID(userID portainer.UserID) ([]portainer.HelmUserRepository, error) { + var result = make([]portainer.HelmUserRepository, 0) + + return result, service.Connection.GetAll( + BucketName, + &portainer.HelmUserRepository{}, + dataservices.FilterFn(&result, func(e portainer.HelmUserRepository) bool { + return e.UserID == userID + }), + ) +} + +// CreateHelmUserRepository creates a new HelmUserRepository object. +func (service *Service) Create(record *portainer.HelmUserRepository) error { + return service.Connection.CreateObject( + BucketName, + func(id uint64) (int, any) { + record.ID = portainer.HelmUserRepositoryID(id) + return int(record.ID), record + }, + ) +} diff --git a/api/dataservices/helpers.go b/api/dataservices/helpers.go new file mode 100644 index 0000000..e2733fa --- /dev/null +++ b/api/dataservices/helpers.go @@ -0,0 +1,77 @@ +package dataservices + +import ( + "errors" + "fmt" + + perrors "github.com/portainer/portainer/api/dataservices/errors" + + "github.com/rs/zerolog/log" +) + +// ErrStop signals the stop of computation when filtering results +var ErrStop = errors.New("stop") + +func IsErrObjectNotFound(e error) bool { + return errors.Is(e, perrors.ErrObjectNotFound) +} + +// AppendFn appends elements to the given collection slice +func AppendFn[T any](collection *[]T) func(obj any) (any, error) { + return func(obj any) (any, error) { + element, ok := obj.(*T) + if !ok { + log.Debug().Str("obj", fmt.Sprintf("%#v", obj)).Msg("type assertion failed") + return nil, fmt.Errorf("failed to convert to %T object: %#v", new(T), obj) + } + + *collection = append(*collection, *element) + + var zero T + *element = zero + + return element, nil + } +} + +// FilterFn appends elements to the given collection when the predicate is true +func FilterFn[T any](collection *[]T, predicate func(T) bool) func(obj any) (any, error) { + return func(obj any) (any, error) { + element, ok := obj.(*T) + if !ok { + log.Debug().Str("obj", fmt.Sprintf("%#v", obj)).Msg("type assertion failed") + return nil, fmt.Errorf("failed to convert to %T object: %#v", new(T), obj) + } + + if predicate(*element) { + *collection = append(*collection, *element) + } + + var zero T + *element = zero + + return element, nil + } +} + +// FirstFn sets the element to the first one that satisfies the predicate and stops the computation, returns ErrStop on +// success +func FirstFn[T any](element *T, predicate func(T) bool) func(obj any) (any, error) { + return func(obj any) (any, error) { + e, ok := obj.(*T) + if !ok { + log.Debug().Str("obj", fmt.Sprintf("%#v", obj)).Msg("type assertion failed") + return nil, fmt.Errorf("failed to convert to %T object: %#v", new(T), obj) + } + + if predicate(*e) { + *element = *e + return e, ErrStop + } + + var zero T + *e = zero + + return e, nil + } +} diff --git a/api/dataservices/interface.go b/api/dataservices/interface.go new file mode 100644 index 0000000..c301616 --- /dev/null +++ b/api/dataservices/interface.go @@ -0,0 +1,282 @@ +package dataservices + +import ( + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/database/models" +) + +type ( + DataStoreTx interface { + IsErrObjectNotFound(err error) bool + AllowList() AllowListService + CustomTemplate() CustomTemplateService + EdgeGroup() EdgeGroupService + EdgeJob() EdgeJobService + EdgeStack() EdgeStackService + EdgeStackStatus() EdgeStackStatusService + Endpoint() EndpointService + EndpointGroup() EndpointGroupService + EndpointRelation() EndpointRelationService + HelmUserRepository() HelmUserRepositoryService + Registry() RegistryService + ResourceControl() ResourceControlService + Role() RoleService + APIKeyRepository() APIKeyRepository + Settings() SettingsService + Snapshot() SnapshotService + SSLSettings() SSLSettingsService + Source() SourceService + Stack() StackService + Tag() TagService + TeamMembership() TeamMembershipService + Team() TeamService + TunnelServer() TunnelServerService + User() UserService + Version() VersionService + Webhook() WebhookService + Workflow() WorkflowService + PendingActions() PendingActionsService + } + + DataStore interface { + Connection() portainer.Connection + Open() (newStore bool, err error) + Init() error + Close() error + UpdateTx(func(tx DataStoreTx) error) error + ViewTx(func(tx DataStoreTx) error) error + MigrateData() error + Rollback(force bool) error + CheckCurrentEdition() error + Backup(path string) (string, error) + Export(filename string) (err error) + + DataStoreTx + } + + // AllowListService represents a service for managing the URL allow list + AllowListService interface { + Read(id portainer.AllowListKey) (*portainer.AllowList, error) + ReadAll() ([]portainer.AllowList, error) + ReadParsed(id portainer.AllowListKey) (*portainer.ParsedAllowList, error) + Update(id portainer.AllowListKey, allowList *portainer.AllowList) error + BucketName() string + } + + // CustomTemplateService represents a service to manage custom templates + CustomTemplateService interface { + BaseCRUD[portainer.CustomTemplate, portainer.CustomTemplateID] + GetNextIdentifier() int + } + + // EdgeGroupService represents a service to manage Edge groups + EdgeGroupService interface { + BaseCRUD[portainer.EdgeGroup, portainer.EdgeGroupID] + UpdateEdgeGroupFunc(ID portainer.EdgeGroupID, updateFunc func(group *portainer.EdgeGroup)) error + } + + // EdgeJobService represents a service to manage Edge jobs + EdgeJobService interface { + BaseCRUD[portainer.EdgeJob, portainer.EdgeJobID] + CreateWithID(ID portainer.EdgeJobID, edgeJob *portainer.EdgeJob) error + UpdateEdgeJobFunc(ID portainer.EdgeJobID, updateFunc func(edgeJob *portainer.EdgeJob)) error + GetNextIdentifier() int + } + + PendingActionsService interface { + BaseCRUD[portainer.PendingAction, portainer.PendingActionID] + GetNextIdentifier() int + DeleteByEndpointID(ID portainer.EndpointID) error + } + + // EdgeStackService represents a service to manage Edge stacks + EdgeStackService interface { + EdgeStacks() ([]portainer.EdgeStack, error) + EdgeStack(ID portainer.EdgeStackID) (*portainer.EdgeStack, error) + EdgeStackVersion(ID portainer.EdgeStackID) (int, bool) + Create(id portainer.EdgeStackID, edgeStack *portainer.EdgeStack) error + UpdateEdgeStack(ID portainer.EdgeStackID, edgeStack *portainer.EdgeStack) error + UpdateEdgeStackFunc(ID portainer.EdgeStackID, updateFunc func(edgeStack *portainer.EdgeStack)) error + DeleteEdgeStack(ID portainer.EdgeStackID) error + GetNextIdentifier() int + BucketName() string + } + + EdgeStackStatusService interface { + Create(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID, status *portainer.EdgeStackStatusForEnv) error + Read(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID) (*portainer.EdgeStackStatusForEnv, error) + ReadAll(edgeStackID portainer.EdgeStackID) ([]portainer.EdgeStackStatusForEnv, error) + Update(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID, status *portainer.EdgeStackStatusForEnv) error + Delete(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID) error + DeleteAll(edgeStackID portainer.EdgeStackID) error + Clear(edgeStackID portainer.EdgeStackID, relatedEnvironmentsIDs []portainer.EndpointID) error + } + + // EndpointService represents a service for managing environment(endpoint) data + EndpointService interface { + // partial dataservices.BaseCRUD[portainer.Endpoint, portainer.EndpointID] + ReadAll(predicates ...func(endpoint portainer.Endpoint) bool) ([]portainer.Endpoint, error) + + Endpoint(ID portainer.EndpointID) (*portainer.Endpoint, error) + EndpointIDByEdgeID(edgeID string) (portainer.EndpointID, bool) + EndpointsByTeamID(teamID portainer.TeamID) ([]portainer.Endpoint, error) + Heartbeat(endpointID portainer.EndpointID) (int64, bool) + UpdateHeartbeat(endpointID portainer.EndpointID) + Endpoints() ([]portainer.Endpoint, error) + Create(endpoint *portainer.Endpoint) error + UpdateEndpoint(ID portainer.EndpointID, endpoint *portainer.Endpoint) error + DeleteEndpoint(ID portainer.EndpointID) error + GetNextIdentifier() int + BucketName() string + } + + // EndpointGroupService represents a service for managing environment(endpoint) group data + EndpointGroupService interface { + BaseCRUD[portainer.EndpointGroup, portainer.EndpointGroupID] + } + + // EndpointRelationService represents a service for managing environment(endpoint) relations data + EndpointRelationService interface { + EndpointRelations() ([]portainer.EndpointRelation, error) + EndpointRelation(EndpointID portainer.EndpointID) (*portainer.EndpointRelation, error) + Create(endpointRelation *portainer.EndpointRelation) error + UpdateEndpointRelation(EndpointID portainer.EndpointID, endpointRelation *portainer.EndpointRelation) error + AddEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStack *portainer.EdgeStack) error + RemoveEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error + DeleteEndpointRelation(EndpointID portainer.EndpointID) error + BucketName() string + } + + // HelmUserRepositoryService represents a service to manage HelmUserRepositories + HelmUserRepositoryService interface { + BaseCRUD[portainer.HelmUserRepository, portainer.HelmUserRepositoryID] + HelmUserRepositoryByUserID(userID portainer.UserID) ([]portainer.HelmUserRepository, error) + } + + // RegistryService represents a service for managing registry data + RegistryService interface { + BaseCRUD[portainer.Registry, portainer.RegistryID] + } + + // ResourceControlService represents a service for managing resource control data + ResourceControlService interface { + BaseCRUD[portainer.ResourceControl, portainer.ResourceControlID] + ResourceControlByResourceIDAndType(resourceID string, resourceType portainer.ResourceControlType) (*portainer.ResourceControl, error) + } + + // RoleService represents a service for managing user roles + RoleService interface { + BaseCRUD[portainer.Role, portainer.RoleID] + } + + // APIKeyRepositoryService + APIKeyRepository interface { + BaseCRUD[portainer.APIKey, portainer.APIKeyID] + GetAPIKeysByUserID(userID portainer.UserID) ([]portainer.APIKey, error) + GetAPIKeyByDigest(digest string) (*portainer.APIKey, error) + } + + // SettingsService represents a service for managing application settings + SettingsService interface { + Settings() (*portainer.Settings, error) + UpdateSettings(settings *portainer.Settings) error + BucketName() string + } + + SnapshotService interface { + BaseCRUD[portainer.Snapshot, portainer.EndpointID] + ReadWithoutSnapshotRaw(ID portainer.EndpointID) (*portainer.Snapshot, error) + } + + // SSLSettingsService represents a service for managing application settings + SSLSettingsService interface { + Settings() (*portainer.SSLSettings, error) + UpdateSettings(settings *portainer.SSLSettings) error + BucketName() string + } + + SourceServiceUserContext interface { + ID() portainer.UserID + TeamMemberships() []portainer.TeamMembership + IsAdmin() bool + } + + // SourceService represents a service for managing GitOps source data + SourceService interface { + Create(context SourceServiceUserContext, source *portainer.Source) error + Read(context SourceServiceUserContext, ID portainer.SourceID) (*portainer.Source, error) + Exists(context SourceServiceUserContext, ID portainer.SourceID) (bool, error) + ReadAll(context SourceServiceUserContext, predicates ...func(portainer.Source) bool) ([]portainer.Source, error) + Update(context SourceServiceUserContext, ID portainer.SourceID, source *portainer.Source) error + Delete(context SourceServiceUserContext, ID portainer.SourceID) error + FindOrCreateGitSource(context SourceServiceUserContext, source *portainer.Source) (*portainer.Source, error) + } + + // StackService represents a service for managing stack data + StackService interface { + BaseCRUD[portainer.Stack, portainer.StackID] + StackByName(name string) (*portainer.Stack, error) + StacksByName(name string) ([]portainer.Stack, error) + GetNextIdentifier() int + StackByWebhookID(ID string) (*portainer.Stack, error) + RefreshableStacks() ([]portainer.Stack, error) + } + + // TagService represents a service for managing tag data + TagService interface { + BaseCRUD[portainer.Tag, portainer.TagID] + UpdateTagFunc(ID portainer.TagID, updateFunc func(tag *portainer.Tag)) error + } + + // TeamService represents a service for managing user data + TeamService interface { + BaseCRUD[portainer.Team, portainer.TeamID] + TeamByName(name string) (*portainer.Team, error) + } + + // TeamMembershipService represents a service for managing team membership data + TeamMembershipService interface { + BaseCRUD[portainer.TeamMembership, portainer.TeamMembershipID] + TeamMembershipsByUserID(userID portainer.UserID) ([]portainer.TeamMembership, error) + TeamMembershipsByTeamID(teamID portainer.TeamID) ([]portainer.TeamMembership, error) + DeleteTeamMembershipByUserID(userID portainer.UserID) error + DeleteTeamMembershipByTeamID(teamID portainer.TeamID) error + DeleteTeamMembershipByTeamIDAndUserID(teamID portainer.TeamID, userID portainer.UserID) error + } + + // TunnelServerService represents a service for managing data associated to the tunnel server + TunnelServerService interface { + Info() (*portainer.TunnelServerInfo, error) + UpdateInfo(info *portainer.TunnelServerInfo) error + BucketName() string + } + + // UserService represents a service for managing user data + UserService interface { + BaseCRUD[portainer.User, portainer.UserID] + UserByUsername(username string) (*portainer.User, error) + UserIDByUsername(username string) (portainer.UserID, error) + UsersByRole(role portainer.UserRole) ([]portainer.User, error) + } + + // VersionService represents a service for managing version data + VersionService interface { + InstanceID() (string, error) + UpdateInstanceID(ID string) error + Edition() (portainer.SoftwareEdition, error) + Version() (*models.Version, error) + UpdateVersion(*models.Version) error + } + + // WebhookService represents a service for managing webhook data. + WebhookService interface { + BaseCRUD[portainer.Webhook, portainer.WebhookID] + WebhookByResourceID(resourceID string) (*portainer.Webhook, error) + WebhookByToken(token string) (*portainer.Webhook, error) + } + + // WorkflowService represents a service for managing GitOps workflow data + WorkflowService interface { + BaseCRUD[portainer.Workflow, portainer.WorkflowID] + } +) diff --git a/api/dataservices/pendingactions/pendingactions.go b/api/dataservices/pendingactions/pendingactions.go new file mode 100644 index 0000000..5fd2793 --- /dev/null +++ b/api/dataservices/pendingactions/pendingactions.go @@ -0,0 +1,59 @@ +package pendingactions + +import ( + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" +) + +const BucketName = "pending_actions" + +type Service struct { + dataservices.BaseDataService[portainer.PendingAction, portainer.PendingActionID] +} + +func NewService(connection portainer.Connection) (*Service, error) { + err := connection.SetServiceName(BucketName) + if err != nil { + return nil, err + } + + return &Service{ + BaseDataService: dataservices.BaseDataService[portainer.PendingAction, portainer.PendingActionID]{ + Bucket: BucketName, + Connection: connection, + }, + }, nil +} + +// GetNextIdentifier returns the next identifier for a custom template. +func (service *Service) GetNextIdentifier() int { + return service.Connection.GetNextIdentifier(BucketName) +} + +func (s Service) Create(config *portainer.PendingAction) error { + return s.Connection.UpdateTx(func(tx portainer.Transaction) error { + return s.Tx(tx).Create(config) + }) +} + +func (s Service) Update(ID portainer.PendingActionID, config *portainer.PendingAction) error { + return s.Connection.UpdateTx(func(tx portainer.Transaction) error { + return s.Tx(tx).Update(ID, config) + }) +} + +func (s Service) DeleteByEndpointID(ID portainer.EndpointID) error { + return s.Connection.UpdateTx(func(tx portainer.Transaction) error { + return s.Tx(tx).DeleteByEndpointID(ID) + }) +} + +func (service *Service) Tx(tx portainer.Transaction) ServiceTx { + return ServiceTx{ + BaseDataServiceTx: dataservices.BaseDataServiceTx[portainer.PendingAction, portainer.PendingActionID]{ + Bucket: BucketName, + Connection: service.Connection, + Tx: tx, + }, + } +} diff --git a/api/dataservices/pendingactions/pendingactions_test.go b/api/dataservices/pendingactions/pendingactions_test.go new file mode 100644 index 0000000..a9583f8 --- /dev/null +++ b/api/dataservices/pendingactions/pendingactions_test.go @@ -0,0 +1,33 @@ +package pendingactions_test + +import ( + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/datastore" + + "github.com/stretchr/testify/require" +) + +func TestDeleteByEndpoint(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, false) + + // Create Endpoint 1 + err := store.PendingActions().Create(&portainer.PendingAction{EndpointID: 1}) + require.NoError(t, err) + + // Create Endpoint 2 + err = store.PendingActions().Create(&portainer.PendingAction{EndpointID: 2}) + require.NoError(t, err) + + // Delete Endpoint 1 + err = store.PendingActions().DeleteByEndpointID(1) + require.NoError(t, err) + + // Check that only Endpoint 2 remains + pendingActions, err := store.PendingActions().ReadAll() + require.NoError(t, err) + require.Len(t, pendingActions, 1) + require.Equal(t, portainer.EndpointID(2), pendingActions[0].EndpointID) +} diff --git a/api/dataservices/pendingactions/tx.go b/api/dataservices/pendingactions/tx.go new file mode 100644 index 0000000..40090a2 --- /dev/null +++ b/api/dataservices/pendingactions/tx.go @@ -0,0 +1,49 @@ +package pendingactions + +import ( + "fmt" + "time" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/rs/zerolog/log" +) + +type ServiceTx struct { + dataservices.BaseDataServiceTx[portainer.PendingAction, portainer.PendingActionID] +} + +func (s ServiceTx) Create(config *portainer.PendingAction) error { + return s.Tx.CreateObject(BucketName, func(id uint64) (int, any) { + config.ID = portainer.PendingActionID(id) + config.CreatedAt = time.Now().Unix() + + return int(config.ID), config + }) +} + +func (s ServiceTx) Update(ID portainer.PendingActionID, config *portainer.PendingAction) error { + return s.BaseDataServiceTx.Update(ID, config) +} + +func (s ServiceTx) DeleteByEndpointID(ID portainer.EndpointID) error { + log.Debug().Int("endpointId", int(ID)).Msg("deleting pending actions for endpoint") + pendingActions, err := s.ReadAll() + if err != nil { + return fmt.Errorf("failed to retrieve pending-actions for endpoint (%d): %w", ID, err) + } + + for _, pendingAction := range pendingActions { + if pendingAction.EndpointID == ID { + if err := s.Delete(pendingAction.ID); err != nil { + log.Debug().Int("endpointId", int(ID)).Msgf("failed to delete pending action: %v", err) + } + } + } + return nil +} + +// GetNextIdentifier returns the next identifier for a custom template. +func (service ServiceTx) GetNextIdentifier() int { + return service.Tx.GetNextIdentifier(BucketName) +} diff --git a/api/dataservices/registry/registry.go b/api/dataservices/registry/registry.go new file mode 100644 index 0000000..c35013f --- /dev/null +++ b/api/dataservices/registry/registry.go @@ -0,0 +1,50 @@ +package registry + +import ( + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" +) + +// BucketName represents the name of the bucket where this service stores data. +const BucketName = "registries" + +// Service represents a service for managing environment(endpoint) data. +type Service struct { + dataservices.BaseDataService[portainer.Registry, portainer.RegistryID] +} + +// NewService creates a new instance of a service. +func NewService(connection portainer.Connection) (*Service, error) { + err := connection.SetServiceName(BucketName) + if err != nil { + return nil, err + } + + return &Service{ + BaseDataService: dataservices.BaseDataService[portainer.Registry, portainer.RegistryID]{ + Bucket: BucketName, + Connection: connection, + }, + }, nil +} + +func (service *Service) Tx(tx portainer.Transaction) ServiceTx { + return ServiceTx{ + BaseDataServiceTx: dataservices.BaseDataServiceTx[portainer.Registry, portainer.RegistryID]{ + Bucket: BucketName, + Connection: service.Connection, + Tx: tx, + }, + } +} + +// Create creates a new registry. +func (service *Service) Create(registry *portainer.Registry) error { + return service.Connection.CreateObject( + BucketName, + func(id uint64) (int, any) { + registry.ID = portainer.RegistryID(id) + return int(registry.ID), registry + }, + ) +} diff --git a/api/dataservices/registry/tx.go b/api/dataservices/registry/tx.go new file mode 100644 index 0000000..410bf12 --- /dev/null +++ b/api/dataservices/registry/tx.go @@ -0,0 +1,21 @@ +package registry + +import ( + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" +) + +type ServiceTx struct { + dataservices.BaseDataServiceTx[portainer.Registry, portainer.RegistryID] +} + +// Create creates a new registry. +func (service ServiceTx) Create(registry *portainer.Registry) error { + return service.Tx.CreateObject( + BucketName, + func(id uint64) (int, any) { + registry.ID = portainer.RegistryID(id) + return int(registry.ID), registry + }, + ) +} diff --git a/api/dataservices/resourcecontrol/resourcecontrol.go b/api/dataservices/resourcecontrol/resourcecontrol.go new file mode 100644 index 0000000..306ad33 --- /dev/null +++ b/api/dataservices/resourcecontrol/resourcecontrol.go @@ -0,0 +1,79 @@ +package resourcecontrol + +import ( + "errors" + "slices" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" +) + +// BucketName represents the name of the bucket where this service stores data. +const BucketName = "resource_control" + +// Service represents a service for managing environment(endpoint) data. +type Service struct { + dataservices.BaseDataService[portainer.ResourceControl, portainer.ResourceControlID] +} + +// NewService creates a new instance of a service. +func NewService(connection portainer.Connection) (*Service, error) { + err := connection.SetServiceName(BucketName) + if err != nil { + return nil, err + } + + return &Service{ + BaseDataService: dataservices.BaseDataService[portainer.ResourceControl, portainer.ResourceControlID]{ + Bucket: BucketName, + Connection: connection, + }, + }, nil +} + +func (service *Service) Tx(tx portainer.Transaction) ServiceTx { + return ServiceTx{ + BaseDataServiceTx: dataservices.BaseDataServiceTx[portainer.ResourceControl, portainer.ResourceControlID]{ + Bucket: BucketName, + Connection: service.Connection, + Tx: tx, + }, + } +} + +// ResourceControlByResourceIDAndType returns a ResourceControl object by checking if the resourceID is equal +// to the main ResourceID or in SubResourceIDs. It also performs a check on the resource type. Return nil +// if no ResourceControl was found. +func (service *Service) ResourceControlByResourceIDAndType(resourceID string, resourceType portainer.ResourceControlType) (*portainer.ResourceControl, error) { + var found portainer.ResourceControl + + err := service.Connection.GetAll( + BucketName, + &portainer.ResourceControl{}, + dataservices.FirstFn(&found, func(rc portainer.ResourceControl) bool { + return (rc.ResourceID == resourceID && rc.Type == resourceType) || + slices.Contains(rc.SubResourceIDs, resourceID) + }), + ) + + if errors.Is(err, dataservices.ErrStop) { + return &found, nil + } + + if err != nil { + return nil, err + } + + return nil, nil +} + +// CreateResourceControl creates a new ResourceControl object +func (service *Service) Create(resourceControl *portainer.ResourceControl) error { + return service.Connection.CreateObject( + BucketName, + func(id uint64) (int, any) { + resourceControl.ID = portainer.ResourceControlID(id) + return int(resourceControl.ID), resourceControl + }, + ) +} diff --git a/api/dataservices/resourcecontrol/tx.go b/api/dataservices/resourcecontrol/tx.go new file mode 100644 index 0000000..d795ce9 --- /dev/null +++ b/api/dataservices/resourcecontrol/tx.go @@ -0,0 +1,50 @@ +package resourcecontrol + +import ( + "errors" + "slices" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" +) + +type ServiceTx struct { + dataservices.BaseDataServiceTx[portainer.ResourceControl, portainer.ResourceControlID] +} + +// ResourceControlByResourceIDAndType returns a ResourceControl object by checking if the resourceID is equal +// to the main ResourceID or in SubResourceIDs. It also performs a check on the resource type. Return nil +// if no ResourceControl was found. +func (service ServiceTx) ResourceControlByResourceIDAndType(resourceID string, resourceType portainer.ResourceControlType) (*portainer.ResourceControl, error) { + var found portainer.ResourceControl + + err := service.Tx.GetAll( + BucketName, + &portainer.ResourceControl{}, + dataservices.FirstFn(&found, func(rc portainer.ResourceControl) bool { + return (rc.ResourceID == resourceID && rc.Type == resourceType) || + slices.Contains(rc.SubResourceIDs, resourceID) + }), + ) + + if errors.Is(err, dataservices.ErrStop) { + return &found, nil + } + + if err != nil { + return nil, err + } + + return nil, nil +} + +// CreateResourceControl creates a new ResourceControl object +func (service ServiceTx) Create(resourceControl *portainer.ResourceControl) error { + return service.Tx.CreateObject( + BucketName, + func(id uint64) (int, any) { + resourceControl.ID = portainer.ResourceControlID(id) + return int(resourceControl.ID), resourceControl + }, + ) +} diff --git a/api/dataservices/role/role.go b/api/dataservices/role/role.go new file mode 100644 index 0000000..04c4cd1 --- /dev/null +++ b/api/dataservices/role/role.go @@ -0,0 +1,50 @@ +package role + +import ( + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" +) + +// BucketName represents the name of the bucket where this service stores data. +const BucketName = "roles" + +// Service represents a service for managing environment(endpoint) data. +type Service struct { + dataservices.BaseDataService[portainer.Role, portainer.RoleID] +} + +// NewService creates a new instance of a service. +func NewService(connection portainer.Connection) (*Service, error) { + err := connection.SetServiceName(BucketName) + if err != nil { + return nil, err + } + + return &Service{ + BaseDataService: dataservices.BaseDataService[portainer.Role, portainer.RoleID]{ + Bucket: BucketName, + Connection: connection, + }, + }, nil +} + +func (service *Service) Tx(tx portainer.Transaction) ServiceTx { + return ServiceTx{ + BaseDataServiceTx: dataservices.BaseDataServiceTx[portainer.Role, portainer.RoleID]{ + Bucket: BucketName, + Connection: service.Connection, + Tx: tx, + }, + } +} + +// CreateRole creates a new Role. +func (service *Service) Create(role *portainer.Role) error { + return service.Connection.CreateObject( + BucketName, + func(id uint64) (int, any) { + role.ID = portainer.RoleID(id) + return int(role.ID), role + }, + ) +} diff --git a/api/dataservices/role/tx.go b/api/dataservices/role/tx.go new file mode 100644 index 0000000..ea2ec7a --- /dev/null +++ b/api/dataservices/role/tx.go @@ -0,0 +1,21 @@ +package role + +import ( + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" +) + +type ServiceTx struct { + dataservices.BaseDataServiceTx[portainer.Role, portainer.RoleID] +} + +// CreateRole creates a new Role. +func (service ServiceTx) Create(role *portainer.Role) error { + return service.Tx.CreateObject( + BucketName, + func(id uint64) (int, any) { + role.ID = portainer.RoleID(id) + return int(role.ID), role + }, + ) +} diff --git a/api/dataservices/schedule/schedule.go b/api/dataservices/schedule/schedule.go new file mode 100644 index 0000000..d8df531 --- /dev/null +++ b/api/dataservices/schedule/schedule.go @@ -0,0 +1,90 @@ +package schedule + +import ( + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" +) + +// BucketName represents the name of the bucket where this service stores data. +const BucketName = "schedules" + +// Service represents a service for managing schedule data. +type Service struct { + connection portainer.Connection +} + +func (service *Service) BucketName() string { + return BucketName +} + +// NewService creates a new instance of a service. +func NewService(connection portainer.Connection) (*Service, error) { + err := connection.SetServiceName(BucketName) + if err != nil { + return nil, err + } + + return &Service{ + connection: connection, + }, nil +} + +// Schedule returns a schedule by ID. +func (service *Service) Schedule(ID portainer.ScheduleID) (*portainer.Schedule, error) { + var schedule portainer.Schedule + identifier := service.connection.ConvertToKey(int(ID)) + + err := service.connection.GetObject(BucketName, identifier, &schedule) + if err != nil { + return nil, err + } + + return &schedule, nil +} + +// UpdateSchedule updates a schedule. +func (service *Service) UpdateSchedule(ID portainer.ScheduleID, schedule *portainer.Schedule) error { + identifier := service.connection.ConvertToKey(int(ID)) + return service.connection.UpdateObject(BucketName, identifier, schedule) +} + +// DeleteSchedule deletes a schedule. +func (service *Service) DeleteSchedule(ID portainer.ScheduleID) error { + identifier := service.connection.ConvertToKey(int(ID)) + return service.connection.DeleteObject(BucketName, identifier) +} + +// Schedules return a array containing all the schedules. +func (service *Service) Schedules() ([]portainer.Schedule, error) { + var schedules = make([]portainer.Schedule, 0) + + return schedules, service.connection.GetAll( + BucketName, + &portainer.Schedule{}, + dataservices.AppendFn(&schedules), + ) +} + +// SchedulesByJobType return a array containing all the schedules +// with the specified JobType. +func (service *Service) SchedulesByJobType(jobType portainer.JobType) ([]portainer.Schedule, error) { + var schedules = make([]portainer.Schedule, 0) + + return schedules, service.connection.GetAll( + BucketName, + &portainer.Schedule{}, + dataservices.FilterFn(&schedules, func(e portainer.Schedule) bool { + return e.JobType == jobType + }), + ) +} + +// Create assign an ID to a new schedule and saves it. +func (service *Service) CreateSchedule(schedule *portainer.Schedule) error { + return service.connection.CreateObjectWithId(BucketName, int(schedule.ID), schedule) +} + +// GetNextIdentifier returns the next identifier for a schedule. +func (service *Service) GetNextIdentifier() int { + return service.connection.GetNextIdentifier(BucketName) +} diff --git a/api/dataservices/settings/settings.go b/api/dataservices/settings/settings.go new file mode 100644 index 0000000..cb991d0 --- /dev/null +++ b/api/dataservices/settings/settings.go @@ -0,0 +1,56 @@ +package settings + +import ( + portainer "github.com/portainer/portainer/api" +) + +const ( + // BucketName represents the name of the bucket where this service stores data. + BucketName = "settings" + settingsKey = "SETTINGS" +) + +// Service represents a service for managing environment(endpoint) data. +type Service struct { + connection portainer.Connection +} + +func (service *Service) BucketName() string { + return BucketName +} + +// NewService creates a new instance of a service. +func NewService(connection portainer.Connection) (*Service, error) { + err := connection.SetServiceName(BucketName) + if err != nil { + return nil, err + } + + return &Service{ + connection: connection, + }, nil +} + +func (service *Service) Tx(tx portainer.Transaction) ServiceTx { + return ServiceTx{ + service: service, + tx: tx, + } +} + +// Settings retrieve the settings object. +func (service *Service) Settings() (*portainer.Settings, error) { + var settings portainer.Settings + + err := service.connection.GetObject(BucketName, []byte(settingsKey), &settings) + if err != nil { + return nil, err + } + + return &settings, nil +} + +// UpdateSettings persists a Settings object. +func (service *Service) UpdateSettings(settings *portainer.Settings) error { + return service.connection.UpdateObject(BucketName, []byte(settingsKey), settings) +} diff --git a/api/dataservices/settings/tx.go b/api/dataservices/settings/tx.go new file mode 100644 index 0000000..7e6899d --- /dev/null +++ b/api/dataservices/settings/tx.go @@ -0,0 +1,31 @@ +package settings + +import ( + portainer "github.com/portainer/portainer/api" +) + +type ServiceTx struct { + service *Service + tx portainer.Transaction +} + +func (service ServiceTx) BucketName() string { + return BucketName +} + +// Settings retrieve the settings object. +func (service ServiceTx) Settings() (*portainer.Settings, error) { + var settings portainer.Settings + + err := service.tx.GetObject(BucketName, []byte(settingsKey), &settings) + if err != nil { + return nil, err + } + + return &settings, nil +} + +// UpdateSettings persists a Settings object. +func (service ServiceTx) UpdateSettings(settings *portainer.Settings) error { + return service.tx.UpdateObject(BucketName, []byte(settingsKey), settings) +} diff --git a/api/dataservices/snapshot/snapshot.go b/api/dataservices/snapshot/snapshot.go new file mode 100644 index 0000000..c006631 --- /dev/null +++ b/api/dataservices/snapshot/snapshot.go @@ -0,0 +1,70 @@ +package snapshot + +import ( + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" +) + +const BucketName = "snapshots" + +type Service struct { + dataservices.BaseDataService[portainer.Snapshot, portainer.EndpointID] +} + +func NewService(connection portainer.Connection) (*Service, error) { + err := connection.SetServiceName(BucketName) + if err != nil { + return nil, err + } + + return &Service{ + BaseDataService: dataservices.BaseDataService[portainer.Snapshot, portainer.EndpointID]{ + Bucket: BucketName, + Connection: connection, + }, + }, nil +} + +func (service *Service) Tx(tx portainer.Transaction) ServiceTx { + return ServiceTx{ + BaseDataServiceTx: dataservices.BaseDataServiceTx[portainer.Snapshot, portainer.EndpointID]{ + Bucket: BucketName, + Connection: service.Connection, + Tx: tx, + }, + } +} + +func (service *Service) Create(snapshot *portainer.Snapshot) error { + return service.Connection.CreateObjectWithId(BucketName, int(snapshot.EndpointID), snapshot) +} + +func (service *Service) ReadWithoutSnapshotRaw(ID portainer.EndpointID) (*portainer.Snapshot, error) { + var snapshot *portainer.Snapshot + + err := service.Connection.ViewTx(func(tx portainer.Transaction) error { + var err error + snapshot, err = service.Tx(tx).ReadWithoutSnapshotRaw(ID) + + return err + }) + + return snapshot, err +} + +func (service *Service) ReadRawMessage(ID portainer.EndpointID) (*portainer.SnapshotRawMessage, error) { + var snapshot *portainer.SnapshotRawMessage + + err := service.Connection.ViewTx(func(tx portainer.Transaction) error { + var err error + snapshot, err = service.Tx(tx).ReadRawMessage(ID) + + return err + }) + + return snapshot, err +} + +func (service *Service) CreateRawMessage(snapshot *portainer.SnapshotRawMessage) error { + return service.Connection.CreateObjectWithId(BucketName, int(snapshot.EndpointID), snapshot) +} diff --git a/api/dataservices/snapshot/tx.go b/api/dataservices/snapshot/tx.go new file mode 100644 index 0000000..45d1df9 --- /dev/null +++ b/api/dataservices/snapshot/tx.go @@ -0,0 +1,53 @@ +package snapshot + +import ( + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" +) + +type ServiceTx struct { + dataservices.BaseDataServiceTx[portainer.Snapshot, portainer.EndpointID] +} + +func (service ServiceTx) Create(snapshot *portainer.Snapshot) error { + return service.Tx.CreateObjectWithId(BucketName, int(snapshot.EndpointID), snapshot) +} + +func (service ServiceTx) ReadWithoutSnapshotRaw(ID portainer.EndpointID) (*portainer.Snapshot, error) { + var snapshot struct { + Docker *struct { + X struct{} `json:"DockerSnapshotRaw"` + *portainer.DockerSnapshot + } `json:"Docker"` + + portainer.Snapshot + } + + identifier := service.Connection.ConvertToKey(int(ID)) + + if err := service.Tx.GetObject(service.Bucket, identifier, &snapshot); err != nil { + return nil, err + } + + if snapshot.Docker != nil { + snapshot.Snapshot.Docker = snapshot.Docker.DockerSnapshot + } + + return &snapshot.Snapshot, nil +} + +func (service ServiceTx) ReadRawMessage(ID portainer.EndpointID) (*portainer.SnapshotRawMessage, error) { + var snapshot = portainer.SnapshotRawMessage{} + + identifier := service.Connection.ConvertToKey(int(ID)) + + if err := service.Tx.GetObject(service.Bucket, identifier, &snapshot); err != nil { + return nil, err + } + + return &snapshot, nil +} + +func (service ServiceTx) CreateRawMessage(snapshot *portainer.SnapshotRawMessage) error { + return service.Tx.CreateObjectWithId(BucketName, int(snapshot.EndpointID), snapshot) +} diff --git a/api/dataservices/source/access_rules.go b/api/dataservices/source/access_rules.go new file mode 100644 index 0000000..d36a186 --- /dev/null +++ b/api/dataservices/source/access_rules.go @@ -0,0 +1,130 @@ +package source + +import ( + "errors" + "slices" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/set" + "github.com/portainer/portainer/api/slicesx" +) + +var ( + ErrInvalidSource = errors.New("invalid source") + ErrInvalidUserContext = errors.New("invalid user context") + ErrNotEnoughPermission = errors.New("not enough permissions to perform this action") + ErrDuplicateSource = errors.New("a source with this URL and credentials already exists") +) + +func validateUserContext(ctx UserContext) error { + if ctx == nil { + return ErrInvalidUserContext + } + + return nil +} + +type actionType string + +const ( + actionRead actionType = "read" + actionWrite actionType = "write" +) + +func enforceUserPermissions(ctx UserContext, source *portainer.Source, action actionType) error { + if action == actionRead && userCanReadSource(source, ctx) { + return nil + } + + if action == actionWrite && userCanWriteSource(source, ctx) { + return nil + } + + return ErrNotEnoughPermission +} + +func userCanWriteSource(source *portainer.Source, context UserContext) bool { + if source == nil || context == nil { + return false + } + + if context.IsAdmin() { + return true + } + + if source.OwnerID != 0 && source.OwnerID == context.ID() && userCanReadSource(source, context) { + return true + } + + return false +} + +func filterSources(sources []portainer.Source, context UserContext) []portainer.Source { + return slicesx.Filter(sources, func(s portainer.Source) bool { + return userCanReadSource(&s, context) + }) +} + +func userCanReadSource(source *portainer.Source, context UserContext) bool { + if source == nil || context == nil { + return false + } + + userTeams := context.TeamMemberships() + + if context.IsAdmin() || source.Public { + return true + } + + if source.AdministratorsOnly { + return false + } + + if slices.Contains(source.UserAccesses, context.ID()) { + return true + } + + if len(userTeams) == 0 || len(source.TeamAccesses) == 0 { + return false + } + + sTeams := set.ToSet(source.TeamAccesses) + uTeams := set.ToSet(slicesx.Map(userTeams, func(u portainer.TeamMembership) portainer.TeamID { return u.TeamID })) + + return set.Intersection(sTeams, uTeams).Len() != 0 +} + +// enforceUniqueGitSource validates there are no other git sources with the same URL and credentials +// It ignores itself +func enforceUniqueGitSource(tx ServiceTx, src *portainer.Source) error { + if src.Type != portainer.SourceTypeGit || src.Git == nil { + return nil + } + + normalized, err := normalizeGitSource(src) + if err != nil { + return err + } + + existing, err := tx.base.ReadAll(func(s portainer.Source) bool { + if src.ID == s.ID { + return false + } + + n, err := normalizeGitSource(&s) + if err != nil { + return false + } + + return normalized.Equal(n) + }) + + if err != nil { + return err + } + + if len(existing) > 0 { + return ErrDuplicateSource + } + return nil +} diff --git a/api/dataservices/source/normalize_source.go b/api/dataservices/source/normalize_source.go new file mode 100644 index 0000000..0b6f68b --- /dev/null +++ b/api/dataservices/source/normalize_source.go @@ -0,0 +1,43 @@ +package source + +import ( + portainer "github.com/portainer/portainer/api" + gittypes "github.com/portainer/portainer/api/git/types" +) + +type normalizedGitSource struct { + url string + username string + password string +} + +func (a *normalizedGitSource) Equal(b *normalizedGitSource) bool { + return a != nil && b != nil && + a.url == b.url && + a.username == b.username && + a.password == b.password +} + +// normalize git source to a lighter object used to compare sources together +func normalizeGitSource(src *portainer.Source) (*normalizedGitSource, error) { + if src == nil || src.Type != portainer.SourceTypeGit || src.Git == nil { + return nil, ErrInvalidSource + } + + url, err := gittypes.NormalizeURL(gittypes.SanitizeURL(src.Git.URL)) + if err != nil { + return nil, err + } + + username, password := "", "" + if src.Git.Authentication != nil { + username = src.Git.Authentication.Username + password = src.Git.Authentication.Password + } + + return &normalizedGitSource{ + url: url, + username: username, + password: password, + }, nil +} diff --git a/api/dataservices/source/sanitize_git.go b/api/dataservices/source/sanitize_git.go new file mode 100644 index 0000000..0b66fed --- /dev/null +++ b/api/dataservices/source/sanitize_git.go @@ -0,0 +1,74 @@ +package source + +import ( + portainer "github.com/portainer/portainer/api" + gittypes "github.com/portainer/portainer/api/git/types" +) + +// Sanitize the source URL and enforce fields values based on user context +func sanitizeGitSource(source *portainer.Source) error { + if source == nil { + return ErrInvalidSource + } + + if source.Type != portainer.SourceTypeGit { + return nil + } + + if source.Git == nil { + return ErrInvalidSource + } + + var err error + + source.Git.URL, err = gittypes.NormalizeURL(gittypes.SanitizeURL(source.Git.URL)) + if err != nil { + return err + } + + return nil +} + +func sanitizeAccesses(ctx UserContext, newValues *portainer.Source, previousValues *portainer.Source) error { + if newValues == nil { + return ErrInvalidSource + } + + if ctx.IsAdmin() { + if newValues.Public && newValues.AdministratorsOnly { + newValues.Public = false + } + + if newValues.Public || newValues.AdministratorsOnly { + newValues.UserAccesses = []portainer.UserID{} + newValues.TeamAccesses = []portainer.TeamID{} + } + + if !newValues.Public && !newValues.AdministratorsOnly && len(newValues.UserAccesses) == 0 && len(newValues.TeamAccesses) == 0 { + newValues.AdministratorsOnly = true + } + + return nil + } + + // Update flow ; regular user is not allowed to change the UAC, visibility or ownership of the source + if previousValues != nil { + newValues.UserAccesses = previousValues.UserAccesses + newValues.TeamAccesses = previousValues.TeamAccesses + newValues.Public = previousValues.Public + newValues.AdministratorsOnly = previousValues.AdministratorsOnly + newValues.OwnerID = previousValues.OwnerID + return nil + } + + // Create flow + userAccesses := []portainer.UserID{ctx.ID()} + if newValues.Public { + userAccesses = []portainer.UserID{} + } + newValues.UserAccesses = userAccesses + newValues.TeamAccesses = []portainer.TeamID{} + newValues.AdministratorsOnly = false + newValues.OwnerID = ctx.ID() + return nil +} diff --git a/api/dataservices/source/sanitize_git_test.go b/api/dataservices/source/sanitize_git_test.go new file mode 100644 index 0000000..0699bba --- /dev/null +++ b/api/dataservices/source/sanitize_git_test.go @@ -0,0 +1,72 @@ +package source + +import ( + "testing" + + portainer "github.com/portainer/portainer/api" + gittypes "github.com/portainer/portainer/api/git/types" + "github.com/stretchr/testify/require" +) + +type vFn func(new *portainer.Source, old *portainer.Source, err error) + +func testUAC( + t *testing.T, + userContext UserContext, + new *portainer.Source, + old *portainer.Source, + validationFuncs ...vFn, +) { + t.Helper() + err := sanitizeAccesses(userContext, new, old) + for _, validate := range validationFuncs { + validate(new, old, err) + } +} + +func Test_SanitizeAccesses_Admin(t *testing.T) { + errInvalidSource := func(_, _ *portainer.Source, err error) { + t.Helper() + require.ErrorIs(t, err, ErrInvalidSource) + } + + noError := func(_, _ *portainer.Source, err error) { t.Helper(); require.NoError(t, err) } + noOwner := func(new, _ *portainer.Source, _ error) { t.Helper(); require.Zero(t, new.OwnerID) } + emptyUsers := func(new, _ *portainer.Source, _ error) { t.Helper(); require.Empty(t, new.UserAccesses) } + emptyTeams := func(new, _ *portainer.Source, _ error) { t.Helper(); require.Empty(t, new.TeamAccesses) } + public := func(v bool) func(new, _ *portainer.Source, _ error) { + return func(new, _ *portainer.Source, _ error) { t.Helper(); require.Equal(t, v, new.Public) } + } + adminOnly := func(v bool) func(new, _ *portainer.Source, _ error) { + return func(new, _ *portainer.Source, _ error) { t.Helper(); require.Equal(t, v, new.AdministratorsOnly) } + } + + adminUserContext := NewUserContext(&portainer.User{Role: portainer.AdministratorRole}, []portainer.TeamMembership{}) + + test := func(new *portainer.Source, old *portainer.Source, validationFuncs ...vFn) { + t.Helper() + testUAC(t, adminUserContext, new, old, validationFuncs...) + } + + test(nil, nil, errInvalidSource) + test(&portainer.Source{}, nil, noError) + test(&portainer.Source{Git: &gittypes.GitSource{}}, nil, + noError, emptyUsers, emptyTeams, adminOnly(true), noOwner, public(false), + ) + test(&portainer.Source{Git: &gittypes.GitSource{}, Public: true}, nil, + noError, emptyUsers, emptyTeams, adminOnly(false), noOwner, public(true), + ) + test(&portainer.Source{Git: &gittypes.GitSource{}, AdministratorsOnly: true}, nil, + noError, emptyUsers, emptyTeams, adminOnly(true), noOwner, public(false), + ) + test(&portainer.Source{Git: &gittypes.GitSource{}, AdministratorsOnly: true, Public: true}, nil, + noError, emptyUsers, emptyTeams, adminOnly(true), noOwner, public(false), + ) + test(&portainer.Source{Git: &gittypes.GitSource{}, AdministratorsOnly: true, Public: true, UserAccesses: []portainer.UserID{1, 2}}, nil, + noError, emptyUsers, emptyTeams, adminOnly(true), noOwner, public(false), + ) +} + +// func Test_SanitizeAccesses_User(t *testing.T) { +// user := NewUserContext(&portainer.User{Role: portainer.StandardUserRole}, []portainer.TeamMembership{}) +// } diff --git a/api/dataservices/source/source.go b/api/dataservices/source/source.go new file mode 100644 index 0000000..372fd5f --- /dev/null +++ b/api/dataservices/source/source.go @@ -0,0 +1,106 @@ +package source + +import ( + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" +) + +// BucketName represents the name of the bucket where this service stores data. +const BucketName = "sources" + +// Service represents a service for managing GitOps source data. +type Service struct { + base dataservices.BaseDataService[portainer.Source, portainer.SourceID] +} + +// NewService creates a new instance of a service. +func NewService(connection portainer.Connection) (*Service, error) { + err := connection.SetServiceName(BucketName) + if err != nil { + return nil, err + } + + return &Service{ + base: dataservices.BaseDataService[portainer.Source, portainer.SourceID]{ + Bucket: BucketName, + Connection: connection, + }, + }, nil +} + +func (service *Service) Tx(tx portainer.Transaction) ServiceTx { + return ServiceTx{ + base: dataservices.BaseDataServiceTx[portainer.Source, portainer.SourceID]{ + Bucket: BucketName, + Connection: service.base.Connection, + Tx: tx, + }, + } +} + +// Create creates a new source. +func (service *Service) Create(context UserContext, source *portainer.Source) error { + return service.base.Connection.UpdateTx(func(tx portainer.Transaction) error { + return service.Tx(tx).Create(context, source) + }) +} + +func (service *Service) Read(context UserContext, ID portainer.SourceID) (*portainer.Source, error) { + var result *portainer.Source + + err := service.base.Connection.ViewTx(func(tx portainer.Transaction) error { + var err error + result, err = service.Tx(tx).Read(context, ID) + return err + }) + + return result, err +} + +func (service *Service) Exists(context UserContext, ID portainer.SourceID) (bool, error) { + var result bool + + err := service.base.Connection.ViewTx(func(tx portainer.Transaction) error { + var err error + result, err = service.Tx(tx).Exists(context, ID) + return err + }) + + return result, err +} + +func (service *Service) ReadAll(context UserContext, predicates ...func(portainer.Source) bool) ([]portainer.Source, error) { + var result []portainer.Source + + err := service.base.Connection.ViewTx(func(tx portainer.Transaction) error { + var err error + result, err = service.Tx(tx).ReadAll(context, predicates...) + return err + }) + + return result, err +} + +func (service *Service) Update(context UserContext, ID portainer.SourceID, source *portainer.Source) error { + return service.base.Connection.UpdateTx(func(tx portainer.Transaction) error { + return service.Tx(tx).Update(context, ID, source) + }) +} + +func (service *Service) Delete(context UserContext, ID portainer.SourceID) error { + return service.base.Connection.UpdateTx(func(tx portainer.Transaction) error { + return service.Tx(tx).Delete(context, ID) + }) +} + +func (service *Service) FindOrCreateGitSource(context UserContext, source *portainer.Source) (*portainer.Source, error) { + var result *portainer.Source + + err := service.base.Connection.UpdateTx(func(tx portainer.Transaction) error { + var err error + result, err = service.Tx(tx).FindOrCreateGitSource(context, source) + return err + }) + + return result, err +} diff --git a/api/dataservices/source/tx.go b/api/dataservices/source/tx.go new file mode 100644 index 0000000..864e86e --- /dev/null +++ b/api/dataservices/source/tx.go @@ -0,0 +1,204 @@ +package source + +import ( + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + gittypes "github.com/portainer/portainer/api/git/types" +) + +type ServiceTx struct { + base dataservices.BaseDataServiceTx[portainer.Source, portainer.SourceID] +} + +// Create creates a new source. +func (service ServiceTx) Create(context UserContext, source *portainer.Source) error { + if err := validateUserContext(context); err != nil { + return err + } + + if source == nil { + return ErrInvalidSource + } + + if err := sanitizeGitSource(source); err != nil { + return err + } + + if err := sanitizeAccesses(context, source, nil); err != nil { + return err + } + + if err := enforceUniqueGitSource(service, source); err != nil { + return err + } + + return service.base.Tx.CreateObject( + BucketName, + func(id uint64) (int, any) { + source.ID = portainer.SourceID(id) + return int(source.ID), source + }, + ) +} + +func (service ServiceTx) Read(context UserContext, ID portainer.SourceID) (*portainer.Source, error) { + if err := validateUserContext(context); err != nil { + return nil, err + } + + source, err := service.base.Read(ID) + if err != nil { + return nil, err + } + + if err := enforceUserPermissions(context, source, actionRead); err != nil { + return nil, err + } + + return source, err +} + +// Access is not enforced on this to avoid the cost of deserialize +// Any user can scan the DB IDs using this method, so be mindful with usage of this func. +func (service ServiceTx) Exists(context UserContext, ID portainer.SourceID) (bool, error) { + if err := validateUserContext(context); err != nil { + return false, err + } + + return service.base.Exists(ID) +} + +// ReadAll fetches all sources the user can access, matching predicates +func (service ServiceTx) ReadAll(context UserContext, predicates ...func(portainer.Source) bool) ([]portainer.Source, error) { + if err := validateUserContext(context); err != nil { + return nil, err + } + + list, err := service.base.ReadAll(predicates...) + if err != nil { + return nil, err + } + + return filterSources(list, context), nil +} + +// Update updates the source of id `ID` with the `source` content +// It validates that the user has access to the source, and has enough permissions to perform the action +func (service ServiceTx) Update(context UserContext, ID portainer.SourceID, source *portainer.Source) error { + if err := validateUserContext(context); err != nil { + return err + } + + originalSource, err := service.base.Read(ID) + if err != nil { + return err + } + + if source == nil || originalSource == nil { + return ErrInvalidSource + } + + if err := enforceUserPermissions(context, originalSource, actionWrite); err != nil { + return err + } + + if err := sanitizeGitSource(source); err != nil { + return err + } + + if err := sanitizeAccesses(context, source, originalSource); err != nil { + return err + } + + if err := enforceUniqueGitSource(service, source); err != nil { + return err + } + + return service.base.Update(ID, source) +} + +// Delete deletes a source +// It validates that the user has access to the source, and has enough permissions to perform the action +func (service ServiceTx) Delete(context UserContext, ID portainer.SourceID) error { + if err := validateUserContext(context); err != nil { + return err + } + + source, err := service.base.Read(ID) + if err != nil { + return err + } + + if err := enforceUserPermissions(context, source, actionWrite); err != nil { + return err + } + + return service.base.Delete(ID) +} + +// FindOrCreateGitSource returns an existing Source whose URL and authentication match cfg, +// or creates a new one. Only URL, authentication, and TLSSkipVerify are stored on the Source; +// per-stack fields (ReferenceName, ConfigFilePath, ConfigHash) belong in the Artifact. +// The function auto adds the user to an existing source if the user doesn't have access but provided a valid full +// config (URL+Auth) +func (service ServiceTx) FindOrCreateGitSource(context UserContext, src *portainer.Source) (*portainer.Source, error) { + if err := validateUserContext(context); err != nil { + return nil, err + } + + if src == nil || src.Git == nil { + return nil, ErrInvalidSource + } + + normalized, err := normalizeGitSource(src) + if err != nil { + return nil, err + } + + existing, err := service.base.ReadAll(func(s portainer.Source) bool { + n, err := normalizeGitSource(&s) + if err != nil { + return false + } + return normalized.Equal(n) + }) + if err != nil { + return nil, err + } + + if len(existing) > 0 { + allowed := filterSources(existing, context) + if len(allowed) > 0 { + return &allowed[0], nil + } + + // give user access to the first source if he doesn't have access + // to any of the sources that have the same url+auth + existing[0].UserAccesses = append(existing[0].UserAccesses, context.ID()) + if err := service.base.Update(existing[0].ID, &existing[0]); err != nil { + return nil, err + } + return &existing[0], nil + } + + toCreate := &portainer.Source{ + Name: src.Name, + Type: portainer.SourceTypeGit, + Git: &gittypes.GitSource{ + URL: src.Git.URL, + Authentication: src.Git.Authentication, + TLSSkipVerify: src.Git.TLSSkipVerify, + }, + Public: src.Public, + AdministratorsOnly: src.AdministratorsOnly, + UserAccesses: src.UserAccesses, + TeamAccesses: src.TeamAccesses, + OwnerID: src.OwnerID, + } + + if err := service.Create(context, toCreate); err != nil { + return nil, err + } + + return toCreate, nil +} diff --git a/api/dataservices/source/user_context.go b/api/dataservices/source/user_context.go new file mode 100644 index 0000000..67f1b07 --- /dev/null +++ b/api/dataservices/source/user_context.go @@ -0,0 +1,39 @@ +package source + +import ( + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" +) + +type UserContext = dataservices.SourceServiceUserContext + +type userContext struct { + id portainer.UserID + role portainer.UserRole + memberships []portainer.TeamMembership +} + +func (uc userContext) ID() portainer.UserID { + return uc.id +} + +func (uc userContext) TeamMemberships() []portainer.TeamMembership { + return uc.memberships +} + +func (uc userContext) IsAdmin() bool { + return uc.role == portainer.AdministratorRole +} + +// Create a new admin context +// +// # THIS FUNCTION MUST NOT BE USED IN A USER-AWARE FLOW, ONLY FOR MIGRATIONS AND TESTS +// +// The only flows outside of migrations/test allowed to use this func is the datastore.Import/Export for sources +func InsecureNewAdminContext() UserContext { + return NewUserContext(&portainer.User{Role: portainer.AdministratorRole}, []portainer.TeamMembership{}) +} + +func NewUserContext(user *portainer.User, userMemberships []portainer.TeamMembership) UserContext { + return userContext{id: user.ID, role: user.Role, memberships: userMemberships} +} diff --git a/api/dataservices/source/validation_rules_test.go b/api/dataservices/source/validation_rules_test.go new file mode 100644 index 0000000..22b14cd --- /dev/null +++ b/api/dataservices/source/validation_rules_test.go @@ -0,0 +1,97 @@ +package source + +// var adminUserContext = InsecureNewAdminContext() + +// func newSourceWithAuth(url, username, password string) *portainer.Source { +// return &portainer.Source{ +// Type: portainer.SourceTypeGit, +// Git: &gittypes.RepoConfig{ +// URL: url, +// Authentication: &gittypes.GitAuthentication{ +// Username: username, +// Password: password, +// }, +// }, +// } +// } + +// func newAuthlessSource(url string) *portainer.Source { +// return &portainer.Source{ +// Type: portainer.SourceTypeGit, +// Git: &gittypes.RepoConfig{URL: url}, +// } +// } + +// func validateUniqueSourceInStore(t *testing.T, tx ServiceTx, url, username, password string, sourceID portainer.SourceID) bool { +// t.Helper() + +// var isUnique bool +// require.NoError(t, store.ViewTx(func(tx dataservices.DataStoreTx) error { +// var err error +// isUnique, err =// enforceUniqueGitSource(tx, url, username, password, sourceID) +// return err +// })) + +// return isUnique +// } + +// func TestValidateUniqueSource_SameURLAndCreds_IsDuplicate(t *testing.T) { +// t.Parallel() +// _, store := datastore.MustNewTestStore(t, false, true) + +// require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { +// return tx.Source().Create(adminUserContext, newSourceWithAuth("https://github.com/org/repo.git", "alice", "secret")) +// })) + +// require.False(t, validateUniqueSourceInStore(t, store, "https://github.com/org/repo.git", "alice", "secret", 0)) +// } + +// func TestValidateUniqueSource_SameURLDifferentCreds_IsUnique(t *testing.T) { +// t.Parallel() +// _, store := datastore.MustNewTestStore(t, false, true) + +// require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { +// return tx.Source().Create(adminUserContext, newSourceWithAuth("https://github.com/org/repo.git", "alice", "secret")) +// })) + +// require.True(t, validateUniqueSourceInStore(t, store, "https://github.com/org/repo.git", "bob", "other", 0)) +// } + +// func TestValidateUniqueSource_TwoAuthlessSameURL_IsDuplicate(t *testing.T) { +// t.Parallel() +// _, store := datastore.MustNewTestStore(t, false, true) + +// require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { +// return tx.Source().Create(adminUserContext, newAuthlessSource("https://github.com/org/repo.git")) +// })) + +// require.False(t, validateUniqueSourceInStore(t, store, "https://github.com/org/repo.git", "", "", 0)) +// } + +// func TestValidateUniqueSource_AuthlessVsAuthenticated_IsUnique(t *testing.T) { +// t.Parallel() +// _, store := datastore.MustNewTestStore(t, false, true) + +// require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { +// return tx.Source().Create(adminUserContext, newAuthlessSource("https://github.com/org/repo.git")) +// })) + +// require.True(t, validateUniqueSourceInStore(t, store, "https://github.com/org/repo.git", "alice", "secret", 0)) +// } + +// func TestValidateUniqueSource_ExcludesSelf(t *testing.T) { +// t.Parallel() +// _, store := datastore.MustNewTestStore(t, false, true) + +// var srcID portainer.SourceID +// require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { +// src := newSourceWithAuth("https://github.com/org/repo.git", "alice", "secret") +// if err := tx.Source().Create(adminUserContext, src); err != nil { +// return err +// } +// srcID = src.ID +// return nil +// })) + +// require.True(t, validateUniqueSourceInStore(t, store, "https://github.com/org/repo.git", "alice", "secret", srcID)) +// } diff --git a/api/dataservices/ssl/ssl.go b/api/dataservices/ssl/ssl.go new file mode 100644 index 0000000..4270c85 --- /dev/null +++ b/api/dataservices/ssl/ssl.go @@ -0,0 +1,56 @@ +package ssl + +import ( + portainer "github.com/portainer/portainer/api" +) + +const ( + // BucketName represents the name of the bucket where this service stores data. + BucketName = "ssl" + key = "SSL" +) + +// Service represents a service for managing ssl data. +type Service struct { + connection portainer.Connection +} + +func (service *Service) BucketName() string { + return BucketName +} + +// NewService creates a new instance of a service. +func NewService(connection portainer.Connection) (*Service, error) { + err := connection.SetServiceName(BucketName) + if err != nil { + return nil, err + } + + return &Service{ + connection: connection, + }, nil +} + +func (service *Service) Tx(tx portainer.Transaction) ServiceTx { + return ServiceTx{ + service: service, + tx: tx, + } +} + +// Settings retrieve the ssl settings object. +func (service *Service) Settings() (*portainer.SSLSettings, error) { + var settings portainer.SSLSettings + + err := service.connection.GetObject(BucketName, []byte(key), &settings) + if err != nil { + return nil, err + } + + return &settings, nil +} + +// UpdateSettings persists a SSLSettings object. +func (service *Service) UpdateSettings(settings *portainer.SSLSettings) error { + return service.connection.UpdateObject(BucketName, []byte(key), settings) +} diff --git a/api/dataservices/ssl/tx.go b/api/dataservices/ssl/tx.go new file mode 100644 index 0000000..d0a2f27 --- /dev/null +++ b/api/dataservices/ssl/tx.go @@ -0,0 +1,31 @@ +package ssl + +import ( + portainer "github.com/portainer/portainer/api" +) + +type ServiceTx struct { + service *Service + tx portainer.Transaction +} + +func (service ServiceTx) BucketName() string { + return BucketName +} + +// Settings retrieve the settings object. +func (service ServiceTx) Settings() (*portainer.SSLSettings, error) { + var settings portainer.SSLSettings + + err := service.tx.GetObject(BucketName, []byte(key), &settings) + if err != nil { + return nil, err + } + + return &settings, nil +} + +// UpdateSettings persists a Settings object. +func (service ServiceTx) UpdateSettings(settings *portainer.SSLSettings) error { + return service.tx.UpdateObject(BucketName, []byte(key), settings) +} diff --git a/api/dataservices/stack/stack.go b/api/dataservices/stack/stack.go new file mode 100644 index 0000000..9c54c28 --- /dev/null +++ b/api/dataservices/stack/stack.go @@ -0,0 +1,136 @@ +package stack + +import ( + "errors" + "strings" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + dserrors "github.com/portainer/portainer/api/dataservices/errors" + + "github.com/rs/zerolog/log" +) + +// BucketName represents the name of the bucket where this service stores data. +const BucketName = "stacks" + +// Service represents a service for managing environment(endpoint) data. +type Service struct { + dataservices.BaseDataService[portainer.Stack, portainer.StackID] +} + +// NewService creates a new instance of a service. +func NewService(connection portainer.Connection) (*Service, error) { + err := connection.SetServiceName(BucketName) + if err != nil { + return nil, err + } + + return &Service{ + BaseDataService: dataservices.BaseDataService[portainer.Stack, portainer.StackID]{ + Bucket: BucketName, + Connection: connection, + }, + }, nil +} + +func (service *Service) Tx(tx portainer.Transaction) ServiceTx { + return ServiceTx{ + BaseDataServiceTx: service.BaseDataService.Tx(tx), + } +} + +// StackByName returns a stack object by name. +func (service *Service) StackByName(name string) (*portainer.Stack, error) { + var s portainer.Stack + + err := service.Connection.GetAll( + BucketName, + &portainer.Stack{}, + dataservices.FirstFn(&s, func(e portainer.Stack) bool { + return e.Name == name + }), + ) + + if errors.Is(err, dataservices.ErrStop) { + return &s, nil + } + + if err == nil { + return nil, dserrors.ErrObjectNotFound + } + + return nil, err +} + +// Stacks returns an array containing all the stacks with same name +func (service *Service) StacksByName(name string) ([]portainer.Stack, error) { + var stacks = make([]portainer.Stack, 0) + + return stacks, service.Connection.GetAll( + BucketName, + &portainer.Stack{}, + dataservices.FilterFn(&stacks, func(e portainer.Stack) bool { + return e.Name == name + }), + ) +} + +// GetNextIdentifier returns the next identifier for a stack. +func (service *Service) GetNextIdentifier() int { + return service.Connection.GetNextIdentifier(BucketName) +} + +// CreateStack creates a new stack. +func (service *Service) Create(stack *portainer.Stack) error { + if stack.GitConfig != nil { + log.Warn().Int("stackID", int(stack.ID)).Str("url", stack.GitConfig.URL).Msg("stack persisted with non-nil GitConfig; GitConfig is deprecated, use WorkflowID/Source instead") + } + + return service.Connection.CreateObjectWithId(BucketName, int(stack.ID), stack) +} + +func (service *Service) Update(ID portainer.StackID, stack *portainer.Stack) error { + if stack.GitConfig != nil { + log.Warn().Int("stackID", int(ID)).Str("url", stack.GitConfig.URL).Msg("stack persisted with non-nil GitConfig; GitConfig is deprecated, use WorkflowID/Source instead") + } + + return service.BaseDataService.Update(ID, stack) +} + +// StackByWebhookID returns a pointer to a stack object by webhook ID. +// It returns nil, errors.ErrObjectNotFound if there's no stack associated with the webhook ID. +func (service *Service) StackByWebhookID(id string) (*portainer.Stack, error) { + var s portainer.Stack + + err := service.Connection.GetAll( + BucketName, + &portainer.Stack{}, + dataservices.FirstFn(&s, func(e portainer.Stack) bool { + return e.AutoUpdate != nil && strings.EqualFold(e.AutoUpdate.Webhook, id) + }), + ) + + if errors.Is(err, dataservices.ErrStop) { + return &s, nil + } + + if err == nil { + return nil, dserrors.ErrObjectNotFound + } + + return nil, err +} + +// RefreshableStacks returns stacks that are configured for a periodic update +func (service *Service) RefreshableStacks() ([]portainer.Stack, error) { + stacks := make([]portainer.Stack, 0) + + return stacks, service.Connection.GetAll( + BucketName, + &portainer.Stack{}, + dataservices.FilterFn(&stacks, func(e portainer.Stack) bool { + return e.WorkflowID != 0 && e.AutoUpdate != nil && e.AutoUpdate.Interval != "" + }), + ) +} diff --git a/api/dataservices/stack/tests/stack_test.go b/api/dataservices/stack/tests/stack_test.go new file mode 100644 index 0000000..1cafe95 --- /dev/null +++ b/api/dataservices/stack/tests/stack_test.go @@ -0,0 +1,107 @@ +package tests + +import ( + "testing" + "time" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/filesystem" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newGuidString(t *testing.T) string { + uuid, err := uuid.NewRandom() + require.NoError(t, err) + + return uuid.String() +} + +type stackBuilder struct { + t *testing.T + count int + store *datastore.Store +} + +func TestService_StackByWebhookID(t *testing.T) { + t.Parallel() + if testing.Short() { + t.Skip("skipping test in short mode. Normally takes ~1s to run.") + } + _, store := datastore.MustNewTestStore(t, false, true) + + b := stackBuilder{t: t, store: store} + b.createNewStack(newGuidString(t)) + for range 10 { + b.createNewStack("") + } + webhookID := newGuidString(t) + stack := b.createNewStack(webhookID) + + // can find a stack by webhook ID + got, err := store.StackService.StackByWebhookID(webhookID) + require.NoError(t, err) + assert.Equal(t, stack, *got) + + // returns nil and object not found error if there's no stack associated with the webhook + got, err = store.StackService.StackByWebhookID(newGuidString(t)) + assert.Nil(t, got) + assert.True(t, store.IsErrObjectNotFound(err)) +} + +func (b *stackBuilder) createNewStack(webhookID string) portainer.Stack { + b.count++ + stack := portainer.Stack{ + ID: portainer.StackID(b.count), + Name: "Name", + Type: portainer.DockerComposeStack, + EndpointID: 2, + EntryPoint: filesystem.ComposeFileDefaultName, + Env: []portainer.Pair{{Name: "Name1", Value: "Value1"}}, + Status: portainer.StackStatusActive, + CreationDate: time.Now().Unix(), + ProjectPath: "/tmp/project", + CreatedBy: "test", + } + + if webhookID == "" { + if b.count%2 == 0 { + stack.AutoUpdate = &portainer.AutoUpdateSettings{ + Interval: "", + Webhook: "", + } + } // else keep AutoUpdate nil + } else { + stack.AutoUpdate = &portainer.AutoUpdateSettings{Webhook: webhookID} + } + + err := b.store.StackService.Create(&stack) + assert.NoError(b.t, err) + + return stack +} + +func Test_RefreshableStacks(t *testing.T) { + t.Parallel() + if testing.Short() { + t.Skip("skipping test in short mode. Normally takes ~1s to run.") + } + _, store := datastore.MustNewTestStore(t, false, true) + + staticStack := portainer.Stack{ID: 1} + stackWithWebhook := portainer.Stack{ID: 2, AutoUpdate: &portainer.AutoUpdateSettings{Webhook: "webhook"}} + intervalNoWorkflow := portainer.Stack{ID: 3, AutoUpdate: &portainer.AutoUpdateSettings{Interval: "1m"}} + refreshableStack := portainer.Stack{ID: 4, WorkflowID: 1, AutoUpdate: &portainer.AutoUpdateSettings{Interval: "1m"}} + + for _, stack := range []*portainer.Stack{&staticStack, &stackWithWebhook, &intervalNoWorkflow, &refreshableStack} { + err := store.Stack().Create(stack) + require.NoError(t, err) + } + + stacks, err := store.Stack().RefreshableStacks() + require.NoError(t, err) + require.ElementsMatch(t, []portainer.Stack{refreshableStack}, stacks) +} diff --git a/api/dataservices/stack/tx.go b/api/dataservices/stack/tx.go new file mode 100644 index 0000000..c05216e --- /dev/null +++ b/api/dataservices/stack/tx.go @@ -0,0 +1,112 @@ +package stack + +import ( + "errors" + "strings" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + dserrors "github.com/portainer/portainer/api/dataservices/errors" + + "github.com/rs/zerolog/log" +) + +type ServiceTx struct { + dataservices.BaseDataServiceTx[portainer.Stack, portainer.StackID] +} + +// StackByName returns a stack object by name. +func (service ServiceTx) StackByName(name string) (*portainer.Stack, error) { + var s portainer.Stack + + err := service.Tx.GetAll( + BucketName, + &portainer.Stack{}, + dataservices.FirstFn(&s, func(e portainer.Stack) bool { + return e.Name == name + }), + ) + + if errors.Is(err, dataservices.ErrStop) { + return &s, nil + } + + if err == nil { + return nil, dserrors.ErrObjectNotFound + } + + return nil, err +} + +// Stacks returns an array containing all the stacks with same name +func (service ServiceTx) StacksByName(name string) ([]portainer.Stack, error) { + var stacks = make([]portainer.Stack, 0) + + return stacks, service.Tx.GetAll( + BucketName, + &portainer.Stack{}, + dataservices.FilterFn(&stacks, func(e portainer.Stack) bool { + return e.Name == name + }), + ) +} + +// GetNextIdentifier returns the next identifier for a stack. +func (service ServiceTx) GetNextIdentifier() int { + return service.Tx.GetNextIdentifier(BucketName) +} + +// CreateStack creates a new stack. +func (service ServiceTx) Create(stack *portainer.Stack) error { + if stack.GitConfig != nil { + log.Warn().Int("stackID", int(stack.ID)).Str("url", stack.GitConfig.URL).Msg("stack persisted with non-nil GitConfig; GitConfig is deprecated, use WorkflowID/Source instead") + } + + return service.Tx.CreateObjectWithId(BucketName, int(stack.ID), stack) +} + +func (service ServiceTx) Update(ID portainer.StackID, stack *portainer.Stack) error { + if stack.GitConfig != nil { + log.Warn().Int("stackID", int(ID)).Str("url", stack.GitConfig.URL).Msg("stack persisted with non-nil GitConfig; GitConfig is deprecated, use WorkflowID/Source instead") + } + + return service.BaseDataServiceTx.Update(ID, stack) +} + +// StackByWebhookID returns a pointer to a stack object by webhook ID. +// It returns nil, errors.ErrObjectNotFound if there's no stack associated with the webhook ID. +func (service ServiceTx) StackByWebhookID(id string) (*portainer.Stack, error) { + var s portainer.Stack + + err := service.Tx.GetAll( + BucketName, + &portainer.Stack{}, + dataservices.FirstFn(&s, func(e portainer.Stack) bool { + return e.AutoUpdate != nil && strings.EqualFold(e.AutoUpdate.Webhook, id) + }), + ) + + if errors.Is(err, dataservices.ErrStop) { + return &s, nil + } + + if err == nil { + return nil, dserrors.ErrObjectNotFound + } + + return nil, err + +} + +// RefreshableStacks returns stacks that are configured for a periodic update +func (service ServiceTx) RefreshableStacks() ([]portainer.Stack, error) { + stacks := make([]portainer.Stack, 0) + + return stacks, service.Tx.GetAll( + BucketName, + &portainer.Stack{}, + dataservices.FilterFn(&stacks, func(e portainer.Stack) bool { + return e.WorkflowID != 0 && e.AutoUpdate != nil && e.AutoUpdate.Interval != "" + }), + ) +} diff --git a/api/dataservices/tag/tag.go b/api/dataservices/tag/tag.go new file mode 100644 index 0000000..052cb10 --- /dev/null +++ b/api/dataservices/tag/tag.go @@ -0,0 +1,60 @@ +package tag + +import ( + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" +) + +// BucketName represents the name of the bucket where this service stores data. +const BucketName = "tags" + +// Service represents a service for managing environment(endpoint) data. +type Service struct { + dataservices.BaseDataService[portainer.Tag, portainer.TagID] +} + +// NewService creates a new instance of a service. +func NewService(connection portainer.Connection) (*Service, error) { + err := connection.SetServiceName(BucketName) + if err != nil { + return nil, err + } + + return &Service{ + BaseDataService: dataservices.BaseDataService[portainer.Tag, portainer.TagID]{ + Bucket: BucketName, + Connection: connection, + }, + }, nil +} + +func (service *Service) Tx(tx portainer.Transaction) ServiceTx { + return ServiceTx{ + BaseDataServiceTx: dataservices.BaseDataServiceTx[portainer.Tag, portainer.TagID]{ + Bucket: BucketName, + Connection: service.Connection, + Tx: tx, + }, + } +} + +// CreateTag creates a new tag. +func (service *Service) Create(tag *portainer.Tag) error { + return service.Connection.CreateObject( + BucketName, + func(id uint64) (int, any) { + tag.ID = portainer.TagID(id) + return int(tag.ID), tag + }, + ) +} + +// UpdateTagFunc updates a tag inside a transaction avoiding data races. +func (service *Service) UpdateTagFunc(ID portainer.TagID, updateFunc func(tag *portainer.Tag)) error { + id := service.Connection.ConvertToKey(int(ID)) + tag := &portainer.Tag{} + + return service.Connection.UpdateObjectFunc(BucketName, id, tag, func() { + updateFunc(tag) + }) +} diff --git a/api/dataservices/tag/tx.go b/api/dataservices/tag/tx.go new file mode 100644 index 0000000..6ee1a9e --- /dev/null +++ b/api/dataservices/tag/tx.go @@ -0,0 +1,28 @@ +package tag + +import ( + "errors" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" +) + +type ServiceTx struct { + dataservices.BaseDataServiceTx[portainer.Tag, portainer.TagID] +} + +// CreateTag creates a new tag. +func (service ServiceTx) Create(tag *portainer.Tag) error { + return service.Tx.CreateObject( + BucketName, + func(id uint64) (int, any) { + tag.ID = portainer.TagID(id) + return int(tag.ID), tag + }, + ) +} + +// UpdateTagFunc is a no-op inside a transaction. +func (service ServiceTx) UpdateTagFunc(ID portainer.TagID, updateFunc func(tag *portainer.Tag)) error { + return errors.New("cannot be called inside a transaction") +} diff --git a/api/dataservices/team/team.go b/api/dataservices/team/team.go new file mode 100644 index 0000000..3d9fddc --- /dev/null +++ b/api/dataservices/team/team.go @@ -0,0 +1,76 @@ +package team + +import ( + "errors" + "strings" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + dserrors "github.com/portainer/portainer/api/dataservices/errors" +) + +// BucketName represents the name of the bucket where this service stores data. +const BucketName = "teams" + +// Service represents a service for managing environment(endpoint) data. +type Service struct { + dataservices.BaseDataService[portainer.Team, portainer.TeamID] +} + +// NewService creates a new instance of a service. +func NewService(connection portainer.Connection) (*Service, error) { + if err := connection.SetServiceName(BucketName); err != nil { + return nil, err + } + + return &Service{ + BaseDataService: dataservices.BaseDataService[portainer.Team, portainer.TeamID]{ + Bucket: BucketName, + Connection: connection, + }, + }, nil +} + +func (service *Service) Tx(tx portainer.Transaction) ServiceTx { + return ServiceTx{ + BaseDataServiceTx: dataservices.BaseDataServiceTx[portainer.Team, portainer.TeamID]{ + Bucket: BucketName, + Connection: service.Connection, + Tx: tx, + }, + } +} + +// TeamByName returns a team by name. +func (service *Service) TeamByName(name string) (*portainer.Team, error) { + var t portainer.Team + + err := service.Connection.GetAll( + BucketName, + &portainer.Team{}, + dataservices.FirstFn(&t, func(e portainer.Team) bool { + return strings.EqualFold(e.Name, name) + }), + ) + + if errors.Is(err, dataservices.ErrStop) { + return &t, nil + } + + if err == nil { + return nil, dserrors.ErrObjectNotFound + } + + return nil, err +} + +// CreateTeam creates a new Team. +func (service *Service) Create(team *portainer.Team) error { + return service.Connection.CreateObject( + BucketName, + func(id uint64) (int, any) { + team.ID = portainer.TeamID(id) + return int(team.ID), team + }, + ) +} diff --git a/api/dataservices/team/tests/team_test.go b/api/dataservices/team/tests/team_test.go new file mode 100644 index 0000000..fa08a5b --- /dev/null +++ b/api/dataservices/team/tests/team_test.go @@ -0,0 +1,73 @@ +package tests + +import ( + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices/errors" + "github.com/portainer/portainer/api/datastore" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type teamBuilder struct { + t *testing.T + count int + store *datastore.Store +} + +func (b *teamBuilder) createNew(name string) *portainer.Team { + b.count++ + team := &portainer.Team{ + ID: portainer.TeamID(b.count), + Name: name, + } + + err := b.store.Team().Create(team) + assert.NoError(b.t, err) + + return team +} + +func Test_teamByName(t *testing.T) { + t.Parallel() + t.Run("When store is empty should return ErrObjectNotFound", func(t *testing.T) { + _, store := datastore.MustNewTestStore(t, false, true) + + _, err := store.Team().TeamByName("name") + require.ErrorIs(t, err, errors.ErrObjectNotFound) + + }) + + t.Run("When there is no object with the same name should return ErrObjectNotFound", func(t *testing.T) { + _, store := datastore.MustNewTestStore(t, false, true) + + teamBuilder := teamBuilder{ + t: t, + store: store, + count: 0, + } + + teamBuilder.createNew("name1") + + _, err := store.Team().TeamByName("name") + require.ErrorIs(t, err, errors.ErrObjectNotFound) + }) + + t.Run("When there is an object with the same name should return the object", func(t *testing.T) { + _, store := datastore.MustNewTestStore(t, false, true) + + teamBuilder := teamBuilder{ + t: t, + store: store, + count: 0, + } + + expectedTeam := teamBuilder.createNew("name1") + + team, err := store.Team().TeamByName("name1") + require.NoError(t, err, "TeamByName should succeed") + assert.Equal(t, expectedTeam, team) + }) +} diff --git a/api/dataservices/team/tx.go b/api/dataservices/team/tx.go new file mode 100644 index 0000000..4887aa4 --- /dev/null +++ b/api/dataservices/team/tx.go @@ -0,0 +1,48 @@ +package team + +import ( + "errors" + "strings" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + dserrors "github.com/portainer/portainer/api/dataservices/errors" +) + +type ServiceTx struct { + dataservices.BaseDataServiceTx[portainer.Team, portainer.TeamID] +} + +// TeamByName returns a team by name. +func (service ServiceTx) TeamByName(name string) (*portainer.Team, error) { + var t portainer.Team + + err := service.Tx.GetAll( + BucketName, + &portainer.Team{}, + dataservices.FirstFn(&t, func(e portainer.Team) bool { + return strings.EqualFold(e.Name, name) + }), + ) + + if errors.Is(err, dataservices.ErrStop) { + return &t, nil + } + + if err == nil { + return nil, dserrors.ErrObjectNotFound + } + + return nil, err +} + +// CreateTeam creates a new Team. +func (service ServiceTx) Create(team *portainer.Team) error { + return service.Tx.CreateObject( + BucketName, + func(id uint64) (int, any) { + team.ID = portainer.TeamID(id) + return int(team.ID), team + }, + ) +} diff --git a/api/dataservices/teammembership/teammembership.go b/api/dataservices/teammembership/teammembership.go new file mode 100644 index 0000000..4c8e80d --- /dev/null +++ b/api/dataservices/teammembership/teammembership.go @@ -0,0 +1,142 @@ +package teammembership + +import ( + "fmt" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + + "github.com/rs/zerolog/log" +) + +// BucketName represents the name of the bucket where this service stores data. +const BucketName = "team_membership" + +// Service represents a service for managing environment(endpoint) data. +type Service struct { + dataservices.BaseDataService[portainer.TeamMembership, portainer.TeamMembershipID] +} + +// NewService creates a new instance of a service. +func NewService(connection portainer.Connection) (*Service, error) { + err := connection.SetServiceName(BucketName) + if err != nil { + return nil, err + } + + return &Service{ + BaseDataService: dataservices.BaseDataService[portainer.TeamMembership, portainer.TeamMembershipID]{ + Bucket: BucketName, + Connection: connection, + }, + }, nil +} + +func (service *Service) Tx(tx portainer.Transaction) ServiceTx { + return ServiceTx{ + BaseDataServiceTx: dataservices.BaseDataServiceTx[portainer.TeamMembership, portainer.TeamMembershipID]{ + Bucket: BucketName, + Connection: service.Connection, + Tx: tx, + }, + } +} + +// TeamMembershipsByUserID return an array containing all the TeamMembership objects where the specified userID is present. +func (service *Service) TeamMembershipsByUserID(userID portainer.UserID) ([]portainer.TeamMembership, error) { + var memberships = make([]portainer.TeamMembership, 0) + + return memberships, service.Connection.GetAll( + BucketName, + &portainer.TeamMembership{}, + dataservices.FilterFn(&memberships, func(e portainer.TeamMembership) bool { + return e.UserID == userID + }), + ) +} + +// TeamMembershipsByTeamID return an array containing all the TeamMembership objects where the specified teamID is present. +func (service *Service) TeamMembershipsByTeamID(teamID portainer.TeamID) ([]portainer.TeamMembership, error) { + var memberships = make([]portainer.TeamMembership, 0) + + return memberships, service.Connection.GetAll( + BucketName, + &portainer.TeamMembership{}, + dataservices.FilterFn(&memberships, func(e portainer.TeamMembership) bool { + return e.TeamID == teamID + }), + ) +} + +// CreateTeamMembership creates a new TeamMembership object. +func (service *Service) Create(membership *portainer.TeamMembership) error { + return service.Connection.CreateObject( + BucketName, + func(id uint64) (int, any) { + membership.ID = portainer.TeamMembershipID(id) + return int(membership.ID), membership + }, + ) +} + +// DeleteTeamMembershipByUserID deletes all the TeamMembership object associated to a UserID. +func (service *Service) DeleteTeamMembershipByUserID(userID portainer.UserID) error { + return service.Connection.DeleteAllObjects( + BucketName, + &portainer.TeamMembership{}, + func(obj any) (id int, ok bool) { + membership, ok := obj.(*portainer.TeamMembership) + if !ok { + log.Debug().Str("obj", fmt.Sprintf("%#v", obj)).Msg("failed to convert to TeamMembership object") + //return fmt.Errorf("Failed to convert to TeamMembership object: %s", obj) + return -1, false + } + + if membership.UserID == userID { + return int(membership.ID), true + } + + return -1, false + }) +} + +// DeleteTeamMembershipByTeamID deletes all the TeamMembership object associated to a TeamID. +func (service *Service) DeleteTeamMembershipByTeamID(teamID portainer.TeamID) error { + return service.Connection.DeleteAllObjects( + BucketName, + &portainer.TeamMembership{}, + func(obj any) (id int, ok bool) { + membership, ok := obj.(*portainer.TeamMembership) + if !ok { + log.Debug().Str("obj", fmt.Sprintf("%#v", obj)).Msg("failed to convert to TeamMembership object") + //return fmt.Errorf("Failed to convert to TeamMembership object: %s", obj) + return -1, false + } + + if membership.TeamID == teamID { + return int(membership.ID), true + } + + return -1, false + }) +} + +func (service *Service) DeleteTeamMembershipByTeamIDAndUserID(teamID portainer.TeamID, userID portainer.UserID) error { + return service.Connection.DeleteAllObjects( + BucketName, + &portainer.TeamMembership{}, + func(obj any) (id int, ok bool) { + membership, ok := obj.(*portainer.TeamMembership) + if !ok { + log.Debug().Str("obj", fmt.Sprintf("%#v", obj)).Msg("failed to convert to TeamMembership object") + //return fmt.Errorf("Failed to convert to TeamMembership object: %s", obj) + return -1, false + } + + if membership.TeamID == teamID && membership.UserID == userID { + return int(membership.ID), true + } + + return -1, false + }) +} diff --git a/api/dataservices/teammembership/tx.go b/api/dataservices/teammembership/tx.go new file mode 100644 index 0000000..3175eb6 --- /dev/null +++ b/api/dataservices/teammembership/tx.go @@ -0,0 +1,113 @@ +package teammembership + +import ( + "fmt" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + + "github.com/rs/zerolog/log" +) + +type ServiceTx struct { + dataservices.BaseDataServiceTx[portainer.TeamMembership, portainer.TeamMembershipID] +} + +// TeamMembershipsByUserID return an array containing all the TeamMembership objects where the specified userID is present. +func (service ServiceTx) TeamMembershipsByUserID(userID portainer.UserID) ([]portainer.TeamMembership, error) { + var memberships = make([]portainer.TeamMembership, 0) + + return memberships, service.Tx.GetAll( + BucketName, + &portainer.TeamMembership{}, + dataservices.FilterFn(&memberships, func(e portainer.TeamMembership) bool { + return e.UserID == userID + }), + ) +} + +// TeamMembershipsByTeamID return an array containing all the TeamMembership objects where the specified teamID is present. +func (service ServiceTx) TeamMembershipsByTeamID(teamID portainer.TeamID) ([]portainer.TeamMembership, error) { + var memberships = make([]portainer.TeamMembership, 0) + + return memberships, service.Tx.GetAll( + BucketName, + &portainer.TeamMembership{}, + dataservices.FilterFn(&memberships, func(e portainer.TeamMembership) bool { + return e.TeamID == teamID + }), + ) +} + +// CreateTeamMembership creates a new TeamMembership object. +func (service ServiceTx) Create(membership *portainer.TeamMembership) error { + return service.Tx.CreateObject( + BucketName, + func(id uint64) (int, any) { + membership.ID = portainer.TeamMembershipID(id) + return int(membership.ID), membership + }, + ) +} + +// DeleteTeamMembershipByUserID deletes all the TeamMembership object associated to a UserID. +func (service ServiceTx) DeleteTeamMembershipByUserID(userID portainer.UserID) error { + return service.Tx.DeleteAllObjects( + BucketName, + &portainer.TeamMembership{}, + func(obj any) (id int, ok bool) { + membership, ok := obj.(portainer.TeamMembership) + if !ok { + log.Debug().Str("obj", fmt.Sprintf("%#v", obj)).Msg("failed to convert to TeamMembership object") + //return fmt.Errorf("Failed to convert to TeamMembership object: %s", obj) + return -1, false + } + + if membership.UserID == userID { + return int(membership.ID), true + } + + return -1, false + }) +} + +// DeleteTeamMembershipByTeamID deletes all the TeamMembership object associated to a TeamID. +func (service ServiceTx) DeleteTeamMembershipByTeamID(teamID portainer.TeamID) error { + return service.Tx.DeleteAllObjects( + BucketName, + &portainer.TeamMembership{}, + func(obj any) (id int, ok bool) { + membership, ok := obj.(portainer.TeamMembership) + if !ok { + log.Debug().Str("obj", fmt.Sprintf("%#v", obj)).Msg("failed to convert to TeamMembership object") + //return fmt.Errorf("Failed to convert to TeamMembership object: %s", obj) + return -1, false + } + + if membership.TeamID == teamID { + return int(membership.ID), true + } + + return -1, false + }) +} + +func (service ServiceTx) DeleteTeamMembershipByTeamIDAndUserID(teamID portainer.TeamID, userID portainer.UserID) error { + return service.Tx.DeleteAllObjects( + BucketName, + &portainer.TeamMembership{}, + func(obj any) (id int, ok bool) { + membership, ok := obj.(portainer.TeamMembership) + if !ok { + log.Debug().Str("obj", fmt.Sprintf("%#v", obj)).Msg("failed to convert to TeamMembership object") + //return fmt.Errorf("Failed to convert to TeamMembership object: %s", obj) + return -1, false + } + + if membership.TeamID == teamID && membership.UserID == userID { + return int(membership.ID), true + } + + return -1, false + }) +} diff --git a/api/dataservices/tunnelserver/tunnelserver.go b/api/dataservices/tunnelserver/tunnelserver.go new file mode 100644 index 0000000..ea573e7 --- /dev/null +++ b/api/dataservices/tunnelserver/tunnelserver.go @@ -0,0 +1,49 @@ +package tunnelserver + +import ( + portainer "github.com/portainer/portainer/api" +) + +const ( + // BucketName represents the name of the bucket where this service stores data. + BucketName = "tunnel_server" + infoKey = "INFO" +) + +// Service represents a service for managing environment(endpoint) data. +type Service struct { + connection portainer.Connection +} + +func (service *Service) BucketName() string { + return BucketName +} + +// NewService creates a new instance of a service. +func NewService(connection portainer.Connection) (*Service, error) { + err := connection.SetServiceName(BucketName) + if err != nil { + return nil, err + } + + return &Service{ + connection: connection, + }, nil +} + +// Info retrieve the TunnelServerInfo object. +func (service *Service) Info() (*portainer.TunnelServerInfo, error) { + var info portainer.TunnelServerInfo + + err := service.connection.GetObject(BucketName, []byte(infoKey), &info) + if err != nil { + return nil, err + } + + return &info, nil +} + +// UpdateInfo persists a TunnelServerInfo object. +func (service *Service) UpdateInfo(settings *portainer.TunnelServerInfo) error { + return service.connection.UpdateObject(BucketName, []byte(infoKey), settings) +} diff --git a/api/dataservices/user/tx.go b/api/dataservices/user/tx.go new file mode 100644 index 0000000..5162b94 --- /dev/null +++ b/api/dataservices/user/tx.go @@ -0,0 +1,75 @@ +package user + +import ( + "errors" + "strings" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + dserrors "github.com/portainer/portainer/api/dataservices/errors" +) + +type ServiceTx struct { + dataservices.BaseDataServiceTx[portainer.User, portainer.UserID] +} + +// UserByUsername returns a user by username. +func (service ServiceTx) UserByUsername(username string) (*portainer.User, error) { + var u portainer.User + + err := service.Tx.GetAll( + BucketName, + &portainer.User{}, + dataservices.FirstFn(&u, func(e portainer.User) bool { + return strings.EqualFold(e.Username, username) + }), + ) + + if errors.Is(err, dataservices.ErrStop) { + return &u, nil + } + + if err == nil { + return nil, dserrors.ErrObjectNotFound + } + + return nil, err +} + +func (service ServiceTx) UserIDByUsername(username string) (portainer.UserID, error) { + user, err := service.UserByUsername(username) + if err != nil { + return 0, err + } + + if user == nil { + return 0, dserrors.ErrObjectNotFound + } + return user.ID, nil +} + +// UsersByRole return an array containing all the users with the specified role. +func (service ServiceTx) UsersByRole(role portainer.UserRole) ([]portainer.User, error) { + var users = make([]portainer.User, 0) + + return users, service.Tx.GetAll( + BucketName, + &portainer.User{}, + dataservices.FilterFn(&users, func(e portainer.User) bool { + return e.Role == role + }), + ) +} + +// CreateUser creates a new user. +func (service ServiceTx) Create(user *portainer.User) error { + return service.Tx.CreateObject( + BucketName, + func(id uint64) (int, any) { + user.ID = portainer.UserID(id) + user.Username = strings.ToLower(user.Username) + + return int(user.ID), user + }, + ) +} diff --git a/api/dataservices/user/user.go b/api/dataservices/user/user.go new file mode 100644 index 0000000..2ab1a52 --- /dev/null +++ b/api/dataservices/user/user.go @@ -0,0 +1,104 @@ +package user + +import ( + "errors" + "strings" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + dserrors "github.com/portainer/portainer/api/dataservices/errors" +) + +// BucketName represents the name of the bucket where this service stores data. +const BucketName = "users" + +// Service represents a service for managing environment(endpoint) data. +type Service struct { + dataservices.BaseDataService[portainer.User, portainer.UserID] +} + +// NewService creates a new instance of a service. +func NewService(connection portainer.Connection) (*Service, error) { + err := connection.SetServiceName(BucketName) + if err != nil { + return nil, err + } + + return &Service{ + BaseDataService: dataservices.BaseDataService[portainer.User, portainer.UserID]{ + Bucket: BucketName, + Connection: connection, + }, + }, nil +} + +func (service *Service) Tx(tx portainer.Transaction) ServiceTx { + return ServiceTx{ + BaseDataServiceTx: dataservices.BaseDataServiceTx[portainer.User, portainer.UserID]{ + Bucket: BucketName, + Connection: service.Connection, + Tx: tx, + }, + } +} + +// UserByUsername returns a user by username. +func (service *Service) UserByUsername(username string) (*portainer.User, error) { + var u portainer.User + + err := service.Connection.GetAll( + BucketName, + &portainer.User{}, + dataservices.FirstFn(&u, func(e portainer.User) bool { + return strings.EqualFold(e.Username, username) + }), + ) + + if errors.Is(err, dataservices.ErrStop) { + return &u, nil + } + + if err == nil { + return nil, dserrors.ErrObjectNotFound + } + + return nil, err +} + +func (service *Service) UserIDByUsername(username string) (portainer.UserID, error) { + user, err := service.UserByUsername(username) + if err != nil { + return 0, err + } + + if user == nil { + return 0, dserrors.ErrObjectNotFound + } + return user.ID, nil +} + +// UsersByRole return an array containing all the users with the specified role. +func (service *Service) UsersByRole(role portainer.UserRole) ([]portainer.User, error) { + var users = make([]portainer.User, 0) + + return users, service.Connection.GetAll( + BucketName, + &portainer.User{}, + dataservices.FilterFn(&users, func(e portainer.User) bool { + return e.Role == role + }), + ) +} + +// CreateUser creates a new user. +func (service *Service) Create(user *portainer.User) error { + return service.Connection.CreateObject( + BucketName, + func(id uint64) (int, any) { + user.ID = portainer.UserID(id) + user.Username = strings.ToLower(user.Username) + + return int(user.ID), user + }, + ) +} diff --git a/api/dataservices/version/tx.go b/api/dataservices/version/tx.go new file mode 100644 index 0000000..b87f89f --- /dev/null +++ b/api/dataservices/version/tx.go @@ -0,0 +1,70 @@ +package version + +import ( + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/database/models" + "github.com/portainer/portainer/api/dataservices" +) + +type ServiceTx struct { + dataservices.BaseDataServiceTx[models.Version, int] // ID is not used +} + +func (tx ServiceTx) InstanceID() (string, error) { + v, err := tx.Version() + if err != nil { + return "", err + } + + return v.InstanceID, nil +} + +func (tx ServiceTx) UpdateInstanceID(ID string) error { + v, err := tx.Version() + if err != nil { + if !dataservices.IsErrObjectNotFound(err) { + return err + } + + v = &models.Version{} + } + + v.InstanceID = ID + + return tx.UpdateVersion(v) +} + +func (tx ServiceTx) Edition() (portainer.SoftwareEdition, error) { + v, err := tx.Version() + if err != nil { + return 0, err + } + + return portainer.SoftwareEdition(v.Edition), nil +} + +func (tx ServiceTx) Version() (*models.Version, error) { + var v models.Version + + err := tx.Tx.GetObject(BucketName, []byte(versionKey), &v) + if err != nil { + return nil, err + } + + return &v, nil +} + +func (tx ServiceTx) UpdateVersion(version *models.Version) error { + return tx.Tx.UpdateObject(BucketName, []byte(versionKey), version) +} + +func (tx ServiceTx) SchemaVersion() (string, error) { + var v models.Version + + err := tx.Tx.GetObject(BucketName, []byte(versionKey), &v) + if err != nil { + return "", err + } + + return v.SchemaVersion, nil +} diff --git a/api/dataservices/version/version.go b/api/dataservices/version/version.go new file mode 100644 index 0000000..bf47c45 --- /dev/null +++ b/api/dataservices/version/version.go @@ -0,0 +1,133 @@ +package version + +import ( + "errors" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/database/models" + "github.com/portainer/portainer/api/dataservices" + dserrors "github.com/portainer/portainer/api/dataservices/errors" +) + +const ( + // BucketName represents the name of the bucket where this service stores data. + BucketName = "version" + versionKey = "VERSION" + updatingKey = "DB_UPDATING" +) + +// Service represents a service to manage stored versions. +type Service struct { + connection portainer.Connection +} + +// NewService creates a new instance of a service. +func NewService(connection portainer.Connection) (*Service, error) { + err := connection.SetServiceName(BucketName) + if err != nil { + return nil, err + } + + return &Service{ + connection: connection, + }, nil +} + +func (service *Service) Tx(tx portainer.Transaction) ServiceTx { + return ServiceTx{ + BaseDataServiceTx: dataservices.BaseDataServiceTx[models.Version, int]{ + Bucket: BucketName, + Connection: service.connection, + Tx: tx, + }, + } +} + +func (service *Service) SchemaVersion() (string, error) { + v, err := service.Version() + if err != nil { + return "", err + } + + return v.SchemaVersion, nil +} + +func (service *Service) UpdateSchemaVersion(version string) error { + v, err := service.Version() + if err != nil { + return err + } + + v.SchemaVersion = version + return service.UpdateVersion(v) +} + +func (service *Service) Edition() (portainer.SoftwareEdition, error) { + v, err := service.Version() + if err != nil { + return 0, err + } + + return portainer.SoftwareEdition(v.Edition), nil +} + +// IsUpdating retrieves the database updating status. +func (service *Service) IsUpdating() (bool, error) { + var isUpdating bool + err := service.connection.GetObject(BucketName, []byte(updatingKey), &isUpdating) + if err != nil && errors.Is(err, dserrors.ErrObjectNotFound) { + return false, nil + } + return isUpdating, err +} + +// StoreIsUpdating store the database updating status. +func (service *Service) StoreIsUpdating(isUpdating bool) error { + if isUpdating { + return service.connection.UpdateObject(BucketName, []byte(updatingKey), isUpdating) + } + + return service.connection.DeleteObject(BucketName, []byte(updatingKey)) +} + +// InstanceID retrieves the stored instance ID. +func (service *Service) InstanceID() (string, error) { + v, err := service.Version() + if err != nil { + return "", err + } + + return v.InstanceID, nil +} + +// StoreInstanceID store the instance ID. +func (service *Service) UpdateInstanceID(id string) error { + v, err := service.Version() + if err != nil { + if !dataservices.IsErrObjectNotFound(err) { + return err + } + + v = &models.Version{} + } + + v.InstanceID = id + return service.UpdateVersion(v) +} + +// Version retrieve the version object. +func (service *Service) Version() (*models.Version, error) { + var v models.Version + + err := service.connection.GetObject(BucketName, []byte(versionKey), &v) + if err != nil { + return nil, err + } + + return &v, nil +} + +// UpdateVersion persists a Version object. +func (service *Service) UpdateVersion(version *models.Version) error { + return service.connection.UpdateObject(BucketName, []byte(versionKey), version) +} diff --git a/api/dataservices/webhook/webhook.go b/api/dataservices/webhook/webhook.go new file mode 100644 index 0000000..58347cb --- /dev/null +++ b/api/dataservices/webhook/webhook.go @@ -0,0 +1,89 @@ +package webhook + +import ( + "errors" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + dserrors "github.com/portainer/portainer/api/dataservices/errors" +) + +// BucketName represents the name of the bucket where this service stores data. +const BucketName = "webhooks" + +// Service represents a service for managing webhook data. +type Service struct { + dataservices.BaseDataService[portainer.Webhook, portainer.WebhookID] +} + +// NewService creates a new instance of a service. +func NewService(connection portainer.Connection) (*Service, error) { + err := connection.SetServiceName(BucketName) + if err != nil { + return nil, err + } + + return &Service{ + BaseDataService: dataservices.BaseDataService[portainer.Webhook, portainer.WebhookID]{ + Bucket: BucketName, + Connection: connection, + }, + }, nil +} + +// WebhookByResourceID returns a webhook by the ResourceID it is associated with. +func (service *Service) WebhookByResourceID(ID string) (*portainer.Webhook, error) { + var w portainer.Webhook + + err := service.Connection.GetAll( + BucketName, + &portainer.Webhook{}, + dataservices.FirstFn(&w, func(e portainer.Webhook) bool { + return e.ResourceID == ID + }), + ) + + if errors.Is(err, dataservices.ErrStop) { + return &w, nil + } + + if err == nil { + return nil, dserrors.ErrObjectNotFound + } + + return nil, err +} + +// WebhookByToken returns a webhook by the random token it is associated with. +func (service *Service) WebhookByToken(token string) (*portainer.Webhook, error) { + var w portainer.Webhook + + err := service.Connection.GetAll( + BucketName, + &portainer.Webhook{}, + dataservices.FirstFn(&w, func(e portainer.Webhook) bool { + return e.Token == token + }), + ) + + if errors.Is(err, dataservices.ErrStop) { + return &w, nil + } + + if err == nil { + return nil, dserrors.ErrObjectNotFound + } + + return nil, err +} + +// CreateWebhook assign an ID to a new webhook and saves it. +func (service *Service) Create(webhook *portainer.Webhook) error { + return service.Connection.CreateObject( + BucketName, + func(id uint64) (int, any) { + webhook.ID = portainer.WebhookID(id) + return int(webhook.ID), webhook + }, + ) +} diff --git a/api/dataservices/workflow/service.go b/api/dataservices/workflow/service.go new file mode 100644 index 0000000..8901ecb --- /dev/null +++ b/api/dataservices/workflow/service.go @@ -0,0 +1,46 @@ +package workflow + +import ( + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" +) + +const BucketName = "workflows" + +type Service struct { + dataservices.BaseDataService[portainer.Workflow, portainer.WorkflowID] +} + +func NewService(connection portainer.Connection) (*Service, error) { + err := connection.SetServiceName(BucketName) + if err != nil { + return nil, err + } + + return &Service{ + BaseDataService: dataservices.BaseDataService[portainer.Workflow, portainer.WorkflowID]{ + Bucket: BucketName, + Connection: connection, + }, + }, nil +} + +func (service *Service) Tx(tx portainer.Transaction) ServiceTx { + return ServiceTx{ + BaseDataServiceTx: dataservices.BaseDataServiceTx[portainer.Workflow, portainer.WorkflowID]{ + Bucket: BucketName, + Connection: service.Connection, + Tx: tx, + }, + } +} + +func (service *Service) Create(workflow *portainer.Workflow) error { + return service.Connection.CreateObject( + BucketName, + func(id uint64) (int, any) { + workflow.ID = portainer.WorkflowID(id) + return int(workflow.ID), workflow + }, + ) +} diff --git a/api/dataservices/workflow/tx.go b/api/dataservices/workflow/tx.go new file mode 100644 index 0000000..8fe38fd --- /dev/null +++ b/api/dataservices/workflow/tx.go @@ -0,0 +1,20 @@ +package workflow + +import ( + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" +) + +type ServiceTx struct { + dataservices.BaseDataServiceTx[portainer.Workflow, portainer.WorkflowID] +} + +func (service ServiceTx) Create(workflow *portainer.Workflow) error { + return service.Tx.CreateObject( + BucketName, + func(id uint64) (int, any) { + workflow.ID = portainer.WorkflowID(id) + return int(workflow.ID), workflow + }, + ) +} diff --git a/api/datastore/backup.go b/api/datastore/backup.go new file mode 100644 index 0000000..8cf9a18 --- /dev/null +++ b/api/datastore/backup.go @@ -0,0 +1,102 @@ +package datastore + +import ( + "fmt" + "os" + "path" + + portainer "github.com/portainer/portainer/api" + "github.com/rs/zerolog/log" +) + +// Backup takes an optional output path and creates a backup of the database. +// The database connection is stopped before running the backup to avoid any +// corruption and if a path is not given a default is used. +// The path or an error are returned. +func (store *Store) Backup(path string) (string, error) { + if err := store.Close(); err != nil { + return "", fmt.Errorf("failed to close store before backup: %w", err) + } + + filename, err := store.backupDBFile(path) + if err != nil { + return "", err + } + + if _, err := store.Open(); err != nil { + return "", fmt.Errorf("failed to reopen store after backup: %w", err) + } + + return filename, nil +} + +// backupDBFile copies the database file to the backup location. +// Does not manage connection state - works with the database file directly regardless of connection state. +func (store *Store) backupDBFile(backupPath string) (string, error) { + if err := store.createBackupPath(); err != nil { + return "", err + } + + backupFilename := store.backupFilename() + if backupPath != "" { + backupFilename = backupPath + } + + log.Info().Str("from", store.connection.GetDatabaseFilePath()).Str("to", backupFilename).Msg("Backing up database") + + if err := store.fileService.Copy(store.connection.GetDatabaseFilePath(), backupFilename, true); err != nil { + return "", fmt.Errorf("failed to create backup file: %w", err) + } + + return backupFilename, nil +} + +func (store *Store) Restore() error { + backupFilename := store.backupFilename() + return store.RestoreFromFile(backupFilename) +} + +func (store *Store) RestoreFromFile(backupFilename string) error { + if err := store.Close(); err != nil { + return err + } + + if err := store.fileService.Copy(backupFilename, store.connection.GetDatabaseFilePath(), true); err != nil { + return fmt.Errorf("unable to restore backup file %q. err: %w", backupFilename, err) + } + + log.Info().Str("from", backupFilename).Str("to", store.connection.GetDatabaseFilePath()).Msgf("database restored") + + if _, err := store.Open(); err != nil { + return fmt.Errorf("unable to determine version of restored portainer backup file: %w", err) + } + + // determine the db version + version, err := store.VersionService.Version() + if err != nil { + return fmt.Errorf("unable to determine restored database version. err: %w", err) + } + + editionLabel := portainer.SoftwareEdition(version.Edition).GetEditionLabel() + log.Info().Msgf("Restored database version: Portainer %s %s", editionLabel, version.SchemaVersion) + return nil +} + +func (store *Store) createBackupPath() error { + backupDir := path.Join(store.connection.GetStorePath(), "backups") + if exists, _ := store.fileService.FileExists(backupDir); !exists { + if err := os.MkdirAll(backupDir, 0o700); err != nil { + return fmt.Errorf("unable to create backup folder: %w", err) + } + } + + return nil +} + +func (store *Store) backupFilename() string { + return path.Join(store.connection.GetStorePath(), "backups", store.connection.GetDatabaseFileName()+".bak") +} + +func (store *Store) databasePath() string { + return store.connection.GetDatabaseFilePath() +} diff --git a/api/datastore/backup_test.go b/api/datastore/backup_test.go new file mode 100644 index 0000000..330a4cf --- /dev/null +++ b/api/datastore/backup_test.go @@ -0,0 +1,146 @@ +package datastore + +import ( + "os" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/database/boltdb" + "github.com/portainer/portainer/api/database/models" + "github.com/stretchr/testify/require" + + "github.com/rs/zerolog/log" +) + +func TestStoreCreation(t *testing.T) { + t.Parallel() + _, store := MustNewTestStore(t, true, true) + require.NotNil(t, store) + + v, err := store.VersionService.Version() + if err != nil { + log.Fatal().Err(err).Msg("") + } + + if portainer.SoftwareEdition(v.Edition) != portainer.PortainerCE { + t.Error("Expect to get CE Edition") + } + + if v.SchemaVersion != portainer.APIVersion { + t.Error("Expect to get APIVersion") + } +} + +func TestBackup(t *testing.T) { + t.Parallel() + _, store := MustNewTestStore(t, true, true) + backupFileName := store.backupFilename() + t.Run("Backup should create "+backupFileName, func(t *testing.T) { + v := models.Version{ + Edition: int(portainer.PortainerCE), + SchemaVersion: portainer.APIVersion, + } + + err := store.VersionService.UpdateVersion(&v) + require.NoError(t, err) + + _, err = store.Backup("") + require.NoError(t, err) + + if !isFileExist(backupFileName) { + t.Errorf("Expect backup file to be created %s", backupFileName) + } + }) +} + +func TestRestore(t *testing.T) { + t.Parallel() + _, store := MustNewTestStore(t, true, false) + + t.Run("Basic Restore", func(t *testing.T) { + // override and set initial db version and edition + updateEdition(store, portainer.PortainerCE) + updateVersion(store, "2.4") + + _, err := store.Backup("") + require.NoError(t, err) + + updateVersion(store, "2.16") + testVersion(store, "2.16", t) + + err = store.Restore() + require.NoError(t, err) + + // check if the restore is successful and the version is correct + testVersion(store, "2.4", t) + }) + + t.Run("Basic Restore After Multiple Backups", func(t *testing.T) { + // override and set initial db version and edition + updateEdition(store, portainer.PortainerCE) + updateVersion(store, "2.4") + + _, err := store.Backup("") + require.NoError(t, err) + + updateVersion(store, "2.14") + updateVersion(store, "2.16") + testVersion(store, "2.16", t) + + err = store.Restore() + require.NoError(t, err) + + // check if the restore is successful and the version is correct + testVersion(store, "2.4", t) + }) +} + +func TestBackupDBFile(t *testing.T) { + t.Parallel() + _, store := MustNewTestStore(t, true, false) + + t.Run("creates backup file without managing connection state", func(t *testing.T) { + // Verify connection is usable before + _, err := store.VersionService.Version() + require.NoError(t, err, "connection should be usable before backupDBFile") + + // backupDBFile should work without closing the connection + backupFilename, err := store.backupDBFile("") + require.NoError(t, err) + require.FileExists(t, backupFilename) + + // Verify connection is still usable after (not closed/reopened) + _, err = store.VersionService.Version() + require.NoError(t, err, "connection should still be usable after backupDBFile") + + require.NoError(t, os.Remove(backupFilename)) + }) + + t.Run("uses custom path when provided", func(t *testing.T) { + customPath := t.TempDir() + "/custom-backup.db" + backupFilename, err := store.backupDBFile(customPath) + require.NoError(t, err) + require.Equal(t, customPath, backupFilename) + require.FileExists(t, backupFilename) + }) +} + +func TestBackupDBFileUsesCorrectPath(t *testing.T) { + t.Parallel() + _, store := MustNewTestStore(t, true, false) + + t.Run("backs up unencrypted db when encrypted flag is false", func(t *testing.T) { + err := store.connection.SetEncrypted(false) + require.NoError(t, err) + + backupFilename, err := store.backupDBFile("") + require.NoError(t, err) + require.FileExists(t, backupFilename) + + // Verify it backed up the unencrypted file (portainer.db) + require.Contains(t, backupFilename, boltdb.DatabaseFileName) + require.NotContains(t, backupFilename, boltdb.EncryptedDatabaseFileName) + + require.NoError(t, os.Remove(backupFilename)) + }) +} diff --git a/api/datastore/datastore.go b/api/datastore/datastore.go new file mode 100644 index 0000000..b31988f --- /dev/null +++ b/api/datastore/datastore.go @@ -0,0 +1,190 @@ +package datastore + +import ( + "errors" + "fmt" + "io" + "os" + "path" + "time" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + portainerErrors "github.com/portainer/portainer/api/dataservices/errors" + + "github.com/rs/zerolog/log" +) + +// NewStore initializes a new Store and the associated services +func NewStore(cliFlags *portainer.CLIFlags, fileService portainer.FileService, connection portainer.Connection) *Store { + return &Store{ + flags: cliFlags, + fileService: fileService, + connection: connection, + } +} + +// Open opens and initializes the BoltDB database. +func (store *Store) Open() (newStore bool, err error) { + encryptionReq, err := store.connection.NeedsEncryptionMigration() + if err != nil { + return false, err + } + + if encryptionReq { + // NeedsEncryptionMigration() sets encrypted=true as a side effect when a key exists. + // We need to set it back to false so GetDatabaseFilePath() returns the path to the + // actual unencrypted file (portainer.db) that we want to back up. + if err := store.connection.SetEncrypted(false); err != nil { + return false, err + } + + // Use backupDBFile directly since connection isn't open yet + // and we don't want to trigger the close/open cycle of Backup() + backupFilename, err := store.backupDBFile("") + if err != nil { + return false, fmt.Errorf("failed to backup database prior to encrypting: %w", err) + } + + if err := store.encryptDB(); err != nil { + innerErr := store.RestoreFromFile(backupFilename) // restore from backup if encryption fails + return false, errors.Join(err, innerErr) + } + } + + if err := store.connection.Open(); err != nil { + return false, err + } + + if err := store.initServices(); err != nil { + return false, err + } + + // If no settings object exists then assume we have a new store + if _, err := store.SettingsService.Settings(); err != nil { + if store.IsErrObjectNotFound(err) { + return true, nil + } + + return false, err + } + + return false, nil +} + +func (store *Store) Close() error { + return store.connection.Close() +} + +func (store *Store) UpdateTx(fn func(dataservices.DataStoreTx) error) error { + return store.connection.UpdateTx(func(tx portainer.Transaction) error { + return fn(&StoreTx{store: store, tx: tx}) + }) +} + +func (store *Store) ViewTx(fn func(dataservices.DataStoreTx) error) error { + return store.connection.ViewTx(func(tx portainer.Transaction) error { + return fn(&StoreTx{store: store, tx: tx}) + }) +} + +// BackupTo backs up db to a provided writer. +// It does hot backup and doesn't block other database reads and writes +func (store *Store) BackupTo(w io.Writer) error { + return store.connection.BackupTo(w) +} + +// CheckCurrentEdition checks if current edition is community edition +func (store *Store) CheckCurrentEdition() error { + if store.edition() != portainer.Edition { + return portainerErrors.ErrWrongDBEdition + } + + return nil +} + +func (store *Store) edition() portainer.SoftwareEdition { + edition, err := store.VersionService.Edition() + if store.IsErrObjectNotFound(err) { + edition = portainer.PortainerCE + } + + return edition +} + +// TODO: move the use of this to dataservices.IsErrObjectNotFound()? +func (store *Store) IsErrObjectNotFound(e error) bool { + return errors.Is(e, portainerErrors.ErrObjectNotFound) +} + +func (store *Store) Connection() portainer.Connection { + return store.connection +} + +func (store *Store) Rollback(force bool) error { + return store.connectionRollback(force) +} + +func (store *Store) encryptDB() error { + if err := store.connection.SetEncrypted(false); err != nil { + return err + } + + if err := store.connection.Open(); err != nil { + return err + } + + if err := store.initServices(); err != nil { + return err + } + + // The DB is not currently encrypted. First save the encrypted db filename + oldFilename := store.connection.GetDatabaseFilePath() + log.Info().Msg("encrypting database") + + // export file path for backup + exportFilename := path.Join(store.databasePath() + "." + fmt.Sprintf("backup-%d.json", time.Now().Unix())) + + log.Info().Str("filename", exportFilename).Msg("exporting database backup") + + if err := store.Export(exportFilename); err != nil { + log.Error().Str("filename", exportFilename).Err(err).Msg("failed to export") + + return err + } + + log.Info().Msg("database backup exported") + + // Close existing un-encrypted db so that we can delete the file later + if err := store.connection.Close(); err != nil { + return err + } + + if err := store.Import(exportFilename); err != nil { + log.Error().Err(err).Msg("failed to import database backup") + + // Remove the new encrypted file that we failed to import + if err := os.Remove(store.connection.GetDatabaseFilePath()); err != nil { + log.Error().Msg("failed to remove the file after import failure") + } + + log.Fatal().Err(portainerErrors.ErrDBImportFailed).Msg("") + } + + if err := os.Remove(oldFilename); err != nil { + log.Error().Msg("failed to remove the un-encrypted db file") + } + + if err := os.Remove(exportFilename); err != nil { + log.Error().Msg("failed to remove the json backup file") + } + + // Close db connection + if err := store.connection.Close(); err != nil { + return err + } + + log.Info().Msg("database successfully encrypted") + + return nil +} diff --git a/api/datastore/datastore_test.go b/api/datastore/datastore_test.go new file mode 100644 index 0000000..2a76530 --- /dev/null +++ b/api/datastore/datastore_test.go @@ -0,0 +1,383 @@ +package datastore + +import ( + "fmt" + "runtime" + "strings" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/chisel" + "github.com/portainer/portainer/api/crypto" + + "github.com/dchest/uniuri" + "github.com/pkg/errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + adminUsername = "admin" + adminPassword = "password" + standardUsername = "standard" + standardPassword = "password" + agentOnDockerEnvironmentUrl = "tcp://192.168.167.207:30775" + edgeAgentOnKubernetesEnvironmentUrl = "tcp://192.168.167.207" + kubernetesLocalEnvironmentUrl = "https://kubernetes.default.svc" +) + +// TestStoreFull an eventually comprehensive set of tests for the Store. +// The idea is what we write to the store, we should read back. +func TestStoreFull(t *testing.T) { + t.Parallel() + _, store := MustNewTestStore(t, true, true) + + testCases := map[string]func(t *testing.T){ + "User Accounts": store.testUserAccounts, + "Environments": store.testEnvironments, + "Settings": store.testSettings, + "SSL Settings": store.testSSLSettings, + "Tunnel Server": store.testTunnelServer, + "Custom Templates": store.testCustomTemplates, + "Registries": store.testRegistries, + "Resource Control": store.testResourceControl, + "Schedules": store.testSchedules, + "Tags": store.testTags, + } + + for name, test := range testCases { + t.Run(name, test) + } +} + +func (store *Store) testEnvironments(t *testing.T) { + id := store.CreateEndpoint(t, "local", portainer.KubernetesLocalEnvironment, "", true) + store.CreateEndpointRelation(t, id) + + id = store.CreateEndpoint(t, "agent", portainer.AgentOnDockerEnvironment, agentOnDockerEnvironmentUrl, true) + store.CreateEndpointRelation(t, id) + + id = store.CreateEndpoint(t, "edge", portainer.EdgeAgentOnKubernetesEnvironment, edgeAgentOnKubernetesEnvironmentUrl, true) + store.CreateEndpointRelation(t, id) +} + +func newEndpoint(endpointType portainer.EndpointType, id portainer.EndpointID, name, URL string, TLS bool) *portainer.Endpoint { + endpoint := &portainer.Endpoint{ + ID: id, + Name: name, + URL: URL, + Type: endpointType, + GroupID: portainer.EndpointGroupID(1), + PublicURL: "", + TLSConfig: portainer.TLSConfiguration{ + TLS: false, + }, + TagIDs: nil, + Status: portainer.EndpointStatusUp, + Snapshots: nil, + Kubernetes: portainer.KubernetesData{ + Configuration: portainer.KubernetesConfiguration{ + UseLoadBalancer: false, + UseServerMetrics: false, + EnableResourceOverCommit: true, + }, + }, + } + + if TLS { + endpoint.TLSConfig = portainer.TLSConfiguration{ + TLS: true, + TLSSkipVerify: true, + } + } + + return endpoint +} + +func setEndpointAuthorizations(endpoint *portainer.Endpoint) { + endpoint.SecuritySettings = portainer.DefaultEndpointSecuritySettings() +} + +func (store *Store) CreateEndpoint(t *testing.T, name string, endpointType portainer.EndpointType, URL string, tls bool) portainer.EndpointID { + is := assert.New(t) + + var expectedEndpoint *portainer.Endpoint + id := portainer.EndpointID(store.Endpoint().GetNextIdentifier()) + + switch endpointType { + case portainer.DockerEnvironment: + if URL == "" { + URL = "unix:///var/run/docker.sock" + if runtime.GOOS == "windows" { + URL = "npipe:////./pipe/docker_engine" + } + } + expectedEndpoint = newEndpoint(endpointType, id, name, URL, tls) + + case portainer.AgentOnDockerEnvironment: + expectedEndpoint = newEndpoint(endpointType, id, name, URL, tls) + + case portainer.AgentOnKubernetesEnvironment: + URL = strings.TrimPrefix(URL, "tcp://") + expectedEndpoint = newEndpoint(endpointType, id, name, URL, tls) + + case portainer.EdgeAgentOnKubernetesEnvironment: + cs := chisel.NewService(store, nil, nil) + expectedEndpoint = newEndpoint(endpointType, id, name, URL, tls) + edgeKey := cs.GenerateEdgeKey(URL, "", int(id)) + expectedEndpoint.EdgeKey = edgeKey + store.testTunnelServer(t) + + case portainer.KubernetesLocalEnvironment: + if URL == "" { + URL = kubernetesLocalEnvironmentUrl + } + expectedEndpoint = newEndpoint(endpointType, id, name, URL, tls) + } + + setEndpointAuthorizations(expectedEndpoint) + + err := store.Endpoint().Create(expectedEndpoint) + require.NoError(t, err) + + endpoint, err := store.Endpoint().Endpoint(id) + require.NoError(t, err, "Endpoint() should not return an error") + is.Equal(expectedEndpoint, endpoint, "endpoint should be the same") + + return endpoint.ID +} + +func (store *Store) CreateEndpointRelation(t *testing.T, id portainer.EndpointID) { + relation := &portainer.EndpointRelation{ + EndpointID: id, + EdgeStacks: map[portainer.EdgeStackID]bool{}, + } + + err := store.EndpointRelation().Create(relation) + require.NoError(t, err) +} + +func (store *Store) testSSLSettings(t *testing.T) { + is := assert.New(t) + ssl := &portainer.SSLSettings{ + CertPath: "/data/certs/cert.pem", + HTTPEnabled: true, + KeyPath: "/data/certs/key.pem", + SelfSigned: true, + } + + err := store.SSLSettings().UpdateSettings(ssl) + require.NoError(t, err) + + settings, err := store.SSLSettings().Settings() + require.NoError(t, err, "Get sslsettings should succeed") + is.Equal(ssl, settings, "Stored SSLSettings should be the same as what is read out") +} + +func (store *Store) testTunnelServer(t *testing.T) { + is := assert.New(t) + expectPrivateKeySeed := uniuri.NewLen(16) + + err := store.TunnelServer().UpdateInfo(&portainer.TunnelServerInfo{PrivateKeySeed: expectPrivateKeySeed}) + require.NoError(t, err, "UpdateInfo should have succeeded") + + serverInfo, err := store.TunnelServer().Info() + require.NoError(t, err, "Info should have succeeded") + + is.Equal(expectPrivateKeySeed, serverInfo.PrivateKeySeed, "hashed passwords should not differ") +} + +// add users, read them back and check the details are unchanged +func (store *Store) testUserAccounts(t *testing.T) { + err := store.createAccount(adminUsername, adminPassword, portainer.AdministratorRole) + require.NoError(t, err, "CreateAccount should succeed") + + err = store.checkAccount(adminUsername, adminPassword, portainer.AdministratorRole) + require.NoError(t, err, "Account failure") + + err = store.createAccount(standardUsername, standardPassword, portainer.StandardUserRole) + require.NoError(t, err, "CreateAccount should succeed") + + err = store.checkAccount(standardUsername, standardPassword, portainer.StandardUserRole) + require.NoError(t, err, "Account failure") +} + +// create an account with the provided details +func (store *Store) createAccount(username, password string, role portainer.UserRole) error { + var err error + user := &portainer.User{Username: username, Role: role} + + // encrypt the password + cs := crypto.Service{} + user.Password, err = cs.Hash(password) + if err != nil { + return err + } + + return store.User().Create(user) +} + +func (store *Store) checkAccount(username, expectPassword string, expectRole portainer.UserRole) error { + // Read the account for username. Check password and role is what we expect + + user, err := store.User().UserByUsername(username) + if err != nil { + return errors.Wrap(err, "failed to find user") + } + + if user.Username != username || user.Role != expectRole { + return fmt.Errorf("%s user details do not match", user.Username) + } + + // Check the password + cs := crypto.Service{} + if cs.CompareHashAndData(user.Password, expectPassword) != nil { + return fmt.Errorf("%s user password hash failure", user.Username) + } + + return nil +} + +func (store *Store) testSettings(t *testing.T) { + is := assert.New(t) + + // since many settings are default and basically nil, I'm going to update some and read them back + expectedSettings, err := store.Settings().Settings() + require.NoError(t, err, "Settings() should not return an error") + expectedSettings.TemplatesURL = "http://portainer.io/application-templates" + expectedSettings.HelmRepositoryURL = "http://portainer.io/helm-repository" + expectedSettings.EdgeAgentCheckinInterval = 60 + expectedSettings.AuthenticationMethod = portainer.AuthenticationLDAP + expectedSettings.LDAPSettings = portainer.LDAPSettings{ + AnonymousMode: true, + StartTLS: true, + AutoCreateUsers: true, + Password: "random", + } + expectedSettings.SnapshotInterval = "10m" + + err = store.Settings().UpdateSettings(expectedSettings) + require.NoError(t, err, "UpdateSettings() should succeed") + + settings, err := store.Settings().Settings() + require.NoError(t, err, "Settings() should not return an error") + is.Equal(expectedSettings, settings, "stored settings should match") +} + +func (store *Store) testCustomTemplates(t *testing.T) { + is := assert.New(t) + + customTemplate := store.CustomTemplate() + is.NotNil(customTemplate, "customTemplate Service shouldn't be nil") + + expectedTemplate := &portainer.CustomTemplate{ + ID: portainer.CustomTemplateID(customTemplate.GetNextIdentifier()), + Title: "Custom Title", + Description: "Custom Template Description", + ProjectPath: "/data/custom_template/1", + Note: "A note about this custom template", + EntryPoint: "docker-compose.yaml", + CreatedByUserID: 10, + } + + err := customTemplate.Create(expectedTemplate) + require.NoError(t, err) + + actualTemplate, err := customTemplate.Read(expectedTemplate.ID) + require.NoError(t, err, "CustomTemplate should not return an error") + is.Equal(expectedTemplate, actualTemplate, "expected and actual template do not match") +} + +func (store *Store) testRegistries(t *testing.T) { + is := assert.New(t) + + regService := store.RegistryService + is.NotNil(regService, "RegistryService shouldn't be nil") + + reg1 := &portainer.Registry{ + ID: 1, + Type: portainer.DockerHubRegistry, + Name: "Dockerhub Registry Test", + } + + reg2 := &portainer.Registry{ + ID: 2, + Type: portainer.GitlabRegistry, + Name: "Gitlab Registry Test", + Gitlab: portainer.GitlabRegistryData{ + ProjectID: 12345, + InstanceURL: "http://gitlab.com/12345", + ProjectPath: "mytestproject", + }, + } + + err := regService.Create(reg1) + require.NoError(t, err) + + err = regService.Create(reg2) + require.NoError(t, err) + + actualReg1, err := regService.Read(reg1.ID) + require.NoError(t, err) + is.Equal(reg1, actualReg1, "registries differ") + + actualReg2, err := regService.Read(reg2.ID) + require.NoError(t, err) + is.Equal(reg2, actualReg2, "registries differ") +} + +func (store *Store) testResourceControl(t *testing.T) { + // is := assert.New(t) + // resControl := store.ResourceControl() + // ctrl := &portainer.ResourceControl{ + // } + // resControl().Create() +} + +func (store *Store) testSchedules(t *testing.T) { + is := assert.New(t) + + schedule := store.ScheduleService + s := &portainer.Schedule{ + ID: portainer.ScheduleID(schedule.GetNextIdentifier()), + Name: "My Custom Schedule 1", + CronExpression: "*/5 * * * * portainer /bin/sh -c echo 'hello world'", + } + + err := schedule.CreateSchedule(s) + require.NoError(t, err, "CreateSchedule should succeed") + + actual, err := schedule.Schedule(s.ID) + require.NoError(t, err, "schedule should be found") + is.Equal(s, actual, "schedules differ") +} + +func (store *Store) testTags(t *testing.T) { + is := assert.New(t) + + tags := store.TagService + + tag1 := &portainer.Tag{ + ID: 1, + Name: "Tag 1", + } + + tag2 := &portainer.Tag{ + ID: 2, + Name: "Tag 1", + } + + err := tags.Create(tag1) + require.NoError(t, err, "Tags.Create should succeed") + + err = tags.Create(tag2) + require.NoError(t, err, "Tags.Create should succeed") + + actual, err := tags.Read(tag1.ID) + require.NoError(t, err, "tag1 should be found") + is.Equal(tag1, actual, "tags differ") + + actual, err = tags.Read(tag2.ID) + require.NoError(t, err, "tag2 should be found") + is.Equal(tag2, actual, "tags differ") +} diff --git a/api/datastore/helpers_test.go b/api/datastore/helpers_test.go new file mode 100644 index 0000000..1419d31 --- /dev/null +++ b/api/datastore/helpers_test.go @@ -0,0 +1,58 @@ +package datastore + +import ( + "path/filepath" + "testing" + + portainer "github.com/portainer/portainer/api" + + "github.com/rs/zerolog/log" +) + +// isFileExist is helper function to check for file existence +func isFileExist(path string) bool { + matches, err := filepath.Glob(path) + if err != nil { + return false + } + return len(matches) > 0 +} + +func updateVersion(store *Store, v string) { + version, err := store.VersionService.Version() + if err != nil { + log.Fatal().Err(err).Msg("") + } + + version.SchemaVersion = v + + err = store.VersionService.UpdateVersion(version) + if err != nil { + log.Fatal().Err(err).Msg("") + } +} + +func updateEdition(store *Store, edition portainer.SoftwareEdition) { + version, err := store.VersionService.Version() + if err != nil { + log.Fatal().Err(err).Msg("") + } + + version.Edition = int(edition) + + err = store.VersionService.UpdateVersion(version) + if err != nil { + log.Fatal().Err(err).Msg("") + } +} + +// testVersion is a helper which tests current store version against wanted version +func testVersion(store *Store, versionWant string, t *testing.T) { + v, err := store.VersionService.Version() + if err != nil { + log.Fatal().Err(err).Msg("") + } + if v.SchemaVersion != versionWant { + t.Errorf("Expect store version to be %s but was %s", versionWant, v.SchemaVersion) + } +} diff --git a/api/datastore/init.go b/api/datastore/init.go new file mode 100644 index 0000000..191457b --- /dev/null +++ b/api/datastore/init.go @@ -0,0 +1,115 @@ +package datastore + +import ( + "os" + + portainer "github.com/portainer/portainer/api" +) + +// Init creates the default data set. +func (store *Store) Init() error { + err := store.checkOrCreateDefaultSettings() + if err != nil { + return err + } + + err = store.checkOrCreateDefaultSSLSettings() + if err != nil { + return err + } + + return store.checkOrCreateDefaultData() +} + +func (store *Store) checkOrCreateDefaultSettings() error { + isDDExtention := false + if _, ok := os.LookupEnv("DOCKER_EXTENSION"); ok { + isDDExtention = true + } + + // TODO: these need to also be applied when importing + settings, err := store.SettingsService.Settings() + if store.IsErrObjectNotFound(err) { + defaultSettings := &portainer.Settings{ + AuthenticationMethod: portainer.AuthenticationInternal, + BlackListedLabels: make([]portainer.Pair, 0), + InternalAuthSettings: portainer.InternalAuthSettings{ + RequiredPasswordLength: 12, + }, + LDAPSettings: portainer.LDAPSettings{ + AnonymousMode: true, + AutoCreateUsers: true, + TLSConfig: portainer.TLSConfiguration{}, + SearchSettings: []portainer.LDAPSearchSettings{ + {}, + }, + GroupSearchSettings: []portainer.LDAPGroupSearchSettings{ + {}, + }, + }, + OAuthSettings: portainer.OAuthSettings{ + SSO: true, + }, + SnapshotInterval: portainer.DefaultSnapshotInterval, + EdgeAgentCheckinInterval: portainer.DefaultEdgeAgentCheckinIntervalInSeconds, + TemplatesURL: "", + HelmRepositoryURL: portainer.DefaultHelmRepositoryURL, + UserSessionTimeout: portainer.DefaultUserSessionTimeout, + KubeconfigExpiry: portainer.DefaultKubeconfigExpiry, + KubectlShellImage: *store.flags.KubectlShellImage, + + IsDockerDesktopExtension: isDDExtention, + EnforceEdgeID: true, + } + + return store.SettingsService.UpdateSettings(defaultSettings) + } + if err != nil { + return err + } + + if settings.UserSessionTimeout == "" { + settings.UserSessionTimeout = portainer.DefaultUserSessionTimeout + return store.Settings().UpdateSettings(settings) + } + + return nil +} + +func (store *Store) checkOrCreateDefaultSSLSettings() error { + _, err := store.SSLSettings().Settings() + if store.IsErrObjectNotFound(err) { + defaultSSLSettings := &portainer.SSLSettings{ + HTTPEnabled: true, + } + + return store.SSLSettings().UpdateSettings(defaultSSLSettings) + } + + return err +} + +func (store *Store) checkOrCreateDefaultData() error { + groups, err := store.EndpointGroupService.ReadAll() + if err != nil { + return err + } + + if len(groups) == 0 { + unassignedGroup := &portainer.EndpointGroup{ + Name: "Unassigned", + Description: "Unassigned environments", + Labels: []portainer.Pair{}, + UserAccessPolicies: portainer.UserAccessPolicies{}, + TeamAccessPolicies: portainer.TeamAccessPolicies{}, + TagIDs: []portainer.TagID{}, + } + + err = store.EndpointGroupService.Create(unassignedGroup) + if err != nil { + return err + } + } + + return nil +} diff --git a/api/datastore/migrate_data.go b/api/datastore/migrate_data.go new file mode 100644 index 0000000..4a2e9de --- /dev/null +++ b/api/datastore/migrate_data.go @@ -0,0 +1,151 @@ +package datastore + +import ( + "fmt" + "os" + "runtime/debug" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/cli" + "github.com/portainer/portainer/api/database/models" + dserrors "github.com/portainer/portainer/api/dataservices/errors" + "github.com/portainer/portainer/api/datastore/migrator" + "github.com/portainer/portainer/api/internal/authorization" + + "github.com/pkg/errors" + "github.com/rs/zerolog/log" +) + +func (store *Store) MigrateData() error { + updating, err := store.VersionService.IsUpdating() + if err != nil { + return errors.Wrap(err, "while checking if the store is updating") + } + + if updating { + return dserrors.ErrDatabaseIsUpdating + } + + // migrate new version bucket if required (doesn't write anything to db yet) + version, err := store.getOrMigrateLegacyVersion() + if err != nil { + return errors.Wrap(err, "while migrating legacy version") + } + + migratorParams := store.newMigratorParameters(version, store.flags) + migrator := migrator.NewMigrator(migratorParams) + + if !migrator.NeedsMigration() { + return nil + } + + // before we alter anything in the DB, create a backup + if _, err := store.Backup(""); err != nil { + return errors.Wrap(err, "while backing up database") + } + + if err := store.FailSafeMigrate(migrator, version); err != nil { + err = errors.Wrap(err, "failed to migrate database") + + log.Warn().Err(err).Msg("migration failed, restoring database to previous version") + restoreErr := store.Restore() + if restoreErr != nil { + return errors.Wrap(restoreErr, "failed to restore database") + } + + log.Info().Msg("database restored to previous version") + return err + } + + return nil +} + +func (store *Store) newMigratorParameters(version *models.Version, flags *portainer.CLIFlags) *migrator.MigratorParameters { + return &migrator.MigratorParameters{ + Flags: flags, + CurrentDBVersion: version, + EndpointGroupService: store.EndpointGroupService, + EndpointService: store.EndpointService, + EndpointRelationService: store.EndpointRelationService, + ExtensionService: store.ExtensionService, + RegistryService: store.RegistryService, + ResourceControlService: store.ResourceControlService, + RoleService: store.RoleService, + ScheduleService: store.ScheduleService, + SettingsService: store.SettingsService, + SnapshotService: store.SnapshotService, + StackService: store.StackService, + TagService: store.TagService, + TeamMembershipService: store.TeamMembershipService, + UserService: store.UserService, + VersionService: store.VersionService, + FileService: store.fileService, + DockerhubService: store.DockerHubService, + AuthorizationService: authorization.NewService(store), + EdgeStackService: store.EdgeStackService, + EdgeStackStatusService: store.EdgeStackStatusService, + EdgeJobService: store.EdgeJobService, + EdgeGroupService: store.EdgeGroupService, + TunnelServerService: store.TunnelServerService, + PendingActionsService: store.PendingActionsService, + CustomTemplateService: store.CustomTemplateService, + SourceService: store.SourceService, + WorkflowService: store.WorkflowService, + } +} + +// FailSafeMigrate backup and restore DB if migration fail +func (store *Store) FailSafeMigrate(migrator *migrator.Migrator, version *models.Version) (err error) { + defer func() { + if e := recover(); e != nil { + // return error with cause and stacktrace (recover() doesn't include a stacktrace) + err = fmt.Errorf("%v %s", e, string(debug.Stack())) + } + }() + + err = store.VersionService.StoreIsUpdating(true) + if err != nil { + return errors.Wrap(err, "while updating the store") + } + + // now update the version to the new struct (if required) + err = store.finishMigrateLegacyVersion(version) + if err != nil { + return errors.Wrap(err, "while updating version") + } + + log.Info().Msg("migrating database from version " + version.SchemaVersion + " to " + portainer.APIVersion) + + err = migrator.Migrate() + if err != nil { + return err + } + + // Special test code to simulate a failure (used by migrate_data_test.go). Do not remove... + if os.Getenv("PORTAINER_TEST_MIGRATE_FAIL") == "FAIL" { + panic("test migration failure") + } + + err = store.VersionService.StoreIsUpdating(false) + if err != nil { + return errors.Wrap(err, "failed to update the store") + } + + return nil +} + +// Rollback to a pre-upgrade backup copy/snapshot of portainer.db +func (store *Store) connectionRollback(force bool) error { + if !force { + confirmed, err := cli.Confirm("Are you sure you want to rollback your database to the previous backup?") + if err != nil || !confirmed { + return err + } + } + + if err := store.Restore(); err != nil { + return err + } + + return store.connection.Close() +} diff --git a/api/datastore/migrate_data_test.go b/api/datastore/migrate_data_test.go new file mode 100644 index 0000000..0b52fee --- /dev/null +++ b/api/datastore/migrate_data_test.go @@ -0,0 +1,542 @@ +package datastore + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "os" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/database/boltdb" + "github.com/portainer/portainer/api/database/models" + "github.com/portainer/portainer/api/datastore/migrator" + "github.com/portainer/portainer/api/filesystem" + + "github.com/Masterminds/semver/v3" + "github.com/google/go-cmp/cmp" + "github.com/rs/zerolog/log" + "github.com/stretchr/testify/require" +) + +func TestMigrateData(t *testing.T) { + tests := []struct { + testName string + srcPath string + wantPath string + overrideInstanceId bool + }{ + { + testName: "migrate version 24 to latest", + srcPath: "test_data/input_24.json", + wantPath: "test_data/output_24_to_latest.json", + overrideInstanceId: true, + }, + } + for _, test := range tests { + t.Run(test.testName, func(t *testing.T) { + err := migrateDBTestHelper(t, test.srcPath, test.wantPath, test.overrideInstanceId) + if err != nil { + t.Errorf( + "Failed migrating mock database %v: %v", + test.srcPath, + err, + ) + } + }) + } + + t.Run("MigrateData for New Store & Re-Open Check", func(t *testing.T) { + newStore, store := MustNewTestStore(t, true, false) + if !newStore { + t.Error("Expect a new DB") + } + + testVersion(store, portainer.APIVersion, t) + err := store.Close() + require.NoError(t, err) + + newStore, err = store.Open() + require.NoError(t, err) + if newStore { + t.Error("Expect store to NOT be new DB") + } + }) + + t.Run("MigrateData should create backup file upon update", func(t *testing.T) { + _, store := MustNewTestStore(t, true, false) + err := store.VersionService.UpdateVersion(&models.Version{SchemaVersion: "2.0", Edition: int(portainer.PortainerCE)}) + require.NoError(t, err) + + err = store.MigrateData() + require.NoError(t, err) + + backupfilename := store.backupFilename() + if exists, _ := store.fileService.FileExists(backupfilename); !exists { + t.Errorf("Expect backup file to be created %s", backupfilename) + } + }) + + t.Run("MigrateData should recover and restore backup during migration critical failure", func(t *testing.T) { + t.Setenv("PORTAINER_TEST_MIGRATE_FAIL", "FAIL") + + version := "2.15" + _, store := MustNewTestStore(t, true, false) + + err := store.VersionService.UpdateVersion(&models.Version{SchemaVersion: version, Edition: int(portainer.PortainerCE)}) + require.NoError(t, err) + + err = store.MigrateData() + require.Error(t, err) + + testVersion(store, version, t) + }) + + t.Run("MigrateData should fail to create backup if database file is set to updating", func(t *testing.T) { + _, store := MustNewTestStore(t, true, false) + + err := store.VersionService.StoreIsUpdating(true) + require.NoError(t, err) + + err = store.MigrateData() + require.Error(t, err) + + // If you get an error, it usually means that the backup folder doesn't exist (no backups). Expected! + // If the backup file is not blank, then it means a backup was created. We don't want that because we + // only create a backup when the version changes. + backupfilename := store.backupFilename() + if exists, _ := store.fileService.FileExists(backupfilename); exists { + t.Errorf("Backup file should not exist for dirty database") + } + }) + + t.Run("MigrateData should not create backup on startup if portainer version matches db", func(t *testing.T) { + _, store := MustNewTestStore(t, true, false) + + // Set migrator the count to match our migrations array (simulate no changes). + // Should not create a backup + v, err := store.VersionService.Version() + if err != nil { + t.Errorf("Unable to read version from db: %s", err) + t.FailNow() + } + + migratorParams := store.newMigratorParameters(v, store.flags) + m := migrator.NewMigrator(migratorParams) + latestMigrations := m.LatestMigrations() + + if latestMigrations.Version.Equal(semver.MustParse(portainer.APIVersion)) { + v.MigratorCount = len(latestMigrations.MigrationFuncs) + err = store.VersionService.UpdateVersion(v) + require.NoError(t, err) + } + + err = store.MigrateData() + require.NoError(t, err) + + // If you get an error, it usually means that the backup folder doesn't exist (no backups). Expected! + // If the backup file is not blank, then it means a backup was created. We don't want that because we + // only create a backup when the version changes. + backupfilename := store.backupFilename() + if exists, _ := store.fileService.FileExists(backupfilename); exists { + t.Errorf("Backup file should not exist for dirty database") + } + }) + + t.Run("MigrateData should create backup on startup if portainer version matches db and migrationFuncs counts differ", func(t *testing.T) { + _, store := MustNewTestStore(t, true, false) + + // Set migrator count very large to simulate changes + // Should not create a backup + v, err := store.VersionService.Version() + if err != nil { + t.Errorf("Unable to read version from db: %s", err) + t.FailNow() + } + + v.MigratorCount = 1000 + + err = store.VersionService.UpdateVersion(v) + require.NoError(t, err) + + err = store.MigrateData() + require.NoError(t, err) + + // If you get an error, it usually means that the backup folder doesn't exist (no backups). Expected! + // If the backup file is not blank, then it means a backup was created. We don't want that because we + // only create a backup when the version changes. + backupfilename := store.backupFilename() + if exists, _ := store.fileService.FileExists(backupfilename); !exists { + t.Errorf("DB backup should exist and there should be no error") + } + }) +} + +func TestRollback(t *testing.T) { + t.Parallel() + t.Run("Rollback should restore upgrade after backup", func(t *testing.T) { + version := "2.11" + + v := models.Version{SchemaVersion: version} + + _, store := MustNewTestStore(t, false, false) + + err := store.VersionService.UpdateVersion(&v) + require.NoError(t, err) + + _, err = store.Backup("") + if err != nil { + log.Fatal().Err(err).Msg("") + } + + v.SchemaVersion = "2.14" + // Change the current edition + err = store.VersionService.UpdateVersion(&v) + if err != nil { + log.Fatal().Err(err).Msg("") + } + + err = store.Rollback(true) + if err != nil { + t.Logf("Rollback failed: %s", err) + t.Fail() + return + } + + _, err = store.Open() + require.NoError(t, err) + + testVersion(store, version, t) + }) + + t.Run("Rollback should restore upgrade after backup", func(t *testing.T) { + version := "2.15" + + v := models.Version{ + SchemaVersion: version, + Edition: int(portainer.PortainerCE), + } + + _, store := MustNewTestStore(t, true, false) + + err := store.VersionService.UpdateVersion(&v) + require.NoError(t, err) + + _, err = store.Backup("") + if err != nil { + log.Fatal().Err(err).Msg("") + } + + v.SchemaVersion = "2.14" + // Change the current edition + err = store.VersionService.UpdateVersion(&v) + if err != nil { + log.Fatal().Err(err).Msg("") + } + + err = store.Rollback(true) + if err != nil { + t.Logf("Rollback failed: %s", err) + t.Fail() + return + } + + _, err = store.Open() + require.NoError(t, err) + testVersion(store, version, t) + }) +} + +// migrateDBTestHelper loads a json representation of a bolt database from srcPath, +// parses it into a database, runs a migration on that database, and then +// compares it with an expected output database. +func migrateDBTestHelper(t *testing.T, srcPath, wantPath string, overrideInstanceId bool) error { + srcJSON, err := os.ReadFile(srcPath) + if err != nil { + t.Fatalf("failed loading source JSON file %v: %v", srcPath, err) + } + + // Parse source json to db. + // When we create a new test store, it sets its version field automatically to latest. + _, store := MustNewTestStore(t, true, false) + + fmt.Println("store.path=", store.GetConnection().GetDatabaseFilePath()) + + err = store.connection.DeleteObject("version", []byte("VERSION")) + require.NoError(t, err) + + // defer teardown() + if err := importJSON(t, bytes.NewReader(srcJSON), store); err != nil { + return err + } + + // Run the actual migrations on our input database. + if err := store.MigrateData(); err != nil { + return err + } + + if overrideInstanceId { + // old versions of portainer did not have instance-id. Because this gets generated + // we need to override the expected output to match the expected value to pass the test + v, err := store.VersionService.Version() + if err != nil { + return err + } + + v.InstanceID = "463d5c47-0ea5-4aca-85b1-405ceefee254" + if err := store.VersionService.UpdateVersion(v); err != nil { + return err + } + } + + // Assert that our database connection is using bolt so we can call + // exportJson rather than ExportRaw. The exportJson function allows us to + // strip out the metadata which we don't want for our tests. + // TODO: update connection interface in CE to allow us to use ExportRaw and pass meta false + if err := store.connection.Close(); err != nil { + t.Fatalf("err closing bolt connection: %v", err) + } + + con, ok := store.connection.(*boltdb.DbConnection) + if !ok { + t.Fatalf("backing database is not using boltdb, but the migrations test requires it") + } + + // Convert database back to json. + databasePath := con.GetDatabaseFilePath() + if _, err := os.Stat(databasePath); err != nil { + return fmt.Errorf("stat on %s failed: %w", databasePath, err) + } + + gotJSON, err := con.ExportJSON(databasePath, false) + if err != nil { + t.Logf( + "failed re-exporting database %s to JSON: %v", + databasePath, + err, + ) + } + + wantJSON, err := os.ReadFile(wantPath) + if err != nil { + t.Fatalf("failed loading want JSON file %v: %v", wantPath, err) + } + + // Compare the result we got with the one we wanted. + if diff := cmp.Diff(wantJSON, gotJSON); diff != "" { + gotPath := filesystem.JoinPaths(os.TempDir(), "portainer-migrator-test-fail.json") + err = os.WriteFile( + gotPath, + gotJSON, + 0o600, + ) + if err != nil { + log.Warn().Err(err).Msg("failed writing migrated output to temp file") + } + + t.Errorf( + "migrate data from %s to %s failed\nwrote migrated input to %s\nmismatch (-want +got):\n%s", + srcPath, + wantPath, + gotPath, + diff, + ) + } + return nil +} + +// importJSON reads input JSON and commits it to a portainer datastore.Store. +// Errors are logged with the testing package. +func importJSON(t *testing.T, r io.Reader, store *Store) error { + objects := make(map[string]any) + + // Parse json into map of objects. + d := json.NewDecoder(r) + d.UseNumber() + err := d.Decode(&objects) + if err != nil { + return err + } + + // Get database connection from store. + con := store.connection + + for k, v := range objects { + switch k { + case "version": + versions, ok := v.(map[string]any) + if !ok { + t.Logf("failed casting %s to map[string]any", k) + } + + // New format db + version, ok := versions["VERSION"] + if ok { + err := con.CreateObjectWithStringId( + k, + []byte("VERSION"), + version, + ) + if err != nil { + t.Logf("failed writing VERSION in %s: %v", k, err) + } + } + + // old format db + + dbVersion, ok := versions["DB_VERSION"] + if ok { + numDBVersion, ok := dbVersion.(json.Number) + if !ok { + t.Logf("failed parsing DB_VERSION as json number from %s", k) + } + + intDBVersion, err := numDBVersion.Int64() + if err != nil { + t.Logf("failed casting %v to int: %v", numDBVersion, intDBVersion) + } + + err = con.CreateObjectWithStringId( + k, + []byte("DB_VERSION"), + int(intDBVersion), + ) + if err != nil { + t.Logf("failed writing DB_VERSION in %s: %v", k, err) + } + } + + instanceID, ok := versions["INSTANCE_ID"] + if ok { + err = con.CreateObjectWithStringId( + k, + []byte("INSTANCE_ID"), + instanceID, + ) + if err != nil { + t.Logf("failed writing INSTANCE_ID in %s: %v", k, err) + } + } + + edition, ok := versions["EDITION"] + if ok { + err = con.CreateObjectWithStringId( + k, + []byte("EDITION"), + edition, + ) + if err != nil { + t.Logf("failed writing EDITION in %s: %v", k, err) + } + } + + case "dockerhub": + obj, ok := v.([]any) + if !ok { + t.Logf("failed to cast %s to []any", k) + } + err := con.CreateObjectWithStringId( + k, + []byte("DOCKERHUB"), + obj[0], + ) + if err != nil { + t.Logf("failed writing DOCKERHUB in %s: %v", k, err) + } + + case "ssl": + obj, ok := v.(map[string]any) + if !ok { + t.Logf("failed to case %s to map[string]any", k) + } + err := con.CreateObjectWithStringId( + k, + []byte("SSL"), + obj, + ) + if err != nil { + t.Logf("failed writing SSL in %s: %v", k, err) + } + + case "settings": + obj, ok := v.(map[string]any) + if !ok { + t.Logf("failed to case %s to map[string]any", k) + } + err := con.CreateObjectWithStringId( + k, + []byte("SETTINGS"), + obj, + ) + if err != nil { + t.Logf("failed writing SETTINGS in %s: %v", k, err) + } + + case "tunnel_server": + obj, ok := v.(map[string]any) + if !ok { + t.Logf("failed to case %s to map[string]any", k) + } + err := con.CreateObjectWithStringId( + k, + []byte("INFO"), + obj, + ) + if err != nil { + t.Logf("failed writing INFO in %s: %v", k, err) + } + case "templates": + continue + + default: + objlist, ok := v.([]any) + if !ok { + t.Logf("failed to cast %s to []any", k) + } + + for _, obj := range objlist { + value, ok := obj.(map[string]any) + if !ok { + t.Logf("failed to cast %v to map[string]any", obj) + } else { + var ok bool + var id any + switch k { + case "endpoint_relations": + // TODO: need to make into an int, then do that weird + // stringification + id, ok = value["EndpointID"] + default: + id, ok = value["Id"] + } + if !ok { + // endpoint_relations: EndpointID + t.Logf("missing Id field: %s", k) + id = "error" + } + n, ok := id.(json.Number) + if !ok { + t.Logf("failed to cast %v to json.Number in %s", id, k) + } else { + key, err := n.Int64() + if err != nil { + t.Logf("failed to cast %v to int in %s", n, k) + } else { + err := con.CreateObjectWithId( + k, + int(key), + value, + ) + if err != nil { + t.Logf("failed writing %v in %s: %v", key, k, err) + } + } + } + } + } + } + } + + return nil +} diff --git a/api/datastore/migrate_dbversion29_test.go b/api/datastore/migrate_dbversion29_test.go new file mode 100644 index 0000000..04c8167 --- /dev/null +++ b/api/datastore/migrate_dbversion29_test.go @@ -0,0 +1,91 @@ +package datastore + +import ( + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/datastore/migrator" + "github.com/portainer/portainer/api/internal/authorization" +) + +const dummyLogoURL = "example.com" + +// initTestingDBConn creates a settings service with raw database DB connection +// for unit testing usage only since using NewStore will cause cycle import inside migrator pkg +func initTestingSettingsService(dbConn portainer.Connection, preSetObj map[string]any) error { + //insert a obj + return dbConn.UpdateObject("settings", []byte("SETTINGS"), preSetObj) +} + +func setup(store *Store) error { + dummySettingsObj := map[string]any{ + "LogoURL": dummyLogoURL, + } + + return initTestingSettingsService(store.connection, dummySettingsObj) +} + +func TestMigrateSettings(t *testing.T) { + t.Parallel() + _, store := MustNewTestStore(t, false, true) + + err := setup(store) + if err != nil { + t.Errorf("failed to complete testing setups, err: %v", err) + } + + updatedSettings, err := store.SettingsService.Settings() + // SO -basically, this test _isn't_ testing migration, its testing golang type defaults. + if updatedSettings.LogoURL != dummyLogoURL { // ensure a pre-migrate setting isn't unset + t.Errorf("unexpected value changes in the updated settings, want LogoURL value: %s, got LogoURL value: %s", dummyLogoURL, updatedSettings.LogoURL) + } + + if updatedSettings.OAuthSettings.SSO != false { // I recon golang defaulting will make this false + t.Errorf("unexpected default OAuth SSO setting, want: false, got: %t", updatedSettings.OAuthSettings.SSO) + } + + if updatedSettings.OAuthSettings.LogoutURI != "" { + t.Errorf("unexpected default OAuth HideInternalAuth setting, want:, got: %s", updatedSettings.OAuthSettings.LogoutURI) + } + + m := migrator.NewMigrator(&migrator.MigratorParameters{ + Flags: store.flags, + EndpointGroupService: store.EndpointGroupService, + EndpointService: store.EndpointService, + EndpointRelationService: store.EndpointRelationService, + ExtensionService: store.ExtensionService, + RegistryService: store.RegistryService, + ResourceControlService: store.ResourceControlService, + RoleService: store.RoleService, + ScheduleService: store.ScheduleService, + SettingsService: store.SettingsService, + StackService: store.StackService, + TagService: store.TagService, + TeamMembershipService: store.TeamMembershipService, + UserService: store.UserService, + VersionService: store.VersionService, + FileService: store.fileService, + DockerhubService: store.DockerHubService, + AuthorizationService: authorization.NewService(store), + }) + + if err := m.MigrateSettingsToDB30(); err != nil { + t.Errorf("failed to update settings: %v", err) + } + + if err != nil { + t.Errorf("failed to retrieve the updated settings: %v", err) + } + + if updatedSettings.LogoURL != dummyLogoURL { + t.Errorf("unexpected value changes in the updated settings, want LogoURL value: %s, got LogoURL value: %s", dummyLogoURL, updatedSettings.LogoURL) + } + + if updatedSettings.OAuthSettings.SSO != false { + t.Errorf("unexpected default OAuth SSO setting, want: false, got: %t", updatedSettings.OAuthSettings.SSO) + } + + if updatedSettings.OAuthSettings.LogoutURI != "" { + t.Errorf("unexpected default OAuth HideInternalAuth setting, want:, got: %s", updatedSettings.OAuthSettings.LogoutURI) + } +} diff --git a/api/datastore/migrate_dbversion33_test.go b/api/datastore/migrate_dbversion33_test.go new file mode 100644 index 0000000..633094a --- /dev/null +++ b/api/datastore/migrate_dbversion33_test.go @@ -0,0 +1,55 @@ +package datastore + +import ( + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/datastore/migrator" + gittypes "github.com/portainer/portainer/api/git/types" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMigrateStackEntryPoint(t *testing.T) { + t.Parallel() + _, store := MustNewTestStore(t, false, true) + + stackService := store.Stack() + + stacks := []*portainer.Stack{ + { + ID: 1, + EntryPoint: "dir/sub/compose.yml", + }, + { + ID: 2, + EntryPoint: "dir/sub/compose.yml", + GitConfig: &gittypes.RepoConfig{}, + }, + } + + for _, s := range stacks { + err := stackService.Create(s) + require.NoError(t, err, "failed to create stack") + } + + s, err := stackService.Read(1) + require.NoError(t, err) + assert.Nil(t, s.GitConfig, "first stack should not have git config") + + s, err = stackService.Read(2) + require.NoError(t, err) + assert.Empty(t, s.GitConfig.ConfigFilePath, "not migrated yet migrated") + + err = migrator.MigrateStackEntryPoint(stackService) + require.NoError(t, err, "failed to migrate entry point to Git ConfigFilePath") + + s, err = stackService.Read(1) + require.NoError(t, err) + assert.Nil(t, s.GitConfig, "first stack should not have git config") + + s, err = stackService.Read(2) + require.NoError(t, err) + assert.Equal(t, "dir/sub/compose.yml", s.GitConfig.ConfigFilePath, "second stack should have config file path migrated") +} diff --git a/api/datastore/migrate_legacyversion.go b/api/datastore/migrate_legacyversion.go new file mode 100644 index 0000000..4b7eee0 --- /dev/null +++ b/api/datastore/migrate_legacyversion.go @@ -0,0 +1,122 @@ +package datastore + +import ( + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/database/models" + "github.com/portainer/portainer/api/dataservices" +) + +const ( + bucketName = "version" + legacyDBVersionKey = "DB_VERSION" + legacyInstanceKey = "INSTANCE_ID" + legacyEditionKey = "EDITION" +) + +var dbVerToSemVerMap = map[int]string{ + 18: "1.21", + 19: "1.22", + 20: "1.22.1", + 21: "1.22.2", + 22: "1.23", + 23: "1.24", + 24: "1.24.1", + 25: "2.0", + 26: "2.1", + 27: "2.2", + 28: "2.4", + 29: "2.4", + 30: "2.6", + 31: "2.7", + 32: "2.9", + 33: "2.9.1", + 34: "2.10", + 35: "2.9.3", + 36: "2.11", + 40: "2.13", + 50: "2.14", + 51: "2.14.1", + 52: "2.14.2", + 60: "2.15", + 61: "2.15.1", + 70: "2.16", + 80: "2.17", +} + +func dbVersionToSemanticVersion(dbVersion int) string { + if dbVersion < 18 { + return "1.0.0" + } + + ver, ok := dbVerToSemVerMap[dbVersion] + if ok { + return ver + } + + // We should always return something sensible + switch { + case dbVersion < 40: + return "2.11" + case dbVersion < 50: + return "2.13" + case dbVersion < 60: + return "2.14.2" + case dbVersion < 70: + return "2.15.1" + } + + return "2.16.0" +} + +// getOrMigrateLegacyVersion to new Version struct +func (store *Store) getOrMigrateLegacyVersion() (*models.Version, error) { + // Very old versions of portainer did not have a version bucket, lets set some defaults + dbVersion := 24 + edition := int(portainer.PortainerCE) + instanceId := "" + + // If we already have a version key, we don't need to migrate + v, err := store.VersionService.Version() + if err == nil || !dataservices.IsErrObjectNotFound(err) { + return v, err + } + + err = store.connection.GetObject(bucketName, []byte(legacyDBVersionKey), &dbVersion) + if err != nil && !dataservices.IsErrObjectNotFound(err) { + return nil, err + } + + err = store.connection.GetObject(bucketName, []byte(legacyEditionKey), &edition) + if err != nil && !dataservices.IsErrObjectNotFound(err) { + return nil, err + } + + err = store.connection.GetObject(bucketName, []byte(legacyInstanceKey), &instanceId) + if err != nil && !dataservices.IsErrObjectNotFound(err) { + return nil, err + } + + return &models.Version{ + SchemaVersion: dbVersionToSemanticVersion(dbVersion), + Edition: edition, + InstanceID: instanceId, + }, nil +} + +// finishMigrateLegacyVersion writes the new version to the DB and removes the old version keys from the DB +func (store *Store) finishMigrateLegacyVersion(versionToWrite *models.Version) error { + if err := store.VersionService.UpdateVersion(versionToWrite); err != nil { + return err + } + + // Remove legacy keys if present + if err := store.connection.DeleteObject(bucketName, []byte(legacyDBVersionKey)); err != nil { + return err + } + + if err := store.connection.DeleteObject(bucketName, []byte(legacyEditionKey)); err != nil { + return err + } + + return store.connection.DeleteObject(bucketName, []byte(legacyInstanceKey)) +} diff --git a/api/datastore/migrator/migrate_2_31_0.go b/api/datastore/migrator/migrate_2_31_0.go new file mode 100644 index 0000000..7afea98 --- /dev/null +++ b/api/datastore/migrator/migrate_2_31_0.go @@ -0,0 +1,31 @@ +package migrator + +import portainer "github.com/portainer/portainer/api" + +func (m *Migrator) migrateEdgeStacksStatuses_2_31_0() error { + edgeStacks, err := m.edgeStackService.EdgeStacks() + if err != nil { + return err + } + + for _, edgeStack := range edgeStacks { + for envID, status := range edgeStack.Status { + if err := m.edgeStackStatusService.Create(edgeStack.ID, envID, &portainer.EdgeStackStatusForEnv{ + EndpointID: envID, + Status: status.Status, + DeploymentInfo: status.DeploymentInfo, + ReadyRePullImage: status.ReadyRePullImage, + }); err != nil { + return err + } + } + + edgeStack.Status = nil + + if err := m.edgeStackService.UpdateEdgeStack(edgeStack.ID, &edgeStack); err != nil { + return err + } + } + + return nil +} diff --git a/api/datastore/migrator/migrate_2_32_0.go b/api/datastore/migrator/migrate_2_32_0.go new file mode 100644 index 0000000..c32a63c --- /dev/null +++ b/api/datastore/migrator/migrate_2_32_0.go @@ -0,0 +1,33 @@ +package migrator + +import ( + "github.com/pkg/errors" + portainer "github.com/portainer/portainer/api" + perrors "github.com/portainer/portainer/api/dataservices/errors" + "github.com/portainer/portainer/api/internal/endpointutils" +) + +func (m *Migrator) addEndpointRelationForEdgeAgents_2_32_0() error { + endpoints, err := m.endpointService.Endpoints() + if err != nil { + return err + } + + for _, endpoint := range endpoints { + if endpointutils.IsEdgeEndpoint(&endpoint) { + _, err := m.endpointRelationService.EndpointRelation(endpoint.ID) + if err != nil && errors.Is(err, perrors.ErrObjectNotFound) { + relation := &portainer.EndpointRelation{ + EndpointID: endpoint.ID, + EdgeStacks: make(map[portainer.EdgeStackID]bool), + } + + if err := m.endpointRelationService.Create(relation); err != nil { + return err + } + } + } + } + + return nil +} diff --git a/api/datastore/migrator/migrate_2_33_0.go b/api/datastore/migrator/migrate_2_33_0.go new file mode 100644 index 0000000..83a822c --- /dev/null +++ b/api/datastore/migrator/migrate_2_33_0.go @@ -0,0 +1,25 @@ +package migrator + +import ( + "github.com/portainer/portainer/api/roar" +) + +func (m *Migrator) migrateEdgeGroupEndpointsToRoars_2_33_0() error { + egs, err := m.edgeGroupService.ReadAll() + if err != nil { + return err + } + + for _, eg := range egs { + if eg.EndpointIDs.Len() == 0 { + eg.EndpointIDs = roar.FromSlice(eg.Endpoints) + eg.Endpoints = nil + } + + if err := m.edgeGroupService.Update(eg.ID, &eg); err != nil { + return err + } + } + + return nil +} diff --git a/api/datastore/migrator/migrate_2_33_test.go b/api/datastore/migrator/migrate_2_33_test.go new file mode 100644 index 0000000..f0e3443 --- /dev/null +++ b/api/datastore/migrator/migrate_2_33_test.go @@ -0,0 +1,57 @@ +package migrator + +import ( + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/database/boltdb" + "github.com/portainer/portainer/api/dataservices/edgegroup" + "github.com/portainer/portainer/api/logs" + + "github.com/stretchr/testify/require" +) + +func TestMigrateEdgeGroupEndpointsToRoars_2_33_0Idempotency(t *testing.T) { + t.Parallel() + var conn portainer.Connection = &boltdb.DbConnection{Path: t.TempDir()} + err := conn.Open() + require.NoError(t, err) + + defer logs.CloseAndLogErr(conn) + + edgeGroupService, err := edgegroup.NewService(conn) + require.NoError(t, err) + + edgeGroup := &portainer.EdgeGroup{ + ID: 1, + Name: "test-edge-group", + Endpoints: []portainer.EndpointID{1, 2, 3}, + } + + err = conn.CreateObjectWithId(edgegroup.BucketName, int(edgeGroup.ID), edgeGroup) + require.NoError(t, err) + + m := NewMigrator(&MigratorParameters{EdgeGroupService: edgeGroupService}) + + // Run migration once + + err = m.migrateEdgeGroupEndpointsToRoars_2_33_0() + require.NoError(t, err) + + migratedEdgeGroup, err := edgeGroupService.Read(edgeGroup.ID) + require.NoError(t, err) + + require.Empty(t, migratedEdgeGroup.Endpoints) + require.Equal(t, len(edgeGroup.Endpoints), migratedEdgeGroup.EndpointIDs.Len()) + + // Run migration again to ensure the results didn't change + + err = m.migrateEdgeGroupEndpointsToRoars_2_33_0() + require.NoError(t, err) + + migratedEdgeGroup, err = edgeGroupService.Read(edgeGroup.ID) + require.NoError(t, err) + + require.Empty(t, migratedEdgeGroup.Endpoints) + require.Equal(t, len(edgeGroup.Endpoints), migratedEdgeGroup.EndpointIDs.Len()) +} diff --git a/api/datastore/migrator/migrate_2_40_test.go b/api/datastore/migrator/migrate_2_40_test.go new file mode 100644 index 0000000..39dc925 --- /dev/null +++ b/api/datastore/migrator/migrate_2_40_test.go @@ -0,0 +1,206 @@ +package migrator + +import ( + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/database/boltdb" + "github.com/portainer/portainer/api/dataservices/endpoint" + "github.com/portainer/portainer/api/dataservices/pendingactions" + "github.com/portainer/portainer/api/dataservices/registry" + "github.com/portainer/portainer/api/logs" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMigrateRegistryAccessSASecrets_2_40_0(t *testing.T) { + t.Parallel() + var conn portainer.Connection = &boltdb.DbConnection{Path: t.TempDir()} + err := conn.Open() + require.NoError(t, err) + defer logs.CloseAndLogErr(conn) + + registryService, err := registry.NewService(conn) + require.NoError(t, err) + + endpointService, err := endpoint.NewService(conn) + require.NoError(t, err) + + pendingActionsService, err := pendingactions.NewService(conn) + require.NoError(t, err) + + t.Run("sets MigrateRegistrySASecrets flag for k8s endpoints with registry access", func(t *testing.T) { + k8sEndpoint := &portainer.Endpoint{ + ID: 1, + Name: "k8s-cluster", + Type: portainer.AgentOnKubernetesEnvironment, + } + dockerEndpoint := &portainer.Endpoint{ + ID: 2, + Name: "docker-standalone", + Type: portainer.DockerEnvironment, + } + + err := conn.CreateObjectWithId(endpoint.BucketName, int(k8sEndpoint.ID), k8sEndpoint) + require.NoError(t, err) + err = conn.CreateObjectWithId(endpoint.BucketName, int(dockerEndpoint.ID), dockerEndpoint) + require.NoError(t, err) + + reg := &portainer.Registry{ + ID: 1, + Name: "test-registry", + RegistryAccesses: portainer.RegistryAccesses{ + k8sEndpoint.ID: portainer.RegistryAccessPolicies{ + Namespaces: []string{"default", "production"}, + }, + dockerEndpoint.ID: portainer.RegistryAccessPolicies{ + Namespaces: []string{"ignored"}, + }, + }, + } + + err = conn.CreateObjectWithId(registry.BucketName, int(reg.ID), reg) + require.NoError(t, err) + + m := NewMigrator(&MigratorParameters{ + RegistryService: registryService, + EndpointService: endpointService, + PendingActionsService: pendingActionsService, + }) + + err = m.migrateRegistryAccessSASecrets_2_40_0() + require.NoError(t, err) + + updatedK8sEndpoint, err := endpointService.Endpoint(k8sEndpoint.ID) + require.NoError(t, err) + assert.True(t, updatedK8sEndpoint.PostInitMigrations.MigrateRegistrySASecrets, "should have set MigrateRegistrySASecrets flag for k8s endpoint") + + updatedDockerEndpoint, err := endpointService.Endpoint(dockerEndpoint.ID) + require.NoError(t, err) + assert.False(t, updatedDockerEndpoint.PostInitMigrations.MigrateRegistrySASecrets, "should not have set MigrateRegistrySASecrets flag for docker endpoint") + }) + + t.Run("skips endpoints with empty namespaces", func(t *testing.T) { + conn2 := &boltdb.DbConnection{Path: t.TempDir()} + err := conn2.Open() + require.NoError(t, err) + defer logs.CloseAndLogErr(conn2) + + registryService2, _ := registry.NewService(conn2) + endpointService2, _ := endpoint.NewService(conn2) + pendingActionsService2, _ := pendingactions.NewService(conn2) + + k8sEndpoint := &portainer.Endpoint{ + ID: 10, + Name: "k8s-cluster", + Type: portainer.AgentOnKubernetesEnvironment, + } + err = conn2.CreateObjectWithId(endpoint.BucketName, int(k8sEndpoint.ID), k8sEndpoint) + require.NoError(t, err) + + reg := &portainer.Registry{ + ID: 10, + Name: "empty-registry", + RegistryAccesses: portainer.RegistryAccesses{ + k8sEndpoint.ID: portainer.RegistryAccessPolicies{ + Namespaces: []string{}, + }, + }, + } + err = conn2.CreateObjectWithId(registry.BucketName, int(reg.ID), reg) + require.NoError(t, err) + + m := NewMigrator(&MigratorParameters{ + RegistryService: registryService2, + EndpointService: endpointService2, + PendingActionsService: pendingActionsService2, + }) + + err = m.migrateRegistryAccessSASecrets_2_40_0() + require.NoError(t, err) + + allPAs, err := pendingActionsService2.ReadAll() + require.NoError(t, err) + assert.Empty(t, allPAs, "should not create pending actions for empty namespaces") + }) + + t.Run("skips non-existent endpoints", func(t *testing.T) { + conn3 := &boltdb.DbConnection{Path: t.TempDir()} + err := conn3.Open() + require.NoError(t, err) + defer logs.CloseAndLogErr(conn3) + + registryService3, _ := registry.NewService(conn3) + endpointService3, _ := endpoint.NewService(conn3) + pendingActionsService3, _ := pendingactions.NewService(conn3) + + reg := &portainer.Registry{ + ID: 20, + Name: "orphan-registry", + RegistryAccesses: portainer.RegistryAccesses{ + 999: portainer.RegistryAccessPolicies{ + Namespaces: []string{"default"}, + }, + }, + } + err = conn3.CreateObjectWithId(registry.BucketName, int(reg.ID), reg) + require.NoError(t, err) + + m := NewMigrator(&MigratorParameters{ + RegistryService: registryService3, + EndpointService: endpointService3, + PendingActionsService: pendingActionsService3, + }) + + err = m.migrateRegistryAccessSASecrets_2_40_0() + require.NoError(t, err) + + allPAs, err := pendingActionsService3.ReadAll() + require.NoError(t, err) + assert.Empty(t, allPAs, "should not create pending actions for non-existent endpoints") + }) + + t.Run("idempotent - running twice creates duplicate actions but doesn't error", func(t *testing.T) { + conn4 := &boltdb.DbConnection{Path: t.TempDir()} + err := conn4.Open() + require.NoError(t, err) + defer logs.CloseAndLogErr(conn4) + + registryService4, _ := registry.NewService(conn4) + endpointService4, _ := endpoint.NewService(conn4) + pendingActionsService4, _ := pendingactions.NewService(conn4) + + k8sEndpoint := &portainer.Endpoint{ + ID: 30, + Name: "k8s-cluster", + Type: portainer.AgentOnKubernetesEnvironment, + } + err = conn4.CreateObjectWithId(endpoint.BucketName, int(k8sEndpoint.ID), k8sEndpoint) + require.NoError(t, err) + + reg := &portainer.Registry{ + ID: 30, + Name: "test-registry", + RegistryAccesses: portainer.RegistryAccesses{ + k8sEndpoint.ID: portainer.RegistryAccessPolicies{ + Namespaces: []string{"default"}, + }, + }, + } + err = conn4.CreateObjectWithId(registry.BucketName, int(reg.ID), reg) + require.NoError(t, err) + + m := NewMigrator(&MigratorParameters{ + RegistryService: registryService4, + EndpointService: endpointService4, + PendingActionsService: pendingActionsService4, + }) + + err = m.migrateRegistryAccessSASecrets_2_40_0() + require.NoError(t, err) + + err = m.migrateRegistryAccessSASecrets_2_40_0() + require.NoError(t, err) + }) +} diff --git a/api/datastore/migrator/migrate_2_43_0.go b/api/datastore/migrator/migrate_2_43_0.go new file mode 100644 index 0000000..4365872 --- /dev/null +++ b/api/datastore/migrator/migrate_2_43_0.go @@ -0,0 +1,333 @@ +package migrator + +import ( + "fmt" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices/source" + "github.com/portainer/portainer/api/dataservices/stack" + gittypes "github.com/portainer/portainer/api/git/types" + + "github.com/rs/zerolog/log" +) + +type legacyRepoConfig struct { + URL string + ReferenceName string + ConfigFilePath string + Authentication *legacyGitAuthentication + ConfigHash string + TLSSkipVerify bool +} + +type legacyGitAuthentication struct { + Username string + Password string + Provider int `json:",omitempty"` + AuthorizationType int `json:",omitempty"` +} + +func (lrc *legacyRepoConfig) toRepoConfig() *gittypes.RepoConfig { + if lrc == nil { + return nil + } + + cfg := &gittypes.RepoConfig{ + URL: lrc.URL, + ReferenceName: lrc.ReferenceName, + ConfigFilePath: lrc.ConfigFilePath, + ConfigHash: lrc.ConfigHash, + TLSSkipVerify: lrc.TLSSkipVerify, + } + + if lrc.Authentication != nil { + cfg.Authentication = &gittypes.GitAuthentication{ + Username: lrc.Authentication.Username, + Password: lrc.Authentication.Password, + Provider: gittypes.GitProvider(lrc.Authentication.Provider), + AuthorizationType: gittypes.GitCredentialAuthType(lrc.Authentication.AuthorizationType), + } + } + + return cfg +} + +type legacyStack struct { + ID int `json:"Id"` + GitConfig *legacyRepoConfig `json:"GitConfig"` + WorkflowID *int + ResourceControl *portainer.ResourceControl `json:"ResourceControl"` + CreatedBy string +} + +// sourceDedupeKey is the identity used to detect duplicate Sources during migration. +// Two stacks sharing the same URL and credentials must reuse the same Source record. +type sourceDedupeKey struct { + url string + username string + password string +} + +func gitSourceKey(cfg *gittypes.RepoConfig) sourceDedupeKey { + url, err := gittypes.NormalizeURL(gittypes.SanitizeURL(cfg.URL)) + if err != nil { + log.Warn().Err(err).Str("url", cfg.URL).Msg("failed to normalize git URL for deduplication, using raw URL") + url = cfg.URL + } + + key := sourceDedupeKey{url: url} + if cfg.Authentication != nil { + key.username = cfg.Authentication.Username + key.password = cfg.Authentication.Password + } + + return key +} + +func (m *Migrator) migrateGitConfigToSources_2_43_0() error { + log.Info().Msg("migrating git-backed stacks to Source+Workflow records") + + var legacyStacks []legacyStack + + err := m.stackService.Connection.GetAll( + stack.BucketName, + new(legacyStack), + func(obj any) (any, error) { + s, ok := obj.(*legacyStack) + if !ok { + return nil, fmt.Errorf("unexpected type reading stack bucket: %T", obj) + } + + legacyStacks = append(legacyStacks, *s) + + return new(legacyStack), nil + }, + ) + if err != nil { + return err + } + + adminUserContext := source.InsecureNewAdminContext() + existingSources, err := m.sourceService.ReadAll(adminUserContext) + if err != nil { + return err + } + + sourcesByKey := make(map[sourceDedupeKey]portainer.SourceID, len(existingSources)) + for _, src := range existingSources { + if src.Git != nil { + sourcesByKey[gitSourceKey(&gittypes.RepoConfig{URL: src.Git.URL, Authentication: src.Git.Authentication})] = src.ID + } + } + + for _, ls := range legacyStacks { + if ls.GitConfig == nil || (ls.WorkflowID != nil && *ls.WorkflowID != 0) { + continue + } + + cfg := ls.GitConfig.toRepoConfig() + cfg.URL = gittypes.SanitizeURL(cfg.URL) + key := gitSourceKey(cfg) + + var newSrcID portainer.SourceID + + if err := m.stackService.Connection.UpdateTx(func(tx portainer.Transaction) error { + liveStack, err := m.stackService.Tx(tx).Read(portainer.StackID(ls.ID)) + if err != nil { + return fmt.Errorf("failed to read stack %d: %w", ls.ID, err) + } + + rc := ls.ResourceControl + if rc == nil { + rcID := fmt.Sprintf("%d_%s", liveStack.EndpointID, liveStack.Name) + rc, err = m.resourceControlService.Tx(tx).ResourceControlByResourceIDAndType(rcID, portainer.StackResourceControl) + if err != nil { + return fmt.Errorf("failed to read resource control for stack %d: %w", ls.ID, err) + } + } + + users, teams, public, adminOnly, ownerId := GetValuesForUsersFromResourceOwnershipAndAccesses_2_43_0(rc, + func() (portainer.UserID, portainer.UserRole, error) { + user, err := m.userService.Tx(tx).UserByUsername(ls.CreatedBy) + if err != nil { + return 0, 0, err + } + return user.ID, user.Role, nil + }, + func(userId portainer.UserID) ([]portainer.TeamMembership, error) { + return m.teamMembershipService.Tx(tx).TeamMembershipsByUserID(userId) + }, + ) + + srcID, exists := sourcesByKey[key] + + if !exists { + src := &portainer.Source{ + Name: gittypes.RepoName(cfg.URL), + Type: portainer.SourceTypeGit, + Git: &gittypes.GitSource{ + URL: cfg.URL, + Authentication: cfg.Authentication, + TLSSkipVerify: cfg.TLSSkipVerify, + }, + OwnerID: ownerId, + Public: public, + AdministratorsOnly: adminOnly, + UserAccesses: users, + TeamAccesses: teams, + } + + if err := m.sourceService.Tx(tx).Create(adminUserContext, src); err != nil { + return fmt.Errorf("failed to create source for stack %d: %w", ls.ID, err) + } + srcID = src.ID + newSrcID = src.ID + } else { + src, err := m.sourceService.Tx(tx).Read(adminUserContext, srcID) + if err != nil { + return fmt.Errorf("failed to read source %d for stack %d: %w", srcID, ls.ID, err) + } + + ApplyUACOnSourceUpdate_2_43_0(src, users, teams, public, adminOnly, ownerId) + + if err := m.sourceService.Tx(tx).Update(adminUserContext, srcID, src); err != nil { + return fmt.Errorf("failed to update source %d for stack %d: %w", srcID, ls.ID, err) + } + } + + wf := &portainer.Workflow{ + Name: liveStack.Name, + Artifacts: []portainer.Artifact{{ + StackID: portainer.StackID(ls.ID), + Files: []portainer.ArtifactFile{{ + SourceID: srcID, + Path: cfg.ConfigFilePath, + Ref: cfg.ReferenceName, + Hash: cfg.ConfigHash, + }}, + }}, + } + if err := m.workflowService.Tx(tx).Create(wf); err != nil { + return fmt.Errorf("failed to create workflow for stack %d: %w", ls.ID, err) + } + + liveStack.WorkflowID = wf.ID + liveStack.GitConfig = nil + + return m.stackService.Tx(tx).Update(portainer.StackID(ls.ID), liveStack) + }); err != nil { + return fmt.Errorf("failed to migrate stack %d: %w", ls.ID, err) + } + + if newSrcID != 0 { + sourcesByKey[key] = newSrcID + } + } + + return nil +} + +func (m *Migrator) migrateCustomTemplateGitConfigToSources_2_43_0() error { + log.Info().Msg("migrating git-backed custom templates to Source records") + + templates, err := m.customTemplateService.ReadAll() + if err != nil { + return err + } + + adminUserContext := source.InsecureNewAdminContext() + existingSources, err := m.sourceService.ReadAll(adminUserContext) + if err != nil { + return err + } + + sourcesByKey := make(map[sourceDedupeKey]portainer.SourceID, len(existingSources)) + for _, src := range existingSources { + if src.Git != nil { + sourcesByKey[gitSourceKey(&gittypes.RepoConfig{URL: src.Git.URL, Authentication: src.Git.Authentication})] = src.ID + } + } + + for i := range templates { + t := &templates[i] + if t.GitConfig == nil || t.Artifact != nil { + continue + } + + cfg := &gittypes.GitSource{ + URL: gittypes.SanitizeURL(t.GitConfig.URL), + Authentication: t.GitConfig.Authentication, + TLSSkipVerify: t.GitConfig.TLSSkipVerify, + } + + key := gitSourceKey(&gittypes.RepoConfig{URL: cfg.URL, Authentication: cfg.Authentication}) + + var newSrcID portainer.SourceID + + if err := m.stackService.Connection.UpdateTx(func(tx portainer.Transaction) error { + users, teams, public, adminOnly, ownerId := GetValuesForUsersFromResourceOwnershipAndAccesses_2_43_0(t.ResourceControl, + func() (portainer.UserID, portainer.UserRole, error) { + user, err := m.userService.Tx(tx).Read(t.CreatedByUserID) + if err != nil { + return 0, 0, err + } + return user.ID, user.Role, nil + }, + func(userId portainer.UserID) ([]portainer.TeamMembership, error) { + return m.teamMembershipService.Tx(tx).TeamMembershipsByUserID(userId) + }, + ) + + srcID, exists := sourcesByKey[key] + + if !exists { + src := &portainer.Source{ + Name: gittypes.RepoName(cfg.URL), + Type: portainer.SourceTypeGit, + Git: cfg, + OwnerID: ownerId, + Public: public, + AdministratorsOnly: adminOnly, + UserAccesses: users, + TeamAccesses: teams, + } + if err := m.sourceService.Tx(tx).Create(adminUserContext, src); err != nil { + return fmt.Errorf("failed to create source for custom template %d: %w", t.ID, err) + } + srcID = src.ID + newSrcID = src.ID + } else { + src, err := m.sourceService.Tx(tx).Read(adminUserContext, srcID) + if err != nil { + return fmt.Errorf("failed to read source %d for custom template %d: %w", srcID, t.ID, err) + } + + ApplyUACOnSourceUpdate_2_43_0(src, users, teams, public, adminOnly, ownerId) + + if err := m.sourceService.Tx(tx).Update(adminUserContext, srcID, src); err != nil { + return fmt.Errorf("failed to update source %d for custom template %d: %w", srcID, t.ID, err) + } + } + + t.Artifact = &portainer.Artifact{ + Files: []portainer.ArtifactFile{{ + SourceID: srcID, + Path: t.GitConfig.ConfigFilePath, + Ref: t.GitConfig.ReferenceName, + Hash: t.GitConfig.ConfigHash, + }}, + } + t.GitConfig = nil + + return m.customTemplateService.Tx(tx).Update(t.ID, t) + }); err != nil { + return fmt.Errorf("failed to migrate custom template %d: %w", t.ID, err) + } + + if newSrcID != 0 { + sourcesByKey[key] = newSrcID + } + } + + return nil +} diff --git a/api/datastore/migrator/migrate_2_43_0_test.go b/api/datastore/migrator/migrate_2_43_0_test.go new file mode 100644 index 0000000..13a99b0 --- /dev/null +++ b/api/datastore/migrator/migrate_2_43_0_test.go @@ -0,0 +1,707 @@ +package migrator + +import ( + "fmt" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/database/boltdb" + "github.com/portainer/portainer/api/dataservices/customtemplate" + "github.com/portainer/portainer/api/dataservices/resourcecontrol" + "github.com/portainer/portainer/api/dataservices/source" + "github.com/portainer/portainer/api/dataservices/stack" + "github.com/portainer/portainer/api/dataservices/teammembership" + "github.com/portainer/portainer/api/dataservices/user" + "github.com/portainer/portainer/api/dataservices/workflow" + gittypes "github.com/portainer/portainer/api/git/types" + "github.com/portainer/portainer/api/logs" + + "github.com/stretchr/testify/require" +) + +// TODO: generate tests for UAC migrations + +var adminUserContext = source.InsecureNewAdminContext() + +func TestMigrateGitConfigToSources_2_43_0_GitStackMigrated(t *testing.T) { + t.Parallel() + + conn := &boltdb.DbConnection{Path: t.TempDir()} + err := conn.Open() + require.NoError(t, err) + defer logs.CloseAndLogErr(conn) + + stackSvc, err := stack.NewService(conn) + require.NoError(t, err) + sourceSvc, err := source.NewService(conn) + require.NoError(t, err) + workflowSvc, err := workflow.NewService(conn) + require.NoError(t, err) + rcSvc, err := resourcecontrol.NewService(conn) + require.NoError(t, err) + + m := NewMigrator(&MigratorParameters{ + StackService: stackSvc, + SourceService: sourceSvc, + WorkflowService: workflowSvc, + ResourceControlService: rcSvc, + }) + + gitStack := &portainer.Stack{ + ID: 1, + Name: "git-stack", + GitConfig: &gittypes.RepoConfig{ + URL: "https://github.com/example/repo", + ReferenceName: "refs/heads/main", + ConfigHash: "abc123", + }, + } + err = conn.CreateObjectWithId(stack.BucketName, int(gitStack.ID), gitStack) + require.NoError(t, err) + + err = m.migrateGitConfigToSources_2_43_0() + require.NoError(t, err) + + migrated, err := stackSvc.Read(gitStack.ID) + require.NoError(t, err) + require.NotZero(t, migrated.WorkflowID) + require.Nil(t, migrated.GitConfig) + + wf, err := workflowSvc.Read(migrated.WorkflowID) + require.NoError(t, err) + require.Len(t, wf.Artifacts, 1) + require.Len(t, wf.Artifacts[0].Files, 1) + + src, err := sourceSvc.Read(adminUserContext, wf.Artifacts[0].Files[0].SourceID) + require.NoError(t, err) + require.Equal(t, portainer.SourceTypeGit, src.Type) + require.Equal(t, gitStack.GitConfig.URL, src.Git.URL) + require.Equal(t, gitStack.GitConfig.ReferenceName, wf.Artifacts[0].Files[0].Ref) +} + +func TestMigrateGitConfigToSources_2_43_0_NonGitStackUntouched(t *testing.T) { + t.Parallel() + + conn := &boltdb.DbConnection{Path: t.TempDir()} + err := conn.Open() + require.NoError(t, err) + defer logs.CloseAndLogErr(conn) + + stackSvc, err := stack.NewService(conn) + require.NoError(t, err) + sourceSvc, err := source.NewService(conn) + require.NoError(t, err) + workflowSvc, err := workflow.NewService(conn) + require.NoError(t, err) + rcSvc, err := resourcecontrol.NewService(conn) + require.NoError(t, err) + + m := NewMigrator(&MigratorParameters{ + StackService: stackSvc, + SourceService: sourceSvc, + WorkflowService: workflowSvc, + ResourceControlService: rcSvc, + }) + + plainStack := &portainer.Stack{ + ID: 1, + Name: "plain-stack", + } + err = conn.CreateObjectWithId(stack.BucketName, int(plainStack.ID), plainStack) + require.NoError(t, err) + + err = m.migrateGitConfigToSources_2_43_0() + require.NoError(t, err) + + result, err := stackSvc.Read(plainStack.ID) + require.NoError(t, err) + require.Zero(t, result.WorkflowID) + require.Nil(t, result.GitConfig) + + sources, err := sourceSvc.ReadAll(adminUserContext) + require.NoError(t, err) + require.Empty(t, sources) + + workflows, err := workflowSvc.ReadAll() + require.NoError(t, err) + require.Empty(t, workflows) +} + +func TestMigrateGitConfigToSources_2_43_0_DuplicateSourcesDeduped(t *testing.T) { + t.Parallel() + + conn := &boltdb.DbConnection{Path: t.TempDir()} + err := conn.Open() + require.NoError(t, err) + defer logs.CloseAndLogErr(conn) + + stackSvc, err := stack.NewService(conn) + require.NoError(t, err) + sourceSvc, err := source.NewService(conn) + require.NoError(t, err) + workflowSvc, err := workflow.NewService(conn) + require.NoError(t, err) + rcSvc, err := resourcecontrol.NewService(conn) + require.NoError(t, err) + + m := NewMigrator(&MigratorParameters{ + StackService: stackSvc, + SourceService: sourceSvc, + WorkflowService: workflowSvc, + ResourceControlService: rcSvc, + }) + + sharedURL := "https://github.com/example/shared-repo" + + stack1 := &portainer.Stack{ + ID: 1, + Name: "stack-a", + GitConfig: &gittypes.RepoConfig{ + URL: sharedURL, + ReferenceName: "refs/heads/main", + }, + } + stack2 := &portainer.Stack{ + ID: 2, + Name: "stack-b", + GitConfig: &gittypes.RepoConfig{ + URL: sharedURL, + ReferenceName: "refs/heads/develop", + }, + } + err = conn.CreateObjectWithId(stack.BucketName, int(stack1.ID), stack1) + require.NoError(t, err) + err = conn.CreateObjectWithId(stack.BucketName, int(stack2.ID), stack2) + require.NoError(t, err) + + err = m.migrateGitConfigToSources_2_43_0() + require.NoError(t, err) + + sources, err := sourceSvc.ReadAll(adminUserContext) + require.NoError(t, err) + require.Len(t, sources, 1, "two stacks with the same URL must share one Source") + + workflows, err := workflowSvc.ReadAll() + require.NoError(t, err) + require.Len(t, workflows, 2, "each stack must get its own Workflow") + + sharedSourceID := sources[0].ID + for _, wf := range workflows { + require.Len(t, wf.Artifacts, 1) + require.Len(t, wf.Artifacts[0].Files, 1) + require.Equal(t, sharedSourceID, wf.Artifacts[0].Files[0].SourceID) + } +} + +func TestMigrateGitConfigToSources_2_43_0_DotGitSuffixDeduped(t *testing.T) { + t.Parallel() + + conn := &boltdb.DbConnection{Path: t.TempDir()} + err := conn.Open() + require.NoError(t, err) + defer logs.CloseAndLogErr(conn) + + stackSvc, err := stack.NewService(conn) + require.NoError(t, err) + sourceSvc, err := source.NewService(conn) + require.NoError(t, err) + workflowSvc, err := workflow.NewService(conn) + require.NoError(t, err) + rcSvc, err := resourcecontrol.NewService(conn) + require.NoError(t, err) + + m := NewMigrator(&MigratorParameters{ + StackService: stackSvc, + SourceService: sourceSvc, + WorkflowService: workflowSvc, + ResourceControlService: rcSvc, + }) + + // One stack uses the .git suffix, the other does not. Both refer to the + // same repository and must share a single Source record after migration. + stack1 := &portainer.Stack{ + ID: 1, + Name: "stack-a", + GitConfig: &gittypes.RepoConfig{ + URL: "https://github.com/example/shared-repo.git", + ReferenceName: "refs/heads/main", + }, + } + stack2 := &portainer.Stack{ + ID: 2, + Name: "stack-b", + GitConfig: &gittypes.RepoConfig{ + URL: "https://github.com/example/shared-repo", + ReferenceName: "refs/heads/develop", + }, + } + err = conn.CreateObjectWithId(stack.BucketName, int(stack1.ID), stack1) + require.NoError(t, err) + err = conn.CreateObjectWithId(stack.BucketName, int(stack2.ID), stack2) + require.NoError(t, err) + + err = m.migrateGitConfigToSources_2_43_0() + require.NoError(t, err) + + sources, err := sourceSvc.ReadAll(adminUserContext) + require.NoError(t, err) + require.Len(t, sources, 1, "stacks whose URLs differ only in .git suffix must share one Source") + + workflows, err := workflowSvc.ReadAll() + require.NoError(t, err) + require.Len(t, workflows, 2, "each stack must get its own Workflow") + + sharedSourceID := sources[0].ID + for _, wf := range workflows { + require.Len(t, wf.Artifacts, 1) + require.Len(t, wf.Artifacts[0].Files, 1) + require.Equal(t, sharedSourceID, wf.Artifacts[0].Files[0].SourceID) + } +} + +func TestMigrateGitConfigToSources_2_43_0_Idempotent(t *testing.T) { + t.Parallel() + + conn := &boltdb.DbConnection{Path: t.TempDir()} + err := conn.Open() + require.NoError(t, err) + defer logs.CloseAndLogErr(conn) + + stackSvc, err := stack.NewService(conn) + require.NoError(t, err) + sourceSvc, err := source.NewService(conn) + require.NoError(t, err) + workflowSvc, err := workflow.NewService(conn) + require.NoError(t, err) + rcSvc, err := resourcecontrol.NewService(conn) + require.NoError(t, err) + + m := NewMigrator(&MigratorParameters{ + StackService: stackSvc, + SourceService: sourceSvc, + WorkflowService: workflowSvc, + ResourceControlService: rcSvc, + }) + + gitStack := &portainer.Stack{ + ID: 1, + Name: "git-stack", + GitConfig: &gittypes.RepoConfig{ + URL: "https://github.com/example/repo", + }, + } + err = conn.CreateObjectWithId(stack.BucketName, int(gitStack.ID), gitStack) + require.NoError(t, err) + + err = m.migrateGitConfigToSources_2_43_0() + require.NoError(t, err) + + // Second run must not create duplicate Source/Workflow records + err = m.migrateGitConfigToSources_2_43_0() + require.NoError(t, err) + + sources, err := sourceSvc.ReadAll(adminUserContext) + require.NoError(t, err) + require.Len(t, sources, 1) + + workflows, err := workflowSvc.ReadAll() + require.NoError(t, err) + require.Len(t, workflows, 1) +} + +func TestMigrateCustomTemplateGitConfigToSources_2_43_0_GitTemplateMigrated(t *testing.T) { + t.Parallel() + + conn := &boltdb.DbConnection{Path: t.TempDir()} + err := conn.Open() + require.NoError(t, err) + defer logs.CloseAndLogErr(conn) + + stackSvc, err := stack.NewService(conn) + require.NoError(t, err) + sourceSvc, err := source.NewService(conn) + require.NoError(t, err) + customTemplateSvc, err := customtemplate.NewService(conn) + require.NoError(t, err) + + m := NewMigrator(&MigratorParameters{ + StackService: stackSvc, + SourceService: sourceSvc, + CustomTemplateService: customTemplateSvc, + }) + + tmpl := &portainer.CustomTemplate{ + ID: 1, + GitConfig: &gittypes.RepoConfig{ + URL: "https://github.com/example/repo", + ReferenceName: "refs/heads/main", + ConfigFilePath: "docker-compose.yml", + ConfigHash: "abc123", + }, + } + err = conn.CreateObjectWithId(customtemplate.BucketName, int(tmpl.ID), tmpl) + require.NoError(t, err) + + err = m.migrateCustomTemplateGitConfigToSources_2_43_0() + require.NoError(t, err) + + migrated, err := customTemplateSvc.Read(tmpl.ID) + require.NoError(t, err) + require.NotNil(t, migrated.Artifact) + require.Nil(t, migrated.GitConfig) + require.Len(t, migrated.Artifact.Files, 1) + require.Equal(t, "refs/heads/main", migrated.Artifact.Files[0].Ref) + require.Equal(t, "docker-compose.yml", migrated.Artifact.Files[0].Path) + require.Equal(t, "abc123", migrated.Artifact.Files[0].Hash) + + src, err := sourceSvc.Read(adminUserContext, migrated.Artifact.Files[0].SourceID) + require.NoError(t, err) + require.Equal(t, portainer.SourceTypeGit, src.Type) + require.Equal(t, "https://github.com/example/repo", src.Git.URL) +} + +func TestMigrateCustomTemplateGitConfigToSources_2_43_0_NonGitTemplateUntouched(t *testing.T) { + t.Parallel() + + conn := &boltdb.DbConnection{Path: t.TempDir()} + err := conn.Open() + require.NoError(t, err) + defer logs.CloseAndLogErr(conn) + + stackSvc, err := stack.NewService(conn) + require.NoError(t, err) + sourceSvc, err := source.NewService(conn) + require.NoError(t, err) + customTemplateSvc, err := customtemplate.NewService(conn) + require.NoError(t, err) + + m := NewMigrator(&MigratorParameters{ + StackService: stackSvc, + SourceService: sourceSvc, + CustomTemplateService: customTemplateSvc, + }) + + tmpl := &portainer.CustomTemplate{ID: 1, Title: "plain-template"} + err = conn.CreateObjectWithId(customtemplate.BucketName, int(tmpl.ID), tmpl) + require.NoError(t, err) + + err = m.migrateCustomTemplateGitConfigToSources_2_43_0() + require.NoError(t, err) + + result, err := customTemplateSvc.Read(tmpl.ID) + require.NoError(t, err) + require.Nil(t, result.Artifact) + require.Nil(t, result.GitConfig) + + sources, err := sourceSvc.ReadAll(adminUserContext) + require.NoError(t, err) + require.Empty(t, sources) +} + +func TestMigrateCustomTemplateGitConfigToSources_2_43_0_AlreadyMigratedSkipped(t *testing.T) { + t.Parallel() + + conn := &boltdb.DbConnection{Path: t.TempDir()} + err := conn.Open() + require.NoError(t, err) + defer logs.CloseAndLogErr(conn) + + stackSvc, err := stack.NewService(conn) + require.NoError(t, err) + sourceSvc, err := source.NewService(conn) + require.NoError(t, err) + customTemplateSvc, err := customtemplate.NewService(conn) + require.NoError(t, err) + + m := NewMigrator(&MigratorParameters{ + StackService: stackSvc, + SourceService: sourceSvc, + CustomTemplateService: customTemplateSvc, + }) + + // Template already has Artifact set (already migrated) + srcID := portainer.SourceID(99) + tmpl := &portainer.CustomTemplate{ + ID: 1, + GitConfig: &gittypes.RepoConfig{ + URL: "https://github.com/example/repo", + }, + Artifact: &portainer.Artifact{ + Files: []portainer.ArtifactFile{{SourceID: srcID}}, + }, + } + err = conn.CreateObjectWithId(customtemplate.BucketName, int(tmpl.ID), tmpl) + require.NoError(t, err) + + err = m.migrateCustomTemplateGitConfigToSources_2_43_0() + require.NoError(t, err) + + sources, err := sourceSvc.ReadAll(adminUserContext) + require.NoError(t, err) + require.Empty(t, sources, "no new sources should be created for already-migrated templates") +} + +func TestMigrateCustomTemplateGitConfigToSources_2_43_0_DuplicateSourcesDeduped(t *testing.T) { + t.Parallel() + + conn := &boltdb.DbConnection{Path: t.TempDir()} + err := conn.Open() + require.NoError(t, err) + defer logs.CloseAndLogErr(conn) + + stackSvc, err := stack.NewService(conn) + require.NoError(t, err) + sourceSvc, err := source.NewService(conn) + require.NoError(t, err) + customTemplateSvc, err := customtemplate.NewService(conn) + require.NoError(t, err) + + m := NewMigrator(&MigratorParameters{ + StackService: stackSvc, + SourceService: sourceSvc, + CustomTemplateService: customTemplateSvc, + }) + + sharedURL := "https://github.com/example/shared-repo" + + tmpl1 := &portainer.CustomTemplate{ + ID: 1, + Title: "template-a", + GitConfig: &gittypes.RepoConfig{ + URL: sharedURL, + ReferenceName: "refs/heads/main", + }, + } + tmpl2 := &portainer.CustomTemplate{ + ID: 2, + Title: "template-b", + GitConfig: &gittypes.RepoConfig{ + URL: sharedURL, + ReferenceName: "refs/heads/develop", + }, + } + err = conn.CreateObjectWithId(customtemplate.BucketName, int(tmpl1.ID), tmpl1) + require.NoError(t, err) + err = conn.CreateObjectWithId(customtemplate.BucketName, int(tmpl2.ID), tmpl2) + require.NoError(t, err) + + err = m.migrateCustomTemplateGitConfigToSources_2_43_0() + require.NoError(t, err) + + sources, err := sourceSvc.ReadAll(adminUserContext) + require.NoError(t, err) + require.Len(t, sources, 1, "two templates with the same URL must share one Source") + + sharedSrcID := sources[0].ID + + migrated1, err := customTemplateSvc.Read(tmpl1.ID) + require.NoError(t, err) + require.NotNil(t, migrated1.Artifact) + require.Equal(t, sharedSrcID, migrated1.Artifact.Files[0].SourceID) + + migrated2, err := customTemplateSvc.Read(tmpl2.ID) + require.NoError(t, err) + require.NotNil(t, migrated2.Artifact) + require.Equal(t, sharedSrcID, migrated2.Artifact.Files[0].SourceID) +} + +func TestMigrateCustomTemplateGitConfigToSources_2_43_0_DotGitSuffixDeduped(t *testing.T) { + t.Parallel() + + conn := &boltdb.DbConnection{Path: t.TempDir()} + err := conn.Open() + require.NoError(t, err) + defer logs.CloseAndLogErr(conn) + + stackSvc, err := stack.NewService(conn) + require.NoError(t, err) + sourceSvc, err := source.NewService(conn) + require.NoError(t, err) + customTemplateSvc, err := customtemplate.NewService(conn) + require.NoError(t, err) + + m := NewMigrator(&MigratorParameters{ + StackService: stackSvc, + SourceService: sourceSvc, + CustomTemplateService: customTemplateSvc, + }) + + // One template uses the .git suffix, the other does not. Both refer to the + // same repository and must share a single Source record after migration. + tmpl1 := &portainer.CustomTemplate{ + ID: 1, + Title: "template-a", + GitConfig: &gittypes.RepoConfig{ + URL: "https://github.com/example/shared-repo.git", + ReferenceName: "refs/heads/main", + }, + } + tmpl2 := &portainer.CustomTemplate{ + ID: 2, + Title: "template-b", + GitConfig: &gittypes.RepoConfig{ + URL: "https://github.com/example/shared-repo", + ReferenceName: "refs/heads/develop", + }, + } + err = conn.CreateObjectWithId(customtemplate.BucketName, int(tmpl1.ID), tmpl1) + require.NoError(t, err) + err = conn.CreateObjectWithId(customtemplate.BucketName, int(tmpl2.ID), tmpl2) + require.NoError(t, err) + + err = m.migrateCustomTemplateGitConfigToSources_2_43_0() + require.NoError(t, err) + + sources, err := sourceSvc.ReadAll(adminUserContext) + require.NoError(t, err) + require.Len(t, sources, 1, "templates whose URLs differ only in .git suffix must share one Source") + + sharedSrcID := sources[0].ID + + migrated1, err := customTemplateSvc.Read(tmpl1.ID) + require.NoError(t, err) + require.NotNil(t, migrated1.Artifact) + require.Equal(t, sharedSrcID, migrated1.Artifact.Files[0].SourceID) + + migrated2, err := customTemplateSvc.Read(tmpl2.ID) + require.NoError(t, err) + require.NotNil(t, migrated2.Artifact) + require.Equal(t, sharedSrcID, migrated2.Artifact.Files[0].SourceID) +} + +// TestMigrateGitConfigToSources_2_43_0_StandardUserStackPreservesAccess is the regression +// test for the bug where stacks owned by standard users became inaccessible after upgrading +// to 2.43. ResourceControls are stored in a separate bucket and are never embedded in the +// stack record, so the migration must look them up explicitly. +func TestMigrateGitConfigToSources_2_43_0_StandardUserStackPreservesAccess(t *testing.T) { + t.Parallel() + + conn := &boltdb.DbConnection{Path: t.TempDir()} + err := conn.Open() + require.NoError(t, err) + defer logs.CloseAndLogErr(conn) + + stackSvc, err := stack.NewService(conn) + require.NoError(t, err) + sourceSvc, err := source.NewService(conn) + require.NoError(t, err) + workflowSvc, err := workflow.NewService(conn) + require.NoError(t, err) + rcSvc, err := resourcecontrol.NewService(conn) + require.NoError(t, err) + userSvc, err := user.NewService(conn) + require.NoError(t, err) + teamMembershipSvc, err := teammembership.NewService(conn) + require.NoError(t, err) + + m := NewMigrator(&MigratorParameters{ + StackService: stackSvc, + SourceService: sourceSvc, + WorkflowService: workflowSvc, + ResourceControlService: rcSvc, + UserService: userSvc, + TeamMembershipService: teamMembershipSvc, + }) + + standardUser := &portainer.User{ + Username: "standarduser", + Role: portainer.StandardUserRole, + } + err = userSvc.Create(standardUser) + require.NoError(t, err) + + const endpointID portainer.EndpointID = 1 + + gitStack := &portainer.Stack{ + ID: 1, + Name: "git-stack", + EndpointID: endpointID, + CreatedBy: "standarduser", + GitConfig: &gittypes.RepoConfig{ + URL: "https://github.com/example/repo", + ReferenceName: "refs/heads/main", + }, + } + err = conn.CreateObjectWithId(stack.BucketName, int(gitStack.ID), gitStack) + require.NoError(t, err) + + // ResourceControls are stored separately from stacks; the stack record never embeds one. + // The migration must look up the RC by resource ID to avoid defaulting to adminOnly=true. + rc := &portainer.ResourceControl{ + ResourceID: fmt.Sprintf("%d_%s", endpointID, gitStack.Name), + Type: portainer.StackResourceControl, + UserAccesses: []portainer.UserResourceAccess{ + {UserID: standardUser.ID, AccessLevel: portainer.ReadWriteAccessLevel}, + }, + AdministratorsOnly: false, + Public: false, + } + err = rcSvc.Create(rc) + require.NoError(t, err) + + err = m.migrateGitConfigToSources_2_43_0() + require.NoError(t, err) + + migrated, err := stackSvc.Read(gitStack.ID) + require.NoError(t, err) + require.NotZero(t, migrated.WorkflowID) + + wf, err := workflowSvc.Read(migrated.WorkflowID) + require.NoError(t, err) + require.Len(t, wf.Artifacts, 1) + require.Len(t, wf.Artifacts[0].Files, 1) + + srcID := wf.Artifacts[0].Files[0].SourceID + src, err := sourceSvc.Read(adminUserContext, srcID) + require.NoError(t, err) + require.False(t, src.AdministratorsOnly, "source must not be admin-only after migrating a standard user's stack") + require.Contains(t, src.UserAccesses, standardUser.ID) + + // The standard user must be able to read the source through the normal access filter + userCtx := source.NewUserContext(standardUser, nil) + userSrc, err := sourceSvc.Read(userCtx, srcID) + require.NoError(t, err) + require.Equal(t, srcID, userSrc.ID) +} + +func TestMigrateCustomTemplateGitConfigToSources_2_43_0_Idempotent(t *testing.T) { + t.Parallel() + + conn := &boltdb.DbConnection{Path: t.TempDir()} + err := conn.Open() + require.NoError(t, err) + defer logs.CloseAndLogErr(conn) + + stackSvc, err := stack.NewService(conn) + require.NoError(t, err) + sourceSvc, err := source.NewService(conn) + require.NoError(t, err) + customTemplateSvc, err := customtemplate.NewService(conn) + require.NoError(t, err) + + m := NewMigrator(&MigratorParameters{ + StackService: stackSvc, + SourceService: sourceSvc, + CustomTemplateService: customTemplateSvc, + }) + + tmpl := &portainer.CustomTemplate{ + ID: 1, + GitConfig: &gittypes.RepoConfig{ + URL: "https://github.com/example/repo", + }, + } + err = conn.CreateObjectWithId(customtemplate.BucketName, int(tmpl.ID), tmpl) + require.NoError(t, err) + + err = m.migrateCustomTemplateGitConfigToSources_2_43_0() + require.NoError(t, err) + + // Second run must not create duplicate Source records + err = m.migrateCustomTemplateGitConfigToSources_2_43_0() + require.NoError(t, err) + + sources, err := sourceSvc.ReadAll(adminUserContext) + require.NoError(t, err) + require.Len(t, sources, 1) +} diff --git a/api/datastore/migrator/migrate_2_43_sources.go b/api/datastore/migrator/migrate_2_43_sources.go new file mode 100644 index 0000000..b8089f4 --- /dev/null +++ b/api/datastore/migrator/migrate_2_43_sources.go @@ -0,0 +1,122 @@ +package migrator + +import ( + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/internal/authorization" + "github.com/portainer/portainer/api/slicesx" + "github.com/rs/zerolog/log" +) + +// DB accesses enforcement are trying to restrict accesses as much as possible +// but because accesses are applied sequentially, we want the accesses to be more open on migration +// so that users retain their accesses +func ApplyUACOnSourceUpdate_2_43_0(source *portainer.Source, + users []portainer.UserID, teams []portainer.TeamID, + public bool, adminOnly bool, + ownerId portainer.UserID, +) { + // sources already public should remain public + // OR + // the resource using this source is public, so the source should be public + if source.Public || public { + source.Public = true + source.AdministratorsOnly = false + source.UserAccesses = []portainer.UserID{} + source.TeamAccesses = []portainer.TeamID{} + return + } + + // add users and teams to source accesses only if the incoming resource is not admninonly + // to avoid saving leftover user/teams from adminonly resources + if !adminOnly { + source.UserAccesses = slicesx.Unique(append(source.UserAccesses, users...)) + source.TeamAccesses = slicesx.Unique(append(source.TeamAccesses, teams...)) + } + + // regardless of the incoming resource's ResourceControl values (func params) + // no accesses means adminonly source not owned by anyone + // we don't want users to own sources they don't have access to + // neither we want to default them to public + // all in all as we are doing an update it's probably redundant, but just in case... + if len(source.UserAccesses) == 0 && len(source.TeamAccesses) == 0 { + source.AdministratorsOnly = true + source.OwnerID = 0 + return + } + + // if owner of the incoming resource (ownerid) is the only one with access, we give the ownership to the user. + // The source could previously be adminonly so we change that as well as we want the most open situation + if len(source.UserAccesses) == 1 && len(source.TeamAccesses) == 0 && ownerId == source.UserAccesses[0] { + source.OwnerID = ownerId + source.AdministratorsOnly = false + return + } + + // Anything else will have multiple accesses (multiple teams or users), from multiple resources (source update flow) + // So we remove the ownership of the source in case it existed + // Scenario: + // - source created for resource owned by Bob + // - now we try to update with the RC from an admin-owned resource, shared to other users/teams + // - we don't want Bob to own the source anymore + source.OwnerID = 0 + +} + +func GetValuesForUsersFromResourceOwnershipAndAccesses_2_43_0( + rc *portainer.ResourceControl, + getCreator func() (portainer.UserID, portainer.UserRole, error), + getCreatorMemberships func(portainer.UserID) ([]portainer.TeamMembership, error), +) ( + users []portainer.UserID, teams []portainer.TeamID, + public bool, adminOnly bool, + ownerId portainer.UserID, +) { + users = []portainer.UserID{} + teams = []portainer.TeamID{} + public = false + adminOnly = true + + if rc == nil { + return + } + + adminOnly = rc.AdministratorsOnly + public = rc.Public + + if adminOnly || public { + return + } + + // only transfer users/teams when the stack is not admin nor public + // this allows avoiding transfering access of sources to users/teams that don't have real access to the stack + // but that may have had their accesses retained in DB + + users = slicesx.Map(rc.UserAccesses, func(ura portainer.UserResourceAccess) portainer.UserID { return ura.UserID }) + teams = slicesx.Map(rc.TeamAccesses, func(tra portainer.TeamResourceAccess) portainer.TeamID { return tra.TeamID }) + + userId, userRole, err := getCreator() + if err != nil { + log.Error().Err(err).Msgf("failed to read user when migrating to source") + return + } + + // we don't want to save the ownerid if the user is admin + // this avoids admins taking ownership of a new source + if userRole == portainer.AdministratorRole { + return + } + + // We also don't want to get the ownerid if the user doesn't have access to the resource anymore + userTeams, err := getCreatorMemberships(userId) + if err != nil { + log.Error().Err(err).Msgf("failed to read user %d teams when migrating source", userId) + return + } + + teamIds := slicesx.Map(userTeams, func(membership portainer.TeamMembership) portainer.TeamID { return membership.TeamID }) + if authorization.UserCanAccessResource(userId, teamIds, rc) { + ownerId = userId + } + + return +} diff --git a/api/datastore/migrator/migrate_ce.go b/api/datastore/migrator/migrate_ce.go new file mode 100644 index 0000000..e5dddbd --- /dev/null +++ b/api/datastore/migrator/migrate_ce.go @@ -0,0 +1,123 @@ +package migrator + +import ( + "reflect" + "runtime" + + "github.com/pkg/errors" + portainer "github.com/portainer/portainer/api" + + "github.com/Masterminds/semver/v3" + "github.com/rs/zerolog/log" +) + +func migrationError(err error, context string) error { + return errors.Wrap(err, "failed in "+context) +} + +func GetFunctionName(i any) string { + return runtime.FuncForPC(reflect.ValueOf(i).Pointer()).Name() +} + +// Migrate checks the database version and migrate the existing data to the most recent data model. +func (m *Migrator) Migrate() error { + version, err := m.versionService.Version() + if err != nil { + return migrationError(err, "get version service") + } + + schemaVersion, err := semver.NewVersion(version.SchemaVersion) + if err != nil { + return migrationError(err, "invalid db schema version") + } + + newMigratorCount := 0 + apiVersion := semver.MustParse(portainer.APIVersion) + if schemaVersion.Equal(apiVersion) { + // detect and run migrations when the versions are the same. + // e.g. development builds + latestMigrations := m.LatestMigrations() + if latestMigrations.Version.Equal(schemaVersion) && + version.MigratorCount != len(latestMigrations.MigrationFuncs) { + if err := runMigrations(latestMigrations.MigrationFuncs); err != nil { + return err + } + + newMigratorCount = len(latestMigrations.MigrationFuncs) + } + } else { + // regular path when major/minor/patch versions differ + for _, migration := range m.migrations { + if schemaVersion.LessThan(migration.Version) { + log.Info().Msgf("migrating data to %s", migration.Version.String()) + + if err := runMigrations(migration.MigrationFuncs); err != nil { + return err + } + } + + if apiVersion.Equal(migration.Version) { + newMigratorCount = len(migration.MigrationFuncs) + } + } + } + + if err := m.Always(); err != nil { + return migrationError(err, "Always migrations returned error") + } + + version.SchemaVersion = portainer.APIVersion + version.MigratorCount = newMigratorCount + + if err := m.versionService.UpdateVersion(version); err != nil { + return migrationError(err, "StoreDBVersion") + } + + log.Info().Msgf("db migrated to %s", portainer.APIVersion) + + return nil +} + +func runMigrations(migrationFuncs []func() error) error { + for _, migrationFunc := range migrationFuncs { + err := migrationFunc() + if err != nil { + return migrationError(err, GetFunctionName(migrationFunc)) + } + } + return nil +} + +func (m *Migrator) NeedsMigration() bool { + // we need to migrate if anything changes with the version in the DB vs what our software version is. + // If the version matches, then it's all down to the number of migration funcs we have for the current version + // i.e. the MigratorCount + + // In this particular instance we should log a fatal error + if m.CurrentDBEdition() != portainer.PortainerCE { + log.Fatal().Msg("the Portainer database is set for Portainer Business Edition, please follow the instructions in our documentation to downgrade it: https://docs.portainer.io/faqs/upgrading/can-i-downgrade-from-portainer-business-to-portainer-ce") + + return false + } + + if m.CurrentSemanticDBVersion().LessThan(semver.MustParse(portainer.APIVersion)) { + return true + } + + // Check if we have any migrations for the current version + latestMigrations := m.LatestMigrations() + if latestMigrations.Version.Equal(semver.MustParse(portainer.APIVersion)) { + if m.currentDBVersion.MigratorCount != len(latestMigrations.MigrationFuncs) { + return true + } + } else { + // One remaining possibility if we get here. If our migrator count > 0 and we have no migration funcs + // for the current version (i.e. they were deleted during development). Then we we need to migrate. + // This is to reset the migrator count back to 0 + if m.currentDBVersion.MigratorCount > 0 { + return true + } + } + + return false +} diff --git a/api/datastore/migrator/migrate_dbversion100.go b/api/datastore/migrator/migrate_dbversion100.go new file mode 100644 index 0000000..59b15b0 --- /dev/null +++ b/api/datastore/migrator/migrate_dbversion100.go @@ -0,0 +1,162 @@ +package migrator + +import ( + "os" + "time" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/chisel/crypto" + "github.com/portainer/portainer/api/dataservices" + + "github.com/rs/zerolog/log" +) + +func (m *Migrator) migrateDockerDesktopExtensionSetting() error { + log.Info().Msg("updating docker desktop extension flag in settings") + + isDDExtension := false + if _, ok := os.LookupEnv("DOCKER_EXTENSION"); ok { + isDDExtension = true + } + + settings, err := m.settingsService.Settings() + if err != nil { + return err + } + + settings.IsDockerDesktopExtension = isDDExtension + + return m.settingsService.UpdateSettings(settings) +} + +func (m *Migrator) convertSeedToPrivateKeyForDB100() error { + var serverInfo *portainer.TunnelServerInfo + + serverInfo, err := m.TunnelServerService.Info() + if err != nil { + if dataservices.IsErrObjectNotFound(err) { + log.Info().Msg("ServerInfo object not found") + return nil + } + + log.Error(). + Err(err). + Msg("Failed to read ServerInfo from DB") + + return err + } + + if serverInfo.PrivateKeySeed != "" { + key, err := crypto.GenerateGo119CompatibleKey(serverInfo.PrivateKeySeed) + if err != nil { + log.Error(). + Err(err). + Msg("Failed to read ServerInfo from DB") + + return err + } + + if err := m.fileService.StoreChiselPrivateKey(key); err != nil { + log.Error(). + Err(err). + Msg("Failed to save Chisel private key to disk") + + return err + } + } else { + log.Info().Msg("PrivateKeySeed is blank") + } + + serverInfo.PrivateKeySeed = "" + if err := m.TunnelServerService.UpdateInfo(serverInfo); err != nil { + log.Error(). + Err(err). + Msg("Failed to clean private key seed in DB") + } else { + log.Info().Msg("Success to migrate private key seed to private key file") + } + + return err +} + +func (m *Migrator) updateEdgeStackStatusForDB100() error { + log.Info().Msg("update edge stack status to have deployment steps") + + edgeStacks, err := m.edgeStackService.EdgeStacks() + if err != nil { + return err + } + + for _, edgeStack := range edgeStacks { + for environmentID, environmentStatus := range edgeStack.Status { + // Skip if status is already updated + if len(environmentStatus.Status) > 0 { + continue + } + + if environmentStatus.Details == nil { + continue + } + + statusArray := []portainer.EdgeStackDeploymentStatus{} + if environmentStatus.Details.Pending { + statusArray = append(statusArray, portainer.EdgeStackDeploymentStatus{ + Type: portainer.EdgeStackStatusPending, + Time: time.Now().Unix(), + }) + } + + if environmentStatus.Details.Acknowledged { + statusArray = append(statusArray, portainer.EdgeStackDeploymentStatus{ + Type: portainer.EdgeStackStatusAcknowledged, + Time: time.Now().Unix(), + }) + } + + if environmentStatus.Details.Error { + statusArray = append(statusArray, portainer.EdgeStackDeploymentStatus{ + Type: portainer.EdgeStackStatusError, + Error: environmentStatus.Error, + Time: time.Now().Unix(), + }) + } + + if environmentStatus.Details.Ok { + statusArray = append(statusArray, + portainer.EdgeStackDeploymentStatus{ + Type: portainer.EdgeStackStatusDeploymentReceived, + Time: time.Now().Unix(), + }, + portainer.EdgeStackDeploymentStatus{ + Type: portainer.EdgeStackStatusRunning, + Time: time.Now().Unix(), + }, + ) + } + + if environmentStatus.Details.ImagesPulled { + statusArray = append(statusArray, portainer.EdgeStackDeploymentStatus{ + Type: portainer.EdgeStackStatusImagesPulled, + Time: time.Now().Unix(), + }) + } + + if environmentStatus.Details.Remove { + statusArray = append(statusArray, portainer.EdgeStackDeploymentStatus{ + Type: portainer.EdgeStackStatusRemoving, + Time: time.Now().Unix(), + }) + } + + environmentStatus.Status = statusArray + + edgeStack.Status[environmentID] = environmentStatus + } + + if err := m.edgeStackService.UpdateEdgeStack(edgeStack.ID, &edgeStack); err != nil { + return err + } + } + + return nil +} diff --git a/api/datastore/migrator/migrate_dbversion110.go b/api/datastore/migrator/migrate_dbversion110.go new file mode 100644 index 0000000..13a797b --- /dev/null +++ b/api/datastore/migrator/migrate_dbversion110.go @@ -0,0 +1,51 @@ +package migrator + +import ( + portainer "github.com/portainer/portainer/api" + "github.com/rs/zerolog/log" +) + +// updateAppTemplatesVersionForDB110 changes the templates URL to be empty if it was never changed +// from the default value (version 2.0 URL) +func (migrator *Migrator) updateAppTemplatesVersionForDB110() error { + log.Info().Msg("updating app templates url to v3.0") + + version2URL := "https://raw.githubusercontent.com/portainer/templates/master/templates-2.0.json" + + settings, err := migrator.settingsService.Settings() + if err != nil { + return err + } + + if settings.TemplatesURL == version2URL || settings.TemplatesURL == portainer.DefaultTemplatesURL { + settings.TemplatesURL = "" + } + + return migrator.settingsService.UpdateSettings(settings) +} + +// In PortainerCE the resource overcommit option should always be true across all endpoints +func (migrator *Migrator) updateResourceOverCommitToDB110() error { + log.Info().Msg("updating resource overcommit setting to true") + + endpoints, err := migrator.endpointService.Endpoints() + if err != nil { + return err + } + + for _, endpoint := range endpoints { + if endpoint.Type == portainer.KubernetesLocalEnvironment || + endpoint.Type == portainer.AgentOnKubernetesEnvironment || + endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment { + + endpoint.Kubernetes.Configuration.EnableResourceOverCommit = true + + err = migrator.endpointService.UpdateEndpoint(endpoint.ID, &endpoint) + if err != nil { + return err + } + } + } + + return nil +} diff --git a/api/datastore/migrator/migrate_dbversion111.go b/api/datastore/migrator/migrate_dbversion111.go new file mode 100644 index 0000000..bd7c0d4 --- /dev/null +++ b/api/datastore/migrator/migrate_dbversion111.go @@ -0,0 +1,32 @@ +package migrator + +import ( + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/rs/zerolog/log" +) + +func (migrator *Migrator) cleanPendingActionsForDeletedEndpointsForDB111() error { + log.Info().Msg("cleaning up pending actions for deleted endpoints") + + pendingActions, err := migrator.pendingActionsService.ReadAll() + if err != nil { + return err + } + + endpoints := make(map[portainer.EndpointID]struct{}) + for _, action := range pendingActions { + endpoints[action.EndpointID] = struct{}{} + } + + for endpointId := range endpoints { + _, err := migrator.endpointService.Endpoint(endpointId) + if dataservices.IsErrObjectNotFound(err) { + err := migrator.pendingActionsService.DeleteByEndpointID(endpointId) + if err != nil { + return err + } + } + } + return nil +} diff --git a/api/datastore/migrator/migrate_dbversion130.go b/api/datastore/migrator/migrate_dbversion130.go new file mode 100644 index 0000000..7fa87e6 --- /dev/null +++ b/api/datastore/migrator/migrate_dbversion130.go @@ -0,0 +1,33 @@ +package migrator + +import ( + "github.com/segmentio/encoding/json" + + "github.com/rs/zerolog/log" +) + +func (migrator *Migrator) migratePendingActionsDataForDB130() error { + log.Info().Msg("Migrating pending actions data") + + pendingActions, err := migrator.pendingActionsService.ReadAll() + if err != nil { + return err + } + + for _, pa := range pendingActions { + actionData, err := json.Marshal(pa.ActionData) + if err != nil { + return err + } + + pa.ActionData = string(actionData) + + // Update the pending action + err = migrator.pendingActionsService.Update(pa.ID, &pa) + if err != nil { + return err + } + } + + return nil +} diff --git a/api/datastore/migrator/migrate_dbversion140.go b/api/datastore/migrator/migrate_dbversion140.go new file mode 100644 index 0000000..bb51fd3 --- /dev/null +++ b/api/datastore/migrator/migrate_dbversion140.go @@ -0,0 +1,58 @@ +package migrator + +import ( + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/internal/endpointutils" + "github.com/rs/zerolog/log" +) + +// migrateRegistryAccessSASecrets_2_40_0 marks Kubernetes endpoints that have +// registry access configured so that imagePullSecrets can be added to their +// default ServiceAccounts during the post-init migration phase (when cluster +// access is available). +func (m *Migrator) migrateRegistryAccessSASecrets_2_40_0() error { + log.Info().Msg("migrating registry access service account secrets") + + registries, err := m.registryService.ReadAll() + if err != nil { + return err + } + + endpoints, err := m.endpointService.Endpoints() + if err != nil { + return err + } + + // Collect the IDs of endpoints that have at least one registry with + // non-empty namespace access - these need the SA imagePullSecrets migration. + needsMigration := make(map[portainer.EndpointID]bool) + for _, registry := range registries { + for endpointID, access := range registry.RegistryAccesses { + if len(access.Namespaces) > 0 { + needsMigration[endpointID] = true + } + } + } + + for i := range endpoints { + endpoint := &endpoints[i] + + if !endpointutils.IsKubernetesEndpoint(endpoint) { + continue + } + + if !needsMigration[endpoint.ID] { + continue + } + + endpoint.PostInitMigrations.MigrateRegistrySASecrets = true + if err := m.endpointService.UpdateEndpoint(endpoint.ID, endpoint); err != nil { + log.Warn(). + Err(err). + Int("endpointID", int(endpoint.ID)). + Msg("failed to set registry SA secret migration flag for endpoint") + } + } + + return nil +} diff --git a/api/datastore/migrator/migrate_dbversion17.go b/api/datastore/migrator/migrate_dbversion17.go new file mode 100644 index 0000000..0bd6cc0 --- /dev/null +++ b/api/datastore/migrator/migrate_dbversion17.go @@ -0,0 +1,135 @@ +package migrator + +import ( + portainer "github.com/portainer/portainer/api" + + "github.com/rs/zerolog/log" +) + +func (m *Migrator) updateUsersToDBVersion18() error { + log.Info().Msg("updating users") + + legacyUsers, err := m.userService.ReadAll() + if err != nil { + return err + } + + for _, user := range legacyUsers { + user.PortainerAuthorizations = map[portainer.Authorization]bool{ + portainer.OperationPortainerDockerHubInspect: true, + portainer.OperationPortainerEndpointGroupList: true, + portainer.OperationPortainerEndpointList: true, + portainer.OperationPortainerEndpointInspect: true, + portainer.OperationPortainerEndpointExtensionAdd: true, + portainer.OperationPortainerEndpointExtensionRemove: true, + portainer.OperationPortainerExtensionList: true, + portainer.OperationPortainerMOTD: true, + portainer.OperationPortainerRegistryList: true, + portainer.OperationPortainerRegistryInspect: true, + portainer.OperationPortainerTeamList: true, + portainer.OperationPortainerTemplateList: true, + portainer.OperationPortainerTemplateInspect: true, + portainer.OperationPortainerUserList: true, + portainer.OperationPortainerUserMemberships: true, + } + + err = m.userService.Update(user.ID, &user) + if err != nil { + return err + } + } + + return nil +} + +func (m *Migrator) updateEndpointsToDBVersion18() error { + log.Info().Msg("updating endpoints") + + legacyEndpoints, err := m.endpointService.Endpoints() + if err != nil { + return err + } + + for _, endpoint := range legacyEndpoints { + endpoint.UserAccessPolicies = make(portainer.UserAccessPolicies) + for _, userID := range endpoint.AuthorizedUsers { + endpoint.UserAccessPolicies[userID] = portainer.AccessPolicy{ + RoleID: 4, + } + } + + endpoint.TeamAccessPolicies = make(portainer.TeamAccessPolicies) + for _, teamID := range endpoint.AuthorizedTeams { + endpoint.TeamAccessPolicies[teamID] = portainer.AccessPolicy{ + RoleID: 4, + } + } + + err = m.endpointService.UpdateEndpoint(endpoint.ID, &endpoint) + if err != nil { + return err + } + } + + return nil +} + +func (m *Migrator) updateEndpointGroupsToDBVersion18() error { + log.Info().Msg("updating endpoint groups") + + legacyEndpointGroups, err := m.endpointGroupService.ReadAll() + if err != nil { + return err + } + + for _, endpointGroup := range legacyEndpointGroups { + endpointGroup.UserAccessPolicies = make(portainer.UserAccessPolicies) + for _, userID := range endpointGroup.AuthorizedUsers { + endpointGroup.UserAccessPolicies[userID] = portainer.AccessPolicy{ + RoleID: 4, + } + } + + endpointGroup.TeamAccessPolicies = make(portainer.TeamAccessPolicies) + for _, teamID := range endpointGroup.AuthorizedTeams { + endpointGroup.TeamAccessPolicies[teamID] = portainer.AccessPolicy{ + RoleID: 4, + } + } + + err = m.endpointGroupService.Update(endpointGroup.ID, &endpointGroup) + if err != nil { + return err + } + } + + return nil +} + +func (m *Migrator) updateRegistriesToDBVersion18() error { + log.Info().Msg("updating registries") + + legacyRegistries, err := m.registryService.ReadAll() + if err != nil { + return err + } + + for _, registry := range legacyRegistries { + registry.UserAccessPolicies = make(portainer.UserAccessPolicies) + for _, userID := range registry.AuthorizedUsers { + registry.UserAccessPolicies[userID] = portainer.AccessPolicy{} + } + + registry.TeamAccessPolicies = make(portainer.TeamAccessPolicies) + for _, teamID := range registry.AuthorizedTeams { + registry.TeamAccessPolicies[teamID] = portainer.AccessPolicy{} + } + + err = m.registryService.Update(registry.ID, ®istry) + if err != nil { + return err + } + } + + return nil +} diff --git a/api/datastore/migrator/migrate_dbversion18.go b/api/datastore/migrator/migrate_dbversion18.go new file mode 100644 index 0000000..9270694 --- /dev/null +++ b/api/datastore/migrator/migrate_dbversion18.go @@ -0,0 +1,22 @@ +package migrator + +import ( + portainer "github.com/portainer/portainer/api" + + "github.com/rs/zerolog/log" +) + +func (m *Migrator) updateSettingsToDBVersion19() error { + log.Info().Msg("updating settings") + + legacySettings, err := m.settingsService.Settings() + if err != nil { + return err + } + + if legacySettings.EdgeAgentCheckinInterval == 0 { + legacySettings.EdgeAgentCheckinInterval = portainer.DefaultEdgeAgentCheckinIntervalInSeconds + } + + return m.settingsService.UpdateSettings(legacySettings) +} diff --git a/api/datastore/migrator/migrate_dbversion19.go b/api/datastore/migrator/migrate_dbversion19.go new file mode 100644 index 0000000..78375cd --- /dev/null +++ b/api/datastore/migrator/migrate_dbversion19.go @@ -0,0 +1,64 @@ +package migrator + +import ( + "strings" + + "github.com/rs/zerolog/log" +) + +const scheduleScriptExecutionJobType = 1 + +func (m *Migrator) updateUsersToDBVersion20() error { + log.Info().Msg("updating user authentication") + + return m.authorizationService.UpdateUsersAuthorizations() +} + +func (m *Migrator) updateSettingsToDBVersion20() error { + log.Info().Msg("updating settings") + legacySettings, err := m.settingsService.Settings() + if err != nil { + return err + } + + legacySettings.AllowVolumeBrowserForRegularUsers = false + + return m.settingsService.UpdateSettings(legacySettings) +} + +func (m *Migrator) updateSchedulesToDBVersion20() error { + log.Info().Msg("updating schedules") + + legacySchedules, err := m.scheduleService.Schedules() + if err != nil { + return err + } + + for _, schedule := range legacySchedules { + if schedule.JobType == scheduleScriptExecutionJobType { + if schedule.CronExpression == "0 0 * * *" { + schedule.CronExpression = "0 * * * *" + } else if schedule.CronExpression == "0 0 0/2 * *" { + schedule.CronExpression = "0 */2 * * *" + } else if schedule.CronExpression == "0 0 0 * *" { + schedule.CronExpression = "0 0 * * *" + } else { + revisedCronExpression := strings.Split(schedule.CronExpression, " ") + if len(revisedCronExpression) == 5 { + continue + } + + revisedCronExpression = revisedCronExpression[1:] + schedule.CronExpression = strings.Join(revisedCronExpression, " ") + } + + err := m.scheduleService.UpdateSchedule(schedule.ID, &schedule) + if err != nil { + return err + } + } + + } + + return nil +} diff --git a/api/datastore/migrator/migrate_dbversion20.go b/api/datastore/migrator/migrate_dbversion20.go new file mode 100644 index 0000000..bad999c --- /dev/null +++ b/api/datastore/migrator/migrate_dbversion20.go @@ -0,0 +1,99 @@ +package migrator + +import ( + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/internal/authorization" + + "github.com/rs/zerolog/log" +) + +func (m *Migrator) updateResourceControlsToDBVersion22() error { + log.Info().Msg("updating resource controls") + + legacyResourceControls, err := m.resourceControlService.ReadAll() + if err != nil { + return err + } + + for _, resourceControl := range legacyResourceControls { + resourceControl.AdministratorsOnly = false + + if err := m.resourceControlService.Update(resourceControl.ID, &resourceControl); err != nil { + return err + } + } + + return nil +} + +func (m *Migrator) updateUsersAndRolesToDBVersion22() error { + log.Info().Msg("updating users and roles") + + legacyUsers, err := m.userService.ReadAll() + if err != nil { + return err + } + + settings, err := m.settingsService.Settings() + if err != nil { + return err + } + + for _, user := range legacyUsers { + user.PortainerAuthorizations = authorization.DefaultPortainerAuthorizations() + + if err := m.userService.Update(user.ID, &user); err != nil { + return err + } + } + + endpointAdministratorRole, err := m.roleService.Read(portainer.RoleID(1)) + if err != nil { + return err + } + + endpointAdministratorRole.Priority = 1 + endpointAdministratorRole.Authorizations = authorization.DefaultEndpointAuthorizationsForEndpointAdministratorRole() + + if err := m.roleService.Update(endpointAdministratorRole.ID, endpointAdministratorRole); err != nil { + return err + } + + helpDeskRole, err := m.roleService.Read(portainer.RoleID(2)) + if err != nil { + return err + } + + helpDeskRole.Priority = 2 + helpDeskRole.Authorizations = authorization.DefaultEndpointAuthorizationsForHelpDeskRole(settings.AllowVolumeBrowserForRegularUsers) + + if err := m.roleService.Update(helpDeskRole.ID, helpDeskRole); err != nil { + return err + } + + standardUserRole, err := m.roleService.Read(portainer.RoleID(3)) + if err != nil { + return err + } + + standardUserRole.Priority = 3 + standardUserRole.Authorizations = authorization.DefaultEndpointAuthorizationsForStandardUserRole(settings.AllowVolumeBrowserForRegularUsers) + + if err := m.roleService.Update(standardUserRole.ID, standardUserRole); err != nil { + return err + } + + readOnlyUserRole, err := m.roleService.Read(portainer.RoleID(4)) + if err != nil { + return err + } + + readOnlyUserRole.Priority = 4 + readOnlyUserRole.Authorizations = authorization.DefaultEndpointAuthorizationsForReadOnlyUserRole(settings.AllowVolumeBrowserForRegularUsers) + + if err := m.roleService.Update(readOnlyUserRole.ID, readOnlyUserRole); err != nil { + return err + } + + return m.authorizationService.UpdateUsersAuthorizations() +} diff --git a/api/datastore/migrator/migrate_dbversion22.go b/api/datastore/migrator/migrate_dbversion22.go new file mode 100644 index 0000000..ccf4665 --- /dev/null +++ b/api/datastore/migrator/migrate_dbversion22.go @@ -0,0 +1,102 @@ +package migrator + +import ( + portainer "github.com/portainer/portainer/api" + + "github.com/rs/zerolog/log" +) + +func (m *Migrator) updateTagsToDBVersion23() error { + log.Info().Msg("updating tags") + + tags, err := m.tagService.ReadAll() + if err != nil { + return err + } + + for _, tag := range tags { + tag.EndpointGroups = make(map[portainer.EndpointGroupID]bool) + tag.Endpoints = make(map[portainer.EndpointID]bool) + err = m.tagService.Update(tag.ID, &tag) + if err != nil { + return err + } + } + + return nil +} + +func (m *Migrator) updateEndpointsAndEndpointGroupsToDBVersion23() error { + log.Info().Msg("updating endpoints and endpoint groups") + + tags, err := m.tagService.ReadAll() + if err != nil { + return err + } + + tagsNameMap := make(map[string]portainer.Tag) + for _, tag := range tags { + tagsNameMap[tag.Name] = tag + } + + endpoints, err := m.endpointService.Endpoints() + if err != nil { + return err + } + + for _, endpoint := range endpoints { + endpointTags := make([]portainer.TagID, 0) + for _, tagName := range endpoint.Tags { + tag, ok := tagsNameMap[tagName] + if ok { + endpointTags = append(endpointTags, tag.ID) + tag.Endpoints[endpoint.ID] = true + } + } + endpoint.TagIDs = endpointTags + err = m.endpointService.UpdateEndpoint(endpoint.ID, &endpoint) + if err != nil { + return err + } + + relation := &portainer.EndpointRelation{ + EndpointID: endpoint.ID, + EdgeStacks: map[portainer.EdgeStackID]bool{}, + } + + err = m.endpointRelationService.Create(relation) + if err != nil { + return err + } + } + + endpointGroups, err := m.endpointGroupService.ReadAll() + if err != nil { + return err + } + + for _, endpointGroup := range endpointGroups { + endpointGroupTags := make([]portainer.TagID, 0) + for _, tagName := range endpointGroup.Tags { + tag, ok := tagsNameMap[tagName] + if ok { + endpointGroupTags = append(endpointGroupTags, tag.ID) + tag.EndpointGroups[endpointGroup.ID] = true + } + } + endpointGroup.TagIDs = endpointGroupTags + err = m.endpointGroupService.Update(endpointGroup.ID, &endpointGroup) + if err != nil { + return err + } + } + + for _, tag := range tagsNameMap { + err = m.tagService.Update(tag.ID, &tag) + if err != nil { + return err + } + } + + return nil +} diff --git a/api/datastore/migrator/migrate_dbversion23.go b/api/datastore/migrator/migrate_dbversion23.go new file mode 100644 index 0000000..f501058 --- /dev/null +++ b/api/datastore/migrator/migrate_dbversion23.go @@ -0,0 +1,42 @@ +package migrator + +import ( + portainer "github.com/portainer/portainer/api" + + "github.com/rs/zerolog/log" +) + +func (m *Migrator) updateSettingsToDB24() error { + log.Info().Msg("updating Settings") + + 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 { + log.Info().Msg("updating stacks") + + stacks, err := m.stackService.ReadAll() + if err != nil { + return err + } + + for idx := range stacks { + stack := &stacks[idx] + stack.Status = portainer.StackStatusActive + + if err := m.stackService.Update(stack.ID, stack); err != nil { + return err + } + } + + return nil +} diff --git a/api/datastore/migrator/migrate_dbversion24.go b/api/datastore/migrator/migrate_dbversion24.go new file mode 100644 index 0000000..33a0bab --- /dev/null +++ b/api/datastore/migrator/migrate_dbversion24.go @@ -0,0 +1,28 @@ +package migrator + +import ( + portainer "github.com/portainer/portainer/api" + + "github.com/rs/zerolog/log" +) + +func (m *Migrator) updateSettingsToDB25() error { + log.Info().Msg("updating settings") + + legacySettings, err := m.settingsService.Settings() + if err != nil { + return err + } + + // to keep the same migration functionality as before 2.20.0, we need to set the templates URL to v2 + version2URL := "https://raw.githubusercontent.com/portainer/templates/master/templates-2.0.json" + if legacySettings.TemplatesURL == "" { + legacySettings.TemplatesURL = version2URL + } + + legacySettings.UserSessionTimeout = portainer.DefaultUserSessionTimeout + + legacySettings.AllowContainerCapabilitiesForRegularUsers = true + + return m.settingsService.UpdateSettings(legacySettings) +} diff --git a/api/datastore/migrator/migrate_dbversion25.go b/api/datastore/migrator/migrate_dbversion25.go new file mode 100644 index 0000000..dfaaa12 --- /dev/null +++ b/api/datastore/migrator/migrate_dbversion25.go @@ -0,0 +1,55 @@ +package migrator + +import ( + portainer "github.com/portainer/portainer/api" + + "github.com/rs/zerolog/log" +) + +func (m *Migrator) updateEndpointSettingsToDB25() error { + log.Info().Msg("updating endpoint settings") + + settings, err := m.settingsService.Settings() + if err != nil { + return err + } + + endpoints, err := m.endpointService.Endpoints() + if err != nil { + return err + } + + for i := range endpoints { + endpoint := endpoints[i] + + securitySettings := portainer.EndpointSecuritySettings{} + + if endpoint.Type == portainer.EdgeAgentOnDockerEnvironment || + endpoint.Type == portainer.AgentOnDockerEnvironment || + endpoint.Type == portainer.DockerEnvironment { + + securitySettings = portainer.EndpointSecuritySettings{ + AllowBindMountsForRegularUsers: settings.AllowBindMountsForRegularUsers, + AllowContainerCapabilitiesForRegularUsers: settings.AllowContainerCapabilitiesForRegularUsers, + AllowDeviceMappingForRegularUsers: settings.AllowDeviceMappingForRegularUsers, + AllowHostNamespaceForRegularUsers: settings.AllowHostNamespaceForRegularUsers, + AllowPrivilegedModeForRegularUsers: settings.AllowPrivilegedModeForRegularUsers, + AllowStackManagementForRegularUsers: settings.AllowStackManagementForRegularUsers, + } + + if endpoint.Type == portainer.AgentOnDockerEnvironment || endpoint.Type == portainer.EdgeAgentOnDockerEnvironment { + securitySettings.AllowVolumeBrowserForRegularUsers = settings.AllowVolumeBrowserForRegularUsers + securitySettings.EnableHostManagementFeatures = settings.EnableHostManagementFeatures + } + } + + endpoint.SecuritySettings = securitySettings + + err = m.endpointService.UpdateEndpoint(endpoint.ID, &endpoint) + if err != nil { + return err + } + } + + return nil +} diff --git a/api/datastore/migrator/migrate_dbversion26.go b/api/datastore/migrator/migrate_dbversion26.go new file mode 100644 index 0000000..9e68be2 --- /dev/null +++ b/api/datastore/migrator/migrate_dbversion26.go @@ -0,0 +1,44 @@ +package migrator + +import ( + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/stacks/stackutils" + + "github.com/rs/zerolog/log" +) + +func (m *Migrator) updateStackResourceControlToDB27() error { + log.Info().Msg("updating stack resource controls") + + resourceControls, err := m.resourceControlService.ReadAll() + if err != nil { + return err + } + + for _, resource := range resourceControls { + if resource.Type != portainer.StackResourceControl { + continue + } + + stackName := resource.ResourceID + + stack, err := m.stackService.StackByName(stackName) + if err != nil { + if dataservices.IsErrObjectNotFound(err) { + continue + } + + return err + } + + resource.ResourceID = stackutils.ResourceControlID(stack.EndpointID, stack.Name) + + err = m.resourceControlService.Update(resource.ID, &resource) + if err != nil { + return err + } + } + + return nil +} diff --git a/api/datastore/migrator/migrate_dbversion29.go b/api/datastore/migrator/migrate_dbversion29.go new file mode 100644 index 0000000..ce54aef --- /dev/null +++ b/api/datastore/migrator/migrate_dbversion29.go @@ -0,0 +1,24 @@ +package migrator + +import "github.com/rs/zerolog/log" + +func (m *Migrator) migrateDBVersionToDB30() error { + log.Info().Msg("updating legacy settings") + + return m.MigrateSettingsToDB30() +} + +// so setting to false and "", is what would happen without this code +// I'm going to bet there's zero point to changing the value inthe DB +// Public for testing +func (m *Migrator) MigrateSettingsToDB30() error { + legacySettings, err := m.settingsService.Settings() + if err != nil { + return err + } + + legacySettings.OAuthSettings.SSO = false + legacySettings.OAuthSettings.LogoutURI = "" + + return m.settingsService.UpdateSettings(legacySettings) +} diff --git a/api/datastore/migrator/migrate_dbversion31.go b/api/datastore/migrator/migrate_dbversion31.go new file mode 100644 index 0000000..09e7690 --- /dev/null +++ b/api/datastore/migrator/migrate_dbversion31.go @@ -0,0 +1,285 @@ +package migrator + +import ( + "fmt" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/internal/endpointutils" + snapshotutils "github.com/portainer/portainer/api/internal/snapshot" + + "github.com/docker/docker/api/types/volume" + "github.com/rs/zerolog/log" +) + +func (m *Migrator) migrateDBVersionToDB32() error { + err := m.updateRegistriesToDB32() + if err != nil { + return err + } + + err = m.updateDockerhubToDB32() + if err != nil { + return err + } + + if err := m.updateVolumeResourceControlToDB32(); err != nil { + return err + } + + if err := m.kubeconfigExpiryToDB32(); err != nil { + return err + } + + if err := m.helmRepositoryURLToDB32(); err != nil { + return err + } + + return nil +} + +func (m *Migrator) updateRegistriesToDB32() error { + log.Info().Msg("updating registries") + + registries, err := m.registryService.ReadAll() + if err != nil { + return err + } + + endpoints, err := m.endpointService.Endpoints() + if err != nil { + return err + } + + for _, registry := range registries { + + registry.RegistryAccesses = portainer.RegistryAccesses{} + + for _, endpoint := range endpoints { + + filteredUserAccessPolicies := portainer.UserAccessPolicies{} + for userId, registryPolicy := range registry.UserAccessPolicies { + if _, found := endpoint.UserAccessPolicies[userId]; found { + filteredUserAccessPolicies[userId] = registryPolicy + } + } + + filteredTeamAccessPolicies := portainer.TeamAccessPolicies{} + for teamId, registryPolicy := range registry.TeamAccessPolicies { + if _, found := endpoint.TeamAccessPolicies[teamId]; found { + filteredTeamAccessPolicies[teamId] = registryPolicy + } + } + + registry.RegistryAccesses[endpoint.ID] = portainer.RegistryAccessPolicies{ + UserAccessPolicies: filteredUserAccessPolicies, + TeamAccessPolicies: filteredTeamAccessPolicies, + Namespaces: []string{}, + } + } + + if err := m.registryService.Update(registry.ID, ®istry); err != nil { + return err + } + } + + return nil +} + +func (m *Migrator) updateDockerhubToDB32() error { + log.Info().Msg("updating dockerhub") + + dockerhub, err := m.dockerhubService.DockerHub() + if dataservices.IsErrObjectNotFound(err) { + return nil + } else if err != nil { + return err + } + + if !dockerhub.Authentication { + return nil + } + + registry := &portainer.Registry{ + Type: portainer.DockerHubRegistry, + Name: "Dockerhub (authenticated - migrated)", + URL: "docker.io", + Authentication: true, + Username: dockerhub.Username, + Password: dockerhub.Password, + RegistryAccesses: portainer.RegistryAccesses{}, + } + + // The following code will make this function idempotent. + // i.e. if run again, it will not change the data. It will ensure that + // we only have one migrated registry entry. Duplicates will be removed + // if they exist and which has been happening due to earlier migration bugs + migrated := false + registries, _ := m.registryService.ReadAll() + for _, r := range registries { + if r.Type == registry.Type && + r.Name == registry.Name && + r.URL == registry.URL && + r.Authentication == registry.Authentication { + + if !migrated { + // keep this one entry + migrated = true + // delete subsequent duplicates + } else if err := m.registryService.Delete(r.ID); err != nil { + return err + } + + } + } + + if migrated { + return nil + } + + endpoints, err := m.endpointService.Endpoints() + if err != nil { + return err + } + + for _, endpoint := range endpoints { + if endpoint.Type != portainer.KubernetesLocalEnvironment && + endpoint.Type != portainer.AgentOnKubernetesEnvironment && + endpoint.Type != portainer.EdgeAgentOnKubernetesEnvironment { + + userAccessPolicies := portainer.UserAccessPolicies{} + for userId := range endpoint.UserAccessPolicies { + if _, found := endpoint.UserAccessPolicies[userId]; found { + userAccessPolicies[userId] = portainer.AccessPolicy{RoleID: 0} + } + } + + teamAccessPolicies := portainer.TeamAccessPolicies{} + for teamId := range endpoint.TeamAccessPolicies { + if _, found := endpoint.TeamAccessPolicies[teamId]; found { + teamAccessPolicies[teamId] = portainer.AccessPolicy{RoleID: 0} + } + } + + registry.RegistryAccesses[endpoint.ID] = portainer.RegistryAccessPolicies{ + UserAccessPolicies: userAccessPolicies, + TeamAccessPolicies: teamAccessPolicies, + Namespaces: []string{}, + } + } + } + + return m.registryService.Create(registry) +} + +func (m *Migrator) updateVolumeResourceControlToDB32() error { + log.Info().Msg("updating resource controls") + + endpoints, err := m.endpointService.Endpoints() + if err != nil { + return fmt.Errorf("failed fetching environments: %w", err) + } + + resourceControls, err := m.resourceControlService.ReadAll() + if err != nil { + return fmt.Errorf("failed fetching resource controls: %w", err) + } + + toUpdate := map[portainer.ResourceControlID]string{} + volumeResourceControls := map[string]*portainer.ResourceControl{} + + for i := range resourceControls { + resourceControl := resourceControls[i] + if resourceControl.Type == portainer.VolumeResourceControl { + volumeResourceControls[resourceControl.ResourceID] = &resourceControl + } + } + + for _, endpoint := range endpoints { + if !endpointutils.IsDockerEndpoint(&endpoint) { + continue + } + + totalSnapshots := len(endpoint.Snapshots) + if totalSnapshots == 0 { + log.Debug().Msg("no snapshot found") + continue + } + + snapshot := endpoint.Snapshots[totalSnapshots-1] + + endpointDockerID, err := snapshotutils.FetchDockerID(snapshot) + if err != nil { + log.Warn().Err(err).Msg("failed fetching environment docker id") + continue + } + + volumesData := snapshot.SnapshotRaw.Volumes + if volumesData.Volumes == nil { + log.Debug().Msg("no volume data found") + continue + } + + findResourcesToUpdateForDB32(endpointDockerID, volumesData, toUpdate, volumeResourceControls) + + } + + for _, resourceControl := range volumeResourceControls { + if newResourceID, ok := toUpdate[resourceControl.ID]; ok { + resourceControl.ResourceID = newResourceID + + err := m.resourceControlService.Update(resourceControl.ID, resourceControl) + if err != nil { + return fmt.Errorf("failed updating resource control %d: %w", resourceControl.ID, err) + } + } else { + err := m.resourceControlService.Delete(resourceControl.ID) + if err != nil { + return fmt.Errorf("failed deleting resource control %d: %w", resourceControl.ID, err) + } + + log.Debug().Str("resource_id", resourceControl.ResourceID).Msg("legacy resource control has been deleted") + } + } + + return nil +} + +func findResourcesToUpdateForDB32(dockerID string, volumesData volume.ListResponse, toUpdate map[portainer.ResourceControlID]string, volumeResourceControls map[string]*portainer.ResourceControl) { + volumes := volumesData.Volumes + for _, volume := range volumes { + volumeName := volume.Name + createTime := volume.CreatedAt + + oldResourceID := fmt.Sprintf("%s%s", volumeName, createTime) + resourceControl, ok := volumeResourceControls[oldResourceID] + + if ok { + toUpdate[resourceControl.ID] = fmt.Sprintf("%s_%s", volumeName, dockerID) + } + } +} + +func (m *Migrator) kubeconfigExpiryToDB32() error { + log.Info().Msg("updating kubeconfig expiry") + + settings, err := m.settingsService.Settings() + if err != nil { + return err + } + + settings.KubeconfigExpiry = portainer.DefaultKubeconfigExpiry + return m.settingsService.UpdateSettings(settings) +} + +func (m *Migrator) helmRepositoryURLToDB32() error { + log.Info().Msg("setting default helm repository URL") + + settings, err := m.settingsService.Settings() + if err != nil { + return err + } + + settings.HelmRepositoryURL = portainer.DefaultHelmRepositoryURL + return m.settingsService.UpdateSettings(settings) +} diff --git a/api/datastore/migrator/migrate_dbversion32.go b/api/datastore/migrator/migrate_dbversion32.go new file mode 100644 index 0000000..391e01b --- /dev/null +++ b/api/datastore/migrator/migrate_dbversion32.go @@ -0,0 +1,24 @@ +package migrator + +import ( + "github.com/rs/zerolog/log" +) + +func (m *Migrator) migrateDBVersionToDB33() error { + log.Info().Msg("updating settings") + + return m.migrateSettingsToDB33() +} + +func (m *Migrator) migrateSettingsToDB33() error { + log.Info().Msg("setting default kubctl shell") + settings, err := m.settingsService.Settings() + if err != nil { + return err + } + + log.Info().Msg("setting default kubectl shell image") + settings.KubectlShellImage = *m.flags.KubectlShellImage + + return m.settingsService.UpdateSettings(settings) +} diff --git a/api/datastore/migrator/migrate_dbversion33.go b/api/datastore/migrator/migrate_dbversion33.go new file mode 100644 index 0000000..1268298 --- /dev/null +++ b/api/datastore/migrator/migrate_dbversion33.go @@ -0,0 +1,35 @@ +package migrator + +import ( + "github.com/portainer/portainer/api/dataservices" + + "github.com/rs/zerolog/log" +) + +func (m *Migrator) migrateDBVersionToDB34() error { + log.Info().Msg("updating stacks") + + return MigrateStackEntryPoint(m.stackService) +} + +// MigrateStackEntryPoint exported for testing +func MigrateStackEntryPoint(stackService dataservices.StackService) error { + stacks, err := stackService.ReadAll() + if err != nil { + return err + } + + for i := range stacks { + stack := &stacks[i] + if stack.GitConfig == nil { + continue + } + + stack.GitConfig.ConfigFilePath = stack.EntryPoint + if err := stackService.Update(stack.ID, stack); err != nil { + return err + } + } + + return nil +} diff --git a/api/datastore/migrator/migrate_dbversion34.go b/api/datastore/migrator/migrate_dbversion34.go new file mode 100644 index 0000000..870194e --- /dev/null +++ b/api/datastore/migrator/migrate_dbversion34.go @@ -0,0 +1,7 @@ +package migrator + +func (m *Migrator) migrateDBVersionToDB35() error { + // These should have been migrated already, but due to an earlier bug and a bunch of duplicates, + // calling it again will now fix the issue as the function has been repaired. + return m.updateDockerhubToDB32() +} diff --git a/api/datastore/migrator/migrate_dbversion35.go b/api/datastore/migrator/migrate_dbversion35.go new file mode 100644 index 0000000..726ecfb --- /dev/null +++ b/api/datastore/migrator/migrate_dbversion35.go @@ -0,0 +1,37 @@ +package migrator + +import ( + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/internal/authorization" + + "github.com/rs/zerolog/log" +) + +func (m *Migrator) migrateDBVersionToDB36() error { + log.Info().Msg("updating user authorizations") + + return m.migrateUsersToDB36() +} + +func (m *Migrator) migrateUsersToDB36() error { + log.Info().Msg("updating user authorizations") + + users, err := m.userService.ReadAll() + if err != nil { + return err + } + + for _, user := range users { + currentAuthorizations := authorization.DefaultPortainerAuthorizations() + currentAuthorizations[portainer.OperationPortainerUserListToken] = true + currentAuthorizations[portainer.OperationPortainerUserCreateToken] = true + currentAuthorizations[portainer.OperationPortainerUserRevokeToken] = true + user.PortainerAuthorizations = currentAuthorizations + err = m.userService.Update(user.ID, &user) + if err != nil { + return err + } + } + + return nil +} diff --git a/api/datastore/migrator/migrate_dbversion40.go b/api/datastore/migrator/migrate_dbversion40.go new file mode 100644 index 0000000..a33e1d2 --- /dev/null +++ b/api/datastore/migrator/migrate_dbversion40.go @@ -0,0 +1,32 @@ +package migrator + +import ( + "github.com/portainer/portainer/api/internal/endpointutils" + + "github.com/rs/zerolog/log" +) + +func (m *Migrator) migrateDBVersionToDB40() error { + return m.trustCurrentEdgeEndpointsDB40() +} + +func (m *Migrator) trustCurrentEdgeEndpointsDB40() error { + log.Info().Msg("trusting current edge endpoints") + + endpoints, err := m.endpointService.Endpoints() + if err != nil { + return err + } + + for _, endpoint := range endpoints { + if endpointutils.IsEdgeEndpoint(&endpoint) { + endpoint.UserTrusted = true + err = m.endpointService.UpdateEndpoint(endpoint.ID, &endpoint) + if err != nil { + return err + } + } + } + + return nil +} diff --git a/api/datastore/migrator/migrate_dbversion50.go b/api/datastore/migrator/migrate_dbversion50.go new file mode 100644 index 0000000..8a58f4e --- /dev/null +++ b/api/datastore/migrator/migrate_dbversion50.go @@ -0,0 +1,22 @@ +package migrator + +import ( + "github.com/pkg/errors" + "github.com/rs/zerolog/log" +) + +func (m *Migrator) migrateDBVersionToDB50() error { + return m.migratePasswordLengthSettings() +} + +func (m *Migrator) migratePasswordLengthSettings() error { + log.Info().Msg("updating required password length") + + s, err := m.settingsService.Settings() + if err != nil { + return errors.Wrap(err, "unable to retrieve settings") + } + + s.InternalAuthSettings.RequiredPasswordLength = 12 + return m.settingsService.UpdateSettings(s) +} diff --git a/api/datastore/migrator/migrate_dbversion60.go b/api/datastore/migrator/migrate_dbversion60.go new file mode 100644 index 0000000..fc7fa50 --- /dev/null +++ b/api/datastore/migrator/migrate_dbversion60.go @@ -0,0 +1,32 @@ +package migrator + +import ( + portainer "github.com/portainer/portainer/api" + + "github.com/rs/zerolog/log" +) + +func (m *Migrator) migrateDBVersionToDB60() error { + return m.addGpuInputFieldDB60() +} + +func (m *Migrator) addGpuInputFieldDB60() error { + log.Info().Msg("add gpu input field") + + endpoints, err := m.endpointService.Endpoints() + if err != nil { + return err + } + + for _, endpoint := range endpoints { + if endpoint.Gpus == nil { + endpoint.Gpus = []portainer.Pair{} + err = m.endpointService.UpdateEndpoint(endpoint.ID, &endpoint) + if err != nil { + return err + } + } + } + + return nil +} diff --git a/api/datastore/migrator/migrate_dbversion70.go b/api/datastore/migrator/migrate_dbversion70.go new file mode 100644 index 0000000..0106cde --- /dev/null +++ b/api/datastore/migrator/migrate_dbversion70.go @@ -0,0 +1,71 @@ +package migrator + +import ( + portainer "github.com/portainer/portainer/api" + + "github.com/rs/zerolog/log" +) + +func (m *Migrator) migrateDBVersionToDB70() error { + log.Info().Msg("add IngressAvailabilityPerNamespace field") + if err := m.updateIngressFieldsForEnvDB70(); err != nil { + return err + } + + endpoints, err := m.endpointService.Endpoints() + if err != nil { + return err + } + + for _, endpoint := range endpoints { + // copy snapshots to new object + log.Info().Msg("moving snapshots from endpoint to new object") + snapshot := portainer.Snapshot{EndpointID: endpoint.ID} + + if len(endpoint.Snapshots) > 0 { + snapshot.Docker = &endpoint.Snapshots[len(endpoint.Snapshots)-1] + } + + if len(endpoint.Kubernetes.Snapshots) > 0 { + snapshot.Kubernetes = &endpoint.Kubernetes.Snapshots[len(endpoint.Kubernetes.Snapshots)-1] + } + + // save new object + err = m.snapshotService.Create(&snapshot) + if err != nil { + return err + } + + // set to nil old fields + log.Info().Msg("deleting snapshot from endpoint") + endpoint.Snapshots = []portainer.DockerSnapshot{} + endpoint.Kubernetes.Snapshots = []portainer.KubernetesSnapshot{} + + // update endpoint + err = m.endpointService.UpdateEndpoint(endpoint.ID, &endpoint) + if err != nil { + return err + } + } + + return nil +} + +func (m *Migrator) updateIngressFieldsForEnvDB70() error { + endpoints, err := m.endpointService.Endpoints() + if err != nil { + return err + } + + for _, endpoint := range endpoints { + endpoint.Kubernetes.Configuration.IngressAvailabilityPerNamespace = true + endpoint.Kubernetes.Configuration.AllowNoneIngressClass = false + endpoint.PostInitMigrations.MigrateIngresses = true + + err = m.endpointService.UpdateEndpoint(endpoint.ID, &endpoint) + if err != nil { + return err + } + } + return nil +} diff --git a/api/datastore/migrator/migrate_dbversion71.go b/api/datastore/migrator/migrate_dbversion71.go new file mode 100644 index 0000000..d1081a8 --- /dev/null +++ b/api/datastore/migrator/migrate_dbversion71.go @@ -0,0 +1,36 @@ +package migrator + +import ( + "github.com/portainer/portainer/api/dataservices" + + "github.com/rs/zerolog/log" +) + +func (m *Migrator) migrateDBVersionToDB71() error { + log.Info().Msg("removing orphaned snapshots") + + snapshots, err := m.snapshotService.ReadAll() + if err != nil { + return err + } + + for _, s := range snapshots { + _, err := m.endpointService.Endpoint(s.EndpointID) + if err == nil { + log.Debug().Int("endpoint_id", int(s.EndpointID)).Msg("keeping snapshot") + continue + } else if !dataservices.IsErrObjectNotFound(err) { + log.Debug().Int("endpoint_id", int(s.EndpointID)).Err(err).Msg("database error") + return err + } + + log.Debug().Int("endpoint_id", int(s.EndpointID)).Msg("removing snapshot") + + err = m.snapshotService.Delete(s.EndpointID) + if err != nil { + return err + } + } + + return nil +} diff --git a/api/datastore/migrator/migrate_dbversion80.go b/api/datastore/migrator/migrate_dbversion80.go new file mode 100644 index 0000000..a738e51 --- /dev/null +++ b/api/datastore/migrator/migrate_dbversion80.go @@ -0,0 +1,106 @@ +package migrator + +import ( + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/internal/endpointutils" + + "github.com/rs/zerolog/log" +) + +func (m *Migrator) migrateDBVersionToDB80() error { + if err := m.updateEdgeStackStatusForDB80(); err != nil { + return err + } + + if err := m.updateExistingEndpointsToNotDetectMetricsAPIForDB80(); err != nil { + return err + } + + if err := m.updateExistingEndpointsToNotDetectStorageAPIForDB80(); err != nil { + return err + } + + return nil +} + +func (m *Migrator) updateExistingEndpointsToNotDetectMetricsAPIForDB80() error { + log.Info().Msg("updating existing endpoints to not detect metrics API for existing endpoints (k8s)") + + endpoints, err := m.endpointService.Endpoints() + if err != nil { + return err + } + + for _, endpoint := range endpoints { + if endpointutils.IsKubernetesEndpoint(&endpoint) { + endpoint.Kubernetes.Flags.IsServerMetricsDetected = true + err = m.endpointService.UpdateEndpoint(endpoint.ID, &endpoint) + if err != nil { + return err + } + } + } + + return nil +} + +func (m *Migrator) updateExistingEndpointsToNotDetectStorageAPIForDB80() error { + log.Info().Msg("updating existing endpoints to not detect metrics API for existing endpoints (k8s)") + + endpoints, err := m.endpointService.Endpoints() + if err != nil { + return err + } + + for _, endpoint := range endpoints { + if endpointutils.IsKubernetesEndpoint(&endpoint) { + endpoint.Kubernetes.Flags.IsServerStorageDetected = true + err = m.endpointService.UpdateEndpoint(endpoint.ID, &endpoint) + if err != nil { + return err + } + } + } + + return nil +} + +func (m *Migrator) updateEdgeStackStatusForDB80() error { + log.Info().Msg("transfer type field to details field for edge stack status") + + edgeStacks, err := m.edgeStackService.EdgeStacks() + if err != nil { + return err + } + + for _, edgeStack := range edgeStacks { + for endpointId, status := range edgeStack.Status { + if status.Details == nil { + status.Details = &portainer.EdgeStackStatusDetails{} + } + + switch status.Type { + case portainer.EdgeStackStatusPending: + status.Details.Pending = true + case portainer.EdgeStackStatusDeploymentReceived: + status.Details.Ok = true + case portainer.EdgeStackStatusError: + status.Details.Error = true + case portainer.EdgeStackStatusAcknowledged: + status.Details.Acknowledged = true + case portainer.EdgeStackStatusRemoved: + status.Details.Remove = true + case portainer.EdgeStackStatusRemoteUpdateSuccess: + status.Details.RemoteUpdateSuccess = true + } + + edgeStack.Status[endpointId] = status + } + + if err := m.edgeStackService.UpdateEdgeStack(edgeStack.ID, &edgeStack); err != nil { + return err + } + } + + return nil +} diff --git a/api/datastore/migrator/migrate_dbversion90.go b/api/datastore/migrator/migrate_dbversion90.go new file mode 100644 index 0000000..129def8 --- /dev/null +++ b/api/datastore/migrator/migrate_dbversion90.go @@ -0,0 +1,92 @@ +package migrator + +import ( + "github.com/rs/zerolog/log" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" +) + +func (m *Migrator) migrateDBVersionToDB90() error { + if err := m.updateUserThemeForDB90(); err != nil { + return err + } + + if err := m.updateEnableGpuManagementFeatures(); err != nil { + return err + } + + return m.updateEdgeStackStatusForDB90() +} + +func (m *Migrator) updateEdgeStackStatusForDB90() error { + log.Info().Msg("clean up deleted endpoints from edge jobs") + + edgeJobs, err := m.edgeJobService.ReadAll() + if err != nil { + return err + } + + for _, edgeJob := range edgeJobs { + for endpointId := range edgeJob.Endpoints { + _, err := m.endpointService.Endpoint(endpointId) + if dataservices.IsErrObjectNotFound(err) { + delete(edgeJob.Endpoints, endpointId) + + err = m.edgeJobService.Update(edgeJob.ID, &edgeJob) + if err != nil { + return err + } + } + } + } + + return nil +} + +func (m *Migrator) updateUserThemeForDB90() error { + log.Info().Msg("updating existing user theme settings") + + users, err := m.userService.ReadAll() + if err != nil { + return err + } + + for i := range users { + user := &users[i] + if user.UserTheme != "" { + user.ThemeSettings.Color = user.UserTheme + } + + if err := m.userService.Update(user.ID, user); err != nil { + return err + } + } + + return nil +} + +func (m *Migrator) updateEnableGpuManagementFeatures() error { + // get all environments + environments, err := m.endpointService.Endpoints() + if err != nil { + return err + } + + for _, environment := range environments { + if environment.Type == portainer.DockerEnvironment { + // set the PostInitMigrations.MigrateGPUs to true on this environment to run the migration only on the 2.18 upgrade + environment.PostInitMigrations.MigrateGPUs = true + // if there's one or more gpu, set the EnableGpuManagement setting to true + gpuList := environment.Gpus + if len(gpuList) > 0 { + environment.EnableGPUManagement = true + } + // update the environment + if err := m.endpointService.UpdateEndpoint(environment.ID, &environment); err != nil { + return err + } + } + } + return nil +} diff --git a/api/datastore/migrator/migrator.go b/api/datastore/migrator/migrator.go new file mode 100644 index 0000000..f7fd38f --- /dev/null +++ b/api/datastore/migrator/migrator.go @@ -0,0 +1,294 @@ +package migrator + +import ( + "errors" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/database/models" + "github.com/portainer/portainer/api/dataservices/customtemplate" + "github.com/portainer/portainer/api/dataservices/dockerhub" + "github.com/portainer/portainer/api/dataservices/edgegroup" + "github.com/portainer/portainer/api/dataservices/edgejob" + "github.com/portainer/portainer/api/dataservices/edgestack" + "github.com/portainer/portainer/api/dataservices/edgestackstatus" + "github.com/portainer/portainer/api/dataservices/endpoint" + "github.com/portainer/portainer/api/dataservices/endpointgroup" + "github.com/portainer/portainer/api/dataservices/endpointrelation" + "github.com/portainer/portainer/api/dataservices/extension" + "github.com/portainer/portainer/api/dataservices/pendingactions" + "github.com/portainer/portainer/api/dataservices/registry" + "github.com/portainer/portainer/api/dataservices/resourcecontrol" + "github.com/portainer/portainer/api/dataservices/role" + "github.com/portainer/portainer/api/dataservices/schedule" + "github.com/portainer/portainer/api/dataservices/settings" + "github.com/portainer/portainer/api/dataservices/snapshot" + "github.com/portainer/portainer/api/dataservices/source" + "github.com/portainer/portainer/api/dataservices/stack" + "github.com/portainer/portainer/api/dataservices/tag" + "github.com/portainer/portainer/api/dataservices/teammembership" + "github.com/portainer/portainer/api/dataservices/tunnelserver" + "github.com/portainer/portainer/api/dataservices/user" + "github.com/portainer/portainer/api/dataservices/version" + "github.com/portainer/portainer/api/dataservices/workflow" + "github.com/portainer/portainer/api/internal/authorization" + + "github.com/Masterminds/semver/v3" + "github.com/rs/zerolog/log" +) + +type ( + // Migrator defines a service to migrate data after a Portainer version update. + Migrator struct { + flags *portainer.CLIFlags + currentDBVersion *models.Version + migrations []Migrations + + endpointGroupService *endpointgroup.Service + endpointService *endpoint.Service + endpointRelationService *endpointrelation.Service + extensionService *extension.Service + registryService *registry.Service + resourceControlService *resourcecontrol.Service + roleService *role.Service + scheduleService *schedule.Service + settingsService *settings.Service + snapshotService *snapshot.Service + stackService *stack.Service + tagService *tag.Service + teamMembershipService *teammembership.Service + userService *user.Service + versionService *version.Service + fileService portainer.FileService + authorizationService *authorization.Service + dockerhubService *dockerhub.Service + edgeStackService *edgestack.Service + edgeStackStatusService *edgestackstatus.Service + edgeJobService *edgejob.Service + edgeGroupService *edgegroup.Service + TunnelServerService *tunnelserver.Service + pendingActionsService *pendingactions.Service + customTemplateService *customtemplate.Service + sourceService *source.Service + workflowService *workflow.Service + } + + // MigratorParameters represents the required parameters to create a new Migrator instance. + MigratorParameters struct { + Flags *portainer.CLIFlags + CurrentDBVersion *models.Version + EndpointGroupService *endpointgroup.Service + EndpointService *endpoint.Service + EndpointRelationService *endpointrelation.Service + ExtensionService *extension.Service + RegistryService *registry.Service + ResourceControlService *resourcecontrol.Service + RoleService *role.Service + ScheduleService *schedule.Service + SettingsService *settings.Service + SnapshotService *snapshot.Service + StackService *stack.Service + TagService *tag.Service + TeamMembershipService *teammembership.Service + UserService *user.Service + VersionService *version.Service + FileService portainer.FileService + AuthorizationService *authorization.Service + DockerhubService *dockerhub.Service + EdgeStackService *edgestack.Service + EdgeStackStatusService *edgestackstatus.Service + EdgeJobService *edgejob.Service + EdgeGroupService *edgegroup.Service + TunnelServerService *tunnelserver.Service + PendingActionsService *pendingactions.Service + CustomTemplateService *customtemplate.Service + SourceService *source.Service + WorkflowService *workflow.Service + } +) + +// NewMigrator creates a new Migrator. +func NewMigrator(parameters *MigratorParameters) *Migrator { + migrator := &Migrator{ + flags: parameters.Flags, + currentDBVersion: parameters.CurrentDBVersion, + endpointGroupService: parameters.EndpointGroupService, + endpointService: parameters.EndpointService, + endpointRelationService: parameters.EndpointRelationService, + extensionService: parameters.ExtensionService, + registryService: parameters.RegistryService, + resourceControlService: parameters.ResourceControlService, + roleService: parameters.RoleService, + scheduleService: parameters.ScheduleService, + settingsService: parameters.SettingsService, + snapshotService: parameters.SnapshotService, + tagService: parameters.TagService, + teamMembershipService: parameters.TeamMembershipService, + stackService: parameters.StackService, + userService: parameters.UserService, + versionService: parameters.VersionService, + fileService: parameters.FileService, + authorizationService: parameters.AuthorizationService, + dockerhubService: parameters.DockerhubService, + edgeStackService: parameters.EdgeStackService, + edgeStackStatusService: parameters.EdgeStackStatusService, + edgeJobService: parameters.EdgeJobService, + edgeGroupService: parameters.EdgeGroupService, + TunnelServerService: parameters.TunnelServerService, + pendingActionsService: parameters.PendingActionsService, + customTemplateService: parameters.CustomTemplateService, + sourceService: parameters.SourceService, + workflowService: parameters.WorkflowService, + } + + migrator.initMigrations() + + return migrator +} + +func (m *Migrator) CurrentDBVersion() string { + return m.currentDBVersion.SchemaVersion +} + +func (m *Migrator) CurrentDBEdition() portainer.SoftwareEdition { + return portainer.SoftwareEdition(m.currentDBVersion.Edition) +} + +func (m *Migrator) CurrentSemanticDBVersion() *semver.Version { + v, err := semver.NewVersion(m.currentDBVersion.SchemaVersion) + if err != nil { + log.Fatal().Stack().Err(err).Msg("failed to parse current version") + } + + return v +} + +func (m *Migrator) addMigrations(v string, funcs ...func() error) { + m.migrations = append(m.migrations, Migrations{ + Version: semver.MustParse(v), + MigrationFuncs: funcs, + }) +} + +func (m *Migrator) LatestMigrations() Migrations { + return m.migrations[len(m.migrations)-1] +} + +func (m *Migrator) GetMigratorCountOfCurrentAPIVersion() int { + migratorCount := 0 + latestMigrations := m.LatestMigrations() + + if latestMigrations.Version.Equal(semver.MustParse(portainer.APIVersion)) { + migratorCount = len(latestMigrations.MigrationFuncs) + } + + return migratorCount +} + +// !NOTE: Migration funtions should ideally be idempotent. +// ! Which simply means the function can run over the same data many times but only transform it once. +// ! In practice this really just means an extra check or two to ensure we're not destroying valid data. +// ! This is not a hard rule though. Understand the limitations. A migration function may only run over +// ! the data more than once if a new migration function is added and the version of your database schema is +// ! the same. e.g. two developers working on the same version add two different functions for different things. +// ! This increases the migration funcs count and so they all run again. + +type Migrations struct { + Version *semver.Version + MigrationFuncs MigrationFuncs +} + +type MigrationFuncs []func() error + +func (m *Migrator) initMigrations() { + // !IMPORTANT: Do not be tempted to alter the order of these migrations. + // ! Even though one of them looks out of order. Caused by history related + // ! to maintaining two versions and releasing at different times + + m.addMigrations("1.0.0", dbTooOldError) // default version found after migration + + m.addMigrations("1.21", + m.updateUsersToDBVersion18, + m.updateEndpointsToDBVersion18, + m.updateEndpointGroupsToDBVersion18, + m.updateRegistriesToDBVersion18) + + m.addMigrations("1.22", m.updateSettingsToDBVersion19) + + m.addMigrations("1.22.1", + m.updateUsersToDBVersion20, + m.updateSettingsToDBVersion20, + m.updateSchedulesToDBVersion20) + + m.addMigrations("1.23", + m.updateResourceControlsToDBVersion22, + m.updateUsersAndRolesToDBVersion22) + + m.addMigrations("1.24", + m.updateTagsToDBVersion23, + m.updateEndpointsAndEndpointGroupsToDBVersion23) + + m.addMigrations("1.24.1", m.updateSettingsToDB24) + + m.addMigrations("2.0", + m.updateSettingsToDB25, + m.updateStacksToDB24) + + m.addMigrations("2.1", m.updateEndpointSettingsToDB25) + m.addMigrations("2.2", m.updateStackResourceControlToDB27) + m.addMigrations("2.6", m.migrateDBVersionToDB30) + m.addMigrations("2.9", m.migrateDBVersionToDB32) + m.addMigrations("2.9.2", m.migrateDBVersionToDB33) + m.addMigrations("2.10.0", m.migrateDBVersionToDB34) + m.addMigrations("2.9.3", m.migrateDBVersionToDB35) + m.addMigrations("2.12", m.migrateDBVersionToDB36) + m.addMigrations("2.13", m.migrateDBVersionToDB40) + m.addMigrations("2.14", m.migrateDBVersionToDB50) + m.addMigrations("2.15", m.migrateDBVersionToDB60) + m.addMigrations("2.16", m.migrateDBVersionToDB70) + m.addMigrations("2.16.1", m.migrateDBVersionToDB71) + m.addMigrations("2.17", m.migrateDBVersionToDB80) + m.addMigrations("2.18", m.migrateDBVersionToDB90) + m.addMigrations("2.19", + m.convertSeedToPrivateKeyForDB100, + m.migrateDockerDesktopExtensionSetting, + m.updateEdgeStackStatusForDB100, + ) + m.addMigrations("2.20", + m.updateAppTemplatesVersionForDB110, + m.updateResourceOverCommitToDB110, + ) + m.addMigrations("2.20.2", + m.cleanPendingActionsForDeletedEndpointsForDB111, + ) + m.addMigrations("2.22.0", + m.migratePendingActionsDataForDB130, + ) + + m.addMigrations("2.31.0", m.migrateEdgeStacksStatuses_2_31_0) + + m.addMigrations("2.32.0", m.addEndpointRelationForEdgeAgents_2_32_0) + + m.addMigrations("2.33.1", m.migrateEdgeGroupEndpointsToRoars_2_33_0) + + m.addMigrations("2.40.0", m.migrateRegistryAccessSASecrets_2_40_0) + + m.addMigrations("2.43.0", + m.migrateGitConfigToSources_2_43_0, + m.migrateCustomTemplateGitConfigToSources_2_43_0, + ) + + // WARNING: do not change migrations that have already been released! + + // Add new migrations above... + // One function per migration, each versions migration funcs in the same file. +} + +// Always is always run at the end of migrations +func (m *Migrator) Always() error { + // currently nothing to be done in CE... yet + return nil +} + +func dbTooOldError() error { + return errors.New("migrating from less than Portainer 1.21.0 is not supported, please contact Portainer support") +} diff --git a/api/datastore/pendingactions_test.go b/api/datastore/pendingactions_test.go new file mode 100644 index 0000000..3c4d40e --- /dev/null +++ b/api/datastore/pendingactions_test.go @@ -0,0 +1,104 @@ +package datastore + +import ( + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/pendingactions/actions" + "github.com/portainer/portainer/api/pendingactions/handlers" + "github.com/stretchr/testify/require" +) + +type cleanNAPWithOverridePolicies struct { + EndpointGroupID portainer.EndpointGroupID +} + +func Test_ConvertCleanNAPWithOverridePoliciesPayload(t *testing.T) { + t.Parallel() + t.Run("test ConvertCleanNAPWithOverridePoliciesPayload", func(t *testing.T) { + + _, store := MustNewTestStore(t, true, false) + defer func() { + err := store.Close() + require.NoError(t, err) + }() + + gid := portainer.EndpointGroupID(1) + + testData := []struct { + Name string + PendingAction portainer.PendingAction + Expected any + Err bool + }{ + { + Name: "test actiondata with EndpointGroupID 1", + PendingAction: handlers.NewCleanNAPWithOverridePolicies( + 1, + &gid, + ), + Expected: portainer.EndpointGroupID(1), + }, + { + Name: "test actionData nil", + PendingAction: handlers.NewCleanNAPWithOverridePolicies( + 2, + nil, + ), + Expected: nil, + }, + { + Name: "test actionData empty and expected error", + PendingAction: portainer.PendingAction{ + EndpointID: 2, + Action: actions.CleanNAPWithOverridePolicies, + ActionData: "", + }, + Expected: nil, + Err: true, + }, + } + + for _, d := range testData { + err := store.PendingActions().Create(&d.PendingAction) + if err != nil { + t.Error(err) + return + } + + pendingActions, err := store.PendingActions().ReadAll() + if err != nil { + t.Error(err) + return + } + + for _, endpointPendingAction := range pendingActions { + t.Run(d.Name, func(t *testing.T) { + if endpointPendingAction.Action == actions.CleanNAPWithOverridePolicies { + var payload cleanNAPWithOverridePolicies + + err := endpointPendingAction.UnmarshallActionData(&payload) + + if d.Err && err == nil { + t.Error(err) + } + + if d.Expected == nil && payload.EndpointGroupID != 0 { + t.Errorf("expected nil, got %d", payload.EndpointGroupID) + } + + if d.Expected != nil { + expected := d.Expected.(portainer.EndpointGroupID) + if d.Expected != nil && expected != payload.EndpointGroupID { + t.Errorf("expected EndpointGroupID %d, got %d", expected, payload.EndpointGroupID) + } + } + } + }) + } + + err = store.PendingActions().Delete(d.PendingAction.ID) + require.NoError(t, err) + } + }) +} diff --git a/api/datastore/postinit/migrate_post_init.go b/api/datastore/postinit/migrate_post_init.go new file mode 100644 index 0000000..d8961f9 --- /dev/null +++ b/api/datastore/postinit/migrate_post_init.go @@ -0,0 +1,334 @@ +package postinit + +import ( + "cmp" + "context" + "fmt" + "slices" + + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/client" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + dockerClient "github.com/portainer/portainer/api/docker/client" + "github.com/portainer/portainer/api/internal/endpointutils" + "github.com/portainer/portainer/api/internal/registryutils" + "github.com/portainer/portainer/api/kubernetes/cli" + "github.com/portainer/portainer/api/logs" + "github.com/portainer/portainer/api/pendingactions/actions" + "github.com/portainer/portainer/pkg/endpoints" + + "github.com/rs/zerolog/log" +) + +type PostInitMigrator struct { + kubeFactory *cli.ClientFactory + dockerFactory *dockerClient.ClientFactory + dataStore dataservices.DataStore + assetsPath string + kubernetesDeployer portainer.KubernetesDeployer +} + +func NewPostInitMigrator( + kubeFactory *cli.ClientFactory, + dockerFactory *dockerClient.ClientFactory, + dataStore dataservices.DataStore, + assetsPath string, + kubernetesDeployer portainer.KubernetesDeployer, +) *PostInitMigrator { + return &PostInitMigrator{ + kubeFactory: kubeFactory, + dockerFactory: dockerFactory, + dataStore: dataStore, + assetsPath: assetsPath, + kubernetesDeployer: kubernetesDeployer, + } +} + +// PostInitMigrate will run all post-init migrations, which require docker/kube clients for all edge or non-edge environments +func (postInitMigrator *PostInitMigrator) PostInitMigrate() error { + var environments []portainer.Endpoint + + if err := postInitMigrator.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + var err error + if environments, err = tx.Endpoint().ReadAll(func(endpoint portainer.Endpoint) bool { + return endpoints.HasDirectConnectivity(&endpoint) + }); err != nil { + return fmt.Errorf("failed to retrieve environments: %w", err) + } + + var pendingActions []portainer.PendingAction + if pendingActions, err = tx.PendingActions().ReadAll(func(action portainer.PendingAction) bool { + return action.Action == actions.PostInitMigrateEnvironment + }); err != nil { + return fmt.Errorf("failed to retrieve pending actions: %w", err) + } + + // Sort for the binary search in createPostInitMigrationPendingAction() + slices.SortFunc(pendingActions, func(a, b portainer.PendingAction) int { + return cmp.Compare(a.EndpointID, b.EndpointID) + }) + + for _, environment := range environments { + if !endpoints.IsEdgeEndpoint(&environment) { + continue + } + + // Edge environments will run after the server starts, in pending actions + log.Info(). + Int("endpoint_id", int(environment.ID)). + Msg("adding pending action 'PostInitMigrateEnvironment' for environment") + + if err := postInitMigrator.createPostInitMigrationPendingAction(tx, environment.ID, pendingActions); err != nil { + log.Error(). + Err(err). + Int("endpoint_id", int(environment.ID)). + Msg("error creating pending action for environment") + } + } + + return err + }); err != nil { + log.Error().Err(err).Msg("error running post-init migrations") + + return err + } + + for _, environment := range environments { + if endpoints.IsEdgeEndpoint(&environment) { + continue + } + + // Non-edge environments will run before the server starts. + if err := postInitMigrator.MigrateEnvironment(&environment); err != nil { + log.Error(). + Err(err). + Int("endpoint_id", int(environment.ID)). + Msg("error running post-init migrations for non-edge environment") + } + } + + return nil +} + +// try to create a post init migration pending action. If it already exists, do nothing +// this function exists for readability, not reusability +// pending actions must be passed in ascending order by endpoint ID +func (postInitMigrator *PostInitMigrator) createPostInitMigrationPendingAction(tx dataservices.DataStoreTx, environmentID portainer.EndpointID, pendingActions []portainer.PendingAction) error { + action := portainer.PendingAction{ + EndpointID: environmentID, + Action: actions.PostInitMigrateEnvironment, + } + + if _, found := slices.BinarySearchFunc(pendingActions, environmentID, func(e portainer.PendingAction, id portainer.EndpointID) int { + return cmp.Compare(e.EndpointID, id) + }); found { + log.Debug(). + Str("action", action.Action). + Int("endpoint_id", int(action.EndpointID)). + Msg("pending action already exists for environment, skipping...") + + return nil + } + + return tx.PendingActions().Create(&action) +} + +// MigrateEnvironment runs migrations on a single environment +func (migrator *PostInitMigrator) MigrateEnvironment(environment *portainer.Endpoint) error { + log.Info(). + Int("endpoint_id", int(environment.ID)). + Msg("executing post init migration for environment") + + switch { + case endpointutils.IsKubernetesEndpoint(environment): + // get the kubeclient for the environment, and skip all kube migrations if there's an error + kubeclient, err := migrator.kubeFactory.GetPrivilegedKubeClient(environment) + if err != nil { + log.Error(). + Err(err). + Int("endpoint_id", int(environment.ID)). + Msg("error creating kubeclient for environment") + + return err + } + + // If one environment fails, it is logged and the next migration runs. The error is returned at the end and handled by pending actions + var latestErr error + kubernetesMigrations := []func() error{ + func() error { return migrator.MigrateIngresses(*environment, kubeclient) }, + func() error { return migrator.MigrateRegistrySASecrets(*environment, kubeclient) }, + } + + for _, migration := range kubernetesMigrations { + if err := migration(); err != nil { + latestErr = err + } + } + + return latestErr + case endpointutils.IsDockerEndpoint(environment): + // get the docker client for the environment, and skip all docker migrations if there's an error + dockerClient, err := migrator.dockerFactory.CreateClient(environment, "", nil) + if err != nil { + log.Error(). + Err(err). + Int("endpoint_id", int(environment.ID)). + Msg("error creating docker client for environment") + + return err + } + defer logs.CloseAndLogErr(dockerClient) + + if err := migrator.MigrateGPUs(*environment, dockerClient); err != nil { + log.Error(). + Err(err). + Int("endpoint_id", int(environment.ID)). + Msg("error migrating GPUs for environment") + + return err + } + } + + return nil +} + +func (migrator *PostInitMigrator) MigrateRegistrySASecrets(environment portainer.Endpoint, kubeclient *cli.KubeClient) error { + if !environment.PostInitMigrations.MigrateRegistrySASecrets { + return nil + } + + log.Debug(). + Int("endpoint_id", int(environment.ID)). + Msg("migrating registry SA secrets for environment") + + return migrator.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + env, err := tx.Endpoint().Endpoint(environment.ID) + if err != nil { + return err + } + + if !env.PostInitMigrations.MigrateRegistrySASecrets { + return nil + } + + registries, err := tx.Registry().ReadAll() + if err != nil { + return err + } + + for _, registry := range registries { + access, ok := registry.RegistryAccesses[env.ID] + if !ok || len(access.Namespaces) == 0 { + continue + } + + secretName := registryutils.RegistrySecretName(registry.ID) + for _, namespace := range access.Namespaces { + if err := kubeclient.AddImagePullSecretToServiceAccount(namespace, "default", secretName); err != nil { + log.Warn(). + Err(err). + Int("endpoint_id", int(env.ID)). + Str("namespace", namespace). + Str("secret", secretName). + Msg("failed to add imagePullSecret to service account during registry SA secret migration") + } + } + } + + env.PostInitMigrations.MigrateRegistrySASecrets = false + return tx.Endpoint().UpdateEndpoint(env.ID, env) + }) +} + +func (migrator *PostInitMigrator) MigrateIngresses(environment portainer.Endpoint, kubeclient *cli.KubeClient) error { + // Early exit if we do not need to migrate! + if !environment.PostInitMigrations.MigrateIngresses { + return nil + } + + log.Debug(). + Int("endpoint_id", int(environment.ID)). + Msg("migrating ingresses for environment") + + if err := migrator.kubeFactory.MigrateEndpointIngresses(&environment, migrator.dataStore, kubeclient); err != nil { + log.Error(). + Err(err). + Int("endpoint_id", int(environment.ID)). + Msg("error migrating ingresses for environment") + + return err + } + + return nil +} + +// MigrateGPUs will check all docker endpoints for containers with GPUs and set EnableGPUManagement to true if any are found +// If there's an error getting the containers, we'll log it and move on +func (migrator *PostInitMigrator) MigrateGPUs(e portainer.Endpoint, dockerClient *client.Client) error { + return migrator.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + environment, err := tx.Endpoint().Endpoint(e.ID) + if err != nil { + log.Error(). + Err(err). + Int("endpoint_id", int(e.ID)). + Msg("error getting environment") + + return err + } + + // Early exit if we do not need to migrate! + if !environment.PostInitMigrations.MigrateGPUs { + return nil + } + + log.Debug(). + Int("endpoint_id", int(e.ID)). + Msg("migrating GPUs for environment") + + // Get all containers + containers, err := dockerClient.ContainerList(context.Background(), container.ListOptions{All: true}) + if err != nil { + log.Error(). + Err(err). + Int("endpoint_id", int(environment.ID)). + Msg("failed to list containers for environment") + + return err + } + + // Check for a gpu on each container. If even one GPU is found, set EnableGPUManagement to true for the whole environment + containersLoop: + for _, container := range containers { + // https://www.sobyte.net/post/2022-10/go-docker/ has nice documentation on the docker client with GPUs + containerDetails, err := dockerClient.ContainerInspect(context.Background(), container.ID) + if err != nil { + log.Error().Err(err).Msg("failed to inspect container") + + continue + } + + deviceRequests := containerDetails.HostConfig.DeviceRequests + for _, deviceRequest := range deviceRequests { + if deviceRequest.Driver == "nvidia" { + environment.EnableGPUManagement = true + + break containersLoop + } + } + } + + // Set the MigrateGPUs flag to false so we don't run this again + environment.PostInitMigrations.MigrateGPUs = false + if err := tx.Endpoint().UpdateEndpoint(environment.ID, environment); err != nil { + log.Error(). + Err(err). + Int("endpoint_id", int(environment.ID)). + Msg("error updating EnableGPUManagement flag for environment") + + return err + } + + return nil + }) +} diff --git a/api/datastore/postinit/migrate_post_init_test.go b/api/datastore/postinit/migrate_post_init_test.go new file mode 100644 index 0000000..69e3587 --- /dev/null +++ b/api/datastore/postinit/migrate_post_init_test.go @@ -0,0 +1,176 @@ +package postinit + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/pendingactions/actions" + + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/client" + "github.com/segmentio/encoding/json" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMigrateGPUs(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/containers/json") { + containerSummary := []container.Summary{{ID: "container1"}} + + if err := json.NewEncoder(w).Encode(containerSummary); err != nil { + w.WriteHeader(http.StatusInternalServerError) + } + + return + } + + container := container.InspectResponse{ + ContainerJSONBase: &container.ContainerJSONBase{ + ID: "container1", + HostConfig: &container.HostConfig{ + Resources: container.Resources{ + DeviceRequests: []container.DeviceRequest{ + {Driver: "nvidia"}, + }, + }, + }, + }, + } + + if err := json.NewEncoder(w).Encode(container); err != nil { + w.WriteHeader(http.StatusInternalServerError) + } + })) + defer srv.Close() + + _, store := datastore.MustNewTestStore(t, true, false) + + migrator := &PostInitMigrator{dataStore: store} + + dockerCli, err := client.NewClientWithOpts(client.WithHost(srv.URL), client.WithHTTPClient(http.DefaultClient)) + require.NoError(t, err) + + // Nonexistent endpoint + + err = migrator.MigrateGPUs(portainer.Endpoint{}, dockerCli) + require.Error(t, err) + + // Valid endpoint + + endpoint := portainer.Endpoint{ID: 1, PostInitMigrations: portainer.EndpointPostInitMigrations{MigrateGPUs: true}} + + err = store.Endpoint().Create(&endpoint) + require.NoError(t, err) + + err = migrator.MigrateGPUs(endpoint, dockerCli) + require.NoError(t, err) + + migratedEndpoint, err := store.Endpoint().Endpoint(endpoint.ID) + require.NoError(t, err) + + require.Equal(t, endpoint.ID, migratedEndpoint.ID) + require.False(t, migratedEndpoint.PostInitMigrations.MigrateGPUs) + require.True(t, migratedEndpoint.EnableGPUManagement) +} + +func TestPostInitMigrate_PendingActionsCreated(t *testing.T) { + t.Parallel() + tests := []struct { + name string + existingPendingActions []*portainer.PendingAction + expectedPendingActions int + expectedAction string + }{ + { + name: "when existing non-matching action exists, should add migration action", + existingPendingActions: []*portainer.PendingAction{ + { + EndpointID: 7, + Action: "some-other-action", + }, + }, + expectedPendingActions: 2, + expectedAction: actions.PostInitMigrateEnvironment, + }, + { + name: "when matching action exists, should not add duplicate", + existingPendingActions: []*portainer.PendingAction{ + { + EndpointID: 7, + Action: actions.PostInitMigrateEnvironment, + }, + }, + expectedPendingActions: 1, + expectedAction: actions.PostInitMigrateEnvironment, + }, + { + name: "when no actions exist, should add migration action", + existingPendingActions: []*portainer.PendingAction{}, + expectedPendingActions: 1, + expectedAction: actions.PostInitMigrateEnvironment, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + is := assert.New(t) + _, store := datastore.MustNewTestStore(t, true, true) + + // Create test endpoint + endpoint := &portainer.Endpoint{ + ID: 7, + UserTrusted: true, + Type: portainer.EdgeAgentOnDockerEnvironment, + Edge: portainer.EnvironmentEdgeSettings{ + AsyncMode: false, + }, + EdgeID: "edgeID", + } + err := store.Endpoint().Create(endpoint) + require.NoError(t, err, "error creating endpoint") + + // Create any existing pending actions + for _, action := range tt.existingPendingActions { + err = store.PendingActions().Create(action) + require.NoError(t, err, "error creating pending action") + } + + migrator := NewPostInitMigrator( + nil, // kubeFactory not needed for this test + nil, // dockerFactory not needed for this test + store, + "", // assetsPath not needed for this test + nil, // kubernetesDeployer not needed for this test + ) + + err = migrator.PostInitMigrate() + require.NoError(t, err, "PostInitMigrate should not return error") + + // Verify the results + pendingActions, err := store.PendingActions().ReadAll() + require.NoError(t, err, "error reading pending actions") + is.Len(pendingActions, tt.expectedPendingActions, "unexpected number of pending actions") + + // If we expect any actions, verify at least one has the expected action type + if tt.expectedPendingActions > 0 { + hasExpectedAction := false + for _, action := range pendingActions { + if action.Action == tt.expectedAction { + hasExpectedAction = true + is.Equal(endpoint.ID, action.EndpointID, "action should reference correct endpoint") + + break + } + } + + is.True(hasExpectedAction, "should have found action of expected type") + } + }) + } +} diff --git a/api/datastore/services.go b/api/datastore/services.go new file mode 100644 index 0000000..5480314 --- /dev/null +++ b/api/datastore/services.go @@ -0,0 +1,823 @@ +package datastore + +import ( + "fmt" + "os" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/database/models" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/dataservices/allowlist" + "github.com/portainer/portainer/api/dataservices/apikeyrepository" + "github.com/portainer/portainer/api/dataservices/customtemplate" + "github.com/portainer/portainer/api/dataservices/dockerhub" + "github.com/portainer/portainer/api/dataservices/edgegroup" + "github.com/portainer/portainer/api/dataservices/edgejob" + "github.com/portainer/portainer/api/dataservices/edgestack" + "github.com/portainer/portainer/api/dataservices/edgestackstatus" + "github.com/portainer/portainer/api/dataservices/endpoint" + "github.com/portainer/portainer/api/dataservices/endpointgroup" + "github.com/portainer/portainer/api/dataservices/endpointrelation" + "github.com/portainer/portainer/api/dataservices/extension" + "github.com/portainer/portainer/api/dataservices/helmuserrepository" + "github.com/portainer/portainer/api/dataservices/pendingactions" + "github.com/portainer/portainer/api/dataservices/registry" + "github.com/portainer/portainer/api/dataservices/resourcecontrol" + "github.com/portainer/portainer/api/dataservices/role" + "github.com/portainer/portainer/api/dataservices/schedule" + "github.com/portainer/portainer/api/dataservices/settings" + "github.com/portainer/portainer/api/dataservices/snapshot" + "github.com/portainer/portainer/api/dataservices/source" + "github.com/portainer/portainer/api/dataservices/ssl" + "github.com/portainer/portainer/api/dataservices/stack" + "github.com/portainer/portainer/api/dataservices/tag" + "github.com/portainer/portainer/api/dataservices/team" + "github.com/portainer/portainer/api/dataservices/teammembership" + "github.com/portainer/portainer/api/dataservices/tunnelserver" + "github.com/portainer/portainer/api/dataservices/user" + "github.com/portainer/portainer/api/dataservices/version" + "github.com/portainer/portainer/api/dataservices/webhook" + "github.com/portainer/portainer/api/dataservices/workflow" + + "github.com/rs/zerolog/log" + "github.com/segmentio/encoding/json" +) + +var _ dataservices.DataStore = &Store{} + +// Store defines the implementation of portainer.DataStore using +// BoltDB as the storage system. +type Store struct { + flags *portainer.CLIFlags + connection portainer.Connection + + fileService portainer.FileService + AllowListService *allowlist.Service + CustomTemplateService *customtemplate.Service + DockerHubService *dockerhub.Service + EdgeGroupService *edgegroup.Service + EdgeJobService *edgejob.Service + EdgeStackService *edgestack.Service + EdgeStackStatusService *edgestackstatus.Service + EndpointGroupService *endpointgroup.Service + EndpointService *endpoint.Service + EndpointRelationService *endpointrelation.Service + ExtensionService *extension.Service + HelmUserRepositoryService *helmuserrepository.Service + RegistryService *registry.Service + ResourceControlService *resourcecontrol.Service + RoleService *role.Service + APIKeyRepositoryService *apikeyrepository.Service + ScheduleService *schedule.Service + SettingsService *settings.Service + SnapshotService *snapshot.Service + SourceService *source.Service + SSLSettingsService *ssl.Service + StackService *stack.Service + TagService *tag.Service + TeamMembershipService *teammembership.Service + TeamService *team.Service + TunnelServerService *tunnelserver.Service + UserService *user.Service + VersionService *version.Service + WebhookService *webhook.Service + WorkflowService *workflow.Service + PendingActionsService *pendingactions.Service +} + +func (store *Store) initServices() error { + allowListService, err := allowlist.NewService(store.connection) + if err != nil { + return err + } + store.AllowListService = allowListService + + authorizationsetService, err := role.NewService(store.connection) + if err != nil { + return err + } + store.RoleService = authorizationsetService + + customTemplateService, err := customtemplate.NewService(store.connection) + if err != nil { + return err + } + store.CustomTemplateService = customTemplateService + + dockerhubService, err := dockerhub.NewService(store.connection) + if err != nil { + return err + } + store.DockerHubService = dockerhubService + + endpointRelationService, err := endpointrelation.NewService(store.connection) + if err != nil { + return err + } + store.EndpointRelationService = endpointRelationService + + edgeStackService, err := edgestack.NewService(store.connection, func(tx portainer.Transaction, ID portainer.EdgeStackID) { + endpointRelationService.Tx(tx).InvalidateEdgeCacheForEdgeStack(ID) + }) + if err != nil { + return err + } + store.EdgeStackService = edgeStackService + endpointRelationService.RegisterUpdateStackFunction(edgeStackService.UpdateEdgeStackFuncTx) + + edgeStackStatusService, err := edgestackstatus.NewService(store.connection) + if err != nil { + return err + } + store.EdgeStackStatusService = edgeStackStatusService + + edgeGroupService, err := edgegroup.NewService(store.connection) + if err != nil { + return err + } + store.EdgeGroupService = edgeGroupService + + edgeJobService, err := edgejob.NewService(store.connection) + if err != nil { + return err + } + store.EdgeJobService = edgeJobService + + endpointgroupService, err := endpointgroup.NewService(store.connection) + if err != nil { + return err + } + store.EndpointGroupService = endpointgroupService + + endpointService, err := endpoint.NewService(store.connection) + if err != nil { + return err + } + store.EndpointService = endpointService + + extensionService, err := extension.NewService(store.connection) + if err != nil { + return err + } + store.ExtensionService = extensionService + + helmUserRepositoryService, err := helmuserrepository.NewService(store.connection) + if err != nil { + return err + } + store.HelmUserRepositoryService = helmUserRepositoryService + + registryService, err := registry.NewService(store.connection) + if err != nil { + return err + } + store.RegistryService = registryService + + resourcecontrolService, err := resourcecontrol.NewService(store.connection) + if err != nil { + return err + } + store.ResourceControlService = resourcecontrolService + + settingsService, err := settings.NewService(store.connection) + if err != nil { + return err + } + store.SettingsService = settingsService + + snapshotService, err := snapshot.NewService(store.connection) + if err != nil { + return err + } + store.SnapshotService = snapshotService + + sourceService, err := source.NewService(store.connection) + if err != nil { + return err + } + store.SourceService = sourceService + + sslSettingsService, err := ssl.NewService(store.connection) + if err != nil { + return err + } + store.SSLSettingsService = sslSettingsService + + stackService, err := stack.NewService(store.connection) + if err != nil { + return err + } + store.StackService = stackService + + tagService, err := tag.NewService(store.connection) + if err != nil { + return err + } + store.TagService = tagService + + teammembershipService, err := teammembership.NewService(store.connection) + if err != nil { + return err + } + store.TeamMembershipService = teammembershipService + + teamService, err := team.NewService(store.connection) + if err != nil { + return err + } + store.TeamService = teamService + + tunnelServerService, err := tunnelserver.NewService(store.connection) + if err != nil { + return err + } + store.TunnelServerService = tunnelServerService + + userService, err := user.NewService(store.connection) + if err != nil { + return err + } + store.UserService = userService + + apiKeyService, err := apikeyrepository.NewService(store.connection) + if err != nil { + return err + } + store.APIKeyRepositoryService = apiKeyService + + versionService, err := version.NewService(store.connection) + if err != nil { + return err + } + store.VersionService = versionService + + webhookService, err := webhook.NewService(store.connection) + if err != nil { + return err + } + store.WebhookService = webhookService + + workflowService, err := workflow.NewService(store.connection) + if err != nil { + return err + } + store.WorkflowService = workflowService + + scheduleService, err := schedule.NewService(store.connection) + if err != nil { + return err + } + store.ScheduleService = scheduleService + + pendingActionsService, err := pendingactions.NewService(store.connection) + if err != nil { + return err + } + store.PendingActionsService = pendingActionsService + + return nil +} + +// PendingActions gives access to the PendingActions data management layer +func (store *Store) PendingActions() dataservices.PendingActionsService { + return store.PendingActionsService +} + +// AllowList gives access to the AllowList data management layer +func (store *Store) AllowList() dataservices.AllowListService { + return store.AllowListService +} + +// CustomTemplate gives access to the CustomTemplate data management layer +func (store *Store) CustomTemplate() dataservices.CustomTemplateService { + return store.CustomTemplateService +} + +// EdgeGroup gives access to the EdgeGroup data management layer +func (store *Store) EdgeGroup() dataservices.EdgeGroupService { + return store.EdgeGroupService +} + +// EdgeJob gives access to the EdgeJob data management layer +func (store *Store) EdgeJob() dataservices.EdgeJobService { + return store.EdgeJobService +} + +// EdgeStack gives access to the EdgeStack data management layer +func (store *Store) EdgeStack() dataservices.EdgeStackService { + return store.EdgeStackService +} + +func (store *Store) EdgeStackStatus() dataservices.EdgeStackStatusService { + return store.EdgeStackStatusService +} + +// Environment(Endpoint) gives access to the Environment(Endpoint) data management layer +func (store *Store) Endpoint() dataservices.EndpointService { + return store.EndpointService +} + +// EndpointGroup gives access to the EndpointGroup data management layer +func (store *Store) EndpointGroup() dataservices.EndpointGroupService { + return store.EndpointGroupService +} + +// EndpointRelation gives access to the EndpointRelation data management layer +func (store *Store) EndpointRelation() dataservices.EndpointRelationService { + return store.EndpointRelationService +} + +// HelmUserRepository access the helm user repository settings +func (store *Store) HelmUserRepository() dataservices.HelmUserRepositoryService { + return store.HelmUserRepositoryService +} + +// Registry gives access to the Registry data management layer +func (store *Store) Registry() dataservices.RegistryService { + return store.RegistryService +} + +// ResourceControl gives access to the ResourceControl data management layer +func (store *Store) ResourceControl() dataservices.ResourceControlService { + return store.ResourceControlService +} + +// Role gives access to the Role data management layer +func (store *Store) Role() dataservices.RoleService { + return store.RoleService +} + +// APIKeyRepository gives access to the api-key data management layer +func (store *Store) APIKeyRepository() dataservices.APIKeyRepository { + return store.APIKeyRepositoryService +} + +// Settings gives access to the Settings data management layer +func (store *Store) Settings() dataservices.SettingsService { + return store.SettingsService +} + +func (store *Store) Snapshot() dataservices.SnapshotService { + return store.SnapshotService +} + +// Source gives access to the Source data management layer +func (store *Store) Source() dataservices.SourceService { + return store.SourceService +} + +// SSLSettings gives access to the SSL Settings data management layer +func (store *Store) SSLSettings() dataservices.SSLSettingsService { + return store.SSLSettingsService +} + +// Stack gives access to the Stack data management layer +func (store *Store) Stack() dataservices.StackService { + return store.StackService +} + +// Tag gives access to the Tag data management layer +func (store *Store) Tag() dataservices.TagService { + return store.TagService +} + +// TeamMembership gives access to the TeamMembership data management layer +func (store *Store) TeamMembership() dataservices.TeamMembershipService { + return store.TeamMembershipService +} + +// Team gives access to the Team data management layer +func (store *Store) Team() dataservices.TeamService { + return store.TeamService +} + +// TunnelServer gives access to the TunnelServer data management layer +func (store *Store) TunnelServer() dataservices.TunnelServerService { + return store.TunnelServerService +} + +// User gives access to the User data management layer +func (store *Store) User() dataservices.UserService { + return store.UserService +} + +// Version gives access to the Version data management layer +func (store *Store) Version() dataservices.VersionService { + return store.VersionService +} + +// Webhook gives access to the Webhook data management layer +func (store *Store) Webhook() dataservices.WebhookService { + return store.WebhookService +} + +// Workflow gives access to the Workflow data management layer +func (store *Store) Workflow() dataservices.WorkflowService { + return store.WorkflowService +} + +type storeExport struct { + CustomTemplate []portainer.CustomTemplate `json:"customtemplates,omitempty"` + EdgeGroup []portainer.EdgeGroup `json:"edgegroups,omitempty"` + EdgeJob []portainer.EdgeJob `json:"edgejobs,omitempty"` + EdgeStack []portainer.EdgeStack `json:"edge_stack,omitempty"` + Endpoint []portainer.Endpoint `json:"endpoints,omitempty"` + EndpointGroup []portainer.EndpointGroup `json:"endpoint_groups,omitempty"` + EndpointRelation []portainer.EndpointRelation `json:"endpoint_relations,omitempty"` + Extensions []portainer.Extension `json:"extension,omitempty"` + HelmUserRepository []portainer.HelmUserRepository `json:"helm_user_repository,omitempty"` + Registry []portainer.Registry `json:"registries,omitempty"` + ResourceControl []portainer.ResourceControl `json:"resource_control,omitempty"` + Role []portainer.Role `json:"roles,omitempty"` + Schedules []portainer.Schedule `json:"schedules,omitempty"` + Settings portainer.Settings `json:"settings,omitzero"` + Snapshot []portainer.Snapshot `json:"snapshots,omitempty"` + SSLSettings portainer.SSLSettings `json:"ssl,omitzero"` + Source []portainer.Source `json:"sources,omitempty"` + Stack []portainer.Stack `json:"stacks,omitempty"` + Tag []portainer.Tag `json:"tags,omitempty"` + TeamMembership []portainer.TeamMembership `json:"team_membership,omitempty"` + Team []portainer.Team `json:"teams,omitempty"` + TunnelServer portainer.TunnelServerInfo `json:"tunnel_server,omitzero"` + User []portainer.User `json:"users,omitempty"` + Version models.Version `json:"version,omitzero"` + Webhook []portainer.Webhook `json:"webhooks,omitempty"` + Workflow []portainer.Workflow `json:"workflows,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` +} + +func (store *Store) Export(filename string) (err error) { + backup := storeExport{} + + if c, err := store.CustomTemplate().ReadAll(); err != nil { + if !store.IsErrObjectNotFound(err) { + log.Error().Err(err).Msg("exporting Custom Templates") + } + } else { + backup.CustomTemplate = c + } + + if e, err := store.EdgeGroup().ReadAll(); err != nil { + if !store.IsErrObjectNotFound(err) { + log.Error().Err(err).Msg("exporting Edge Groups") + } + } else { + backup.EdgeGroup = e + } + + if e, err := store.EdgeJob().ReadAll(); err != nil { + if !store.IsErrObjectNotFound(err) { + log.Error().Err(err).Msg("exporting Edge Jobs") + } + } else { + backup.EdgeJob = e + } + + if e, err := store.EdgeStack().EdgeStacks(); err != nil { + if !store.IsErrObjectNotFound(err) { + log.Error().Err(err).Msg("exporting Edge Stacks") + } + } else { + backup.EdgeStack = e + } + + if e, err := store.Endpoint().Endpoints(); err != nil { + if !store.IsErrObjectNotFound(err) { + log.Error().Err(err).Msg("exporting Endpoints") + } + } else { + backup.Endpoint = e + } + + if e, err := store.EndpointGroup().ReadAll(); err != nil { + if !store.IsErrObjectNotFound(err) { + log.Error().Err(err).Msg("exporting Endpoint Groups") + } + } else { + backup.EndpointGroup = e + } + + if r, err := store.EndpointRelation().EndpointRelations(); err != nil { + if !store.IsErrObjectNotFound(err) { + log.Error().Err(err).Msg("exporting Endpoint Relations") + } + } else { + backup.EndpointRelation = r + } + + if r, err := store.ExtensionService.Extensions(); err != nil { + if !store.IsErrObjectNotFound(err) { + log.Error().Err(err).Msg("exporting Extensions") + } + } else { + backup.Extensions = r + } + + if r, err := store.HelmUserRepository().ReadAll(); err != nil { + if !store.IsErrObjectNotFound(err) { + log.Error().Err(err).Msg("exporting Helm User Repositories") + } + } else { + backup.HelmUserRepository = r + } + + if r, err := store.Registry().ReadAll(); err != nil { + if !store.IsErrObjectNotFound(err) { + log.Error().Err(err).Msg("exporting Registries") + } + } else { + backup.Registry = r + } + + if c, err := store.ResourceControl().ReadAll(); err != nil { + if !store.IsErrObjectNotFound(err) { + log.Error().Err(err).Msg("exporting Resource Controls") + } + } else { + backup.ResourceControl = c + } + + if role, err := store.Role().ReadAll(); err != nil { + if !store.IsErrObjectNotFound(err) { + log.Error().Err(err).Msg("exporting Roles") + } + } else { + backup.Role = role + } + + if r, err := store.ScheduleService.Schedules(); err != nil { + if !store.IsErrObjectNotFound(err) { + log.Error().Err(err).Msg("exporting Schedules") + } + } else { + backup.Schedules = r + } + + if settings, err := store.Settings().Settings(); err != nil { + if !store.IsErrObjectNotFound(err) { + log.Error().Err(err).Msg("exporting Settings") + } + } else { + backup.Settings = *settings + } + + if snapshot, err := store.Snapshot().ReadAll(); err != nil { + if !store.IsErrObjectNotFound(err) { + log.Error().Err(err).Msg("exporting Snapshots") + } + } else { + backup.Snapshot = snapshot + } + + if settings, err := store.SSLSettings().Settings(); err != nil { + if !store.IsErrObjectNotFound(err) { + log.Error().Err(err).Msg("exporting SSL Settings") + } + } else { + backup.SSLSettings = *settings + } + + if s, err := store.Source().ReadAll(source.InsecureNewAdminContext()); err != nil { + if !store.IsErrObjectNotFound(err) { + log.Error().Err(err).Msg("exporting Sources") + } + } else { + backup.Source = s + } + + if t, err := store.Stack().ReadAll(); err != nil { + if !store.IsErrObjectNotFound(err) { + log.Error().Err(err).Msg("exporting Stacks") + } + } else { + backup.Stack = t + } + + if t, err := store.Tag().ReadAll(); err != nil { + if !store.IsErrObjectNotFound(err) { + log.Error().Err(err).Msg("exporting Tags") + } + } else { + backup.Tag = t + } + + if t, err := store.TeamMembership().ReadAll(); err != nil { + if !store.IsErrObjectNotFound(err) { + log.Error().Err(err).Msg("exporting Team Memberships") + } + } else { + backup.TeamMembership = t + } + + if t, err := store.Team().ReadAll(); err != nil { + if !store.IsErrObjectNotFound(err) { + log.Error().Err(err).Msg("exporting Teams") + } + } else { + backup.Team = t + } + + if info, err := store.TunnelServer().Info(); err != nil { + if !store.IsErrObjectNotFound(err) { + log.Error().Err(err).Msg("exporting Tunnel Server") + } + } else { + backup.TunnelServer = *info + } + + if users, err := store.User().ReadAll(); err != nil { + if !store.IsErrObjectNotFound(err) { + log.Error().Err(err).Msg("exporting Users") + } + } else { + backup.User = users + } + + if webhooks, err := store.Webhook().ReadAll(); err != nil { + if !store.IsErrObjectNotFound(err) { + log.Error().Err(err).Msg("exporting Webhooks") + } + } else { + backup.Webhook = webhooks + } + + if w, err := store.Workflow().ReadAll(); err != nil { + if !store.IsErrObjectNotFound(err) { + log.Error().Err(err).Msg("exporting Workflows") + } + } else { + backup.Workflow = w + } + + if version, err := store.Version().Version(); err != nil { + if !store.IsErrObjectNotFound(err) { + log.Error().Err(err).Msg("exporting Version") + } + } else { + backup.Version = *version + } + + backup.Metadata, err = store.connection.BackupMetadata() + if err != nil { + log.Error().Err(err).Msg("exporting Metadata") + } + + b, err := json.MarshalIndent(backup, "", " ") + if err != nil { + return err + } + + return os.WriteFile(filename, b, 0o600) +} + +func (store *Store) Import(filename string) (err error) { + backup := storeExport{} + + s, err := os.ReadFile(filename) + if err != nil { + return err + } + err = json.Unmarshal(s, &backup) + if err != nil { + return err + } + + err = store.Version().UpdateVersion(&backup.Version) + if err != nil { + return err + } + + for _, v := range backup.CustomTemplate { + if err := store.CustomTemplate().Update(v.ID, &v); err != nil { + log.Warn().Err(err).Msg("failed to update the custom template in the database") + } + } + + for _, v := range backup.EdgeGroup { + if err := store.EdgeGroup().Update(v.ID, &v); err != nil { + log.Warn().Err(err).Msg("failed to update the edge group in the database") + } + } + + for _, v := range backup.EdgeJob { + if err := store.EdgeJob().Update(v.ID, &v); err != nil { + log.Warn().Err(err).Msg("failed to update the edge job in the database") + } + } + + for _, v := range backup.EdgeStack { + if err := store.EdgeStack().UpdateEdgeStack(v.ID, &v); err != nil { + log.Warn().Err(err).Msg("failed to update the edge stack in the database") + } + } + + for _, v := range backup.Endpoint { + if err := store.Endpoint().UpdateEndpoint(v.ID, &v); err != nil { + log.Warn().Err(err).Msg("failed to update the endpoint in the database") + } + } + + for _, v := range backup.EndpointGroup { + if err := store.EndpointGroup().Update(v.ID, &v); err != nil { + log.Warn().Err(err).Msg("failed to update the endpoint group in the database") + } + } + + for _, v := range backup.EndpointRelation { + if err := store.EndpointRelation().UpdateEndpointRelation(v.EndpointID, &v); err != nil { + log.Warn().Err(err).Msg("failed to update the endpoint relation in the database") + } + } + + for _, v := range backup.HelmUserRepository { + if err := store.HelmUserRepository().Update(v.ID, &v); err != nil { + log.Warn().Err(err).Msg("failed to update the helm user repository in the database") + } + } + + for _, v := range backup.Registry { + if err := store.Registry().Update(v.ID, &v); err != nil { + log.Warn().Err(err).Msg("failed to update the registry in the database") + } + } + + for _, v := range backup.ResourceControl { + if err := store.ResourceControl().Update(v.ID, &v); err != nil { + log.Warn().Err(err).Msg("failed to update the resource control in the database") + } + } + + for _, v := range backup.Role { + if err := store.Role().Update(v.ID, &v); err != nil { + log.Warn().Err(err).Msg("failed to update the role in the database") + } + } + + if err := store.Settings().UpdateSettings(&backup.Settings); err != nil { + log.Warn().Err(err).Msg("failed to update the settings in the database") + } + + if err := store.SSLSettings().UpdateSettings(&backup.SSLSettings); err != nil { + log.Warn().Err(err).Msg("failed to update the SSL settings in the database") + } + + for _, v := range backup.Snapshot { + if err := store.Snapshot().Update(v.EndpointID, &v); err != nil { + log.Warn().Err(err).Msg("failed to update the snapshot in the database") + } + } + + for _, v := range backup.Source { + if err := store.Source().Update(source.InsecureNewAdminContext(), v.ID, &v); err != nil { + log.Warn().Err(err).Msg("failed to update the source in the database") + } + } + + for _, v := range backup.Workflow { + if err := store.Workflow().Update(v.ID, &v); err != nil { + log.Warn().Err(err).Msg("failed to update the workflow in the database") + } + } + + for _, v := range backup.Stack { + if err := store.Stack().Update(v.ID, &v); err != nil { + log.Warn().Err(err).Msg("failed to update the stack in the database") + } + } + + for _, v := range backup.Tag { + if err := store.Tag().Update(v.ID, &v); err != nil { + log.Warn().Err(err).Msg("failed to update the tag in the database") + } + } + + for _, v := range backup.TeamMembership { + if err := store.TeamMembership().Update(v.ID, &v); err != nil { + log.Warn().Err(err).Msg("failed to update the team membership in the database") + } + } + + for _, v := range backup.Team { + if err := store.Team().Update(v.ID, &v); err != nil { + log.Warn().Err(err).Msg("failed to update the team in the database") + } + } + + if err := store.TunnelServer().UpdateInfo(&backup.TunnelServer); err != nil { + log.Warn().Err(err).Msg("failed to update the tunnel server info in the database") + } + + for _, user := range backup.User { + if err := store.User().Update(user.ID, &user); err != nil { + log.Warn().Str("user", fmt.Sprintf("%+v", user)).Err(err).Msg("failed to update the user in the database") + } + } + + for _, v := range backup.Webhook { + if err := store.Webhook().Update(v.ID, &v); err != nil { + log.Warn().Err(err).Msg("failed to update the webhook in the database") + } + } + + return store.connection.RestoreMetadata(backup.Metadata) +} diff --git a/api/datastore/services_tx.go b/api/datastore/services_tx.go new file mode 100644 index 0000000..bfb9dc7 --- /dev/null +++ b/api/datastore/services_tx.go @@ -0,0 +1,116 @@ +package datastore + +import ( + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" +) + +type StoreTx struct { + store *Store + tx portainer.Transaction +} + +func (tx *StoreTx) IsErrObjectNotFound(err error) bool { + return tx.store.IsErrObjectNotFound(err) +} + +func (tx *StoreTx) AllowList() dataservices.AllowListService { + return tx.store.AllowListService.Tx(tx.tx) +} + +func (tx *StoreTx) CustomTemplate() dataservices.CustomTemplateService { + return tx.store.CustomTemplateService.Tx(tx.tx) +} + +func (tx *StoreTx) PendingActions() dataservices.PendingActionsService { + return tx.store.PendingActionsService.Tx(tx.tx) +} + +func (tx *StoreTx) EdgeGroup() dataservices.EdgeGroupService { + return tx.store.EdgeGroupService.Tx(tx.tx) +} + +func (tx *StoreTx) EdgeJob() dataservices.EdgeJobService { + return tx.store.EdgeJobService.Tx(tx.tx) +} + +func (tx *StoreTx) EdgeStack() dataservices.EdgeStackService { + return tx.store.EdgeStackService.Tx(tx.tx) +} + +func (tx *StoreTx) EdgeStackStatus() dataservices.EdgeStackStatusService { + return tx.store.EdgeStackStatusService.Tx(tx.tx) +} + +func (tx *StoreTx) Endpoint() dataservices.EndpointService { + return tx.store.EndpointService.Tx(tx.tx) +} + +func (tx *StoreTx) EndpointGroup() dataservices.EndpointGroupService { + return tx.store.EndpointGroupService.Tx(tx.tx) +} + +func (tx *StoreTx) EndpointRelation() dataservices.EndpointRelationService { + return tx.store.EndpointRelationService.Tx(tx.tx) +} + +func (tx *StoreTx) HelmUserRepository() dataservices.HelmUserRepositoryService { return nil } + +func (tx *StoreTx) Registry() dataservices.RegistryService { + return tx.store.RegistryService.Tx(tx.tx) +} + +func (tx *StoreTx) ResourceControl() dataservices.ResourceControlService { + return tx.store.ResourceControlService.Tx(tx.tx) +} + +func (tx *StoreTx) Role() dataservices.RoleService { + return tx.store.RoleService.Tx(tx.tx) +} + +func (tx *StoreTx) APIKeyRepository() dataservices.APIKeyRepository { return nil } + +func (tx *StoreTx) Settings() dataservices.SettingsService { + return tx.store.SettingsService.Tx(tx.tx) +} + +func (tx *StoreTx) Snapshot() dataservices.SnapshotService { + return tx.store.SnapshotService.Tx(tx.tx) +} + +func (tx *StoreTx) Source() dataservices.SourceService { + return tx.store.SourceService.Tx(tx.tx) +} + +func (tx *StoreTx) SSLSettings() dataservices.SSLSettingsService { + return tx.store.SSLSettingsService.Tx(tx.tx) +} + +func (tx *StoreTx) Stack() dataservices.StackService { + return tx.store.StackService.Tx(tx.tx) +} + +func (tx *StoreTx) Tag() dataservices.TagService { + return tx.store.TagService.Tx(tx.tx) +} + +func (tx *StoreTx) TeamMembership() dataservices.TeamMembershipService { + return tx.store.TeamMembershipService.Tx(tx.tx) +} + +func (tx *StoreTx) Team() dataservices.TeamService { + return tx.store.TeamService.Tx(tx.tx) +} + +func (tx *StoreTx) TunnelServer() dataservices.TunnelServerService { return nil } + +func (tx *StoreTx) User() dataservices.UserService { + return tx.store.UserService.Tx(tx.tx) +} + +func (tx *StoreTx) Version() dataservices.VersionService { return nil } +func (tx *StoreTx) Webhook() dataservices.WebhookService { return nil } + +func (tx *StoreTx) Workflow() dataservices.WorkflowService { + return tx.store.WorkflowService.Tx(tx.tx) +} diff --git a/api/datastore/test_data/input_24.json b/api/datastore/test_data/input_24.json new file mode 100644 index 0000000..e89d59d --- /dev/null +++ b/api/datastore/test_data/input_24.json @@ -0,0 +1,3093 @@ +{ + "dockerhub": [ + { + "Authentication": false, + "Username": "" + } + ], + "endpoint_groups": [ + { + "AuthorizedTeams": null, + "AuthorizedUsers": null, + "Description": "Unassigned endpoints", + "Id": 1, + "Labels": [], + "Name": "Unassigned", + "TagIds": [], + "Tags": null, + "TeamAccessPolicies": {}, + "UserAccessPolicies": {} + } + ], + "endpoint_relations": [ + { + "EdgeStacks": {}, + "EndpointID": 1 + } + ], + "endpoints": [ + { + "AuthorizedTeams": null, + "AuthorizedUsers": null, + "AzureCredentials": { + "ApplicationID": "", + "AuthenticationKey": "", + "TenantID": "" + }, + "EdgeKey": "", + "Extensions": [], + "GroupId": 1, + "Heartbeat": false, + "Id": 1, + "Name": "local", + "PublicURL": "", + "Snapshots": [ + { + "DiagnosticsData": { + "Log": "", + "DNS": {}, + "Proxy": {}, + "Telnet": {} + }, + "DockerVersion": "20.10.13", + "HealthyContainerCount": 0, + "ImageCount": 9, + "RunningContainerCount": 5, + "ServiceCount": 0, + "SnapshotRaw": { + "Containers": [ + { + "Command": "/docker-entrypoint.sh nginx -g 'daemon off;'", + "Created": 1648609973, + "HostConfig": { + "NetworkMode": "nginx_default" + }, + "Id": "0eca796ba47ad6e479a09191d90be17b5d151b63227f30ec1974338a55a24f11", + "Image": "nginx:latest", + "ImageID": "sha256:c919045c4c2b0b0007c606e763ed2c830c7b1d038ce878a3c0d6f5b81e6ab80b", + "Labels": { + "com.docker.compose.config-hash": "b6013e48916cd17f37d6675ae35e0cac34cace20", + "com.docker.compose.container-number": "1", + "com.docker.compose.oneoff": "False", + "com.docker.compose.project": "nginx", + "com.docker.compose.service": "redis-master", + "com.docker.compose.version": "1.5.0", + "maintainer": "NGINX Docker Maintainers \u003cdocker-maint@nginx.com\u003e" + }, + "Mounts": [], + "Names": [ + "/nginx_redis-master_1" + ], + "NetworkSettings": { + "Networks": { + "nginx_default": { + "Aliases": null, + "DriverOpts": null, + "EndpointID": "f761433ec60e0514f2a4ce9e7e408029af6c363a95e5723e31f82a497597ede4", + "Gateway": "172.20.0.1", + "GlobalIPv6Address": "", + "GlobalIPv6PrefixLen": 0, + "IPAMConfig": {}, + "IPAddress": "172.20.0.2", + "IPPrefixLen": 16, + "IPv6Gateway": "", + "Links": null, + "MacAddress": "02:42:ac:14:00:02", + "NetworkID": "d9576d8c709d65504ca1d0a654cdc7b13ab17ebc6ae051f74e6b124d9d368c9a" + } + } + }, + "Ports": [ + { + "IP": "0.0.0.0", + "PrivatePort": 80, + "PublicPort": 8080, + "Type": "tcp" + }, + { + "IP": "::", + "PrivatePort": 80, + "PublicPort": 8080, + "Type": "tcp" + } + ], + "State": "running", + "Status": "Up 2 minutes" + }, + { + "Command": "apache2-foreground", + "Created": 1648609919, + "HostConfig": { + "NetworkMode": "redis_default" + }, + "Id": "ab541bbf504f9ef6cddfbd0dd06224f36fe7b291a2d9a3bc1ed406140312bf7a", + "Image": "gcr.io/google-samples/gb-frontend:v4", + "ImageID": "sha256:e2b3e8542af735080e6bda06873ce666e2319eea353884a88e45f3c9ef996846", + "Labels": { + "com.docker.compose.config-hash": "7c2882b6ebdb0d582b4a326e9383d0ea5b3cc155", + "com.docker.compose.container-number": "1", + "com.docker.compose.oneoff": "False", + "com.docker.compose.project": "redis", + "com.docker.compose.service": "frontend", + "com.docker.compose.version": "1.5.0", + "kompose.service.type": "LoadBalancer" + }, + "Mounts": [], + "Names": [ + "/redis_frontend_1" + ], + "NetworkSettings": { + "Networks": { + "redis_default": { + "Aliases": null, + "DriverOpts": null, + "EndpointID": "f9c6fb004c9546d999230b1a3b64f0e680eef8a74d494f7e0455ba64a190b322", + "Gateway": "172.19.0.1", + "GlobalIPv6Address": "", + "GlobalIPv6PrefixLen": 0, + "IPAMConfig": {}, + "IPAddress": "172.19.0.2", + "IPPrefixLen": 16, + "IPv6Gateway": "", + "Links": null, + "MacAddress": "02:42:ac:13:00:02", + "NetworkID": "9fa60f4b6a71b29a95127e99ae0ba09f616386a58ae790f0e8f975e8029f791d" + } + } + }, + "Ports": [ + { + "IP": "0.0.0.0", + "PrivatePort": 80, + "PublicPort": 80, + "Type": "tcp" + }, + { + "IP": "::", + "PrivatePort": 80, + "PublicPort": 80, + "Type": "tcp" + } + ], + "State": "running", + "Status": "Up 3 minutes" + }, + { + "Command": "redis-server /etc/redis/redis.conf", + "Created": 1648609919, + "HostConfig": { + "NetworkMode": "redis_default" + }, + "Id": "621d31f8aa50ad8ee182f3bc581fe19fa67c4f3728b412cfe3d20aa0d6deb2ef", + "Image": "k8s.gcr.io/redis:e2e", + "ImageID": "sha256:e5e67996c442f903cda78dd983ea6e94bb4e542950fd2eba666b44cbd303df42", + "Labels": { + "com.docker.compose.config-hash": "86f8fef221e155eb14f94d707313986c865e8ac5", + "com.docker.compose.container-number": "1", + "com.docker.compose.oneoff": "False", + "com.docker.compose.project": "redis", + "com.docker.compose.service": "redis-master", + "com.docker.compose.version": "1.5.0" + }, + "Mounts": [ + { + "Destination": "/data", + "Driver": "local", + "Mode": "", + "Name": "a3fedc4b90b70e9f28456b4f88f8a2ebd90f76cf8a8a5e4fb5dcbd0b90ff0153", + "Propagation": "", + "RW": true, + "Source": "", + "Type": "volume" + } + ], + "Names": [ + "/redis_redis-master_1" + ], + "NetworkSettings": { + "Networks": { + "redis_default": { + "Aliases": null, + "DriverOpts": null, + "EndpointID": "7efb43c9f1b67e518e53b2dbcee2cfbbab07dcfa4788bad8c9fde8334cb6a213", + "Gateway": "172.19.0.1", + "GlobalIPv6Address": "", + "GlobalIPv6PrefixLen": 0, + "IPAMConfig": {}, + "IPAddress": "172.19.0.3", + "IPPrefixLen": 16, + "IPv6Gateway": "", + "Links": null, + "MacAddress": "02:42:ac:13:00:03", + "NetworkID": "9fa60f4b6a71b29a95127e99ae0ba09f616386a58ae790f0e8f975e8029f791d" + } + } + }, + "Ports": [ + { + "IP": "0.0.0.0", + "PrivatePort": 6379, + "PublicPort": 49154, + "Type": "tcp" + }, + { + "IP": "::", + "PrivatePort": 6379, + "PublicPort": 49154, + "Type": "tcp" + } + ], + "State": "running", + "Status": "Up 3 minutes" + }, + { + "Command": "/entrypoint.sh /bin/sh -c /run.sh", + "Created": 1648609919, + "HostConfig": { + "NetworkMode": "redis_default" + }, + "Id": "536f5789838e57d3e24658cf5a45d7957861542ac4d8dab688d7a64d2be274f1", + "Image": "gcr.io/google_samples/gb-redisslave:v1", + "ImageID": "sha256:5f026ddffa27f011242781f7f2498538334e173869e7fe757008881fb48180b6", + "Labels": { + "com.docker.compose.config-hash": "757a0da54b072e33a11a367408e83d930ad1f30f", + "com.docker.compose.container-number": "1", + "com.docker.compose.oneoff": "False", + "com.docker.compose.project": "redis", + "com.docker.compose.service": "redis-slave", + "com.docker.compose.version": "1.5.0" + }, + "Mounts": [ + { + "Destination": "/data", + "Driver": "local", + "Mode": "", + "Name": "5f93240d96e42d0b3728435cbfb43b6fcb3b01446d5c7d4be1cba8f9336c0a58", + "Propagation": "", + "RW": true, + "Source": "", + "Type": "volume" + } + ], + "Names": [ + "/redis_redis-slave_1" + ], + "NetworkSettings": { + "Networks": { + "redis_default": { + "Aliases": null, + "DriverOpts": null, + "EndpointID": "78b76382fcb909030a6e96caf5ce579d852bb565d82d47293302900eb81042ee", + "Gateway": "172.19.0.1", + "GlobalIPv6Address": "", + "GlobalIPv6PrefixLen": 0, + "IPAMConfig": {}, + "IPAddress": "172.19.0.4", + "IPPrefixLen": 16, + "IPv6Gateway": "", + "Links": null, + "MacAddress": "02:42:ac:13:00:04", + "NetworkID": "9fa60f4b6a71b29a95127e99ae0ba09f616386a58ae790f0e8f975e8029f791d" + } + } + }, + "Ports": [ + { + "IP": "0.0.0.0", + "PrivatePort": 6379, + "PublicPort": 49155, + "Type": "tcp" + }, + { + "IP": "::", + "PrivatePort": 6379, + "PublicPort": 49155, + "Type": "tcp" + } + ], + "State": "running", + "Status": "Up 3 minutes" + }, + { + "Command": "httpd-foreground", + "Created": 1647381304, + "HostConfig": { + "NetworkMode": "bridge" + }, + "Id": "74034d9d05b07b2592fbe4ec878486eac5c743e960f75816884750e0aa40939b", + "Image": "httpd:latest", + "ImageID": "sha256:6b8e87fff1072470bbfc957a735e7e46007177864a7f61bd9e0f5872d3d7b4a5", + "Labels": {}, + "Mounts": [ + { + "Destination": "/usr/local/apache2/htdocs", + "Driver": "local", + "Mode": "z", + "Name": "f1d6fe0188cc05f24309a86b58cf8130f89fa21aaa73f37789ad36fd188b38e2", + "Propagation": "", + "RW": true, + "Source": "/var/lib/docker/volumes/f1d6fe0188cc05f24309a86b58cf8130f89fa21aaa73f37789ad36fd188b38e2/_data", + "Type": "volume" + } + ], + "Names": [ + "/httpd" + ], + "NetworkSettings": { + "Networks": { + "bridge": { + "Aliases": null, + "DriverOpts": null, + "EndpointID": "2914aa7399460c2e7d48e9c7ae17b29a099bbda9e4fd514ac4cb660c30edf677", + "Gateway": "172.17.0.1", + "GlobalIPv6Address": "", + "GlobalIPv6PrefixLen": 0, + "IPAMConfig": null, + "IPAddress": "172.17.0.2", + "IPPrefixLen": 16, + "IPv6Gateway": "", + "Links": null, + "MacAddress": "02:42:ac:11:00:02", + "NetworkID": "ed5a12608291f91d5c344f685f97665e3b7252d32c71ec15b10c55c3d8eb8235" + } + } + }, + "Ports": [ + { + "IP": "0.0.0.0", + "PrivatePort": 80, + "PublicPort": 49153, + "Type": "tcp" + }, + { + "IP": "::", + "PrivatePort": 80, + "PublicPort": 49153, + "Type": "tcp" + } + ], + "State": "running", + "Status": "Up 2 days" + } + ], + "Images": [ + { + "Containers": -1, + "Created": 1648010665, + "Id": "sha256:86a511f3915ac296298506d2caf827e9597483bf84115bbe4e1707829de8534a", + "Labels": null, + "ParentId": "sha256:d03d4e751a04005e130d20ca4f22b3074b1ffbfdcf6b59a5d85727e267d31d98", + "RepoDigests": [ + "prabhat/ubuntu@sha256:bfe1371854e7e0e28517a041bfda08ecb3b345738c62092bfdf04f0513c27219" + ], + "RepoTags": [ + "ubuntu-prabhat:latest", + "prabhat/ubuntu:latest" + ], + "SharedSize": -1, + "Size": 753642080, + "VirtualSize": 753642080 + }, + { + "Containers": -1, + "Created": 1647581440, + "Id": "sha256:ff0fea8310f3957d9b1e6ba494f3e4b63cb348c76160c6c15578e65995ffaa87", + "Labels": null, + "ParentId": "", + "RepoDigests": [ + "ubuntu@sha256:bea6d19168bbfd6af8d77c2cc3c572114eb5d113e6f422573c93cb605a0e2ffb" + ], + "RepoTags": [ + "ubuntu:latest" + ], + "SharedSize": -1, + "Size": 72759731, + "VirtualSize": 72759731 + }, + { + "Containers": -1, + "Created": 1647285410, + "Id": "sha256:6b8e87fff1072470bbfc957a735e7e46007177864a7f61bd9e0f5872d3d7b4a5", + "Labels": null, + "ParentId": "", + "RepoDigests": [ + "httpd@sha256:73496cbfc473872dd185154a3b96faa4407d773e893c6a7b9d8f977c331bc45d" + ], + "RepoTags": [ + "httpd:latest" + ], + "SharedSize": -1, + "Size": 143974476, + "VirtualSize": 143974476 + }, + { + "Containers": -1, + "Created": 1646799692, + "Id": "sha256:a4ca82e34b45e34c0a8bffe4e974983a528e8beca6030c1f9d17ac7f96c7847f", + "Labels": null, + "ParentId": "", + "RepoDigests": [ + "portainer/agent@sha256:ca1a51a745f2490cf5345883dd8c5f6a953a15251ab95af246ca9e2fb3436dde" + ], + "RepoTags": null, + "SharedSize": -1, + "Size": 154347153, + "VirtualSize": 154347153 + }, + { + "Containers": -1, + "Created": 1646143205, + "Id": "sha256:c919045c4c2b0b0007c606e763ed2c830c7b1d038ce878a3c0d6f5b81e6ab80b", + "Labels": { + "maintainer": "NGINX Docker Maintainers \u003cdocker-maint@nginx.com\u003e" + }, + "ParentId": "", + "RepoDigests": [ + "nginx@sha256:1c13bc6de5dfca749c377974146ac05256791ca2fe1979fc8e8278bf0121d285", + "prabhat/nginx@sha256:2468d48e476b6a079eb646e87620f96ce1818ac0c5b3a8450532cea64b3421f4" + ], + "RepoTags": [ + "nginx:latest", + "prabhat/nginx:latest" + ], + "SharedSize": -1, + "Size": 141505630, + "VirtualSize": 141505630 + }, + { + "Containers": -1, + "Created": 1551997193, + "Id": "sha256:6d1ef012b5674ad8a127ecfa9b5e6f5178d171b90ee462846974177fd9bdd39f", + "Labels": null, + "ParentId": "", + "RepoDigests": [ + "alpine@sha256:8421d9a84432575381bfabd248f1eb56f3aa21d9d7cd2511583c68c9b7511d10" + ], + "RepoTags": null, + "SharedSize": -1, + "Size": 4206494, + "VirtualSize": 4206494 + }, + { + "Containers": -1, + "Created": 1460421063, + "Id": "sha256:e2b3e8542af735080e6bda06873ce666e2319eea353884a88e45f3c9ef996846", + "Labels": {}, + "ParentId": "", + "RepoDigests": [ + "gcr.io/google-samples/gb-frontend@sha256:d44e7d7491a537f822e7fe8615437e4a8a08f3a7a1d7d4cb9066b92f7556ba6d" + ], + "RepoTags": [ + "gcr.io/google-samples/gb-frontend:v4" + ], + "SharedSize": -1, + "Size": 512161546, + "VirtualSize": 512161546 + }, + { + "Containers": -1, + "Created": 1439232099, + "Id": "sha256:5f026ddffa27f011242781f7f2498538334e173869e7fe757008881fb48180b6", + "Labels": null, + "ParentId": "", + "RepoDigests": [ + "gcr.io/google_samples/gb-redisslave@sha256:90f62695e641e1a27d1a5e0bbb8b622205a48e18311b51b0da419ffad24b9016" + ], + "RepoTags": [ + "gcr.io/google_samples/gb-redisslave:v1" + ], + "SharedSize": -1, + "Size": 109508753, + "VirtualSize": 109508753 + }, + { + "Containers": -1, + "Created": 1426838165, + "Id": "sha256:e5e67996c442f903cda78dd983ea6e94bb4e542950fd2eba666b44cbd303df42", + "Labels": null, + "ParentId": "", + "RepoDigests": [ + "k8s.gcr.io/redis@sha256:f066bcf26497fbc55b9bf0769cb13a35c0afa2aa42e737cc46b7fb04b23a2f25" + ], + "RepoTags": [ + "k8s.gcr.io/redis:e2e" + ], + "SharedSize": -1, + "Size": 419003740, + "VirtualSize": 419003740 + } + ], + "Info": { + "Architecture": "x86_64", + "BridgeNfIp6tables": true, + "BridgeNfIptables": true, + "CPUSet": true, + "CPUShares": true, + "CgroupDriver": "cgroupfs", + "ClusterAdvertise": "", + "ClusterStore": "", + "ContainerdCommit": { + "Expected": "2a1d4dbdb2a1030dc5b01e96fb110a9d9f150ecc", + "ID": "2a1d4dbdb2a1030dc5b01e96fb110a9d9f150ecc" + }, + "Containers": 5, + "ContainersPaused": 0, + "ContainersRunning": 5, + "ContainersStopped": 0, + "CpuCfsPeriod": true, + "CpuCfsQuota": true, + "Debug": false, + "DefaultRuntime": "runc", + "DockerRootDir": "/var/lib/docker", + "Driver": "overlay2", + "DriverStatus": [ + [ + "Backing Filesystem", + "extfs" + ], + [ + "Supports d_type", + "true" + ], + [ + "Native Overlay Diff", + "true" + ], + [ + "userxattr", + "false" + ] + ], + "ExperimentalBuild": false, + "GenericResources": null, + "HttpProxy": "", + "HttpsProxy": "", + "ID": "UQ2Y:ZHNN:XIZL:66ZK:NJCU:EO2L:BH35:SXHA:6TLU:AA25:PCAE:UQVE", + "IPv4Forwarding": true, + "Images": 13, + "IndexServerAddress": "https://index.docker.io/v1/", + "InitBinary": "docker-init", + "InitCommit": { + "Expected": "de40ad0", + "ID": "de40ad0" + }, + "Isolation": "", + "KernelMemory": true, + "KernelMemoryTCP": true, + "KernelVersion": "5.13.0-35-generic", + "Labels": [], + "LiveRestoreEnabled": false, + "LoggingDriver": "json-file", + "MemTotal": 25098706944, + "MemoryLimit": true, + "NCPU": 8, + "NEventsListener": 0, + "NFd": 813, + "NGoroutines": 68, + "Name": "prabhat-linux", + "NoProxy": "", + "OSType": "linux", + "OomKillDisable": true, + "OperatingSystem": "Zorin OS 16.1", + "PidsLimit": true, + "Plugins": { + "Authorization": null, + "Log": [ + "awslogs", + "fluentd", + "gcplogs", + "gelf", + "journald", + "json-file", + "local", + "logentries", + "splunk", + "syslog" + ], + "Network": [ + "bridge", + "host", + "ipvlan", + "macvlan", + "null", + "overlay" + ], + "Volume": [ + "local" + ] + }, + "RegistryConfig": { + "AllowNondistributableArtifactsCIDRs": [], + "AllowNondistributableArtifactsHostnames": [], + "IndexConfigs": { + "docker.io": { + "Mirrors": [], + "Name": "docker.io", + "Official": true, + "Secure": true + } + }, + "InsecureRegistryCIDRs": [ + "127.0.0.0/8" + ], + "Mirrors": [] + }, + "RuncCommit": { + "Expected": "v1.0.3-0-gf46b6ba", + "ID": "v1.0.3-0-gf46b6ba" + }, + "Runtimes": { + "io.containerd.runc.v2": { + "path": "runc" + }, + "io.containerd.runtime.v1.linux": { + "path": "runc" + }, + "runc": { + "path": "runc" + } + }, + "SecurityOptions": [ + "name=apparmor", + "name=seccomp,profile=default" + ], + "ServerVersion": "20.10.13", + "SwapLimit": true, + "Swarm": { + "ControlAvailable": false, + "Error": "", + "LocalNodeState": "inactive", + "NodeAddr": "", + "NodeID": "", + "RemoteManagers": null + }, + "SystemStatus": null, + "SystemTime": "2022-03-30T16:15:12.017274117+13:00", + "Warnings": null + }, + "Networks": [ + { + "Attachable": false, + "ConfigFrom": { + "Network": "" + }, + "ConfigOnly": false, + "Containers": {}, + "Created": "2022-03-28T08:57:24.093279463+13:00", + "Driver": "bridge", + "EnableIPv6": false, + "IPAM": { + "Config": [ + { + "Gateway": "172.17.0.1", + "Subnet": "172.17.0.0/16" + } + ], + "Driver": "default", + "Options": null + }, + "Id": "ed5a12608291f91d5c344f685f97665e3b7252d32c71ec15b10c55c3d8eb8235", + "Ingress": false, + "Internal": false, + "Labels": {}, + "Name": "bridge", + "Options": { + "com.docker.network.bridge.default_bridge": "true", + "com.docker.network.bridge.enable_icc": "true", + "com.docker.network.bridge.enable_ip_masquerade": "true", + "com.docker.network.bridge.host_binding_ipv4": "0.0.0.0", + "com.docker.network.bridge.name": "docker0", + "com.docker.network.driver.mtu": "1500" + }, + "Scope": "local" + }, + { + "Attachable": false, + "ConfigFrom": { + "Network": "" + }, + "ConfigOnly": false, + "Containers": {}, + "Created": "2022-03-03T23:31:59.297633861+13:00", + "Driver": "null", + "EnableIPv6": false, + "IPAM": { + "Config": [], + "Driver": "default", + "Options": null + }, + "Id": "33c89bff6ff59323ac1c9b10dc7c2920b994b03eca696cfdbabce802f236f6e2", + "Ingress": false, + "Internal": false, + "Labels": {}, + "Name": "none", + "Options": {}, + "Scope": "local" + }, + { + "Attachable": false, + "ConfigFrom": { + "Network": "" + }, + "ConfigOnly": false, + "Containers": {}, + "Created": "2022-03-03T23:31:59.309101595+13:00", + "Driver": "host", + "EnableIPv6": false, + "IPAM": { + "Config": [], + "Driver": "default", + "Options": null + }, + "Id": "968304627a446dbe28a9985cbc2b7b8a0414cbb400cfc6533d26dafc715ff175", + "Ingress": false, + "Internal": false, + "Labels": {}, + "Name": "host", + "Options": {}, + "Scope": "local" + }, + { + "Attachable": false, + "ConfigFrom": { + "Network": "" + }, + "ConfigOnly": false, + "Containers": {}, + "Created": "2022-03-28T10:01:18.600583916+13:00", + "Driver": "bridge", + "EnableIPv6": false, + "IPAM": { + "Config": [ + { + "Gateway": "172.18.0.1", + "Subnet": "172.18.0.0/16" + } + ], + "Driver": "default", + "Options": null + }, + "Id": "817c5491280dd743a47eb32119fe15eb0fc5bff118ae485dcd0d95673fdd55fd", + "Ingress": false, + "Internal": false, + "Labels": {}, + "Name": "docker_gwbridge", + "Options": { + "com.docker.network.bridge.enable_icc": "false", + "com.docker.network.bridge.enable_ip_masquerade": "true", + "com.docker.network.bridge.name": "docker_gwbridge" + }, + "Scope": "local" + }, + { + "Attachable": false, + "ConfigFrom": { + "Network": "" + }, + "ConfigOnly": false, + "Containers": {}, + "Created": "2022-03-30T16:11:59.214342406+13:00", + "Driver": "bridge", + "EnableIPv6": false, + "IPAM": { + "Config": [ + { + "Gateway": "172.19.0.1", + "Subnet": "172.19.0.0/16" + } + ], + "Driver": "default", + "Options": null + }, + "Id": "9fa60f4b6a71b29a95127e99ae0ba09f616386a58ae790f0e8f975e8029f791d", + "Ingress": false, + "Internal": false, + "Labels": {}, + "Name": "redis_default", + "Options": {}, + "Scope": "local" + }, + { + "Attachable": false, + "ConfigFrom": { + "Network": "" + }, + "ConfigOnly": false, + "Containers": {}, + "Created": "2022-03-30T16:12:52.93768265+13:00", + "Driver": "bridge", + "EnableIPv6": false, + "IPAM": { + "Config": [ + { + "Gateway": "172.20.0.1", + "Subnet": "172.20.0.0/16" + } + ], + "Driver": "default", + "Options": null + }, + "Id": "d9576d8c709d65504ca1d0a654cdc7b13ab17ebc6ae051f74e6b124d9d368c9a", + "Ingress": false, + "Internal": false, + "Labels": {}, + "Name": "nginx_default", + "Options": {}, + "Scope": "local" + } + ], + "Version": { + "ApiVersion": "1.41", + "Arch": "amd64", + "BuildTime": "2022-03-10T14:05:44.000000000+00:00", + "Components": [ + { + "Details": { + "ApiVersion": "1.41", + "Arch": "amd64", + "BuildTime": "2022-03-10T14:05:44.000000000+00:00", + "Experimental": "false", + "GitCommit": "906f57f", + "GoVersion": "go1.16.15", + "KernelVersion": "5.13.0-35-generic", + "MinAPIVersion": "1.12", + "Os": "linux" + }, + "Name": "Engine", + "Version": "20.10.13" + }, + { + "Details": { + "GitCommit": "2a1d4dbdb2a1030dc5b01e96fb110a9d9f150ecc" + }, + "Name": "containerd", + "Version": "1.5.10" + }, + { + "Details": { + "GitCommit": "v1.0.3-0-gf46b6ba" + }, + "Name": "runc", + "Version": "1.0.3" + }, + { + "Details": { + "GitCommit": "de40ad0" + }, + "Name": "docker-init", + "Version": "0.19.0" + } + ], + "GitCommit": "906f57f", + "GoVersion": "go1.16.15", + "KernelVersion": "5.13.0-35-generic", + "MinAPIVersion": "1.12", + "Os": "linux", + "Platform": { + "Name": "Docker Engine - Community" + }, + "Version": "20.10.13" + }, + "Volumes": { + "Volumes": [ + { + "CreatedAt": "2022-03-22T19:52:28+13:00", + "Driver": "local", + "Labels": null, + "Mountpoint": "/var/lib/docker/volumes/prabhat/_data", + "Name": "prabhat", + "Options": { + "device": "/home/prabhat/portainer/mounted", + "type": "tmpfs" + }, + "Scope": "local" + }, + { + "CreatedAt": "2022-03-18T13:51:39+13:00", + "Driver": "local", + "Labels": null, + "Mountpoint": "/var/lib/docker/volumes/bc9d2f97a920b6be6869dc69738b1e682234d3b530539986eb5343bcf5572983/_data", + "Name": "bc9d2f97a920b6be6869dc69738b1e682234d3b530539986eb5343bcf5572983", + "Options": null, + "Scope": "local" + }, + { + "CreatedAt": "2022-03-30T16:12:01+13:00", + "Driver": "local", + "Labels": null, + "Mountpoint": "/var/lib/docker/volumes/a3fedc4b90b70e9f28456b4f88f8a2ebd90f76cf8a8a5e4fb5dcbd0b90ff0153/_data", + "Name": "a3fedc4b90b70e9f28456b4f88f8a2ebd90f76cf8a8a5e4fb5dcbd0b90ff0153", + "Options": null, + "Scope": "local" + }, + { + "CreatedAt": "2022-03-30T16:12:01+13:00", + "Driver": "local", + "Labels": null, + "Mountpoint": "/var/lib/docker/volumes/5f93240d96e42d0b3728435cbfb43b6fcb3b01446d5c7d4be1cba8f9336c0a58/_data", + "Name": "5f93240d96e42d0b3728435cbfb43b6fcb3b01446d5c7d4be1cba8f9336c0a58", + "Options": null, + "Scope": "local" + }, + { + "CreatedAt": "2022-03-16T10:55:05+13:00", + "Driver": "local", + "Labels": null, + "Mountpoint": "/var/lib/docker/volumes/f1d6fe0188cc05f24309a86b58cf8130f89fa21aaa73f37789ad36fd188b38e2/_data", + "Name": "f1d6fe0188cc05f24309a86b58cf8130f89fa21aaa73f37789ad36fd188b38e2", + "Options": null, + "Scope": "local" + }, + { + "CreatedAt": "2022-03-09T12:05:37+13:00", + "Driver": "local", + "Labels": null, + "Mountpoint": "/var/lib/docker/volumes/83aea89faf9721ec0065ea37b32608a504088ac86a882069776be3899a0034a5/_data", + "Name": "83aea89faf9721ec0065ea37b32608a504088ac86a882069776be3899a0034a5", + "Options": null, + "Scope": "local" + }, + { + "CreatedAt": "2022-03-11T08:56:01+13:00", + "Driver": "local", + "Labels": null, + "Mountpoint": "/var/lib/docker/volumes/d38856ca6c6879a8c667c300aa6e4c35c2ba93d1a1b06ba78813141fd27b0697/_data", + "Name": "d38856ca6c6879a8c667c300aa6e4c35c2ba93d1a1b06ba78813141fd27b0697", + "Options": null, + "Scope": "local" + }, + { + "CreatedAt": "2022-03-22T22:06:49+13:00", + "Driver": "local", + "Labels": null, + "Mountpoint": "/var/lib/docker/volumes/pk/_data", + "Name": "pk", + "Options": {}, + "Scope": "local" + }, + { + "CreatedAt": "2022-03-22T22:02:46+13:00", + "Driver": "local", + "Labels": {}, + "Mountpoint": "/var/lib/docker/volumes/test-vol/_data", + "Name": "test-vol", + "Options": {}, + "Scope": "local" + }, + { + "CreatedAt": "2022-03-16T10:49:47+13:00", + "Driver": "local", + "Labels": null, + "Mountpoint": "/var/lib/docker/volumes/326d246e9371e4b426b018a7093a522f699a79bf6dda185314ef6683747a5836/_data", + "Name": "326d246e9371e4b426b018a7093a522f699a79bf6dda185314ef6683747a5836", + "Options": null, + "Scope": "local" + } + ], + "Warnings": null + } + }, + "StackCount": 2, + "StoppedContainerCount": 0, + "Swarm": false, + "Time": 1648610112, + "TotalCPU": 8, + "TotalMemory": 25098706944, + "UnhealthyContainerCount": 0, + "VolumeCount": 10 + } + ], + "Status": 1, + "TLSConfig": { + "TLS": false, + "TLSSkipVerify": false + }, + "TagIds": [], + "Tags": null, + "TeamAccessPolicies": {}, + "Type": 1, + "URL": "unix:///var/run/docker.sock", + "UserAccessPolicies": {} + } + ], + "registries": [ + { + "Authentication": true, + "AuthorizedTeams": null, + "AuthorizedUsers": null, + "Gitlab": { + "InstanceURL": "", + "ProjectId": 0, + "ProjectPath": "" + }, + "Id": 1, + "ManagementConfiguration": null, + "Name": "canister.io", + "Password": "MjWbx8A6YK7cw7", + "TeamAccessPolicies": {}, + "Type": 3, + "URL": "cloud.canister.io:5000", + "UserAccessPolicies": {}, + "Username": "prabhatkhera" + } + ], + "resource_control": [ + { + "AdministratorsOnly": false, + "Id": 2, + "Public": true, + "ResourceId": "762gbwaj8r4gcsdy8ld1u4why", + "SubResourceIds": [], + "System": false, + "TeamAccesses": [], + "Type": 5, + "UserAccesses": [] + }, + { + "AdministratorsOnly": false, + "Id": 3, + "Public": true, + "ResourceId": "alpine", + "SubResourceIds": [], + "System": false, + "TeamAccesses": [], + "Type": 6, + "UserAccesses": [] + }, + { + "AdministratorsOnly": false, + "Id": 4, + "Public": true, + "ResourceId": "redis", + "SubResourceIds": [], + "System": false, + "TeamAccesses": [], + "Type": 6, + "UserAccesses": [] + }, + { + "AdministratorsOnly": false, + "Id": 5, + "Public": false, + "ResourceId": "nginx", + "SubResourceIds": [], + "System": false, + "TeamAccesses": [ + { + "AccessLevel": 1, + "TeamId": 1 + } + ], + "Type": 6, + "UserAccesses": [] + } + ], + "roles": [ + { + "Authorizations": { + "DockerAgentBrowseDelete": true, + "DockerAgentBrowseGet": true, + "DockerAgentBrowseList": true, + "DockerAgentBrowsePut": true, + "DockerAgentBrowseRename": true, + "DockerAgentHostInfo": true, + "DockerAgentList": true, + "DockerAgentPing": true, + "DockerAgentUndefined": true, + "DockerBuildCancel": true, + "DockerBuildPrune": true, + "DockerConfigCreate": true, + "DockerConfigDelete": true, + "DockerConfigInspect": true, + "DockerConfigList": true, + "DockerConfigUpdate": true, + "DockerContainerArchive": true, + "DockerContainerArchiveInfo": true, + "DockerContainerAttach": true, + "DockerContainerAttachWebsocket": true, + "DockerContainerChanges": true, + "DockerContainerCreate": true, + "DockerContainerDelete": true, + "DockerContainerExec": true, + "DockerContainerExport": true, + "DockerContainerInspect": true, + "DockerContainerKill": true, + "DockerContainerList": true, + "DockerContainerLogs": true, + "DockerContainerPause": true, + "DockerContainerPrune": true, + "DockerContainerPutContainerArchive": true, + "DockerContainerRename": true, + "DockerContainerResize": true, + "DockerContainerRestart": true, + "DockerContainerStart": true, + "DockerContainerStats": true, + "DockerContainerStop": true, + "DockerContainerTop": true, + "DockerContainerUnpause": true, + "DockerContainerUpdate": true, + "DockerContainerWait": true, + "DockerDistributionInspect": true, + "DockerEvents": true, + "DockerExecInspect": true, + "DockerExecResize": true, + "DockerExecStart": true, + "DockerImageBuild": true, + "DockerImageCommit": true, + "DockerImageCreate": true, + "DockerImageDelete": true, + "DockerImageGet": true, + "DockerImageGetAll": true, + "DockerImageHistory": true, + "DockerImageInspect": true, + "DockerImageList": true, + "DockerImageLoad": true, + "DockerImagePrune": true, + "DockerImagePush": true, + "DockerImageSearch": true, + "DockerImageTag": true, + "DockerInfo": true, + "DockerNetworkConnect": true, + "DockerNetworkCreate": true, + "DockerNetworkDelete": true, + "DockerNetworkDisconnect": true, + "DockerNetworkInspect": true, + "DockerNetworkList": true, + "DockerNetworkPrune": true, + "DockerNodeDelete": true, + "DockerNodeInspect": true, + "DockerNodeList": true, + "DockerNodeUpdate": true, + "DockerPing": true, + "DockerPluginCreate": true, + "DockerPluginDelete": true, + "DockerPluginDisable": true, + "DockerPluginEnable": true, + "DockerPluginInspect": true, + "DockerPluginList": true, + "DockerPluginPrivileges": true, + "DockerPluginPull": true, + "DockerPluginPush": true, + "DockerPluginSet": true, + "DockerPluginUpgrade": true, + "DockerSecretCreate": true, + "DockerSecretDelete": true, + "DockerSecretInspect": true, + "DockerSecretList": true, + "DockerSecretUpdate": true, + "DockerServiceCreate": true, + "DockerServiceDelete": true, + "DockerServiceInspect": true, + "DockerServiceList": true, + "DockerServiceLogs": true, + "DockerServiceUpdate": true, + "DockerSessionStart": true, + "DockerSwarmInit": true, + "DockerSwarmInspect": true, + "DockerSwarmJoin": true, + "DockerSwarmLeave": true, + "DockerSwarmUnlock": true, + "DockerSwarmUnlockKey": true, + "DockerSwarmUpdate": true, + "DockerSystem": true, + "DockerTaskInspect": true, + "DockerTaskList": true, + "DockerTaskLogs": true, + "DockerUndefined": true, + "DockerVersion": true, + "DockerVolumeCreate": true, + "DockerVolumeDelete": true, + "DockerVolumeInspect": true, + "DockerVolumeList": true, + "DockerVolumePrune": true, + "EndpointResourcesAccess": true, + "IntegrationStoridgeAdmin": true, + "PortainerResourceControlCreate": true, + "PortainerResourceControlUpdate": true, + "PortainerStackCreate": true, + "PortainerStackDelete": true, + "PortainerStackFile": true, + "PortainerStackInspect": true, + "PortainerStackList": true, + "PortainerStackMigrate": true, + "PortainerStackUpdate": true, + "PortainerWebhookCreate": true, + "PortainerWebhookDelete": true, + "PortainerWebhookList": true, + "PortainerWebsocketExec": true + }, + "Description": "Full control of all resources in an endpoint", + "Id": 1, + "Name": "Endpoint administrator", + "Priority": 1 + }, + { + "Authorizations": { + "DockerAgentHostInfo": true, + "DockerAgentList": true, + "DockerAgentPing": true, + "DockerConfigInspect": true, + "DockerConfigList": true, + "DockerContainerArchiveInfo": true, + "DockerContainerChanges": true, + "DockerContainerInspect": true, + "DockerContainerList": true, + "DockerContainerLogs": true, + "DockerContainerStats": true, + "DockerContainerTop": true, + "DockerDistributionInspect": true, + "DockerEvents": true, + "DockerImageGet": true, + "DockerImageGetAll": true, + "DockerImageHistory": true, + "DockerImageInspect": true, + "DockerImageList": true, + "DockerImageSearch": true, + "DockerInfo": true, + "DockerNetworkInspect": true, + "DockerNetworkList": true, + "DockerNodeInspect": true, + "DockerNodeList": true, + "DockerPing": true, + "DockerPluginList": true, + "DockerSecretInspect": true, + "DockerSecretList": true, + "DockerServiceInspect": true, + "DockerServiceList": true, + "DockerServiceLogs": true, + "DockerSwarmInspect": true, + "DockerSystem": true, + "DockerTaskInspect": true, + "DockerTaskList": true, + "DockerTaskLogs": true, + "DockerVersion": true, + "DockerVolumeInspect": true, + "DockerVolumeList": true, + "EndpointResourcesAccess": true, + "PortainerStackFile": true, + "PortainerStackInspect": true, + "PortainerStackList": true, + "PortainerWebhookList": true + }, + "Description": "Read-only access of all resources in an endpoint", + "Id": 2, + "Name": "Helpdesk", + "Priority": 2 + }, + { + "Authorizations": { + "DockerAgentHostInfo": true, + "DockerAgentList": true, + "DockerAgentPing": true, + "DockerAgentUndefined": true, + "DockerBuildCancel": true, + "DockerBuildPrune": true, + "DockerConfigCreate": true, + "DockerConfigDelete": true, + "DockerConfigInspect": true, + "DockerConfigList": true, + "DockerConfigUpdate": true, + "DockerContainerArchive": true, + "DockerContainerArchiveInfo": true, + "DockerContainerAttach": true, + "DockerContainerAttachWebsocket": true, + "DockerContainerChanges": true, + "DockerContainerCreate": true, + "DockerContainerDelete": true, + "DockerContainerExec": true, + "DockerContainerExport": true, + "DockerContainerInspect": true, + "DockerContainerKill": true, + "DockerContainerList": true, + "DockerContainerLogs": true, + "DockerContainerPause": true, + "DockerContainerPutContainerArchive": true, + "DockerContainerRename": true, + "DockerContainerResize": true, + "DockerContainerRestart": true, + "DockerContainerStart": true, + "DockerContainerStats": true, + "DockerContainerStop": true, + "DockerContainerTop": true, + "DockerContainerUnpause": true, + "DockerContainerUpdate": true, + "DockerContainerWait": true, + "DockerDistributionInspect": true, + "DockerEvents": true, + "DockerExecInspect": true, + "DockerExecResize": true, + "DockerExecStart": true, + "DockerImageBuild": true, + "DockerImageCommit": true, + "DockerImageCreate": true, + "DockerImageDelete": true, + "DockerImageGet": true, + "DockerImageGetAll": true, + "DockerImageHistory": true, + "DockerImageInspect": true, + "DockerImageList": true, + "DockerImageLoad": true, + "DockerImagePush": true, + "DockerImageSearch": true, + "DockerImageTag": true, + "DockerInfo": true, + "DockerNetworkConnect": true, + "DockerNetworkCreate": true, + "DockerNetworkDelete": true, + "DockerNetworkDisconnect": true, + "DockerNetworkInspect": true, + "DockerNetworkList": true, + "DockerNodeDelete": true, + "DockerNodeInspect": true, + "DockerNodeList": true, + "DockerNodeUpdate": true, + "DockerPing": true, + "DockerPluginCreate": true, + "DockerPluginDelete": true, + "DockerPluginDisable": true, + "DockerPluginEnable": true, + "DockerPluginInspect": true, + "DockerPluginList": true, + "DockerPluginPrivileges": true, + "DockerPluginPull": true, + "DockerPluginPush": true, + "DockerPluginSet": true, + "DockerPluginUpgrade": true, + "DockerSecretCreate": true, + "DockerSecretDelete": true, + "DockerSecretInspect": true, + "DockerSecretList": true, + "DockerSecretUpdate": true, + "DockerServiceCreate": true, + "DockerServiceDelete": true, + "DockerServiceInspect": true, + "DockerServiceList": true, + "DockerServiceLogs": true, + "DockerServiceUpdate": true, + "DockerSessionStart": true, + "DockerSwarmInit": true, + "DockerSwarmInspect": true, + "DockerSwarmJoin": true, + "DockerSwarmLeave": true, + "DockerSwarmUnlock": true, + "DockerSwarmUnlockKey": true, + "DockerSwarmUpdate": true, + "DockerSystem": true, + "DockerTaskInspect": true, + "DockerTaskList": true, + "DockerTaskLogs": true, + "DockerUndefined": true, + "DockerVersion": true, + "DockerVolumeCreate": true, + "DockerVolumeDelete": true, + "DockerVolumeInspect": true, + "DockerVolumeList": true, + "PortainerResourceControlUpdate": true, + "PortainerStackCreate": true, + "PortainerStackDelete": true, + "PortainerStackFile": true, + "PortainerStackInspect": true, + "PortainerStackList": true, + "PortainerStackMigrate": true, + "PortainerStackUpdate": true, + "PortainerWebhookCreate": true, + "PortainerWebhookList": true, + "PortainerWebsocketExec": true + }, + "Description": "Full control of assigned resources in an endpoint", + "Id": 3, + "Name": "Standard user", + "Priority": 3 + }, + { + "Authorizations": { + "DockerAgentHostInfo": true, + "DockerAgentList": true, + "DockerAgentPing": true, + "DockerConfigInspect": true, + "DockerConfigList": true, + "DockerContainerArchiveInfo": true, + "DockerContainerChanges": true, + "DockerContainerInspect": true, + "DockerContainerList": true, + "DockerContainerLogs": true, + "DockerContainerStats": true, + "DockerContainerTop": true, + "DockerDistributionInspect": true, + "DockerEvents": true, + "DockerImageGet": true, + "DockerImageGetAll": true, + "DockerImageHistory": true, + "DockerImageInspect": true, + "DockerImageList": true, + "DockerImageSearch": true, + "DockerInfo": true, + "DockerNetworkInspect": true, + "DockerNetworkList": true, + "DockerNodeInspect": true, + "DockerNodeList": true, + "DockerPing": true, + "DockerPluginList": true, + "DockerSecretInspect": true, + "DockerSecretList": true, + "DockerServiceInspect": true, + "DockerServiceList": true, + "DockerServiceLogs": true, + "DockerSwarmInspect": true, + "DockerSystem": true, + "DockerTaskInspect": true, + "DockerTaskList": true, + "DockerTaskLogs": true, + "DockerVersion": true, + "DockerVolumeInspect": true, + "DockerVolumeList": true, + "PortainerStackFile": true, + "PortainerStackInspect": true, + "PortainerStackList": true, + "PortainerWebhookList": true + }, + "Description": "Read-only access of assigned resources in an endpoint", + "Id": 4, + "Name": "Read-only user", + "Priority": 4 + } + ], + "schedules": [ + { + "Created": 1648608136, + "CronExpression": "@every 5m", + "EdgeSchedule": null, + "EndpointSyncJob": null, + "Id": 1, + "JobType": 2, + "Name": "system_snapshot", + "Recurring": true, + "ScriptExecutionJob": null, + "SnapshotJob": {} + } + ], + "settings": { + "AllowBindMountsForRegularUsers": true, + "AllowContainerCapabilitiesForRegularUsers": true, + "AllowDeviceMappingForRegularUsers": true, + "AllowHostNamespaceForRegularUsers": true, + "AllowPrivilegedModeForRegularUsers": true, + "AllowStackManagementForRegularUsers": true, + "AllowVolumeBrowserForRegularUsers": false, + "AuthenticationMethod": 1, + "BlackListedLabels": [], + "DisplayDonationHeader": false, + "DisplayExternalContributors": false, + "EdgeAgentCheckinInterval": 5, + "EnableEdgeComputeFeatures": false, + "EnableHostManagementFeatures": false, + "LDAPSettings": { + "AnonymousMode": true, + "AutoCreateUsers": true, + "GroupSearchSettings": [ + { + "GroupAttribute": "", + "GroupBaseDN": "", + "GroupFilter": "" + } + ], + "ReaderDN": "", + "SearchSettings": [ + { + "BaseDN": "", + "Filter": "", + "UserNameAttribute": "" + } + ], + "StartTLS": false, + "TLSConfig": { + "TLS": false, + "TLSSkipVerify": false + }, + "URL": "" + }, + "LogoURL": "", + "OAuthSettings": { + "AccessTokenURI": "", + "AuthorizationURI": "", + "ClientID": "", + "DefaultTeamID": 0, + "OAuthAutoCreateUsers": false, + "RedirectURI": "", + "ResourceURI": "", + "Scopes": "", + "UserIdentifier": "" + }, + "SnapshotInterval": "5m", + "TemplatesURL": "" + }, + "stacks": [ + { + "EndpointId": 1, + "EntryPoint": "docker/alpine37-compose.yml", + "Env": [], + "Id": 2, + "Name": "alpine", + "ProjectPath": "/home/prabhat/portainer/data/ce1.25/compose/2", + "ResourceControl": null, + "SwarmId": "s3fd604zdba7z13tbq2x6lyue", + "Type": 1 + }, + { + "EndpointId": 1, + "EntryPoint": "docker-compose.yml", + "Env": [], + "Id": 5, + "Name": "redis", + "ProjectPath": "/home/prabhat/portainer/data/ce1.25/compose/5", + "ResourceControl": null, + "SwarmId": "", + "Type": 2 + }, + { + "EndpointId": 1, + "EntryPoint": "docker-compose.yml", + "Env": [], + "Id": 6, + "Name": "nginx", + "ProjectPath": "/home/prabhat/portainer/data/ce1.25/compose/6", + "ResourceControl": null, + "SwarmId": "", + "Type": 2 + } + ], + "teams": [ + { + "Id": 1, + "Name": "hello" + } + ], + "templates": [ + { + "Id": 1, + "administrator_only": false, + "categories": [ + "docker" + ], + "description": "Docker image registry", + "image": "registry:latest", + "logo": "https://portainer-io-assets.sfo2.digitaloceanspaces.com/logos/registry.png", + "platform": "linux", + "ports": [ + "5000/tcp" + ], + "repository": { + "stackfile": "", + "url": "" + }, + "stackFile": "", + "title": "Registry", + "type": 1, + "volumes": [ + { + "container": "/var/lib/registry" + } + ] + }, + { + "Id": 2, + "administrator_only": false, + "categories": [ + "webserver" + ], + "description": "High performance web server", + "image": "nginx:latest", + "logo": "https://portainer-io-assets.sfo2.digitaloceanspaces.com/logos/nginx.png", + "platform": "linux", + "ports": [ + "80/tcp", + "443/tcp" + ], + "repository": { + "stackfile": "", + "url": "" + }, + "stackFile": "", + "title": "Nginx", + "type": 1, + "volumes": [ + { + "container": "/etc/nginx" + }, + { + "container": "/usr/share/nginx/html" + } + ] + }, + { + "Id": 3, + "administrator_only": false, + "categories": [ + "webserver" + ], + "description": "Open-source HTTP server", + "image": "httpd:latest", + "logo": "https://portainer-io-assets.sfo2.digitaloceanspaces.com/logos/httpd.png", + "platform": "linux", + "ports": [ + "80/tcp" + ], + "repository": { + "stackfile": "", + "url": "" + }, + "stackFile": "", + "title": "Httpd", + "type": 1, + "volumes": [ + { + "container": "/usr/local/apache2/htdocs/" + } + ] + }, + { + "Id": 4, + "administrator_only": false, + "categories": [ + "webserver" + ], + "description": "HTTP/2 web server with automatic HTTPS", + "image": "abiosoft/caddy:latest", + "logo": "https://portainer-io-assets.sfo2.digitaloceanspaces.com/logos/caddy.png", + "platform": "linux", + "ports": [ + "80/tcp", + "443/tcp", + "2015/tcp" + ], + "repository": { + "stackfile": "", + "url": "" + }, + "stackFile": "", + "title": "Caddy", + "type": 1, + "volumes": [ + { + "container": "/root/.caddy" + } + ] + }, + { + "Id": 5, + "administrator_only": false, + "categories": [ + "database" + ], + "description": "The most popular open-source database", + "env": [ + { + "label": "Root password", + "name": "MYSQL_ROOT_PASSWORD" + } + ], + "image": "mysql:latest", + "logo": "https://portainer-io-assets.sfo2.digitaloceanspaces.com/logos/mysql.png", + "platform": "linux", + "ports": [ + "3306/tcp" + ], + "repository": { + "stackfile": "", + "url": "" + }, + "stackFile": "", + "title": "MySQL", + "type": 1, + "volumes": [ + { + "container": "/var/lib/mysql" + } + ] + }, + { + "Id": 6, + "administrator_only": false, + "categories": [ + "database" + ], + "description": "Performance beyond MySQL", + "env": [ + { + "label": "Root password", + "name": "MYSQL_ROOT_PASSWORD" + } + ], + "image": "mariadb:latest", + "logo": "https://portainer-io-assets.sfo2.digitaloceanspaces.com/logos/mariadb.png", + "platform": "linux", + "ports": [ + "3306/tcp" + ], + "repository": { + "stackfile": "", + "url": "" + }, + "stackFile": "", + "title": "MariaDB", + "type": 1, + "volumes": [ + { + "container": "/var/lib/mysql" + } + ] + }, + { + "Id": 7, + "administrator_only": false, + "categories": [ + "database" + ], + "description": "The most advanced open-source database", + "env": [ + { + "label": "Superuser", + "name": "POSTGRES_USER" + }, + { + "label": "Superuser password", + "name": "POSTGRES_PASSWORD" + } + ], + "image": "postgres:latest", + "logo": "https://portainer-io-assets.sfo2.digitaloceanspaces.com/logos/postgres.png", + "platform": "linux", + "ports": [ + "5432/tcp" + ], + "repository": { + "stackfile": "", + "url": "" + }, + "stackFile": "", + "title": "PostgreSQL", + "type": 1, + "volumes": [ + { + "container": "/var/lib/postgresql/data" + } + ] + }, + { + "Id": 8, + "administrator_only": false, + "categories": [ + "database" + ], + "description": "Open-source document-oriented database", + "image": "mongo:latest", + "logo": "https://portainer-io-assets.sfo2.digitaloceanspaces.com/logos/mongo.png", + "platform": "linux", + "ports": [ + "27017/tcp" + ], + "repository": { + "stackfile": "", + "url": "" + }, + "stackFile": "", + "title": "Mongo", + "type": 1, + "volumes": [ + { + "container": "/data/db" + } + ] + }, + { + "Id": 9, + "administrator_only": false, + "categories": [ + "database" + ], + "command": "start --insecure", + "description": "An open-source, survivable, strongly consistent, scale-out SQL database", + "image": "cockroachdb/cockroach:latest", + "logo": "https://portainer-io-assets.sfo2.digitaloceanspaces.com/logos/cockroachdb.png", + "platform": "linux", + "ports": [ + "26257/tcp", + "8080/tcp" + ], + "repository": { + "stackfile": "", + "url": "" + }, + "stackFile": "", + "title": "CockroachDB", + "type": 1, + "volumes": [ + { + "container": "/cockroach/cockroach-data" + } + ] + }, + { + "Id": 10, + "administrator_only": false, + "categories": [ + "database" + ], + "description": "An open-source distributed SQL database", + "image": "crate:latest", + "logo": "https://portainer-io-assets.sfo2.digitaloceanspaces.com/logos/cratedb.png", + "platform": "linux", + "ports": [ + "4200/tcp", + "4300/tcp" + ], + "repository": { + "stackfile": "", + "url": "" + }, + "stackFile": "", + "title": "CrateDB", + "type": 1, + "volumes": [ + { + "container": "/data" + } + ] + }, + { + "Id": 11, + "administrator_only": false, + "categories": [ + "database" + ], + "description": "Open-source search and analytics engine", + "image": "elasticsearch:latest", + "logo": "https://portainer-io-assets.sfo2.digitaloceanspaces.com/logos/elasticsearch.png", + "platform": "linux", + "ports": [ + "9200/tcp", + "9300/tcp" + ], + "repository": { + "stackfile": "", + "url": "" + }, + "stackFile": "", + "title": "Elasticsearch", + "type": 1, + "volumes": [ + { + "container": "/usr/share/elasticsearch/data" + } + ] + }, + { + "Id": 12, + "administrator_only": false, + "categories": [ + "development", + "project-management" + ], + "description": "Open-source end-to-end software development platform", + "image": "gitlab/gitlab-ce:latest", + "logo": "https://portainer-io-assets.sfo2.digitaloceanspaces.com/logos/gitlab_ce.png", + "note": "Default username is \u003cb\u003eroot\u003c/b\u003e. Check the \u003ca href=\"https://docs.gitlab.com/omnibus/docker/README.html#after-starting-a-container\" target=\"_blank\"\u003eGitlab documentation\u003c/a\u003e to get started.", + "platform": "linux", + "ports": [ + "80/tcp", + "443/tcp", + "22/tcp" + ], + "repository": { + "stackfile": "", + "url": "" + }, + "stackFile": "", + "title": "Gitlab CE", + "type": 1, + "volumes": [ + { + "container": "/etc/gitlab" + }, + { + "container": "/var/log/gitlab" + }, + { + "container": "/var/opt/gitlab" + } + ] + }, + { + "Id": 13, + "administrator_only": false, + "categories": [ + "storage" + ], + "command": "server /data", + "description": "A distributed object storage server built for cloud applications and devops", + "env": [ + { + "label": "Minio access key", + "name": "MINIO_ACCESS_KEY" + }, + { + "label": "Minio secret key", + "name": "MINIO_SECRET_KEY" + } + ], + "image": "minio/minio:latest", + "logo": "https://portainer-io-assets.sfo2.digitaloceanspaces.com/logos/minio.png", + "platform": "linux", + "ports": [ + "9000/tcp" + ], + "repository": { + "stackfile": "", + "url": "" + }, + "stackFile": "", + "title": "Minio", + "type": 1, + "volumes": [ + { + "container": "/data" + }, + { + "container": "/root/.minio" + } + ] + }, + { + "Id": 14, + "administrator_only": false, + "categories": [ + "storage" + ], + "description": "Standalone AWS S3 protocol server", + "env": [ + { + "label": "Scality S3 access key", + "name": "SCALITY_ACCESS_KEY" + }, + { + "label": "Scality S3 secret key", + "name": "SCALITY_SECRET_KEY" + } + ], + "image": "scality/s3server", + "logo": "https://portainer-io-assets.sfo2.digitaloceanspaces.com/logos/scality-s3.png", + "platform": "linux", + "ports": [ + "8000/tcp" + ], + "repository": { + "stackfile": "", + "url": "" + }, + "stackFile": "", + "title": "Scality S3", + "type": 1, + "volumes": [ + { + "container": "/usr/src/app/localData" + }, + { + "container": "/usr/src/app/localMetadata" + } + ] + }, + { + "Id": 15, + "administrator_only": false, + "categories": [ + "database" + ], + "description": "Microsoft SQL Server on Linux", + "env": [ + { + "name": "ACCEPT_EULA" + }, + { + "label": "SA password", + "name": "SA_PASSWORD" + } + ], + "image": "microsoft/mssql-server-linux:2017-GA", + "logo": "https://portainer-io-assets.sfo2.digitaloceanspaces.com/logos/microsoft.png", + "note": "Password needs to include at least 8 characters including uppercase, lowercase letters, base-10 digits and/or non-alphanumeric symbols.", + "platform": "linux", + "ports": [ + "1433/tcp" + ], + "repository": { + "stackfile": "", + "url": "" + }, + "stackFile": "", + "title": "SQL Server", + "type": 1 + }, + { + "Id": 16, + "administrator_only": false, + "categories": [ + "database" + ], + "description": "Microsoft SQL Server Developer for Windows containers", + "env": [ + { + "name": "ACCEPT_EULA" + }, + { + "label": "SA password", + "name": "sa_password" + } + ], + "image": "microsoft/mssql-server-windows-developer:latest", + "logo": "https://portainer-io-assets.sfo2.digitaloceanspaces.com/logos/microsoft.png", + "note": "Password needs to include at least 8 characters including uppercase, lowercase letters, base-10 digits and/or non-alphanumeric symbols.", + "platform": "windows", + "ports": [ + "1433/tcp" + ], + "repository": { + "stackfile": "", + "url": "" + }, + "stackFile": "", + "title": "SQL Server", + "type": 1, + "volumes": [ + { + "container": "C:/temp/" + } + ] + }, + { + "Id": 17, + "administrator_only": false, + "categories": [ + "database" + ], + "description": "Microsoft SQL Server Express for Windows containers", + "env": [ + { + "name": "ACCEPT_EULA" + }, + { + "label": "SA password", + "name": "sa_password" + } + ], + "image": "microsoft/mssql-server-windows-express:latest", + "logo": "https://portainer-io-assets.sfo2.digitaloceanspaces.com/logos/microsoft.png", + "note": "Password needs to include at least 8 characters including uppercase, lowercase letters, base-10 digits and/or non-alphanumeric symbols.", + "platform": "windows", + "ports": [ + "1433/tcp" + ], + "repository": { + "stackfile": "", + "url": "" + }, + "stackFile": "", + "title": "SQL Server Express", + "type": 1, + "volumes": [ + { + "container": "C:/temp/" + } + ] + }, + { + "Id": 18, + "administrator_only": false, + "categories": [ + "serverless" + ], + "description": "Open-source serverless computing platform", + "image": "iron/functions:latest", + "logo": "https://portainer-io-assets.sfo2.digitaloceanspaces.com/logos/ironfunctions.png", + "platform": "linux", + "ports": [ + "8080/tcp" + ], + "privileged": true, + "repository": { + "stackfile": "", + "url": "" + }, + "stackFile": "", + "title": "IronFunctions API", + "type": 1, + "volumes": [ + { + "container": "/app/data" + } + ] + }, + { + "Id": 19, + "administrator_only": false, + "categories": [ + "serverless" + ], + "description": "Open-source user interface for IronFunctions", + "env": [ + { + "label": "API URL", + "name": "API_URL" + } + ], + "image": "iron/functions-ui:latest", + "logo": "https://portainer-io-assets.sfo2.digitaloceanspaces.com/logos/ironfunctions.png", + "platform": "linux", + "ports": [ + "4000/tcp" + ], + "privileged": true, + "repository": { + "stackfile": "", + "url": "" + }, + "stackFile": "", + "title": "IronFunctions UI", + "type": 1, + "volumes": [ + { + "container": "/app/data" + } + ] + }, + { + "Id": 20, + "administrator_only": false, + "categories": [ + "search-engine" + ], + "description": "Open-source enterprise search platform", + "image": "solr:latest", + "logo": "https://portainer-io-assets.sfo2.digitaloceanspaces.com/logos/solr.png", + "platform": "linux", + "ports": [ + "8983/tcp" + ], + "repository": { + "stackfile": "", + "url": "" + }, + "stackFile": "", + "title": "Solr", + "type": 1, + "volumes": [ + { + "container": "/opt/solr/mydata" + } + ] + }, + { + "Id": 21, + "administrator_only": false, + "categories": [ + "database" + ], + "description": "Open-source in-memory data structure store", + "image": "redis:latest", + "logo": "https://portainer-io-assets.sfo2.digitaloceanspaces.com/logos/redis.png", + "platform": "linux", + "ports": [ + "6379/tcp" + ], + "repository": { + "stackfile": "", + "url": "" + }, + "stackFile": "", + "title": "Redis", + "type": 1, + "volumes": [ + { + "container": "/data" + } + ] + }, + { + "Id": 22, + "administrator_only": false, + "categories": [ + "messaging" + ], + "description": "Highly reliable enterprise messaging system", + "image": "rabbitmq:latest", + "logo": "https://portainer-io-assets.sfo2.digitaloceanspaces.com/logos/rabbitmq.png", + "platform": "linux", + "ports": [ + "5671/tcp", + "5672/tcp" + ], + "repository": { + "stackfile": "", + "url": "" + }, + "stackFile": "", + "title": "RabbitMQ", + "type": 1, + "volumes": [ + { + "container": "/var/lib/rabbitmq" + } + ] + }, + { + "Id": 23, + "administrator_only": false, + "categories": [ + "blog" + ], + "description": "Free and open-source blogging platform", + "image": "ghost:latest", + "logo": "https://portainer-io-assets.sfo2.digitaloceanspaces.com/logos/ghost.png", + "note": "Access the blog management interface under \u003ccode\u003e/ghost/\u003c/code\u003e.", + "platform": "linux", + "ports": [ + "2368/tcp" + ], + "repository": { + "stackfile": "", + "url": "" + }, + "stackFile": "", + "title": "Ghost", + "type": 1, + "volumes": [ + { + "container": "/var/lib/ghost/content" + } + ] + }, + { + "Id": 24, + "administrator_only": false, + "categories": [ + "CMS" + ], + "description": "WebOps platform and hosting control panel", + "image": "plesk/plesk:preview", + "logo": "https://portainer-io-assets.sfo2.digitaloceanspaces.com/logos/plesk.png", + "note": "Default credentials: admin / changeme", + "platform": "linux", + "ports": [ + "21/tcp", + "80/tcp", + "443/tcp", + "8880/tcp", + "8443/tcp", + "8447/tcp" + ], + "repository": { + "stackfile": "", + "url": "" + }, + "stackFile": "", + "title": "Plesk", + "type": 1 + }, + { + "Id": 25, + "administrator_only": false, + "categories": [ + "CMS" + ], + "description": "Another free and open-source CMS", + "env": [ + { + "label": "MySQL database host", + "name": "JOOMLA_DB_HOST" + }, + { + "label": "Database password", + "name": "JOOMLA_DB_PASSWORD" + } + ], + "image": "joomla:latest", + "logo": "https://portainer-io-assets.sfo2.digitaloceanspaces.com/logos/joomla.png", + "platform": "linux", + "ports": [ + "80/tcp" + ], + "repository": { + "stackfile": "", + "url": "" + }, + "stackFile": "", + "title": "Joomla", + "type": 1, + "volumes": [ + { + "container": "/var/www/html" + } + ] + }, + { + "Id": 26, + "administrator_only": false, + "categories": [ + "CMS" + ], + "description": "Open-source content management framework", + "image": "drupal:latest", + "logo": "https://portainer-io-assets.sfo2.digitaloceanspaces.com/logos/drupal.png", + "platform": "linux", + "ports": [ + "80/tcp" + ], + "repository": { + "stackfile": "", + "url": "" + }, + "stackFile": "", + "title": "Drupal", + "type": 1, + "volumes": [ + { + "container": "/var/www/html" + } + ] + }, + { + "Id": 27, + "administrator_only": false, + "categories": [ + "CMS" + ], + "description": "A free and open-source CMS built on top of Zope", + "image": "plone:latest", + "logo": "https://portainer-io-assets.sfo2.digitaloceanspaces.com/logos/plone.png", + "platform": "linux", + "ports": [ + "8080/tcp" + ], + "repository": { + "stackfile": "", + "url": "" + }, + "stackFile": "", + "title": "Plone", + "type": 1, + "volumes": [ + { + "container": "/data" + } + ] + }, + { + "Id": 28, + "administrator_only": false, + "categories": [ + "CMS" + ], + "description": "Open-source e-commerce platform", + "image": "alankent/gsd:latest", + "logo": "https://portainer-io-assets.sfo2.digitaloceanspaces.com/logos/magento.png", + "platform": "linux", + "ports": [ + "80/tcp", + "3000/tcp", + "3001/tcp" + ], + "repository": { + "stackfile": "", + "url": "" + }, + "stackFile": "", + "title": "Magento 2", + "type": 1, + "volumes": [ + { + "container": "/var/www/html/" + } + ] + }, + { + "Id": 29, + "administrator_only": false, + "categories": [ + "Log Management", + "Monitoring" + ], + "description": "Collect logs, metrics and docker events", + "env": [ + { + "label": "Logs token", + "name": "LOGSENE_TOKEN" + }, + { + "label": "SPM monitoring token", + "name": "SPM_TOKEN" + } + ], + "image": "sematext/sematext-agent-docker:latest", + "logo": "https://portainer-io-assets.sfo2.digitaloceanspaces.com/logos/sematext_agent.png", + "name": "sematext-agent", + "platform": "linux", + "privileged": true, + "repository": { + "stackfile": "", + "url": "" + }, + "stackFile": "", + "title": "Sematext Docker Agent", + "type": 1, + "volumes": [ + { + "bind": "/var/run/docker.sock", + "container": "/var/run/docker.sock" + } + ] + }, + { + "Id": 30, + "administrator_only": false, + "categories": [ + "Monitoring" + ], + "description": "Collect events and metrics", + "env": [ + { + "label": "Datadog API key", + "name": "DD_API_KEY" + } + ], + "image": "datadog/agent:latest", + "logo": "https://portainer-io-assets.sfo2.digitaloceanspaces.com/logos/datadog_agent.png", + "platform": "linux", + "repository": { + "stackfile": "", + "url": "" + }, + "stackFile": "", + "title": "Datadog agent", + "type": 1, + "volumes": [ + { + "bind": "/var/run/docker.sock", + "container": "/var/run/docker.sock", + "readonly": true + }, + { + "bind": "/sys/fs/cgroup", + "container": "/host/sys/fs/cgroup", + "readonly": true + }, + { + "bind": "/proc", + "container": "/host/proc", + "readonly": true + } + ] + }, + { + "Id": 31, + "administrator_only": false, + "categories": [ + "marketing" + ], + "description": "Open-source marketing automation platform", + "env": [ + { + "label": "MySQL database host", + "name": "MAUTIC_DB_HOST" + }, + { + "label": "Database password", + "name": "MAUTIC_DB_PASSWORD" + } + ], + "image": "mautic/mautic:latest", + "logo": "https://portainer-io-assets.sfo2.digitaloceanspaces.com/logos/mautic.png", + "platform": "linux", + "ports": [ + "80/tcp" + ], + "repository": { + "stackfile": "", + "url": "" + }, + "stackFile": "", + "title": "Mautic", + "type": 1, + "volumes": [ + { + "container": "/var/www/html" + } + ] + }, + { + "Id": 32, + "administrator_only": false, + "categories": [ + "streaming" + ], + "description": "Streaming media server", + "env": [ + { + "label": "Agree to Wowza EULA", + "name": "WOWZA_ACCEPT_LICENSE" + }, + { + "label": "License key", + "name": "WOWZA_KEY" + } + ], + "image": "sameersbn/wowza:4.1.2-8", + "logo": "https://portainer-io-assets.sfo2.digitaloceanspaces.com/logos/wowza.png", + "platform": "linux", + "ports": [ + "1935/tcp", + "8086/tcp", + "8087/tcp", + "8088/tcp" + ], + "repository": { + "stackfile": "", + "url": "" + }, + "stackFile": "", + "title": "Wowza", + "type": 1, + "volumes": [ + { + "container": "/var/lib/wowza" + } + ] + }, + { + "Id": 33, + "administrator_only": false, + "categories": [ + "continuous-integration" + ], + "description": "Open-source continuous integration tool", + "env": [ + { + "label": "Jenkins options", + "name": "JENKINS_OPTS" + } + ], + "image": "jenkins/jenkins:lts", + "logo": "https://portainer-io-assets.sfo2.digitaloceanspaces.com/logos/jenkins.png", + "platform": "linux", + "ports": [ + "8080/tcp", + "50000/tcp" + ], + "repository": { + "stackfile": "", + "url": "" + }, + "stackFile": "", + "title": "Jenkins", + "type": 1, + "volumes": [ + { + "container": "/var/jenkins_home" + } + ] + }, + { + "Id": 34, + "administrator_only": false, + "categories": [ + "project-management" + ], + "description": "Open-source project management tool", + "image": "redmine:latest", + "logo": "https://portainer-io-assets.sfo2.digitaloceanspaces.com/logos/redmine.png", + "platform": "linux", + "ports": [ + "3000/tcp" + ], + "repository": { + "stackfile": "", + "url": "" + }, + "stackFile": "", + "title": "Redmine", + "type": 1, + "volumes": [ + { + "container": "/usr/src/redmine/files" + } + ] + }, + { + "Id": 35, + "administrator_only": false, + "categories": [ + "project-management" + ], + "description": "Open-source business apps", + "env": [ + { + "label": "PostgreSQL database host", + "name": "HOST" + }, + { + "label": "Database user", + "name": "USER" + }, + { + "label": "Database password", + "name": "PASSWORD" + } + ], + "image": "odoo:latest", + "logo": "https://portainer-io-assets.sfo2.digitaloceanspaces.com/logos/odoo.png", + "platform": "linux", + "ports": [ + "8069/tcp" + ], + "repository": { + "stackfile": "", + "url": "" + }, + "stackFile": "", + "title": "Odoo", + "type": 1, + "volumes": [ + { + "container": "/var/lib/odoo" + }, + { + "container": "/mnt/extra-addons" + } + ] + }, + { + "Id": 36, + "administrator_only": false, + "categories": [ + "backup" + ], + "description": "Open-source network backup", + "image": "cfstras/urbackup", + "logo": "https://portainer-io-assets.sfo2.digitaloceanspaces.com/logos/urbackup.png", + "note": "This application web interface is exposed on the port 55414 inside the container.", + "platform": "linux", + "ports": [ + "55413/tcp", + "55414/tcp", + "55415/tcp", + "35622/tcp" + ], + "repository": { + "stackfile": "", + "url": "" + }, + "stackFile": "", + "title": "Urbackup", + "type": 1, + "volumes": [ + { + "container": "/var/urbackup" + } + ] + }, + { + "Id": 37, + "administrator_only": false, + "categories": [ + "filesystem", + "storage" + ], + "command": "--port 80 --database /data/database.db --scope /srv", + "description": "A web file manager", + "image": "filebrowser/filebrowser:latest", + "logo": "https://portainer-io-assets.sfo2.digitaloceanspaces.com/logos/filebrowser.png", + "note": "Default credentials: admin/admin", + "platform": "linux", + "ports": [ + "80/tcp" + ], + "repository": { + "stackfile": "", + "url": "" + }, + "stackFile": "", + "title": "File browser", + "type": 1, + "volumes": [ + { + "container": "/data" + }, + { + "container": "/srv" + } + ] + }, + { + "Id": 38, + "administrator_only": false, + "categories": [ + "development" + ], + "description": "ColdFusion (CFML) CLI", + "env": [ + { + "name": "CFENGINE" + } + ], + "image": "ortussolutions/commandbox:latest", + "logo": "https://portainer-io-assets.sfo2.digitaloceanspaces.com/logos/ortussolutions-commandbox.png", + "platform": "linux", + "ports": [ + "8080/tcp", + "8443/tcp" + ], + "repository": { + "stackfile": "", + "url": "" + }, + "stackFile": "", + "title": "CommandBox", + "type": 1 + }, + { + "Id": 39, + "administrator_only": false, + "categories": [ + "CMS" + ], + "description": "Open-source modular CMS", + "env": [ + { + "name": "express" + }, + { + "name": "install" + }, + { + "name": "CFENGINE" + } + ], + "image": "ortussolutions/contentbox:latest", + "logo": "https://portainer-io-assets.sfo2.digitaloceanspaces.com/logos/ortussolutions-contentbox.png", + "platform": "linux", + "ports": [ + "8080/tcp", + "8443/tcp" + ], + "repository": { + "stackfile": "", + "url": "" + }, + "stackFile": "", + "title": "ContentBox", + "type": 1, + "volumes": [ + { + "container": "/data/contentbox/db" + }, + { + "container": "/app/includes/shared/media" + } + ] + }, + { + "Id": 40, + "administrator_only": false, + "categories": [ + "portainer" + ], + "description": "Manage all the resources in your Swarm cluster", + "image": "", + "logo": "https://portainer-io-assets.sfo2.digitaloceanspaces.com/logos/portainer.png", + "note": "The agent will be deployed globally inside your cluster and available on port 9001.", + "platform": "linux", + "repository": { + "stackfile": "stacks/portainer-agent/docker-stack.yml", + "url": "https://github.com/portainer/templates" + }, + "stackFile": "", + "title": "Portainer Agent", + "type": 2 + }, + { + "Id": 41, + "administrator_only": false, + "categories": [ + "serverless" + ], + "description": "Serverless functions made simple", + "image": "", + "logo": "https://portainer-io-assets.sfo2.digitaloceanspaces.com/logos/openfaas.png", + "name": "func", + "note": "Deploys the API gateway and sample functions. You can access the UI on port 8080. \u003cb\u003eWarning\u003c/b\u003e: the name of the stack must be 'func'.", + "platform": "linux", + "repository": { + "stackfile": "docker-compose.yml", + "url": "https://github.com/openfaas/faas" + }, + "stackFile": "", + "title": "OpenFaaS", + "type": 2 + }, + { + "Id": 42, + "administrator_only": false, + "categories": [ + "serverless" + ], + "description": "Open-source serverless computing platform", + "image": "", + "logo": "https://portainer-io-assets.sfo2.digitaloceanspaces.com/logos/ironfunctions.png", + "note": "Deploys the IronFunctions API and UI.", + "platform": "linux", + "repository": { + "stackfile": "stacks/ironfunctions/docker-stack.yml", + "url": "https://github.com/portainer/templates" + }, + "stackFile": "", + "title": "IronFunctions", + "type": 2 + }, + { + "Id": 43, + "administrator_only": false, + "categories": [ + "database" + ], + "description": "CockroachDB cluster", + "image": "", + "logo": "https://portainer-io-assets.sfo2.digitaloceanspaces.com/logos/cockroachdb.png", + "note": "Deploys an insecure CockroachDB cluster, please refer to \u003ca href=\"https://www.cockroachlabs.com/docs/stable/orchestrate-cockroachdb-with-docker-swarm.html\" target=\"_blank\"\u003eCockroachDB documentation\u003c/a\u003e for production deployments.", + "platform": "linux", + "repository": { + "stackfile": "stacks/cockroachdb/docker-stack.yml", + "url": "https://github.com/portainer/templates" + }, + "stackFile": "", + "title": "CockroachDB", + "type": 2 + }, + { + "Id": 44, + "administrator_only": false, + "categories": [ + "CMS" + ], + "description": "Wordpress setup with a MySQL database", + "env": [ + { + "description": "Password used by the MySQL root user.", + "label": "Database root password", + "name": "MYSQL_DATABASE_PASSWORD" + } + ], + "image": "", + "logo": "https://portainer-io-assets.sfo2.digitaloceanspaces.com/logos/wordpress.png", + "note": "Deploys a Wordpress instance connected to a MySQL database.", + "platform": "linux", + "repository": { + "stackfile": "stacks/wordpress/docker-stack.yml", + "url": "https://github.com/portainer/templates" + }, + "stackFile": "", + "title": "Wordpress", + "type": 2 + }, + { + "Id": 45, + "administrator_only": false, + "categories": [ + "CMS" + ], + "description": "Wordpress setup with a MySQL database", + "env": [ + { + "description": "Password used by the MySQL root user.", + "label": "Database root password", + "name": "MYSQL_DATABASE_PASSWORD" + } + ], + "image": "", + "logo": "https://portainer-io-assets.sfo2.digitaloceanspaces.com/logos/wordpress.png", + "note": "Deploys a Wordpress instance connected to a MySQL database.", + "platform": "linux", + "repository": { + "stackfile": "stacks/wordpress/docker-compose.yml", + "url": "https://github.com/portainer/templates" + }, + "stackFile": "", + "title": "Wordpress", + "type": 3 + }, + { + "Id": 46, + "administrator_only": false, + "categories": [ + "OPS" + ], + "description": "Microsoft Operations Management Suite Linux agent.", + "env": [ + { + "description": "Azure Workspace ID", + "label": "Workspace ID", + "name": "AZURE_WORKSPACE_ID" + }, + { + "description": "Azure primary key", + "label": "Primary key", + "name": "AZURE_PRIMARY_KEY" + } + ], + "image": "", + "logo": "https://portainer-io-assets.sfo2.digitaloceanspaces.com/logos/microsoft.png", + "platform": "linux", + "repository": { + "stackfile": "stacks/microsoft-oms/docker-stack.yml", + "url": "https://github.com/portainer/templates" + }, + "stackFile": "", + "title": "Microsoft OMS Agent", + "type": 2 + }, + { + "Id": 47, + "administrator_only": false, + "categories": [ + "Log Management", + "Monitoring" + ], + "description": "Collect logs, metrics and docker events", + "env": [ + { + "label": "Logs token", + "name": "LOGSENE_TOKEN" + }, + { + "label": "SPM monitoring token", + "name": "SPM_TOKEN" + } + ], + "image": "", + "logo": "https://portainer-io-assets.sfo2.digitaloceanspaces.com/logos/sematext_agent.png", + "platform": "linux", + "repository": { + "stackfile": "stacks/sematext-agent-docker/docker-stack.yml", + "url": "https://github.com/portainer/templates" + }, + "stackFile": "", + "title": "Sematext Docker Agent", + "type": 2 + }, + { + "Id": 48, + "administrator_only": false, + "categories": [ + "Monitoring" + ], + "description": "Collect events and metrics", + "env": [ + { + "label": "Datadog API key", + "name": "API_KEY" + } + ], + "image": "", + "logo": "https://portainer-io-assets.sfo2.digitaloceanspaces.com/logos/datadog_agent.png", + "platform": "linux", + "repository": { + "stackfile": "stacks/datadog-agent/docker-stack.yml", + "url": "https://github.com/portainer/templates" + }, + "stackFile": "", + "title": "Datadog agent", + "type": 2 + }, + { + "Id": 49, + "administrator_only": false, + "categories": [ + "docker" + ], + "description": "Sonatype Nexus3 registry manager", + "image": "sonatype/nexus3:latest", + "logo": "https://portainer-io-assets.sfo2.digitaloceanspaces.com/logos/sonatype.png", + "platform": "linux", + "ports": [ + "8081/tcp" + ], + "repository": { + "stackfile": "", + "url": "" + }, + "stackFile": "", + "title": "Sonatype Nexus3", + "type": 1, + "volumes": [ + { + "container": "/nexus-data" + } + ] + } + ], + "tunnel_server": { + "PrivateKeySeed": "IvX6ZPRuWtLS5zyg" + }, + "users": [ + { + "EndpointAuthorizations": null, + "Id": 1, + "Password": "$2a$10$siRDprr/5uUFAU8iom3Sr./WXQkN2dhSNjAC471pkJaALkghS762a", + "PortainerAuthorizations": { + "PortainerDockerHubInspect": true, + "PortainerEndpointExtensionAdd": true, + "PortainerEndpointExtensionRemove": true, + "PortainerEndpointGroupList": true, + "PortainerEndpointInspect": true, + "PortainerEndpointList": true, + "PortainerExtensionList": true, + "PortainerMOTD": true, + "PortainerRegistryInspect": true, + "PortainerRegistryList": true, + "PortainerTeamList": true, + "PortainerTemplateInspect": true, + "PortainerTemplateList": true, + "PortainerUserInspect": true, + "PortainerUserList": true, + "PortainerUserMemberships": true + }, + "Role": 1, + "Username": "admin" + }, + { + "EndpointAuthorizations": null, + "Id": 2, + "Password": "$2a$10$WpCAW8mSt6FRRp1GkynbFOGSZnHR6E5j9cETZ8HiMlw06hVlDW/Li", + "PortainerAuthorizations": { + "PortainerDockerHubInspect": true, + "PortainerEndpointExtensionAdd": true, + "PortainerEndpointExtensionRemove": true, + "PortainerEndpointGroupList": true, + "PortainerEndpointInspect": true, + "PortainerEndpointList": true, + "PortainerExtensionList": true, + "PortainerMOTD": true, + "PortainerRegistryInspect": true, + "PortainerRegistryList": true, + "PortainerTeamList": true, + "PortainerTemplateInspect": true, + "PortainerTemplateList": true, + "PortainerUserInspect": true, + "PortainerUserList": true, + "PortainerUserMemberships": true + }, + "Role": 1, + "Username": "prabhat" + } + ], + "version": { + "DB_VERSION": 24 + } +} \ No newline at end of file diff --git a/api/datastore/test_data/output_24_to_latest.json b/api/datastore/test_data/output_24_to_latest.json new file mode 100644 index 0000000..16d6c3c --- /dev/null +++ b/api/datastore/test_data/output_24_to_latest.json @@ -0,0 +1,928 @@ +{ + "allowlist": null, + "api_key": null, + "customtemplates": null, + "dockerhub": [ + { + "Authentication": false, + "Username": "" + } + ], + "edge_stack": null, + "edge_stack_status": null, + "edgegroups": null, + "edgejobs": null, + "endpoint_groups": [ + { + "AuthorizedTeams": null, + "AuthorizedUsers": null, + "Description": "Unassigned endpoints", + "Id": 1, + "Labels": [], + "Name": "Unassigned", + "TagIds": [], + "Tags": null, + "TeamAccessPolicies": {}, + "UserAccessPolicies": {} + } + ], + "endpoint_relations": [ + { + "EdgeStacks": {}, + "EndpointID": 1 + } + ], + "endpoints": [ + { + "Agent": {}, + "AzureCredentials": { + "ApplicationID": "", + "AuthenticationKey": "", + "TenantID": "" + }, + "ComposeSyntaxMaxVersion": "", + "ContainerEngine": "", + "Edge": { + "AsyncMode": false, + "CommandInterval": 0, + "PingInterval": 0, + "SnapshotInterval": 0 + }, + "EdgeCheckinInterval": 0, + "EdgeKey": "", + "GroupId": 1, + "Heartbeat": false, + "Id": 1, + "Kubernetes": { + "Configuration": { + "AllowNoneIngressClass": false, + "EnableResourceOverCommit": false, + "IngressAvailabilityPerNamespace": true, + "ResourceOverCommitPercentage": 0, + "RestrictDefaultNamespace": false, + "UseLoadBalancer": false, + "UseServerMetrics": false + }, + "Flags": { + "IsServerIngressClassDetected": false, + "IsServerMetricsDetected": false, + "IsServerStorageDetected": false + } + }, + "LastCheckInDate": 0, + "Name": "local", + "PostInitMigrations": { + "MigrateGPUs": true, + "MigrateIngresses": true, + "MigrateRegistrySASecrets": false + }, + "PublicURL": "", + "SecuritySettings": { + "allowBindMountsForRegularUsers": true, + "allowContainerCapabilitiesForRegularUsers": true, + "allowDeviceMappingForRegularUsers": true, + "allowHostNamespaceForRegularUsers": true, + "allowPrivilegedModeForRegularUsers": true, + "allowSecurityOptForRegularUsers": false, + "allowStackManagementForRegularUsers": true, + "allowSysctlSettingForRegularUsers": false, + "allowVolumeBrowserForRegularUsers": false, + "enableHostManagementFeatures": false + }, + "Status": 1, + "TLSConfig": { + "TLS": false, + "TLSSkipVerify": false + }, + "Type": 1, + "URL": "unix:///var/run/docker.sock" + } + ], + "extension": null, + "helm_user_repository": null, + "pending_actions": null, + "registries": [ + { + "Authentication": true, + "AuthorizedTeams": null, + "AuthorizedUsers": null, + "BaseURL": "", + "Ecr": { + "Region": "" + }, + "Github": { + "OrganisationName": "", + "UseOrganisation": false + }, + "Gitlab": { + "InstanceURL": "", + "ProjectId": 0, + "ProjectPath": "" + }, + "Id": 1, + "ManagementConfiguration": null, + "Name": "canister.io", + "Password": "MjWbx8A6YK7cw7", + "Quay": { + "OrganisationName": "" + }, + "RegistryAccesses": { + "1": { + "Namespaces": [], + "TeamAccessPolicies": {}, + "UserAccessPolicies": {} + } + }, + "TeamAccessPolicies": {}, + "Type": 3, + "URL": "cloud.canister.io:5000", + "UserAccessPolicies": {}, + "Username": "prabhatkhera" + } + ], + "resource_control": [ + { + "AdministratorsOnly": false, + "Id": 2, + "Public": true, + "ResourceId": "762gbwaj8r4gcsdy8ld1u4why", + "SubResourceIds": [], + "System": false, + "TeamAccesses": [], + "Type": 5, + "UserAccesses": [] + }, + { + "AdministratorsOnly": false, + "Id": 3, + "Public": true, + "ResourceId": "1_alpine", + "SubResourceIds": [], + "System": false, + "TeamAccesses": [], + "Type": 6, + "UserAccesses": [] + }, + { + "AdministratorsOnly": false, + "Id": 4, + "Public": true, + "ResourceId": "1_redis", + "SubResourceIds": [], + "System": false, + "TeamAccesses": [], + "Type": 6, + "UserAccesses": [] + }, + { + "AdministratorsOnly": false, + "Id": 5, + "Public": false, + "ResourceId": "1_nginx", + "SubResourceIds": [], + "System": false, + "TeamAccesses": [ + { + "AccessLevel": 1, + "TeamId": 1 + } + ], + "Type": 6, + "UserAccesses": [] + } + ], + "roles": [ + { + "Authorizations": { + "DockerAgentBrowseDelete": true, + "DockerAgentBrowseGet": true, + "DockerAgentBrowseList": true, + "DockerAgentBrowsePut": true, + "DockerAgentBrowseRename": true, + "DockerAgentHostInfo": true, + "DockerAgentList": true, + "DockerAgentPing": true, + "DockerAgentUndefined": true, + "DockerBuildCancel": true, + "DockerBuildPrune": true, + "DockerConfigCreate": true, + "DockerConfigDelete": true, + "DockerConfigInspect": true, + "DockerConfigList": true, + "DockerConfigUpdate": true, + "DockerContainerArchive": true, + "DockerContainerArchiveInfo": true, + "DockerContainerAttach": true, + "DockerContainerAttachWebsocket": true, + "DockerContainerChanges": true, + "DockerContainerCreate": true, + "DockerContainerDelete": true, + "DockerContainerExec": true, + "DockerContainerExport": true, + "DockerContainerInspect": true, + "DockerContainerKill": true, + "DockerContainerList": true, + "DockerContainerLogs": true, + "DockerContainerPause": true, + "DockerContainerPrune": true, + "DockerContainerPutContainerArchive": true, + "DockerContainerRename": true, + "DockerContainerResize": true, + "DockerContainerRestart": true, + "DockerContainerStart": true, + "DockerContainerStats": true, + "DockerContainerStop": true, + "DockerContainerTop": true, + "DockerContainerUnpause": true, + "DockerContainerUpdate": true, + "DockerContainerWait": true, + "DockerDistributionInspect": true, + "DockerEvents": true, + "DockerExecInspect": true, + "DockerExecResize": true, + "DockerExecStart": true, + "DockerImageBuild": true, + "DockerImageCommit": true, + "DockerImageCreate": true, + "DockerImageDelete": true, + "DockerImageGet": true, + "DockerImageGetAll": true, + "DockerImageHistory": true, + "DockerImageInspect": true, + "DockerImageList": true, + "DockerImageLoad": true, + "DockerImagePrune": true, + "DockerImagePush": true, + "DockerImageSearch": true, + "DockerImageTag": true, + "DockerInfo": true, + "DockerNetworkConnect": true, + "DockerNetworkCreate": true, + "DockerNetworkDelete": true, + "DockerNetworkDisconnect": true, + "DockerNetworkInspect": true, + "DockerNetworkList": true, + "DockerNetworkPrune": true, + "DockerNodeDelete": true, + "DockerNodeInspect": true, + "DockerNodeList": true, + "DockerNodeUpdate": true, + "DockerPing": true, + "DockerPluginCreate": true, + "DockerPluginDelete": true, + "DockerPluginDisable": true, + "DockerPluginEnable": true, + "DockerPluginInspect": true, + "DockerPluginList": true, + "DockerPluginPrivileges": true, + "DockerPluginPull": true, + "DockerPluginPush": true, + "DockerPluginSet": true, + "DockerPluginUpgrade": true, + "DockerSecretCreate": true, + "DockerSecretDelete": true, + "DockerSecretInspect": true, + "DockerSecretList": true, + "DockerSecretUpdate": true, + "DockerServiceCreate": true, + "DockerServiceDelete": true, + "DockerServiceInspect": true, + "DockerServiceList": true, + "DockerServiceLogs": true, + "DockerServiceUpdate": true, + "DockerSessionStart": true, + "DockerSwarmInit": true, + "DockerSwarmInspect": true, + "DockerSwarmJoin": true, + "DockerSwarmLeave": true, + "DockerSwarmUnlock": true, + "DockerSwarmUnlockKey": true, + "DockerSwarmUpdate": true, + "DockerSystem": true, + "DockerTaskInspect": true, + "DockerTaskList": true, + "DockerTaskLogs": true, + "DockerUndefined": true, + "DockerVersion": true, + "DockerVolumeCreate": true, + "DockerVolumeDelete": true, + "DockerVolumeInspect": true, + "DockerVolumeList": true, + "DockerVolumePrune": true, + "EndpointResourcesAccess": true, + "IntegrationStoridgeAdmin": true, + "PortainerResourceControlCreate": true, + "PortainerResourceControlUpdate": true, + "PortainerStackCreate": true, + "PortainerStackDelete": true, + "PortainerStackFile": true, + "PortainerStackInspect": true, + "PortainerStackList": true, + "PortainerStackMigrate": true, + "PortainerStackUpdate": true, + "PortainerWebhookCreate": true, + "PortainerWebhookDelete": true, + "PortainerWebhookList": true, + "PortainerWebsocketExec": true + }, + "Description": "Full control of all resources in an endpoint", + "Id": 1, + "Name": "Endpoint administrator", + "Priority": 1 + }, + { + "Authorizations": { + "DockerAgentHostInfo": true, + "DockerAgentList": true, + "DockerAgentPing": true, + "DockerConfigInspect": true, + "DockerConfigList": true, + "DockerContainerArchiveInfo": true, + "DockerContainerChanges": true, + "DockerContainerInspect": true, + "DockerContainerList": true, + "DockerContainerLogs": true, + "DockerContainerStats": true, + "DockerContainerTop": true, + "DockerDistributionInspect": true, + "DockerEvents": true, + "DockerImageGet": true, + "DockerImageGetAll": true, + "DockerImageHistory": true, + "DockerImageInspect": true, + "DockerImageList": true, + "DockerImageSearch": true, + "DockerInfo": true, + "DockerNetworkInspect": true, + "DockerNetworkList": true, + "DockerNodeInspect": true, + "DockerNodeList": true, + "DockerPing": true, + "DockerPluginList": true, + "DockerSecretInspect": true, + "DockerSecretList": true, + "DockerServiceInspect": true, + "DockerServiceList": true, + "DockerServiceLogs": true, + "DockerSwarmInspect": true, + "DockerSystem": true, + "DockerTaskInspect": true, + "DockerTaskList": true, + "DockerTaskLogs": true, + "DockerVersion": true, + "DockerVolumeInspect": true, + "DockerVolumeList": true, + "EndpointResourcesAccess": true, + "PortainerStackFile": true, + "PortainerStackInspect": true, + "PortainerStackList": true, + "PortainerWebhookList": true + }, + "Description": "Read-only access of all resources in an endpoint", + "Id": 2, + "Name": "Helpdesk", + "Priority": 2 + }, + { + "Authorizations": { + "DockerAgentHostInfo": true, + "DockerAgentList": true, + "DockerAgentPing": true, + "DockerAgentUndefined": true, + "DockerBuildCancel": true, + "DockerBuildPrune": true, + "DockerConfigCreate": true, + "DockerConfigDelete": true, + "DockerConfigInspect": true, + "DockerConfigList": true, + "DockerConfigUpdate": true, + "DockerContainerArchive": true, + "DockerContainerArchiveInfo": true, + "DockerContainerAttach": true, + "DockerContainerAttachWebsocket": true, + "DockerContainerChanges": true, + "DockerContainerCreate": true, + "DockerContainerDelete": true, + "DockerContainerExec": true, + "DockerContainerExport": true, + "DockerContainerInspect": true, + "DockerContainerKill": true, + "DockerContainerList": true, + "DockerContainerLogs": true, + "DockerContainerPause": true, + "DockerContainerPutContainerArchive": true, + "DockerContainerRename": true, + "DockerContainerResize": true, + "DockerContainerRestart": true, + "DockerContainerStart": true, + "DockerContainerStats": true, + "DockerContainerStop": true, + "DockerContainerTop": true, + "DockerContainerUnpause": true, + "DockerContainerUpdate": true, + "DockerContainerWait": true, + "DockerDistributionInspect": true, + "DockerEvents": true, + "DockerExecInspect": true, + "DockerExecResize": true, + "DockerExecStart": true, + "DockerImageBuild": true, + "DockerImageCommit": true, + "DockerImageCreate": true, + "DockerImageDelete": true, + "DockerImageGet": true, + "DockerImageGetAll": true, + "DockerImageHistory": true, + "DockerImageInspect": true, + "DockerImageList": true, + "DockerImageLoad": true, + "DockerImagePush": true, + "DockerImageSearch": true, + "DockerImageTag": true, + "DockerInfo": true, + "DockerNetworkConnect": true, + "DockerNetworkCreate": true, + "DockerNetworkDelete": true, + "DockerNetworkDisconnect": true, + "DockerNetworkInspect": true, + "DockerNetworkList": true, + "DockerNodeDelete": true, + "DockerNodeInspect": true, + "DockerNodeList": true, + "DockerNodeUpdate": true, + "DockerPing": true, + "DockerPluginCreate": true, + "DockerPluginDelete": true, + "DockerPluginDisable": true, + "DockerPluginEnable": true, + "DockerPluginInspect": true, + "DockerPluginList": true, + "DockerPluginPrivileges": true, + "DockerPluginPull": true, + "DockerPluginPush": true, + "DockerPluginSet": true, + "DockerPluginUpgrade": true, + "DockerSecretCreate": true, + "DockerSecretDelete": true, + "DockerSecretInspect": true, + "DockerSecretList": true, + "DockerSecretUpdate": true, + "DockerServiceCreate": true, + "DockerServiceDelete": true, + "DockerServiceInspect": true, + "DockerServiceList": true, + "DockerServiceLogs": true, + "DockerServiceUpdate": true, + "DockerSessionStart": true, + "DockerSwarmInit": true, + "DockerSwarmInspect": true, + "DockerSwarmJoin": true, + "DockerSwarmLeave": true, + "DockerSwarmUnlock": true, + "DockerSwarmUnlockKey": true, + "DockerSwarmUpdate": true, + "DockerSystem": true, + "DockerTaskInspect": true, + "DockerTaskList": true, + "DockerTaskLogs": true, + "DockerUndefined": true, + "DockerVersion": true, + "DockerVolumeCreate": true, + "DockerVolumeDelete": true, + "DockerVolumeInspect": true, + "DockerVolumeList": true, + "PortainerResourceControlUpdate": true, + "PortainerStackCreate": true, + "PortainerStackDelete": true, + "PortainerStackFile": true, + "PortainerStackInspect": true, + "PortainerStackList": true, + "PortainerStackMigrate": true, + "PortainerStackUpdate": true, + "PortainerWebhookCreate": true, + "PortainerWebhookList": true, + "PortainerWebsocketExec": true + }, + "Description": "Full control of assigned resources in an endpoint", + "Id": 3, + "Name": "Standard user", + "Priority": 3 + }, + { + "Authorizations": { + "DockerAgentHostInfo": true, + "DockerAgentList": true, + "DockerAgentPing": true, + "DockerConfigInspect": true, + "DockerConfigList": true, + "DockerContainerArchiveInfo": true, + "DockerContainerChanges": true, + "DockerContainerInspect": true, + "DockerContainerList": true, + "DockerContainerLogs": true, + "DockerContainerStats": true, + "DockerContainerTop": true, + "DockerDistributionInspect": true, + "DockerEvents": true, + "DockerImageGet": true, + "DockerImageGetAll": true, + "DockerImageHistory": true, + "DockerImageInspect": true, + "DockerImageList": true, + "DockerImageSearch": true, + "DockerInfo": true, + "DockerNetworkInspect": true, + "DockerNetworkList": true, + "DockerNodeInspect": true, + "DockerNodeList": true, + "DockerPing": true, + "DockerPluginList": true, + "DockerSecretInspect": true, + "DockerSecretList": true, + "DockerServiceInspect": true, + "DockerServiceList": true, + "DockerServiceLogs": true, + "DockerSwarmInspect": true, + "DockerSystem": true, + "DockerTaskInspect": true, + "DockerTaskList": true, + "DockerTaskLogs": true, + "DockerVersion": true, + "DockerVolumeInspect": true, + "DockerVolumeList": true, + "PortainerStackFile": true, + "PortainerStackInspect": true, + "PortainerStackList": true, + "PortainerWebhookList": true + }, + "Description": "Read-only access of assigned resources in an endpoint", + "Id": 4, + "Name": "Read-only user", + "Priority": 4 + } + ], + "schedules": [ + { + "Created": 1648608136, + "CronExpression": "@every 5m", + "EdgeSchedule": null, + "EndpointSyncJob": null, + "Id": 1, + "JobType": 2, + "Name": "system_snapshot", + "Recurring": true, + "ScriptExecutionJob": null, + "SnapshotJob": {} + } + ], + "settings": { + "AgentSecret": "", + "AllowBindMountsForRegularUsers": true, + "AllowContainerCapabilitiesForRegularUsers": true, + "AllowDeviceMappingForRegularUsers": true, + "AllowHostNamespaceForRegularUsers": true, + "AllowPrivilegedModeForRegularUsers": true, + "AllowStackManagementForRegularUsers": true, + "AuthenticationMethod": 1, + "BlackListedLabels": [], + "Edge": { + "CommandInterval": 0, + "PingInterval": 0, + "SnapshotInterval": 0 + }, + "EdgeAgentCheckinInterval": 5, + "EdgePortainerUrl": "", + "EnableEdgeComputeFeatures": false, + "EnforceEdgeID": false, + "FeatureFlagSettings": null, + "ForceSecureCookies": false, + "GlobalDeploymentOptions": { + "hideStacksFunctionality": false + }, + "HelmRepositoryURL": "https://charts.bitnami.com/bitnami", + "InternalAuthSettings": { + "RequiredPasswordLength": 12 + }, + "KubeconfigExpiry": "0", + "KubectlShellImage": "portainer/kubectl-shell:2.43.0", + "LDAPSettings": { + "AnonymousMode": true, + "AutoCreateUsers": true, + "GroupSearchSettings": [ + { + "GroupAttribute": "", + "GroupBaseDN": "", + "GroupFilter": "" + } + ], + "ReaderDN": "", + "SearchSettings": [ + { + "BaseDN": "", + "Filter": "", + "UserNameAttribute": "" + } + ], + "StartTLS": false, + "TLSConfig": { + "TLS": false, + "TLSSkipVerify": false + }, + "URL": "" + }, + "LogoURL": "", + "OAuthSettings": { + "AccessTokenURI": "", + "AuthStyle": 0, + "AuthorizationURI": "", + "ClientID": "", + "DefaultTeamID": 0, + "KubeSecretKey": null, + "LogoutURI": "", + "OAuthAutoCreateUsers": false, + "RedirectURI": "", + "ResourceURI": "", + "SSO": false, + "Scopes": "", + "UserIdentifier": "" + }, + "SnapshotInterval": "5m", + "TemplatesURL": "", + "TrustOnFirstConnect": false, + "UserSessionTimeout": "8h" + }, + "snapshots": [ + { + "Docker": { + "ContainerCount": 0, + "DiagnosticsData": {}, + "DockerSnapshotRaw": { + "Info": { + "Architecture": "", + "CDISpecDirs": null, + "CPUSet": false, + "CPUShares": false, + "CgroupDriver": "", + "ContainerdCommit": { + "ID": "" + }, + "Containers": 0, + "ContainersPaused": 0, + "ContainersRunning": 0, + "ContainersStopped": 0, + "CpuCfsPeriod": false, + "CpuCfsQuota": false, + "Debug": false, + "DefaultRuntime": "", + "DockerRootDir": "", + "Driver": "", + "DriverStatus": null, + "ExperimentalBuild": false, + "GenericResources": null, + "HttpProxy": "", + "HttpsProxy": "", + "ID": "", + "IPv4Forwarding": false, + "Images": 0, + "IndexServerAddress": "", + "InitBinary": "", + "InitCommit": { + "ID": "" + }, + "Isolation": "", + "KernelVersion": "", + "Labels": null, + "LiveRestoreEnabled": false, + "LoggingDriver": "", + "MemTotal": 0, + "MemoryLimit": false, + "NCPU": 0, + "NEventsListener": 0, + "NFd": 0, + "NGoroutines": 0, + "Name": "", + "NoProxy": "", + "OSType": "", + "OSVersion": "", + "OomKillDisable": false, + "OperatingSystem": "", + "PidsLimit": false, + "Plugins": { + "Authorization": null, + "Log": null, + "Network": null, + "Volume": null + }, + "RegistryConfig": null, + "RuncCommit": { + "ID": "" + }, + "Runtimes": null, + "SecurityOptions": null, + "ServerVersion": "", + "SwapLimit": false, + "Swarm": { + "ControlAvailable": false, + "Error": "", + "LocalNodeState": "", + "NodeAddr": "", + "NodeID": "", + "RemoteManagers": null + }, + "SystemTime": "", + "Warnings": null + }, + "Version": { + "ApiVersion": "", + "Arch": "", + "GitCommit": "", + "GoVersion": "", + "Os": "", + "Platform": { + "Name": "" + }, + "Version": "" + }, + "Volumes": { + "Volumes": null, + "Warnings": null + } + }, + "DockerVersion": "20.10.13", + "GpuUseAll": false, + "HealthyContainerCount": 0, + "ImageCount": 9, + "IsPodman": false, + "NodeCount": 0, + "RunningContainerCount": 5, + "ServiceCount": 0, + "StackCount": 2, + "StoppedContainerCount": 0, + "Swarm": false, + "Time": 1648610112, + "TotalCPU": 8, + "TotalMemory": 25098706944, + "UnhealthyContainerCount": 0, + "VolumeCount": 10 + }, + "EndpointId": 1, + "Kubernetes": null + } + ], + "sources": null, + "ssl": { + "certPath": "", + "httpEnabled": true, + "keyPath": "", + "selfSigned": false + }, + "stacks": [ + { + "AdditionalFiles": null, + "AutoUpdate": null, + "CreatedBy": "", + "CreationDate": 0, + "DeploymentStartStatus": 0, + "EndpointId": 1, + "EntryPoint": "docker/alpine37-compose.yml", + "Env": [], + "FromAppTemplate": false, + "GitConfig": null, + "Id": 2, + "Name": "alpine", + "Namespace": "", + "Option": null, + "ProjectPath": "/home/prabhat/portainer/data/ce1.25/compose/2", + "ResourceControl": null, + "Status": 1, + "SwarmId": "s3fd604zdba7z13tbq2x6lyue", + "Type": 1, + "UpdateDate": 0, + "UpdatedBy": "" + }, + { + "AdditionalFiles": null, + "AutoUpdate": null, + "CreatedBy": "", + "CreationDate": 0, + "DeploymentStartStatus": 0, + "EndpointId": 1, + "EntryPoint": "docker-compose.yml", + "Env": [], + "FromAppTemplate": false, + "GitConfig": null, + "Id": 5, + "Name": "redis", + "Namespace": "", + "Option": null, + "ProjectPath": "/home/prabhat/portainer/data/ce1.25/compose/5", + "ResourceControl": null, + "Status": 1, + "SwarmId": "", + "Type": 2, + "UpdateDate": 0, + "UpdatedBy": "" + }, + { + "AdditionalFiles": null, + "AutoUpdate": null, + "CreatedBy": "", + "CreationDate": 0, + "DeploymentStartStatus": 0, + "EndpointId": 1, + "EntryPoint": "docker-compose.yml", + "Env": [], + "FromAppTemplate": false, + "GitConfig": null, + "Id": 6, + "Name": "nginx", + "Namespace": "", + "Option": null, + "ProjectPath": "/home/prabhat/portainer/data/ce1.25/compose/6", + "ResourceControl": null, + "Status": 1, + "SwarmId": "", + "Type": 2, + "UpdateDate": 0, + "UpdatedBy": "" + } + ], + "tags": null, + "team_membership": null, + "teams": [ + { + "Id": 1, + "Name": "hello" + } + ], + "tunnel_server": { + "PrivateKeySeed": "" + }, + "users": [ + { + "EndpointAuthorizations": null, + "Id": 1, + "Password": "$2a$10$siRDprr/5uUFAU8iom3Sr./WXQkN2dhSNjAC471pkJaALkghS762a", + "PortainerAuthorizations": { + "PortainerDockerHubInspect": true, + "PortainerEndpointGroupList": true, + "PortainerEndpointInspect": true, + "PortainerEndpointList": true, + "PortainerMOTD": true, + "PortainerRegistryInspect": true, + "PortainerRegistryList": true, + "PortainerTeamList": true, + "PortainerTemplateInspect": true, + "PortainerTemplateList": true, + "PortainerUserCreateToken": true, + "PortainerUserInspect": true, + "PortainerUserList": true, + "PortainerUserListToken": true, + "PortainerUserMemberships": true, + "PortainerUserRevokeToken": true + }, + "Role": 1, + "ThemeSettings": { + "color": "" + }, + "TokenIssueAt": 0, + "UseCache": false, + "Username": "admin" + }, + { + "EndpointAuthorizations": null, + "Id": 2, + "Password": "$2a$10$WpCAW8mSt6FRRp1GkynbFOGSZnHR6E5j9cETZ8HiMlw06hVlDW/Li", + "PortainerAuthorizations": { + "PortainerDockerHubInspect": true, + "PortainerEndpointGroupList": true, + "PortainerEndpointInspect": true, + "PortainerEndpointList": true, + "PortainerMOTD": true, + "PortainerRegistryInspect": true, + "PortainerRegistryList": true, + "PortainerTeamList": true, + "PortainerTemplateInspect": true, + "PortainerTemplateList": true, + "PortainerUserCreateToken": true, + "PortainerUserInspect": true, + "PortainerUserList": true, + "PortainerUserListToken": true, + "PortainerUserMemberships": true, + "PortainerUserRevokeToken": true + }, + "Role": 1, + "ThemeSettings": { + "color": "" + }, + "TokenIssueAt": 0, + "UseCache": false, + "Username": "prabhat" + } + ], + "version": { + "VERSION": "{\"SchemaVersion\":\"2.43.0\",\"MigratorCount\":2,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}" + }, + "webhooks": null, + "workflows": null +} \ No newline at end of file diff --git a/api/datastore/teststore.go b/api/datastore/teststore.go new file mode 100644 index 0000000..5d9573a --- /dev/null +++ b/api/datastore/teststore.go @@ -0,0 +1,82 @@ +package datastore + +import ( + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/database" + "github.com/portainer/portainer/api/database/models" + "github.com/portainer/portainer/api/filesystem" + + "github.com/rs/zerolog/log" +) + +func (store *Store) GetConnection() portainer.Connection { + return store.connection +} + +func MustNewTestStore(t testing.TB, init, secure bool) (bool, *Store) { + newStore, store, teardown, err := NewTestStore(t, init, secure) + if err != nil { + log.Fatal().Err(err).Msg("") + } + + t.Cleanup(teardown) + + return newStore, store +} + +func NewTestStore(t testing.TB, init, secure bool) (bool, *Store, func(), error) { + // Creates unique temp directory in a concurrency friendly manner. + storePath := t.TempDir() + defaultKubectlShellImage := portainer.DefaultKubectlShellImage + flags := &portainer.CLIFlags{ + KubectlShellImage: &defaultKubectlShellImage, + } + + fileService, err := filesystem.NewService(storePath, "") + if err != nil { + return false, nil, nil, err + } + + secretKey := []byte("apassphrasewhichneedstobe32bytes") + if !secure { + secretKey = nil + } + + connection, err := database.NewDatabase("boltdb", storePath, secretKey, false) + if err != nil { + panic(err) + } + + store := NewStore(flags, fileService, connection) + newStore, err := store.Open() + if err != nil { + return newStore, nil, nil, err + } + + if init { + if err := store.Init(); err != nil { + return newStore, nil, nil, err + } + } + + if newStore { + // From MigrateData + v := models.Version{ + SchemaVersion: portainer.APIVersion, + Edition: int(portainer.PortainerCE), + } + if err := store.VersionService.UpdateVersion(&v); err != nil { + return newStore, nil, nil, err + } + } + + teardown := func() { + if err := store.Close(); err != nil { + log.Fatal().Err(err).Msg("") + } + } + + return newStore, store, teardown, nil +} diff --git a/api/docker/client/client.go b/api/docker/client/client.go new file mode 100644 index 0000000..eff3184 --- /dev/null +++ b/api/docker/client/client.go @@ -0,0 +1,212 @@ +package client + +import ( + "bytes" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/crypto" + "github.com/portainer/portainer/pkg/libhttp/ssrf" + + "github.com/rs/zerolog/log" + + "github.com/docker/docker/api/types/image" + "github.com/docker/docker/client" + "github.com/segmentio/encoding/json" +) + +var errUnsupportedEnvironmentType = errors.New("environment not supported") + +const ( + defaultDockerRequestTimeout = 60 * time.Second + dockerClientVersion = "1.37" +) + +type NodeNamesCtxKey struct{} + +// ClientFactory is used to create Docker clients +type ClientFactory struct { + signatureService portainer.DigitalSignatureService + reverseTunnelService portainer.ReverseTunnelService +} + +// NewClientFactory returns a new instance of a ClientFactory +func NewClientFactory(signatureService portainer.DigitalSignatureService, reverseTunnelService portainer.ReverseTunnelService) *ClientFactory { + return &ClientFactory{ + signatureService: signatureService, + reverseTunnelService: reverseTunnelService, + } +} + +// CreateClient is a generic function to create a Docker client based on +// a specific environment(endpoint) configuration. The nodeName parameter can be used +// with an agent enabled environment(endpoint) to target a specific node in an agent cluster. +// The underlying http client timeout may be specified, a default value is used otherwise. +func (factory *ClientFactory) CreateClient(endpoint *portainer.Endpoint, nodeName string, timeout *time.Duration) (*client.Client, error) { + switch endpoint.Type { + case portainer.AzureEnvironment: + return nil, errUnsupportedEnvironmentType + case portainer.AgentOnDockerEnvironment: + return createAgentClient(endpoint, endpoint.URL, factory.signatureService, nodeName, timeout) + case portainer.EdgeAgentOnDockerEnvironment: + tunnelAddr, err := factory.reverseTunnelService.TunnelAddr(endpoint) + if err != nil { + return nil, err + } + + endpointURL := "http://" + tunnelAddr + + return createAgentClient(endpoint, endpointURL, factory.signatureService, nodeName, timeout) + } + + if strings.HasPrefix(endpoint.URL, "unix://") || strings.HasPrefix(endpoint.URL, "npipe://") { + return createLocalClient(endpoint) + } + + return createTCPClient(endpoint, timeout) +} + +func createLocalClient(endpoint *portainer.Endpoint) (*client.Client, error) { + return client.NewClientWithOpts( + client.WithHost(endpoint.URL), + client.WithAPIVersionNegotiation(), + ) +} + +func createTCPClient(endpoint *portainer.Endpoint, timeout *time.Duration) (*client.Client, error) { + httpCli, err := httpClient(endpoint, timeout) + if err != nil { + return nil, err + } + + opts := []client.Opt{ + client.WithHost(endpoint.URL), + client.WithAPIVersionNegotiation(), + client.WithHTTPClient(httpCli), + } + + if endpoint.TLSConfig.TLS { + opts = append(opts, client.WithScheme("https")) + } + + return client.NewClientWithOpts(opts...) +} + +func createAgentClient(endpoint *portainer.Endpoint, endpointURL string, signatureService portainer.DigitalSignatureService, nodeName string, timeout *time.Duration) (*client.Client, error) { + httpCli, err := httpClient(endpoint, timeout) + if err != nil { + return nil, err + } + + signature, err := signatureService.CreateSignature(portainer.PortainerAgentSignatureMessage) + if err != nil { + return nil, err + } + + headers := map[string]string{ + portainer.PortainerAgentPublicKeyHeader: signatureService.EncodedPublicKey(), + portainer.PortainerAgentSignatureHeader: signature, + } + + if nodeName != "" { + headers[portainer.PortainerAgentTargetHeader] = nodeName + } + + opts := []client.Opt{ + client.WithHost(endpointURL), + client.WithAPIVersionNegotiation(), + client.WithHTTPClient(httpCli), + client.WithHTTPHeaders(headers), + } + + if endpoint.TLSConfig.TLS { + opts = append(opts, client.WithScheme("https")) + } + + return client.NewClientWithOpts(opts...) +} + +type NodeNameTransport struct { + *http.Transport +} + +func (t *NodeNameTransport) RoundTrip(req *http.Request) (*http.Response, error) { + resp, err := t.Transport.RoundTrip(req) + if err != nil || + resp.StatusCode != http.StatusOK || + resp.ContentLength == 0 || + !strings.HasSuffix(req.URL.Path, "/images/json") { + return resp, err + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + if err := resp.Body.Close(); err != nil { + log.Warn().Err(err).Msg("failed to close response body") + } + + return resp, err + } + + if err := resp.Body.Close(); err != nil { + log.Warn().Err(err).Msg("failed to close response body") + } + + resp.Body = io.NopCloser(bytes.NewReader(body)) + + var rs []struct { + image.Summary + Portainer struct { + Agent struct { + NodeName string + } + } + } + + if err = json.Unmarshal(body, &rs); err != nil { + return resp, nil + } + + nodeNames, ok := req.Context().Value(NodeNamesCtxKey{}).(map[string]string) + if ok { + for idx, r := range rs { + // as there is no way to differentiate the same image available in multiple nodes only by their ID + // we append the index of the image in the payload response to match the node name later + // from the image.Summary[] list returned by docker's client.ImageList() + nodeNames[fmt.Sprintf("%s-%d", r.ID, idx)] = r.Portainer.Agent.NodeName + } + } + + return resp, err +} + +func httpClient(endpoint *portainer.Endpoint, timeout *time.Duration) (*http.Client, error) { + var transport *NodeNameTransport + if endpoint.TLSConfig.TLS { + tlsConfig, err := crypto.CreateTLSConfigurationFromDisk(endpoint.TLSConfig) + if err != nil { + return nil, err + } + + t := ssrf.NewTransport(tlsConfig) + t.Protocols = ssrf.HTTP1Only() + transport = &NodeNameTransport{Transport: t} + } else { + transport = &NodeNameTransport{Transport: ssrf.NewTransport(nil)} + } + + clientTimeout := defaultDockerRequestTimeout + if timeout != nil { + clientTimeout = *timeout + } + + return &http.Client{ + Transport: transport, + Timeout: clientTimeout, + }, nil +} diff --git a/api/docker/client/client_test.go b/api/docker/client/client_test.go new file mode 100644 index 0000000..cf1f1bd --- /dev/null +++ b/api/docker/client/client_test.go @@ -0,0 +1,31 @@ +package client + +import ( + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/pkg/fips" + + "github.com/stretchr/testify/require" +) + +func TestHttpClient(t *testing.T) { + t.Parallel() + fips.InitFIPS(false) + + // Valid TLS configuration + endpoint := &portainer.Endpoint{} + endpoint.TLSConfig = portainer.TLSConfiguration{TLS: true} + + cli, err := httpClient(endpoint, nil) + require.NoError(t, err) + require.NotNil(t, cli) + + // Invalid TLS configuration + endpoint.TLSConfig.TLSCertPath = "/invalid/path/client.crt" + endpoint.TLSConfig.TLSKeyPath = "/invalid/path/client.key" + + cli, err = httpClient(endpoint, nil) + require.Error(t, err) + require.Nil(t, cli) +} diff --git a/api/docker/consts/labels.go b/api/docker/consts/labels.go new file mode 100644 index 0000000..aeb24ba --- /dev/null +++ b/api/docker/consts/labels.go @@ -0,0 +1,9 @@ +package consts + +const ( + ComposeStackNameLabel = "com.docker.compose.project" + SwarmStackNameLabel = "com.docker.stack.namespace" + SwarmServiceIDLabel = "com.docker.swarm.service.id" + SwarmNodeIDLabel = "com.docker.swarm.node.id" + HideStackLabel = "io.portainer.hideStack" +) diff --git a/api/docker/container.go b/api/docker/container.go new file mode 100644 index 0000000..2b8cfd5 --- /dev/null +++ b/api/docker/container.go @@ -0,0 +1,239 @@ +package docker + +import ( + "context" + "strings" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + dockerclient "github.com/portainer/portainer/api/docker/client" + "github.com/portainer/portainer/api/docker/images" + "github.com/portainer/portainer/api/logs" + + "github.com/Masterminds/semver/v3" + "github.com/docker/docker/api/types" + dockercontainer "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/network" + "github.com/pkg/errors" + "github.com/rs/zerolog/log" +) + +type ContainerService struct { + factory *dockerclient.ClientFactory + dataStore dataservices.DataStore +} + +func NewContainerService(factory *dockerclient.ClientFactory, dataStore dataservices.DataStore) *ContainerService { + return &ContainerService{ + factory: factory, + dataStore: dataStore, + } +} + +// applyVersionConstraint uses the version to apply a transformation function to +// the value when the constraint is satisfied +func applyVersionConstraint[T any](currentVersion, versionConstraint string, value T, transform func(T) T) (T, error) { + newValue := value + + constraint, err := semver.NewConstraint(versionConstraint) + if err != nil { + return newValue, errors.New("invalid version constraint specified") + } + + currentVer, err := semver.NewVersion(currentVersion) + if err != nil { + log.Warn().Err(err).Msg("Unable to parse the Docker client version") + + return newValue, nil + } + + if satisfiesConstraint, _ := constraint.Validate(currentVer); satisfiesConstraint { + newValue = transform(value) + } + + return newValue, nil +} + +func clearMacAddrs(n network.NetworkingConfig) network.NetworkingConfig { + netConfig := network.NetworkingConfig{ + EndpointsConfig: make(map[string]*network.EndpointSettings), + } + + for k := range n.EndpointsConfig { + endpointConfig := n.EndpointsConfig[k].Copy() + endpointConfig.MacAddress = "" + netConfig.EndpointsConfig[k] = endpointConfig + } + + return netConfig +} + +// Recreate a container +func (c *ContainerService) Recreate(ctx context.Context, endpoint *portainer.Endpoint, containerId string, forcePullImage bool, imageTag, nodeName string) (*types.ContainerJSON, error) { + cli, err := c.factory.CreateClient(endpoint, nodeName, nil) + if err != nil { + return nil, errors.Wrap(err, "create client error") + } + defer logs.CloseAndLogErr(cli) + + log.Debug().Str("container_id", containerId).Msg("starting to fetch container information") + + container, _, err := cli.ContainerInspectWithRaw(ctx, containerId, true) + if err != nil { + return nil, errors.Wrap(err, "fetch container information error") + } + + log.Debug().Str("image", container.Config.Image).Msg("starting to parse image") + img, err := images.ParseImage(images.ParseImageOptions{ + Name: container.Config.Image, + }) + if err != nil { + return nil, errors.Wrap(err, "parse image error") + } + + if imageTag != "" { + if err := img.WithTag(imageTag); err != nil { + return nil, errors.Wrapf(err, "set image tag error %s", imageTag) + } + + log.Debug().Str("image", container.Config.Image).Msg("new image with tag") + + container.Config.Image = img.FullName() + } + + // 1. pull image if you need force pull + if forcePullImage { + puller := images.NewPuller(cli, images.NewRegistryClient(c.dataStore), c.dataStore) + if err := puller.Pull(ctx, img); err != nil { + return nil, errors.Wrapf(err, "pull image error %s", img.FullName()) + } + } + + // 2. stop the current container + log.Debug().Str("container_id", containerId).Msg("starting to stop the container") + if err := cli.ContainerStop(ctx, containerId, dockercontainer.StopOptions{}); err != nil { + return nil, errors.Wrap(err, "stop container error") + } + + // 3. rename the current container + log.Debug().Str("container_id", containerId).Msg("starting to rename the container") + if err := cli.ContainerRename(ctx, containerId, container.Name+"-old"); err != nil { + return nil, errors.Wrap(err, "rename container error") + } + + initialNetwork := network.NetworkingConfig{ + EndpointsConfig: make(map[string]*network.EndpointSettings), + } + + // 4. disconnect all networks from the current container + for name, network := range container.NetworkSettings.Networks { + // This allows new container to use the same IP address if specified + if err := cli.NetworkDisconnect(ctx, network.NetworkID, containerId, true); err != nil { + return nil, errors.Wrap(err, "disconnect network from old container error") + } + + // 5. get the first network attached to the current container + if len(initialNetwork.EndpointsConfig) == 0 { + // Retrieve the first network that is linked to the present container, which + // will be utilized when creating the container. + initialNetwork.EndpointsConfig[name] = network + } + } + + restore := true + + defer func() { + if !restore { + return + } + + log.Debug().Str("container_id", containerId).Str("container", container.Name).Msg("restoring the container") + if err := cli.ContainerRename(ctx, containerId, container.Name); err != nil { + log.Warn().Err(err).Msg("failure to rename container") + } + + for _, network := range container.NetworkSettings.Networks { + if err := cli.NetworkConnect(ctx, network.NetworkID, containerId, network); err != nil { + log.Warn().Err(err).Msg("failure to connect container to network") + } + } + + if err := cli.ContainerStart(ctx, containerId, dockercontainer.StartOptions{}); err != nil { + log.Warn().Err(err).Msg("failure to start container") + } + }() + + log.Debug().Str("container", strings.Split(container.Name, "/")[1]).Msg("starting to create a new container") + + // 6. create a new container + // when a container is created without a network, docker connected it by default to the + // bridge network with a random IP, also it can only connect to one network on creation. + // to retain the same network settings we have to connect on creation to one of the old + // container's networks, and connect to the other networks after creation. + // see: https://portainer.atlassian.net/browse/EE-5448 + + // Docker API < 1.44 does not support specifying MAC addresses + // https://github.com/moby/moby/blob/6aea26b431ea152a8b085e453da06ea403f89886/client/container_create.go#L44-L46 + initialNetwork, err = applyVersionConstraint(cli.ClientVersion(), "< 1.44", initialNetwork, clearMacAddrs) + if err != nil { + return nil, err + } + + create, err := cli.ContainerCreate(ctx, container.Config, container.HostConfig, &initialNetwork, nil, container.Name) + if err != nil { + return nil, errors.Wrap(err, "create container error") + } + + defer func() { + if !restore { + return + } + + log.Debug().Str("container_id", create.ID).Msg("removing the new container") + + if err := cli.ContainerStop(ctx, create.ID, dockercontainer.StopOptions{}); err != nil { + log.Warn().Err(err).Msg("failure to stop container") + } + + if err := cli.ContainerRemove(ctx, create.ID, dockercontainer.RemoveOptions{}); err != nil { + log.Warn().Err(err).Msg("failure to remove container") + } + }() + + newContainerId := create.ID + + // 7. connect to networks + // docker can connect to only one network at creation, so we need to connect to networks after creation + // see https://github.com/moby/moby/issues/17750 + log.Debug().Str("container_id", newContainerId).Msg("connecting networks to container") + networks := container.NetworkSettings.Networks + for key, network := range networks { + if _, ok := initialNetwork.EndpointsConfig[key]; ok { + // skip the network that is used during container creation + continue + } + + if err := cli.NetworkConnect(ctx, network.NetworkID, newContainerId, network); err != nil { + return nil, errors.Wrap(err, "connect container network error") + } + } + + // 8. start the new container + log.Debug().Str("container_id", newContainerId).Msg("starting the new container") + if err := cli.ContainerStart(ctx, newContainerId, dockercontainer.StartOptions{}); err != nil { + return nil, errors.Wrap(err, "start container error") + } + + // 9. delete the old container + log.Debug().Str("container_id", containerId).Msg("starting to remove the old container") + _ = cli.ContainerRemove(ctx, containerId, dockercontainer.RemoveOptions{}) + + restore = false + + newContainer, _, err := cli.ContainerInspectWithRaw(ctx, newContainerId, true) + if err != nil { + return nil, errors.Wrap(err, "fetch container information error") + } + + return &newContainer, nil +} diff --git a/api/docker/container_test.go b/api/docker/container_test.go new file mode 100644 index 0000000..58aa605 --- /dev/null +++ b/api/docker/container_test.go @@ -0,0 +1,53 @@ +package docker + +import ( + "testing" + + "github.com/docker/docker/api/types/network" + "github.com/stretchr/testify/require" +) + +func TestApplyVersionConstraint(t *testing.T) { + t.Parallel() + initialNet := network.NetworkingConfig{ + EndpointsConfig: map[string]*network.EndpointSettings{ + "key1": { + MacAddress: "mac1", + EndpointID: "endpointID1", + }, + "key2": { + MacAddress: "mac2", + EndpointID: "endpointID2", + }, + }, + } + + f := func(currentVer string, constraint string, success, emptyMac bool) { + t.Helper() + + transformedNet, err := applyVersionConstraint(currentVer, constraint, initialNet, clearMacAddrs) + if success { + require.NoError(t, err) + } else { + require.Error(t, err) + } + + require.Len(t, transformedNet.EndpointsConfig, len(initialNet.EndpointsConfig)) + + for k := range initialNet.EndpointsConfig { + if emptyMac { + require.NotEqual(t, initialNet.EndpointsConfig[k], transformedNet.EndpointsConfig[k]) + require.Empty(t, transformedNet.EndpointsConfig[k].MacAddress) + + continue + } + + require.Equal(t, initialNet.EndpointsConfig[k], transformedNet.EndpointsConfig[k]) + } + } + + f("1.45", "< 1.44", true, false) // No transformation + f("1.43", "< 1.44", true, true) // Transformation + f("a.b.", "< 1.44", true, false) // Invalid current version + f("1.45", "z 1.44", false, false) // Invalid version constraint +} diff --git a/api/docker/errors.go b/api/docker/errors.go new file mode 100644 index 0000000..04e4159 --- /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 environment") +) diff --git a/api/docker/images/digest.go b/api/docker/images/digest.go new file mode 100644 index 0000000..7641b8f --- /dev/null +++ b/api/docker/images/digest.go @@ -0,0 +1,178 @@ +package images + +import ( + "context" + "strings" + "time" + + portainer "github.com/portainer/portainer/api" + + "github.com/docker/docker/api/types/image" + dockerclient "github.com/docker/docker/client" + "github.com/opencontainers/go-digest" + "github.com/pkg/errors" + "github.com/rs/zerolog/log" + "go.podman.io/image/v5/docker" + imagetypes "go.podman.io/image/v5/types" +) + +const digestFetchTimeout = 5 * time.Second + +// ClientFactory creates Docker clients for a given environment. +type ClientFactory interface { + CreateClient(endpoint *portainer.Endpoint, nodeName string, timeout *time.Duration) (*dockerclient.Client, error) +} + +// RegistryAuthProvider looks up registry credentials for an image. +type RegistryAuthProvider interface { + RegistryAuth(image Image) (string, string, error) +} + +type DigestClient struct { + clientFactory ClientFactory + sysCtx *imagetypes.SystemContext + registryClient RegistryAuthProvider +} + +func NewClientWithRegistry(registryClient RegistryAuthProvider, clientFactory ClientFactory) *DigestClient { + return &DigestClient{ + clientFactory: clientFactory, + registryClient: registryClient, + } +} + +func (c *DigestClient) RemoteDigest(ctx context.Context, image Image) (digest.Digest, error) { + ctx, cancel := context.WithTimeout(ctx, digestFetchTimeout) + defer cancel() + + // Docker references with both a tag and digest are currently not supported + if image.Tag != "" && image.Digest != "" { + if err := image.TrimDigest(); err != nil { + return "", err + } + } + + rmRef, err := ParseReference(image.String()) + if err != nil { + return "", errors.Wrap(err, "Cannot parse the image reference") + } + + sysCtx := c.sysCtx + if c.registryClient != nil { + username, password, err := c.registryClient.RegistryAuth(image) + if err != nil { + log.Info().Str("image up to date indicator", image.String()).Msg("No environment registry credentials found, using anonymous access") + } else { + sysCtx = &imagetypes.SystemContext{ + DockerAuthConfig: &imagetypes.DockerAuthConfig{ + Username: username, + Password: password, + }, + } + } + } + + // Retrieve remote digest through HEAD request + rmDigest, err := docker.GetDigest(ctx, sysCtx, rmRef) + if err != nil { + // Fallback to public registry for hub + if image.HubLink != "" { + rmDigest, err = docker.GetDigest(ctx, c.sysCtx, rmRef) + if err == nil { + return rmDigest, nil + } + } + + log.Debug().Err(err).Msg("get remote digest error") + + return "", errors.Wrap(err, "Cannot get image digest from HEAD request") + } + + return rmDigest, nil +} + +func ParseLocalImage(inspect image.InspectResponse) (*Image, error) { + if IsLocalImage(inspect) || IsDanglingImage(inspect) { + return nil, errors.New("the image is not regular") + } + + fromRepoDigests, err := ParseImage(ParseImageOptions{ + // including image name but no tag + Name: inspect.RepoDigests[0], + }) + if err != nil { + return nil, err + } + + if IsNoTagImage(inspect) { + return &fromRepoDigests, nil + } + + fromRepoTags, err := ParseImage(ParseImageOptions{ + Name: inspect.RepoTags[0], + }) + if err != nil { + return nil, err + } + + fromRepoDigests.Tag = fromRepoTags.Tag + + return &fromRepoDigests, nil +} + +func ParseRepoDigests(repoDigests []string) []digest.Digest { + digests := make([]digest.Digest, 0) + for _, repoDigest := range repoDigests { + d := ParseRepoDigest(repoDigest) + if d == "" { + continue + } + + digests = append(digests, d) + } + + return digests +} + +func ParseRepoTags(repoTags []string) []*Image { + images := make([]*Image, 0) + for _, repoTag := range repoTags { + if image := ParseRepoTag(repoTag); image != nil { + images = append(images, image) + } + } + + return images +} + +func ParseRepoDigest(repoDigest string) digest.Digest { + if !strings.ContainsAny(repoDigest, "@") { + return "" + } + + d, err := digest.Parse(strings.Split(repoDigest, "@")[1]) + if err != nil { + log.Warn().Err(err).Str("digest", repoDigest).Msg("skip invalid repo item") + + return "" + } + + return d +} + +func ParseRepoTag(repoTag string) *Image { + if repoTag == "" { + return nil + } + + image, err := ParseImage(ParseImageOptions{ + Name: repoTag, + }) + if err != nil { + log.Warn().Err(err).Str("repoTag", repoTag).Msg("RepoTag cannot be parsed.") + + return nil + } + + return &image +} diff --git a/api/docker/images/digest_test.go b/api/docker/images/digest_test.go new file mode 100644 index 0000000..dc1a4e0 --- /dev/null +++ b/api/docker/images/digest_test.go @@ -0,0 +1,34 @@ +package images + +import ( + "testing" + + "github.com/docker/docker/api/types/image" + "github.com/stretchr/testify/require" +) + +func TestParseLocalImage(t *testing.T) { + t.Parallel() + // Test with a regular image + + img, err := ParseLocalImage(image.InspectResponse{ + ID: "sha256:9234e8fb04c47cfe0f49931e4ac7eb76fa904e33b7f8576aec0501c085f02516", + RepoTags: []string{"myimage:latest"}, + RepoDigests: []string{"myimage@sha256:4bcff63911fcb4448bd4fdacec207030997caf25e9bea4045fa6c8c44de311d1"}, + }) + require.NoError(t, err) + require.NotNil(t, img) + require.Equal(t, "library/myimage", img.Path) + require.Equal(t, "latest", img.Tag) + require.Equal(t, "sha256:4bcff63911fcb4448bd4fdacec207030997caf25e9bea4045fa6c8c44de311d1", img.Digest.String()) + + // Test with a dangling image + + img, err = ParseLocalImage(image.InspectResponse{ + ID: "sha256:abcdef1234567890", + RepoTags: []string{":"}, + RepoDigests: []string{"@"}, + }) + require.Error(t, err) + require.Nil(t, img) +} diff --git a/api/docker/images/image.go b/api/docker/images/image.go new file mode 100644 index 0000000..3d60e36 --- /dev/null +++ b/api/docker/images/image.go @@ -0,0 +1,189 @@ +package images + +import ( + "bytes" + "fmt" + "path/filepath" + "strings" + "text/template" + + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/image" + "github.com/opencontainers/go-digest" + "github.com/pkg/errors" + "go.podman.io/image/v5/docker/reference" +) + +type ImageID string + +// Image holds information about an image. +type Image struct { + // Domain is the registry host of this image + Domain string + // Path may include username like portainer/portainer-ee, no Tag or Digest + Path string + Tag string + Digest digest.Digest + HubLink string + named reference.Named + Opts ParseImageOptions `json:"-"` +} + +// ParseImageOptions holds image options for parsing. +type ParseImageOptions struct { + Name string + HubTpl string +} + +// Name returns the full name representation of an image but no Tag or Digest. +func (i *Image) Name() string { + return i.named.Name() +} + +// FullName return the real full name may include Tag or Digest of the image, Tag first. +func (i *Image) FullName() string { + if i.Tag == "" { + return i.Name() + "@" + i.Digest.String() + } + + return i.Name() + ":" + i.Tag +} + +// String returns the string representation of an image, including Tag and Digest if existed. +func (i *Image) String() string { + return i.named.String() +} + +// Reference returns either the digest if it is non-empty or the tag for the image. +func (i *Image) Reference() string { + if len(i.Digest.String()) > 1 { + return i.Digest.String() + } + + return i.Tag +} + +// WithDigest sets the digest for an image. +func (i *Image) WithDigest(digest digest.Digest) (err error) { + i.Digest = digest + i.named, err = reference.WithDigest(i.named, digest) + + return err +} + +func (i *Image) WithTag(tag string) (err error) { + i.Tag = tag + i.named, err = reference.WithTag(i.named, tag) + + return err +} + +func (i *Image) TrimDigest() error { + i.Digest = "" + named, err := ParseImage(ParseImageOptions{Name: i.FullName()}) + if err != nil { + return err + } + i.named = &named + + return nil +} + +// ParseImage returns an Image struct with all the values filled in for a given image. +func ParseImage(parseOpts ParseImageOptions) (Image, error) { + // Parse the image name and tag. + named, err := reference.ParseNormalizedNamed(parseOpts.Name) + if err != nil { + return Image{}, errors.Wrapf(err, "parsing image %s failed", parseOpts.Name) + } + + // Add the latest lag if they did not provide one. + named = reference.TagNameOnly(named) + + i := Image{ + Opts: parseOpts, + named: named, + Domain: reference.Domain(named), + Path: reference.Path(named), + } + + // Hub link + i.HubLink, err = i.hubLink() + if err != nil { + return Image{}, errors.Wrap(err, fmt.Sprintf("resolving hub link for image %s failed", parseOpts.Name)) + } + + // Add the tag if there was one. + if tagged, ok := named.(reference.Tagged); ok { + i.Tag = tagged.Tag() + } + + // Add the digest if there was one. + if canonical, ok := named.(reference.Canonical); ok { + i.Digest = canonical.Digest() + } + + return i, nil +} + +func (i *Image) hubLink() (string, error) { + if i.Opts.HubTpl != "" { + var out bytes.Buffer + tmpl, err := template.New("tmpl"). + Option("missingkey=error"). + Parse(i.Opts.HubTpl) + if err != nil { + return "", err + } + err = tmpl.Execute(&out, i) + + return out.String(), err + } + + switch i.Domain { + case "docker.io": + prefix := "r" + path := i.Path + if strings.HasPrefix(i.Path, "library/") { + prefix = "_" + path = strings.Replace(i.Path, "library/", "", 1) + } + + return "https://hub.docker.com/" + prefix + "/" + path, nil + case "docker.bintray.io", "jfrog-docker-reg2.bintray.io": + return "https://bintray.com/jfrog/reg2/" + strings.ReplaceAll(i.Path, "/", "%3A"), nil + case "docker.pkg.github.com": + return "https://github.com/" + filepath.ToSlash(filepath.Dir(i.Path)) + "/packages", nil + case "gcr.io": + return "https://" + i.Domain + "/" + i.Path, nil + case "ghcr.io": + ref := strings.Split(i.Path, "/") + ghUser, ghPackage := ref[0], ref[1] + return "https://github.com/users/" + ghUser + "/packages/container/package/" + ghPackage, nil + case "quay.io": + return "https://quay.io/repository/" + i.Path, nil + case "registry.access.redhat.com": + return "https://access.redhat.com/containers/#/registry.access.redhat.com/" + i.Path, nil + case "registry.gitlab.com": + return "https://gitlab.com/" + i.Path + "/container_registry", nil + default: + return "", nil + } +} + +// IsLocalImage checks if the image has been built locally +func IsLocalImage(image types.ImageInspect) bool { + return len(image.RepoDigests) == 0 +} + +// IsDanglingImage returns whether the given image is "dangling" which means +// that there are no repository references to the given image and it has no +// child images +func IsDanglingImage(image image.InspectResponse) bool { + return len(image.RepoTags) == 1 && image.RepoTags[0] == ":" && len(image.RepoDigests) == 1 && image.RepoDigests[0] == "@" +} + +// IsNoTagImage returns whether the given image is damaged, has no tags +func IsNoTagImage(image image.InspectResponse) bool { + return len(image.RepoTags) == 0 +} diff --git a/api/docker/images/image_test.go b/api/docker/images/image_test.go new file mode 100644 index 0000000..46bdd44 --- /dev/null +++ b/api/docker/images/image_test.go @@ -0,0 +1,125 @@ +package images + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestImageParser(t *testing.T) { + t.Parallel() + is := assert.New(t) + + // portainer/portainer-ee + t.Run("", func(t *testing.T) { + image, err := ParseImage(ParseImageOptions{ + Name: "portainer/portainer-ee", + }) + require.NoError(t, err) + is.Equal("docker.io/portainer/portainer-ee:latest", image.FullName()) + is.Equal("portainer/portainer-ee", image.Opts.Name) + is.Equal("latest", image.Tag) + is.Equal("portainer/portainer-ee", image.Path) + is.Equal("docker.io", image.Domain) + is.Equal("docker.io/portainer/portainer-ee", image.Name()) + is.Equal("latest", image.Reference()) + is.Equal("docker.io/portainer/portainer-ee:latest", image.String()) + + }) + // gcr.io/k8s-minikube/kicbase@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2 + t.Run("", func(t *testing.T) { + image, err := ParseImage(ParseImageOptions{ + Name: "gcr.io/k8s-minikube/kicbase@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2", + }) + require.NoError(t, err) + is.Equal("gcr.io/k8s-minikube/kicbase@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2", image.FullName()) + is.Equal("gcr.io/k8s-minikube/kicbase@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2", image.Opts.Name) + is.Empty(image.Tag) + is.Equal("k8s-minikube/kicbase", image.Path) + is.Equal("gcr.io", image.Domain) + is.Equal("https://gcr.io/k8s-minikube/kicbase", image.HubLink) + is.Equal("gcr.io/k8s-minikube/kicbase", image.Name()) + is.Equal("sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2", image.Reference()) + is.Equal("gcr.io/k8s-minikube/kicbase@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2", image.String()) + }) + + // gcr.io/k8s-minikube/kicbase:v0.0.30@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2 + t.Run("", func(t *testing.T) { + image, err := ParseImage(ParseImageOptions{ + Name: "gcr.io/k8s-minikube/kicbase:v0.0.30@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2", + }) + require.NoError(t, err) + is.Equal("gcr.io/k8s-minikube/kicbase:v0.0.30", image.FullName()) + is.Equal("gcr.io/k8s-minikube/kicbase:v0.0.30@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2", image.Opts.Name) + is.Equal("v0.0.30", image.Tag) + is.Equal("k8s-minikube/kicbase", image.Path) + is.Equal("gcr.io", image.Domain) + is.Equal("https://gcr.io/k8s-minikube/kicbase", image.HubLink) + is.Equal("gcr.io/k8s-minikube/kicbase", image.Name()) + is.Equal("sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2", image.Reference()) + is.Equal("gcr.io/k8s-minikube/kicbase:v0.0.30@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2", image.String()) + }) +} + +func TestUpdateParsedImage(t *testing.T) { + t.Parallel() + is := assert.New(t) + + // gcr.io/k8s-minikube/kicbase:v0.0.30@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2 + t.Run("", func(t *testing.T) { + image, err := ParseImage(ParseImageOptions{ + Name: "gcr.io/k8s-minikube/kicbase:v0.0.30@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2", + }) + require.NoError(t, err) + err = image.WithTag("v0.0.31") + require.NoError(t, err) + is.Equal("gcr.io/k8s-minikube/kicbase:v0.0.31", image.FullName()) + is.Equal("gcr.io/k8s-minikube/kicbase:v0.0.30@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2", image.Opts.Name) + is.Equal("v0.0.31", image.Tag) + is.Equal("k8s-minikube/kicbase", image.Path) + is.Equal("gcr.io", image.Domain) + is.Equal("https://gcr.io/k8s-minikube/kicbase", image.HubLink) + is.Equal("gcr.io/k8s-minikube/kicbase", image.Name()) + is.Equal("sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2", image.Reference()) + is.Equal("gcr.io/k8s-minikube/kicbase:v0.0.31@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2", image.String()) + }) + + // gcr.io/k8s-minikube/kicbase:v0.0.30@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2 + t.Run("", func(t *testing.T) { + image, err := ParseImage(ParseImageOptions{ + Name: "gcr.io/k8s-minikube/kicbase:v0.0.30@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2", + }) + require.NoError(t, err) + err = image.WithDigest("sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b3") + require.NoError(t, err) + is.Equal("gcr.io/k8s-minikube/kicbase:v0.0.30", image.FullName()) + is.Equal("gcr.io/k8s-minikube/kicbase:v0.0.30@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2", image.Opts.Name) + is.Equal("v0.0.30", image.Tag) + is.Equal("k8s-minikube/kicbase", image.Path) + is.Equal("gcr.io", image.Domain) + is.Equal("https://gcr.io/k8s-minikube/kicbase", image.HubLink) + is.Equal("gcr.io/k8s-minikube/kicbase", image.Name()) + is.Equal("sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b3", image.Reference()) + is.Equal("gcr.io/k8s-minikube/kicbase:v0.0.30@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b3", image.String()) + }) + + // gcr.io/k8s-minikube/kicbase:v0.0.30@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2 + t.Run("", func(t *testing.T) { + image, err := ParseImage(ParseImageOptions{ + Name: "gcr.io/k8s-minikube/kicbase:v0.0.30@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2", + }) + require.NoError(t, err) + err = image.TrimDigest() + require.NoError(t, err) + is.Equal("gcr.io/k8s-minikube/kicbase:v0.0.30", image.FullName()) + is.Equal("gcr.io/k8s-minikube/kicbase:v0.0.30@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2", image.Opts.Name) + is.Equal("v0.0.30", image.Tag) + is.Equal("k8s-minikube/kicbase", image.Path) + is.Equal("gcr.io", image.Domain) + is.Equal("https://gcr.io/k8s-minikube/kicbase", image.HubLink) + is.Equal("gcr.io/k8s-minikube/kicbase", image.Name()) + is.Equal("v0.0.30", image.Reference()) + is.Equal("gcr.io/k8s-minikube/kicbase:v0.0.30", image.String()) + }) +} diff --git a/api/docker/images/puller.go b/api/docker/images/puller.go new file mode 100644 index 0000000..a0f35cd --- /dev/null +++ b/api/docker/images/puller.go @@ -0,0 +1,51 @@ +package images + +import ( + "context" + "io" + + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/logs" + + "github.com/docker/docker/api/types/image" + "github.com/docker/docker/client" + "github.com/rs/zerolog/log" +) + +type Puller struct { + client *client.Client + registryClient *RegistryClient + dataStore dataservices.DataStore +} + +func NewPuller(client *client.Client, registryClient *RegistryClient, dataStore dataservices.DataStore) *Puller { + return &Puller{ + client: client, + registryClient: registryClient, + dataStore: dataStore, + } +} + +func (puller *Puller) Pull(ctx context.Context, img Image) error { + log.Debug().Str("image", img.FullName()).Msg("starting to pull the image") + + registryAuth, err := puller.registryClient.EncodedRegistryAuth(img) + if err != nil { + log.Debug(). + Str("image", img.FullName()). + Err(err). + Msg("failed to get an encoded registry auth via image, try to pull image without registry auth") + } + + out, err := puller.client.ImagePull(ctx, img.FullName(), image.PullOptions{ + RegistryAuth: registryAuth, + }) + if err != nil { + return err + } + defer logs.CloseAndLogErr(out) + + _, err = io.ReadAll(out) + + return err +} diff --git a/api/docker/images/ref.go b/api/docker/images/ref.go new file mode 100644 index 0000000..83a0fa4 --- /dev/null +++ b/api/docker/images/ref.go @@ -0,0 +1,15 @@ +package images + +import ( + "strings" + + "go.podman.io/image/v5/docker" + "go.podman.io/image/v5/types" +) + +func ParseReference(imageStr string) (types.ImageReference, error) { + if !strings.HasPrefix(imageStr, "//") { + imageStr = "//" + imageStr + } + return docker.ParseReference(imageStr) +} diff --git a/api/docker/images/registry.go b/api/docker/images/registry.go new file mode 100644 index 0000000..346df29 --- /dev/null +++ b/api/docker/images/registry.go @@ -0,0 +1,155 @@ +package images + +import ( + "cmp" + "strings" + "time" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/internal/registryutils" + + "github.com/patrickmn/go-cache" + "github.com/pkg/errors" +) + +var registriesCache = cache.New(5*time.Minute, 5*time.Minute) + +type RegistryClient struct { + dataStore dataservices.DataStore +} + +func NewRegistryClient(dataStore dataservices.DataStore) *RegistryClient { + return &RegistryClient{dataStore: dataStore} +} + +func (c *RegistryClient) RegistryAuth(image Image) (string, string, error) { + registry, err := cachedRegistry(image.Opts.Name) + if err != nil { + var registries []portainer.Registry + err = c.dataStore.ViewTx(func(tx dataservices.DataStoreTx) error { + var err error + registries, err = tx.Registry().ReadAll() + return err + }) + if err != nil { + return "", "", err + } + + registry, err = findBestMatchRegistry(image.Opts.Name, registries) + if err != nil { + return "", "", err + } + } + + if !registry.Authentication { + return "", "", errors.New("authentication is disabled") + } + + return c.CertainRegistryAuth(registry) +} + +func (c *RegistryClient) CertainRegistryAuth(registry *portainer.Registry) (string, string, error) { + if err := registryutils.EnsureRegTokenValid(c.dataStore, registry); err != nil { + return "", "", err + } + + if !registry.Authentication { + return "", "", errors.New("authentication is disabled") + } + + return registryutils.GetRegEffectiveCredential(registry) +} + +func (c *RegistryClient) EncodedRegistryAuth(image Image) (string, error) { + registry, err := cachedRegistry(image.Opts.Name) + if err != nil { + var registries []portainer.Registry + err = c.dataStore.ViewTx(func(tx dataservices.DataStoreTx) error { + var err error + registries, err = tx.Registry().ReadAll() + return err + }) + if err != nil { + return "", err + } + + registry, err = findBestMatchRegistry(image.Opts.Name, registries) + if err != nil { + return "", err + } + } + + if !registry.Authentication { + return "", errors.New("authentication is disabled") + } + + return c.EncodedCertainRegistryAuth(registry) +} + +func (c *RegistryClient) EncodedCertainRegistryAuth(registry *portainer.Registry) (string, error) { + if err := registryutils.EnsureRegTokenValid(c.dataStore, registry); err != nil { + return "", err + } + + return registryutils.GetRegistryAuthHeader(registry) +} + +// findBestMatchRegistry finds out the best match registry for repository @Meng +// matching precedence: +// 1. both domain name and username matched (for dockerhub only) +// 2. only URL matched +// 3. pick up the first dockerhub registry +func findBestMatchRegistry(repository string, registries []portainer.Registry) (*portainer.Registry, error) { + cachedRegistry, err := cachedRegistry(repository) + if err == nil { + return cachedRegistry, nil + } + + var match1, match2, match3 *portainer.Registry + + for _, registry := range registries { + if strings.Contains(repository, registry.URL) { + match2 = ®istry + } + + if registry.Type != portainer.DockerHubRegistry { + continue + } + + // try to match repository examples: + // /nginx:latest + // docker.io//nginx:latest + if strings.HasPrefix(repository, registry.Username+"/") || strings.HasPrefix(repository, registry.URL+"/"+registry.Username+"/") { + match1 = ®istry + } + + // try to match repository examples: + // portainer/portainer-ee:latest + // /portainer-ee:latest + if match3 == nil { + match3 = ®istry + } + } + + match := cmp.Or(match1, match2, match3) + if match == nil { + return nil, errors.New("no registries matched") + } + + registriesCache.Set(repository, *match, 0) + + return match, nil +} + +func cachedRegistry(cacheKey string) (*portainer.Registry, error) { + r, ok := registriesCache.Get(cacheKey) + if ok { + registry, ok := r.(portainer.Registry) + if ok { + return ®istry, nil + } + } + + return nil, errors.Errorf("no registry found in cache: %s", cacheKey) +} diff --git a/api/docker/images/registry_test.go b/api/docker/images/registry_test.go new file mode 100644 index 0000000..750e165 --- /dev/null +++ b/api/docker/images/registry_test.go @@ -0,0 +1,87 @@ +package images + +import ( + "testing" + + portainer "github.com/portainer/portainer/api" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFindBestMatchNeedAuthRegistry(t *testing.T) { + t.Parallel() + is := assert.New(t) + + t.Run("", func(t *testing.T) { + image := "USERNAME/nginx:latest" + registries := []portainer.Registry{createNewRegistry("docker.io", "USERNAME", false), + createNewRegistry("hub-mirror.c.163.com", "", false)} + r, err := findBestMatchRegistry(image, registries) + require.NoError(t, err) + is.NotNil(r) + is.False(r.Authentication) + is.Equal("docker.io", r.URL) + }) + + t.Run("", func(t *testing.T) { + image := "USERNAME/nginx:latest" + registries := []portainer.Registry{createNewRegistry("docker.io", "", false), + createNewRegistry("hub-mirror.c.163.com", "USERNAME", false)} + r, err := findBestMatchRegistry(image, registries) + require.NoError(t, err) + is.NotNil(r) + is.False(r.Authentication) + is.Equal("docker.io", r.URL) + }) + + t.Run("", func(t *testing.T) { + image := "docker.io//nginx:latest" + registries := []portainer.Registry{createNewRegistry("docker.io", "USERNAME", true), + createNewRegistry("hub-mirror.c.163.com", "", false)} + r, err := findBestMatchRegistry(image, registries) + require.NoError(t, err) + is.NotNil(r) + is.True(r.Authentication) + is.Equal("docker.io", r.URL) + }) + + t.Run("", func(t *testing.T) { + image := "portainer/portainer-ee:latest" + registries := []portainer.Registry{createNewRegistry("docker.io", "", true)} + r, err := findBestMatchRegistry(image, registries) + require.NoError(t, err) + is.NotNil(r) + is.True(r.Authentication) + is.Equal("docker.io", r.URL) + }) +} + +func TestFindBestMatchRegistryCachesResult(t *testing.T) { + t.Parallel() + + repository := "caching-test/nginx:latest" + registries := []portainer.Registry{createNewRegistry("docker.io", "", true)} + + r, err := findBestMatchRegistry(repository, registries) + require.NoError(t, err) + + cached, err := cachedRegistry(repository) + require.NoError(t, err) + require.Equal(t, r.URL, cached.URL) + require.Equal(t, r.Authentication, cached.Authentication) +} + +func createNewRegistry(domain, username string, auth bool) portainer.Registry { + registry := portainer.Registry{ + URL: domain, + Authentication: auth, + Username: username, + } + + if domain == "docker.io" { + registry.Type = portainer.DockerHubRegistry + } + + return registry +} diff --git a/api/docker/images/status.go b/api/docker/images/status.go new file mode 100644 index 0000000..09a0111 --- /dev/null +++ b/api/docker/images/status.go @@ -0,0 +1,307 @@ +package images + +import ( + "context" + "slices" + "strings" + "time" + + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/filters" + portainer "github.com/portainer/portainer/api" + consts "github.com/portainer/portainer/api/docker/consts" + + "github.com/opencontainers/go-digest" + "github.com/patrickmn/go-cache" + "github.com/pkg/errors" + "github.com/rs/zerolog/log" + "golang.org/x/sync/errgroup" +) + +// Status constants +const ( + Processing = Status("processing") + Outdated = Status("outdated") + Updated = Status("updated") + Skipped = Status("skipped") + Preparing = Status("preparing") + Error = Status("error") +) + +const ( + errorStatusCacheTTL = 5 * time.Minute + maxConcurrentStatusChecks = 8 +) + +var ( + statusCache = cache.New(24*time.Hour, 24*time.Hour) + remoteDigestCache = cache.New(5*time.Second, 5*time.Second) + swarmID2NameCache = cache.New(5*time.Second, 5*time.Second) +) + +// Status holds Docker image analysis +type Status string + +func (c *DigestClient) ContainersImageStatus(ctx context.Context, containers []types.Container, endpoint *portainer.Endpoint) Status { + cli, err := c.clientFactory.CreateClient(endpoint, "", nil) + if err != nil { + log.Error().Err(err).Msg("cannot create docker client") + + return Error + } + + statuses := make([]Status, len(containers)) + + g, ctx := errgroup.WithContext(ctx) + g.SetLimit(maxConcurrentStatusChecks) + + containerStatus := func(ct types.Container) Status { + var nodeName string + if swarmNodeId := ct.Labels[consts.SwarmNodeIDLabel]; swarmNodeId != "" { + if swarmNodeName, ok := swarmID2NameCache.Get(swarmNodeId); ok { + nodeName, _ = swarmNodeName.(string) + } else { + node, _, err := cli.NodeInspectWithRaw(ctx, swarmNodeId) + if err != nil { + return Error + } + + nodeName = node.Description.Hostname + swarmID2NameCache.Set(swarmNodeId, nodeName, 0) + } + } + + s, err := c.ContainerImageStatus(ctx, ct.ID, endpoint, nodeName) + if err != nil { + log.Warn().Str("containerId", ct.ID).Err(err).Msg("error when fetching image status for container") + return Error + } + + return s + } + + for i, ct := range containers { + g.Go(func() error { + statuses[i] = containerStatus(ct) + return nil + }) + } + + _ = g.Wait() + + return AggregateImageStatus(statuses) +} + +func AggregateImageStatus(statuses []Status) Status { + if allMatch(statuses, Skipped) { + return Skipped + } + + if allMatch(statuses, Preparing) { + return Preparing + } + + if slices.Contains(statuses, Outdated) { + return Outdated + } else if slices.Contains(statuses, Processing) { + return Processing + } else if slices.Contains(statuses, Error) { + return Error + } + + return Updated +} + +func (c *DigestClient) ContainerImageStatus(ctx context.Context, containerID string, endpoint *portainer.Endpoint, nodeName string) (Status, error) { + cli, err := c.clientFactory.CreateClient(endpoint, nodeName, nil) + if err != nil { + log.Warn().Str("swarmNodeId", nodeName).Msg("Cannot create new docker client.") + } + + container, err := cli.ContainerInspect(ctx, containerID) + if err != nil { + log.Warn().Err(err).Str("containerID", containerID).Msg("Inspect container error.") + return Skipped, nil + } + + var imageID string + if strings.Contains(container.Image, "sha256") { + imageID = container.Image[strings.Index(container.Image, "sha256"):] + } + + if imageID == "" { + return Skipped, nil + } + + digs := make([]digest.Digest, 0) + images := make([]*Image, 0) + if i, err := ParseImage(ParseImageOptions{Name: container.Config.Image}); err == nil { + images = append(images, &i) + } + + imageInspect, _, err := cli.ImageInspectWithRaw(ctx, imageID) + if err != nil { + log.Debug().Str("imageID", imageID).Msg("inspect failed") + return Error, err + } + + if len(imageInspect.RepoDigests) > 0 { + digs = append(digs, ParseRepoDigests(imageInspect.RepoDigests)...) + } + + if len(imageInspect.RepoTags) > 0 { + images = append(images, ParseRepoTags(imageInspect.RepoTags)...) + } + + s, err := c.checkStatus(ctx, images, digs) + if err != nil { + log.Debug().Str("image", container.Image).Err(err).Msg("fetching a certain image status") + return Error, err + } + + statusCache.Set(imageID, s, 0) + + return s, err +} + +func (c *DigestClient) ServiceImageStatus(ctx context.Context, serviceID string, endpoint *portainer.Endpoint) (Status, error) { + cli, err := c.clientFactory.CreateClient(endpoint, "", nil) + if err != nil { + return Error, nil + } + + containers, err := cli.ContainerList(ctx, container.ListOptions{ + All: true, + Filters: filters.NewArgs(filters.Arg("label", consts.SwarmServiceIDLabel+"="+serviceID)), + }) + if err != nil { + log.Warn().Err(err).Str("serviceID", serviceID).Msg("cannot list container for the service") + return Error, err + } + + nonExistedOrStoppedContainers := make([]types.Container, 0) + for _, container := range containers { + if container.State == "exited" || container.State == "stopped" { + continue + } + + // When there is a container with the state "Created" under the service, it + // indicates that the Docker Swarm is replacing the existing task with + // a new task. At the moment, the state of the new task is "Created", and + // the state of the old task is "Running". + // Until the new task runs up, the image status should be set "Preparing" + if container.State == "created" { + return Preparing, nil + } + nonExistedOrStoppedContainers = append(nonExistedOrStoppedContainers, container) + } + + if len(nonExistedOrStoppedContainers) == 0 { + return Preparing, nil + } + + return c.ContainersImageStatus(ctx, nonExistedOrStoppedContainers, endpoint), nil +} + +func (c *DigestClient) checkStatus(ctx context.Context, images []*Image, digests []digest.Digest) (Status, error) { + if digests == nil { + digests = make([]digest.Digest, 0) + } + + for _, img := range images { + if img.Digest != "" && !slices.Contains(digests, img.Digest) { + log.Info().Str("localDigest", img.Domain).Msg("incoming local digest is not nil") + digests = append([]digest.Digest{img.Digest}, digests...) + } + } + + if len(digests) == 0 { + return Skipped, nil + } + + var imageStatus Status + + for _, img := range images { + var remoteDigest digest.Digest + var err error + if rd, ok := remoteDigestCache.Get(img.FullName()); ok { + remoteDigest, _ = rd.(digest.Digest) + } + if remoteDigest == "" { + remoteDigest, err = c.RemoteDigest(ctx, *img) + if err != nil { + log.Error().Str("image", img.String()).Msg("error when fetch remote digest for image") + return Error, err + } + } + remoteDigestCache.Set(img.FullName(), remoteDigest, 0) + + log.Debug().Str("image", img.FullName()).Stringer("remote_digest", remoteDigest). + Int("local_digest_size", len(digests)). + Msg("Digests") + + // final locals vs remote one + for _, dig := range digests { + log.Debug(). + Str("image", img.FullName()). + Stringer("remote_digest", remoteDigest). + Stringer("local_digest", dig). + Msg("Comparing") + + if dig == remoteDigest { + log.Debug().Str("image", img.FullName()). + Stringer("remote_digest", remoteDigest). + Stringer("local_digest", dig). + Msg("Found a match") + return Updated, nil + } + } + } + + imageStatus = Outdated + + return imageStatus, nil +} + +func CachedResourceImageStatus(resourceID string) (Status, error) { + if s, ok := statusCache.Get(resourceID); ok { + return s.(Status), nil + } + + return "", errors.Errorf("no image found in cache: %s", resourceID) +} + +func CacheResourceImageStatus(resourceID string, status Status) { + statusCache.Set(resourceID, status, 0) +} + +func CacheErrorImageStatus(resourceID string) { + statusCache.Set(resourceID, Error, errorStatusCacheTTL) +} + +func CachedImageDigest(resourceID string) (Status, error) { + if s, ok := statusCache.Get(resourceID); ok { + return s.(Status), nil + } + + return "", errors.Errorf("no image found in cache: %s", resourceID) +} + +func EvictImageStatus(resourceID string) { + statusCache.Delete(resourceID) +} + +func allMatch(statuses []Status, status Status) bool { + if len(statuses) == 0 { + return false + } + + for _, s := range statuses { + if s != status { + return false + } + } + + return true +} diff --git a/api/docker/images/status_test.go b/api/docker/images/status_test.go new file mode 100644 index 0000000..e7135c2 --- /dev/null +++ b/api/docker/images/status_test.go @@ -0,0 +1,63 @@ +package images + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestAggregateImageStatus(t *testing.T) { + t.Parallel() + + f := func(statuses []Status, expected Status) { + t.Helper() + require.Equal(t, expected, AggregateImageStatus(statuses)) + } + + f([]Status{Skipped, Skipped, Skipped}, Skipped) + f([]Status{Preparing, Preparing}, Preparing) + f([]Status{Updated, Outdated, Processing, Error}, Outdated) + f([]Status{Updated, Processing, Error}, Processing) + f([]Status{Updated, Error}, Error) + f([]Status{Updated, Updated}, Updated) + f([]Status{}, Updated) + f([]Status{Updated, Skipped}, Updated) +} + +func TestCachedResourceImageStatusMiss(t *testing.T) { + t.Parallel() + + _, err := CachedResourceImageStatus("status-test-miss-key") + require.Error(t, err) +} + +func TestCachedResourceImageStatusHitAndEvict(t *testing.T) { + t.Parallel() + + key := "status-test-hit-evict-key" + + CacheResourceImageStatus(key, Updated) + + s, err := CachedResourceImageStatus(key) + require.NoError(t, err) + require.Equal(t, Updated, s) + + EvictImageStatus(key) + + _, err = CachedResourceImageStatus(key) + require.Error(t, err) +} + +func TestCacheErrorImageStatus(t *testing.T) { + t.Parallel() + + key := "status-test-error-key" + + CacheErrorImageStatus(key) + + s, err := CachedResourceImageStatus(key) + require.NoError(t, err) + require.Equal(t, Error, s) + + EvictImageStatus(key) +} diff --git a/api/docker/snapshot.go b/api/docker/snapshot.go new file mode 100644 index 0000000..c7cb804 --- /dev/null +++ b/api/docker/snapshot.go @@ -0,0 +1,31 @@ +package docker + +import ( + portainer "github.com/portainer/portainer/api" + dockerclient "github.com/portainer/portainer/api/docker/client" + "github.com/portainer/portainer/api/logs" + "github.com/portainer/portainer/pkg/snapshot" +) + +// Snapshotter represents a service used to create environment(endpoint) snapshots +type Snapshotter struct { + clientFactory *dockerclient.ClientFactory +} + +// NewSnapshotter returns a new Snapshotter instance +func NewSnapshotter(clientFactory *dockerclient.ClientFactory) *Snapshotter { + return &Snapshotter{ + clientFactory: clientFactory, + } +} + +// CreateSnapshot creates a snapshot of a specific Docker environment(endpoint) +func (snapshotter *Snapshotter) CreateSnapshot(endpoint *portainer.Endpoint) (*portainer.DockerSnapshot, error) { + cli, err := snapshotter.clientFactory.CreateClient(endpoint, "", nil) + if err != nil { + return nil, err + } + defer logs.CloseAndLogErr(cli) + + return snapshot.CreateDockerSnapshot(cli) +} diff --git a/api/docker/stats/container_stats.go b/api/docker/stats/container_stats.go new file mode 100644 index 0000000..a3f98f6 --- /dev/null +++ b/api/docker/stats/container_stats.go @@ -0,0 +1,138 @@ +package stats + +import ( + "context" + "errors" + "strings" + "sync" + + "github.com/containerd/containerd/errdefs" + "github.com/docker/docker/api/types/container" +) + +type ContainerStats struct { + Running int `json:"running"` + Stopped int `json:"stopped"` + Healthy int `json:"healthy"` + Unhealthy int `json:"unhealthy"` + Total int `json:"total"` +} + +type DockerClient interface { + ContainerInspect(ctx context.Context, containerID string) (container.InspectResponse, error) +} + +func CalculateContainerStats(ctx context.Context, cli DockerClient, isSwarm bool, containers []container.Summary) (ContainerStats, error) { + if isSwarm { + return CalculateContainerStatsForSwarm(containers), nil + } + + var running, stopped, healthy, unhealthy int + + var mu sync.Mutex + var wg sync.WaitGroup + semaphore := make(chan struct{}, 5) + + var aggErr error + var aggMu sync.Mutex + + var processedCount int + for i := range containers { + id := containers[i].ID + + semaphore <- struct{}{} + wg.Go(func() { + defer func() { <-semaphore }() + + containerInspection, err := cli.ContainerInspect(ctx, id) + stat := ContainerStats{} + if err != nil { + if errdefs.IsNotFound(err) { + // An edge case is reported that Docker can list containers with no names, + // but when inspecting a container with specific ID and it is not found. + // In this case, we can safely ignore the error. + // ref@https://linear.app/portainer/issue/BE-12567/500-error-when-loading-docker-dashboard-in-portainer + return + } + + aggMu.Lock() + aggErr = errors.Join(aggErr, err) + processedCount++ + aggMu.Unlock() + return + } + stat = getContainerStatus(containerInspection.State) + + mu.Lock() + running += stat.Running + stopped += stat.Stopped + healthy += stat.Healthy + unhealthy += stat.Unhealthy + processedCount++ + mu.Unlock() + }) + } + + wg.Wait() + + return ContainerStats{ + Running: running, + Stopped: stopped, + Healthy: healthy, + Unhealthy: unhealthy, + Total: processedCount, + }, aggErr +} + +func getContainerStatus(state *container.State) ContainerStats { + stat := ContainerStats{} + if state == nil { + return stat + } + + switch state.Status { + case container.StateRunning: + stat.Running++ + case container.StateExited, container.StateDead: + stat.Stopped++ + } + + if state.Health != nil { + switch state.Health.Status { + case container.Healthy: + stat.Healthy++ + case container.Unhealthy: + stat.Unhealthy++ + } + } + + return stat +} + +// This is a temporary workaround to calculate container stats for Swarm +// TODO: Remove this once we have a proper way to calculate container stats for Swarm +func CalculateContainerStatsForSwarm(containers []container.Summary) ContainerStats { + var running, stopped, healthy, unhealthy int + for _, container := range containers { + switch container.State { + case "running": + running++ + case "exited", "stopped": + stopped++ + } + + if strings.Contains(container.Status, "(healthy)") { + healthy++ + } else if strings.Contains(container.Status, "(unhealthy)") { + unhealthy++ + } + } + + return ContainerStats{ + Running: running, + Stopped: stopped, + Healthy: healthy, + Unhealthy: unhealthy, + Total: len(containers), + } +} diff --git a/api/docker/stats/container_stats_test.go b/api/docker/stats/container_stats_test.go new file mode 100644 index 0000000..1a034ed --- /dev/null +++ b/api/docker/stats/container_stats_test.go @@ -0,0 +1,255 @@ +package stats + +import ( + "context" + "errors" + "fmt" + "testing" + "time" + + "github.com/containerd/containerd/errdefs" + "github.com/docker/docker/api/types/container" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// MockDockerClient implements the DockerClient interface for testing +type MockDockerClient struct { + mock.Mock +} + +func (m *MockDockerClient) ContainerInspect(ctx context.Context, containerID string) (container.InspectResponse, error) { + args := m.Called(ctx, containerID) + return args.Get(0).(container.InspectResponse), args.Error(1) +} + +func TestCalculateContainerStats(t *testing.T) { + t.Parallel() + mockClient := new(MockDockerClient) + + // Test containers - using enough containers to test concurrent processing + containers := []container.Summary{ + {ID: "container1"}, + {ID: "container2"}, + {ID: "container3"}, + {ID: "container4"}, + {ID: "container5"}, + {ID: "container6"}, + {ID: "container7"}, + {ID: "container8"}, + {ID: "container9"}, + {ID: "container10"}, + {ID: "container11"}, + } + + // Setup mock expectations with different container states to test various scenarios + containerStates := []struct { + id string + status string + health *container.Health + expected ContainerStats + }{ + {"container1", container.StateRunning, &container.Health{Status: container.Healthy}, ContainerStats{Running: 1, Stopped: 0, Healthy: 1, Unhealthy: 0}}, + {"container2", container.StateRunning, &container.Health{Status: container.Unhealthy}, ContainerStats{Running: 1, Stopped: 0, Healthy: 0, Unhealthy: 1}}, + {"container3", container.StateRunning, nil, ContainerStats{Running: 1, Stopped: 0, Healthy: 0, Unhealthy: 0}}, + {"container4", container.StateExited, nil, ContainerStats{Running: 0, Stopped: 1, Healthy: 0, Unhealthy: 0}}, + {"container5", container.StateDead, nil, ContainerStats{Running: 0, Stopped: 1, Healthy: 0, Unhealthy: 0}}, + {"container6", container.StateRunning, &container.Health{Status: container.Healthy}, ContainerStats{Running: 1, Stopped: 0, Healthy: 1, Unhealthy: 0}}, + {"container7", container.StateRunning, &container.Health{Status: container.Unhealthy}, ContainerStats{Running: 1, Stopped: 0, Healthy: 0, Unhealthy: 1}}, + {"container8", container.StateExited, nil, ContainerStats{Running: 0, Stopped: 1, Healthy: 0, Unhealthy: 0}}, + {"container9", container.StateRunning, nil, ContainerStats{Running: 1, Stopped: 0, Healthy: 0, Unhealthy: 0}}, + {"container10", container.StateDead, nil, ContainerStats{Running: 0, Stopped: 1, Healthy: 0, Unhealthy: 0}}, + } + + // Setup mock expectations for all containers with artificial delays to simulate real Docker calls + for _, state := range containerStates { + mockClient.On("ContainerInspect", mock.Anything, state.id).Return(container.InspectResponse{ + ContainerJSONBase: &container.ContainerJSONBase{ + State: &container.State{ + Status: state.status, + Health: state.health, + }, + }, + }, nil).After(30 * time.Millisecond) // Simulate 30ms Docker API call + } + + // Setup mock expectation for a container that returns NotFound error + mockClient.On("ContainerInspect", mock.Anything, "container11").Return(container.InspectResponse{}, fmt.Errorf("No such container: %w", errdefs.ErrNotFound)).After(50 * time.Millisecond) + + // Call the function and measure time + startTime := time.Now() + stats, err := CalculateContainerStats(t.Context(), mockClient, false, containers) + require.NoError(t, err, "failed to calculate container stats") + duration := time.Since(startTime) + + // Assert results + assert.Equal(t, 6, stats.Running) + assert.Equal(t, 4, stats.Stopped) + assert.Equal(t, 2, stats.Healthy) + assert.Equal(t, 2, stats.Unhealthy) + assert.Equal(t, 10, stats.Total) + + // Verify concurrent processing by checking that all mock calls were made + mockClient.AssertExpectations(t) + + // Test concurrency: With 5 workers and 10 containers taking 50ms each: + // Sequential would take: 10 * 50ms = 500ms + sequentialTime := 10 * 50 * time.Millisecond + + // Verify that concurrent processing is actually faster than sequential + // Allow some overhead for goroutine scheduling + assert.Less(t, duration, sequentialTime, "Concurrent processing should be faster than sequential") + // Concurrent should take: ~100-150ms (depending on scheduling) + assert.Less(t, duration, 150*time.Millisecond, "Concurrent processing should be significantly faster") + assert.Greater(t, duration, 100*time.Millisecond, "Concurrent processing should be longer than 100ms") +} + +func TestCalculateContainerStatsAllErrors(t *testing.T) { + t.Parallel() + mockClient := new(MockDockerClient) + + // Test containers + containers := []container.Summary{ + {ID: "container1"}, + {ID: "container2"}, + } + + // Setup mock expectations with all calls returning errors + mockClient.On("ContainerInspect", mock.Anything, "container1").Return(container.InspectResponse{}, errors.New("network error")) + mockClient.On("ContainerInspect", mock.Anything, "container2").Return(container.InspectResponse{}, errors.New("permission denied")) + + // Call the function + stats, err := CalculateContainerStats(t.Context(), mockClient, false, containers) + + // Assert that an error was returned + require.Error(t, err, "should return error when all containers fail to inspect") + assert.Contains(t, err.Error(), "network error", "error should contain one of the original error messages") + assert.Contains(t, err.Error(), "permission denied", "error should contain the other original error message") + + // Assert that stats are zero since no containers were successfully processed + expectedStats := ContainerStats{ + Running: 0, + Stopped: 0, + Healthy: 0, + Unhealthy: 0, + Total: 2, // total containers processed + } + assert.Equal(t, expectedStats, stats) + + // Verify all mock calls were made + mockClient.AssertExpectations(t) +} + +func TestGetContainerStatus(t *testing.T) { + t.Parallel() + testCases := []struct { + name string + state *container.State + expected ContainerStats + }{ + { + name: "running healthy container", + state: &container.State{ + Status: container.StateRunning, + Health: &container.Health{ + Status: container.Healthy, + }, + }, + expected: ContainerStats{ + Running: 1, + Stopped: 0, + Healthy: 1, + Unhealthy: 0, + }, + }, + { + name: "running unhealthy container", + state: &container.State{ + Status: container.StateRunning, + Health: &container.Health{ + Status: container.Unhealthy, + }, + }, + expected: ContainerStats{ + Running: 1, + Stopped: 0, + Healthy: 0, + Unhealthy: 1, + }, + }, + { + name: "running container without health check", + state: &container.State{ + Status: container.StateRunning, + }, + expected: ContainerStats{ + Running: 1, + Stopped: 0, + Healthy: 0, + Unhealthy: 0, + }, + }, + { + name: "exited container", + state: &container.State{ + Status: container.StateExited, + }, + expected: ContainerStats{ + Running: 0, + Stopped: 1, + Healthy: 0, + Unhealthy: 0, + }, + }, + { + name: "dead container", + state: &container.State{ + Status: container.StateDead, + }, + expected: ContainerStats{ + Running: 0, + Stopped: 1, + Healthy: 0, + Unhealthy: 0, + }, + }, + { + name: "nil state", + state: nil, + expected: ContainerStats{ + Running: 0, + Stopped: 0, + Healthy: 0, + Unhealthy: 0, + }, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + stat := getContainerStatus(testCase.state) + assert.Equal(t, testCase.expected, stat) + }) + } +} + +func TestCalculateContainerStatsForSwarm(t *testing.T) { + t.Parallel() + containers := []container.Summary{ + {State: "running"}, + {State: "running", Status: "Up 5 minutes (healthy)"}, + {State: "exited"}, + {State: "stopped"}, + {State: "running", Status: "Up 10 minutes"}, + {State: "running", Status: "Up about an hour (unhealthy)"}, + } + + stats := CalculateContainerStatsForSwarm(containers) + + assert.Equal(t, 4, stats.Running) + assert.Equal(t, 2, stats.Stopped) + assert.Equal(t, 1, stats.Healthy) + assert.Equal(t, 1, stats.Unhealthy) + assert.Equal(t, 6, stats.Total) +} diff --git a/api/docs/openapi.yaml b/api/docs/openapi.yaml new file mode 100644 index 0000000..3f96b56 --- /dev/null +++ b/api/docs/openapi.yaml @@ -0,0 +1,21461 @@ +openapi: 3.0.0 +info: + contact: + email: info@portainer.io + description: > + The Portainer API is an HTTP API served by Portainer. It is used by the + Portainer UI, and anything you can do in the UI can also be done via the + HTTP API. + + + API examples are available in the [Portainer documentation](https://documentation.portainer.io/api/api-examples/) + + + You can find out more about Portainer [on our website](http://portainer.io) and get some support on [Slack](http://portainer.io/slack/). + + + # Authentication + + + Most of the API endpoints require authentication, as well as some level of authorization. + + Portainer uses JSON Web Tokens to manage authentication. You must provide a token in the **Authorization** header of each request using the **Bearer** scheme. + + + Example: + + + ``` + + Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidXNlcm5hbWUiOiJhZG1pbiIsInJvbGUiOjEsImV4cCI6MTQ5OTM3NjE1NH0.NJ6vE8FY1WG6jsRQzfMqeatJ4vh2TWAeeYfDhP71YEE + + ``` + + + # Security + + + Each API endpoint has an associated access policy, documented in its description. + + + The following policies are available: + + + - Public access + + - Authenticated access + + - Restricted access + + - Administrator access + + + ### Public access + + + No authentication is required. + + + ### Authenticated access + + + Authentication is required. + + + ### Restricted access + + + Authentication is required. Additional checks may apply to verify access to the resource, and returned data may be filtered. + + + ### Administrator access + + + Authentication and an administrator role are both required. + + + # Execute Docker requests + + + Portainer does not expose dedicated endpoints for managing Docker resources (create a container, remove a volume, etc). + + + Instead, it acts as a reverse-proxy to the Docker HTTP API, allowing you to execute Docker requests via the Portainer HTTP API. + + + To do so, use the `/endpoints/{id}/docker` endpoint. Note that this endpoint is not documented below due to Swagger limitations. It has a restricted access policy, so authentication is still required. Any request made to this endpoint is proxied to the Docker API of the associated environment - request and response objects are identical to those in the [Docker official documentation](https://docs.docker.com/engine/api). + + + # Private Registry + + + When using a private registry, include a Base64-encoded JSON string in the request header. The header parameter name is `X-Registry-Auth` and the value should encode the following structure: ‘{"registryId":\}’ where `` is the ID of the registry where the repository was created. + + + Example encoded value: + + + ``` + + eyJyZWdpc3RyeUlkIjoxfQ== + + ``` + license: + name: zlib + url: https://github.com/portainer/portainer/blob/develop/LICENSE + title: PortainerCE API + version: 2.43.0 +paths: + /auth: + post: + description: >- + **Access policy**: public + + Use this environment(endpoint) to authenticate against Portainer using a username and password. + operationId: AuthenticateUser + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/auth.authenticatePayload" + description: Credentials used for authentication + required: true + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/auth.authenticateResponse" + "400": + description: Invalid request + "422": + description: Invalid Credentials + "500": + description: Server error + summary: Authenticate + tags: + - auth + /auth/logout: + post: + description: "**Access policy**: public" + operationId: Logout + responses: + "204": + description: Success + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Logout + tags: + - auth + /auth/oauth/validate: + post: + description: "**Access policy**: public" + operationId: ValidateOAuth + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/auth.oauthPayload" + description: OAuth Credentials used for authentication + required: true + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/auth.authenticateResponse" + "400": + description: Invalid request + "422": + description: Invalid Credentials + "500": + description: Server error + summary: Authenticate with OAuth + tags: + - auth + /backup: + post: + description: >- + Creates an archive with a system data snapshot that could be used to + restore the system. + + **Access policy**: admin + operationId: Backup + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/backup.backupPayload" + description: An object contains the password to encrypt the backup with + responses: + "200": + description: Success + "400": + description: Invalid request + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Creates an archive with a system data snapshot that could be used to + restore the system. + tags: + - backup + /custom_templates: + get: + description: |- + List available custom templates. + **Access policy**: authenticated + operationId: CustomTemplateList + parameters: + - description: Template types + in: query + name: type + required: true + style: form + explode: false + schema: + type: array + items: + enum: + - 1 + - 2 + - 3 + type: integer + - description: Filter by edge templates + in: query + name: edge + schema: + type: boolean + responses: + "200": + description: Success + content: + application/json: + schema: + items: + $ref: "#/components/schemas/portainer.CustomTemplate" + type: array + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: List available custom templates + tags: + - custom_templates + "/custom_templates/{id}": + delete: + description: |- + Remove a template. + **Access policy**: authenticated + operationId: CustomTemplateDelete + parameters: + - description: Template identifier + in: path + name: id + required: true + schema: + type: integer + responses: + "204": + description: Success + "400": + description: Invalid request + "403": + description: Access denied to resource + "404": + description: Template not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Remove a template + tags: + - custom_templates + get: + description: |- + Retrieve details about a template. + **Access policy**: authenticated + operationId: CustomTemplateInspect + parameters: + - description: Template identifier + in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/portainer.CustomTemplate" + "400": + description: Invalid request + "404": + description: Template not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Inspect a custom template + tags: + - custom_templates + put: + description: |- + Update a template. + **Access policy**: authenticated + operationId: CustomTemplateUpdate + parameters: + - description: Template identifier + in: path + name: id + required: true + schema: + type: integer + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/customtemplates.customTemplateUpdatePayload" + description: Template details + required: true + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/portainer.CustomTemplate" + "400": + description: Invalid request + "403": + description: Permission denied to access template + "404": + description: Template not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Update a template + tags: + - custom_templates + "/custom_templates/{id}/file": + get: + description: |- + Retrieve the content of the Stack file for the specified custom template + **Access policy**: authenticated + operationId: CustomTemplateFile + parameters: + - description: Template identifier + in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/customtemplates.fileResponse" + "400": + description: Invalid request + "404": + description: Custom template not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Get Template stack file content. + tags: + - custom_templates + "/custom_templates/{id}/git_fetch": + put: + description: |- + Retrieve details about a template created from git repository method. + **Access policy**: authenticated + operationId: CustomTemplateGitFetch + parameters: + - description: Template identifier + in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/customtemplates.fileResponse" + "400": + description: Invalid request + "404": + description: Custom template not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Fetch the latest config file content based on custom template's git + repository configuration + tags: + - custom_templates + /custom_templates/create/file: + post: + description: |- + Create a custom template. + **Access policy**: authenticated + operationId: CustomTemplateCreateFile + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + Title: + description: Title of the template + type: string + Description: + description: Description of the template + type: string + Note: + description: A note that will be displayed in the UI. Supports HTML content + type: string + Platform: + description: Platform associated to the template (1 - 'linux', 2 - 'windows') + type: integer + enum: + - 1 + - 2 + Type: + description: Type of created stack (1 - swarm, 2 - compose, 3 - kubernetes) + type: integer + enum: + - 1 + - 2 + - 3 + File: + description: File + type: string + format: binary + Logo: + description: URL of the template's logo + type: string + Variables: + description: A json array of variables definitions + type: string + required: + - Title + - Description + - Note + - Platform + - Type + - File + required: true + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/portainer.CustomTemplate" + "400": + description: Invalid request + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Create a custom template + tags: + - custom_templates + /custom_templates/create/repository: + post: + description: |- + Create a custom template. + **Access policy**: authenticated + operationId: CustomTemplateCreateRepository + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/customtemplates.customTemplateFromGitRepositoryPayl\ + oad" + description: Required when using method=repository + required: true + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/portainer.CustomTemplate" + "400": + description: Invalid request + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Create a custom template + tags: + - custom_templates + /custom_templates/create/string: + post: + description: |- + Create a custom template. + **Access policy**: authenticated + operationId: CustomTemplateCreateString + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/customtemplates.customTemplateFromFileContentPayloa\ + d" + description: body + required: true + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/portainer.CustomTemplate" + "400": + description: Invalid request + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Create a custom template + tags: + - custom_templates + "/docker/{environmentId}/containers/{containerId}/gpus": + get: + description: "**Access policy**:" + operationId: dockerContainerGpusInspect + parameters: + - description: Environment identifier + in: path + name: environmentId + required: true + schema: + type: integer + - description: Container identifier + in: path + name: containerId + required: true + schema: + type: integer + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/containers.containerGpusResponse" + "400": + description: Bad request + "404": + description: Environment or container not found + "500": + description: Internal server error + security: + - jwt: [] + summary: Fetch container gpus data + tags: + - docker + "/docker/{environmentId}/dashboard": + get: + description: "**Access policy**: restricted" + operationId: dockerDashboard + parameters: + - description: Environment identifier + in: path + name: environmentId + required: true + schema: + type: integer + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/docker.dashboardResponse" + "400": + description: Bad request + "500": + description: Internal server error + security: + - jwt: [] + summary: Get counters for the dashboard + tags: + - docker + "/docker/{environmentId}/images": + get: + description: "**Access policy**:" + operationId: dockerImagesList + parameters: + - description: Environment identifier + in: path + name: environmentId + required: true + schema: + type: integer + - description: Include image usage information + in: query + name: withUsage + schema: + type: boolean + responses: + "200": + description: Success + content: + application/json: + schema: + items: + $ref: "#/components/schemas/images.ImageResponse" + type: array + "400": + description: Bad request + "500": + description: Internal server error + security: + - jwt: [] + summary: Fetch images + tags: + - docker + /edge_groups: + get: + description: "**Access policy**: administrator" + operationId: EdgeGroupList + responses: + "200": + description: EdgeGroups + content: + application/json: + schema: + items: + $ref: "#/components/schemas/edgegroups.decoratedEdgeGroup" + type: array + "500": + description: Internal Server Error + "503": + description: Edge compute features are disabled + security: + - ApiKeyAuth: [] + - jwt: [] + summary: list EdgeGroups + tags: + - edge_groups + post: + description: "**Access policy**: administrator" + operationId: EdgeGroupCreate + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/edgegroups.edgeGroupCreatePayload" + description: EdgeGroup data + required: true + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/portainer.EdgeGroup" + "500": + description: Internal Server Error + "503": + description: Edge compute features are disabled + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Create an EdgeGroup + tags: + - edge_groups + "/edge_groups/{id}": + delete: + description: "**Access policy**: administrator" + operationId: EdgeGroupDelete + parameters: + - description: EdgeGroup Id + in: path + name: id + required: true + schema: + type: integer + responses: + "204": + description: No Content + "409": + description: Edge group is in use by an Edge stack or Edge job + "500": + description: Server error + "503": + description: Edge compute features are disabled + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Deletes an EdgeGroup + tags: + - edge_groups + get: + description: "**Access policy**: administrator" + operationId: EdgeGroupInspect + parameters: + - description: EdgeGroup Id + in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/portainer.EdgeGroup" + "500": + description: Internal Server Error + "503": + description: Edge compute features are disabled + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Inspects an EdgeGroup + tags: + - edge_groups + put: + description: "**Access policy**: administrator" + operationId: EdgeGroupUpdate + parameters: + - description: EdgeGroup Id + in: path + name: id + required: true + schema: + type: integer + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/edgegroups.edgeGroupUpdatePayload" + description: EdgeGroup data + required: true + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/portainer.EdgeGroup" + "500": + description: Internal Server Error + "503": + description: Edge compute features are disabled + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Updates an EdgeGroup + tags: + - edge_groups + /edge_jobs: + get: + description: "**Access policy**: administrator" + operationId: EdgeJobList + responses: + "200": + description: OK + content: + application/json: + schema: + items: + $ref: "#/components/schemas/portainer.EdgeJob" + type: array + "400": + description: Bad Request + "500": + description: Internal Server Error + "503": + description: Edge compute features are disabled + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Fetch EdgeJobs list + tags: + - edge_jobs + "/edge_jobs/{id}": + delete: + description: "**Access policy**: administrator" + operationId: EdgeJobDelete + parameters: + - description: EdgeJob Id + in: path + name: id + required: true + schema: + type: integer + responses: + "204": + description: No Content + "400": + description: Bad Request + "500": + description: Internal Server Error + "503": + description: Edge compute features are disabled + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Delete an EdgeJob + tags: + - edge_jobs + get: + description: "**Access policy**: administrator" + operationId: EdgeJobInspect + parameters: + - description: EdgeJob Id + in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/portainer.EdgeJob" + "400": + description: Bad Request + "500": + description: Internal Server Error + "503": + description: Edge compute features are disabled + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Inspect an EdgeJob + tags: + - edge_jobs + put: + description: "**Access policy**: administrator" + operationId: EdgeJobUpdate + parameters: + - description: EdgeJob Id + in: path + name: id + required: true + schema: + type: integer + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/edgejobs.edgeJobUpdatePayload" + description: EdgeGroup data + required: true + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/portainer.EdgeJob" + "400": + description: Bad Request + "500": + description: Internal Server Error + "503": + description: Edge compute features are disabled + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Update an EdgeJob + tags: + - edge_jobs + "/edge_jobs/{id}/file": + get: + description: "**Access policy**: administrator" + operationId: EdgeJobFile + parameters: + - description: EdgeJob Id + in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/edgejobs.edgeJobFileResponse" + "400": + description: Bad Request + "500": + description: Internal Server Error + "503": + description: Edge compute features are disabled + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Fetch a file of an EdgeJob + tags: + - edge_jobs + "/edge_jobs/{id}/tasks": + get: + description: "**Access policy**: administrator" + operationId: EdgeJobTasksList + parameters: + - description: EdgeJob Id + in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: OK + content: + application/json: + schema: + items: + $ref: "#/components/schemas/edgejobs.taskContainer" + type: array + "400": + description: Bad Request + "500": + description: Internal Server Error + "503": + description: Edge compute features are disabled + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Fetch the list of tasks on an EdgeJob + tags: + - edge_jobs + "/edge_jobs/{id}/tasks/{taskID}/logs": + delete: + description: "**Access policy**: administrator" + operationId: EdgeJobTasksClear + parameters: + - description: EdgeJob Id + in: path + name: id + required: true + schema: + type: integer + - description: Task Id + in: path + name: taskID + required: true + schema: + type: integer + responses: + "204": + description: No Content + "400": + description: Bad Request + "500": + description: Internal Server Error + "503": + description: Edge compute features are disabled + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Clear the log for a specifc task on an EdgeJob + tags: + - edge_jobs + get: + description: "**Access policy**: administrator" + operationId: EdgeJobTaskLogsInspect + parameters: + - description: EdgeJob Id + in: path + name: id + required: true + schema: + type: integer + - description: Task Id + in: path + name: taskID + required: true + schema: + type: integer + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/edgejobs.fileResponse" + "400": + description: Bad Request + "500": + description: Internal Server Error + "503": + description: Edge compute features are disabled + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Fetch the log for a specifc task on an EdgeJob + tags: + - edge_jobs + post: + description: "**Access policy**: administrator" + operationId: EdgeJobTasksCollect + parameters: + - description: EdgeJob Id + in: path + name: id + required: true + schema: + type: integer + - description: Task Id + in: path + name: taskID + required: true + schema: + type: integer + responses: + "204": + description: No Content + "400": + description: Bad Request + "500": + description: Internal Server Error + "503": + description: Edge compute features are disabled + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Collect the log for a specifc task on an EdgeJob + tags: + - edge_jobs + /edge_jobs/create/file: + post: + description: "**Access policy**: administrator" + operationId: EdgeJobCreateFile + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + file: + description: Content of the Stack file + type: string + format: binary + Name: + description: Name of the stack + type: string + CronExpression: + description: A cron expression to schedule this job + type: string + EdgeGroups: + description: JSON stringified array of Edge Groups ids + type: string + Endpoints: + description: JSON stringified array of Environment ids + type: string + Recurring: + description: If recurring + type: boolean + required: + - file + - Name + - CronExpression + - EdgeGroups + - Endpoints + required: true + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/portainer.EdgeGroup" + "500": + description: Internal Server Error + "503": + description: Edge compute features are disabled + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Create an EdgeJob from a file + tags: + - edge_jobs + /edge_jobs/create/string: + post: + description: "**Access policy**: administrator" + operationId: EdgeJobCreateString + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/edgejobs.edgeJobCreateFromFileContentPayload" + description: EdgeGroup data when method is string + required: true + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/portainer.EdgeGroup" + "500": + description: Internal Server Error + "503": + description: Edge compute features are disabled + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Create an EdgeJob from a text + tags: + - edge_jobs + /edge_stacks: + get: + description: "**Access policy**: administrator" + operationId: EdgeStackList + parameters: + - description: will summarize the statuses + in: query + name: summarizeStatuses + schema: + type: boolean + responses: + "200": + description: OK + content: + application/json: + schema: + items: + $ref: "#/components/schemas/portainer.EdgeStack" + type: array + "400": + description: Bad Request + "500": + description: Internal Server Error + "503": + description: Edge compute features are disabled + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Fetches the list of EdgeStacks + tags: + - edge_stacks + "/edge_stacks/{id}": + delete: + description: "**Access policy**: administrator" + operationId: EdgeStackDelete + parameters: + - description: EdgeStack Id + in: path + name: id + required: true + schema: + type: integer + responses: + "204": + description: No Content + "400": + description: Bad Request + "500": + description: Internal Server Error + "503": + description: Edge compute features are disabled + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Delete an EdgeStack + tags: + - edge_stacks + get: + description: "**Access policy**: administrator" + operationId: EdgeStackInspect + parameters: + - description: EdgeStack Id + in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/portainer.EdgeStack" + "400": + description: Bad Request + "500": + description: Internal Server Error + "503": + description: Edge compute features are disabled + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Inspect an EdgeStack + tags: + - edge_stacks + put: + description: "**Access policy**: administrator" + operationId: EdgeStackUpdate + parameters: + - description: EdgeStack Id + in: path + name: id + required: true + schema: + type: integer + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/edgestacks.updateEdgeStackPayload" + description: EdgeStack data + required: true + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/portainer.EdgeStack" + "400": + description: Bad Request + "500": + description: Internal Server Error + "503": + description: Edge compute features are disabled + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Update an EdgeStack + tags: + - edge_stacks + "/edge_stacks/{id}/file": + get: + description: "**Access policy**: administrator" + operationId: EdgeStackFile + parameters: + - description: EdgeStack Id + in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/edgestacks.stackFileResponse" + "400": + description: Bad Request + "500": + description: Internal Server Error + "503": + description: Edge compute features are disabled + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Fetches the stack file for an EdgeStack + tags: + - edge_stacks + "/edge_stacks/{id}/status": + put: + description: Authorized only if the request is done by an Edge Environment(Endpoint) + operationId: EdgeStackStatusUpdate + parameters: + - description: EdgeStack Id + in: path + name: id + required: true + schema: + type: integer + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/edgestacks.updateStatusPayload" + description: EdgeStack status payload + required: true + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/portainer.EdgeStack" + "400": + description: Bad Request + "403": + description: Forbidden + "404": + description: Not Found + "500": + description: Internal Server Error + summary: Update an EdgeStack status + tags: + - edge_stacks + /edge_stacks/create/file: + post: + description: "**Access policy**: administrator" + operationId: EdgeStackCreateFile + parameters: + - description: if true, will not create an edge stack, but just will check the + settings and return a non-persisted edge stack object + in: query + name: dryrun + schema: + type: string + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + Name: + description: Name of the stack. it must only consist of lowercase alphanumeric + characters, hyphens, or underscores as well as start with a + letter or number + type: string + file: + description: Content of the Stack file + type: string + format: binary + EdgeGroups: + description: JSON stringified array of Edge Groups ids + type: string + DeploymentType: + description: deploy type 0 - 'compose', 1 - 'kubernetes' + type: integer + Registries: + description: JSON stringified array of Registry ids to use for this stack + type: string + UseManifestNamespaces: + description: Uses the manifest's namespaces instead of the default one, relevant + only for kube environments + type: boolean + PrePullImage: + description: Pre Pull image + type: boolean + RetryDeploy: + description: Retry deploy + type: boolean + required: + - Name + - file + - EdgeGroups + - DeploymentType + required: true + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/portainer.EdgeStack" + "400": + description: Bad request + "500": + description: Internal server error + "503": + description: Edge compute features are disabled + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Create an EdgeStack from file + tags: + - edge_stacks + /edge_stacks/create/repository: + post: + description: "**Access policy**: administrator" + operationId: EdgeStackCreateRepository + parameters: + - description: if true, will not create an edge stack, but just will check the + settings and return a non-persisted edge stack object + in: query + name: dryrun + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/edgestacks.edgeStackFromGitRepositoryPayload" + description: stack config + required: true + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/portainer.EdgeStack" + "400": + description: Bad request + "500": + description: Internal server error + "503": + description: Edge compute features are disabled + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Create an EdgeStack from a git repository + tags: + - edge_stacks + /edge_stacks/create/string: + post: + description: "**Access policy**: administrator" + operationId: EdgeStackCreateString + parameters: + - description: if true, will not create an edge stack, but just will check the + settings and return a non-persisted edge stack object + in: query + name: dryrun + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/edgestacks.edgeStackFromStringPayload" + description: stack config + required: true + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/portainer.EdgeStack" + "400": + description: Bad request + "500": + description: Internal server error + "503": + description: Edge compute features are disabled + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Create an EdgeStack from a text + tags: + - edge_stacks + /endpoint_groups: + get: + description: >- + List all environment(endpoint) groups based on the current user + authorizations. Will + + return all environment(endpoint) groups if using an administrator account otherwise it will + + only return authorized environment(endpoint) groups. + + **Access policy**: restricted + operationId: EndpointGroupList + parameters: + - description: If true, each environment(endpoint) group will include the number + of environments(endpoints) associated to it and breakdown by type + in: query + name: size + schema: + type: boolean + responses: + "200": + description: Environment(Endpoint) group + content: + application/json: + schema: + items: + $ref: "#/components/schemas/endpointgroups.EndpointGroupResponse" + type: array + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: List Environment(Endpoint) groups + tags: + - endpoint_groups + post: + description: |- + Create a new environment(endpoint) group. + **Access policy**: administrator + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/endpointgroups.endpointGroupCreatePayload" + description: Environment(Endpoint) Group details + required: true + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/portainer.EndpointGroup" + "400": + description: Invalid request + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Create an Environment(Endpoint) Group + tags: + - endpoint_groups + "/endpoint_groups/{id}": + delete: + description: |- + Remove an environment(endpoint) group. + **Access policy**: administrator + operationId: EndpointGroupDelete + parameters: + - description: EndpointGroup identifier + in: path + name: id + required: true + schema: + type: integer + responses: + "204": + description: Success + "400": + description: Invalid request + "404": + description: EndpointGroup not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Remove an environment(endpoint) group + tags: + - endpoint_groups + get: + description: |- + Retrieve details abont an environment(endpoint) group. + **Access policy**: administrator + parameters: + - description: Environment(Endpoint) group identifier + in: path + name: id + required: true + schema: + type: integer + - description: If true, include the number of environments and breakdown by type + in: query + name: size + schema: + type: boolean + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/endpointgroups.EndpointGroupResponse" + "400": + description: Invalid request + "404": + description: EndpointGroup not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Inspect an Environment(Endpoint) group + tags: + - endpoint_groups + put: + description: |- + Update an environment(endpoint) group. + **Access policy**: administrator + operationId: EndpointGroupUpdate + parameters: + - description: EndpointGroup identifier + in: path + name: id + required: true + schema: + type: integer + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/endpointgroups.endpointGroupUpdatePayload" + description: EndpointGroup details + required: true + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/portainer.EndpointGroup" + "400": + description: Invalid request + "404": + description: EndpointGroup not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Update an environment(endpoint) group + tags: + - endpoint_groups + "/endpoint_groups/{id}/endpoints/{endpointId}": + delete: + description: "**Access policy**: administrator" + operationId: EndpointGroupDeleteEndpoint + parameters: + - description: EndpointGroup identifier + in: path + name: id + required: true + schema: + type: integer + - description: Environment(Endpoint) identifier + in: path + name: endpointId + required: true + schema: + type: integer + responses: + "204": + description: Success + "400": + description: Invalid request + "404": + description: EndpointGroup not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Removes environment(endpoint) from an environment(endpoint) group + tags: + - endpoint_groups + put: + description: |- + Add an environment(endpoint) to an environment(endpoint) group + **Access policy**: administrator + operationId: EndpointGroupAddEndpoint + parameters: + - description: EndpointGroup identifier + in: path + name: id + required: true + schema: + type: integer + - description: Environment(Endpoint) identifier + in: path + name: endpointId + required: true + schema: + type: integer + responses: + "204": + description: Success + "400": + description: Invalid request + "404": + description: EndpointGroup not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Add an environment(endpoint) to an environment(endpoint) group + tags: + - endpoint_groups + /endpoints: + delete: + deprecated: true + description: >- + Deprecated: use the `POST` endpoint instead. + + Remove multiple environments and optionally clean-up associated resources. + + **Access policy**: Administrator only. + operationId: EndpointDeleteBatchDeprecated + requestBody: + $ref: "#/components/requestBodies/endpoints.endpointDeleteBatchPayload" + responses: + "204": + description: Environment(s) successfully deleted. + "207": + description: Partial success. Some environments were deleted successfully, while + others failed. + content: + application/json: + schema: + $ref: "#/components/schemas/endpoints.endpointDeleteBatchPartialResponse" + "400": + description: Invalid request payload, such as missing required fields or fields + not meeting validation criteria. + "403": + description: Unauthorized access or operation not allowed. + "500": + description: Server error occurred while attempting to delete the specified + environments. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Remove multiple environments + tags: + - endpoints + get: + description: >- + List all environments(endpoints) based on the current user + authorizations. Will + + return all environments(endpoints) if using an administrator or team leader account otherwise it will + + only return authorized environments(endpoints). + + **Access policy**: restricted + operationId: EndpointList + parameters: + - description: Start searching from + in: query + name: start + schema: + type: integer + - description: Limit results to this value + in: query + name: limit + schema: + type: integer + - description: Sort results by this value + in: query + name: sort + schema: + type: string + enum: + - Name + - Group + - Status + - LastCheckIn + - EdgeID + - PlatformType + - Health + - Id + - description: Order sorted results by desc/asc + in: query + name: order + schema: + type: string + - description: Search query + in: query + name: search + schema: + type: string + - description: List environments(endpoints) of these groups + in: query + name: groupIds + style: form + explode: false + schema: + type: array + items: + type: integer + - description: List environments(endpoints) by this status + in: query + name: status + style: form + explode: false + schema: + type: array + items: + type: integer + - description: List environments(endpoints) of this type + in: query + name: types + style: form + explode: false + schema: + type: array + items: + enum: + - 0 + - 1 + - 2 + - 3 + - 4 + - 5 + - 6 + - 7 + type: integer + - description: Filter environments by platform type + in: query + name: platformTypes + style: form + explode: false + schema: + type: array + items: + enum: + - 0 + - 1 + - 2 + - 3 + - 4 + type: integer + - description: If true, return only environments with an outdated agent + in: query + name: outdated + schema: + type: boolean + - description: Exclude environments of these groups + in: query + name: excludeGroupIds + style: form + explode: false + schema: + type: array + items: + type: integer + - description: search environments(endpoints) with these tags (depends on + tagsPartialMatch) + in: query + name: tagIds + style: form + explode: false + schema: + type: array + items: + type: integer + - description: If true, will return environment(endpoint) which has one of tagIds, + if false (or missing) will return only environments(endpoints) that + has all the tags + in: query + name: tagsPartialMatch + schema: + type: boolean + - description: will return only these environments(endpoints) + in: query + name: endpointIds + style: form + explode: false + schema: + type: array + items: + type: integer + - description: will exclude these environments(endpoints) + in: query + name: excludeIds + style: form + explode: false + schema: + type: array + items: + type: integer + - description: will return only environments with on of these agent versions + in: query + name: agentVersions + style: form + explode: false + schema: + type: array + items: + type: string + - description: if exists true show only edge async agents, false show only + standard edge agents. if missing, will show both types (relevant + only for edge agents) + in: query + name: edgeAsync + schema: + type: boolean + - description: if true, show only untrusted edge agents, if false show only + trusted edge agents (relevant only for edge agents) + in: query + name: edgeDeviceUntrusted + schema: + type: boolean + - description: if bigger then zero, show only edge agents that checked-in in the + last provided seconds (relevant only for edge agents) + in: query + name: edgeCheckInPassedSeconds + schema: + type: number + - description: if true, the snapshot data won't be retrieved + in: query + name: excludeSnapshots + schema: + type: boolean + - description: will return only environments(endpoints) with this name + in: query + name: name + schema: + type: string + - description: will return the environments of the specified edge stack + in: query + name: edgeStackId + schema: + type: integer + - description: only applied when edgeStackId exists. Filter the returned + environments based on their deployment status in the stack (not the + environment status!) + in: query + name: edgeStackStatus + schema: + type: integer + enum: + - 0 + - 1 + - 2 + - 3 + - 4 + - 5 + - 6 + - 7 + - 8 + - 9 + - 10 + - 11 + - 12 + - 13 + - description: List environments(endpoints) of these edge groups + in: query + name: edgeGroupIds + style: form + explode: false + schema: + type: array + items: + type: integer + - description: Exclude environments(endpoints) of these edge groups + in: query + name: excludeEdgeGroupIds + style: form + explode: false + schema: + type: array + items: + type: integer + responses: + "200": + description: Endpoints + content: + application/json: + schema: + items: + $ref: "#/components/schemas/portainer.Endpoint" + type: array + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: List environments(endpoints) + tags: + - endpoints + post: + description: >- + Create a new environment(endpoint) that will be used to manage an + environment(endpoint). + + **Access policy**: administrator + operationId: EndpointCreate + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + Name: + description: "Name that will be used to identify this environment(endpoint) + (example: my-environment)" + type: string + EndpointCreationType: + description: "Environment(Endpoint) type. Value must be one of: 1 (Local Docker + environment), 2 (Agent environment), 3 (Azure environment), + 4 (Edge agent environment) or 5 (Local Kubernetes + Environment)" + type: integer + enum: + - 0 + - 1 + - 2 + - 3 + - 4 + - 5 + ContainerEngine: + description: "Container engine used by the environment(endpoint). Value must be + one of: 'docker' or 'podman'" + type: string + URL: + 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). Cannot be empty if + EndpointCreationType is set to 4 (Edge agent environment)" + type: string + PublicURL: + description: "URL or IP address where exposed containers will be reachable. + Defaults to URL if not specified (example: + docker.mydomain.tld:2375)" + type: string + GroupID: + description: Environment(Endpoint) group identifier. If not specified will + default to 1 (unassigned). + type: integer + TLS: + description: Require TLS to connect against this environment(endpoint). Must be + true if EndpointCreationType is set to 2 (Agent environment) + type: boolean + TLSSkipVerify: + description: Skip server verification when using TLS. Must be true if + EndpointCreationType is set to 2 (Agent environment) + type: boolean + TLSSkipClientVerify: + description: Skip client verification when using TLS. Must be true if + EndpointCreationType is set to 2 (Agent environment) + type: boolean + TLSCACertFile: + description: TLS CA certificate file + type: string + format: binary + TLSCertFile: + description: TLS client certificate file + type: string + format: binary + TLSKeyFile: + description: TLS client key file + type: string + format: binary + AzureApplicationID: + description: Azure application ID. Required if environment(endpoint) type is set + to 3 + type: string + AzureTenantID: + description: Azure tenant ID. Required if environment(endpoint) type is set to 3 + type: string + AzureAuthenticationKey: + description: Azure authentication key. Required if environment(endpoint) type is + set to 3 + type: string + TagIds: + description: JSON-parsable array of tag identifiers to which this + environment(endpoint) is associated + type: string + EdgeCheckinInterval: + description: The check in interval for edge agent (in seconds) + type: integer + EdgeTunnelServerAddress: + description: URL or IP address that will be used to establish a reverse tunnel + type: string + Gpus: + description: List of GPUs - json stringified array of {name, value} structs + type: string + required: + - Name + - EndpointCreationType + required: true + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/portainer.Endpoint" + "400": + description: Invalid request + "409": + description: Name is not unique + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Create a new environment(endpoint) + tags: + - endpoints + "/endpoints/{id}": + delete: + description: >- + Remove the environment associated to the specified identifier and + optionally clean-up associated resources. + + **Access policy**: Administrator only. + operationId: EndpointDelete + parameters: + - description: Environment(Endpoint) identifier + in: path + name: id + required: true + schema: + type: integer + responses: + "204": + description: Environment successfully deleted. + "400": + description: Invalid request payload, such as missing required fields or fields + not meeting validation criteria. + "403": + description: Unauthorized access or operation not allowed. + "404": + description: Unable to find the environment with the specified identifier inside + the database. + "500": + description: Server error occurred while attempting to delete the environment. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Remove an environment + tags: + - endpoints + get: + description: |- + Retrieve details about an environment(endpoint). + **Access policy**: restricted + operationId: EndpointInspect + parameters: + - description: Environment(Endpoint) identifier + in: path + name: id + required: true + schema: + type: integer + - description: if true, the snapshot data won't be retrieved + in: query + name: excludeSnapshot + schema: + type: boolean + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/portainer.Endpoint" + "400": + description: Invalid request + "404": + description: Environment(Endpoint) not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Inspect an environment(endpoint) + tags: + - endpoints + put: + description: |- + Update an environment(endpoint). + **Access policy**: authenticated + operationId: EndpointUpdate + parameters: + - description: Environment(Endpoint) identifier + in: path + name: id + required: true + schema: + type: integer + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/endpoints.endpointUpdatePayload" + description: Environment(Endpoint) details + required: true + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/portainer.Endpoint" + "400": + description: Invalid request + "404": + description: Environment(Endpoint) not found + "409": + description: Name is not unique + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Update an environment(endpoint) + tags: + - endpoints + "/endpoints/{id}/association": + put: + description: |- + De-association an edge environment(endpoint). + **Access policy**: administrator + operationId: EndpointAssociationDelete + parameters: + - description: Environment(Endpoint) identifier + in: path + name: id + required: true + schema: + type: integer + responses: + "204": + description: Success + "400": + description: Invalid request + "404": + description: Environment(Endpoint) not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: De-association an edge environment(endpoint) + tags: + - endpoints + "/endpoints/{id}/docker/v2/browse/put": + post: + description: |- + Use this environment(endpoint) to upload TLS files. + **Access policy**: administrator + parameters: + - description: Environment(Endpoint) identifier + in: path + name: id + required: true + schema: + type: integer + - description: Optional volume identifier to upload the file + in: query + name: volumeID + schema: + type: string + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + Path: + description: The destination path to upload the file to + type: string + file: + description: The file to upload + type: string + format: binary + required: + - Path + - file + required: true + responses: + "204": + description: Success + "400": + description: Invalid request + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Upload a file under a specific path on the file system of an + environment (endpoint) + tags: + - endpoints + "/endpoints/{id}/dockerhub/{registryId}": + get: + description: |- + get docker pull limits for a docker hub registry in the environment + **Access policy**: + operationId: endpointDockerhubStatus + parameters: + - description: endpoint ID + in: path + name: id + required: true + schema: + type: integer + - description: registry ID + in: path + name: registryId + required: true + schema: + type: integer + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/endpoints.dockerhubStatusResponse" + "400": + description: Invalid request + "403": + description: Permission denied + "404": + description: registry or endpoint not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: fetch docker pull limits + tags: + - endpoints + "/endpoints/{id}/edge/jobs/{jobID}/logs": + post: + description: "**Access policy**: Edge agent only — requires + X-PortainerAgent-EdgeID header" + parameters: + - description: environment Id + in: path + name: id + required: true + schema: + type: integer + - description: Job Id + in: path + name: jobID + required: true + schema: + type: integer + responses: + "200": + description: OK + "400": + description: Bad Request + "403": + description: Forbidden + "500": + description: Internal Server Error + summary: Update the logs collected from an Edge Job + tags: + - edge_agent + "/endpoints/{id}/edge/stacks/{stackId}": + get: + description: "**Access policy**: Edge agent only — requires + X-PortainerAgent-EdgeID header" + parameters: + - description: environment Id + in: path + name: id + required: true + schema: + type: integer + - description: EdgeStack Id + in: path + name: stackId + required: true + schema: + type: integer + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/edge.StackPayload" + "400": + description: Bad Request + "404": + description: Not Found + "500": + description: Internal Server Error + summary: Inspect an Edge Stack for an Environment + tags: + - edge_agent + - edge_stacks + "/endpoints/{id}/edge/status": + get: + description: >- + Endpoint for edge agent to check status of environment + + **Access policy**: Edge agent only — requires X-PortainerAgent-EdgeID header + operationId: EndpointEdgeStatusInspect + parameters: + - description: Environment identifier + in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: Success + content: + "*/*": + schema: + $ref: "#/components/schemas/endpointedge.endpointEdgeStatusInspectResponse" + "400": + description: Invalid request + "403": + description: Permission denied to access environment + "404": + description: Environment not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Get environment status + tags: + - edge_agent + "/endpoints/{id}/forceupdateservice": + put: + description: |- + force update a docker service + **Access policy**: authenticated + operationId: endpointForceUpdateService + parameters: + - description: endpoint identifier + in: path + name: id + required: true + schema: + type: integer + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/endpoints.forceUpdateServicePayload" + description: details + required: true + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/swarm.ServiceUpdateResponse" + "400": + description: Invalid request + "403": + description: Permission denied + "404": + description: endpoint not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: force update a docker service + tags: + - endpoints + "/endpoints/{id}/kubernetes/helm": + get: + description: "**Access policy**: authenticated" + operationId: HelmList + parameters: + - description: Environment(Endpoint) identifier + in: path + name: id + required: true + schema: + type: integer + - description: specify an optional namespace + in: query + name: namespace + schema: + type: string + - description: specify an optional filter + in: query + name: filter + schema: + type: string + - description: specify an optional selector + in: query + name: selector + schema: + type: string + responses: + "200": + description: Success + content: + application/json: + schema: + items: + $ref: "#/components/schemas/release.ReleaseElement" + type: array + "400": + description: Invalid environment(endpoint) identifier + "401": + description: Unauthorized + "404": + description: Environment(Endpoint) or ServiceAccount not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: List Helm Releases + tags: + - helm + post: + description: "**Access policy**: authenticated" + operationId: HelmInstall + parameters: + - description: Environment(Endpoint) identifier + in: path + name: id + required: true + schema: + type: integer + - description: Dry run + in: query + name: dryRun + schema: + type: boolean + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/helm.installChartPayload" + description: Chart details + required: true + responses: + "201": + description: Created + content: + application/json: + schema: + $ref: "#/components/schemas/release.Release" + "400": + description: Invalid request payload + "401": + description: Unauthorized + "404": + description: Environment(Endpoint) or ServiceAccount not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Install Helm Chart + tags: + - helm + "/endpoints/{id}/kubernetes/helm/{release}": + delete: + description: "**Access policy**: authenticated" + operationId: HelmDelete + parameters: + - description: Environment(Endpoint) identifier + in: path + name: id + required: true + schema: + type: integer + - description: The name of the release/application to uninstall + in: path + name: release + required: true + schema: + type: string + - description: An optional namespace + in: query + name: namespace + schema: + type: string + responses: + "204": + description: Success + "400": + description: Invalid environment(endpoint) id or bad request + "401": + description: Unauthorized + "404": + description: Environment(Endpoint) or ServiceAccount not found + "500": + description: Server error or helm error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Delete Helm Release + tags: + - helm + get: + description: |- + Get details of a helm release by release name + **Access policy**: authenticated + operationId: HelmGet + parameters: + - description: Environment(Endpoint) identifier + in: path + name: id + required: true + schema: + type: integer + - description: Helm release name + in: path + name: release + required: true + schema: + type: string + - description: specify an optional namespace + in: query + name: namespace + schema: + type: string + - description: show resources of the release + in: query + name: showResources + schema: + type: boolean + - description: specify an optional revision + in: query + name: revision + schema: + type: integer + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/release.Release" + "400": + description: Invalid request payload, such as missing required fields or fields + not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does not + have the necessary permissions. Ensure that you have provided a + valid API key or JWT token, and that you have the required + permissions. + "403": + description: Permission denied - the user is authenticated but does not have the + necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier. + "500": + description: Server error occurred while attempting to retrieve the release. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get a helm release + tags: + - helm + "/endpoints/{id}/kubernetes/helm/{release}/history": + get: + description: |- + Get a historical list of releases by release name + **Access policy**: authenticated + operationId: HelmGetHistory + parameters: + - description: Environment(Endpoint) identifier + in: path + name: id + required: true + schema: + type: integer + - description: Helm release name + in: path + name: release + required: true + schema: + type: string + - description: specify an optional namespace + in: query + name: namespace + schema: + type: string + responses: + "200": + description: Success + content: + application/json: + schema: + items: + $ref: "#/components/schemas/release.Release" + type: array + "400": + description: Invalid request payload, such as missing required fields or fields + not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does not + have the necessary permissions. Ensure that you have provided a + valid API key or JWT token, and that you have the required + permissions. + "403": + description: Permission denied - the user is authenticated but does not have the + necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier. + "500": + description: Server error occurred while attempting to retrieve the historical + list of releases. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get a historical list of releases + tags: + - helm + "/endpoints/{id}/kubernetes/helm/{release}/rollback": + post: + description: |- + Rollback a helm release to a previous revision + **Access policy**: authenticated + operationId: HelmRollback + parameters: + - description: Environment(Endpoint) identifier + in: path + name: id + required: true + schema: + type: integer + - description: Helm release name + in: path + name: release + required: true + schema: + type: string + - description: specify an optional namespace + in: query + name: namespace + schema: + type: string + - description: specify the revision to rollback to (defaults to previous revision + if not specified) + in: query + name: revision + schema: + type: integer + - description: "wait for resources to be ready (default: false)" + in: query + name: wait + schema: + type: boolean + - description: "wait for jobs to complete before marking the release as successful + (default: false)" + in: query + name: waitForJobs + schema: + type: boolean + - description: "performs pods restart for the resource if applicable (default: + true)" + in: query + name: recreate + schema: + type: boolean + - description: "force resource update through delete/recreate if needed (default: + false)" + in: query + name: force + schema: + type: boolean + - description: "time to wait for any individual Kubernetes operation in seconds + (default: 300)" + in: query + name: timeout + schema: + type: integer + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/release.Release" + "400": + description: Invalid request payload, such as missing required fields or fields + not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does not + have the necessary permissions. Ensure that you have provided a + valid API key or JWT token, and that you have the required + permissions. + "403": + description: Permission denied - the user is authenticated but does not have the + necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier or + release name. + "500": + description: Server error occurred while attempting to rollback the release. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Rollback a helm release + tags: + - helm + "/endpoints/{id}/registries": + get: + description: >- + List all registries based on the current user authorizations in current + environment. + + **Access policy**: authenticated + operationId: endpointRegistriesList + parameters: + - description: required if kubernetes environment, will show registries by namespace + in: query + name: namespace + schema: + type: string + - description: Environment(Endpoint) identifier + in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: Success + content: + application/json: + schema: + items: + $ref: "#/components/schemas/portainer.Registry" + type: array + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: List Registries on environment + tags: + - endpoints + "/endpoints/{id}/registries/{registryId}": + put: + description: "**Access policy**: authenticated" + operationId: endpointRegistryAccess + parameters: + - description: Environment(Endpoint) identifier + in: path + name: id + required: true + schema: + type: integer + - description: Registry identifier + in: path + name: registryId + required: true + schema: + type: integer + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/endpoints.registryAccessPayload" + description: details + required: true + responses: + "204": + description: Success + "400": + description: Invalid request + "403": + description: Permission denied + "404": + description: Endpoint not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: update registry access for environment + tags: + - endpoints + "/endpoints/{id}/settings": + put: + description: |- + Update settings for an environment(endpoint). + **Access policy**: authenticated + operationId: EndpointSettingsUpdate + parameters: + - description: Environment(Endpoint) identifier + in: path + name: id + required: true + schema: + type: integer + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/endpoints.endpointSettingsUpdatePayload" + description: Environment(Endpoint) details + required: true + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/portainer.Endpoint" + "400": + description: Invalid request + "404": + description: Environment(Endpoint) not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Update settings for an environment(endpoint) + tags: + - endpoints + "/endpoints/{id}/snapshot": + post: + description: |- + Snapshots an environment(endpoint) + **Access policy**: administrator + operationId: EndpointSnapshot + parameters: + - description: Environment(Endpoint) identifier + in: path + name: id + required: true + schema: + type: integer + responses: + "204": + description: Success + "400": + description: Invalid request + "404": + description: Environment(Endpoint) not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Snapshots an environment(endpoint) + tags: + - endpoints + /endpoints/delete: + post: + description: >- + Remove multiple environments and optionally clean-up associated + resources. + + **Access policy**: Administrator only. + operationId: EndpointDeleteBatch + requestBody: + $ref: "#/components/requestBodies/endpoints.endpointDeleteBatchPayload" + responses: + "204": + description: Environment(s) successfully deleted. + "207": + description: Partial success. Some environments were deleted successfully, while + others failed. + content: + application/json: + schema: + $ref: "#/components/schemas/endpoints.endpointDeleteBatchPartialResponse" + "400": + description: Invalid request payload, such as missing required fields or fields + not meeting validation criteria. + "403": + description: Unauthorized access or operation not allowed. + "500": + description: Server error occurred while attempting to delete the specified + environments. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Remove multiple environments + tags: + - endpoints + /endpoints/global-key: + post: + operationId: EndpointCreateGlobalKey + responses: + "200": + description: Success + content: + "*/*": + schema: + $ref: "#/components/schemas/endpoints.endpointCreateGlobalKeyResponse" + "400": + description: Invalid request + "500": + description: Server error + summary: Create or retrieve the endpoint for an EdgeID + tags: + - endpoints + /endpoints/relations: + put: + description: |- + Update relations for a list of environments + Edge groups, tags and environment group can be updated. + + **Access policy**: administrator + operationId: EndpointUpdateRelations + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/endpoints.endpointUpdateRelationsPayload" + description: Environment relations data + required: true + responses: + "204": + description: Success + "400": + description: Invalid request + "401": + description: Unauthorized + "404": + description: Not found + "500": + description: Server error + security: + - jwt: [] + summary: Update relations for a list of environments + tags: + - endpoints + /endpoints/snapshot: + post: + description: |- + Snapshot all environments(endpoints) + **Access policy**: administrator + operationId: EndpointSnapshots + responses: + "204": + description: Success + "500": + description: Server Error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Snapshot all environments(endpoints) + tags: + - endpoints + /endpoints/summary: + get: + description: >- + Returns counts of environments by status (up, down) and ungrouped + environments (unassigned), plus breakdowns by group, type, and health. + + **Access policy**: restricted + operationId: EndpointSummaryCounts + responses: + "200": + description: Environment summary counts + content: + application/json: + schema: + $ref: "#/components/schemas/endpoints.EnvironmentSummaryCountsResponse" + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Get environment summary counts + tags: + - endpoints + /gitops/repo/file/preview: + post: + description: |- + Retrieve the compose file content based on git repository configuration + **Access policy**: authenticated + operationId: GitOperationRepoFilePreview + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/gitops.repositoryFilePreviewPayload" + description: Template details + required: true + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/gitops.fileResponse" + "400": + description: Invalid request + "404": + description: Source not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: preview the content of target file in the git repository + tags: + - gitops + /gitops/sources: + get: + description: >- + Returns a deduplicated list of git repositories used across all GitOps + workflows. + + **Access policy**: authenticated + operationId: GitOpsSourcesList + parameters: + - description: Search term (matches URL) + in: query + name: search + schema: + type: string + - description: "Sort field: name | status | type" + in: query + name: sort + schema: + type: string + - description: "Sort order: asc or desc" + in: query + name: order + schema: + type: string + - description: Pagination start index + in: query + name: start + schema: + type: integer + - description: Pagination limit (0 = unlimited) + in: query + name: limit + schema: + type: integer + - description: "Filter by status: healthy | syncing | error | paused | unknown" + in: query + name: status + schema: + type: string + - description: "Filter by source type: git | oci | helm" + in: query + name: type + schema: + type: string + enum: + - git + - helm + - oci + responses: + "200": + description: OK + content: + application/json: + schema: + items: + $ref: "#/components/schemas/sources.Source" + type: array + "400": + description: Invalid status parameter + "403": + description: Access denied + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: List all GitOps sources + tags: + - gitops + "/gitops/sources/{id}": + delete: + description: >- + Deletes an existing GitOps source. Returns 409 if the source is + referenced by any workflow or custom template. + + **Access policy**: authenticated + operationId: GitOpsSourcesDelete + parameters: + - description: Source identifier + in: path + name: id + required: true + schema: + type: integer + responses: + "204": + description: Source deleted + "400": + description: Invalid request + "403": + description: Access denied + "404": + description: Source not found + "409": + description: Source is in use by one or more workflows or custom templates + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Delete a source + tags: + - gitops + get: + description: >- + Returns a single GitOps source with its connection settings and linked + workflows. + + **Access policy**: authenticated + operationId: GitOpsSourceGet + parameters: + - description: Source identifier + in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/sources.SourceDetail" + "400": + description: Invalid request + "403": + description: Access denied + "404": + description: Source not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Get a GitOps source by ID + tags: + - gitops + put: + description: |- + Updates an existing GitOps source backed by a Git repository. + **Access policy**: authenticated + operationId: GitOpsSourcesUpdateGit + parameters: + - description: Source identifier + in: path + name: id + required: true + schema: + type: integer + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/sources.GitSourceUpdatePayload" + description: Git source details + required: true + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/portainer.Source" + "400": + description: Invalid request payload + "403": + description: Access denied + "404": + description: Source not found + "409": + description: A source with this URL and credentials already exists + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Update a Git source + tags: + - gitops + "/gitops/sources/{id}/access": + put: + description: |- + Updates the access control settings for an existing GitOps source. + **Access policy**: administrator + operationId: GitOpsSourcesUpdateAccess + parameters: + - description: Source identifier + in: path + name: id + required: true + schema: + type: integer + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/sources.SourceAccessUpdatePayload" + description: Source access control + required: true + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/portainer.Source" + "400": + description: Invalid request payload + "403": + description: Access denied + "404": + description: Source not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Update a GitOps source's access control + tags: + - gitops + "/gitops/sources/{id}/test": + post: + description: >- + Tests connectivity for a GitOps source, applying optional overrides to + the stored configuration. + + **Access policy**: authenticated + operationId: GitOpsSourcesTestById + parameters: + - description: Source identifier + in: path + name: id + required: true + schema: + type: integer + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/sources.GitSourceUpdatePayload" + description: Optional connection overrides; omitted fields fall back to stored + values + responses: + "200": + description: Connection test result + content: + application/json: + schema: + $ref: "#/components/schemas/sources.ConnectionTestResult" + "400": + description: Invalid request payload + "403": + description: Access denied + "404": + description: Source not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Test the connection of a stored source + tags: + - gitops + /gitops/sources/git: + post: + description: |- + Creates a new GitOps source backed by a Git repository. + **Access policy**: authenticated + operationId: GitOpsSourcesCreateGit + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/sources.GitSourceCreatePayload" + description: Git source details + required: true + responses: + "201": + description: Created + content: + application/json: + schema: + $ref: "#/components/schemas/portainer.Source" + "400": + description: Invalid request payload + "403": + description: Access denied + "409": + description: A source with this URL and credentials already exists + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Create a Git source + tags: + - gitops + /gitops/sources/summary: + get: + description: |- + Returns a count of sources per status. + **Access policy**: authenticated + operationId: GitOpsSourcesSummary + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/workflows.StatusSummary" + "403": + description: Access denied + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Summarize GitOps source status counts + tags: + - gitops + /gitops/sources/test: + post: + description: >- + Tests connectivity for Git connection details that have not been + persisted yet. + + **Access policy**: authenticated + operationId: GitOpsSourcesTest + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/sources.GitSourceCreatePayload" + description: Git connection details + required: true + responses: + "200": + description: Connection test result + content: + application/json: + schema: + $ref: "#/components/schemas/sources.ConnectionTestResult" + "400": + description: Invalid request payload + "403": + description: Access denied + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Test a Git source connection + tags: + - gitops + /gitops/workflows: + get: + description: >- + Returns a unified list of all stacks that have GitOps (GitConfig) + configured. + + **Access policy**: authenticated + operationId: GitOpsWorkflowsList + parameters: + - description: Search term (matches name or repository URL) + in: query + name: search + schema: + type: string + - description: "Sort field: name | type | status | creationDate | lastSyncDate" + in: query + name: sort + schema: + type: string + - description: "Sort order: asc or desc" + in: query + name: order + schema: + type: string + - description: Pagination start index + in: query + name: start + schema: + type: integer + - description: Pagination limit (0 = unlimited) + in: query + name: limit + schema: + type: integer + - description: Filter by environment IDs (e.g. endpointIds[]=1&endpointIds[]=2) + in: query + name: endpointIds + style: form + explode: false + schema: + type: array + items: + type: integer + - description: "Filter by status: healthy | syncing | error | paused | unknown" + in: query + name: status + schema: + type: string + - description: "Filter by type: stack" + in: query + name: type + schema: + type: string + - description: "Filter by platform: dockerStandalone | dockerSwarm | kubernetes" + in: query + name: platform + schema: + type: string + responses: + "200": + description: OK + content: + application/json: + schema: + items: + $ref: "#/components/schemas/workflows.Workflow" + type: array + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: List all GitOps workflows + tags: + - gitops + "/gitops/workflows/{id}": + get: + description: >- + Returns the detail view of a single GitOps workflow, with one entry per + backing + + stack or edge stack artifact. + + **Access policy**: authenticated + operationId: GitOpsWorkflowGet + parameters: + - description: Workflow identifier + in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/workflows.WorkflowDetail" + "400": + description: Invalid request + "404": + description: Workflow not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Get a GitOps workflow by ID + tags: + - gitops + /gitops/workflows/summary: + get: + description: |- + Returns a count of workflows per status across all environments. + **Access policy**: authenticated + operationId: GitOpsWorkflowsSummary + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/workflows.StatusSummary" + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Summarize GitOps workflow status counts + tags: + - gitops + "/kubernetes/{id}/applications": + get: + description: >- + Get a list of applications across all namespaces in the cluster. If the + nodeName is provided, it will return the applications running on that + node. + + **Access policy**: authenticated + operationId: GetAllKubernetesApplications + parameters: + - description: Environment(Endpoint) identifier + in: path + name: id + required: true + schema: + type: integer + - description: Namespace name + in: query + name: namespace + required: true + schema: + type: string + - description: Node name + in: query + name: nodeName + required: true + schema: + type: string + responses: + "200": + description: Success + content: + application/json: + schema: + items: + $ref: "#/components/schemas/kubernetes.K8sApplication" + type: array + "400": + description: Invalid request payload, such as missing required fields or fields + not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does not + have the necessary permissions. Ensure that you have provided a + valid API key or JWT token, and that you have the required + permissions. + "403": + description: Permission denied - the user is authenticated but does not have the + necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier. + "500": + description: Server error occurred while attempting to retrieve the list of + applications from the cluster. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get a list of applications across all namespaces in the cluster. If the + nodeName is provided, it will return the applications running on that + node. + tags: + - kubernetes + "/kubernetes/{id}/applications/count": + get: + description: >- + Get the count of Applications across all namespaces in the cluster. If + the nodeName is provided, it will return the count of applications + running on that node. + + **Access policy**: Authenticated user. + operationId: GetAllKubernetesApplicationsCount + parameters: + - description: Environment identifier + in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: Success + content: + application/json: + schema: + type: integer + "400": + description: Invalid request payload, such as missing required fields or fields + not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does not + have the necessary permissions. Ensure that you have provided a + valid API key or JWT token, and that you have the required + permissions. + "403": + description: Permission denied - the user is authenticated but does not have the + necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier. + "500": + description: Server error occurred while attempting to retrieve the count of all + applications from the cluster. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get Applications count + tags: + - kubernetes + "/kubernetes/{id}/cluster_role_bindings/delete": + post: + description: |- + Delete the provided list of cluster role bindings. + **Access policy**: Authenticated user. + operationId: DeleteClusterRoleBindings + parameters: + - description: Environment identifier + in: path + name: id + required: true + schema: + type: integer + requestBody: + content: + application/json: + schema: + items: + type: string + type: array + description: A list of cluster role bindings to delete + required: true + responses: + "204": + description: Success + "400": + description: Invalid request payload, such as missing required fields or fields + not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does not + have the necessary permissions. Ensure that you have provided a + valid API key or JWT token, and that you have the required + permissions. + "403": + description: Permission denied - the user is authenticated but does not have the + necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier or + unable to find a specific cluster role binding. + "500": + description: Server error occurred while attempting to delete cluster role + bindings. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Delete cluster role bindings + tags: + - kubernetes + "/kubernetes/{id}/cluster_roles/delete": + post: + description: |- + Delete the provided list of cluster roles. + **Access policy**: Authenticated user. + operationId: DeleteClusterRoles + parameters: + - description: Environment identifier + in: path + name: id + required: true + schema: + type: integer + requestBody: + content: + application/json: + schema: + items: + type: string + type: array + description: A list of cluster roles to delete + required: true + responses: + "204": + description: Success + "400": + description: Invalid request payload, such as missing required fields or fields + not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does not + have the necessary permissions. Ensure that you have provided a + valid API key or JWT token, and that you have the required + permissions. + "403": + description: Permission denied - the user is authenticated but does not have the + necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier or + unable to find a specific cluster role. + "500": + description: Server error occurred while attempting to delete cluster roles. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Delete cluster roles + tags: + - kubernetes + "/kubernetes/{id}/clusterrolebindings": + get: + description: >- + Get a list of kubernetes cluster role bindings within the given + environment at the cluster level. + + **Access policy**: Authenticated user. + operationId: GetAllKubernetesClusterRoleBindings + parameters: + - description: Environment identifier + in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: Success + content: + application/json: + schema: + items: + $ref: "#/components/schemas/kubernetes.K8sClusterRoleBinding" + type: array + "400": + description: Invalid request payload, such as missing required fields or fields + not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does not + have the necessary permissions. Ensure that you have provided a + valid API key or JWT token, and that you have the required + permissions. + "403": + description: Permission denied - the user is authenticated but does not have the + necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier. + "500": + description: Server error occurred while attempting to retrieve the list of + cluster role bindings. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get a list of kubernetes cluster role bindings + tags: + - kubernetes + "/kubernetes/{id}/clusterroles": + get: + description: >- + Get a list of kubernetes cluster roles within the given environment at + the cluster level. + + **Access policy**: Authenticated user. + operationId: GetAllKubernetesClusterRoles + parameters: + - description: Environment identifier + in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: Success + content: + application/json: + schema: + items: + $ref: "#/components/schemas/kubernetes.K8sClusterRole" + type: array + "400": + description: Invalid request payload, such as missing required fields or fields + not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does not + have the necessary permissions. Ensure that you have provided a + valid API key or JWT token, and that you have the required + permissions. + "403": + description: Permission denied - the user is authenticated but does not have the + necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier. + "500": + description: Server error occurred while attempting to retrieve the list of + cluster roles. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get a list of kubernetes cluster roles + tags: + - kubernetes + "/kubernetes/{id}/configmaps": + get: + description: >- + Get a list of ConfigMaps across all namespaces in the cluster. For + non-admin users, it will only return ConfigMaps based on the namespaces + that they have access to. + + **Access policy**: Authenticated user. + operationId: GetAllKubernetesConfigMaps + parameters: + - description: Environment identifier + in: path + name: id + required: true + schema: + type: integer + - description: Set to true to include information about applications that use the + ConfigMaps in the response + in: query + name: isUsed + required: true + schema: + type: boolean + responses: + "200": + description: Success + content: + application/json: + schema: + items: + $ref: "#/components/schemas/kubernetes.K8sConfigMap" + type: array + "400": + description: Invalid request payload, such as missing required fields or fields + not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does not + have the necessary permissions. Ensure that you have provided a + valid API key or JWT token, and that you have the required + permissions. + "403": + description: Permission denied - the user is authenticated but does not have the + necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier. + "500": + description: Server error occurred while attempting to retrieve all configmaps + from the cluster. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get a list of ConfigMaps + tags: + - kubernetes + "/kubernetes/{id}/configmaps/count": + get: + description: >- + Get the count of ConfigMaps across all namespaces in the cluster. For + non-admin users, it will only return the count of ConfigMaps based on + the namespaces that they have access to. + + **Access policy**: Authenticated user. + operationId: GetAllKubernetesConfigMapsCount + parameters: + - description: Environment identifier + in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: Success + content: + application/json: + schema: + type: integer + "400": + description: Invalid request payload, such as missing required fields or fields + not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does not + have the necessary permissions. Ensure that you have provided a + valid API key or JWT token, and that you have the required + permissions. + "403": + description: Permission denied - the user is authenticated but does not have the + necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier. + "500": + description: Server error occurred while attempting to retrieve the count of all + configmaps from the cluster. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get ConfigMaps count + tags: + - kubernetes + "/kubernetes/{id}/cron_jobs": + get: + description: |- + Get a list of kubernetes Cron Jobs that the user has access to. + **Access policy**: Authenticated user. + operationId: GetKubernetesCronJobs + parameters: + - description: Environment identifier + in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: Success + content: + application/json: + schema: + items: + $ref: "#/components/schemas/kubernetes.K8sCronJob" + type: array + "400": + description: Invalid request payload, such as missing required fields or fields + not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does not + have the necessary permissions. Ensure that you have provided a + valid API key or JWT token, and that you have the required + permissions. + "403": + description: Permission denied - the user is authenticated but does not have the + necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier. + "500": + description: Server error occurred while attempting to retrieve the list of Cron + Jobs. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get a list of kubernetes Cron Jobs + tags: + - kubernetes + "/kubernetes/{id}/cron_jobs/delete": + post: + description: |- + Delete the provided list of Cron Jobs. + **Access policy**: Authenticated user. + operationId: DeleteCronJobs + parameters: + - description: Environment identifier + in: path + name: id + required: true + schema: + type: integer + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/kubernetes.K8sCronJobDeleteRequests" + description: A map where the key is the namespace and the value is an array of + Cron Jobs to delete + required: true + responses: + "204": + description: Success + "400": + description: Invalid request payload, such as missing required fields or fields + not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does not + have the necessary permissions. Ensure that you have provided a + valid API key or JWT token, and that you have the required + permissions. + "403": + description: Permission denied - the user is authenticated but does not have the + necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier or + unable to find a specific service account. + "500": + description: Server error occurred while attempting to delete Cron Jobs. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Delete Cron Jobs + tags: + - kubernetes + "/kubernetes/{id}/dashboard": + get: + description: >- + Get the dashboard summary data which is simply a count of a range of + different commonly used kubernetes resources. + + **Access policy**: Authenticated user. + operationId: GetKubernetesDashboard + parameters: + - description: Environment (Endpoint) identifier + in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: Success + content: + application/json: + schema: + items: + $ref: "#/components/schemas/kubernetes.K8sDashboard" + type: array + "400": + description: Invalid request + "500": + description: Server error + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get the dashboard summary data + tags: + - kubernetes + "/kubernetes/{id}/describe": + get: + description: |- + Get a description of a kubernetes resource. + **Access policy**: Authenticated user. + operationId: DescribeResource + parameters: + - description: Environment identifier + in: path + name: id + required: true + schema: + type: integer + - description: Resource name + in: query + name: name + required: true + schema: + type: string + - description: Resource kind + in: query + name: kind + required: true + schema: + type: string + - description: Namespace + in: query + name: namespace + schema: + type: string + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/kubernetes.describeResourceResponse" + "400": + description: Invalid request payload, such as missing required fields or fields + not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does not + have the necessary permissions. Ensure that you have provided a + valid API key or JWT token, and that you have the required + permissions. + "403": + description: Permission denied - the user is authenticated but does not have the + necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier. + "500": + description: Server error occurred while attempting to retrieve resource + description + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get a description of a kubernetes resource + tags: + - kubernetes + "/kubernetes/{id}/events": + get: + description: |- + Get events by query param resourceId + **Access policy**: Authenticated user. + operationId: getAllKubernetesEvents + parameters: + - description: Environment identifier + in: path + name: id + required: true + schema: + type: integer + - description: The resource id of the involved kubernetes object + in: query + name: resourceId + schema: + type: string + responses: + "200": + description: Success + content: + application/json: + schema: + items: + $ref: "#/components/schemas/kubernetes.K8sEvent" + type: array + "400": + description: Invalid request payload, such as missing required fields or fields + not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does not + have the necessary permissions. Ensure that you have provided a + valid API key or JWT token, and that you have the required + permissions. + "403": + description: Permission denied - the user is authenticated but does not have the + necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "500": + description: Server error occurred while attempting to retrieve the events. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Gets kubernetes events + tags: + - kubernetes + "/kubernetes/{id}/ingresscontrollers": + get: + description: >- + Get a list of ingress controllers for the given environment. If the + allowedOnly query parameter is set, only ingress controllers that are + allowed by the environment's ingress configuration will be returned. + + **Access policy**: Authenticated user. + operationId: GetAllKubernetesIngressControllers + parameters: + - description: Environment identifier + in: path + name: id + required: true + schema: + type: integer + - description: Only return allowed ingress controllers + in: query + name: allowedOnly + schema: + type: boolean + responses: + "200": + description: Success + content: + application/json: + schema: + items: + $ref: "#/components/schemas/kubernetes.K8sIngressController" + type: array + "400": + description: Invalid request payload, such as missing required fields or fields + not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does not + have the necessary permissions. Ensure that you have provided a + valid API key or JWT token, and that you have the required + permissions. + "403": + description: Permission denied - the user is authenticated but does not have the + necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier. + "500": + description: Server error occurred while attempting to retrieve ingress + controllers + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get a list of ingress controllers + tags: + - kubernetes + put: + description: |- + Update (block/unblock) ingress controllers for the provided environment. + **Access policy**: Authenticated user. + operationId: UpdateKubernetesIngressControllers + parameters: + - description: Environment identifier + in: path + name: id + required: true + schema: + type: integer + requestBody: + $ref: "#/components/requestBodies/kubernetes.K8sIngressControllerArray" + responses: + "204": + description: Success + "400": + description: Invalid request payload, such as missing required fields or fields + not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does not + have the necessary permissions. Ensure that you have provided a + valid API key or JWT token, and that you have the required + permissions. + "403": + description: Permission denied - the user is authenticated but does not have the + necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier or + unable to find the ingress controllers to update. + "500": + description: Server error occurred while attempting to update ingress controllers. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Update (block/unblock) ingress controllers + tags: + - kubernetes + "/kubernetes/{id}/ingresses": + get: + description: >- + Get kubernetes ingresses at the cluster level for the provided + environment. + + **Access policy**: Authenticated user. + operationId: GetAllKubernetesClusterIngresses + parameters: + - description: Environment identifier + in: path + name: id + required: true + schema: + type: integer + - description: Lookup services associated with each ingress + in: query + name: withServices + schema: + type: boolean + responses: + "200": + description: Success + content: + application/json: + schema: + items: + $ref: "#/components/schemas/kubernetes.K8sIngressInfo" + type: array + "400": + description: Invalid request payload, such as missing required fields or fields + not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does not + have the necessary permissions. Ensure that you have provided a + valid API key or JWT token, and that you have the required + permissions. + "403": + description: Permission denied - the user is authenticated but does not have the + necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier. + "500": + description: Server error occurred while attempting to retrieve ingresses. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get kubernetes ingresses at the cluster level + tags: + - kubernetes + "/kubernetes/{id}/ingresses/count": + get: + description: |- + Get the number of kubernetes ingresses within the given environment. + **Access policy**: Authenticated user. + operationId: GetAllKubernetesClusterIngressesCount + parameters: + - description: Environment identifier + in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: Success + content: + application/json: + schema: + type: integer + "400": + description: Invalid request payload, such as missing required fields or fields + not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does not + have the necessary permissions. Ensure that you have provided a + valid API key or JWT token, and that you have the required + permissions. + "403": + description: Permission denied - the user is authenticated but does not have the + necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier. + "500": + description: Server error occurred while attempting to retrieve ingresses count. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get Ingresses count + tags: + - kubernetes + "/kubernetes/{id}/ingresses/delete": + post: + description: |- + Delete one or more Ingresses in the provided environment. + **Access policy**: Authenticated user. + operationId: DeleteKubernetesIngresses + parameters: + - description: Environment identifier + in: path + name: id + required: true + schema: + type: integer + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/kubernetes.K8sIngressDeleteRequests" + description: Ingress details + required: true + responses: + "204": + description: Success + "400": + description: Invalid request payload, such as missing required fields or fields + not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does not + have the necessary permissions. Ensure that you have provided a + valid API key or JWT token, and that you have the required + permissions. + "403": + description: Permission denied - the user is authenticated but does not have the + necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier or + unable to find a specific ingress. + "500": + description: Server error occurred while attempting to delete specified ingresses. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Delete one or more Ingresses + tags: + - kubernetes + "/kubernetes/{id}/jobs": + get: + description: |- + Get a list of kubernetes Jobs that the user has access to. + **Access policy**: Authenticated user. + operationId: GetKubernetesJobs + parameters: + - description: Environment identifier + in: path + name: id + required: true + schema: + type: integer + - description: Whether to include Jobs that have a cronjob owner + in: query + name: includeCronJobChildren + schema: + type: boolean + responses: + "200": + description: Success + content: + application/json: + schema: + items: + $ref: "#/components/schemas/kubernetes.K8sJob" + type: array + "400": + description: Invalid request payload, such as missing required fields or fields + not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does not + have the necessary permissions. Ensure that you have provided a + valid API key or JWT token, and that you have the required + permissions. + "403": + description: Permission denied - the user is authenticated but does not have the + necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier. + "500": + description: Server error occurred while attempting to retrieve the list of Jobs. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get a list of kubernetes Jobs + tags: + - kubernetes + "/kubernetes/{id}/jobs/delete": + post: + description: |- + Delete the provided list of Jobs. + **Access policy**: Authenticated user. + operationId: DeleteJobs + parameters: + - description: Environment identifier + in: path + name: id + required: true + schema: + type: integer + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/kubernetes.K8sJobDeleteRequests" + description: A map where the key is the namespace and the value is an array of + Jobs to delete + required: true + responses: + "204": + description: Success + "400": + description: Invalid request payload, such as missing required fields or fields + not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does not + have the necessary permissions. Ensure that you have provided a + valid API key or JWT token, and that you have the required + permissions. + "403": + description: Permission denied - the user is authenticated but does not have the + necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier or + unable to find a specific service account. + "500": + description: Server error occurred while attempting to delete Jobs. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Delete Jobs + tags: + - kubernetes + "/kubernetes/{id}/max_resource_limits": + get: + description: >- + Get max CPU and memory limits (unused resources) of all nodes within k8s + cluster. + + **Access policy**: Authenticated user. + operationId: GetKubernetesMaxResourceLimits + parameters: + - description: Environment(Endpoint) identifier + in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/portainer.K8sNodesLimits" + "400": + description: Invalid request + "401": + description: Unauthorized access - the user is not authenticated or does not + have the necessary permissions. Ensure that you have provided a + valid API key or JWT token, and that you have the required + permissions. + "403": + description: Permission denied - the user is authenticated but does not have the + necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier. + "500": + description: Server error occurred while attempting to retrieve nodes limits. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get max CPU and memory limits of all nodes within k8s cluster + tags: + - kubernetes + "/kubernetes/{id}/metrics/applications_resources": + get: + description: >- + Get the total CPU (cores) and memory (bytes) requests and limits of all + applications across all namespaces. + + **Access policy**: Authenticated user. + operationId: GetApplicationsResources + parameters: + - description: Environment(Endpoint) identifier + in: path + name: id + required: true + schema: + type: integer + - description: Node name + in: query + name: node + required: true + schema: + type: string + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/kubernetes.K8sApplicationResource" + "400": + description: Invalid request payload, such as missing required fields or fields + not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does not + have the necessary permissions. Ensure that you have provided a + valid API key or JWT token, and that you have the required + permissions. + "403": + description: Permission denied - the user is authenticated but does not have the + necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier. + "500": + description: Server error occurred while attempting to retrieve the total + resource requests and limits for all applications from the cluster. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get the total resource requests and limits of all applications + tags: + - kubernetes + "/kubernetes/{id}/metrics/nodes": + get: + description: |- + Get a list of metrics associated with all nodes of a cluster. + **Access policy**: Authenticated user. + operationId: GetKubernetesMetricsForAllNodes + parameters: + - description: Environment identifier + in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/v1beta1.NodeMetricsList" + "401": + description: Unauthorized access - the user is not authenticated or does not + have the necessary permissions. Ensure that you have provided a + valid API key or JWT token, and that you have the required + permissions. + "500": + description: Server error occurred while attempting to retrieve the list of + nodes with their live metrics. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get a list of nodes with their live metrics + tags: + - kubernetes + "/kubernetes/{id}/metrics/nodes/{name}": + get: + description: |- + Get live metrics for the specified node. + **Access policy**: Authenticated user. + operationId: GetKubernetesMetricsForNode + parameters: + - description: Environment identifier + in: path + name: id + required: true + schema: + type: integer + - description: Node identifier + in: path + name: name + required: true + schema: + type: string + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/v1beta1.NodeMetrics" + "400": + description: Invalid request payload, such as missing required fields or fields + not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does not + have the necessary permissions. Ensure that you have provided a + valid API key or JWT token, and that you have the required + permissions. + "500": + description: Server error occurred while attempting to retrieve the live metrics + for the specified node. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get live metrics for a node + tags: + - kubernetes + "/kubernetes/{id}/metrics/pods/{namespace}": + get: + description: |- + Get a list of pods with their live metrics for the specified namespace. + **Access policy**: Authenticated user. + operationId: GetKubernetesMetricsForAllPods + parameters: + - description: Environment identifier + in: path + name: id + required: true + schema: + type: integer + - description: Namespace + in: path + name: namespace + required: true + schema: + type: string + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/v1beta1.PodMetricsList" + "400": + description: Invalid request payload, such as missing required fields or fields + not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does not + have the necessary permissions. Ensure that you have provided a + valid API key or JWT token, and that you have the required + permissions. + "500": + description: Server error occurred while attempting to retrieve the list of pods + with their live metrics. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get a list of pods with their live metrics + tags: + - kubernetes + "/kubernetes/{id}/metrics/pods/{namespace}/{name}": + get: + description: |- + Get live metrics for the specified pod. + **Access policy**: Authenticated user. + operationId: GetKubernetesMetricsForPod + parameters: + - description: Environment identifier + in: path + name: id + required: true + schema: + type: integer + - description: Namespace + in: path + name: namespace + required: true + schema: + type: string + - description: Pod identifier + in: path + name: name + required: true + schema: + type: string + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/v1beta1.PodMetrics" + "400": + description: Invalid request payload, such as missing required fields or fields + not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does not + have the necessary permissions. Ensure that you have provided a + valid API key or JWT token, and that you have the required + permissions. + "500": + description: Server error occurred while attempting to retrieve the live metrics + for the specified pod. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get live metrics for a pod + tags: + - kubernetes + "/kubernetes/{id}/namespaces": + delete: + description: |- + Delete a kubernetes namespace within the given environment. + **Access policy**: Authenticated user. + operationId: DeleteKubernetesNamespace + parameters: + - description: Environment identifier + in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: Success + content: + "*/*": + schema: + type: string + "400": + description: Invalid request payload, such as missing required fields or fields + not meeting validation criteria. + "403": + description: Unauthorized access or operation not allowed. + "500": + description: Server error occurred while attempting to delete the namespace. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Delete a kubernetes namespace + tags: + - kubernetes + get: + description: >- + Get a list of all namespaces within the given environment based on the + user role and permissions. If the user is an admin, they can access all + namespaces. If the user is not an admin, they can only access namespaces + that they have access to. + + **Access policy**: Authenticated user. + operationId: GetKubernetesNamespaces + parameters: + - description: Environment identifier + in: path + name: id + required: true + schema: + type: integer + - description: When set to true, include the resource quota information as part of + the Namespace information. Default is false + in: query + name: withResourceQuota + required: true + schema: + type: boolean + - description: When set to true, include the unhealthy events information as part + of the Namespace information. Default is false + in: query + name: withUnhealthyEvents + required: true + schema: + type: boolean + responses: + "200": + description: Success + content: + application/json: + schema: + items: + $ref: "#/components/schemas/portainer.K8sNamespaceInfo" + type: array + "400": + description: Invalid request payload, such as missing required fields or fields + not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does not + have the necessary permissions. Ensure that you have provided a + valid API key or JWT token, and that you have the required + permissions. + "403": + description: Permission denied - the user is authenticated but does not have the + necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier. + "500": + description: Server error occurred while attempting to retrieve the list of + namespaces. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get a list of namespaces + tags: + - kubernetes + post: + description: |- + Create a namespace within the given environment. + **Access policy**: Authenticated user. + operationId: CreateKubernetesNamespace + parameters: + - description: Environment identifier + in: path + name: id + required: true + schema: + type: integer + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/kubernetes.K8sNamespaceDetails" + description: Namespace configuration details + required: true + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/portainer.K8sNamespaceInfo" + "400": + description: Invalid request payload, such as missing required fields or fields + not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does not + have the necessary permissions. Ensure that you have provided a + valid API key or JWT token, and that you have the required + permissions. + "403": + description: Permission denied - the user is authenticated but does not have the + necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "409": + description: Conflict - the namespace already exists. + "500": + description: Server error occurred while attempting to create the namespace. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Create a namespace + tags: + - kubernetes + put: + description: |- + Update a namespace within the given environment. + **Access policy**: Authenticated user. + operationId: UpdateKubernetesNamespaceDeprecated + parameters: + - description: Environment identifier + in: path + name: id + required: true + schema: + type: integer + - description: Namespace + in: path + name: namespace + required: true + schema: + type: string + requestBody: + $ref: "#/components/requestBodies/kubernetes.K8sNamespaceDetails" + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/portainer.K8sNamespaceInfo" + "400": + description: Invalid request payload, such as missing required fields or fields + not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does not + have the necessary permissions. Ensure that you have provided a + valid API key or JWT token, and that you have the required + permissions. + "403": + description: Permission denied - the user is authenticated but does not have the + necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier or + unable to find a specific namespace. + "500": + description: Server error occurred while attempting to update the namespace. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Update a namespace + tags: + - kubernetes + "/kubernetes/{id}/namespaces/{namespace}": + get: + description: >- + Get namespace details for the provided namespace within the given + environment. + + **Access policy**: Authenticated user. + operationId: GetKubernetesNamespace + parameters: + - description: Environment identifier + in: path + name: id + required: true + schema: + type: integer + - description: The namespace name to get details for + in: path + name: namespace + required: true + schema: + type: string + - description: When set to true, include the resource quota information as part of + the Namespace information. Default is false + in: query + name: withResourceQuota + required: true + schema: + type: boolean + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/portainer.K8sNamespaceInfo" + "400": + description: Invalid request payload, such as missing required fields or fields + not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does not + have the necessary permissions. Ensure that you have provided a + valid API key or JWT token, and that you have the required + permissions. + "403": + description: Permission denied - the user is authenticated but does not have the + necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier or + unable to find a specific namespace. + "500": + description: Server error occurred while attempting to retrieve specified + namespace information. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get namespace details + tags: + - kubernetes + put: + description: |- + Update a namespace within the given environment. + **Access policy**: Authenticated user. + operationId: UpdateKubernetesNamespace + parameters: + - description: Environment identifier + in: path + name: id + required: true + schema: + type: integer + - description: Namespace + in: path + name: namespace + required: true + schema: + type: string + requestBody: + $ref: "#/components/requestBodies/kubernetes.K8sNamespaceDetails" + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/portainer.K8sNamespaceInfo" + "400": + description: Invalid request payload, such as missing required fields or fields + not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does not + have the necessary permissions. Ensure that you have provided a + valid API key or JWT token, and that you have the required + permissions. + "403": + description: Permission denied - the user is authenticated but does not have the + necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier or + unable to find a specific namespace. + "500": + description: Server error occurred while attempting to update the namespace. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Update a namespace + tags: + - kubernetes + "/kubernetes/{id}/namespaces/{namespace}/configmaps/{configmap}": + get: + description: |- + Get a ConfigMap by name for a given namespace. + **Access policy**: Authenticated user. + operationId: GetKubernetesConfigMap + parameters: + - description: Environment identifier + in: path + name: id + required: true + schema: + type: integer + - description: The namespace name where the configmap is located + in: path + name: namespace + required: true + schema: + type: string + - description: The configmap name to get details for + in: path + name: configmap + required: true + schema: + type: string + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/kubernetes.K8sConfigMap" + "400": + description: Invalid request payload, such as missing required fields or fields + not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does not + have the necessary permissions. Ensure that you have provided a + valid API key or JWT token, and that you have the required + permissions. + "403": + description: Permission denied - the user is authenticated but does not have the + necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier or a + configmap with the specified name in the given namespace. + "500": + description: Server error occurred while attempting to retrieve a configmap by + name within the specified namespace. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get a ConfigMap + tags: + - kubernetes + "/kubernetes/{id}/namespaces/{namespace}/events": + get: + description: |- + Get events by optional query param resourceId for a given namespace. + **Access policy**: Authenticated user. + operationId: getKubernetesEventsForNamespace + parameters: + - description: Environment identifier + in: path + name: id + required: true + schema: + type: integer + - description: The namespace name the events are associated to + in: path + name: namespace + required: true + schema: + type: string + - description: The resource id of the involved kubernetes object + in: query + name: resourceId + schema: + type: string + responses: + "200": + description: Success + content: + application/json: + schema: + items: + $ref: "#/components/schemas/kubernetes.K8sEvent" + type: array + "400": + description: Invalid request payload, such as missing required fields or fields + not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does not + have the necessary permissions. Ensure that you have provided a + valid API key or JWT token, and that you have the required + permissions. + "403": + description: Permission denied - the user is authenticated but does not have the + necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "500": + description: Server error occurred while attempting to retrieve the events + within the specified namespace. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Gets kubernetes events for namespace + tags: + - kubernetes + "/kubernetes/{id}/namespaces/{namespace}/ingresscontrollers": + get: + description: >- + Get a list of ingress controllers for the given environment in the + provided namespace. + + **Access policy**: Authenticated user. + operationId: GetKubernetesIngressControllersByNamespace + parameters: + - description: Environment identifier + in: path + name: id + required: true + schema: + type: integer + - description: Namespace + in: path + name: namespace + required: true + schema: + type: string + responses: + "200": + description: Success + content: + application/json: + schema: + items: + $ref: "#/components/schemas/kubernetes.K8sIngressController" + type: array + "400": + description: Invalid request payload, such as missing required fields or fields + not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does not + have the necessary permissions. Ensure that you have provided a + valid API key or JWT token, and that you have the required + permissions. + "403": + description: Permission denied - the user is authenticated but does not have the + necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier or a + namespace with the specified name. + "500": + description: Server error occurred while attempting to retrieve ingress + controllers by a namespace + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get a list ingress controllers by namespace + tags: + - kubernetes + put: + description: >- + Update (block/unblock) ingress controllers by namespace for the provided + environment. + + **Access policy**: Authenticated user. + operationId: UpdateKubernetesIngressControllersByNamespace + parameters: + - description: Environment identifier + in: path + name: id + required: true + schema: + type: integer + - description: Namespace name + in: path + name: namespace + required: true + schema: + type: string + requestBody: + $ref: "#/components/requestBodies/kubernetes.K8sIngressControllerArray" + responses: + "204": + description: Success + "400": + description: Invalid request payload, such as missing required fields or fields + not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does not + have the necessary permissions. Ensure that you have provided a + valid API key or JWT token, and that you have the required + permissions. + "403": + description: Permission denied - the user is authenticated but does not have the + necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier. + "500": + description: Server error occurred while attempting to update ingress + controllers by namespace. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Update (block/unblock) ingress controllers by namespace + tags: + - kubernetes + "/kubernetes/{id}/namespaces/{namespace}/ingresses": + get: + description: >- + Get a list of Ingresses. If namespace is provided, it will return the + list of Ingresses in that namespace. + + **Access policy**: Authenticated user. + operationId: GetAllKubernetesIngresses + parameters: + - description: Environment identifier + in: path + name: id + required: true + schema: + type: integer + - description: Namespace name + in: path + name: namespace + required: true + schema: + type: string + responses: + "200": + description: Success + content: + application/json: + schema: + items: + $ref: "#/components/schemas/kubernetes.K8sIngressInfo" + type: array + "400": + description: Invalid request payload, such as missing required fields or fields + not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does not + have the necessary permissions. Ensure that you have provided a + valid API key or JWT token, and that you have the required + permissions. + "403": + description: Permission denied - the user is authenticated but does not have the + necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier. + "500": + description: Server error occurred while attempting to retrieve ingresses + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get a list of Ingresses + tags: + - kubernetes + post: + description: |- + Create an Ingress for the provided environment. + **Access policy**: Authenticated user. + operationId: CreateKubernetesIngress + parameters: + - description: Environment identifier + in: path + name: id + required: true + schema: + type: integer + - description: Namespace name + in: path + name: namespace + required: true + schema: + type: string + requestBody: + $ref: "#/components/requestBodies/kubernetes.K8sIngressInfo" + responses: + "204": + description: Success + "400": + description: Invalid request payload, such as missing required fields or fields + not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does not + have the necessary permissions. Ensure that you have provided a + valid API key or JWT token, and that you have the required + permissions. + "403": + description: Permission denied - the user is authenticated but does not have the + necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier. + "409": + description: Conflict - an ingress with the same name already exists in the + specified namespace. + "500": + description: Server error occurred while attempting to create an ingress. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Create an Ingress + tags: + - kubernetes + put: + description: |- + Update an Ingress for the provided environment. + **Access policy**: Authenticated user. + operationId: UpdateKubernetesIngress + parameters: + - description: Environment identifier + in: path + name: id + required: true + schema: + type: integer + - description: Namespace name + in: path + name: namespace + required: true + schema: + type: string + requestBody: + $ref: "#/components/requestBodies/kubernetes.K8sIngressInfo" + responses: + "204": + description: Success + "400": + description: Invalid request payload, such as missing required fields or fields + not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does not + have the necessary permissions. Ensure that you have provided a + valid API key or JWT token, and that you have the required + permissions. + "403": + description: Permission denied - the user is authenticated but does not have the + necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier or + unable to find the specified ingress. + "500": + description: Server error occurred while attempting to update the specified + ingress. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Update an Ingress + tags: + - kubernetes + "/kubernetes/{id}/namespaces/{namespace}/ingresses/{ingress}": + get: + description: |- + Get an Ingress by name for the provided environment. + **Access policy**: Authenticated user. + operationId: GetKubernetesIngress + parameters: + - description: Environment identifier + in: path + name: id + required: true + schema: + type: integer + - description: Namespace name + in: path + name: namespace + required: true + schema: + type: string + - description: Ingress name + in: path + name: ingress + required: true + schema: + type: string + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/kubernetes.K8sIngressInfo" + "400": + description: Invalid request payload, such as missing required fields or fields + not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does not + have the necessary permissions. Ensure that you have provided a + valid API key or JWT token, and that you have the required + permissions. + "403": + description: Permission denied - the user is authenticated but does not have the + necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier or + unable to find an ingress with the specified name. + "500": + description: Server error occurred while attempting to retrieve an ingress. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get an Ingress by name + tags: + - kubernetes + "/kubernetes/{id}/namespaces/{namespace}/persistent_volume_claims": + get: + description: |- + Get a list of PersistentVolumeClaims in the specified namespace. + **Access policy**: Authenticated user. + operationId: GetKubernetesPersistentVolumeClaimsInNamespace + parameters: + - description: Environment identifier + in: path + name: id + required: true + schema: + type: integer + - description: Namespace name + in: path + name: namespace + required: true + schema: + type: string + responses: + "200": + description: Success + content: + application/json: + schema: + items: + $ref: "#/components/schemas/kubernetes.K8sPersistentVolumeClaim" + type: array + "400": + description: Invalid request payload. + "403": + description: Unauthorized access or operation not allowed. + "500": + description: Server error occurred while attempting to retrieve persistent + volume claims. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get PersistentVolumeClaims in a namespace + tags: + - kubernetes + "/kubernetes/{id}/namespaces/{namespace}/persistent_volume_claims/{name}": + get: + description: |- + Get a PersistentVolumeClaim by name within a namespace. + **Access policy**: Authenticated user. + operationId: GetKubernetesPersistentVolumeClaim + parameters: + - description: Environment identifier + in: path + name: id + required: true + schema: + type: integer + - description: Namespace name + in: path + name: namespace + required: true + schema: + type: string + - description: PVC name + in: path + name: name + required: true + schema: + type: string + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/kubernetes.K8sPersistentVolumeClaim" + "400": + description: Invalid request payload. + "403": + description: Unauthorized access or operation not allowed. + "404": + description: PVC not found. + "500": + description: Server error occurred while attempting to retrieve the persistent + volume claim. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get a specific PersistentVolumeClaim + tags: + - kubernetes + "/kubernetes/{id}/namespaces/{namespace}/pods/{name}": + delete: + description: |- + Delete a single Kubernetes pod in the given namespace. The owning + controller (Deployment, StatefulSet, DaemonSet, ...) is responsible + for recreating the pod. For naked pods the pod is removed permanently. + **Access policy**: Authenticated user. + operationId: DeleteKubernetesPod + parameters: + - description: Environment(Endpoint) identifier + in: path + name: id + required: true + schema: + type: integer + - description: Namespace + in: path + name: namespace + required: true + schema: + type: string + - description: Pod name + in: path + name: name + required: true + schema: + type: string + responses: + "204": + description: Success + "400": + description: Invalid request payload, such as missing required fields or fields + not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does not + have the necessary permissions. Ensure that you have provided a + valid API key or JWT token, and that you have the required + permissions. + "403": + description: Permission denied - the user is authenticated but does not have the + necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier or + unable to find the specified pod. + "500": + description: Server error occurred while attempting to delete the pod. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Delete a kubernetes pod + tags: + - kubernetes + "/kubernetes/{id}/namespaces/{namespace}/pods/{name}/restart": + post: + description: |- + Restart all containers in a single Kubernetes pod in place using + the Kubernetes 1.35 alpha pod-restart subresource. The pod itself + is preserved. Requires the cluster to expose the corresponding + subresource (and the matching feature gate to be enabled). + **Access policy**: Authenticated user. + operationId: RestartKubernetesPod + parameters: + - description: Environment(Endpoint) identifier + in: path + name: id + required: true + schema: + type: integer + - description: Namespace + in: path + name: namespace + required: true + schema: + type: string + - description: Pod name + in: path + name: name + required: true + schema: + type: string + responses: + "204": + description: Success + "400": + description: Invalid request payload, such as missing required fields or fields + not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does not + have the necessary permissions. Ensure that you have provided a + valid API key or JWT token, and that you have the required + permissions. + "403": + description: Permission denied - the user is authenticated but does not have the + necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment, the specified pod, or the cluster + does not expose the pod-restart subresource (Kubernetes <1.35 or + feature gate disabled). + "405": + description: The cluster does not support the pod-restart subresource + (Kubernetes <1.35 or feature gate disabled). + "500": + description: Server error occurred while attempting to restart the pod. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Restart all containers in a Kubernetes pod + tags: + - kubernetes + "/kubernetes/{id}/namespaces/{namespace}/secrets/{secret}": + get: + description: |- + Get a Secret by name for a given namespace. + **Access policy**: Authenticated user. + operationId: GetKubernetesSecret + parameters: + - description: Environment identifier + in: path + name: id + required: true + schema: + type: integer + - description: The namespace name where the secret is located + in: path + name: namespace + required: true + schema: + type: string + - description: The secret name to get details for + in: path + name: secret + required: true + schema: + type: string + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/kubernetes.K8sSecret" + "400": + description: Invalid request payload, such as missing required fields or fields + not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does not + have the necessary permissions. Ensure that you have provided a + valid API key or JWT token, and that you have the required + permissions. + "403": + description: Permission denied - the user is authenticated but does not have the + necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier. + "500": + description: Server error occurred while attempting to retrieve a secret by name + belong in a namespace. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get a Secret + tags: + - kubernetes + "/kubernetes/{id}/namespaces/{namespace}/service_accounts/{name}": + get: + description: |- + Get a kubernetes service account in the given namespace. + **Access policy**: Authenticated user. + operationId: GetKubernetesServiceAccount + parameters: + - description: Environment identifier + in: path + name: id + required: true + schema: + type: integer + - description: Namespace + in: path + name: namespace + required: true + schema: + type: string + - description: Service account name + in: path + name: name + required: true + schema: + type: string + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/kubernetes.K8sServiceAccount" + "400": + description: Invalid request + "401": + description: Unauthorized + "403": + description: Permission denied + "404": + description: Service account not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get a kubernetes service account + tags: + - kubernetes + "/kubernetes/{id}/namespaces/{namespace}/service_accounts/{name}/image_pull_secrets": + put: + description: >- + Replace the imagePullSecrets list on a service account with the provided + list. + + **Access policy**: Authenticated user. + operationId: UpdateKubernetesServiceAccountImagePullSecrets + parameters: + - description: Environment identifier + in: path + name: id + required: true + schema: + type: integer + - description: Namespace + in: path + name: namespace + required: true + schema: + type: string + - description: Service account name + in: path + name: name + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/kubernetes.K8sServiceAccountImagePullSecretsUpdateP\ + ayload" + description: New imagePullSecrets list + required: true + responses: + "204": + description: Success + "400": + description: Invalid request payload, such as missing required fields or fields + not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does not + have the necessary permissions. Ensure that you have provided a + valid API key or JWT token, and that you have the required + permissions. + "403": + description: Permission denied - the user is authenticated but does not have the + necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier, + namespace, or service account. + "500": + description: Server error occurred while attempting to update image pull secrets + for the service account. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Update image pull secrets for a service account + tags: + - kubernetes + "/kubernetes/{id}/namespaces/{namespace}/services": + get: + description: |- + Get a list of services for a given namespace. + **Access policy**: Authenticated user. + operationId: GetKubernetesServicesByNamespace + parameters: + - description: Environment identifier + in: path + name: id + required: true + schema: + type: integer + - description: Namespace name + in: path + name: namespace + required: true + schema: + type: string + responses: + "200": + description: Success + content: + application/json: + schema: + items: + $ref: "#/components/schemas/kubernetes.K8sServiceInfo" + type: array + "400": + description: Invalid request payload, such as missing required fields or fields + not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does not + have the necessary permissions. Ensure that you have provided a + valid API key or JWT token, and that you have the required + permissions. + "403": + description: Permission denied - the user is authenticated but does not have the + necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier. + "500": + description: Server error occurred while attempting to retrieve all services for + a namespace. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get a list of services for a given namespace + tags: + - kubernetes + post: + description: |- + Create a service within a given namespace + **Access policy**: Authenticated user. + operationId: CreateKubernetesService + parameters: + - description: Environment identifier + in: path + name: id + required: true + schema: + type: integer + - description: Namespace name + in: path + name: namespace + required: true + schema: + type: string + requestBody: + $ref: "#/components/requestBodies/kubernetes.K8sServiceInfo" + responses: + "204": + description: Success + "400": + description: Invalid request payload, such as missing required fields or fields + not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does not + have the necessary permissions. Ensure that you have provided a + valid API key or JWT token, and that you have the required + permissions. + "403": + description: Permission denied - the user is authenticated but does not have the + necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier. + "500": + description: Server error occurred while attempting to create a service. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Create a service + tags: + - kubernetes + put: + description: |- + Update a service within a given namespace. + **Access policy**: Authenticated user. + operationId: UpdateKubernetesService + parameters: + - description: Environment identifier + in: path + name: id + required: true + schema: + type: integer + - description: Namespace name + in: path + name: namespace + required: true + schema: + type: string + requestBody: + $ref: "#/components/requestBodies/kubernetes.K8sServiceInfo" + responses: + "204": + description: Success + "400": + description: Invalid request payload, such as missing required fields or fields + not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does not + have the necessary permissions. Ensure that you have provided a + valid API key or JWT token, and that you have the required + permissions. + "403": + description: Permission denied - the user is authenticated but does not have the + necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier or + unable to find the service to update. + "500": + description: Server error occurred while attempting to update a service. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Update a service + tags: + - kubernetes + "/kubernetes/{id}/namespaces/{namespace}/system": + put: + description: |- + Toggle the system state for a namespace + **Access policy**: Administrator or environment administrator. + operationId: KubernetesNamespacesToggleSystem + parameters: + - description: Environment identifier + in: path + name: id + required: true + schema: + type: integer + - description: Namespace name + in: path + name: namespace + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/kubernetes.namespacesToggleSystemPayload" + description: Update details + required: true + responses: + "204": + description: Success + "400": + description: Invalid request payload, such as missing required fields or fields + not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does not + have the necessary permissions. Ensure that you have provided a + valid API key or JWT token, and that you have the required + permissions. + "403": + description: Permission denied - the user is authenticated but does not have the + necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier or + unable to find the namespace to update. + "500": + description: Server error occurred while attempting to update the system state + of the namespace. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Toggle the system state for a namespace + tags: + - kubernetes + "/kubernetes/{id}/namespaces/{namespace}/volumes": + get: + description: >- + Get a list of kubernetes volumes within the specified namespace in the + given environment (Endpoint). The Endpoint ID must be a valid Portainer + environment identifier. + + **Access policy**: Authenticated user. + operationId: GetKubernetesVolumesInNamespace + parameters: + - description: Environment identifier + in: path + name: id + required: true + schema: + type: integer + - description: Namespace identifier + in: path + name: namespace + required: true + schema: + type: string + - description: When set to True, include the applications that are using the + volumes. It is set to false by default + in: query + name: withApplications + schema: + type: boolean + responses: + "200": + description: Success + content: + application/json: + schema: + additionalProperties: + $ref: "#/components/schemas/kubernetes.K8sVolumeInfo" + type: object + "400": + description: Invalid request payload, such as missing required fields or fields + not meeting validation criteria. + "403": + description: Unauthorized access or operation not allowed. + "500": + description: Server error occurred while attempting to retrieve kubernetes + volumes in the namespace. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get Kubernetes volumes within a namespace in the given Portainer + environment + tags: + - kubernetes + "/kubernetes/{id}/namespaces/count": + get: + description: >- + Get the total number of kubernetes namespaces within the given + environment, including the system namespaces. The total count depends on + the user's role and permissions. + + **Access policy**: Authenticated user. + operationId: GetKubernetesNamespacesCount + parameters: + - description: Environment identifier + in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: Success + content: + application/json: + schema: + type: integer + "400": + description: Invalid request payload, such as missing required fields or fields + not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does not + have the necessary permissions. Ensure that you have provided a + valid API key or JWT token, and that you have the required + permissions. + "403": + description: Permission denied - the user is authenticated but does not have the + necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier. + "500": + description: Server error occurred while attempting to compute the namespace + count. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get the total number of kubernetes namespaces within the given + Portainer environment. + tags: + - kubernetes + "/kubernetes/{id}/nodes": + get: + description: |- + Returns the list of Kubernetes nodes for the selected environment. + **Access policy**: Authenticated user. + operationId: GetKubernetesNodes + parameters: + - description: Environment(Endpoint) identifier + in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: Success + content: + application/json: + schema: + items: + $ref: "#/components/schemas/kubernetes.KubernetesNodeResponse" + type: array + "401": + description: Unauthorized access - the user is not authenticated or does not + have the necessary permissions. + "403": + description: Permission denied - the user is authenticated but does not have the + necessary permissions to access the requested resource. + "500": + description: Server error occurred while attempting to retrieve the list of nodes. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get Kubernetes cluster nodes + tags: + - kubernetes + "/kubernetes/{id}/nodes/{name}/drain": + post: + description: >- + Drain a Kubernetes node by safely evicting all pods from the node, + preparing it for maintenance or removal + + **Access policy**: authenticated + operationId: drainNode + parameters: + - description: Environment(Endpoint) identifier + in: path + name: id + required: true + schema: + type: integer + - description: Name of the node to drain + in: path + name: name + required: true + schema: + type: string + responses: + "204": + description: Success + "400": + description: Invalid request, such as missing required fields or fields not + meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does not + have the necessary permissions. Ensure that you have provided a + valid API key or JWT token, and that you have the required + permissions. + "403": + description: Permission denied - the user is authenticated but does not have the + necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier or + unable to find the specified node. + "500": + description: Server error occurred while attempting to drain node. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Drain a Kubernetes node + tags: + - kubernetes + "/kubernetes/{id}/nodes_limits": + get: + description: |- + Get CPU and memory limits of all nodes within k8s cluster. + **Access policy**: Authenticated user. + operationId: GetKubernetesNodesLimits + parameters: + - description: Environment(Endpoint) identifier + in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/portainer.K8sNodesLimits" + "400": + description: Invalid request + "401": + description: Unauthorized access - the user is not authenticated or does not + have the necessary permissions. Ensure that you have provided a + valid API key or JWT token, and that you have the required + permissions. + "403": + description: Permission denied - the user is authenticated but does not have the + necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier. + "500": + description: Server error occurred while attempting to retrieve nodes limits. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get CPU and memory limits of all nodes within k8s cluster + tags: + - kubernetes + "/kubernetes/{id}/persistent_volume_claims": + get: + description: >- + Get a list of all PersistentVolumeClaims within the given environment. + Scoped by namespace for non-admin users. + + **Access policy**: Authenticated user. + operationId: GetAllKubernetesPersistentVolumeClaims + parameters: + - description: Environment identifier + in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: Success + content: + application/json: + schema: + items: + $ref: "#/components/schemas/kubernetes.K8sPersistentVolumeClaim" + type: array + "400": + description: Invalid request payload. + "403": + description: Unauthorized access or operation not allowed. + "500": + description: Server error occurred while attempting to retrieve persistent + volume claims. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get all PersistentVolumeClaims + tags: + - kubernetes + "/kubernetes/{id}/persistent_volume_claims/delete": + post: + description: |- + Delete the provided list of PersistentVolumeClaims. + **Access policy**: Authenticated user. + operationId: DeleteKubernetesPersistentVolumeClaims + parameters: + - description: Environment identifier + in: path + name: id + required: true + schema: + type: integer + requestBody: + content: + application/json: + schema: + items: + $ref: "#/components/schemas/kubernetes.K8sVolumeDeleteRequest" + type: array + description: List of PVCs to delete (namespace + name) + required: true + responses: + "204": + description: Success + "400": + description: Invalid request payload. + "403": + description: Unauthorized access or operation not allowed. + "500": + description: Server error occurred while attempting to delete persistent volume + claims. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Delete PersistentVolumeClaims + tags: + - kubernetes + "/kubernetes/{id}/persistent_volume_claims/resize": + put: + description: >- + Resize a PVC to a new size. The StorageClass must support volume + expansion. + + **Access policy**: Authenticated user. + operationId: ResizeKubernetesPersistentVolumeClaim + parameters: + - description: Environment identifier + in: path + name: id + required: true + schema: + type: integer + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/kubernetes.K8sPVCResizeRequest" + description: PVC resize request + required: true + responses: + "204": + description: Success + "400": + description: Invalid request payload. + "403": + description: Unauthorized access or operation not allowed. + "500": + description: Server error occurred while attempting to resize the persistent + volume claim. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Resize a PersistentVolumeClaim + tags: + - kubernetes + "/kubernetes/{id}/persistent_volumes": + get: + description: |- + Get a list of all PersistentVolumes in the given environment. + **Access policy**: Authenticated user. + operationId: GetAllKubernetesPersistentVolumes + parameters: + - description: Environment identifier + in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: Success + content: + application/json: + schema: + items: + $ref: "#/components/schemas/kubernetes.K8sPersistentVolume" + type: array + "400": + description: Invalid request payload, such as missing required fields or fields + not meeting validation criteria. + "403": + description: Unauthorized access or operation not allowed. + "500": + description: Server error occurred while attempting to retrieve persistent + volumes. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get all PersistentVolumes in the cluster + tags: + - kubernetes + "/kubernetes/{id}/persistent_volumes/{name}": + get: + description: |- + Get a PersistentVolume by name in the given environment. + **Access policy**: Authenticated user. + operationId: GetKubernetesPersistentVolume + parameters: + - description: Environment identifier + in: path + name: id + required: true + schema: + type: integer + - description: PersistentVolume name + in: path + name: name + required: true + schema: + type: string + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/kubernetes.K8sPersistentVolume" + "400": + description: Invalid request payload. + "403": + description: Unauthorized access or operation not allowed. + "404": + description: PersistentVolume not found. + "500": + description: Server error occurred while attempting to retrieve the persistent + volume. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get a specific PersistentVolume + tags: + - kubernetes + "/kubernetes/{id}/persistent_volumes/delete": + post: + description: |- + Delete the provided list of PersistentVolumes. + **Access policy**: Authenticated user. + operationId: DeleteKubernetesPersistentVolumes + parameters: + - description: Environment identifier + in: path + name: id + required: true + schema: + type: integer + requestBody: + content: + application/json: + schema: + items: + type: string + type: array + description: List of PV names to delete + required: true + responses: + "204": + description: Success + "400": + description: Invalid request payload. + "403": + description: Unauthorized access or operation not allowed. + "500": + description: Server error occurred while attempting to delete persistent volumes. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Delete PersistentVolumes + tags: + - kubernetes + "/kubernetes/{id}/persistent_volumes/reclaim_policy": + put: + description: |- + Update the reclaim policy of a PersistentVolume. + **Access policy**: Authenticated user. + operationId: UpdateKubernetesPersistentVolumeReclaimPolicy + parameters: + - description: Environment identifier + in: path + name: id + required: true + schema: + type: integer + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/kubernetes.K8sPVReclaimPolicyRequest" + description: Reclaim policy update request + required: true + responses: + "204": + description: Success + "400": + description: Invalid request payload. + "403": + description: Unauthorized access or operation not allowed. + "500": + description: Server error occurred while attempting to update reclaim policy. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Update reclaim policy of a PersistentVolume + tags: + - kubernetes + "/kubernetes/{id}/rbac_enabled": + get: + description: |- + Check if RBAC is enabled in the specified Kubernetes cluster. + **Access policy**: Authenticated user. + operationId: GetKubernetesRBACStatus + parameters: + - description: Environment(Endpoint) identifier + in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: RBAC status + content: + application/json: + schema: + type: boolean + "401": + description: Unauthorized access - the user is not authenticated or does not + have the necessary permissions. Ensure that you have provided a + valid API key or JWT token, and that you have the required + permissions. + "403": + description: Permission denied - the user is authenticated but does not have the + necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier. + "500": + description: Server error occurred while attempting to retrieve the RBAC status. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Check if RBAC is enabled + tags: + - kubernetes + "/kubernetes/{id}/role_bindings/delete": + post: + description: |- + Delete the provided list of role bindings. + **Access policy**: Authenticated user. + operationId: DeleteRoleBindings + parameters: + - description: Environment identifier + in: path + name: id + required: true + schema: + type: integer + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/kubernetes.K8sRoleBindingDeleteRequests" + description: A map where the key is the namespace and the value is an array of + role bindings to delete + required: true + responses: + "204": + description: Success + "400": + description: Invalid request payload, such as missing required fields or fields + not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does not + have the necessary permissions. Ensure that you have provided a + valid API key or JWT token, and that you have the required + permissions. + "403": + description: Permission denied - the user is authenticated but does not have the + necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier or + unable to find a specific role binding. + "500": + description: Server error occurred while attempting to delete role bindings. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Delete role bindings + tags: + - kubernetes + "/kubernetes/{id}/rolebindings": + get: + description: |- + Get a list of kubernetes role bindings that the user has access to. + **Access policy**: Authenticated user. + operationId: GetKubernetesRoleBindings + parameters: + - description: Environment identifier + in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: Success + content: + application/json: + schema: + items: + $ref: "#/components/schemas/kubernetes.K8sRoleBinding" + type: array + "400": + description: Invalid request payload, such as missing required fields or fields + not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does not + have the necessary permissions. Ensure that you have provided a + valid API key or JWT token, and that you have the required + permissions. + "403": + description: Permission denied - the user is authenticated but does not have the + necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier. + "500": + description: Server error occurred while attempting to retrieve the list of role + bindings. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get a list of kubernetes role bindings + tags: + - kubernetes + "/kubernetes/{id}/roles": + get: + description: |- + Get a list of kubernetes roles that the user has access to. + **Access policy**: Authenticated user. + operationId: GetKubernetesRoles + parameters: + - description: Environment identifier + in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: Success + content: + application/json: + schema: + items: + $ref: "#/components/schemas/kubernetes.K8sRole" + type: array + "400": + description: Invalid request payload, such as missing required fields or fields + not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does not + have the necessary permissions. Ensure that you have provided a + valid API key or JWT token, and that you have the required + permissions. + "403": + description: Permission denied - the user is authenticated but does not have the + necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier. + "500": + description: Server error occurred while attempting to retrieve the list of roles. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get a list of kubernetes roles + tags: + - kubernetes + "/kubernetes/{id}/roles/delete": + post: + description: |- + Delete the provided list of roles. + **Access policy**: Authenticated user. + operationId: DeleteRoles + parameters: + - description: Environment identifier + in: path + name: id + required: true + schema: + type: integer + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/kubernetes.K8sRoleDeleteRequests" + description: A map where the key is the namespace and the value is an array of + roles to delete + required: true + responses: + "204": + description: Success + "400": + description: Invalid request payload, such as missing required fields or fields + not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does not + have the necessary permissions. Ensure that you have provided a + valid API key or JWT token, and that you have the required + permissions. + "403": + description: Permission denied - the user is authenticated but does not have the + necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier or + unable to find a specific role. + "500": + description: Server error occurred while attempting to delete roles. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Delete roles + tags: + - kubernetes + "/kubernetes/{id}/secrets": + get: + description: >- + Get a list of Secrets for a given namespace. If isUsed is set to true, + information about the applications that use the secrets is also + returned. + + **Access policy**: Authenticated user. + operationId: GetKubernetesSecrets + parameters: + - description: Environment identifier + in: path + name: id + required: true + schema: + type: integer + - description: When set to true, associate the Secrets with the applications that + use them + in: query + name: isUsed + required: true + schema: + type: boolean + responses: + "200": + description: Success + content: + application/json: + schema: + items: + $ref: "#/components/schemas/kubernetes.K8sSecret" + type: array + "400": + description: Invalid request payload, such as missing required fields or fields + not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does not + have the necessary permissions. Ensure that you have provided a + valid API key or JWT token, and that you have the required + permissions. + "403": + description: Permission denied - the user is authenticated but does not have the + necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier. + "500": + description: Server error occurred while attempting to retrieve all secrets from + the cluster. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get a list of Secrets + tags: + - kubernetes + "/kubernetes/{id}/secrets/count": + get: + description: >- + Get the count of Secrets across all namespaces that the user has access + to. + + **Access policy**: Authenticated user. + operationId: GetKubernetesSecretsCount + parameters: + - description: Environment identifier + in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: Success + content: + application/json: + schema: + type: integer + "400": + description: Invalid request payload, such as missing required fields or fields + not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does not + have the necessary permissions. Ensure that you have provided a + valid API key or JWT token, and that you have the required + permissions. + "403": + description: Permission denied - the user is authenticated but does not have the + necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier. + "500": + description: Server error occurred while attempting to retrieve the count of all + secrets from the cluster. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get Secrets count + tags: + - kubernetes + "/kubernetes/{id}/service_accounts/delete": + post: + description: |- + Delete the provided list of service accounts. + **Access policy**: Authenticated user. + operationId: DeleteServiceAccounts + parameters: + - description: Environment identifier + in: path + name: id + required: true + schema: + type: integer + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/kubernetes.K8sServiceAccountDeleteRequests" + description: A map where the key is the namespace and the value is an array of + service accounts to delete + required: true + responses: + "204": + description: Success + "400": + description: Invalid request payload, such as missing required fields or fields + not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does not + have the necessary permissions. Ensure that you have provided a + valid API key or JWT token, and that you have the required + permissions. + "403": + description: Permission denied - the user is authenticated but does not have the + necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier or + unable to find a specific service account. + "500": + description: Server error occurred while attempting to delete service accounts. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Delete service accounts + tags: + - kubernetes + "/kubernetes/{id}/serviceaccounts": + get: + description: |- + Get a list of kubernetes service accounts that the user has access to. + **Access policy**: Authenticated user. + operationId: GetKubernetesServiceAccounts + parameters: + - description: Environment identifier + in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: Success + content: + application/json: + schema: + items: + $ref: "#/components/schemas/kubernetes.K8sServiceAccount" + type: array + "400": + description: Invalid request payload, such as missing required fields or fields + not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does not + have the necessary permissions. Ensure that you have provided a + valid API key or JWT token, and that you have the required + permissions. + "403": + description: Permission denied - the user is authenticated but does not have the + necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier. + "500": + description: Server error occurred while attempting to retrieve the list of + service accounts. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get a list of kubernetes service accounts + tags: + - kubernetes + "/kubernetes/{id}/services": + get: + description: |- + Get a list of services that the user has access to. + **Access policy**: Authenticated user. + operationId: GetKubernetesServices + parameters: + - description: Environment identifier + in: path + name: id + required: true + schema: + type: integer + - description: Lookup applications associated with each service + in: query + name: withApplications + schema: + type: boolean + responses: + "200": + description: Success + content: + application/json: + schema: + items: + $ref: "#/components/schemas/kubernetes.K8sServiceInfo" + type: array + "400": + description: Invalid request payload, such as missing required fields or fields + not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does not + have the necessary permissions. Ensure that you have provided a + valid API key or JWT token, and that you have the required + permissions. + "403": + description: Permission denied - the user is authenticated but does not have the + necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier. + "500": + description: Server error occurred while attempting to retrieve all services. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get a list of services + tags: + - kubernetes + "/kubernetes/{id}/services/count": + get: + description: |- + Get the count of services that the user has access to. + **Access policy**: Authenticated user. + operationId: GetAllKubernetesServicesCount + parameters: + - description: Environment identifier + in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: Success + content: + application/json: + schema: + type: integer + "400": + description: Invalid request payload, such as missing required fields or fields + not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does not + have the necessary permissions. Ensure that you have provided a + valid API key or JWT token, and that you have the required + permissions. + "403": + description: Permission denied - the user is authenticated but does not have the + necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier. + "500": + description: Server error occurred while attempting to retrieve the total count + of all services. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get services count + tags: + - kubernetes + "/kubernetes/{id}/services/delete": + post: + description: |- + Delete the provided list of services. + **Access policy**: Authenticated user. + operationId: DeleteKubernetesServices + parameters: + - description: Environment identifier + in: path + name: id + required: true + schema: + type: integer + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/kubernetes.K8sServiceDeleteRequests" + description: A map where the key is the namespace and the value is an array of + services to delete + required: true + responses: + "204": + description: Success + "400": + description: Invalid request payload, such as missing required fields or fields + not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does not + have the necessary permissions. Ensure that you have provided a + valid API key or JWT token, and that you have the required + permissions. + "403": + description: Permission denied - the user is authenticated but does not have the + necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier or + unable to find a specific service. + "500": + description: Server error occurred while attempting to delete services. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Delete services + tags: + - kubernetes + "/kubernetes/{id}/storage_classes": + get: + description: |- + Get a list of all StorageClasses in the given environment. + **Access policy**: Authenticated user. + operationId: GetAllKubernetesStorageClasses + parameters: + - description: Environment identifier + in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: Success + content: + application/json: + schema: + items: + $ref: "#/components/schemas/kubernetes.K8sStorageClass" + type: array + "400": + description: Invalid request payload. + "403": + description: Unauthorized access or operation not allowed. + "500": + description: Server error occurred while attempting to retrieve storage classes. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get all StorageClasses + tags: + - kubernetes + "/kubernetes/{id}/storage_classes/{name}": + get: + description: |- + Get a StorageClass by name in the given environment. + **Access policy**: Authenticated user. + operationId: GetKubernetesStorageClass + parameters: + - description: Environment identifier + in: path + name: id + required: true + schema: + type: integer + - description: StorageClass name + in: path + name: name + required: true + schema: + type: string + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/kubernetes.K8sStorageClass" + "400": + description: Invalid request payload. + "403": + description: Unauthorized access or operation not allowed. + "404": + description: StorageClass not found. + "500": + description: Server error occurred while attempting to retrieve the storage class. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get a specific StorageClass + tags: + - kubernetes + "/kubernetes/{id}/storage_classes/{name}/default": + put: + description: >- + Set the specified StorageClass as the cluster default, removing default + from any other. + + **Access policy**: Authenticated user. + operationId: SetDefaultKubernetesStorageClass + parameters: + - description: Environment identifier + in: path + name: id + required: true + schema: + type: integer + - description: StorageClass name + in: path + name: name + required: true + schema: + type: string + responses: + "204": + description: Success + "400": + description: Invalid request payload. + "403": + description: Unauthorized access or operation not allowed. + "500": + description: Server error occurred while attempting to set default storage class. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Set a StorageClass as default + tags: + - kubernetes + "/kubernetes/{id}/storage_classes/delete": + post: + description: |- + Delete the provided list of StorageClasses. + **Access policy**: Authenticated user. + operationId: DeleteKubernetesStorageClasses + parameters: + - description: Environment identifier + in: path + name: id + required: true + schema: + type: integer + requestBody: + content: + application/json: + schema: + items: + type: string + type: array + description: List of StorageClass names to delete + required: true + responses: + "204": + description: Success + "400": + description: Invalid request payload. + "403": + description: Unauthorized access or operation not allowed. + "500": + description: Server error occurred while attempting to delete storage classes. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Delete StorageClasses + tags: + - kubernetes + "/kubernetes/{id}/version": + get: + description: |- + Get the Kubernetes cluster version (major, minor, gitVersion, ...) + as reported by the cluster's discovery API, augmented with capability + flags Portainer uses to gate UI features (e.g. supportsPodRestart). + **Access policy**: Authenticated user. + operationId: GetKubernetesVersion + parameters: + - description: Environment(Endpoint) identifier + in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/kubernetes.kubernetesVersionResponse" + "401": + description: Unauthorized access - the user is not authenticated or does not + have the necessary permissions. + "403": + description: Permission denied - the user is authenticated but does not have the + necessary permissions to access the requested resource or perform + the specified operation. + "404": + description: Unable to find an environment with the specified identifier. + "500": + description: Server error occurred while attempting to retrieve the cluster + version. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get the Kubernetes cluster version and Portainer-relevant capabilities + tags: + - kubernetes + "/kubernetes/{id}/volumes": + get: + description: >- + Get a list of all kubernetes volumes within the given environment + (Endpoint). The Endpoint ID must be a valid Portainer environment + identifier. + + **Access policy**: Authenticated user. + operationId: GetAllKubernetesVolumes + parameters: + - description: Environment identifier + in: path + name: id + required: true + schema: + type: integer + - description: When set to True, include the applications that are using the + volumes. It is set to false by default + in: query + name: withApplications + schema: + type: boolean + responses: + "200": + description: Success + content: + application/json: + schema: + additionalProperties: + $ref: "#/components/schemas/kubernetes.K8sVolumeInfo" + type: object + "400": + description: Invalid request payload, such as missing required fields or fields + not meeting validation criteria. + "403": + description: Unauthorized access or operation not allowed. + "500": + description: Server error occurred while attempting to retrieve kubernetes + volumes. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get Kubernetes volumes within the given Portainer environment + tags: + - kubernetes + "/kubernetes/{id}/volumes/{namespace}/{volume}": + get: + description: >- + Get a Kubernetes volume within the given environment (Endpoint). The + Endpoint ID must be a valid Portainer environment identifier. + + **Access policy**: Authenticated user. + operationId: GetKubernetesVolume + parameters: + - description: Environment identifier + in: path + name: id + required: true + schema: + type: integer + - description: Namespace identifier + in: path + name: namespace + required: true + schema: + type: string + - description: Volume name + in: path + name: volume + required: true + schema: + type: string + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/kubernetes.K8sVolumeInfo" + "400": + description: Invalid request + "500": + description: Server error + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get a Kubernetes volume within the given Portainer environment + tags: + - kubernetes + "/kubernetes/{id}/volumes/count": + get: + description: >- + Get the total number of kubernetes volumes within the given environment + (Endpoint). The total count depends on the user's role and permissions. + The Endpoint ID must be a valid Portainer environment identifier. + + **Access policy**: Authenticated user. + operationId: getAllKubernetesVolumesCount + parameters: + - description: Environment identifier + in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: Success + content: + application/json: + schema: + type: integer + "400": + description: Invalid request payload, such as missing required fields or fields + not meeting validation criteria. + "403": + description: Unauthorized access or operation not allowed. + "500": + description: Server error occurred while attempting to retrieve kubernetes + volumes count. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get the total number of kubernetes volumes within the given Portainer + environment. + tags: + - kubernetes + /kubernetes/config: + get: + description: >- + Generate a kubeconfig file that allows a client to communicate with the + Kubernetes API server + + **Access policy**: Authenticated user. + operationId: GetKubernetesConfig + parameters: + - description: will include only these environments(endpoints) + in: query + name: ids + style: form + explode: false + schema: + type: array + items: + type: integer + - description: will exclude these environments(endpoints) + in: query + name: excludeIds + style: form + explode: false + schema: + type: array + items: + type: integer + responses: + "200": + description: Success + content: + application/json: + schema: {} + " application/yaml": + schema: {} + "400": + description: Invalid request payload, such as missing required fields or fields + not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does not + have the necessary permissions. Ensure that you have provided a + valid API key or JWT token, and that you have the required + permissions. + "403": + description: Permission denied - the user is authenticated but does not have the + necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier. + "500": + description: Server error occurred while attempting to generate the kubeconfig + file. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Generate a kubeconfig file + tags: + - kubernetes + /ldap/check: + post: + description: |- + Test LDAP connectivity using LDAP details + **Access policy**: administrator + operationId: LDAPCheck + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/ldap.checkPayload" + description: details + required: true + responses: + "204": + description: Success + "400": + description: Invalid request + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Test LDAP connectivity + tags: + - ldap + /motd: + get: + description: "**Access policy**: restricted" + operationId: MOTD + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/motd.Motd" + security: + - ApiKeyAuth: [] + - jwt: [] + summary: fetches the message of the day + tags: + - motd + /registries: + get: + 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 + responses: + "200": + description: Success + content: + application/json: + schema: + items: + $ref: "#/components/schemas/portainer.Registry" + type: array + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: List Registries + tags: + - registries + post: + description: |- + Create a new registry. + **Access policy**: restricted + operationId: RegistryCreate + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/registries.registryCreatePayload" + description: Registry details + required: true + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/portainer.Registry" + "400": + description: Invalid request + "409": + description: Another registry with the same name or same URL & credentials + already exists + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Create a new registry + tags: + - registries + "/registries/{id}": + delete: + description: |- + Remove a registry + **Access policy**: restricted + operationId: RegistryDelete + parameters: + - description: Registry identifier + in: path + name: id + required: true + schema: + type: integer + responses: + "204": + description: Success + "400": + description: Invalid request + "404": + description: Registry not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Remove a registry + tags: + - registries + get: + description: |- + Retrieve details about a registry. + **Access policy**: restricted + operationId: RegistryInspect + parameters: + - description: Registry identifier + in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/portainer.Registry" + "400": + description: Invalid request + "403": + description: Permission denied to access registry + "404": + description: Registry not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Inspect a registry + tags: + - registries + put: + description: |- + Update a registry + **Access policy**: restricted + operationId: RegistryUpdate + parameters: + - description: Registry identifier + in: path + name: id + required: true + schema: + type: integer + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/registries.registryUpdatePayload" + description: Registry details + required: true + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/portainer.Registry" + "400": + description: Invalid request + "404": + description: Registry not found + "409": + description: Another registry with the same name or same URL & credentials + already exists + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Update a registry + tags: + - registries + "/registries/{id}/configure": + post: + description: |- + Configures a registry. + **Access policy**: restricted + operationId: RegistryConfigure + parameters: + - description: Registry identifier + in: path + name: id + required: true + schema: + type: integer + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/registries.registryConfigurePayload" + description: Registry configuration + required: true + responses: + "204": + description: Success + "400": + description: Invalid request + "403": + description: Permission denied + "404": + description: Registry not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Configures a registry + tags: + - registries + /registries/ping: + post: + description: |- + Test connection to a registry with provided credentials + **Access policy**: authenticated + operationId: RegistryPing + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/registries.registryPingPayload" + description: Registry credentials to test + required: true + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/registries.registryPingResponse" + "400": + description: Invalid request + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Test registry connection + tags: + - registries + /resource_controls: + post: + description: |- + Create a new resource control to restrict access to a Docker resource. + **Access policy**: administrator + operationId: ResourceControlCreate + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/resourcecontrols.resourceControlCreatePayload" + description: Resource control details + required: true + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/portainer.ResourceControl" + "400": + description: Invalid request + "409": + description: A resource control is already associated to this resource + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Create a new resource control + tags: + - resource_controls + "/resource_controls/{id}": + delete: + description: |- + Remove a resource control. + **Access policy**: administrator + operationId: ResourceControlDelete + parameters: + - description: Resource control identifier + in: path + name: id + required: true + schema: + type: integer + responses: + "204": + description: Success + "400": + description: Invalid request + "404": + description: Resource control not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Remove a resource control + tags: + - resource_controls + put: + description: |- + Update a resource control + **Access policy**: authenticated + operationId: ResourceControlUpdate + parameters: + - description: Resource control identifier + in: path + name: id + required: true + schema: + type: integer + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/resourcecontrols.resourceControlUpdatePayload" + description: Resource control details + required: true + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/portainer.ResourceControl" + "400": + description: Invalid request + "403": + description: Unauthorized + "404": + description: Resource control not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Update a resource control + tags: + - resource_controls + /restore: + post: + description: >- + Triggers a system restore using provided backup file + + **Access policy**: public (requires the X-Setup-Token header on an uninitialized instance unless --no-setup-token is set) + operationId: Restore + parameters: + - description: Setup token (required when instance is uninitialized and + --no-setup-token is not set) + in: header + name: X-Setup-Token + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/backup.restorePayload" + description: Restore request payload + required: true + responses: + "200": + description: Success + "400": + description: Invalid request + "403": + description: Access denied - invalid or missing setup token + "500": + description: Server error + summary: Triggers a system restore using provided backup file + tags: + - backup + /roles: + get: + description: |- + List all roles available for use + **Access policy**: administrator + operationId: RoleList + responses: + "200": + description: Success + content: + application/json: + schema: + items: + $ref: "#/components/schemas/portainer.Role" + type: array + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: List roles + tags: + - roles + /settings: + get: + description: |- + Retrieve Portainer settings. + **Access policy**: administrator + operationId: SettingsInspect + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/portainer.Settings" + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Retrieve Portainer settings + tags: + - settings + put: + description: |- + Update Portainer settings. + **Access policy**: administrator + operationId: SettingsUpdate + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/settings.settingsUpdatePayload" + description: New settings + required: true + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/portainer.Settings" + "400": + description: Invalid request + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Update Portainer settings + tags: + - settings + /settings/public: + get: + description: >- + Retrieve public settings. Returns a small set of settings that are not + reserved to administrators only. + + **Access policy**: public + operationId: SettingsPublic + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/settings.publicSettingsResponse" + "500": + description: Server error + summary: Retrieve Portainer public settings + tags: + - settings + /ssl: + get: + description: |- + Retrieve the ssl settings. + **Access policy**: administrator + operationId: SSLInspect + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/portainer.SSLSettings" + "400": + description: Invalid request + "403": + description: Permission denied to access settings + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Inspect the ssl settings + tags: + - ssl + put: + description: |- + Update the ssl settings. + **Access policy**: administrator + operationId: SSLUpdate + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/ssl.sslUpdatePayload" + description: SSL Settings + required: true + responses: + "204": + description: Success + "400": + description: Invalid request + "403": + description: Permission denied to access settings + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Update the ssl settings + tags: + - ssl + /stacks: + get: + 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. + Limited stacks will not be returned by this endpoint. + **Access policy**: authenticated + operationId: StackList + parameters: + - 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." + in: query + name: filters + schema: + type: string + responses: + "200": + description: Success + content: + "*/*": + schema: + items: + $ref: "#/components/schemas/portainer.Stack" + type: array + "204": + description: Success + "400": + description: Invalid request + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: List stacks + tags: + - stacks + "/stacks/{id}": + delete: + description: |- + Remove a stack. + **Access policy**: restricted + operationId: StackDelete + parameters: + - description: Stack identifier + in: path + name: id + required: true + schema: + type: integer + - description: Set to true to delete an external stack. Only external Swarm stacks + are supported + in: query + name: external + schema: + type: boolean + - description: Environment identifier + in: query + name: endpointId + required: true + schema: + type: integer + responses: + "204": + description: Success + "400": + description: Invalid request + "403": + description: Permission denied + "404": + description: Not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Remove a stack + tags: + - stacks + get: + description: |- + Retrieve details about a stack. + **Access policy**: restricted + operationId: StackInspect + parameters: + - description: Stack identifier + in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/stacks.stackResponse" + "400": + description: Invalid request + "403": + description: Permission denied + "404": + description: Stack not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Inspect a stack + tags: + - stacks + put: + description: |- + Update a stack, only for file based stacks. + **Access policy**: authenticated + operationId: StackUpdate + parameters: + - description: Stack identifier + in: path + name: id + required: true + schema: + type: integer + - description: Environment identifier + in: query + name: endpointId + required: true + schema: + type: integer + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/stacks.updateSwarmStackPayload" + description: Stack details + required: true + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/portainer.Stack" + "400": + description: Invalid request + "403": + description: Permission denied + "404": + description: Not found + "409": + description: Conflict + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Update a stack + tags: + - stacks + "/stacks/{id}/associate": + put: + description: "**Access policy**: administrator" + operationId: StackAssociate + parameters: + - description: Stack identifier + in: path + name: id + required: true + schema: + type: integer + - description: Environment identifier + in: query + name: endpointId + required: true + schema: + type: integer + - description: Swarm identifier + in: query + name: swarmId + required: true + schema: + type: integer + - description: Indicates whether the stack is orphaned + in: query + name: orphanedRunning + required: true + schema: + type: boolean + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/portainer.Stack" + "400": + description: Invalid request + "403": + description: Permission denied + "404": + description: Stack not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Associate an orphaned stack to a new environment(endpoint) + tags: + - stacks + "/stacks/{id}/file": + get: + description: |- + Get Stack file content. + **Access policy**: restricted + operationId: StackFileInspect + parameters: + - description: Stack identifier + in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/stacks.stackFileResponse" + "400": + description: Invalid request + "403": + description: Permission denied + "404": + description: Stack not found + "409": + description: Git settings changed, redeploy required + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Retrieve the content of the Stack file for the specified stack + tags: + - stacks + "/stacks/{id}/git": + post: + description: >- + Update the Git settings in a stack, e.g., RepositoryReferenceName and + AutoUpdate. When SourceID is set, URL/auth/TLS are taken from the + referenced Source. + + **Access policy**: authenticated + operationId: StackUpdateGit + parameters: + - description: Stack identifier + in: path + name: id + required: true + schema: + type: integer + - description: Stacks created before version 1.18.0 might not have an associated + environment(endpoint) identifier. Use this optional parameter to set + the environment(endpoint) identifier used by the stack. + in: query + name: endpointId + schema: + type: integer + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/stacks.stackGitUpdatePayload" + description: Git configs for pull and redeploy a stack + required: true + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/stacks.stackResponse" + "400": + description: Invalid request + "403": + description: Permission denied + "404": + description: Not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Update a stack's Git configs + tags: + - stacks + "/stacks/{id}/git/redeploy": + put: + description: |- + Pull and redeploy a stack via Git + **Access policy**: authenticated + operationId: StackGitRedeploy + parameters: + - description: Stack identifier + in: path + name: id + required: true + schema: + type: integer + - description: Stacks created before version 1.18.0 might not have an associated + environment(endpoint) identifier. Use this optional parameter to set + the environment(endpoint) identifier used by the stack. + in: query + name: endpointId + schema: + type: integer + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/stacks.stackGitRedeployPayload" + description: Git configs for pull and redeploy of a stack. **StackName** may + only be populated for Kuberenetes stacks, and if specified with a + blank string, it will be set to blank + required: true + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/portainer.Stack" + "400": + description: Invalid request + "403": + description: Permission denied + "404": + description: Not found + "409": + description: Conflict + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Redeploy a stack + tags: + - stacks + "/stacks/{id}/migrate": + post: + description: >- + Migrate a stack from an environment(endpoint) to another + environment(endpoint). It will re-create the stack inside the target + environment(endpoint) before removing the original stack. + + **Access policy**: authenticated + operationId: StackMigrate + parameters: + - description: Stack identifier + in: path + name: id + required: true + schema: + type: integer + - description: Stacks created before version 1.18.0 might not have an associated + environment(endpoint) identifier. Use this optional parameter to set + the environment(endpoint) identifier used by the stack. + in: query + name: endpointId + schema: + type: integer + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/stacks.stackMigratePayload" + description: Stack migration details + required: true + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/portainer.Stack" + "400": + description: Invalid request + "403": + description: Permission denied + "404": + description: Stack not found + "409": + description: A stack with the same name is already running on the target + environment(endpoint) + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Migrate a stack to another environment(endpoint) + tags: + - stacks + "/stacks/{id}/start": + post: + description: |- + Starts a stopped Stack. + **Access policy**: authenticated + operationId: StackStart + parameters: + - description: Stack identifier + in: path + name: id + required: true + schema: + type: integer + - description: Environment identifier + in: query + name: endpointId + required: true + schema: + type: integer + responses: + "200": + description: Success + content: + "*/*": + schema: + $ref: "#/components/schemas/portainer.Stack" + "400": + description: Invalid request + "403": + description: Permission denied + "404": + description: Not found + "409": + description: Stack is already active, deploying, or in error state + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Starts a stopped Stack + tags: + - stacks + "/stacks/{id}/stop": + post: + description: |- + Stop a running Stack. + **Access policy**: authenticated + operationId: StackStop + parameters: + - description: Stack identifier + in: path + name: id + required: true + schema: + type: integer + - description: Environment identifier + in: query + name: endpointId + required: true + schema: + type: integer + responses: + "200": + description: Success + content: + "*/*": + schema: + $ref: "#/components/schemas/portainer.Stack" + "400": + description: Invalid request + "403": + description: Permission denied + "404": + description: Not found + "409": + description: Conflict + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Stop a running Stack + tags: + - stacks + /stacks/create/kubernetes/repository: + post: + description: >- + Deploy a new stack into a Docker environment specified via the + environment identifier. + + **Access policy**: authenticated + operationId: StackCreateKubernetesGit + parameters: + - description: Identifier of the environment that will be used to deploy the stack + in: query + name: endpointId + required: true + schema: + type: integer + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/stacks.kubernetesGitDeploymentPayload" + description: stack config + required: true + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/portainer.Stack" + "400": + description: Invalid request + "409": + description: Stack name or webhook ID already exists + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Deploy a new kubernetes stack from a git repository + tags: + - stacks + /stacks/create/kubernetes/string: + post: + description: >- + Deploy a new stack into a Docker environment specified via the + environment identifier. + + **Access policy**: authenticated + operationId: StackCreateKubernetesFile + parameters: + - description: Identifier of the environment that will be used to deploy the stack + in: query + name: endpointId + required: true + schema: + type: integer + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/stacks.kubernetesStringDeploymentPayload" + description: stack config + required: true + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/portainer.Stack" + "400": + description: Invalid request + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Deploy a new kubernetes stack from a file + tags: + - stacks + /stacks/create/kubernetes/url: + post: + description: >- + Deploy a new stack into a Docker environment specified via the + environment identifier. + + **Access policy**: authenticated + operationId: StackCreateKubernetesUrl + parameters: + - description: Identifier of the environment that will be used to deploy the stack + in: query + name: endpointId + required: true + schema: + type: integer + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/stacks.kubernetesManifestURLDeploymentPayload" + description: stack config + required: true + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/portainer.Stack" + "400": + description: Invalid request + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Deploy a new kubernetes stack from a url + tags: + - stacks + /stacks/create/standalone/file: + post: + description: >- + Deploy a new stack into a Docker environment specified via the + environment identifier. + + **Access policy**: authenticated + operationId: StackCreateDockerStandaloneFile + parameters: + - description: Identifier of the environment that will be used to deploy the stack + in: query + name: endpointId + required: true + schema: + type: integer + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + Name: + description: Name of the stack + type: string + Env: + description: "Environment variables passed during deployment, represented as a + JSON array [{'name': 'name', 'value': 'value'}]." + type: string + file: + description: Stack file + type: string + format: binary + required: + - Name + required: true + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/portainer.Stack" + "400": + description: Invalid request + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Deploy a new compose stack from a file + tags: + - stacks + /stacks/create/standalone/repository: + post: + description: >- + Deploy a new stack into a Docker environment specified via the + environment identifier. + + **Access policy**: authenticated + operationId: StackCreateDockerStandaloneRepository + parameters: + - description: Identifier of the environment that will be used to deploy the stack + in: query + name: endpointId + required: true + schema: + type: integer + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/stacks.composeStackFromGitRepositoryPayload" + description: stack config + required: true + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/portainer.Stack" + "400": + description: Invalid request + "409": + description: Stack name or webhook ID already exists + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Deploy a new compose stack from repository + tags: + - stacks + /stacks/create/standalone/string: + post: + description: >- + Deploy a new stack into a Docker environment specified via the + environment identifier. + + **Access policy**: authenticated + operationId: StackCreateDockerStandaloneString + parameters: + - description: Identifier of the environment that will be used to deploy the stack + in: query + name: endpointId + required: true + schema: + type: integer + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/stacks.composeStackFromFileContentPayload" + description: stack config + required: true + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/portainer.Stack" + "400": + description: Invalid request + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Deploy a new compose stack from a text + tags: + - stacks + /stacks/create/swarm/file: + post: + description: >- + Deploy a new stack into a Docker environment specified via the + environment identifier. + + **Access policy**: authenticated + operationId: StackCreateDockerSwarmFile + parameters: + - description: Identifier of the environment that will be used to deploy the stack + in: query + name: endpointId + required: true + schema: + type: integer + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + Name: + description: Name of the stack + type: string + SwarmID: + description: Swarm cluster identifier. + type: string + Env: + description: "Environment variables passed during deployment, represented as a + JSON array [{'name': 'name', 'value': 'value'}]. Optional" + type: string + file: + description: Stack file + type: string + format: binary + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/portainer.Stack" + "400": + description: Invalid request + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Deploy a new swarm stack from a file + tags: + - stacks + /stacks/create/swarm/repository: + post: + description: >- + Deploy a new stack into a Docker environment specified via the + environment identifier. + + **Access policy**: authenticated + operationId: StackCreateDockerSwarmRepository + parameters: + - description: Identifier of the environment that will be used to deploy the stack + in: query + name: endpointId + required: true + schema: + type: integer + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/stacks.swarmStackFromGitRepositoryPayload" + description: stack config + required: true + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/portainer.Stack" + "400": + description: Invalid request + "409": + description: Stack name or webhook ID already exists + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Deploy a new swarm stack from a git repository + tags: + - stacks + /stacks/create/swarm/string: + post: + description: >- + Deploy a new stack into a Docker environment specified via the + environment identifier. + + **Access policy**: authenticated + operationId: StackCreateDockerSwarmString + parameters: + - description: Identifier of the environment that will be used to deploy the stack + in: query + name: endpointId + required: true + schema: + type: integer + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/stacks.swarmStackFromFileContentPayload" + description: stack config + required: true + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/portainer.Stack" + "400": + description: Invalid request + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Deploy a new swarm stack from a text + tags: + - stacks + "/stacks/name/{name}": + delete: + description: |- + Remove a stack. + **Access policy**: restricted + operationId: StackDeleteKubernetesByName + parameters: + - description: Stack name + in: path + name: name + required: true + schema: + type: string + - description: Set to true to delete an external stack. Only external Swarm stacks + are supported + in: query + name: external + schema: + type: boolean + - description: Environment identifier + in: query + name: endpointId + required: true + schema: + type: integer + responses: + "204": + description: Success + "400": + description: Invalid request + "403": + description: Permission denied + "404": + description: Not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Remove Kubernetes stacks by name + tags: + - stacks + "/stacks/webhooks/{webhookID}": + post: + description: "**Access policy**: public" + operationId: WebhookInvoke + parameters: + - description: Stack identifier + in: path + name: webhookID + required: true + schema: + type: string + responses: + "200": + description: Success + "400": + description: Invalid request + "409": + description: Autoupdate for the stack isn't available" or "Stack deployment is + already in progress + "500": + description: Server error + summary: Webhook for triggering stack updates from git + tags: + - stacks + /status: + get: + deprecated: true + description: |- + Deprecated: use the `/system/status` endpoint instead. + Retrieve Portainer status + **Access policy**: public + operationId: StatusInspect + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/system.status" + summary: Check Portainer status + tags: + - status + /system/info: + get: + description: "**Access policy**: authenticated" + operationId: systemInfo + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/system.systemInfoResponse" + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Retrieve system info + tags: + - system + /system/nodes: + get: + description: "**Access policy**: authenticated" + operationId: systemNodesCount + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/system.nodesCountResponse" + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Retrieve the count of nodes + tags: + - system + /system/status: + get: + description: |- + Retrieve Portainer status + **Access policy**: public + operationId: systemStatus + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/system.status" + summary: Check Portainer status + tags: + - system + /system/upgrade: + post: + description: |- + Upgrade Portainer to BE + **Access policy**: administrator + operationId: systemUpgrade + responses: + "204": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/system.status" + summary: Upgrade Portainer to BE + tags: + - system + /system/version: + get: + description: |- + Check if portainer has an update available + **Access policy**: authenticated + operationId: systemVersion + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/system.versionResponse" + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Check for portainer updates + tags: + - system + /tags: + get: + description: |- + List tags. + **Access policy**: authenticated + operationId: TagList + responses: + "200": + description: Success + content: + application/json: + schema: + items: + $ref: "#/components/schemas/portainer.Tag" + type: array + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: List tags + tags: + - tags + post: + description: |- + Create a new tag. + **Access policy**: administrator + operationId: TagCreate + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/tags.tagCreatePayload" + description: Tag details + required: true + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/portainer.Tag" + "409": + description: This name is already associated to a tag + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Create a new tag + tags: + - tags + "/tags/{id}": + delete: + description: |- + Remove a tag. + **Access policy**: administrator + operationId: TagDelete + parameters: + - description: Tag identifier + in: path + name: id + required: true + schema: + type: integer + responses: + "204": + description: Success + "400": + description: Invalid request + "403": + description: Permission denied + "404": + description: Tag not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Remove a tag + tags: + - tags + /team_memberships: + get: + description: >- + List team memberships. Access is only available to administrators and + team leaders. + + **Access policy**: administrator + operationId: TeamMembershipList + responses: + "200": + description: Success + content: + application/json: + schema: + items: + $ref: "#/components/schemas/portainer.TeamMembership" + type: array + "400": + description: Invalid request + "403": + description: Permission denied + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: List team memberships + tags: + - team_memberships + post: + description: >- + Create a new team memberships. Access is only available to + administrators leaders of the associated team. + + **Access policy**: administrator + operationId: TeamMembershipCreate + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/teammemberships.teamMembershipCreatePayload" + description: Team membership details + required: true + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/portainer.TeamMembership" + "400": + description: Invalid request + "403": + description: Permission denied to manage memberships + "409": + description: Team membership already registered + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Create a new team membership + tags: + - team_memberships + "/team_memberships/{id}": + delete: + description: >- + Remove a team membership. Access is only available to administrators + leaders of the associated team. + + **Access policy**: administrator + operationId: TeamMembershipDelete + parameters: + - description: TeamMembership identifier + in: path + name: id + required: true + schema: + type: integer + responses: + "204": + description: Success + "400": + description: Invalid request + "403": + description: Permission denied + "404": + description: TeamMembership not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Remove a team membership + tags: + - team_memberships + put: + description: >- + Update a team membership. Access is only available to administrators or + leaders of the associated team. + + **Access policy**: administrator or leaders of the associated team + operationId: TeamMembershipUpdate + parameters: + - description: Team membership identifier + in: path + name: id + required: true + schema: + type: integer + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/teammemberships.teamMembershipUpdatePayload" + description: Team membership details + required: true + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/portainer.TeamMembership" + "400": + description: Invalid request + "403": + description: Permission denied + "404": + description: TeamMembership not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Update a team membership + tags: + - team_memberships + /teams: + get: + description: >- + List teams. For non-administrator users, will only list the teams they + are member of. + + **Access policy**: restricted + operationId: TeamList + parameters: + - description: Only list teams that the user is leader of + in: query + name: onlyLedTeams + schema: + type: boolean + - description: Identifier of the environment(endpoint) that will be used to filter + the authorized teams + in: query + name: environmentId + schema: + type: integer + responses: + "200": + description: Success + content: + application/json: + schema: + items: + $ref: "#/components/schemas/portainer.Team" + type: array + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: List teams + tags: + - teams + post: + description: |- + Create a new team. + **Access policy**: administrator + operationId: TeamCreate + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/teams.teamCreatePayload" + description: details + required: true + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/portainer.Team" + "400": + description: Invalid request + "409": + description: A team with the same name already exists + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Create a new team + tags: + - teams + "/teams/{id}": + delete: + description: |- + Remove a team. + **Access policy**: administrator + operationId: TeamDelete + parameters: + - description: Team Id + in: path + name: id + required: true + schema: + type: integer + responses: + "204": + description: Success + "400": + description: Invalid request + "403": + description: Permission denied + "404": + description: Team not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Remove a team + tags: + - teams + get: + description: >- + Retrieve details about a team. Access is only available for + administrator and leaders of that team. + + **Access policy**: administrator + operationId: TeamInspect + parameters: + - description: Team identifier + in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/portainer.Team" + "400": + description: Invalid request + "403": + description: Permission denied + "404": + description: Team not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Inspect a team + tags: + - teams + put: + description: |- + Update a team. + **Access policy**: administrator + operationId: TeamUpdate + parameters: + - description: Team identifier + in: path + name: id + required: true + schema: + type: integer + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/teams.teamUpdatePayload" + description: Team details + required: true + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/portainer.Team" + "400": + description: Invalid request + "403": + description: Permission denied + "404": + description: Team not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Update a team + tags: + - teams + "/teams/{id}/memberships": + get: + description: >- + List team memberships. Access is only available to administrators and + team leaders. + + **Access policy**: restricted + operationId: TeamMemberships + parameters: + - description: Team Id + in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: Success + content: + application/json: + schema: + items: + $ref: "#/components/schemas/portainer.TeamMembership" + type: array + "400": + description: Invalid request + "403": + description: Permission denied + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: List team memberships + tags: + - team_memberships + /templates: + get: + description: |- + List available templates. + **Access policy**: authenticated + operationId: TemplateList + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/templates.listResponse" + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: List available templates + tags: + - templates + "/templates/{id}/file": + post: + description: |- + Get a template's file + **Access policy**: authenticated + operationId: TemplateFile + parameters: + - description: Template identifier + in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/templates.fileResponse" + "400": + description: Invalid request + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Get a template's file + tags: + - templates + /templates/helm: + get: + description: "**Access policy**: authenticated" + operationId: HelmRepoSearch + parameters: + - description: Helm repository URL + in: query + name: repo + required: true + schema: + type: string + - description: Helm chart name + in: query + name: chart + schema: + type: string + - description: If true will use cache to search + in: query + name: useCache + schema: + type: string + responses: + "200": + description: Success + content: + application/json: + schema: + type: string + "400": + description: Bad request + "401": + description: Unauthorized + "404": + description: Not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Search Helm Charts + tags: + - helm + "/templates/helm/{command}": + get: + description: "**Access policy**: authenticated" + operationId: HelmShow + parameters: + - description: Helm repository URL + in: query + name: repo + required: true + schema: + type: string + - description: Chart name + in: query + name: chart + required: true + schema: + type: string + - description: Chart version + in: query + name: version + schema: + type: string + - description: chart/values/readme + in: path + name: command + required: true + schema: + type: string + responses: + "200": + description: Success + content: + text/plain: + schema: + type: string + "401": + description: Unauthorized + "404": + description: Environment(Endpoint) or ServiceAccount not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Show Helm Chart Information + tags: + - helm + "/upload/tls/{certificate}": + post: + description: |- + Use this environment(endpoint) to upload TLS files. + **Access policy**: administrator + operationId: UploadTLS + parameters: + - description: TLS file type. Valid values are 'ca', 'cert' or 'key'. + in: path + name: certificate + required: true + schema: + type: string + enum: + - ca + - cert + - key + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + folder: + description: Folder where the TLS file will be stored. Will be created if not + existing + type: string + file: + description: The file to upload + type: string + format: binary + required: + - folder + - file + required: true + responses: + "204": + description: Success + "400": + description: Invalid request + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Upload TLS files + tags: + - upload + /users: + get: + description: >- + List Portainer users. + + Non-administrator users will only be able to list other non-administrator user accounts. + + User passwords are filtered out, and should never be accessible. + + **Access policy**: restricted + operationId: UserList + parameters: + - description: Identifier of the environment(endpoint) that will be used to filter + the authorized users + in: query + name: environmentId + schema: + type: integer + responses: + "200": + description: Success + content: + application/json: + schema: + items: + $ref: "#/components/schemas/portainer.User" + type: array + "400": + description: Invalid request + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: List users + tags: + - users + post: + description: |- + Create a new Portainer user. + Only administrators can create users. + **Access policy**: restricted + operationId: UserCreate + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/users.userCreatePayload" + description: User details + required: true + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/portainer.User" + "400": + description: Invalid request + "403": + description: Permission denied + "409": + description: User already exists + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Create a new user + tags: + - users + "/users/{id}": + delete: + description: |- + Remove a user. + **Access policy**: administrator + operationId: UserDelete + parameters: + - description: User identifier + in: path + name: id + required: true + schema: + type: integer + responses: + "204": + description: Success + "400": + description: Invalid request + "403": + description: Permission denied + "404": + description: User not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Remove a user + tags: + - users + get: + description: |- + Retrieve details about a user. + User passwords are filtered out, and should never be accessible. + **Access policy**: authenticated + operationId: UserInspect + parameters: + - description: User identifier + in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/portainer.User" + "400": + description: Invalid request + "403": + description: Permission denied + "404": + description: User not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Inspect a user + tags: + - users + put: + description: |- + Update user details. A regular user account can only update his details. + A regular user account cannot change their username or role. + **Access policy**: authenticated + operationId: UserUpdate + parameters: + - description: User identifier + in: path + name: id + required: true + schema: + type: integer + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/users.userUpdatePayload" + description: User details + required: true + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/portainer.User" + "400": + description: Invalid request + "403": + description: Permission denied + "404": + description: User not found + "409": + description: Username already exist + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Update a user + tags: + - users + "/users/{id}/effective-access": + get: + description: |- + Returns the resolved role for each environment the user can access, + following the policy precedence used by the access viewer + (user-endpoint, user-group, team-endpoint, team-group). + Environments where the user has no role are omitted. + **Access policy**: restricted + operationId: UserEffectiveAccessInspect + parameters: + - description: User identifier + in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: Success + content: + application/json: + schema: + items: + $ref: "#/components/schemas/users.EffectiveAccessEntry" + type: array + "400": + description: Invalid request + "403": + description: Permission denied + "404": + description: User not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + jwt: [] + summary: Inspect a user's effective access on every environment + tags: + - users + "/users/{id}/helm/repositories": + get: + description: |- + Inspect a user helm repositories. + **Access policy**: authenticated + operationId: HelmUserRepositoriesList + parameters: + - description: User identifier + in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/users.helmUserRepositoryResponse" + "400": + description: Invalid request + "403": + description: Permission denied + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: List a users helm repositories + tags: + - helm + post: + description: |- + Create a user helm repository. + **Access policy**: authenticated + operationId: HelmUserRepositoryCreate + parameters: + - description: User identifier + in: path + name: id + required: true + schema: + type: integer + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/users.addHelmRepoUrlPayload" + description: Helm Repository + required: true + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/portainer.HelmUserRepository" + "400": + description: Invalid request + "403": + description: Permission denied + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Create a user helm repository + tags: + - helm + "/users/{id}/helm/repositories/{repositoryID}": + delete: + description: "**Access policy**: authenticated" + operationId: HelmUserRepositoryDelete + parameters: + - description: User identifier + in: path + name: id + required: true + schema: + type: integer + - description: Repository identifier + in: path + name: repositoryID + required: true + schema: + type: integer + responses: + "204": + description: Success + "400": + description: Invalid request + "403": + description: Permission denied + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Delete a users helm repositoryies + tags: + - helm + "/users/{id}/memberships": + get: + description: |- + Inspect a user memberships. + **Access policy**: restricted + operationId: UserMembershipsInspect + parameters: + - description: User identifier + in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/portainer.TeamMembership" + "400": + description: Invalid request + "403": + description: Permission denied + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Inspect a user memberships + tags: + - users + "/users/{id}/passwd": + put: + description: |- + Update password for the specified user. + **Access policy**: authenticated + operationId: UserUpdatePassword + parameters: + - description: identifier + in: path + name: id + required: true + schema: + type: integer + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/users.userUpdatePasswordPayload" + description: details + required: true + responses: + "204": + description: Success + "400": + description: Invalid request + "403": + description: Permission denied + "404": + description: User not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Update password for a user + tags: + - users + "/users/{id}/tokens": + get: + description: |- + Gets all API keys for a user. + Only the calling user or admin can retrieve api-keys. + **Access policy**: authenticated + operationId: UserGetAPIKeys + parameters: + - description: User identifier + in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: Success + content: + application/json: + schema: + items: + $ref: "#/components/schemas/portainer.APIKey" + type: array + "400": + description: Invalid request + "403": + description: Permission denied + "404": + description: User not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Get all API keys for a user + tags: + - users + post: + description: |- + Generates an API key for a user. + Only the calling user can generate a token for themselves. + Password is required only for internal authentication. + **Access policy**: restricted + operationId: UserGenerateAPIKey + parameters: + - description: User identifier + in: path + name: id + required: true + schema: + type: integer + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/users.userAccessTokenCreatePayload" + description: details + required: true + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/users.accessTokenResponse" + "400": + description: Invalid request + "401": + description: Unauthorized + "403": + description: Permission denied + "404": + description: User not found + "500": + description: Server error + security: + - jwt: [] + summary: Generate an API key for a user + tags: + - users + "/users/{id}/tokens/{keyID}": + delete: + description: |- + Remove an api-key associated to a user.. + Only the calling user or admin can remove api-key. + **Access policy**: authenticated + operationId: UserRemoveAPIKey + parameters: + - description: User identifier + in: path + name: id + required: true + schema: + type: integer + - description: Api Key identifier + in: path + name: keyID + required: true + schema: + type: integer + responses: + "204": + description: Success + "400": + description: Invalid request + "403": + description: Permission denied + "404": + description: Not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Remove an api-key associated to a user + tags: + - users + /users/admin/check: + get: + description: |- + Check if an administrator account exists in the database. + **Access policy**: public + operationId: UserAdminCheck + responses: + "204": + description: Success + "404": + description: User not found + summary: Check administrator account existence + tags: + - users + /users/admin/init: + post: + description: >- + Initialize the 'admin' user account. + + **Access policy**: public (requires the X-Setup-Token header on an uninitialized instance unless --no-setup-token is set) + operationId: UserAdminInit + parameters: + - description: Setup token (required when instance is uninitialized and + --no-setup-token is not set) + in: header + name: X-Setup-Token + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/users.adminInitPayload" + description: User details + required: true + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/portainer.User" + "400": + description: Invalid request + "403": + description: Access denied - invalid or missing setup token + "409": + description: Admin user already initialized + "500": + description: Server error + summary: Initialize administrator account + tags: + - users + /users/me: + get: + description: |- + Retrieve details about the current user. + User passwords are filtered out, and should never be accessible. + **Access policy**: authenticated + operationId: CurrentUserInspect + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/portainer.User" + "400": + description: Invalid request + "403": + description: Permission denied + "404": + description: User not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Inspect the current user user + tags: + - users + /webhooks: + get: + description: "**Access policy**: authenticated" + parameters: + - description: Filters (json-string) + example: '{"EndpointID":1,"ResourceID":"abc12345-abcd-2345-ab12-58005b4a0260"}' + in: query + name: filters + schema: + type: string + responses: + "200": + description: OK + content: + application/json: + schema: + items: + $ref: "#/components/schemas/portainer.Webhook" + type: array + "400": + description: Bad Request + "500": + description: Internal Server Error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: List webhooks + tags: + - webhooks + post: + description: "**Access policy**: authenticated" + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/webhooks.webhookCreatePayload" + description: Webhook data + required: true + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/portainer.Webhook" + "400": + description: Invalid request + "409": + description: A webhook for this resource already exists + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Create a webhook + tags: + - webhooks + "/webhooks/{id}": + delete: + description: "**Access policy**: authenticated" + parameters: + - description: Webhook id + in: path + name: id + required: true + schema: + type: integer + responses: + "202": + description: Webhook deleted + "400": + description: Bad Request + "500": + description: Internal Server Error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Delete a webhook + tags: + - webhooks + post: + description: |- + Acts on a passed in token UUID to restart the docker service + **Access policy**: public + parameters: + - description: Webhook token + in: path + name: id + required: true + schema: + type: string + responses: + "202": + description: Webhook executed + "400": + description: Bad Request + "500": + description: Internal Server Error + summary: Execute a webhook + tags: + - webhooks + put: + description: "**Access policy**: authenticated" + parameters: + - description: Webhook id + in: path + name: id + required: true + schema: + type: integer + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/webhooks.webhookUpdatePayload" + description: Webhook data + required: true + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/portainer.Webhook" + "400": + description: Bad Request + "409": + description: Conflict + "500": + description: Internal Server Error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Update a webhook + tags: + - webhooks + /websocket/attach: + get: + description: >- + If the nodeName query parameter is present, the request will be proxied + to the underlying agent environment(endpoint). + + If the nodeName query parameter is not specified, the request will be upgraded to the websocket protocol and + + an AttachStart operation HTTP request will be created and hijacked. + + **Access policy**: authenticated + parameters: + - description: environment(endpoint) ID of the environment(endpoint) where the + resource is located + in: query + name: endpointId + required: true + schema: + type: integer + - description: node name + in: query + name: nodeName + schema: + type: string + responses: + "200": + description: OK + "400": + description: Bad Request + "403": + description: Forbidden + "404": + description: Not Found + "500": + description: Internal Server Error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Attach a websocket + tags: + - websocket + /websocket/exec: + get: + description: >- + If the nodeName query parameter is present, the request will be proxied + to the underlying agent environment(endpoint). + + If the nodeName query parameter is not specified, the request will be upgraded to the websocket protocol and + + an ExecStart operation HTTP request will be created and hijacked. + parameters: + - description: environment(endpoint) ID of the environment(endpoint) where the + resource is located + in: query + name: endpointId + required: true + schema: + type: integer + - description: node name + in: query + name: nodeName + schema: + type: string + responses: + "200": + description: OK + "400": + description: Bad Request + "409": + description: Conflict + "500": + description: Internal Server Error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Execute a websocket + tags: + - websocket + /websocket/kubernetes-shell: + get: + description: >- + The request will be upgraded to the websocket protocol. The request will + proxy input from the client to the pod via long-lived websocket + connection. + + **Access policy**: authenticated + parameters: + - description: environment(endpoint) ID of the environment(endpoint) where the + resource is located + in: query + name: endpointId + required: true + schema: + type: integer + responses: + "200": + description: Success + "400": + description: Invalid request + "403": + description: Permission denied + "404": + description: Environment not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Execute a websocket on kubectl shell pod + tags: + - websocket + /websocket/pod: + get: + description: |- + The request will be upgraded to the websocket protocol. + **Access policy**: authenticated + parameters: + - description: environment(endpoint) ID of the environment(endpoint) where the + resource is located + in: query + name: endpointId + required: true + schema: + type: integer + - description: namespace where the container is located + in: query + name: namespace + required: true + schema: + type: string + - description: name of the pod containing the container + in: query + name: podName + required: true + schema: + type: string + - description: name of the container + in: query + name: containerName + required: true + schema: + type: string + - description: command to execute in the container + in: query + name: command + required: true + schema: + type: string + responses: + "200": + description: OK + "400": + description: Bad Request + "403": + description: Forbidden + "404": + description: Not Found + "500": + description: Internal Server Error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Execute a websocket on pod + tags: + - websocket +tags: + - description: Authenticate against Portainer HTTP API + name: auth + x-displayName: Authentication + - description: Manage backups + name: backup + x-displayName: Backup + - description: Manage Custom Templates + name: custom_templates + x-displayName: Custom templates + - description: Manage Docker resources + name: docker + x-displayName: Docker resources + - description: Manage Edge related settings + name: edge + x-displayName: Edge settings + - description: Manage an Edge agent + name: edge_agent + x-displayName: Edge agent + - description: Manage Edge Groups + name: edge_groups + x-displayName: Edge groups + - description: Manage Edge Jobs + name: edge_jobs + x-displayName: Edge jobs + - description: Manage Edge Stacks + name: edge_stacks + x-displayName: Edge stacks + - description: Manage Edge Templates + name: edge_templates + x-displayName: Edge templates + - description: Manage environment groups + name: endpoint_groups + x-displayName: Environment groups + - description: Manage environments + name: endpoints + x-displayName: Environments + - description: Operate git repository + name: gitops + x-displayName: GitOps + - description: Manage Helm charts + name: helm + x-displayName: Helm charts + - description: Manage Kubernetes cluster + name: kubernetes + x-displayName: Kubernetes + - description: Manage LDAP settings + name: ldap + x-displayName: LDAP + - description: Fetch the message of the day + name: motd + x-displayName: Message of the day + - description: Manage Docker registries + name: registries + x-displayName: Registries + - description: Manage access control on Docker resources + name: resource_controls + x-displayName: Resource controls + - description: Manage roles + name: roles + x-displayName: Roles + - description: Manage Portainer settings + name: settings + x-displayName: Portainer settings + - description: Manage ssl settings + name: ssl + x-displayName: SSL + - description: Manage stacks + name: stacks + x-displayName: Stacks + - description: Information about the Portainer instance + name: status + x-displayName: Portainer status + - description: Manage Portainer system + name: system + x-displayName: Portainer system + - description: Manage tags + name: tags + x-displayName: Tags + - description: Manage team memberships + name: team_memberships + x-displayName: Team memberships + - description: Manage teams + name: teams + x-displayName: Teams + - description: Manage App Templates + name: templates + x-displayName: App templates + - description: Upload files + name: upload + x-displayName: Upload files + - description: Manage users + name: users + x-displayName: Users + - description: Manage webhooks + name: webhooks + x-displayName: Webhooks + - description: Create exec sessions using websockets + name: websocket + x-displayName: Websocket +x-tagGroups: + - name: Access Control + tags: + - auth + - roles + - team_memberships + - teams + - users + - name: Administration + tags: + - backup + - ldap + - motd + - settings + - status + - system + - ssl + - upload + - name: Docker + tags: + - templates + - custom_templates + - docker + - registries + - resource_controls + - stacks + - webhooks + - websocket + - name: Edge Compute + tags: + - edge_agent + - edge_groups + - edge_jobs + - edge + - edge_stacks + - name: Environment Management + tags: + - endpoint_groups + - endpoints + - tags + - name: GitOps + tags: + - gitops + - name: Kubernetes + tags: + - helm + - kubernetes +servers: + - url: /api +components: + requestBodies: + kubernetes.K8sIngressControllerArray: + content: + application/json: + schema: + items: + $ref: "#/components/schemas/kubernetes.K8sIngressController" + type: array + description: Ingress controllers + required: true + endpoints.endpointDeleteBatchPayload: + content: + application/json: + schema: + $ref: "#/components/schemas/endpoints.endpointDeleteBatchPayload" + description: List of environments to delete, with optional deleteCluster flag to + clean-up associated resources (cloud environments only) + required: true + kubernetes.K8sNamespaceDetails: + content: + application/json: + schema: + $ref: "#/components/schemas/kubernetes.K8sNamespaceDetails" + description: Namespace details + required: true + kubernetes.K8sIngressInfo: + content: + application/json: + schema: + $ref: "#/components/schemas/kubernetes.K8sIngressInfo" + description: Ingress details + required: true + kubernetes.K8sServiceInfo: + content: + application/json: + schema: + $ref: "#/components/schemas/kubernetes.K8sServiceInfo" + description: Service definition + required: true + securitySchemes: + ApiKeyAuth: + in: header + name: X-API-KEY + type: apiKey + jwt: + in: header + name: Authorization + type: apiKey + schemas: + auth.authenticatePayload: + properties: + Password: + description: Password + example: mypassword + type: string + Username: + description: Username + example: admin + type: string + required: + - Password + - Username + type: object + auth.authenticateResponse: + properties: + jwt: + description: JWT token used to authenticate against the API + example: abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzAB + type: string + type: object + auth.oauthPayload: + properties: + Code: + description: OAuth code returned from OAuth Provided + type: string + type: object + backup.backupPayload: + properties: + Password: + type: string + type: object + backup.restorePayload: + properties: + FileContent: + items: + format: int32 + type: integer + type: array + FileName: + type: string + Password: + type: string + type: object + build.BuildInfo: + properties: + BuildNumber: + type: string + GitCommit: + type: string + GoVersion: + type: string + ImageTag: + type: string + NodejsVersion: + type: string + PnpmVersion: + type: string + WebpackVersion: + type: string + type: object + build.DependenciesInfo: + properties: + ComposeVersion: + type: string + DockerVersion: + type: string + HelmVersion: + type: string + KubectlVersion: + type: string + type: object + build.RuntimeInfo: + properties: + Env: + items: + type: string + type: array + type: object + containers.containerGpusResponse: + properties: + gpus: + type: string + type: object + customtemplates.customTemplateFromFileContentPayload: + properties: + Description: + description: Description of the template + example: High performance web server + type: string + EdgeTemplate: + description: EdgeTemplate indicates if this template purpose for Edge Stack + example: false + type: boolean + FileContent: + description: Content of stack file + type: string + Logo: + description: URL of the template's logo + example: https://portainer.io/img/logo.svg + type: string + Note: + description: A note that will be displayed in the UI. Supports HTML content + example: This is my custom template + type: string + Platform: + allOf: + - $ref: "#/components/schemas/portainer.CustomTemplatePlatform" + description: |- + Platform associated to the template. + Valid values are: 1 - 'linux', 2 - 'windows' + Required for Docker stacks + enum: + - 1 + - 2 + example: 1 + Title: + description: Title of the template + example: Nginx + type: string + Type: + allOf: + - $ref: "#/components/schemas/portainer.StackType" + description: |- + Type of created stack: + * 1 - swarm + * 2 - compose + * 3 - kubernetes + enum: + - 1 + - 2 + - 3 + example: 1 + Variables: + description: Definitions of variables in the stack file + items: + $ref: "#/components/schemas/portainer.CustomTemplateVariableDefinition" + type: array + required: + - Description + - FileContent + - Title + - Type + type: object + customtemplates.customTemplateFromGitRepositoryPayload: + properties: + ComposeFilePathInRepository: + default: docker-compose.yml + description: Path to the Stack file inside the Git repository + example: docker-compose.yml + type: string + Description: + description: Description of the template + example: High performance web server + type: string + EdgeTemplate: + description: EdgeTemplate indicates if this template purpose for Edge Stack + example: false + type: boolean + IsComposeFormat: + description: IsComposeFormat indicates if the Kubernetes template is created + from a Docker Compose file + example: false + type: boolean + Logo: + description: URL of the template's logo + example: https://portainer.io/img/logo.svg + type: string + Note: + description: A note that will be displayed in the UI. Supports HTML content + example: This is my custom template + type: string + Platform: + allOf: + - $ref: "#/components/schemas/portainer.CustomTemplatePlatform" + description: |- + Platform associated to the template. + Valid values are: 1 - 'linux', 2 - 'windows' + Required for Docker stacks + enum: + - 1 + - 2 + example: 1 + RepositoryAuthentication: + description: "Deprecated: use SourceID instead. Use basic authentication to + clone the Git repository." + example: true + type: boolean + RepositoryPassword: + description: "Deprecated: use SourceID instead. Password used in basic + authentication. Required when RepositoryAuthentication is true." + example: myGitPassword + type: string + RepositoryReferenceName: + description: Reference name of a Git repository hosting the Stack file + example: refs/heads/master + type: string + RepositoryURL: + description: "Deprecated: use SourceID instead. URL of a Git repository hosting + the Stack file." + example: https://github.com/openfaas/faas + type: string + RepositoryUsername: + description: "Deprecated: use SourceID instead. Username used in basic + authentication. Required when RepositoryAuthentication is true." + example: myGitUsername + type: string + SourceID: + description: |- + SourceID references an existing Source for git credentials/URL. + When set, the inline URL and authentication fields are ignored. + example: 1 + type: integer + TLSSkipVerify: + description: "Deprecated: use SourceID instead. TLSSkipVerify skips SSL + verification when cloning the Git repository." + example: false + type: boolean + Title: + description: Title of the template + example: Nginx + type: string + Type: + allOf: + - $ref: "#/components/schemas/portainer.StackType" + description: |- + Type of created stack: + * 1 - swarm + * 2 - compose + * 3 - kubernetes + enum: + - 1 + - 2 + example: 1 + Variables: + description: Definitions of variables in the stack file + items: + $ref: "#/components/schemas/portainer.CustomTemplateVariableDefinition" + type: array + required: + - Description + - SourceID + - Title + - Type + type: object + customtemplates.customTemplateUpdatePayload: + properties: + ComposeFilePathInRepository: + default: docker-compose.yml + description: Path to the Stack file inside the Git repository + example: docker-compose.yml + type: string + Description: + description: Description of the template + example: High performance web server + type: string + EdgeTemplate: + description: EdgeTemplate indicates if this template purpose for Edge Stack + example: false + type: boolean + FileContent: + description: Content of stack file + type: string + IsComposeFormat: + description: IsComposeFormat indicates if the Kubernetes template is created + from a Docker Compose file + example: false + type: boolean + Logo: + description: URL of the template's logo + example: https://portainer.io/img/logo.svg + type: string + Note: + description: A note that will be displayed in the UI. Supports HTML content + example: This is my custom template + type: string + Platform: + allOf: + - $ref: "#/components/schemas/portainer.CustomTemplatePlatform" + description: |- + Platform associated to the template. + Valid values are: 1 - 'linux', 2 - 'windows' + Required for Docker stacks + enum: + - 1 + - 2 + example: 1 + RepositoryAuthentication: + description: "Deprecated: use SourceID instead. Use authentication to clone the + Git repository." + example: true + type: boolean + RepositoryPassword: + description: "Deprecated: use SourceID instead. Password used in basic + authentication or token used in token authentication. Required when + RepositoryAuthentication is true." + example: myGitPassword + type: string + RepositoryReferenceName: + description: Reference name of a Git repository hosting the Stack file + example: refs/heads/master + type: string + RepositoryURL: + description: "Deprecated: use SourceID instead. URL of a Git repository hosting + the Stack file." + example: https://github.com/openfaas/faas + type: string + RepositoryUsername: + description: "Deprecated: use SourceID instead. Username used in basic + authentication. Required when RepositoryAuthentication is true." + example: myGitUsername + type: string + SourceID: + description: |- + SourceID references an existing Source for git credentials/URL. + When set, the inline URL and authentication fields are ignored. + example: 1 + type: integer + TLSSkipVerify: + description: "Deprecated: use SourceID instead. TLSSkipVerify skips SSL + verification when cloning the Git repository." + example: false + type: boolean + Title: + description: Title of the template + example: Nginx + type: string + Type: + allOf: + - $ref: "#/components/schemas/portainer.StackType" + description: Type of created stack (1 - swarm, 2 - compose, 3 - kubernetes) + enum: + - 1 + - 2 + - 3 + example: 1 + Variables: + description: Definitions of variables in the stack file + items: + $ref: "#/components/schemas/portainer.CustomTemplateVariableDefinition" + type: array + required: + - Description + - FileContent + - Title + - Type + type: object + customtemplates.fileResponse: + properties: + FileContent: + type: string + type: object + docker.dashboardResponse: + properties: + containers: + $ref: "#/components/schemas/stats.ContainerStats" + images: + $ref: "#/components/schemas/docker.imagesCounters" + networks: + type: integer + services: + type: integer + stacks: + type: integer + volumes: + type: integer + type: object + docker.imagesCounters: + properties: + size: + type: integer + total: + type: integer + type: object + edge.DeployerOptionsPayload: + properties: + ForceRecreate: + description: >- + ForceRecreate is a flag indicating if the agent must force the + redeployment of the stack. + + This field is only used when the Force Redeployment is triggered. + + Once the stack is redeployed, this field will be reset to false. + + For standard edge agent, this field is used in agent side + + For async edge agent, this field is used in both agent side and server side. + + This flag drives `docker compose up --force-recreate` option + type: boolean + Prune: + description: >- + Prune is a flag indicating if the agent must prune the containers or + not when creating/updating an edge stack + + This flag drives `docker compose up --remove-orphans` and `docker stack up --prune` options + + Used only for EE + type: boolean + RemoveVolumes: + description: >- + RemoveVolumes is a flag indicating if the agent must remove the + named volumes declared + + in the compose file and anonymouse volumes attached to containers + + This flag drives `docker compose down --volumes` option + + Used only for EE + type: boolean + type: object + edge.RegistryCredentials: + properties: + Secret: + type: string + ServerURL: + type: string + Username: + type: string + type: object + edge.StackPayload: + properties: + AlwaysCloneGitRepoForRelativePath: + description: >- + AlwaysCloneGitRepoForRelativePath is a flag indicating if the agent + must always clone the git repository for relative path. + + This field is only valid when SupportRelativePath is true. + + Used only for EE + type: boolean + CreatedBy: + description: |- + CreatedBy is the username that created this stack + Used for adding labels to Kubernetes manifests + type: string + CreatedByUserId: + description: |- + CreatedByUserId is the user ID that created this stack + Used for adding labels to Kubernetes manifests + type: string + DeployerOptionsPayload: + $ref: "#/components/schemas/edge.DeployerOptionsPayload" + DirEntries: + description: Content of stack folder + items: + $ref: "#/components/schemas/filesystem.DirEntry" + type: array + EdgeUpdateID: + description: |- + EdgeUpdateID is the ID of the edge update related to this stack. + Used only for EE + type: integer + EntryFileName: + description: Name of the stack entry file + type: string + EnvVars: + description: |- + Used only for EE + EnvVars is a list of environment variables to inject into the stack + items: + $ref: "#/components/schemas/portainer.Pair" + type: array + FilesystemPath: + description: Mount point for relative path + type: string + ForceUpdate: + description: >- + ForceUpdate is a flag indicating if the agent must force the update + of the stack. + + Used only for EE + type: boolean + HelmConfig: + allOf: + - $ref: "#/components/schemas/portainer.HelmConfig" + description: HelmConfig represents the Helm configuration for an edge stack + ID: + description: ID of the stack + type: integer + Name: + description: Name of the stack + type: string + Namespace: + description: Namespace to use for kubernetes stack. Keep empty to use the + manifest namespace. + type: string + PrePullImage: + description: >- + PrePullImage is a flag indicating if the agent must pull the image + before deploying the stack. + + Used only for EE + type: boolean + RePullImage: + description: >- + RePullImage is a flag indicating if the agent must pull the image if + it is already present on the node. + + Used only for EE + type: boolean + ReadyRePullImage: + description: >- + Used only for EE async edge agent + + ReadyRePullImage is a flag to indicate whether the auto update is trigger to re-pull image + + Deprecated(2.36): use DeployerOptionsPayload.ForceRecreate instead + type: boolean + RegistryCredentials: + description: |- + RegistryCredentials holds the credentials for a Docker registry. + Used only for EE + items: + $ref: "#/components/schemas/edge.RegistryCredentials" + type: array + RetryDeploy: + description: >- + RetryDeploy is a flag indicating if the agent must retry to deploy + the stack if it fails. + + Used only for EE + type: boolean + RetryPeriod: + description: >- + RetryPeriod specifies the duration, in seconds, for which the agent + should continue attempting to deploy the stack after a failure + + Used only for EE + type: integer + RollbackTo: + description: RollbackTo specifies the stack file version to rollback to (only + support to rollback to the last version currently) + type: integer + StackFileContent: + description: Content of the stack file (for compatibility to agent version less + than 2.19.0) + type: string + SupportPerDeviceConfigs: + description: Whether the edge stack supports per device configs + type: boolean + SupportRelativePath: + description: Is relative path supported + type: boolean + Version: + description: Version of the stack file + type: integer + type: object + edgegroups.decoratedEdgeGroup: + properties: + Dynamic: + type: boolean + EndpointIds: + description: Shadow to avoid exposing in the API + type: integer + EndpointTypes: + items: + $ref: "#/components/schemas/portainer.EndpointType" + type: array + Endpoints: + description: "Deprecated: only used for API responses" + items: + type: integer + type: array + HasEdgeJob: + type: boolean + HasEdgeStack: + type: boolean + Id: + description: EdgeGroup Identifier + example: 1 + type: integer + Name: + type: string + PartialMatch: + type: boolean + TagIds: + items: + type: integer + type: array + TrustedEndpoints: + items: + type: integer + type: array + type: object + edgegroups.edgeGroupCreatePayload: + properties: + Dynamic: + type: boolean + Endpoints: + items: + type: integer + type: array + Name: + type: string + PartialMatch: + type: boolean + TagIDs: + items: + type: integer + type: array + type: object + edgegroups.edgeGroupUpdatePayload: + properties: + Dynamic: + type: boolean + Endpoints: + items: + type: integer + type: array + Name: + type: string + PartialMatch: + type: boolean + TagIDs: + items: + type: integer + type: array + type: object + edgejobs.edgeJobCreateFromFileContentPayload: + properties: + CronExpression: + type: string + EdgeGroups: + items: + type: integer + type: array + Endpoints: + items: + type: integer + type: array + FileContent: + type: string + Name: + type: string + Recurring: + type: boolean + type: object + edgejobs.edgeJobFileResponse: + properties: + FileContent: + type: string + type: object + edgejobs.edgeJobUpdatePayload: + properties: + CronExpression: + type: string + EdgeGroups: + items: + type: integer + type: array + Endpoints: + items: + type: integer + type: array + FileContent: + type: string + Name: + type: string + Recurring: + type: boolean + type: object + edgejobs.fileResponse: + properties: + FileContent: + type: string + type: object + edgejobs.taskContainer: + properties: + EndpointId: + type: integer + EndpointName: + type: string + Id: + type: string + LogsStatus: + $ref: "#/components/schemas/portainer.EdgeJobLogsStatus" + type: object + edgestacks.edgeStackFromGitRepositoryPayload: + properties: + DeploymentType: + allOf: + - $ref: "#/components/schemas/portainer.EdgeStackDeploymentType" + description: |- + Deployment type to deploy this stack + Valid values are: 0 - 'compose', 1 - 'kubernetes' + compose is enabled only for docker environments + kubernetes is enabled only for kubernetes environments + enum: + - 0 + - 1 + - 2 + example: 0 + EdgeGroups: + description: List of identifiers of EdgeGroups + example: + - 1 + items: + type: integer + type: array + FilePathInRepository: + default: docker-compose.yml + description: Path to the Stack file inside the Git repository + example: docker-compose.yml + type: string + Name: + description: >- + Name of the stack + + Max length: 255 + + Name must only contains lowercase characters, numbers, hyphens, or underscores + + Name must start with a lowercase character or number + + Example: stack-name or stack_123 or stackName + example: stack-name + type: string + Registries: + description: List of Registries to use for this stack + items: + type: integer + type: array + RepositoryAuthentication: + description: "Deprecated: Use SourceID instead. Use basic authentication to + clone the Git repository." + example: true + type: boolean + RepositoryPassword: + description: "Deprecated: Use SourceID instead. Password used in basic + authentication." + example: myGitPassword + type: string + RepositoryReferenceName: + description: Reference name of a Git repository hosting the Stack file + example: refs/heads/master + type: string + RepositoryURL: + description: "Deprecated: Use SourceID instead. URL of a Git repository hosting + the Stack file." + example: https://github.com/openfaas/faas + type: string + RepositoryUsername: + description: "Deprecated: Use SourceID instead. Username used in basic + authentication." + example: myGitUsername + type: string + SourceID: + description: |- + SourceID references an existing Source for git credentials/URL. + When set, the inline URL and authentication fields are ignored. + example: 1 + type: integer + TLSSkipVerify: + description: "Deprecated: Use SourceID instead. TLSSkipVerify skips SSL + verification when cloning the Git repository." + example: false + type: boolean + UseManifestNamespaces: + description: Uses the manifest's namespaces instead of the default one + type: boolean + required: + - EdgeGroups + - Name + type: object + edgestacks.edgeStackFromStringPayload: + properties: + DeploymentType: + allOf: + - $ref: "#/components/schemas/portainer.EdgeStackDeploymentType" + description: |- + Deployment type to deploy this stack + Valid values are: 0 - 'compose', 1 - 'kubernetes' + compose is enabled only for docker environments + kubernetes is enabled only for kubernetes environments + enum: + - 0 + - 1 + - 2 + example: 0 + EdgeGroups: + description: List of identifiers of EdgeGroups + example: + - 1 + items: + type: integer + type: array + Name: + description: >- + Name of the stack + + Max length: 255 + + Name must only contains lowercase characters, numbers, hyphens, or underscores + + Name must start with a lowercase character or number + + Example: stack-name or stack_123 or stackName + example: stack-name + type: string + Registries: + description: List of Registries to use for this stack + items: + type: integer + type: array + StackFileContent: + description: Content of the Stack file + example: |- + version: 3 + services: + web: + image:nginx + type: string + UseManifestNamespaces: + description: Uses the manifest's namespaces instead of the default one + type: boolean + required: + - Name + - StackFileContent + type: object + edgestacks.stackFileResponse: + properties: + StackFileContent: + type: string + type: object + edgestacks.updateEdgeStackPayload: + properties: + DeploymentType: + $ref: "#/components/schemas/portainer.EdgeStackDeploymentType" + EdgeGroups: + items: + type: integer + type: array + StackFileContent: + type: string + UpdateVersion: + type: boolean + UseManifestNamespaces: + description: Uses the manifest's namespaces instead of the default one + type: boolean + type: object + edgestacks.updateStatusPayload: + properties: + EndpointID: + type: integer + Error: + type: string + Status: + $ref: "#/components/schemas/portainer.EdgeStackStatusType" + Time: + format: int64 + type: integer + Version: + type: integer + type: object + endpointedge.edgeJobResponse: + properties: + CollectLogs: + description: Whether to collect logs + example: true + type: boolean + CronExpression: + description: A cron expression to schedule this job + example: "* * * * *" + type: string + Id: + description: EdgeJob Identifier + example: 2 + type: integer + Script: + description: Script to run + example: echo hello + type: string + Version: + description: Version of this EdgeJob + example: 2 + type: integer + type: object + endpointedge.endpointEdgeStatusInspectResponse: + properties: + checkin: + description: The current value of CheckinInterval + example: 5 + type: integer + credentials: + type: string + port: + description: The tunnel port + example: 8732 + type: integer + schedules: + description: List of requests for jobs to run on the environment(endpoint) + items: + $ref: "#/components/schemas/endpointedge.edgeJobResponse" + type: array + stacks: + description: List of stacks to be deployed on the environments(endpoints) + items: + $ref: "#/components/schemas/endpointedge.stackStatusResponse" + type: array + status: + description: Status represents the environment(endpoint) status + example: REQUIRED + type: string + type: object + endpointedge.stackStatusResponse: + properties: + ID: + description: EdgeStack Identifier + example: 1 + type: integer + Version: + description: Version of this stack + example: 3 + type: integer + type: object + endpointgroups.EndpointGroupResponse: + properties: + Description: + description: Description associated to the environment(endpoint) group + example: Environment(Endpoint) group description + type: string + Id: + description: Environment(Endpoint) group Identifier + example: 1 + type: integer + Name: + description: Environment(Endpoint) group name + example: my-environment-group + type: string + TagIds: + description: List of tags associated to this environment(endpoint) group + items: + type: integer + type: array + TeamAccessPolicies: + $ref: "#/components/schemas/portainer.TeamAccessPolicies" + Total: + type: integer + TypeInfo: + $ref: "#/components/schemas/endpointgroups.EndpointGroupTypeInfo" + UserAccessPolicies: + $ref: "#/components/schemas/portainer.UserAccessPolicies" + required: + - Description + - Id + - Name + type: object + endpointgroups.EndpointGroupTypeInfo: + properties: + Docker: + type: integer + Kubernetes: + type: integer + Mixed: + type: boolean + Podman: + type: integer + required: + - Docker + - Kubernetes + - Mixed + - Podman + type: object + endpointgroups.endpointGroupCreatePayload: + properties: + AssociatedEndpoints: + description: List of environment(endpoint) identifiers that will be part of this + group + example: + - 1 + - 3 + items: + type: integer + type: array + Description: + description: Environment(Endpoint) group description + example: description + type: string + Name: + description: Environment(Endpoint) group name + example: my-environment-group + type: string + TagIDs: + description: List of tag identifiers to which this environment(endpoint) group + is associated + example: + - 1 + - 2 + items: + type: integer + type: array + required: + - Name + type: object + endpointgroups.endpointGroupUpdatePayload: + properties: + AssociatedEndpoints: + description: List of environment(endpoint) identifiers that will be part of this + group + example: + - 1 + - 3 + items: + type: integer + type: array + Description: + description: Environment(Endpoint) group description + example: description + type: string + Name: + description: Environment(Endpoint) group name + example: my-environment-group + type: string + TagIDs: + description: List of tag identifiers associated to the environment(endpoint) group + example: + - 3 + - 4 + items: + type: integer + type: array + TeamAccessPolicies: + $ref: "#/components/schemas/portainer.TeamAccessPolicies" + UserAccessPolicies: + $ref: "#/components/schemas/portainer.UserAccessPolicies" + type: object + endpoints.EnvironmentSummaryCountsResponse: + properties: + byGroup: + items: + $ref: "#/components/schemas/endpoints.groupCount" + type: array + byHealth: + $ref: "#/components/schemas/endpoints.healthCounts" + byPlatformType: + $ref: "#/components/schemas/endpoints.platformCounts" + down: + type: integer + outdated: + type: integer + total: + type: integer + unassigned: + type: integer + up: + type: integer + type: object + endpoints.dockerhubStatusResponse: + properties: + limit: + description: Daily limit + type: integer + remaining: + description: Remaiming images to pull + type: integer + type: object + endpoints.endpointCreateGlobalKeyResponse: + properties: + endpointID: + type: integer + type: object + endpoints.endpointDeleteBatchPartialResponse: + properties: + deleted: + items: + type: integer + type: array + errors: + items: + type: integer + type: array + type: object + endpoints.endpointDeleteBatchPayload: + properties: + endpoints: + items: + $ref: "#/components/schemas/endpoints.endpointDeleteRequest" + type: array + type: object + endpoints.endpointDeleteRequest: + properties: + deleteCluster: + type: boolean + id: + type: integer + type: object + endpoints.endpointSettingsUpdatePayload: + properties: + allowBindMountsForRegularUsers: + description: Whether non-administrator should be able to use bind mounts when + creating containers + example: false + type: boolean + allowContainerCapabilitiesForRegularUsers: + description: Whether non-administrator should be able to use container + capabilities + example: true + type: boolean + allowDeviceMappingForRegularUsers: + description: Whether non-administrator should be able to use device mapping + example: true + type: boolean + allowHostNamespaceForRegularUsers: + description: Whether non-administrator should be able to use the host pid + example: true + type: boolean + allowPrivilegedModeForRegularUsers: + description: Whether non-administrator should be able to use privileged mode + when creating containers + example: false + type: boolean + allowSecurityOptForRegularUsers: + description: Whether non-administrator should be able to use security-opt settings + example: true + type: boolean + allowStackManagementForRegularUsers: + description: Whether non-administrator should be able to manage stacks + example: true + type: boolean + allowSysctlSettingForRegularUsers: + description: Whether non-administrator should be able to use sysctl settings + example: true + type: boolean + allowVolumeBrowserForRegularUsers: + description: Whether non-administrator should be able to browse volumes + example: true + type: boolean + enableGPUManagement: + example: false + type: boolean + enableHostManagementFeatures: + description: Whether host management features are enabled + example: true + type: boolean + gpus: + items: + $ref: "#/components/schemas/portainer.Pair" + type: array + type: object + endpoints.endpointUpdatePayload: + properties: + AzureApplicationID: + description: Azure application ID + example: eag7cdo9-o09l-9i83-9dO9-f0b23oe78db4 + type: string + AzureAuthenticationKey: + description: Azure authentication key + example: cOrXoK/1D35w8YQ8nH1/8ZGwzz45JIYD5jxHKXEQknk= + type: string + AzureTenantID: + description: Azure tenant ID + example: 34ddc78d-4fel-2358-8cc1-df84c8o839f5 + type: string + EdgeCheckinInterval: + description: The check in interval for edge agent (in seconds) + example: 5 + type: integer + Gpus: + description: GPUs information + items: + $ref: "#/components/schemas/portainer.Pair" + type: array + GroupID: + description: Group identifier + example: 1 + type: integer + Kubernetes: + allOf: + - $ref: "#/components/schemas/portainer.KubernetesData" + description: Associated Kubernetes data + Name: + description: Name that will be used to identify this environment(endpoint) + example: my-environment + type: string + PublicURL: + description: |- + URL or IP address where exposed containers will be reachable.\ + Defaults to URL if not specified + example: docker.mydomain.tld:2375 + type: string + Status: + description: The status of the environment(endpoint) (1 - up, 2 - down) + example: 1 + type: integer + TLS: + description: Require TLS to connect against this environment(endpoint) + example: true + type: boolean + TLSSkipClientVerify: + description: Skip client verification when using TLS + example: false + type: boolean + TLSSkipVerify: + description: Skip server verification when using TLS + example: false + type: boolean + TagIDs: + description: List of tag identifiers to which this environment(endpoint) is + associated + example: + - 1 + - 2 + items: + type: integer + type: array + TeamAccessPolicies: + $ref: "#/components/schemas/portainer.TeamAccessPolicies" + URL: + description: URL or IP address of a Docker host + example: docker.mydomain.tld:2375 + type: string + UserAccessPolicies: + $ref: "#/components/schemas/portainer.UserAccessPolicies" + type: object + endpoints.endpointUpdateRelationsPayload: + properties: + Relations: + additionalProperties: + properties: + EdgeGroups: + items: + type: integer + type: array + Group: + type: integer + Tags: + items: + type: integer + type: array + type: object + type: object + type: object + endpoints.forceUpdateServicePayload: + properties: + PullImage: + description: PullImage if true will pull the image + type: boolean + ServiceID: + description: ServiceId to update + type: string + type: object + endpoints.groupCount: + properties: + count: + type: integer + groupID: + type: integer + groupName: + type: string + type: object + endpoints.healthCounts: + properties: + down: + type: integer + heartbeat: + type: integer + outdated: + type: integer + up: + type: integer + type: object + endpoints.platformCounts: + properties: + azure: + type: integer + docker: + type: integer + kubernetes: + type: integer + podman: + type: integer + type: object + endpoints.registryAccessPayload: + properties: + Namespaces: + items: + type: string + type: array + TeamAccessPolicies: + $ref: "#/components/schemas/portainer.TeamAccessPolicies" + UserAccessPolicies: + $ref: "#/components/schemas/portainer.UserAccessPolicies" + type: object + filesystem.DirEntry: + properties: + Content: + type: string + IsFile: + type: boolean + Name: + type: string + Permissions: + type: integer + type: object + github_com_portainer_portainer_pkg_libhelm_release.Hook: + properties: + delete_policies: + description: DeletePolicies are the policies that indicate when to delete the hook + items: + type: string + type: array + events: + description: Events are the events that this hook fires on. + items: + type: string + type: array + kind: + description: Kind is the Kubernetes kind. + type: string + last_run: + allOf: + - $ref: "#/components/schemas/release.HookExecution" + description: LastRun indicates the date/time this was last run. + manifest: + description: Manifest is the manifest contents. + type: string + name: + type: string + path: + description: Path is the chart-relative path to the template. + type: string + weight: + description: Weight indicates the sort order for execution among similar Hook type + type: integer + type: object + gitops.fileResponse: + properties: + FileContent: + type: string + type: object + gitops.repositoryFilePreviewPayload: + properties: + password: + description: |- + Password for git authentication. + Deprecated: use SourceID instead + example: myGitPassword + type: string + reference: + example: refs/heads/master + type: string + repository: + description: |- + URL of a Git repository to preview. + Deprecated: use SourceID instead + example: https://github.com/openfaas/faas + type: string + sourceID: + description: >- + SourceID resolves URL and auth from the stored Source record. + + When set, the inline Repository/Username/Password/TLSSkipVerify fields are ignored. + example: 1 + type: integer + targetFile: + description: Path to file whose content will be read + example: docker-compose.yml + type: string + tlsSkipVerify: + description: >- + TLSSkipVerify skips SSL verification when cloning the Git + repository. + + Deprecated: use SourceID instead + example: false + type: boolean + username: + description: |- + Username for git authentication. + Deprecated: use SourceID instead + example: myGitUsername + type: string + type: object + gittypes.GitAuthentication: + properties: + AuthorizationType: + type: integer + Password: + type: string + Provider: + type: integer + Username: + type: string + type: object + gittypes.GitSource: + properties: + Authentication: + $ref: "#/components/schemas/gittypes.GitAuthentication" + TLSSkipVerify: + example: false + type: boolean + URL: + example: https://github.com/portainer/portainer.git + type: string + type: object + gittypes.RepoConfig: + properties: + Authentication: + allOf: + - $ref: "#/components/schemas/gittypes.GitAuthentication" + description: Git credentials + ConfigFilePath: + description: >- + ConfigFilePath is the path to the config file within the repository. + + NOTE: For stacks, this mirrors Stack.EntryPoint and the two are kept in sync by stackUpdateGit. + example: docker-compose.yml + type: string + ConfigHash: + description: Repository hash + example: bc4c183d756879ea4d173315338110b31004b8e0 + type: string + ReferenceName: + description: The reference name + example: refs/heads/branch_name + type: string + TLSSkipVerify: + description: TLSSkipVerify skips SSL verification when cloning the Git repository + example: false + type: boolean + URL: + description: The repo url + example: https://github.com/portainer/portainer.git + type: string + type: object + helm.installChartPayload: + properties: + atomic: + type: boolean + chart: + type: string + name: + type: string + namespace: + type: string + repo: + type: string + values: + type: string + version: + type: string + type: object + images.ImageResponse: + properties: + created: + type: integer + id: + type: string + nodeName: + type: string + size: + type: integer + tags: + items: + type: string + type: array + used: + description: |- + Used is true if the image is used by at least one container + supplied only when withUsage is true + type: boolean + type: object + intstr.IntOrString: + properties: + IntVal: + type: integer + StrVal: + type: string + Type: + $ref: "#/components/schemas/intstr.Type" + type: object + intstr.Type: + enum: + - 0 + - 1 + format: int64 + type: integer + x-enum-comments: + Int: The IntOrString holds an int. + String: The IntOrString holds a string. + x-enum-descriptions: + - The IntOrString holds an int. + - The IntOrString holds a string. + x-enum-varnames: + - Int + - String + k8s_io_api_core_v1.ConditionStatus: + enum: + - "True" + - "False" + - Unknown + type: string + x-enum-varnames: + - ConditionTrue + - ConditionFalse + - ConditionUnknown + k8s_io_api_core_v1.HTTPHeader: + properties: + name: + description: >- + The header field name. + + This will be canonicalized upon output, so case-variant names will be understood as the same header. + type: string + value: + description: The header field value + type: string + type: object + k8s_io_api_core_v1.LocalObjectReference: + properties: + name: + description: >- + Name of the referent. + + This field is effectively required, but due to backwards compatibility is + + allowed to be empty. Instances of this type with an empty value here are + + almost certainly wrong. + + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + + +optional + + +default="" + + +kubebuilder:default="" + + TODO: Drop `kubebuilder:default` when controller-gen doesn't need it https://github.com/kubernetes-sigs/kubebuilder/issues/3896. + type: string + type: object + k8s_io_api_core_v1.ObjectReference: + properties: + apiVersion: + description: |- + API version of the referent. + +optional + type: string + fieldPath: + description: >- + If referring to a piece of an object instead of an entire object, + this string + + should contain a valid JSON/Go field access statement, such as desiredState.manifest.containers[2]. + + For example, if the object reference is to a container within a pod, this would take on a value like: + + "spec.containers{name}" (where "name" refers to the name of the container that triggered + + the event) or if no container name is specified "spec.containers[2]" (container with + + index 2 in this pod). This syntax is chosen only to have some well-defined way of + + referencing a part of an object. + + TODO: this design is not final and this field is subject to change in the future. + + +optional + type: string + kind: + description: >- + Kind of the referent. + + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + + +optional + type: string + name: + description: >- + Name of the referent. + + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + + +optional + type: string + namespace: + description: >- + Namespace of the referent. + + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces/ + + +optional + type: string + resourceVersion: + description: >- + Specific resourceVersion to which this reference is made, if any. + + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency + + +optional + type: string + uid: + description: >- + UID of the referent. + + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#uids + + +optional + type: string + type: object + k8s_io_api_core_v1.ResourceClaim: + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. + type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + + +optional + type: string + type: object + k8s_io_api_rbac_v1.Subject: + properties: + apiGroup: + description: |- + APIGroup holds the API group of the referenced subject. + Defaults to "" for ServiceAccount subjects. + Defaults to "rbac.authorization.k8s.io" for User and Group subjects. + +optional + type: string + kind: + description: >- + Kind of object being referenced. Values defined by this API group + are "User", "Group", and "ServiceAccount". + + If the Authorizer does not recognized the kind value, the Authorizer should report an error. + type: string + name: + description: |- + Name of the object being referenced. + +required + +k8s:required + type: string + namespace: + description: >- + Namespace of the referenced object. If the object kind is + non-namespace, such as "User" or "Group", and this value is not + empty + + the Authorizer should report an error. + + +optional + type: string + type: object + kubernetes.Configuration: + properties: + ConfigurationOwner: + type: string + Data: + additionalProperties: {} + type: object + Kind: + type: string + type: object + kubernetes.CustomResourceMetadata: + properties: + apiVersion: + type: string + kind: + type: string + name: + type: string + plural: + type: string + scope: + type: string + type: object + kubernetes.IngressRule: + properties: + Host: + type: string + IP: + type: string + Path: + type: string + TLS: + items: + $ref: "#/components/schemas/kubernetes.TLSInfo" + type: array + type: object + kubernetes.K8sApplication: + properties: + Annotations: + additionalProperties: + type: string + type: object + ApplicationOwner: + type: string + ApplicationType: + type: string + Configurations: + items: + $ref: "#/components/schemas/kubernetes.Configuration" + type: array + Containers: + items: {} + type: array + CreationDate: + type: string + CustomResourceMetadata: + $ref: "#/components/schemas/kubernetes.CustomResourceMetadata" + DeploymentType: + type: string + Id: + type: string + Image: + type: string + Kind: + type: string + Labels: + additionalProperties: + type: string + type: object + LoadBalancerIPAddress: + type: string + MatchLabels: + additionalProperties: + type: string + type: object + Metadata: + $ref: "#/components/schemas/kubernetes.Metadata" + Name: + type: string + Namespace: + type: string + Pods: + items: + $ref: "#/components/schemas/kubernetes.Pod" + type: array + PublishedPorts: + items: + $ref: "#/components/schemas/kubernetes.PublishedPort" + type: array + Resource: + $ref: "#/components/schemas/kubernetes.K8sApplicationResource" + ResourcePool: + type: string + RunningPodsCount: + type: integer + ServiceId: + type: string + ServiceName: + type: string + ServiceType: + type: string + StackId: + type: string + StackKind: + type: string + StackName: + type: string + Status: + type: string + TotalPodsCount: + type: integer + Uid: + type: string + type: object + kubernetes.K8sApplicationResource: + properties: + CpuLimit: + type: number + CpuRequest: + type: number + MemoryLimit: + type: integer + MemoryRequest: + type: integer + type: object + kubernetes.K8sClusterRole: + properties: + creationDate: + type: string + isSystem: + type: boolean + name: + type: string + uid: + type: string + type: object + kubernetes.K8sClusterRoleBinding: + properties: + creationDate: + type: string + isSystem: + type: boolean + name: + type: string + namespace: + type: string + roleRef: + $ref: "#/components/schemas/v1.RoleRef" + subjects: + items: + $ref: "#/components/schemas/k8s_io_api_rbac_v1.Subject" + type: array + uid: + type: string + type: object + kubernetes.K8sConfigMap: + properties: + Annotations: + additionalProperties: + type: string + type: object + ConfigurationOwner: + type: string + ConfigurationOwnerId: + type: string + ConfigurationOwners: + items: + $ref: "#/components/schemas/kubernetes.K8sConfigurationOwnerResource" + type: array + CreationDate: + type: string + Data: + additionalProperties: + type: string + type: object + IsUsed: + type: boolean + Labels: + additionalProperties: + type: string + type: object + Name: + type: string + Namespace: + type: string + UID: + type: string + type: object + kubernetes.K8sConfigurationOwnerResource: + properties: + Id: + type: string + Name: + type: string + ResourceKind: + type: string + type: object + kubernetes.K8sCronJob: + properties: + Command: + type: string + Id: + type: string + IsSystem: + type: boolean + Jobs: + items: + $ref: "#/components/schemas/kubernetes.K8sJob" + type: array + Name: + type: string + Namespace: + type: string + Schedule: + type: string + Suspend: + type: boolean + Timezone: + type: string + type: object + kubernetes.K8sCronJobDeleteRequests: + additionalProperties: + items: + type: string + type: array + type: object + kubernetes.K8sDashboard: + properties: + applicationsCount: + type: integer + configMapsCount: + type: integer + ingressesCount: + type: integer + namespacesCount: + type: integer + secretsCount: + type: integer + servicesCount: + type: integer + volumesCount: + type: integer + type: object + kubernetes.K8sEvent: + properties: + count: + type: integer + eventTime: + type: string + firstTimestamp: + type: string + involvedObject: + $ref: "#/components/schemas/kubernetes.K8sEventInvolvedObject" + kind: + type: string + lastTimestamp: + type: string + message: + type: string + name: + type: string + namespace: + type: string + reason: + type: string + type: + type: string + uid: + type: string + type: object + kubernetes.K8sEventInvolvedObject: + properties: + kind: + type: string + name: + type: string + namespace: + type: string + uid: + type: string + type: object + kubernetes.K8sIngressController: + properties: + Availability: + type: boolean + ClassName: + type: string + Name: + type: string + New: + type: boolean + Type: + type: string + Used: + type: boolean + type: object + kubernetes.K8sIngressDeleteRequests: + additionalProperties: + items: + type: string + type: array + type: object + kubernetes.K8sIngressInfo: + properties: + Annotations: + additionalProperties: + type: string + type: object + ClassName: + type: string + CreationDate: + type: string + Hosts: + items: + type: string + type: array + Labels: + additionalProperties: + type: string + type: object + Name: + type: string + Namespace: + type: string + Paths: + items: + $ref: "#/components/schemas/kubernetes.K8sIngressPath" + type: array + TLS: + items: + $ref: "#/components/schemas/kubernetes.K8sIngressTLS" + type: array + Type: + type: string + UID: + type: string + type: object + kubernetes.K8sIngressPath: + properties: + HasService: + type: boolean + Host: + type: string + IngressName: + type: string + Path: + type: string + PathType: + type: string + Port: + type: integer + ServiceName: + type: string + type: object + kubernetes.K8sIngressTLS: + properties: + Hosts: + items: + type: string + type: array + SecretName: + type: string + type: object + kubernetes.K8sJob: + properties: + BackoffLimit: + type: integer + Command: + type: string + Completions: + type: integer + Container: + $ref: "#/components/schemas/v1.Container" + Duration: + type: string + FailedReason: + type: string + FinishTime: + type: string + Id: + type: string + IsSystem: + type: boolean + Name: + type: string + Namespace: + type: string + PodName: + type: string + StartTime: + type: string + Status: + type: string + type: object + kubernetes.K8sJobDeleteRequests: + additionalProperties: + items: + type: string + type: array + type: object + kubernetes.K8sNamespaceDetails: + properties: + Annotations: + additionalProperties: + type: string + type: object + Name: + type: string + Owner: + type: string + ResourceQuota: + $ref: "#/components/schemas/kubernetes.K8sResourceQuota" + type: object + kubernetes.K8sPVCResizeRequest: + properties: + name: + type: string + namespace: + type: string + newSize: + type: string + type: object + kubernetes.K8sPVReclaimPolicyRequest: + properties: + name: + type: string + reclaimPolicy: + $ref: "#/components/schemas/v1.PersistentVolumeReclaimPolicy" + type: object + kubernetes.K8sPersistentVolume: + properties: + accessModes: + items: + type: string + type: array + annotations: + additionalProperties: + type: string + type: object + capacity: + $ref: "#/components/schemas/v1.ResourceList" + claimRef: + $ref: "#/components/schemas/k8s_io_api_core_v1.ObjectReference" + creationDate: + type: string + csi: + $ref: "#/components/schemas/v1.CSIPersistentVolumeSource" + humanReadableAccessModes: + items: + $ref: "#/components/schemas/v1.PersistentVolumeAccessMode" + type: array + labels: + additionalProperties: + type: string + type: object + name: + type: string + persistentVolumeReclaimPolicy: + $ref: "#/components/schemas/v1.PersistentVolumeReclaimPolicy" + status: + $ref: "#/components/schemas/v1.PersistentVolumePhase" + storageClassName: + type: string + volumeMode: + $ref: "#/components/schemas/v1.PersistentVolumeMode" + type: object + kubernetes.K8sPersistentVolumeClaim: + properties: + accessModes: + items: + type: string + type: array + allowVolumeExpansion: + type: boolean + creationDate: + type: string + humanReadableAccessModes: + items: + $ref: "#/components/schemas/v1.PersistentVolumeAccessMode" + type: array + id: + type: string + labels: + additionalProperties: + type: string + type: object + name: + type: string + namespace: + type: string + owningApplications: + items: + $ref: "#/components/schemas/kubernetes.K8sApplication" + type: array + phase: + $ref: "#/components/schemas/v1.PersistentVolumeClaimPhase" + resourcesRequests: + $ref: "#/components/schemas/v1.ResourceList" + storage: + type: integer + storageClass: + type: string + storageRequest: + type: string + volumeMode: + $ref: "#/components/schemas/v1.PersistentVolumeMode" + volumeName: + type: string + type: object + kubernetes.K8sResourceQuota: + properties: + cpu: + type: string + enabled: + type: boolean + memory: + type: string + type: object + kubernetes.K8sRole: + properties: + creationDate: + type: string + isSystem: + description: >- + isSystem is true if prefixed with "system:" or exists in the + kube-system namespace + + or is one of the portainer roles + type: boolean + name: + type: string + namespace: + type: string + uid: + type: string + type: object + kubernetes.K8sRoleBinding: + properties: + creationDate: + type: string + isSystem: + type: boolean + name: + type: string + namespace: + type: string + roleRef: + $ref: "#/components/schemas/v1.RoleRef" + subjects: + items: + $ref: "#/components/schemas/k8s_io_api_rbac_v1.Subject" + type: array + uid: + type: string + type: object + kubernetes.K8sRoleBindingDeleteRequests: + additionalProperties: + items: + type: string + type: array + type: object + kubernetes.K8sRoleDeleteRequests: + additionalProperties: + items: + type: string + type: array + type: object + kubernetes.K8sSecret: + properties: + Annotations: + additionalProperties: + type: string + type: object + ConfigurationOwner: + type: string + ConfigurationOwnerId: + type: string + ConfigurationOwners: + items: + $ref: "#/components/schemas/kubernetes.K8sConfigurationOwnerResource" + type: array + CreationDate: + type: string + Data: + additionalProperties: + type: string + type: object + IsUsed: + type: boolean + Labels: + additionalProperties: + type: string + type: object + Name: + type: string + Namespace: + type: string + SecretType: + type: string + UID: + type: string + type: object + kubernetes.K8sServiceAccount: + properties: + annotations: + additionalProperties: + type: string + type: object + automountServiceAccountToken: + type: boolean + creationDate: + type: string + imagePullSecrets: + items: + $ref: "#/components/schemas/k8s_io_api_core_v1.LocalObjectReference" + type: array + isSystem: + type: boolean + labels: + additionalProperties: + type: string + type: object + name: + type: string + namespace: + type: string + uid: + type: string + type: object + kubernetes.K8sServiceAccountDeleteRequests: + additionalProperties: + items: + type: string + type: array + type: object + kubernetes.K8sServiceAccountImagePullSecretsUpdatePayload: + properties: + secretNames: + items: + type: string + type: array + type: object + kubernetes.K8sServiceDeleteRequests: + additionalProperties: + items: + type: string + type: array + type: object + kubernetes.K8sServiceInfo: + properties: + AllocateLoadBalancerNodePorts: + type: boolean + Annotations: + additionalProperties: + type: string + type: object + Applications: + description: serviceList screen + items: + $ref: "#/components/schemas/kubernetes.K8sApplication" + type: array + ClusterIPs: + items: + type: string + type: array + CreationDate: + type: string + ExternalIPs: + items: + type: string + type: array + ExternalName: + type: string + IngressStatus: + items: + $ref: "#/components/schemas/kubernetes.K8sServiceIngress" + type: array + Labels: + additionalProperties: + type: string + type: object + Name: + type: string + Namespace: + type: string + Ports: + items: + $ref: "#/components/schemas/kubernetes.K8sServicePort" + type: array + Selector: + additionalProperties: + type: string + type: object + Type: + type: string + UID: + type: string + type: object + kubernetes.K8sServiceIngress: + properties: + Hostname: + type: string + IP: + type: string + type: object + kubernetes.K8sServicePort: + properties: + Name: + type: string + NodePort: + type: integer + Port: + type: integer + Protocol: + type: string + TargetPort: + type: string + type: object + kubernetes.K8sStorageClass: + properties: + allowVolumeExpansion: + type: boolean + annotations: + additionalProperties: + type: string + type: object + creationDate: + type: string + isDefault: + type: boolean + labels: + additionalProperties: + type: string + type: object + mountOptions: + items: + type: string + type: array + name: + type: string + parameters: + additionalProperties: + type: string + type: object + provisioner: + type: string + reclaimPolicy: + $ref: "#/components/schemas/v1.PersistentVolumeReclaimPolicy" + type: object + kubernetes.K8sVolumeDeleteRequest: + properties: + name: + type: string + namespace: + type: string + type: object + kubernetes.K8sVolumeInfo: + properties: + persistentVolume: + $ref: "#/components/schemas/kubernetes.K8sPersistentVolume" + persistentVolumeClaim: + $ref: "#/components/schemas/kubernetes.K8sPersistentVolumeClaim" + storageClass: + $ref: "#/components/schemas/kubernetes.K8sStorageClass" + type: object + kubernetes.KubernetesNodeResponse: + properties: + apiVersion: + description: >- + APIVersion defines the versioned schema of this representation of an + object. + + Servers should convert recognized schemas to the latest internal value, and + + may reject unrecognized values. + + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + + +optional + type: string + kind: + description: >- + Kind is a string value representing the REST resource this object + represents. + + Servers may infer this from the endpoint the client submits requests to. + + Cannot be updated. + + In CamelCase. + + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + + +optional + type: string + metadata: + allOf: + - $ref: "#/components/schemas/v1.ObjectMeta" + description: >- + Standard object's metadata. + + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata + + +optional + spec: + allOf: + - $ref: "#/components/schemas/v1.NodeSpec" + description: >- + Spec defines the behavior of a node. + + https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status + + +optional + status: + allOf: + - $ref: "#/components/schemas/v1.NodeStatus" + description: >- + Most recently observed status of the node. + + Populated by the system. + + Read-only. + + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status + + +optional + type: object + kubernetes.Metadata: + properties: + annotations: + additionalProperties: + type: string + type: object + labels: + additionalProperties: + type: string + type: object + type: object + kubernetes.Pod: + properties: + ContainerName: + type: string + CreationDate: + type: string + Image: + type: string + ImagePullPolicy: + type: string + Name: + type: string + NodeName: + type: string + PodIP: + type: string + Resource: + $ref: "#/components/schemas/kubernetes.K8sApplicationResource" + Status: + type: string + Uid: + type: string + type: object + kubernetes.PublishedPort: + properties: + IngressRules: + items: + $ref: "#/components/schemas/kubernetes.IngressRule" + type: array + Port: + type: integer + type: object + kubernetes.TLSInfo: + properties: + hosts: + items: + type: string + type: array + type: object + kubernetes.describeResourceResponse: + properties: + describe: + type: string + type: object + kubernetes.kubernetesVersionResponse: + properties: + buildDate: + type: string + compiler: + type: string + emulationMajor: + description: EmulationMajor is the major version of the emulation version + type: string + emulationMinor: + description: EmulationMinor is the minor version of the emulation version + type: string + gitCommit: + type: string + gitTreeState: + type: string + gitVersion: + type: string + goVersion: + type: string + major: + description: Major is the major version of the binary version + type: string + minCompatibilityMajor: + description: MinCompatibilityMajor is the major version of the minimum + compatibility version + type: string + minCompatibilityMinor: + description: MinCompatibilityMinor is the minor version of the minimum + compatibility version + type: string + minor: + description: Minor is the minor version of the binary version + type: string + platform: + type: string + supportsPodRestart: + description: >- + SupportsPodRestart is true when the cluster exposes the + `pods/restart` + + subresource via API discovery — i.e. the feature gate is enabled and + + the cluster version is recent enough. This is the authoritative + + signal for whether Portainer can call the pod-restart endpoint, and + + is preferred over a raw Kubernetes-version comparison. + type: boolean + type: object + kubernetes.namespacesToggleSystemPayload: + properties: + System: + description: Toggle the system state of this namespace to true or false + example: true + type: boolean + type: object + ldap.checkPayload: + properties: + LDAPSettings: + $ref: "#/components/schemas/portainer.LDAPSettings" + type: object + motd.Motd: + properties: + ContentLayout: + additionalProperties: + type: string + type: object + Hash: + items: + type: integer + type: array + Message: + type: string + Style: + type: string + Title: + type: string + type: object + oauth2.AuthStyle: + enum: + - 0 + - 1 + - 2 + type: integer + x-enum-varnames: + - AuthStyleAutoDetect + - AuthStyleInParams + - AuthStyleInHeader + platform.ContainerPlatform: + enum: + - Docker + - Docker Standalone + - Docker Swarm + - Kubernetes + - Podman + type: string + x-enum-varnames: + - PlatformDocker + - PlatformDockerStandalone + - PlatformDockerSwarm + - PlatformKubernetes + - PlatformPodman + portainer.APIKey: + properties: + dateCreated: + description: Unix timestamp (UTC) when the API key was created + type: integer + description: + example: portainer-api-key + type: string + digest: + description: Digest represents SHA256 hash of the raw API key + type: string + id: + example: 1 + type: integer + lastUsed: + description: Unix timestamp (UTC) when the API key was last used + type: integer + prefix: + description: API key identifier (7 char prefix) + type: string + userId: + example: 1 + type: integer + type: object + portainer.AccessPolicy: + properties: + Namespaces: + description: Namespaces is a list of namespaces that this access policy applies + to. Only used for namespaced level roles + items: + type: string + type: array + RoleId: + description: Role identifier. Reference the role that will be associated to this + access policy + example: 1 + type: integer + required: + - RoleId + type: object + portainer.Artifact: + properties: + edgeGroups: + items: + type: integer + type: array + edgeStackId: + type: integer + envGroups: + items: + type: integer + type: array + envIds: + items: + type: integer + type: array + files: + items: + $ref: "#/components/schemas/portainer.ArtifactFile" + type: array + stackId: + type: integer + type: object + portainer.ArtifactFile: + properties: + hash: + example: abc123 + type: string + path: + example: portainer.yaml + type: string + pathError: + type: string + pathStatus: + $ref: "#/components/schemas/portainer.SourceStatus" + ref: + example: refs/heads/main + type: string + refError: + type: string + refStatus: + $ref: "#/components/schemas/portainer.SourceStatus" + sourceId: + type: integer + type: object + portainer.AuthenticationMethod: + enum: + - 0 + - 1 + - 2 + - 3 + type: integer + x-enum-varnames: + - _ + - AuthenticationInternal + - AuthenticationLDAP + - AuthenticationOAuth + portainer.Authorizations: + additionalProperties: + type: boolean + type: object + portainer.AutoUpdateSettings: + properties: + ForcePullImage: + description: Pull latest image + example: false + type: boolean + ForceUpdate: + description: Force update ignores repo changes + example: false + type: boolean + Interval: + description: Auto update interval + example: 1m30s + type: string + JobID: + description: Autoupdate job id + example: "15" + type: string + Webhook: + description: A UUID generated from client + example: 05de31a2-79fa-4644-9c12-faa67e5c49f0 + type: string + type: object + portainer.AzureCredentials: + properties: + ApplicationID: + description: Azure application ID + example: eag7cdo9-o09l-9i83-9dO9-f0b23oe78db4 + type: string + AuthenticationKey: + description: Azure authentication key + example: cOrXoK/1D35w8YQ8nH1/8ZGwzz45JIYD5jxHKXEQknk= + type: string + TenantID: + description: Azure tenant ID + example: 34ddc78d-4fel-2358-8cc1-df84c8o839f5 + type: string + required: + - ApplicationID + - AuthenticationKey + - TenantID + type: object + portainer.CustomTemplate: + properties: + CreatedByUserId: + description: User identifier who created this template + example: 3 + type: integer + Description: + description: Description of the template + example: High performance web server + type: string + EdgeTemplate: + description: EdgeTemplate indicates if this template purpose for Edge Stack + example: false + type: boolean + EntryPoint: + description: Path to the Stack file + example: docker-compose.yml + type: string + GitConfig: + $ref: "#/components/schemas/gittypes.RepoConfig" + Id: + description: CustomTemplate Identifier + example: 1 + type: integer + IsComposeFormat: + description: IsComposeFormat indicates if the Kubernetes template is created + from a Docker Compose file + example: false + type: boolean + Logo: + description: URL of the template's logo + example: https://portainer.io/img/logo.svg + type: string + Note: + description: A note that will be displayed in the UI. Supports HTML content + example: This is my custom template + type: string + Platform: + allOf: + - $ref: "#/components/schemas/portainer.CustomTemplatePlatform" + description: |- + Platform associated to the template. + Valid values are: 1 - 'linux', 2 - 'windows' + enum: + - 1 + - 2 + example: 1 + ProjectPath: + description: Path on disk to the repository hosting the Stack file + example: /data/custom_template/3 + type: string + ResourceControl: + $ref: "#/components/schemas/portainer.ResourceControl" + Title: + description: Title of the template + example: Nginx + type: string + Type: + allOf: + - $ref: "#/components/schemas/portainer.StackType" + description: |- + Type of created stack: + * 1 - swarm + * 2 - compose + * 3 - kubernetes + enum: + - 1 + - 2 + - 3 + example: 1 + Variables: + items: + $ref: "#/components/schemas/portainer.CustomTemplateVariableDefinition" + type: array + artifact: + $ref: "#/components/schemas/portainer.Artifact" + type: object + portainer.CustomTemplatePlatform: + enum: + - 0 + - 1 + - 2 + type: integer + x-enum-varnames: + - _ + - CustomTemplatePlatformLinux + - CustomTemplatePlatformWindows + portainer.CustomTemplateVariableDefinition: + properties: + defaultValue: + example: default value + type: string + description: + example: Description + type: string + label: + example: My Variable + type: string + name: + example: MY_VAR + type: string + type: object + portainer.DiagnosticsData: + properties: + DNS: + additionalProperties: + type: string + type: object + Log: + type: string + Proxy: + additionalProperties: + type: string + type: object + Telnet: + additionalProperties: + type: string + type: object + type: object + portainer.DockerSnapshot: + properties: + ContainerCount: + type: integer + DiagnosticsData: + $ref: "#/components/schemas/portainer.DiagnosticsData" + DockerSnapshotRaw: + $ref: "#/components/schemas/portainer.DockerSnapshotRaw" + DockerVersion: + type: string + GpuUseAll: + type: boolean + GpuUseList: + items: + type: string + type: array + HealthyContainerCount: + type: integer + ImageCount: + type: integer + IsPodman: + type: boolean + NodeCount: + type: integer + PerformanceMetrics: + $ref: "#/components/schemas/portainer.PerformanceMetrics" + RunningContainerCount: + type: integer + ServiceCount: + type: integer + StackCount: + type: integer + StoppedContainerCount: + type: integer + Swarm: + type: boolean + Time: + type: integer + TotalCPU: + type: integer + TotalMemory: + type: integer + UnhealthyContainerCount: + type: integer + VolumeCount: + type: integer + required: + - ContainerCount + - DockerVersion + - GpuUseAll + - HealthyContainerCount + - ImageCount + - IsPodman + - NodeCount + - RunningContainerCount + - ServiceCount + - StackCount + - StoppedContainerCount + - Swarm + - Time + - TotalCPU + - TotalMemory + - UnhealthyContainerCount + - VolumeCount + type: object + portainer.DockerSnapshotRaw: + type: object + portainer.EcrData: + properties: + Region: + example: ap-southeast-2 + type: string + type: object + portainer.Edge: + properties: + AsyncMode: + description: Deprecated 2.18 + example: false + type: boolean + CommandInterval: + description: The command list interval for edge agent - used in edge async mode + (in seconds) + example: 5 + type: integer + PingInterval: + description: The ping interval for edge agent - used in edge async mode (in + seconds) + example: 5 + type: integer + SnapshotInterval: + description: The snapshot interval for edge agent - used in edge async mode (in + seconds) + example: 5 + type: integer + type: object + portainer.EdgeGroup: + properties: + Dynamic: + type: boolean + EndpointIds: + $ref: "#/components/schemas/roar.Roar-portainer_EndpointID" + Endpoints: + description: "Deprecated: only used for API responses" + items: + type: integer + type: array + Id: + description: EdgeGroup Identifier + example: 1 + type: integer + Name: + type: string + PartialMatch: + type: boolean + TagIds: + items: + type: integer + type: array + type: object + portainer.EdgeJob: + properties: + Created: + type: integer + CronExpression: + type: string + EdgeGroups: + items: + type: integer + type: array + Endpoints: + additionalProperties: + $ref: "#/components/schemas/portainer.EdgeJobEndpointMeta" + type: object + GroupLogsCollection: + additionalProperties: + $ref: "#/components/schemas/portainer.EdgeJobEndpointMeta" + description: Field used for log collection of Endpoints belonging to EdgeGroups + type: object + Id: + description: EdgeJob Identifier + example: 1 + type: integer + Name: + type: string + Recurring: + type: boolean + ScriptPath: + type: string + Version: + type: integer + type: object + portainer.EdgeJobEndpointMeta: + properties: + CollectLogs: + type: boolean + LogsStatus: + $ref: "#/components/schemas/portainer.EdgeJobLogsStatus" + type: object + portainer.EdgeJobLogsStatus: + enum: + - 0 + - 1 + - 2 + - 3 + type: integer + x-enum-varnames: + - _ + - EdgeJobLogsStatusIdle + - EdgeJobLogsStatusPending + - EdgeJobLogsStatusCollected + portainer.EdgeStack: + properties: + CreatedBy: + description: The username which created this stack + example: admin + type: string + CreatedByUserId: + description: The username id which created this stack + example: "1" + type: string + CreationDate: + description: StatusArray map[EndpointID][]EdgeStackStatus `json:"StatusArray"` + type: integer + DeploymentType: + $ref: "#/components/schemas/portainer.EdgeStackDeploymentType" + EdgeGroups: + items: + type: integer + type: array + EntryPoint: + type: string + Id: + description: EdgeStack Identifier + example: 1 + type: integer + ManifestPath: + type: string + Name: + type: string + NumDeployments: + type: integer + ProjectPath: + type: string + Status: + additionalProperties: + $ref: "#/components/schemas/portainer.EdgeStackStatus" + type: object + UseManifestNamespaces: + description: Uses the manifest's namespaces instead of the default one + type: boolean + Version: + type: integer + type: object + portainer.EdgeStackDeploymentStatus: + properties: + Error: + type: string + RollbackTo: + description: EE only feature + type: integer + Time: + format: int64 + type: integer + Type: + $ref: "#/components/schemas/portainer.EdgeStackStatusType" + Version: + type: integer + type: object + portainer.EdgeStackDeploymentType: + enum: + - 0 + - 1 + type: integer + x-enum-varnames: + - EdgeStackDeploymentCompose + - EdgeStackDeploymentKubernetes + portainer.EdgeStackStatus: + properties: + DeploymentInfo: + allOf: + - $ref: "#/components/schemas/portainer.StackDeploymentInfo" + description: EE only feature + Details: + allOf: + - $ref: "#/components/schemas/portainer.EdgeStackStatusDetails" + description: Deprecated + EndpointID: + type: integer + Error: + description: Deprecated + type: string + ReadyRePullImage: + description: ReadyRePullImage is a flag to indicate whether the auto update is + trigger to re-pull image + type: boolean + Status: + items: + $ref: "#/components/schemas/portainer.EdgeStackDeploymentStatus" + type: array + Type: + allOf: + - $ref: "#/components/schemas/portainer.EdgeStackStatusType" + description: Deprecated + type: object + portainer.EdgeStackStatusDetails: + properties: + Acknowledged: + type: boolean + Error: + type: boolean + ImagesPulled: + type: boolean + Ok: + type: boolean + Pending: + type: boolean + RemoteUpdateSuccess: + type: boolean + Remove: + type: boolean + type: object + portainer.EdgeStackStatusType: + enum: + - 0 + - 1 + - 2 + - 3 + - 4 + - 5 + - 6 + - 7 + - 8 + - 9 + - 10 + - 11 + - 12 + - 13 + type: integer + x-enum-varnames: + - EdgeStackStatusPending + - EdgeStackStatusDeploymentReceived + - EdgeStackStatusError + - EdgeStackStatusAcknowledged + - EdgeStackStatusRemoved + - EdgeStackStatusRemoteUpdateSuccess + - EdgeStackStatusImagesPulled + - EdgeStackStatusRunning + - EdgeStackStatusDeploying + - EdgeStackStatusRemoving + - EdgeStackStatusPausedDeploying + - EdgeStackStatusRollingBack + - EdgeStackStatusRolledBack + - EdgeStackStatusCompleted + portainer.Endpoint: + properties: + Agent: + $ref: "#/components/schemas/portainer.EnvironmentAgentData" + AzureCredentials: + $ref: "#/components/schemas/portainer.AzureCredentials" + ComposeSyntaxMaxVersion: + description: Maximum version of docker-compose + example: "3.8" + type: string + ContainerEngine: + description: ContainerEngine represents the container engine type. This can be + 'docker' or 'podman' when interacting directly with these + environments, otherwise '' for kubernetes environments. + example: docker + type: string + Edge: + $ref: "#/components/schemas/portainer.EnvironmentEdgeSettings" + EdgeCheckinInterval: + description: The check in interval for edge agent (in seconds) + example: 5 + type: integer + EdgeID: + description: The identifier of the edge agent associated with this + environment(endpoint) + type: string + EdgeKey: + description: The key which is used to map the agent to Portainer + type: string + EnableGPUManagement: + type: boolean + Gpus: + items: + $ref: "#/components/schemas/portainer.Pair" + type: array + GroupId: + description: Environment(Endpoint) group identifier + example: 1 + type: integer + Heartbeat: + description: Heartbeat indicates the heartbeat status of an edge environment + example: true + type: boolean + Id: + description: Environment(Endpoint) Identifier + example: 1 + type: integer + Kubernetes: + allOf: + - $ref: "#/components/schemas/portainer.KubernetesData" + description: Associated Kubernetes data + LastCheckInDate: + description: LastCheckInDate mark last check-in date on checkin + type: integer + Name: + description: Environment(Endpoint) name + example: my-environment + type: string + PublicURL: + description: URL or IP address where exposed containers will be reachable + example: docker.mydomain.tld:2375 + type: string + SecuritySettings: + allOf: + - $ref: "#/components/schemas/portainer.EndpointSecuritySettings" + description: Environment(Endpoint) specific security settings + Snapshots: + description: List of snapshots + items: + $ref: "#/components/schemas/portainer.DockerSnapshot" + type: array + Status: + allOf: + - $ref: "#/components/schemas/portainer.EndpointStatus" + description: The status of the environment(endpoint) (1 - up, 2 - down, 3 - + provisioning, 4 - error) + enum: + - 1 + - 2 + - 3 + - 4 + example: 1 + TLSConfig: + $ref: "#/components/schemas/portainer.TLSConfiguration" + TagIds: + description: List of tag identifiers to which this environment(endpoint) is + associated + items: + type: integer + type: array + TeamAccessPolicies: + allOf: + - $ref: "#/components/schemas/portainer.TeamAccessPolicies" + description: List of team identifiers authorized to connect to this + environment(endpoint) + Type: + allOf: + - $ref: "#/components/schemas/portainer.EndpointType" + description: Environment(Endpoint) environment(endpoint) type. 1 for a Docker + environment(endpoint), 2 for an agent on Docker + environment(endpoint) or 3 for an Azure environment(endpoint). + example: 1 + URL: + description: URL or IP address of the Docker host associated to this + environment(endpoint) + example: docker.mydomain.tld:2375 + type: string + UserAccessPolicies: + allOf: + - $ref: "#/components/schemas/portainer.UserAccessPolicies" + description: List of user identifiers authorized to connect to this + environment(endpoint) + UserTrusted: + description: Whether the device has been trusted or not by the user + type: boolean + required: + - Agent + - ComposeSyntaxMaxVersion + - ContainerEngine + - Edge + - EdgeCheckinInterval + - EdgeKey + - GroupId + - Id + - Kubernetes + - LastCheckInDate + - Name + - PublicURL + - SecuritySettings + - TLSConfig + - Type + - URL + type: object + portainer.EndpointGroup: + properties: + Description: + description: Description associated to the environment(endpoint) group + example: Environment(Endpoint) group description + type: string + Id: + description: Environment(Endpoint) group Identifier + example: 1 + type: integer + Name: + description: Environment(Endpoint) group name + example: my-environment-group + type: string + TagIds: + description: List of tags associated to this environment(endpoint) group + items: + type: integer + type: array + TeamAccessPolicies: + $ref: "#/components/schemas/portainer.TeamAccessPolicies" + UserAccessPolicies: + $ref: "#/components/schemas/portainer.UserAccessPolicies" + required: + - Description + - Id + - Name + type: object + portainer.EndpointSecuritySettings: + properties: + allowBindMountsForRegularUsers: + description: Whether non-administrator should be able to use bind mounts when + creating containers + example: false + type: boolean + allowContainerCapabilitiesForRegularUsers: + description: Whether non-administrator should be able to use container + capabilities + example: true + type: boolean + allowDeviceMappingForRegularUsers: + description: Whether non-administrator should be able to use device mapping + example: true + type: boolean + allowHostNamespaceForRegularUsers: + description: Whether non-administrator should be able to use the host pid + example: true + type: boolean + allowPrivilegedModeForRegularUsers: + description: Whether non-administrator should be able to use privileged mode + when creating containers + example: false + type: boolean + allowSecurityOptForRegularUsers: + description: Whether non-administrator should be able to use security-opt settings + example: true + type: boolean + allowStackManagementForRegularUsers: + description: Whether non-administrator should be able to manage stacks + example: true + type: boolean + allowSysctlSettingForRegularUsers: + description: Whether non-administrator should be able to use sysctl settings + example: true + type: boolean + allowVolumeBrowserForRegularUsers: + description: Whether non-administrator should be able to browse volumes + example: true + type: boolean + enableHostManagementFeatures: + description: Whether host management features are enabled + example: true + type: boolean + required: + - allowBindMountsForRegularUsers + - allowContainerCapabilitiesForRegularUsers + - allowDeviceMappingForRegularUsers + - allowHostNamespaceForRegularUsers + - allowPrivilegedModeForRegularUsers + - allowSecurityOptForRegularUsers + - allowStackManagementForRegularUsers + - allowSysctlSettingForRegularUsers + - allowVolumeBrowserForRegularUsers + - enableHostManagementFeatures + type: object + portainer.EndpointStatus: + enum: + - 0 + - 1 + - 2 + type: integer + x-enum-varnames: + - _ + - EndpointStatusUp + - EndpointStatusDown + portainer.EndpointType: + enum: + - 0 + - 1 + - 2 + - 3 + - 4 + - 5 + - 6 + - 7 + type: integer + x-enum-varnames: + - _ + - DockerEnvironment + - AgentOnDockerEnvironment + - AzureEnvironment + - EdgeAgentOnDockerEnvironment + - KubernetesLocalEnvironment + - AgentOnKubernetesEnvironment + - EdgeAgentOnKubernetesEnvironment + portainer.EnvironmentAgentData: + properties: + Version: + example: 1.0.0 + type: string + type: object + portainer.EnvironmentEdgeSettings: + properties: + AsyncMode: + description: Whether the device has been started in edge async mode + type: boolean + CommandInterval: + description: The command list interval for edge agent - used in edge async mode + [seconds] + example: 60 + type: integer + PingInterval: + description: The ping interval for edge agent - used in edge async mode [seconds] + example: 60 + type: integer + SnapshotInterval: + description: The snapshot interval for edge agent - used in edge async mode + [seconds] + example: 60 + type: integer + required: + - AsyncMode + - CommandInterval + - PingInterval + - SnapshotInterval + type: object + portainer.GithubRegistryData: + properties: + OrganisationName: + type: string + UseOrganisation: + type: boolean + type: object + portainer.GitlabRegistryData: + properties: + InstanceURL: + type: string + ProjectId: + type: integer + ProjectPath: + type: string + type: object + portainer.GlobalDeploymentOptions: + properties: + hideStacksFunctionality: + example: false + type: boolean + type: object + portainer.HelmConfig: + properties: + Atomic: + description: >- + Atomic enables automatic rollback on deployment failure (equivalent + to helm --atomic). + + Used by both git repo and Helm repository deployments. + example: true + type: boolean + ChartName: + description: |- + ChartName is the name of the Helm chart within the repository. + Required for Helm repository deployments. + example: nginx + type: string + ChartPath: + description: >- + ChartPath is the path to a Helm chart folder within the cloned git + repository. + + Used exclusively for git repo helm deployments. Mutually exclusive with ChartURL. + example: charts/my-app + type: string + ChartURL: + description: >- + ChartURL is the URL of a Helm chart repository. + + Used exclusively for Helm repository deployments. Mutually exclusive with ChartPath. + example: https://charts.bitnami.com/bitnami + type: string + ChartVersion: + description: >- + ChartVersion is the version of the Helm chart to deploy. Empty means + latest. + + Used exclusively for Helm repository deployments. + example: 15.0.0 + type: string + Namespace: + description: |- + Namespace is the Kubernetes namespace to deploy the Helm chart into. + Used by both git repo and Helm repository deployments. + example: default + type: string + Timeout: + description: >- + Timeout sets the deadline for Helm operations (equivalent to helm + --timeout, e.g. "5m0s"). + + Used by both git repo and Helm repository deployments. + example: 5m0s + type: string + ValuesFiles: + description: >- + ValuesFiles is a list of relative paths to Helm values YAML files + within the cloned git repository. + + Used exclusively for git repo helm deployments. + example: + - "['values/prod.yaml'" + - " 'values/secrets.yaml']" + items: + type: string + type: array + ValuesInline: + description: |- + ValuesInline is the inline YAML string of Helm values. + Used exclusively for Helm repository deployments. + example: "replicaCount: 2" + type: string + type: object + portainer.HelmUserRepository: + properties: + Id: + description: Membership Identifier + example: 1 + type: integer + URL: + description: Helm repository URL + example: https://charts.bitnami.com/bitnami + type: string + UserId: + description: User identifier + example: 1 + type: integer + type: object + portainer.InternalAuthSettings: + properties: + RequiredPasswordLength: + type: integer + type: object + portainer.K8sNamespaceInfo: + properties: + Annotations: + additionalProperties: + type: string + type: object + CreationDate: + type: string + Id: + type: string + IsDefault: + type: boolean + IsSystem: + type: boolean + Name: + type: string + NamespaceOwner: + type: string + ResourceQuota: + $ref: "#/components/schemas/v1.ResourceQuota" + Status: + $ref: "#/components/schemas/v1.NamespaceStatus" + UnhealthyEventCount: + type: integer + type: object + portainer.K8sNodeLimits: + properties: + CPU: + type: integer + Memory: + type: integer + type: object + portainer.K8sNodesLimits: + additionalProperties: + $ref: "#/components/schemas/portainer.K8sNodeLimits" + type: object + portainer.KubernetesConfiguration: + properties: + AllowNoneIngressClass: + type: boolean + EnableResourceOverCommit: + type: boolean + IngressAvailabilityPerNamespace: + type: boolean + IngressClasses: + items: + $ref: "#/components/schemas/portainer.KubernetesIngressClassConfig" + type: array + ResourceOverCommitPercentage: + type: integer + RestrictDefaultNamespace: + type: boolean + StorageClasses: + items: + $ref: "#/components/schemas/portainer.KubernetesStorageClassConfig" + type: array + UseLoadBalancer: + type: boolean + UseServerMetrics: + type: boolean + required: + - AllowNoneIngressClass + - IngressAvailabilityPerNamespace + type: object + portainer.KubernetesData: + properties: + Configuration: + $ref: "#/components/schemas/portainer.KubernetesConfiguration" + Flags: + $ref: "#/components/schemas/portainer.KubernetesFlags" + Snapshots: + items: + $ref: "#/components/schemas/portainer.KubernetesSnapshot" + type: array + required: + - Configuration + - Flags + type: object + portainer.KubernetesFlags: + properties: + GPUOperator: + type: boolean + IsServerIngressClassDetected: + type: boolean + IsServerMetricsDetected: + type: boolean + IsServerStorageDetected: + type: boolean + required: + - IsServerIngressClassDetected + - IsServerMetricsDetected + - IsServerStorageDetected + type: object + portainer.KubernetesIngressClassConfig: + properties: + Blocked: + type: boolean + BlockedNamespaces: + items: + type: string + type: array + Name: + type: string + Type: + type: string + required: + - Name + - Type + type: object + portainer.KubernetesSnapshot: + properties: + ClusterType: + type: string + DiagnosticsData: + $ref: "#/components/schemas/portainer.DiagnosticsData" + GPUNodeCount: + type: integer + KubernetesVersion: + type: string + NodeCount: + type: integer + PerformanceMetrics: + $ref: "#/components/schemas/portainer.PerformanceMetrics" + Time: + type: integer + TotalCPU: + type: integer + TotalGPU: + additionalProperties: + format: int64 + type: integer + type: object + TotalMemory: + type: integer + required: + - KubernetesVersion + - NodeCount + - Time + - TotalCPU + - TotalMemory + type: object + portainer.KubernetesStorageClassConfig: + properties: + AccessModes: + items: + type: string + type: array + AllowVolumeExpansion: + type: boolean + Name: + type: string + Provisioner: + type: string + required: + - AllowVolumeExpansion + - Name + - Provisioner + type: object + portainer.LDAPGroupSearchSettings: + properties: + GroupAttribute: + description: LDAP attribute which denotes the group membership + example: member + type: string + GroupBaseDN: + description: The distinguished name of the element from which the LDAP server + will search for groups + example: dc=ldap,dc=domain,dc=tld + type: string + GroupFilter: + description: The LDAP search filter used to select group elements, optional + example: (objectClass=account + type: string + type: object + portainer.LDAPSearchSettings: + properties: + BaseDN: + description: The distinguished name of the element from which the LDAP server + will search for users + example: dc=ldap,dc=domain,dc=tld + type: string + Filter: + description: Optional LDAP search filter used to select user elements + example: (objectClass=account) + type: string + UserNameAttribute: + description: LDAP attribute which denotes the username + example: uid + type: string + type: object + portainer.LDAPSettings: + properties: + AnonymousMode: + description: Enable this option if the server is configured for Anonymous + access. When enabled, ReaderDN and Password will not be used + example: true + type: boolean + AutoCreateUsers: + description: Automatically provision users and assign them to matching LDAP + group names + example: true + type: boolean + GroupSearchSettings: + items: + $ref: "#/components/schemas/portainer.LDAPGroupSearchSettings" + type: array + Password: + description: Password of the account that will be used to search users + example: readonly-password + type: string + ReaderDN: + description: Account that will be used to search for users + example: cn=readonly-account,dc=ldap,dc=domain,dc=tld + type: string + SearchSettings: + items: + $ref: "#/components/schemas/portainer.LDAPSearchSettings" + type: array + StartTLS: + description: Whether LDAP connection should use StartTLS + example: true + type: boolean + TLSConfig: + $ref: "#/components/schemas/portainer.TLSConfiguration" + URL: + description: URL or IP address of the LDAP server + example: myldap.domain.tld:389 + type: string + type: object + portainer.MembershipRole: + enum: + - 0 + - 1 + - 2 + type: integer + x-enum-varnames: + - _ + - TeamLeader + - TeamMember + portainer.OAuthSettings: + properties: + AccessTokenURI: + type: string + AuthStyle: + $ref: "#/components/schemas/oauth2.AuthStyle" + AuthorizationURI: + type: string + ClientID: + type: string + ClientSecret: + type: string + DefaultTeamID: + type: integer + KubeSecretKey: + items: + type: integer + type: array + LogoutURI: + type: string + OAuthAutoCreateUsers: + type: boolean + RedirectURI: + type: string + ResourceURI: + type: string + SSO: + type: boolean + Scopes: + type: string + UserIdentifier: + type: string + type: object + portainer.Pair: + properties: + name: + example: name + type: string + value: + example: value + type: string + required: + - name + - value + type: object + portainer.PerformanceMetrics: + properties: + CPUUsage: + type: number + DiskUsage: + type: number + MemoryUsage: + type: number + NetworkUsage: + type: number + type: object + portainer.QuayRegistryData: + properties: + OrganisationName: + type: string + UseOrganisation: + type: boolean + type: object + portainer.Registry: + properties: + AccessToken: + description: Stores temporary access token + type: string + AccessTokenExpiry: + type: integer + Authentication: + description: Is authentication against this registry enabled + example: true + type: boolean + AuthorizedTeams: + description: Deprecated in DBVersion == 18 + items: + type: integer + type: array + AuthorizedUsers: + description: Deprecated in DBVersion == 18 + items: + type: integer + type: array + BaseURL: + description: Base URL, introduced for ProGet registry + example: registry.mydomain.tld:2375 + type: string + Ecr: + $ref: "#/components/schemas/portainer.EcrData" + Github: + $ref: "#/components/schemas/portainer.GithubRegistryData" + Gitlab: + $ref: "#/components/schemas/portainer.GitlabRegistryData" + Id: + description: Registry Identifier + example: 1 + type: integer + ManagementConfiguration: + $ref: "#/components/schemas/portainer.RegistryManagementConfiguration" + Name: + description: Registry Name + example: my-registry + type: string + Password: + description: Password or SecretAccessKey used to authenticate against this + registry + example: registry_password + type: string + Quay: + $ref: "#/components/schemas/portainer.QuayRegistryData" + RegistryAccesses: + $ref: "#/components/schemas/portainer.RegistryAccesses" + TeamAccessPolicies: + allOf: + - $ref: "#/components/schemas/portainer.TeamAccessPolicies" + description: Deprecated in DBVersion == 31 + Type: + allOf: + - $ref: "#/components/schemas/portainer.RegistryType" + description: Registry Type (1 - Quay, 2 - Azure, 3 - Custom, 4 - Gitlab, 5 - + ProGet, 6 - DockerHub, 7 - ECR) + enum: + - 1 + - 2 + - 3 + - 4 + - 5 + - 6 + - 7 + URL: + description: URL or IP address of the Docker registry + example: registry.mydomain.tld:2375 + type: string + UserAccessPolicies: + allOf: + - $ref: "#/components/schemas/portainer.UserAccessPolicies" + description: |- + Deprecated fields + Deprecated in DBVersion == 31 + Username: + description: Username or AccessKeyID used to authenticate against this registry + example: registry user + type: string + type: object + portainer.RegistryAccessPolicies: + properties: + Namespaces: + description: Kubernetes specific fields (with kubernetes, namespaces have access + to a registry, if users/teams have access to the same namespace, + they have access to the registry) + items: + type: string + type: array + TeamAccessPolicies: + $ref: "#/components/schemas/portainer.TeamAccessPolicies" + UserAccessPolicies: + allOf: + - $ref: "#/components/schemas/portainer.UserAccessPolicies" + description: Docker specific fields (with docker, users/teams have access to a + registry) + type: object + portainer.RegistryAccesses: + additionalProperties: + $ref: "#/components/schemas/portainer.RegistryAccessPolicies" + type: object + portainer.RegistryManagementConfiguration: + properties: + AccessToken: + type: string + AccessTokenExpiry: + type: integer + Authentication: + type: boolean + Ecr: + $ref: "#/components/schemas/portainer.EcrData" + Password: + type: string + TLSConfig: + $ref: "#/components/schemas/portainer.TLSConfiguration" + Type: + $ref: "#/components/schemas/portainer.RegistryType" + Username: + type: string + type: object + portainer.RegistryType: + enum: + - 0 + - 1 + - 2 + - 3 + - 4 + - 5 + - 6 + - 7 + - 8 + type: integer + x-enum-varnames: + - _ + - QuayRegistry + - AzureRegistry + - CustomRegistry + - GitlabRegistry + - ProGetRegistry + - DockerHubRegistry + - EcrRegistry + - GithubRegistry + portainer.ResourceAccessLevel: + enum: + - 0 + - 1 + type: integer + x-enum-varnames: + - _ + - ReadWriteAccessLevel + portainer.ResourceControl: + properties: + AccessLevel: + $ref: "#/components/schemas/portainer.ResourceAccessLevel" + AdministratorsOnly: + description: Permit access to resource only to admins + example: true + type: boolean + Id: + description: ResourceControl Identifier + example: 1 + type: integer + OwnerId: + description: |- + Deprecated fields + Deprecated in DBVersion == 2 + type: integer + Public: + description: Permit access to the associated resource to any user + example: true + type: boolean + ResourceId: + 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 + example: 617c5f22bb9b023d6daab7cba43a57576f83492867bc767d1c59416b065e5f08 + type: string + SubResourceIds: + description: List of Docker resources that will inherit this access control + example: + - 617c5f22bb9b023d6daab7cba43a57576f83492867bc767d1c59416b065e5f08 + items: + type: string + type: array + System: + type: boolean + TeamAccesses: + items: + $ref: "#/components/schemas/portainer.TeamResourceAccess" + type: array + Type: + allOf: + - $ref: "#/components/schemas/portainer.ResourceControlType" + description: |- + Type of Docker resource. Valid values are: 1- container, 2 -service + 3 - volume, 4 - secret, 5 - stack, 6 - config or 7 - custom template + example: 1 + UserAccesses: + items: + $ref: "#/components/schemas/portainer.UserResourceAccess" + type: array + type: object + portainer.ResourceControlType: + enum: + - 0 + - 1 + - 2 + - 3 + - 4 + - 5 + - 6 + - 7 + - 8 + - 9 + type: integer + x-enum-varnames: + - _ + - ContainerResourceControl + - ServiceResourceControl + - VolumeResourceControl + - NetworkResourceControl + - SecretResourceControl + - StackResourceControl + - ConfigResourceControl + - CustomTemplateResourceControl + - ContainerGroupResourceControl + portainer.Role: + properties: + Authorizations: + allOf: + - $ref: "#/components/schemas/portainer.Authorizations" + description: Authorizations associated to a role + Description: + description: Role description + example: Read-only access of all resources in an environment(endpoint) + type: string + Id: + description: Role Identifier + example: 1 + type: integer + Name: + description: Role name + example: HelpDesk + type: string + Priority: + type: integer + type: object + portainer.SSLSettings: + properties: + certPath: + type: string + httpEnabled: + type: boolean + keyPath: + type: string + selfSigned: + type: boolean + type: object + portainer.Settings: + properties: + AgentSecret: + description: Container environment parameter AGENT_SECRET + type: string + AllowBindMountsForRegularUsers: + type: boolean + AllowContainerCapabilitiesForRegularUsers: + type: boolean + AllowDeviceMappingForRegularUsers: + type: boolean + AllowHostNamespaceForRegularUsers: + type: boolean + AllowPrivilegedModeForRegularUsers: + type: boolean + AllowStackManagementForRegularUsers: + type: boolean + AllowVolumeBrowserForRegularUsers: + type: boolean + AuthenticationMethod: + allOf: + - $ref: "#/components/schemas/portainer.AuthenticationMethod" + description: "Active authentication method for the Portainer instance. Valid + values are: 1 for internal, 2 for LDAP, or 3 for oauth" + example: 1 + BlackListedLabels: + description: A list of label name & value that will be used to hide containers + when querying containers + items: + $ref: "#/components/schemas/portainer.Pair" + type: array + DisplayDonationHeader: + description: Deprecated fields + type: boolean + DisplayExternalContributors: + type: boolean + Edge: + $ref: "#/components/schemas/portainer.Edge" + EdgeAgentCheckinInterval: + description: The default check in interval for edge agent (in seconds) + example: 5 + type: integer + EdgePortainerUrl: + description: EdgePortainerURL is the URL that is exposed to edge agents + type: string + EnableEdgeComputeFeatures: + description: Whether edge compute features are enabled + type: boolean + EnableHostManagementFeatures: + description: Deprecated fields v26 + type: boolean + EnforceEdgeID: + description: EnforceEdgeID makes Portainer store the Edge ID instead of + accepting anyone + example: false + type: boolean + FeatureFlagSettings: + additionalProperties: + type: boolean + type: object + ForceSecureCookies: + description: >- + ForceSecureCookies forces the Secure attribute on auth cookies + regardless of detected scheme. + + Enable when Portainer runs behind a TLS-terminating proxy. + example: false + type: boolean + GlobalDeploymentOptions: + allOf: + - $ref: "#/components/schemas/portainer.GlobalDeploymentOptions" + description: Deployment options for encouraging git ops workflows + HelmRepositoryURL: + description: Helm repository URL, defaults to "https://charts.bitnami.com/bitnami" + example: https://charts.bitnami.com/bitnami + type: string + InternalAuthSettings: + $ref: "#/components/schemas/portainer.InternalAuthSettings" + IsDockerDesktopExtension: + type: boolean + KubeconfigExpiry: + description: The expiry of a Kubeconfig + example: 24h + type: string + KubectlShellImage: + description: KubectlImage, defaults to portainer/kubectl-shell + example: portainer/kubectl-shell + type: string + LDAPSettings: + $ref: "#/components/schemas/portainer.LDAPSettings" + LogoURL: + 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 + example: https://mycompany.mydomain.tld/logo.png + type: string + OAuthSettings: + $ref: "#/components/schemas/portainer.OAuthSettings" + SnapshotInterval: + description: The interval in which environment(endpoint) snapshots are created + example: 5m + type: string + TemplatesURL: + description: URL to the templates that will be displayed in the UI when + navigating to App Templates + example: https://raw.githubusercontent.com/portainer/templates/master/templates.json + type: string + TrustOnFirstConnect: + description: TrustOnFirstConnect makes Portainer accepting edge agent connection + by default + example: false + type: boolean + UserSessionTimeout: + description: The duration of a user session + example: 5m + type: string + type: object + portainer.Source: + properties: + administratorsOnly: + type: boolean + git: + $ref: "#/components/schemas/gittypes.GitSource" + helm: + $ref: "#/components/schemas/portainer.HelmConfig" + id: + example: 1 + type: integer + lastSync: + example: 1587399600 + type: integer + name: + example: my-source + type: string + ownerID: + type: integer + public: + type: boolean + registry: + $ref: "#/components/schemas/portainer.Registry" + status: + $ref: "#/components/schemas/portainer.SourceStatus" + statusError: + type: string + teamAccesses: + items: + type: integer + type: array + type: + allOf: + - $ref: "#/components/schemas/portainer.SourceType" + example: 1 + userAccesses: + items: + type: integer + type: array + type: object + portainer.SourceStatus: + enum: + - 0 + - 1 + - 2 + type: integer + x-enum-varnames: + - SourceStatusUnknown + - SourceStatusHealthy + - SourceStatusError + portainer.SourceType: + enum: + - 0 + - 1 + - 2 + - 3 + type: integer + x-enum-varnames: + - _ + - SourceTypeGit + - SourceTypeRegistry + - SourceTypeHelm + portainer.Stack: + properties: + AdditionalFiles: + description: Only applies when deploying stack with multiple files + items: + type: string + type: array + AutoUpdate: + allOf: + - $ref: "#/components/schemas/portainer.AutoUpdateSettings" + description: The GitOps update settings of a git stack + CreatedBy: + description: The username which created this stack + example: admin + type: string + CreationDate: + description: The date in unix time when stack was created + example: 1587399600 + type: integer + CurrentDeploymentInfo: + allOf: + - $ref: "#/components/schemas/portainer.StackDeploymentInfo" + description: CurrentDeploymentInfo records the git repository state at the time + of the last actual deployment. + DeploymentStartStatus: + allOf: + - $ref: "#/components/schemas/portainer.StackStatus" + description: >- + DeploymentStartStatus is the stack status captured when the current + + deployment starts. It is used by deployment logic during the current + + deployment attempt and is cleared/replaced when a new deployment begins. + example: 1 + DeploymentStatus: + description: >- + DeploymentStatus records the status progression of the current + deployment. + + Cleared when a new deployment starts. + items: + $ref: "#/components/schemas/portainer.StackDeploymentStatus" + type: array + EndpointId: + description: Environment(Endpoint) identifier. Reference the + environment(endpoint) that will be used for deployment + example: 1 + type: integer + EntryPoint: + description: >- + EntryPoint is the path to the config file relative to the project + root. + + NOTE: For git stacks this mirrors GitConfig.ConfigFilePath and the two are kept in sync + + by stackUpdateGit. The deploy command builder (compose_unpacker_cmd_builder) uses this + + field directly; Kubernetes deploy and git clone operations use GitConfig.ConfigFilePath. + example: docker-compose.yml + type: string + Env: + description: A list of environment(endpoint) variables used during stack + deployment + items: + $ref: "#/components/schemas/portainer.Pair" + type: array + FromAppTemplate: + description: Whether the stack is from a app template + example: false + type: boolean + GitConfig: + allOf: + - $ref: "#/components/schemas/gittypes.RepoConfig" + description: >- + GitConfig is the git repository configuration for git-backed stacks. + + Deprecated: loaded from Source via WorkflowID; kept for DB backwards-compatibility only. + + Non-migration code must not read or write this field; use Source records instead. + Id: + description: Stack Identifier + example: 1 + type: integer + Name: + description: Stack name + example: myStack + type: string + Namespace: + description: Kubernetes namespace if stack is a kube application + example: default + type: string + Option: + allOf: + - $ref: "#/components/schemas/portainer.StackOption" + description: The stack deployment option + ProjectPath: + description: Path on disk to the repository hosting the Stack file + example: /data/compose/myStack_jpofkc0i9uo9wtx1zesuk649w + type: string + ResourceControl: + $ref: "#/components/schemas/portainer.ResourceControl" + Status: + allOf: + - $ref: "#/components/schemas/portainer.StackStatus" + description: Stack status (1 - active, 2 - inactive, 3 - deploying, 4 - error) + example: 1 + SwarmId: + description: Cluster identifier of the Swarm cluster where the stack is deployed + example: jpofkc0i9uo9wtx1zesuk649w + type: string + Type: + allOf: + - $ref: "#/components/schemas/portainer.StackType" + description: Stack type. 1 for a Swarm stack, 2 for a Compose stack + example: 2 + UpdateDate: + description: The date in unix time when stack was last updated + example: 1587399600 + type: integer + UpdatedBy: + description: The username which last updated this stack + example: bob + type: string + WorkflowID: + description: WorkflowID is the ID of the Workflow that owns the Source for this + stack. + type: integer + type: object + portainer.StackDeploymentInfo: + properties: + AdditionalFiles: + description: AdditionalFiles are the additional files used for deploying the stack + items: + type: string + type: array + ConfigFilePath: + description: ConfigFilePath is the path to the config file in the git repository + used for deploying the stack + type: string + ConfigHash: + description: ConfigHash is the commit hash of the git repository used for + deploying the stack + type: string + FileVersion: + description: FileVersion is the version of the stack file, used to detect changes + type: integer + ReferenceName: + description: ReferenceName is the git reference (branch/tag) used for deploying + the stack + type: string + RepositoryURL: + description: RepositoryURL is the git repository URL used for deploying the stack + type: string + SourceID: + description: SourceID is the Source used for deploying the stack + type: integer + Version: + description: Version is the version of the stack and also is the deployed + version in edge agent + type: integer + type: object + portainer.StackDeploymentStatus: + properties: + Message: + description: populated on Error entries + type: string + Status: + $ref: "#/components/schemas/portainer.StackStatus" + Time: + type: integer + type: object + portainer.StackOption: + properties: + HelmAtomic: + description: Enable atomic rollback on failure (Helm --atomic flag for + Kubernetes Helm stacks) + example: false + type: boolean + Prune: + description: Prune services that are no longer referenced + example: false + type: boolean + type: object + portainer.StackStatus: + enum: + - 0 + - 1 + - 2 + - 3 + - 4 + type: integer + x-enum-comments: + StackStatusActive: 1 - deployed and running + StackStatusDeploying: 3 - deployment in progress + StackStatusError: 4 - deployment failed + StackStatusInactive: 2 - intentionally stopped + x-enum-descriptions: + - "" + - 1 - deployed and running + - 2 - intentionally stopped + - 3 - deployment in progress + - 4 - deployment failed + x-enum-varnames: + - _ + - StackStatusActive + - StackStatusInactive + - StackStatusDeploying + - StackStatusError + portainer.StackType: + enum: + - 0 + - 1 + - 2 + - 3 + type: integer + x-enum-varnames: + - _ + - DockerSwarmStack + - DockerComposeStack + - KubernetesStack + portainer.TLSConfiguration: + properties: + TLS: + description: Use TLS + example: true + type: boolean + TLSCACert: + description: Path to the TLS CA certificate file + example: /data/tls/ca.pem + type: string + TLSCert: + description: Path to the TLS client certificate file + example: /data/tls/cert.pem + type: string + TLSKey: + description: Path to the TLS client key file + example: /data/tls/key.pem + type: string + TLSSkipVerify: + description: Skip the verification of the server TLS certificate + example: false + type: boolean + required: + - TLS + - TLSSkipVerify + type: object + portainer.Tag: + properties: + EndpointGroups: + additionalProperties: + type: boolean + description: A set of environment(endpoint) group ids that have this tag + type: object + Endpoints: + additionalProperties: + type: boolean + description: A set of environment(endpoint) ids that have this tag + type: object + ID: + description: Tag identifier + example: 1 + type: integer + Name: + description: Tag name + example: org/acme + type: string + type: object + portainer.Team: + properties: + Id: + description: Team Identifier + example: 1 + type: integer + Name: + description: Team name + example: developers + type: string + type: object + portainer.TeamAccessPolicies: + additionalProperties: + $ref: "#/components/schemas/portainer.AccessPolicy" + type: object + portainer.TeamMembership: + properties: + Id: + description: Membership Identifier + example: 1 + type: integer + Role: + allOf: + - $ref: "#/components/schemas/portainer.MembershipRole" + description: Team role (1 for team leader and 2 for team member) + example: 1 + TeamID: + description: Team identifier + example: 1 + type: integer + UserID: + description: User identifier + example: 1 + type: integer + type: object + portainer.TeamResourceAccess: + properties: + AccessLevel: + $ref: "#/components/schemas/portainer.ResourceAccessLevel" + TeamId: + type: integer + type: object + portainer.Template: + properties: + administrator_only: + description: Whether the template should be available to administrators only + example: true + type: boolean + categories: + description: A list of categories associated to the template + example: + - database + items: + type: string + type: array + command: + description: The command that will be executed in a container template + example: ls -lah + type: string + description: + description: Description of the template + example: High performance web server + type: string + env: + description: A list of environment(endpoint) variables used during the template + deployment + items: + $ref: "#/components/schemas/portainer.TemplateEnv" + type: array + hostname: + description: Container hostname + example: mycontainer + type: string + id: + description: |- + Mandatory container/stack fields + Template Identifier + example: 1 + type: integer + image: + description: >- + Mandatory container fields + + Image associated to a container template. Mandatory for a container template + example: nginx:latest + type: string + interactive: + description: |- + Whether the container should be started in + interactive mode (-i -t equivalent on the CLI) + example: true + type: boolean + labels: + description: Container labels + items: + $ref: "#/components/schemas/portainer.Pair" + type: array + logo: + description: URL of the template's logo + example: https://portainer.io/img/logo.svg + type: string + name: + description: |- + Optional stack/container fields + Default name for the stack/container to be used on deployment + example: mystackname + type: string + network: + description: Name of a network that will be used on container deployment if it + exists inside the environment(endpoint) + example: mynet + type: string + note: + description: A note that will be displayed in the UI. Supports HTML content + example: This is my custom template + type: string + platform: + description: >- + Platform associated to the template. + + Valid values are: 'linux', 'windows' or leave empty for multi-platform + example: linux + type: string + ports: + description: A list of ports exposed by the container + example: + - 8080:80/tcp + items: + type: string + type: array + privileged: + description: Whether the container should be started in privileged mode + example: true + type: boolean + registry: + description: >- + Optional container fields + + The URL of a registry associated to the image for a container template + example: quay.io + type: string + repository: + allOf: + - $ref: "#/components/schemas/portainer.TemplateRepository" + description: Mandatory stack fields + restart_policy: + description: Container restart policy + example: on-failure + type: string + stackFile: + description: |- + Mandatory Edge stack fields + Stack file used for this template + type: string + title: + description: Title of the template + example: Nginx + type: string + type: + allOf: + - $ref: "#/components/schemas/portainer.TemplateType" + description: "Template type. Valid values are: 1 (container), 2 (Swarm stack), 3 + (Compose stack), 4 (Compose edge stack)" + example: 1 + volumes: + description: A list of volumes used during the container template deployment + items: + $ref: "#/components/schemas/portainer.TemplateVolume" + type: array + type: object + portainer.TemplateEnv: + properties: + default: + description: Default value that will be set for the variable + example: default_value + type: string + description: + description: Content of the tooltip that will be generated in the UI + example: MySQL root account password + type: string + label: + description: Text for the label that will be generated in the UI + example: Root password + type: string + name: + description: name of the environment(endpoint) variable + example: MYSQL_ROOT_PASSWORD + type: string + preset: + description: If set to true, will not generate any input for this variable in + the UI + example: false + type: boolean + select: + description: A list of name/value that will be used to generate a dropdown in + the UI + items: + $ref: "#/components/schemas/portainer.TemplateEnvSelect" + type: array + type: object + portainer.TemplateEnvSelect: + properties: + default: + description: Will set this choice as the default choice + example: false + type: boolean + text: + description: Some text that will displayed as a choice + example: text value + type: string + value: + description: A value that will be associated to the choice + example: value + type: string + type: object + portainer.TemplateRepository: + properties: + stackfile: + description: Path to the stack file inside the git repository + example: ./subfolder/docker-compose.yml + type: string + url: + description: URL of a git repository used to deploy a stack template. Mandatory + for a Swarm/Compose stack template + example: https://github.com/portainer/portainer-compose + type: string + type: object + portainer.TemplateType: + enum: + - 0 + - 1 + - 2 + - 3 + type: integer + x-enum-varnames: + - _ + - ContainerTemplate + - SwarmStackTemplate + - ComposeStackTemplate + portainer.TemplateVolume: + properties: + bind: + description: Path on the host + example: /tmp + type: string + container: + description: Path inside the container + example: /data + type: string + readonly: + description: Whether the volume used should be readonly + example: true + type: boolean + type: object + portainer.User: + properties: + Id: + description: User Identifier + example: 1 + type: integer + Role: + allOf: + - $ref: "#/components/schemas/portainer.UserRole" + description: User role (1 for administrator account and 2 for regular account) + example: 1 + ThemeSettings: + $ref: "#/components/schemas/portainer.UserThemeSettings" + TokenIssueAt: + example: 1 + type: integer + UseCache: + example: true + type: boolean + Username: + example: bob + type: string + required: + - Id + - Role + - Username + type: object + portainer.UserAccessPolicies: + additionalProperties: + $ref: "#/components/schemas/portainer.AccessPolicy" + type: object + portainer.UserResourceAccess: + properties: + AccessLevel: + $ref: "#/components/schemas/portainer.ResourceAccessLevel" + UserId: + type: integer + type: object + portainer.UserRole: + enum: + - 0 + - 1 + - 2 + type: integer + x-enum-varnames: + - _ + - AdministratorRole + - StandardUserRole + portainer.UserThemeSettings: + properties: + color: + description: Color represents the color theme of the UI + enum: + - dark + - light + - highcontrast + - auto + - "" + example: dark + type: string + type: object + portainer.Webhook: + properties: + EndpointId: + type: integer + Id: + description: Webhook Identifier + example: 1 + type: integer + RegistryId: + type: integer + ResourceId: + type: string + Token: + type: string + Type: + allOf: + - $ref: "#/components/schemas/portainer.WebhookType" + description: Type of webhook (1 - service) + type: object + portainer.WebhookType: + enum: + - 0 + - 1 + type: integer + x-enum-varnames: + - _ + - ServiceWebhook + registries.registryConfigurePayload: + properties: + Authentication: + description: Is authentication against this registry enabled + example: false + type: boolean + Password: + description: Password used to authenticate against this registry. required when + Authentication is true + example: registry_password + type: string + Region: + description: ECR region + type: string + TLS: + description: Use TLS + example: true + type: boolean + TLSCACertFile: + description: The TLS CA certificate file + items: + format: int32 + type: integer + type: array + TLSCertFile: + description: The TLS client certificate file + items: + format: int32 + type: integer + type: array + TLSKeyFile: + description: The TLS client key file + items: + format: int32 + type: integer + type: array + TLSSkipVerify: + description: Skip the verification of the server TLS certificate + example: false + type: boolean + Username: + description: Username used to authenticate against this registry. Required when + Authentication is true + example: registry_user + type: string + required: + - Authentication + type: object + registries.registryCreatePayload: + properties: + Authentication: + description: Is authentication against this registry enabled + example: false + type: boolean + BaseURL: + description: BaseURL required for ProGet registry + example: registry.mydomain.tld:2375 + type: string + Ecr: + allOf: + - $ref: "#/components/schemas/portainer.EcrData" + description: ECR specific details, required when type = 7 + Gitlab: + allOf: + - $ref: "#/components/schemas/portainer.GitlabRegistryData" + description: Gitlab specific details, required when type = 4 + Name: + description: Name that will be used to identify this registry + example: my-registry + type: string + Password: + description: Password used to authenticate against this registry. required when + Authentication is true + example: registry_password + type: string + Quay: + allOf: + - $ref: "#/components/schemas/portainer.QuayRegistryData" + description: Quay specific details, required when type = 1 + TLS: + description: Use TLS + example: true + type: boolean + Type: + allOf: + - $ref: "#/components/schemas/portainer.RegistryType" + description: |- + Registry Type. Valid values are: + 1 (Quay.io), + 2 (Azure container registry), + 3 (custom registry), + 4 (Gitlab registry), + 5 (ProGet registry), + 6 (DockerHub) + 7 (ECR) + enum: + - 1 + - 2 + - 3 + - 4 + - 5 + - 6 + - 7 + example: 1 + URL: + description: URL or IP address of the Docker registry + example: registry.mydomain.tld:2375/feed + type: string + Username: + description: Username used to authenticate against this registry. Required when + Authentication is true + example: registry_user + type: string + required: + - Authentication + - Name + - Type + - URL + type: object + registries.registryPingPayload: + properties: + Password: + description: Password used to authenticate against this registry + example: registry_password + type: string + TLS: + description: Use TLS + example: true + type: boolean + Type: + allOf: + - $ref: "#/components/schemas/portainer.RegistryType" + description: |- + Registry Type. Valid values are: + 1 (Quay.io), + 2 (Azure container registry), + 3 (custom registry), + 4 (Gitlab registry), + 5 (ProGet registry), + 6 (DockerHub) + 7 (ECR) + 8 (Github registry) + enum: + - 1 + - 2 + - 3 + - 4 + - 5 + - 6 + - 7 + - 8 + example: 6 + URL: + description: URL or IP address of the Docker registry + example: registry-1.docker.io + type: string + Username: + description: Username used to authenticate against this registry + example: registry_user + type: string + required: + - Type + - URL + type: object + registries.registryPingResponse: + properties: + message: + description: Message provides details about the connection test result + example: Registry connection successful + type: string + success: + description: Success indicates if the registry connection was successful + example: true + type: boolean + type: object + registries.registryUpdatePayload: + properties: + Authentication: + description: Is authentication against this registry enabled + example: false + type: boolean + BaseURL: + description: BaseURL is used for quay registry + example: registry.mydomain.tld:2375 + type: string + Ecr: + allOf: + - $ref: "#/components/schemas/portainer.EcrData" + description: ECR data + Name: + description: Name that will be used to identify this registry + example: my-registry + type: string + Password: + description: Password used to authenticate against this registry. required when + Authentication is true + example: registry_password + type: string + Quay: + allOf: + - $ref: "#/components/schemas/portainer.QuayRegistryData" + description: Quay data + RegistryAccesses: + allOf: + - $ref: "#/components/schemas/portainer.RegistryAccesses" + description: Registry access control + URL: + description: URL or IP address of the Docker registry + example: registry.mydomain.tld:2375 + type: string + Username: + description: Username used to authenticate against this registry. Required when + Authentication is true + example: registry_user + type: string + required: + - Authentication + - Name + - URL + type: object + release.Chart: + properties: + files: + description: |- + Files are miscellaneous files in a chart archive, + e.g. README, LICENSE, etc. + items: + $ref: "#/components/schemas/release.File" + type: array + lock: + allOf: + - $ref: "#/components/schemas/release.Lock" + description: Lock is the contents of Chart.lock. + metadata: + allOf: + - $ref: "#/components/schemas/release.Metadata" + description: Metadata is the contents of the Chartfile. + schema: + description: Schema is an optional JSON schema for imposing structure on Values + items: + type: integer + type: array + templates: + description: Templates for this chart. + items: + $ref: "#/components/schemas/release.File" + type: array + values: + additionalProperties: {} + description: Values are default config for this chart. + type: object + type: object + release.ChartReference: + properties: + chartPath: + type: string + registryID: + type: integer + repoURL: + type: string + type: object + release.Dependency: + properties: + alias: + description: Alias usable alias to be used for the chart + type: string + condition: + description: A yaml path that resolves to a boolean, used for enabling/disabling + charts (e.g. subchart1.enabled ) + type: string + enabled: + description: Enabled bool determines if chart should be loaded + type: boolean + import-values: + description: >- + ImportValues holds the mapping of source values to parent key to be + imported. Each item can be a + + string or pair of child/parent sublist items. + items: {} + type: array + name: + description: |- + Name is the name of the dependency. + + This must mach the name in the dependency's Chart.yaml. + type: string + repository: + description: >- + The URL to the repository. + + + Appending `index.yaml` to this string should result in a URL that can be + + used to fetch the repository index. + type: string + tags: + description: Tags can be used to group charts for enabling/disabling together + items: + type: string + type: array + version: + description: |- + Version is the version (range) of this chart. + + A lock file will always produce a single version, while a dependency + may contain a semantic version range. + type: string + type: object + release.File: + properties: + data: + description: Data is the template as byte data. + items: + type: integer + type: array + name: + description: Name is the path-like name of the template. + type: string + type: object + release.HookExecution: + properties: + completed_at: + description: CompletedAt indicates the date/time this hook was completed. + type: string + phase: + description: Phase indicates whether the hook completed successfully + type: string + started_at: + description: StartedAt indicates the date/time this hook was started + type: string + type: object + release.Info: + properties: + deleted: + description: Deleted tracks when this object was deleted. + type: string + description: + description: Description is human-friendly "log entry" about this release. + type: string + first_deployed: + description: FirstDeployed is when the release was first deployed. + type: string + last_deployed: + description: LastDeployed is when the release was last deployed. + type: string + notes: + description: Contains the rendered templates/NOTES.txt if available + type: string + resources: + description: Resources is the list of resources that are part of the release + items: + $ref: "#/components/schemas/unstructured.Unstructured" + type: array + status: + description: Status is the current state of the release + type: string + type: object + release.Lock: + properties: + dependencies: + description: Dependencies is the list of dependencies that this lock file has + locked. + items: + $ref: "#/components/schemas/release.Dependency" + type: array + digest: + description: Digest is a hash of the dependencies in Chart.yaml. + type: string + generated: + description: Generated is the date the lock file was last generated. + type: string + type: object + release.Maintainer: + properties: + email: + description: Email is an optional email address to contact the named maintainer + type: string + name: + description: Name is a user name or organization name + type: string + url: + description: URL is an optional URL to an address for the named maintainer + type: string + type: object + release.Metadata: + properties: + annotations: + additionalProperties: + type: string + description: |- + Annotations are additional mappings uninterpreted by Helm, + made available for inspection by other applications. + type: object + apiVersion: + description: The API Version of this chart. Required. + type: string + appVersion: + description: The version of the application enclosed inside of this chart. + type: string + condition: + description: The condition to check to enable chart + type: string + dependencies: + description: Dependencies are a list of dependencies for a chart. + items: + $ref: "#/components/schemas/release.Dependency" + type: array + deprecated: + description: Whether or not this chart is deprecated + type: boolean + description: + description: A one-sentence description of the chart + type: string + home: + description: The URL to a relevant project page, git repo, or contact person + type: string + icon: + description: The URL to an icon file. + type: string + keywords: + description: A list of string keywords + items: + type: string + type: array + kubeVersion: + description: KubeVersion is a SemVer constraint specifying the version of + Kubernetes required. + type: string + maintainers: + description: A list of name and URL/email address combinations for the + maintainer(s) + items: + $ref: "#/components/schemas/release.Maintainer" + type: array + name: + description: The name of the chart. Required. + type: string + sources: + description: Source is the URL to the source code of this chart + items: + type: string + type: array + tags: + description: The tags to check to enable chart + type: string + type: + description: "Specifies the chart type: application or library" + type: string + version: + description: A SemVer 2 conformant version string of the chart. Required. + type: string + type: object + release.Release: + properties: + appVersion: + description: AppVersion is the app version of the release. + type: string + chart: + allOf: + - $ref: "#/components/schemas/release.Chart" + description: Chart is the chart that was released. + chartReference: + allOf: + - $ref: "#/components/schemas/release.ChartReference" + description: ChartReference are the labels that are used to identify the chart + source. + config: + additionalProperties: {} + description: |- + Config is the set of extra Values added to the chart. + These values override the default values inside of the chart. + type: object + hooks: + description: Hooks are all of the hooks declared for this release. + items: + $ref: "#/components/schemas/github_com_portainer_portainer_pkg_libhelm_release.\ + Hook" + type: array + info: + allOf: + - $ref: "#/components/schemas/release.Info" + description: Info provides information about a release + manifest: + description: Manifest is the string representation of the rendered template. + type: string + name: + description: Name is the name of the release + type: string + namespace: + description: Namespace is the kubernetes namespace of the release. + type: string + stackID: + description: StackID is the ID of the Portainer stack associated with this + release (if using GitOps) + type: integer + values: + allOf: + - $ref: "#/components/schemas/release.Values" + description: Values are the values used to deploy the chart. + version: + description: Version is an int which represents the revision of the release. + type: integer + type: object + release.ReleaseElement: + properties: + appVersion: + type: string + chart: + type: string + name: + type: string + namespace: + type: string + revision: + type: string + status: + type: string + updated: + type: string + type: object + release.Values: + properties: + computedValues: + type: string + userSuppliedValues: + type: string + type: object + resource.Quantity: + properties: + Format: + enum: + - DecimalExponent + - BinarySI + - DecimalSI + type: string + x-enum-comments: + BinarySI: e.g., 12Mi (12 * 2^20) + DecimalExponent: e.g., 12e6 + DecimalSI: e.g., 12M (12 * 10^6) + x-enum-descriptions: + - e.g., 12e6 + - e.g., 12Mi (12 * 2^20) + - e.g., 12M (12 * 10^6) + x-enum-varnames: + - DecimalExponent + - BinarySI + - DecimalSI + type: object + resourcecontrols.resourceControlCreatePayload: + properties: + AdministratorsOnly: + description: Permit access to resource only to admins + example: true + type: boolean + Public: + description: Permit access to the associated resource to any user + example: true + type: boolean + ResourceID: + example: 617c5f22bb9b023d6daab7cba43a57576f83492867bc767d1c59416b065e5f08 + type: string + SubResourceIDs: + description: List of Docker resources that will inherit this access control + example: + - 617c5f22bb9b023d6daab7cba43a57576f83492867bc767d1c59416b065e5f08 + items: + type: string + type: array + Teams: + description: List of team identifiers with access to the associated resource + example: + - 56 + - 7 + items: + type: integer + type: array + Type: + allOf: + - $ref: "#/components/schemas/portainer.ResourceControlType" + description: >- + Type of Resource. Valid values are: 1 - container, 2 - service + + 3 - volume, 4 - network, 5 - secret, 6 - stack, 7 - config, 8 - custom template, 9 - azure-container-group + enum: + - 1 + - 2 + - 3 + - 4 + - 5 + - 6 + - 7 + - 8 + - 9 + example: 1 + Users: + description: List of user identifiers with access to the associated resource + example: + - 1 + - 4 + items: + type: integer + type: array + required: + - ResourceID + - Type + type: object + resourcecontrols.resourceControlUpdatePayload: + properties: + AdministratorsOnly: + description: Permit access to resource only to admins + example: true + type: boolean + Public: + description: Permit access to the associated resource to any user + example: true + type: boolean + Teams: + description: List of team identifiers with access to the associated resource + example: + - 7 + items: + type: integer + type: array + Users: + description: List of user identifiers with access to the associated resource + example: + - 4 + items: + type: integer + type: array + type: object + roar.Roar-portainer_EndpointID: + type: object + settings.publicSettingsResponse: + properties: + AuthenticationMethod: + allOf: + - $ref: "#/components/schemas/portainer.AuthenticationMethod" + description: "Active authentication method for the Portainer instance. Valid + values are: 1 for internal, 2 for LDAP, or 3 for oauth" + example: 1 + Edge: + properties: + CheckinInterval: + description: The check in interval for edge agent (in seconds) - used in non + async mode [seconds] + example: 60 + type: integer + CommandInterval: + description: The command list interval for edge agent - used in edge async mode + [seconds] + example: 60 + type: integer + PingInterval: + description: The ping interval for edge agent - used in edge async mode + [seconds] + example: 60 + type: integer + SnapshotInterval: + description: The snapshot interval for edge agent - used in edge async mode + [seconds] + example: 60 + type: integer + type: object + EnableEdgeComputeFeatures: + description: Whether edge compute features are enabled + example: true + type: boolean + Features: + additionalProperties: + type: boolean + description: Supported feature flags + type: object + GlobalDeploymentOptions: + allOf: + - $ref: "#/components/schemas/portainer.GlobalDeploymentOptions" + description: Deployment options for encouraging deployment as code + IsDockerDesktopExtension: + example: false + type: boolean + KubeconfigExpiry: + default: "0" + description: The expiry of a Kubeconfig + example: 24h + type: string + LogoURL: + 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 + example: https://mycompany.mydomain.tld/logo.png + type: string + OAuthLoginURI: + description: The URL used for oauth login + example: https://gitlab.com/oauth + type: string + OAuthLogoutURI: + description: The URL used for oauth logout + example: https://gitlab.com/oauth/logout + type: string + RequiredPasswordLength: + description: The minimum required length for a password of any user when using + internal auth mode + example: 1 + type: integer + RequiresSetupToken: + description: Whether the setup wizard must send the X-Setup-Token header for + admin init / restore + example: false + type: boolean + TeamSync: + description: Whether team sync is enabled + example: true + type: boolean + type: object + settings.settingsUpdatePayload: + properties: + AuthenticationMethod: + description: "Active authentication method for the Portainer instance. Valid + values are: 1 for internal, 2 for LDAP, or 3 for oauth" + example: 1 + type: integer + BlackListedLabels: + description: A list of label name & value that will be used to hide containers + when querying containers + items: + $ref: "#/components/schemas/portainer.Pair" + type: array + EdgeAgentCheckinInterval: + example: 5 + type: integer + EdgePortainerURL: + description: EdgePortainerURL is the URL that is exposed to edge agents + type: string + EnableEdgeComputeFeatures: + description: Whether edge compute features are enabled + example: true + type: boolean + EnforceEdgeID: + description: EnforceEdgeID makes Portainer store the Edge ID instead of + accepting anyone + example: false + type: boolean + ForceSecureCookies: + description: ForceSecureCookies forces the Secure attribute on auth cookies + regardless of the detected scheme + example: false + type: boolean + GlobalDeploymentOptions: + allOf: + - $ref: "#/components/schemas/portainer.GlobalDeploymentOptions" + description: Deployment options for encouraging deployment as code + HelmRepositoryURL: + description: Helm repository URL + example: https://charts.bitnami.com/bitnami + type: string + InternalAuthSettings: + $ref: "#/components/schemas/portainer.InternalAuthSettings" + KubeconfigExpiry: + default: "0" + description: The expiry of a Kubeconfig + example: 24h + type: string + KubectlShellImage: + description: Kubectl Shell Image + example: portainer/kubectl-shell:latest + type: string + LDAPSettings: + $ref: "#/components/schemas/portainer.LDAPSettings" + LogoURL: + 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 + example: https://mycompany.mydomain.tld/logo.png + type: string + OAuthSettings: + $ref: "#/components/schemas/portainer.OAuthSettings" + SnapshotInterval: + description: The interval in which environment(endpoint) snapshots are created + example: 5m + type: string + TemplatesURL: + description: URL to the templates that will be displayed in the UI when + navigating to App Templates + example: https://raw.githubusercontent.com/portainer/templates/master/templates.json + type: string + TrustOnFirstConnect: + description: TrustOnFirstConnect makes Portainer accepting edge agent connection + by default + example: false + type: boolean + UserSessionTimeout: + description: The duration of a user session + example: 5m + type: string + type: object + sources.AutoUpdateInfo: + properties: + fetchInterval: + type: string + mechanism: + type: string + type: object + sources.ConnectionTestResult: + properties: + error: + type: string + success: + type: boolean + type: object + sources.GitAuthenticationPayload: + properties: + password: + type: string + username: + type: string + type: object + sources.GitAuthenticationUpdatePayload: + properties: + password: + type: string + username: + type: string + type: object + sources.GitSourceCreatePayload: + properties: + administratorsOnly: + example: true + type: boolean + authentication: + $ref: "#/components/schemas/sources.GitAuthenticationPayload" + name: + type: string + public: + example: true + type: boolean + teamAccesses: + items: + type: integer + type: array + tlsSkipVerify: + type: boolean + url: + type: string + userAccesses: + items: + type: integer + type: array + required: + - url + type: object + sources.GitSourceUpdatePayload: + properties: + authentication: + $ref: "#/components/schemas/sources.GitAuthenticationUpdatePayload" + name: + type: string + tlsSkipVerify: + type: boolean + url: + type: string + type: object + sources.Source: + properties: + environments: + type: integer + error: + type: string + id: + type: integer + lastSync: + type: integer + name: + type: string + status: + $ref: "#/components/schemas/workflows.Status" + type: + $ref: "#/components/schemas/sources.SourceType" + url: + type: string + usedBy: + type: integer + required: + - id + - name + - status + - type + - url + type: object + sources.SourceAccess: + properties: + public: + type: boolean + teams: + items: + type: integer + type: array + users: + items: + type: integer + type: array + type: object + sources.SourceAccessUpdatePayload: + properties: + public: + type: boolean + teams: + items: + type: integer + type: array + users: + items: + type: integer + type: array + type: object + sources.SourceDetail: + properties: + access: + $ref: "#/components/schemas/sources.SourceAccess" + autoUpdate: + $ref: "#/components/schemas/sources.AutoUpdateInfo" + connection: + $ref: "#/components/schemas/sources.connectionInfo" + environments: + type: integer + error: + type: string + id: + type: integer + lastSync: + type: integer + name: + type: string + status: + $ref: "#/components/schemas/workflows.Status" + type: + $ref: "#/components/schemas/sources.SourceType" + url: + type: string + usedBy: + type: integer + workflows: + items: + $ref: "#/components/schemas/workflows.Workflow" + type: array + required: + - connection + - id + - name + - status + - type + - url + type: object + sources.SourceType: + enum: + - git + - helm + - oci + type: string + x-enum-varnames: + - SourceTypeGit + - SourceTypeHelm + - SourceTypeOCI + sources.Status: + enum: + - unknown + - healthy + - error + type: string + x-enum-varnames: + - SourceStatusUnknown + - SourceStatusHealthy + - SourceStatusError + sources.connectionInfo: + properties: + authentication: + $ref: "#/components/schemas/sources.gitAuthInfo" + tlsSkipVerify: + type: boolean + type: object + sources.gitAuthInfo: + properties: + username: + type: string + type: object + ssl.sslUpdatePayload: + properties: + Cert: + description: SSL Certificates + type: string + HTTPEnabled: + type: boolean + Key: + type: string + type: object + stacks.composeStackFromFileContentPayload: + properties: + Env: + description: A list of environment variables used during stack deployment + items: + $ref: "#/components/schemas/portainer.Pair" + type: array + FromAppTemplate: + description: Whether the stack is from a app template + example: false + type: boolean + Name: + description: Name of the stack + example: myStack + type: string + StackFileContent: + description: Content of the Stack file + example: |- + version: 3 + services: + web: + image:nginx + type: string + required: + - Name + - StackFileContent + type: object + stacks.composeStackFromGitRepositoryPayload: + properties: + AdditionalFiles: + description: Applicable when deploying with multiple stack files + example: + - "[nz.compose.yml" + - " uat.compose.yml]" + items: + type: string + type: array + AutoUpdate: + allOf: + - $ref: "#/components/schemas/portainer.AutoUpdateSettings" + description: Optional GitOps update configuration + ComposeFile: + default: docker-compose.yml + description: Path to the Stack file inside the Git repository + example: docker-compose.yml + type: string + Env: + description: A list of environment variables used during stack deployment + items: + $ref: "#/components/schemas/portainer.Pair" + type: array + FromAppTemplate: + description: Whether the stack is from a app template + example: false + type: boolean + Name: + description: Name of the stack + example: myStack + type: string + RepositoryAuthentication: + description: "Deprecated: use SourceID instead. Use basic authentication to + clone the Git repository." + example: true + type: boolean + RepositoryPassword: + description: "Deprecated: use SourceID instead. Password used in basic + authentication." + example: myGitPassword + type: string + RepositoryReferenceName: + description: Reference name of a Git repository hosting the Stack file + example: refs/heads/master + type: string + RepositoryURL: + description: "Deprecated: use SourceID instead. URL of a Git repository hosting + the Stack file." + example: https://github.com/openfaas/faas + type: string + RepositoryUsername: + description: "Deprecated: use SourceID instead. Username used in basic + authentication." + example: myGitUsername + type: string + SourceID: + description: |- + SourceID references an existing Source for git credentials/URL. + When set, the inline URL and authentication fields are ignored. + example: 1 + type: integer + TLSSkipVerify: + description: "Deprecated: use SourceID instead. TLSSkipVerify skips SSL + verification when cloning the Git repository." + example: false + type: boolean + required: + - Name + type: object + stacks.kubernetesGitDeploymentPayload: + properties: + AdditionalFiles: + items: + type: string + type: array + AutoUpdate: + $ref: "#/components/schemas/portainer.AutoUpdateSettings" + ComposeFormat: + type: boolean + ManifestFile: + type: string + Namespace: + type: string + RepositoryAuthentication: + description: "Deprecated: use SourceID instead. Use basic authentication to + clone the Git repository." + type: boolean + RepositoryPassword: + description: "Deprecated: use SourceID instead. Password used in basic + authentication." + type: string + RepositoryReferenceName: + description: "Deprecated: use SourceID instead. Reference name of a Git + repository hosting the Stack file." + type: string + RepositoryURL: + description: "Deprecated: use SourceID instead. URL of a Git repository hosting + the Stack file." + type: string + RepositoryUsername: + description: "Deprecated: use SourceID instead. Username used in basic + authentication." + type: string + SourceID: + description: |- + SourceID references an existing Source for git credentials/URL. + When set, the inline URL and authentication fields are ignored. + example: 1 + type: integer + StackName: + type: string + TLSSkipVerify: + description: "Deprecated: use SourceID instead. TLSSkipVerify skips SSL + verification when cloning the Git repository." + example: false + type: boolean + type: object + stacks.kubernetesManifestURLDeploymentPayload: + properties: + ComposeFormat: + type: boolean + ManifestURL: + type: string + Namespace: + type: string + StackName: + type: string + type: object + stacks.kubernetesStringDeploymentPayload: + properties: + ComposeFormat: + type: boolean + FromAppTemplate: + description: Whether the stack is from a app template + example: false + type: boolean + Namespace: + type: string + StackFileContent: + type: string + StackName: + type: string + type: object + stacks.stackFileResponse: + properties: + StackFileContent: + description: Content of the Stack file + example: |- + version: 3 + services: + web: + image:nginx + type: string + type: object + stacks.stackGitRedeployPayload: + properties: + Env: + items: + $ref: "#/components/schemas/portainer.Pair" + type: array + Prune: + type: boolean + PullImage: + description: >- + Deprecated(2.36): use RepullImageAndRedeploy instead for cleaner + responsibility + + Force a pulling to current image with the original tag though the image is already the latest + example: false + type: boolean + RepositoryAuthentication: + description: When true and RepositoryPassword is non-empty, stored credentials + are replaced. + type: boolean + RepositoryPassword: + description: Non-empty value (with RepositoryAuthentication=true) replaces + stored credentials; leave blank to keep them. + type: string + RepositoryReferenceName: + type: string + RepositoryUsername: + type: string + RepullImageAndRedeploy: + description: RepullImageAndRedeploy indicates whether to force repulling images + and redeploying the stack + type: boolean + StackName: + type: string + type: object + stacks.stackGitUpdatePayload: + properties: + AdditionalFiles: + items: + type: string + type: array + AutoUpdate: + $ref: "#/components/schemas/portainer.AutoUpdateSettings" + ConfigFilePath: + type: string + Env: + items: + $ref: "#/components/schemas/portainer.Pair" + type: array + Prune: + type: boolean + RepositoryAuthentication: + description: "Deprecated: use SourceID instead. Use basic authentication to + clone the Git repository." + type: boolean + RepositoryPassword: + description: "Deprecated: use SourceID instead. Password used in basic + authentication." + type: string + RepositoryReferenceName: + type: string + RepositoryURL: + description: "Deprecated: use SourceID instead. URL of a Git repository hosting + the Stack file." + type: string + RepositoryUsername: + description: "Deprecated: use SourceID instead. Username used in basic + authentication." + type: string + SourceID: + description: |- + SourceID references an existing Source for git credentials/URL. + When set, the inline URL and authentication fields are ignored. + type: integer + TLSSkipVerify: + description: "Deprecated: use SourceID instead. Skip TLS verification when + cloning the Git repository." + type: boolean + type: object + stacks.stackMigratePayload: + properties: + EndpointID: + description: Environment(Endpoint) identifier of the target + environment(endpoint) where the stack will be relocated + example: 2 + type: integer + Name: + description: If provided will rename the migrated stack + example: new-stack + type: string + SwarmID: + description: Swarm cluster identifier, must match the identifier of the cluster + where the stack will be relocated + example: jpofkc0i9uo9wtx1zesuk649w + type: string + required: + - EndpointID + type: object + stacks.stackResponse: + properties: + AdditionalFiles: + description: Only applies when deploying stack with multiple files + items: + type: string + type: array + AutoUpdate: + allOf: + - $ref: "#/components/schemas/portainer.AutoUpdateSettings" + description: The GitOps update settings of a git stack + CreatedBy: + description: The username which created this stack + example: admin + type: string + CreationDate: + description: The date in unix time when stack was created + example: 1587399600 + type: integer + CurrentDeploymentInfo: + allOf: + - $ref: "#/components/schemas/portainer.StackDeploymentInfo" + description: CurrentDeploymentInfo records the git repository state at the time + of the last actual deployment. + DeploymentStartStatus: + allOf: + - $ref: "#/components/schemas/portainer.StackStatus" + description: >- + DeploymentStartStatus is the stack status captured when the current + + deployment starts. It is used by deployment logic during the current + + deployment attempt and is cleared/replaced when a new deployment begins. + example: 1 + DeploymentStatus: + description: >- + DeploymentStatus records the status progression of the current + deployment. + + Cleared when a new deployment starts. + items: + $ref: "#/components/schemas/portainer.StackDeploymentStatus" + type: array + EndpointId: + description: Environment(Endpoint) identifier. Reference the + environment(endpoint) that will be used for deployment + example: 1 + type: integer + EntryPoint: + description: >- + EntryPoint is the path to the config file relative to the project + root. + + NOTE: For git stacks this mirrors GitConfig.ConfigFilePath and the two are kept in sync + + by stackUpdateGit. The deploy command builder (compose_unpacker_cmd_builder) uses this + + field directly; Kubernetes deploy and git clone operations use GitConfig.ConfigFilePath. + example: docker-compose.yml + type: string + Env: + description: A list of environment(endpoint) variables used during stack + deployment + items: + $ref: "#/components/schemas/portainer.Pair" + type: array + FromAppTemplate: + description: Whether the stack is from a app template + example: false + type: boolean + GitConfig: + allOf: + - $ref: "#/components/schemas/gittypes.RepoConfig" + description: >- + GitConfig is the git repository configuration for git-backed stacks. + + Deprecated: loaded from Source via WorkflowID; kept for DB backwards-compatibility only. + + Non-migration code must not read or write this field; use Source records instead. + GitSourceId: + type: integer + Id: + description: Stack Identifier + example: 1 + type: integer + Name: + description: Stack name + example: myStack + type: string + Namespace: + description: Kubernetes namespace if stack is a kube application + example: default + type: string + Option: + allOf: + - $ref: "#/components/schemas/portainer.StackOption" + description: The stack deployment option + ProjectPath: + description: Path on disk to the repository hosting the Stack file + example: /data/compose/myStack_jpofkc0i9uo9wtx1zesuk649w + type: string + ResourceControl: + $ref: "#/components/schemas/portainer.ResourceControl" + Status: + allOf: + - $ref: "#/components/schemas/portainer.StackStatus" + description: Stack status (1 - active, 2 - inactive, 3 - deploying, 4 - error) + example: 1 + SwarmId: + description: Cluster identifier of the Swarm cluster where the stack is deployed + example: jpofkc0i9uo9wtx1zesuk649w + type: string + Type: + allOf: + - $ref: "#/components/schemas/portainer.StackType" + description: Stack type. 1 for a Swarm stack, 2 for a Compose stack + example: 2 + UpdateDate: + description: The date in unix time when stack was last updated + example: 1587399600 + type: integer + UpdatedBy: + description: The username which last updated this stack + example: bob + type: string + WorkflowID: + description: WorkflowID is the ID of the Workflow that owns the Source for this + stack. + type: integer + type: object + stacks.swarmStackFromFileContentPayload: + properties: + Env: + description: A list of environment variables used during stack deployment + items: + $ref: "#/components/schemas/portainer.Pair" + type: array + FromAppTemplate: + description: Whether the stack is from a app template + example: false + type: boolean + Name: + description: Name of the stack + example: myStack + type: string + StackFileContent: + description: Content of the Stack file + example: |- + version: 3 + services: + web: + image:nginx + type: string + SwarmID: + description: Swarm cluster identifier + example: jpofkc0i9uo9wtx1zesuk649w + type: string + required: + - Name + - StackFileContent + - SwarmID + type: object + stacks.swarmStackFromGitRepositoryPayload: + properties: + AdditionalFiles: + description: Applicable when deploying with multiple stack files + example: + - "[nz.compose.yml" + - " uat.compose.yml]" + items: + type: string + type: array + AutoUpdate: + allOf: + - $ref: "#/components/schemas/portainer.AutoUpdateSettings" + description: Optional GitOps update configuration + ComposeFile: + default: docker-compose.yml + description: Path to the Stack file inside the Git repository + example: docker-compose.yml + type: string + Env: + description: A list of environment variables used during stack deployment + items: + $ref: "#/components/schemas/portainer.Pair" + type: array + FromAppTemplate: + description: Whether the stack is from a app template + example: false + type: boolean + Name: + description: Name of the stack + example: myStack + type: string + RepositoryAuthentication: + description: "Deprecated: use SourceID instead. Use basic authentication to + clone the Git repository." + example: true + type: boolean + RepositoryPassword: + description: "Deprecated: use SourceID instead. Password used in basic + authentication." + example: myGitPassword + type: string + RepositoryReferenceName: + description: Reference name of a Git repository hosting the Stack file + example: refs/heads/master + type: string + RepositoryURL: + description: "Deprecated: use SourceID instead. URL of a Git repository hosting + the Stack file." + example: https://github.com/openfaas/faas + type: string + RepositoryUsername: + description: "Deprecated: use SourceID instead. Username used in basic + authentication." + example: myGitUsername + type: string + SourceID: + description: |- + SourceID references an existing Source for git credentials/URL. + When set, the inline URL and authentication fields are ignored. + example: 1 + type: integer + SwarmID: + description: Swarm cluster identifier + example: jpofkc0i9uo9wtx1zesuk649w + type: string + TLSSkipVerify: + description: "Deprecated: use SourceID instead. TLSSkipVerify skips SSL + verification when cloning the Git repository." + example: false + type: boolean + required: + - Name + - SwarmID + type: object + stacks.updateSwarmStackPayload: + properties: + Env: + description: A list of environment(endpoint) variables used during stack + deployment + items: + $ref: "#/components/schemas/portainer.Pair" + type: array + Prune: + description: Prune services that are no longer referenced + example: true + type: boolean + PullImage: + description: >- + Deprecated(2.36): use RepullImageAndRedeploy instead for cleaner + responsibility + + Force a pulling to current image with the original tag though the image is already the latest + example: false + type: boolean + RepullImageAndRedeploy: + description: RepullImageAndRedeploy indicates whether to force repulling images + and redeploying the stack + type: boolean + StackFileContent: + description: New content of the Stack file + example: |- + version: 3 + services: + web: + image:nginx + type: string + type: object + stats.ContainerStats: + properties: + healthy: + type: integer + running: + type: integer + stopped: + type: integer + total: + type: integer + unhealthy: + type: integer + type: object + swarm.ServiceUpdateResponse: + properties: + Warnings: + description: Optional warning messages + items: + type: string + type: array + type: object + system.nodesCountResponse: + properties: + nodes: + type: integer + type: object + system.status: + properties: + InstanceID: + description: Server Instance ID + example: 299ab403-70a8-4c05-92f7-bf7a994d50df + type: string + Version: + description: Portainer API version + example: 2.0.0 + type: string + type: object + system.systemInfoResponse: + properties: + agents: + type: integer + edgeAgents: + type: integer + platform: + $ref: "#/components/schemas/platform.ContainerPlatform" + type: object + system.versionResponse: + properties: + Build: + $ref: "#/components/schemas/build.BuildInfo" + DatabaseVersion: + type: string + Dependencies: + $ref: "#/components/schemas/build.DependenciesInfo" + LatestVersion: + description: The latest version available + example: 2.0.0 + type: string + Runtime: + $ref: "#/components/schemas/build.RuntimeInfo" + ServerEdition: + example: CE/EE + type: string + ServerVersion: + type: string + UpdateAvailable: + description: Whether portainer has an update available + example: false + type: boolean + VersionSupport: + example: STS/LTS + type: string + type: object + tags.tagCreatePayload: + properties: + Name: + example: org/acme + type: string + required: + - Name + type: object + teammemberships.teamMembershipCreatePayload: + properties: + Role: + description: Role for the user inside the team (1 for leader and 2 for regular + member) + enum: + - 1 + - 2 + example: 1 + type: integer + TeamID: + description: Team identifier + example: 1 + type: integer + UserID: + description: User identifier + example: 1 + type: integer + required: + - Role + - TeamID + - UserID + type: object + teammemberships.teamMembershipUpdatePayload: + properties: + Role: + description: Role for the user inside the team (1 for leader and 2 for regular + member) + enum: + - 1 + - 2 + example: 1 + type: integer + TeamID: + description: Team identifier + example: 1 + type: integer + UserID: + description: User identifier + example: 1 + type: integer + required: + - Role + - TeamID + - UserID + type: object + teams.teamCreatePayload: + properties: + Name: + description: Name + example: developers + type: string + TeamLeaders: + description: TeamLeaders + example: + - 3 + - 5 + items: + type: integer + type: array + required: + - Name + type: object + teams.teamUpdatePayload: + properties: + Name: + description: Name + example: developers + type: string + type: object + templates.fileResponse: + properties: + FileContent: + description: The requested file content + example: version:2 + type: string + type: object + templates.listResponse: + properties: + templates: + items: + $ref: "#/components/schemas/portainer.Template" + type: array + version: + type: string + type: object + unstructured.Unstructured: + properties: + Object: + additionalProperties: true + description: >- + Object is a JSON compatible map with string, float, int, bool, + []interface{}, or + + map[string]interface{} + + children. + type: object + type: object + users.AccessLocation: + enum: + - environment + - environmentGroup + type: string + x-enum-varnames: + - AccessLocationEnvironment + - AccessLocationEnvironmentGroup + users.EffectiveAccessEntry: + properties: + accessLocation: + $ref: "#/components/schemas/users.AccessLocation" + endpointId: + type: integer + endpointName: + type: string + groupId: + type: integer + groupName: + type: string + roleId: + type: integer + roleName: + type: string + rolePriority: + type: integer + teamId: + type: integer + teamName: + type: string + type: object + users.accessTokenResponse: + properties: + apiKey: + $ref: "#/components/schemas/portainer.APIKey" + rawAPIKey: + type: string + type: object + users.addHelmRepoUrlPayload: + properties: + url: + type: string + type: object + users.adminInitPayload: + properties: + Password: + description: Password for the admin user + example: admin-password + type: string + Username: + description: Username for the admin user + example: admin + type: string + required: + - Password + - Username + type: object + users.helmUserRepositoryResponse: + properties: + GlobalRepository: + type: string + UserRepositories: + items: + $ref: "#/components/schemas/portainer.HelmUserRepository" + type: array + type: object + users.themePayload: + properties: + color: + description: Color represents the color theme of the UI + enum: + - dark + - light + - highcontrast + - auto + example: dark + type: string + type: object + users.userAccessTokenCreatePayload: + properties: + description: + example: github-api-key + type: string + password: + example: password + type: string + required: + - description + - password + type: object + users.userCreatePayload: + properties: + Password: + example: cg9Wgky3 + type: string + Role: + description: User role (1 for administrator account and 2 for regular account) + enum: + - 1 + - 2 + example: 2 + type: integer + Username: + example: bob + type: string + required: + - Password + - Role + - Username + type: object + users.userUpdatePasswordPayload: + properties: + NewPassword: + description: New Password + example: new_passwd + type: string + Password: + description: Current Password + example: passwd + type: string + required: + - NewPassword + - Password + type: object + users.userUpdatePayload: + properties: + NewPassword: + example: asfj2emv + type: string + Password: + example: cg9Wgky3 + type: string + Role: + description: User role (1 for administrator account and 2 for regular account) + enum: + - 1 + - 2 + example: 2 + type: integer + Theme: + $ref: "#/components/schemas/users.themePayload" + UseCache: + example: true + type: boolean + Username: + example: bob + type: string + required: + - NewPassword + - Password + - Role + - UseCache + - Username + type: object + v1.AppArmorProfile: + properties: + localhostProfile: + description: >- + localhostProfile indicates a profile loaded on the node that should + be used. + + The profile must be preconfigured on the node to work. + + Must match the loaded name of the profile. + + Must be set if and only if type is "Localhost". + + +optional + type: string + type: + allOf: + - $ref: "#/components/schemas/v1.AppArmorProfileType" + description: |- + type indicates which kind of AppArmor profile will be applied. + Valid options are: + Localhost - a profile pre-loaded on the node. + RuntimeDefault - the container runtime's default profile. + Unconfined - no AppArmor enforcement. + +unionDiscriminator + type: object + v1.AppArmorProfileType: + enum: + - Unconfined + - RuntimeDefault + - Localhost + type: string + x-enum-varnames: + - AppArmorProfileTypeUnconfined + - AppArmorProfileTypeRuntimeDefault + - AppArmorProfileTypeLocalhost + v1.AttachedVolume: + properties: + devicePath: + description: DevicePath represents the device path where the volume should be + available + type: string + name: + description: Name of the attached volume + type: string + type: object + v1.CSIPersistentVolumeSource: + properties: + controllerExpandSecretRef: + allOf: + - $ref: "#/components/schemas/v1.SecretReference" + description: >- + controllerExpandSecretRef is a reference to the secret object + containing + + sensitive information to pass to the CSI driver to complete the CSI + + ControllerExpandVolume call. + + This field is optional, and may be empty if no secret is required. If the + + secret object contains more than one secret, all secrets are passed. + + +optional + controllerPublishSecretRef: + allOf: + - $ref: "#/components/schemas/v1.SecretReference" + description: >- + controllerPublishSecretRef is a reference to the secret object + containing + + sensitive information to pass to the CSI driver to complete the CSI + + ControllerPublishVolume and ControllerUnpublishVolume calls. + + This field is optional, and may be empty if no secret is required. If the + + secret object contains more than one secret, all secrets are passed. + + +optional + driver: + description: |- + driver is the name of the driver to use for this volume. + Required. + type: string + fsType: + description: >- + fsType to mount. Must be a filesystem type supported by the host + operating system. + + Ex. "ext4", "xfs", "ntfs". + + +optional + type: string + nodeExpandSecretRef: + allOf: + - $ref: "#/components/schemas/v1.SecretReference" + description: >- + nodeExpandSecretRef is a reference to the secret object containing + + sensitive information to pass to the CSI driver to complete the CSI + + NodeExpandVolume call. + + This field is optional, may be omitted if no secret is required. If the + + secret object contains more than one secret, all secrets are passed. + + +optional + nodePublishSecretRef: + allOf: + - $ref: "#/components/schemas/v1.SecretReference" + description: >- + nodePublishSecretRef is a reference to the secret object containing + + sensitive information to pass to the CSI driver to complete the CSI + + NodePublishVolume and NodeUnpublishVolume calls. + + This field is optional, and may be empty if no secret is required. If the + + secret object contains more than one secret, all secrets are passed. + + +optional + nodeStageSecretRef: + allOf: + - $ref: "#/components/schemas/v1.SecretReference" + description: >- + nodeStageSecretRef is a reference to the secret object containing + sensitive + + information to pass to the CSI driver to complete the CSI NodeStageVolume + + and NodeStageVolume and NodeUnstageVolume calls. + + This field is optional, and may be empty if no secret is required. If the + + secret object contains more than one secret, all secrets are passed. + + +optional + readOnly: + description: |- + readOnly value to pass to ControllerPublishVolumeRequest. + Defaults to false (read/write). + +optional + type: boolean + volumeAttributes: + additionalProperties: + type: string + description: |- + volumeAttributes of the volume to publish. + +optional + type: object + volumeHandle: + description: >- + volumeHandle is the unique volume name returned by the CSI volume + + plugin’s CreateVolume to refer to the volume on all subsequent calls. + + Required. + type: string + type: object + v1.Capabilities: + properties: + add: + description: |- + Added capabilities + +optional + +listType=atomic + items: + type: string + type: array + drop: + description: |- + Removed capabilities + +optional + +listType=atomic + items: + type: string + type: array + type: object + v1.ConfigMapEnvSource: + properties: + name: + description: >- + Name of the referent. + + This field is effectively required, but due to backwards compatibility is + + allowed to be empty. Instances of this type with an empty value here are + + almost certainly wrong. + + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + + +optional + + +default="" + + +kubebuilder:default="" + + TODO: Drop `kubebuilder:default` when controller-gen doesn't need it https://github.com/kubernetes-sigs/kubebuilder/issues/3896. + type: string + optional: + description: |- + Specify whether the ConfigMap must be defined + +optional + type: boolean + type: object + v1.ConfigMapKeySelector: + properties: + key: + description: The key to select. + type: string + name: + description: >- + Name of the referent. + + This field is effectively required, but due to backwards compatibility is + + allowed to be empty. Instances of this type with an empty value here are + + almost certainly wrong. + + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + + +optional + + +default="" + + +kubebuilder:default="" + + TODO: Drop `kubebuilder:default` when controller-gen doesn't need it https://github.com/kubernetes-sigs/kubebuilder/issues/3896. + type: string + optional: + description: |- + Specify whether the ConfigMap or its key must be defined + +optional + type: boolean + type: object + v1.ConfigMapNodeConfigSource: + properties: + kubeletConfigKey: + description: >- + KubeletConfigKey declares which key of the referenced ConfigMap + corresponds to the KubeletConfiguration structure + + This field is required in all cases. + type: string + name: + description: |- + Name is the metadata.name of the referenced ConfigMap. + This field is required in all cases. + type: string + namespace: + description: |- + Namespace is the metadata.namespace of the referenced ConfigMap. + This field is required in all cases. + type: string + resourceVersion: + description: >- + ResourceVersion is the metadata.ResourceVersion of the referenced + ConfigMap. + + This field is forbidden in Node.Spec, and required in Node.Status. + + +optional + type: string + uid: + description: |- + UID is the metadata.UID of the referenced ConfigMap. + This field is forbidden in Node.Spec, and required in Node.Status. + +optional + type: string + type: object + v1.Container: + properties: + args: + description: >- + Arguments to the entrypoint. + + The container image's CMD is used if this is not provided. + + Variable references $(VAR_NAME) are expanded using the container's environment. If a variable + + cannot be resolved, the reference in the input string will be unchanged. Double $$ are reduced + + to a single $, which allows for escaping the $(VAR_NAME) syntax: i.e. "$$(VAR_NAME)" will + + produce the string literal "$(VAR_NAME)". Escaped references will never be expanded, regardless + + of whether the variable exists or not. Cannot be updated. + + More info: https://kubernetes.io/docs/tasks/inject-data-application/define-command-argument-container/#running-a-command-in-a-shell + + +optional + + +listType=atomic + items: + type: string + type: array + command: + description: >- + Entrypoint array. Not executed within a shell. + + The container image's ENTRYPOINT is used if this is not provided. + + Variable references $(VAR_NAME) are expanded using the container's environment. If a variable + + cannot be resolved, the reference in the input string will be unchanged. Double $$ are reduced + + to a single $, which allows for escaping the $(VAR_NAME) syntax: i.e. "$$(VAR_NAME)" will + + produce the string literal "$(VAR_NAME)". Escaped references will never be expanded, regardless + + of whether the variable exists or not. Cannot be updated. + + More info: https://kubernetes.io/docs/tasks/inject-data-application/define-command-argument-container/#running-a-command-in-a-shell + + +optional + + +listType=atomic + items: + type: string + type: array + env: + description: |- + List of environment variables to set in the container. + Cannot be updated. + +optional + +patchMergeKey=name + +patchStrategy=merge + +listType=map + +listMapKey=name + items: + $ref: "#/components/schemas/v1.EnvVar" + type: array + envFrom: + description: >- + List of sources to populate environment variables in the container. + + The keys defined within a source may consist of any printable ASCII characters except '='. + + When a key exists in multiple + + sources, the value associated with the last source will take precedence. + + Values defined by an Env with a duplicate key will take precedence. + + Cannot be updated. + + +optional + + +listType=atomic + items: + $ref: "#/components/schemas/v1.EnvFromSource" + type: array + image: + description: >- + Container image name. + + More info: https://kubernetes.io/docs/concepts/containers/images + + This field is optional to allow higher level config management to default or override + + container images in workload controllers like Deployments and StatefulSets. + + +optional + type: string + imagePullPolicy: + allOf: + - $ref: "#/components/schemas/v1.PullPolicy" + description: >- + Image pull policy. + + One of Always, Never, IfNotPresent. + + Defaults to Always if :latest tag is specified, or IfNotPresent otherwise. + + Cannot be updated. + + More info: https://kubernetes.io/docs/concepts/containers/images#updating-images + + +optional + lifecycle: + allOf: + - $ref: "#/components/schemas/v1.Lifecycle" + description: >- + Actions that the management system should take in response to + container lifecycle events. + + Cannot be updated. + + +optional + livenessProbe: + allOf: + - $ref: "#/components/schemas/v1.Probe" + description: >- + Periodic probe of container liveness. + + Container will be restarted if the probe fails. + + Cannot be updated. + + More info: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle#container-probes + + +optional + name: + description: |- + Name of the container specified as a DNS_LABEL. + Each container in a pod must have a unique name (DNS_LABEL). + Cannot be updated. + type: string + ports: + description: >- + List of ports to expose from the container. Not specifying a port + here + + DOES NOT prevent that port from being exposed. Any port which is + + listening on the default "0.0.0.0" address inside a container will be + + accessible from the network. + + Modifying this array with strategic merge patch may corrupt the data. + + For more information See https://github.com/kubernetes/kubernetes/issues/108255. + + Cannot be updated. + + +optional + + +patchMergeKey=containerPort + + +patchStrategy=merge + + +listType=map + + +listMapKey=containerPort + + +listMapKey=protocol + items: + $ref: "#/components/schemas/v1.ContainerPort" + type: array + readinessProbe: + allOf: + - $ref: "#/components/schemas/v1.Probe" + description: >- + Periodic probe of container service readiness. + + Container will be removed from service endpoints if the probe fails. + + Cannot be updated. + + More info: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle#container-probes + + +optional + resizePolicy: + description: |- + Resources resize policy for the container. + This field cannot be set on ephemeral containers. + +featureGate=InPlacePodVerticalScaling + +optional + +listType=atomic + items: + $ref: "#/components/schemas/v1.ContainerResizePolicy" + type: array + resources: + allOf: + - $ref: "#/components/schemas/v1.ResourceRequirements" + description: >- + Compute Resources required by this container. + + Cannot be updated. + + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + + +optional + restartPolicy: + allOf: + - $ref: "#/components/schemas/v1.ContainerRestartPolicy" + description: >- + RestartPolicy defines the restart behavior of individual containers + in a pod. + + This overrides the pod-level restart policy. When this field is not specified, + + the restart behavior is defined by the Pod's restart policy and the container type. + + Additionally, setting the RestartPolicy as "Always" for the init container will + + have the following effect: + + this init container will be continually restarted on + + exit until all regular containers have terminated. Once all regular + + containers have completed, all init containers with restartPolicy "Always" + + will be shut down. This lifecycle differs from normal init containers and + + is often referred to as a "sidecar" container. Although this init + + container still starts in the init container sequence, it does not wait + + for the container to complete before proceeding to the next init + + container. Instead, the next init container starts immediately after this + + init container is started, or after any startupProbe has successfully + + completed. + + +optional + restartPolicyRules: + description: >- + Represents a list of rules to be checked to determine if the + + container should be restarted on exit. The rules are evaluated in + + order. Once a rule matches a container exit condition, the remaining + + rules are ignored. If no rule matches the container exit condition, + + the Container-level restart policy determines the whether the container + + is restarted or not. Constraints on the rules: + + - At most 20 rules are allowed. + + - Rules can have the same action. + + - Identical rules are not forbidden in validations. + + When rules are specified, container MUST set RestartPolicy explicitly + + even it if matches the Pod's RestartPolicy. + + +featureGate=ContainerRestartRules + + +optional + + +listType=atomic + items: + $ref: "#/components/schemas/v1.ContainerRestartRule" + type: array + securityContext: + allOf: + - $ref: "#/components/schemas/v1.SecurityContext" + description: >- + SecurityContext defines the security options the container should be + run with. + + If set, the fields of SecurityContext override the equivalent fields of PodSecurityContext. + + More info: https://kubernetes.io/docs/tasks/configure-pod-container/security-context/ + + +optional + startupProbe: + allOf: + - $ref: "#/components/schemas/v1.Probe" + description: >- + StartupProbe indicates that the Pod has successfully initialized. + + If specified, no other probes are executed until this completes successfully. + + If this probe fails, the Pod will be restarted, just as if the livenessProbe failed. + + This can be used to provide different probe parameters at the beginning of a Pod's lifecycle, + + when it might take a long time to load data or warm a cache, than during steady-state operation. + + This cannot be updated. + + More info: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle#container-probes + + +optional + stdin: + description: >- + Whether this container should allocate a buffer for stdin in the + container runtime. If this + + is not set, reads from stdin in the container will always result in EOF. + + Default is false. + + +optional + type: boolean + stdinOnce: + description: >- + Whether the container runtime should close the stdin channel after + it has been opened by + + a single attach. When stdin is true the stdin stream will remain open across multiple attach + + sessions. If stdinOnce is set to true, stdin is opened on container start, is empty until the + + first client attaches to stdin, and then remains open and accepts data until the client disconnects, + + at which time stdin is closed and remains closed until the container is restarted. If this + + flag is false, a container processes that reads from stdin will never receive an EOF. + + Default is false + + +optional + type: boolean + terminationMessagePath: + description: >- + Optional: Path at which the file to which the container's + termination message + + will be written is mounted into the container's filesystem. + + Message written is intended to be brief final status, such as an assertion failure message. + + Will be truncated by the node if greater than 4096 bytes. The total message length across + + all containers will be limited to 12kb. + + Defaults to /dev/termination-log. + + Cannot be updated. + + +optional + type: string + terminationMessagePolicy: + allOf: + - $ref: "#/components/schemas/v1.TerminationMessagePolicy" + description: >- + Indicate how the termination message should be populated. File will + use the contents of + + terminationMessagePath to populate the container status message on both success and failure. + + FallbackToLogsOnError will use the last chunk of container log output if the termination + + message file is empty and the container exited with an error. + + The log output is limited to 2048 bytes or 80 lines, whichever is smaller. + + Defaults to File. + + Cannot be updated. + + +optional + tty: + description: >- + Whether this container should allocate a TTY for itself, also + requires 'stdin' to be true. + + Default is false. + + +optional + type: boolean + volumeDevices: + description: >- + volumeDevices is the list of block devices to be used by the + container. + + +patchMergeKey=devicePath + + +patchStrategy=merge + + +listType=map + + +listMapKey=devicePath + + +optional + items: + $ref: "#/components/schemas/v1.VolumeDevice" + type: array + volumeMounts: + description: |- + Pod volumes to mount into the container's filesystem. + Cannot be updated. + +optional + +patchMergeKey=mountPath + +patchStrategy=merge + +listType=map + +listMapKey=mountPath + items: + $ref: "#/components/schemas/v1.VolumeMount" + type: array + workingDir: + description: >- + Container's working directory. + + If not specified, the container runtime's default will be used, which + + might be configured in the container image. + + Cannot be updated. + + +optional + type: string + type: object + v1.ContainerImage: + properties: + names: + description: >- + Names by which this image is known. + + e.g. ["kubernetes.example/hyperkube:v1.0.7", "cloud-vendor.registry.example/cloud-vendor/hyperkube:v1.0.7"] + + +optional + + +listType=atomic + items: + type: string + type: array + sizeBytes: + description: |- + The size of the image in bytes. + +optional + type: integer + type: object + v1.ContainerPort: + properties: + containerPort: + description: |- + Number of port to expose on the pod's IP address. + This must be a valid port number, 0 < x < 65536. + type: integer + hostIP: + description: |- + What host IP to bind the external port to. + +optional + type: string + hostPort: + description: |- + Number of port to expose on the host. + If specified, this must be a valid port number, 0 < x < 65536. + If HostNetwork is specified, this must match ContainerPort. + Most containers do not need this. + +optional + type: integer + name: + description: >- + If specified, this must be an IANA_SVC_NAME and unique within the + pod. Each + + named port in a pod must have a unique name. Name for the port that can be + + referred to by services. + + +optional + type: string + protocol: + allOf: + - $ref: "#/components/schemas/v1.Protocol" + description: |- + Protocol for port. Must be UDP, TCP, or SCTP. + Defaults to "TCP". + +optional + +default="TCP" + type: object + v1.ContainerResizePolicy: + properties: + resourceName: + allOf: + - $ref: "#/components/schemas/v1.ResourceName" + description: |- + Name of the resource to which this resource resize policy applies. + Supported values: cpu, memory. + restartPolicy: + allOf: + - $ref: "#/components/schemas/v1.ResourceResizeRestartPolicy" + description: |- + Restart policy to apply when specified resource is resized. + If not specified, it defaults to NotRequired. + type: object + v1.ContainerRestartPolicy: + enum: + - Always + - Never + - OnFailure + type: string + x-enum-varnames: + - ContainerRestartPolicyAlways + - ContainerRestartPolicyNever + - ContainerRestartPolicyOnFailure + v1.ContainerRestartRule: + properties: + action: + allOf: + - $ref: "#/components/schemas/v1.ContainerRestartRuleAction" + description: |- + Specifies the action taken on a container exit if the requirements + are satisfied. The only possible value is "Restart" to restart the + container. + +required + exitCodes: + allOf: + - $ref: "#/components/schemas/v1.ContainerRestartRuleOnExitCodes" + description: |- + Represents the exit codes to check on container exits. + +optional + +oneOf=when + type: object + v1.ContainerRestartRuleAction: + enum: + - Restart + - RestartAllContainers + type: string + x-enum-varnames: + - ContainerRestartRuleActionRestart + - ContainerRestartRuleActionRestartAllContainers + v1.ContainerRestartRuleOnExitCodes: + properties: + operator: + allOf: + - $ref: "#/components/schemas/v1.ContainerRestartRuleOnExitCodesOperator" + description: >- + Represents the relationship between the container exit code(s) and + the + + specified values. Possible values are: + + - In: the requirement is satisfied if the container exit code is in the + set of specified values. + - NotIn: the requirement is satisfied if the container exit code is + not in the set of specified values. + +required + values: + description: |- + Specifies the set of values to check for container exit codes. + At most 255 elements are allowed. + +optional + +listType=set + items: + type: integer + type: array + type: object + v1.ContainerRestartRuleOnExitCodesOperator: + enum: + - In + - NotIn + type: string + x-enum-varnames: + - ContainerRestartRuleOnExitCodesOpIn + - ContainerRestartRuleOnExitCodesOpNotIn + v1.DaemonEndpoint: + properties: + Port: + description: Port number of the given endpoint. + type: integer + type: object + v1.EnvFromSource: + properties: + configMapRef: + allOf: + - $ref: "#/components/schemas/v1.ConfigMapEnvSource" + description: |- + The ConfigMap to select from + +optional + prefix: + description: |- + Optional text to prepend to the name of each environment variable. + May consist of any printable ASCII characters except '='. + +optional + type: string + secretRef: + allOf: + - $ref: "#/components/schemas/v1.SecretEnvSource" + description: |- + The Secret to select from + +optional + type: object + v1.EnvVar: + properties: + name: + description: |- + Name of the environment variable. + May consist of any printable ASCII characters except '='. + type: string + value: + description: >- + Variable references $(VAR_NAME) are expanded + + using the previously defined environment variables in the container and + + any service environment variables. If a variable cannot be resolved, + + the reference in the input string will be unchanged. Double $$ are reduced + + to a single $, which allows for escaping the $(VAR_NAME) syntax: i.e. + + "$$(VAR_NAME)" will produce the string literal "$(VAR_NAME)". + + Escaped references will never be expanded, regardless of whether the variable + + exists or not. + + Defaults to "". + + +optional + type: string + valueFrom: + allOf: + - $ref: "#/components/schemas/v1.EnvVarSource" + description: >- + Source for the environment variable's value. Cannot be used if value + is not empty. + + +optional + type: object + v1.EnvVarSource: + properties: + configMapKeyRef: + allOf: + - $ref: "#/components/schemas/v1.ConfigMapKeySelector" + description: |- + Selects a key of a ConfigMap. + +optional + fieldRef: + allOf: + - $ref: "#/components/schemas/v1.ObjectFieldSelector" + description: >- + Selects a field of the pod: supports metadata.name, + metadata.namespace, `metadata.labels['']`, + `metadata.annotations['']`, + + spec.nodeName, spec.serviceAccountName, status.hostIP, status.podIP, status.podIPs. + + +optional + fileKeyRef: + allOf: + - $ref: "#/components/schemas/v1.FileKeySelector" + description: |- + FileKeyRef selects a key of the env file. + Requires the EnvFiles feature gate to be enabled. + + +featureGate=EnvFiles + +optional + resourceFieldRef: + allOf: + - $ref: "#/components/schemas/v1.ResourceFieldSelector" + description: >- + Selects a resource of the container: only resources limits and + requests + + (limits.cpu, limits.memory, limits.ephemeral-storage, requests.cpu, requests.memory and requests.ephemeral-storage) are currently supported. + + +optional + secretKeyRef: + allOf: + - $ref: "#/components/schemas/v1.SecretKeySelector" + description: |- + Selects a key of a secret in the pod's namespace + +optional + type: object + v1.ExecAction: + properties: + command: + description: >- + Command is the command line to execute inside the container, the + working directory for the + + command is root ('/') in the container's filesystem. The command is simply exec'd, it is + + not run inside a shell, so traditional shell instructions ('|', etc) won't work. To use + + a shell, you need to explicitly call out to that shell. + + Exit status of 0 is treated as live/healthy and non-zero is unhealthy. + + +optional + + +listType=atomic + items: + type: string + type: array + type: object + v1.FieldsV1: + type: object + v1.FileKeySelector: + properties: + key: + description: >- + The key within the env file. An invalid key will prevent the pod + from starting. + + The keys defined within a source may consist of any printable ASCII characters except '='. + + During Alpha stage of the EnvFiles feature gate, the key size is limited to 128 characters. + + +required + type: string + optional: + description: >- + Specify whether the file or its key must be defined. If the file or + key + + does not exist, then the env var is not published. + + If optional is set to true and the specified key does not exist, + + the environment variable will not be set in the Pod's containers. + + + If optional is set to false and the specified key does not exist, + + an error will be returned during Pod creation. + + +optional + + +default=false + type: boolean + path: + description: >- + The path within the volume from which to select the file. + + Must be relative and may not contain the '..' path or start with '..'. + + +required + type: string + volumeName: + description: |- + The name of the volume mount containing the env file. + +required + type: string + type: object + v1.GRPCAction: + properties: + port: + description: Port number of the gRPC service. Number must be in the range 1 to + 65535. + type: integer + service: + description: >- + Service is the name of the service to place in the gRPC + HealthCheckRequest + + (see https://github.com/grpc/grpc/blob/master/doc/health-checking.md). + + + If this is not specified, the default behavior is defined by gRPC. + + +optional + + +default="" + type: string + type: object + v1.HTTPGetAction: + properties: + host: + description: >- + Host name to connect to, defaults to the pod IP. You probably want + to set + + "Host" in httpHeaders instead. + + +optional + type: string + httpHeaders: + description: |- + Custom headers to set in the request. HTTP allows repeated headers. + +optional + +listType=atomic + items: + $ref: "#/components/schemas/k8s_io_api_core_v1.HTTPHeader" + type: array + path: + description: |- + Path to access on the HTTP server. + +optional + type: string + port: + allOf: + - $ref: "#/components/schemas/intstr.IntOrString" + description: |- + Name or number of the port to access on the container. + Number must be in the range 1 to 65535. + Name must be an IANA_SVC_NAME. + scheme: + allOf: + - $ref: "#/components/schemas/v1.URIScheme" + description: |- + Scheme to use for connecting to the host. + Defaults to HTTP. + +optional + type: object + v1.Lifecycle: + properties: + postStart: + allOf: + - $ref: "#/components/schemas/v1.LifecycleHandler" + description: >- + PostStart is called immediately after a container is created. If the + handler fails, + + the container is terminated and restarted according to its restart policy. + + Other management of the container blocks until the hook completes. + + More info: https://kubernetes.io/docs/concepts/containers/container-lifecycle-hooks/#container-hooks + + +optional + preStop: + allOf: + - $ref: "#/components/schemas/v1.LifecycleHandler" + description: >- + PreStop is called immediately before a container is terminated due + to an + + API request or management event such as liveness/startup probe failure, + + preemption, resource contention, etc. The handler is not called if the + + container crashes or exits. The Pod's termination grace period countdown begins before the + + PreStop hook is executed. Regardless of the outcome of the handler, the + + container will eventually terminate within the Pod's termination grace + + period (unless delayed by finalizers). Other management of the container blocks until the hook completes + + or until the termination grace period is reached. + + More info: https://kubernetes.io/docs/concepts/containers/container-lifecycle-hooks/#container-hooks + + +optional + stopSignal: + allOf: + - $ref: "#/components/schemas/v1.Signal" + description: >- + StopSignal defines which signal will be sent to a container when it + is being stopped. + + If not specified, the default is defined by the container runtime in use. + + StopSignal can only be set for Pods with a non-empty .spec.os.name + + +optional + type: object + v1.LifecycleHandler: + properties: + exec: + allOf: + - $ref: "#/components/schemas/v1.ExecAction" + description: |- + Exec specifies a command to execute in the container. + +optional + httpGet: + allOf: + - $ref: "#/components/schemas/v1.HTTPGetAction" + description: |- + HTTPGet specifies an HTTP GET request to perform. + +optional + sleep: + allOf: + - $ref: "#/components/schemas/v1.SleepAction" + description: |- + Sleep represents a duration that the container should sleep. + +optional + tcpSocket: + allOf: + - $ref: "#/components/schemas/v1.TCPSocketAction" + description: >- + Deprecated. TCPSocket is NOT supported as a LifecycleHandler and + kept + + for backward compatibility. There is no validation of this field and + + lifecycle hooks will fail at runtime when it is specified. + + +optional + type: object + v1.ListMeta: + properties: + continue: + description: >- + continue may be set if the user set a limit on the number of items + returned, and indicates that + + the server has more data available. The value is opaque and may be used to issue another request + + to the endpoint that served this list to retrieve the next set of available objects. Continuing a + + consistent list may not be possible if the server configuration has changed or more than a few + + minutes have passed. The resourceVersion field returned when using this continue value will be + + identical to the value in the first response, unless you have received this token from an error + + message. + type: string + remainingItemCount: + description: >- + remainingItemCount is the number of subsequent items in the list + which are not included in this + + list response. If the list request contained label or field selectors, then the number of + + remaining items is unknown and the field will be left unset and omitted during serialization. + + If the list is complete (either because it is not chunking or because this is the last chunk), + + then there are no more remaining items and this field will be left unset and omitted during + + serialization. + + Servers older than v1.15 do not set this field. + + The intended use of the remainingItemCount is *estimating* the size of a collection. Clients + + should not rely on the remainingItemCount to be set or to be exact. + + +optional + type: integer + resourceVersion: + description: >- + String that identifies the server's internal version of this object + that + + can be used by clients to determine when objects have changed. + + Value must be treated as opaque by clients and passed unmodified back to the server. + + Populated by the system. + + Read-only. + + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency + + +optional + type: string + selfLink: + description: >- + Deprecated: selfLink is a legacy read-only field that is no longer + populated by the system. + + +optional + type: string + type: object + v1.ManagedFieldsEntry: + properties: + apiVersion: + description: |- + APIVersion defines the version of this resource that this field set + applies to. The format is "group/version" just like the top-level + APIVersion field. It is necessary to track the version of a field + set because it cannot be automatically converted. + type: string + fieldsType: + description: >- + FieldsType is the discriminator for the different fields format and + version. + + There is currently only one possible value: "FieldsV1" + type: string + fieldsV1: + allOf: + - $ref: "#/components/schemas/v1.FieldsV1" + description: >- + FieldsV1 holds the first JSON version format as described in the + "FieldsV1" type. + + +optional + manager: + description: Manager is an identifier of the workflow managing these fields. + type: string + operation: + allOf: + - $ref: "#/components/schemas/v1.ManagedFieldsOperationType" + description: >- + Operation is the type of operation which lead to this + ManagedFieldsEntry being created. + + The only valid values for this field are 'Apply' and 'Update'. + subresource: + description: >- + Subresource is the name of the subresource used to update that + object, or + + empty string if the object was updated through the main resource. The + + value of this field is used to distinguish between managers, even if they + + share the same name. For example, a status update will be distinct from a + + regular update using the same manager name. + + Note that the APIVersion field is not related to the Subresource field and + + it always corresponds to the version of the main resource. + type: string + time: + description: |- + Time is the timestamp of when the ManagedFields entry was added. The + timestamp will also be updated if a field is added, the manager + changes any of the owned fields value or removes a field. The + timestamp does not update when a field is removed from the entry + because another manager took it over. + +optional + type: string + type: object + v1.ManagedFieldsOperationType: + enum: + - Apply + - Update + type: string + x-enum-varnames: + - ManagedFieldsOperationApply + - ManagedFieldsOperationUpdate + v1.MountPropagationMode: + enum: + - None + - HostToContainer + - Bidirectional + type: string + x-enum-varnames: + - MountPropagationNone + - MountPropagationHostToContainer + - MountPropagationBidirectional + v1.NamespaceCondition: + properties: + lastTransitionTime: + description: |- + Last time the condition transitioned from one status to another. + +optional + type: string + message: + description: |- + Human-readable message indicating details about last transition. + +optional + type: string + reason: + description: >- + Unique, one-word, CamelCase reason for the condition's last + transition. + + +optional + type: string + status: + allOf: + - $ref: "#/components/schemas/k8s_io_api_core_v1.ConditionStatus" + description: Status of the condition, one of True, False, Unknown. + type: + allOf: + - $ref: "#/components/schemas/v1.NamespaceConditionType" + description: Type of namespace controller condition. + type: object + v1.NamespaceConditionType: + enum: + - NamespaceDeletionDiscoveryFailure + - NamespaceDeletionContentFailure + - NamespaceDeletionGroupVersionParsingFailure + - NamespaceContentRemaining + - NamespaceFinalizersRemaining + type: string + x-enum-varnames: + - NamespaceDeletionDiscoveryFailure + - NamespaceDeletionContentFailure + - NamespaceDeletionGVParsingFailure + - NamespaceContentRemaining + - NamespaceFinalizersRemaining + v1.NamespacePhase: + enum: + - Active + - Terminating + type: string + x-enum-varnames: + - NamespaceActive + - NamespaceTerminating + v1.NamespaceStatus: + properties: + conditions: + description: >- + Represents the latest available observations of a namespace's + current state. + + +optional + + +patchMergeKey=type + + +patchStrategy=merge + + +listType=map + + +listMapKey=type + items: + $ref: "#/components/schemas/v1.NamespaceCondition" + type: array + phase: + allOf: + - $ref: "#/components/schemas/v1.NamespacePhase" + description: >- + Phase is the current lifecycle phase of the namespace. + + More info: https://kubernetes.io/docs/tasks/administer-cluster/namespaces/ + + +optional + type: object + v1.NodeAddress: + properties: + address: + description: The node address. + type: string + type: + allOf: + - $ref: "#/components/schemas/v1.NodeAddressType" + description: Node address type, one of Hostname, ExternalIP or InternalIP. + type: object + v1.NodeAddressType: + enum: + - Hostname + - InternalIP + - ExternalIP + - InternalDNS + - ExternalDNS + type: string + x-enum-varnames: + - NodeHostName + - NodeInternalIP + - NodeExternalIP + - NodeInternalDNS + - NodeExternalDNS + v1.NodeCondition: + properties: + lastHeartbeatTime: + description: |- + Last time we got an update on a given condition. + +optional + type: string + lastTransitionTime: + description: |- + Last time the condition transit from one status to another. + +optional + type: string + message: + description: |- + Human readable message indicating details about last transition. + +optional + type: string + reason: + description: |- + (brief) reason for the condition's last transition. + +optional + type: string + status: + allOf: + - $ref: "#/components/schemas/k8s_io_api_core_v1.ConditionStatus" + description: Status of the condition, one of True, False, Unknown. + type: + allOf: + - $ref: "#/components/schemas/v1.NodeConditionType" + description: Type of node condition. + type: object + v1.NodeConditionType: + enum: + - Ready + - MemoryPressure + - DiskPressure + - PIDPressure + - NetworkUnavailable + type: string + x-enum-varnames: + - NodeReady + - NodeMemoryPressure + - NodeDiskPressure + - NodePIDPressure + - NodeNetworkUnavailable + v1.NodeConfigSource: + properties: + configMap: + allOf: + - $ref: "#/components/schemas/v1.ConfigMapNodeConfigSource" + description: ConfigMap is a reference to a Node's ConfigMap + type: object + v1.NodeConfigStatus: + properties: + active: + allOf: + - $ref: "#/components/schemas/v1.NodeConfigSource" + description: >- + Active reports the checkpointed config the node is actively using. + + Active will represent either the current version of the Assigned config, + + or the current LastKnownGood config, depending on whether attempting to use the + + Assigned config results in an error. + + +optional + assigned: + allOf: + - $ref: "#/components/schemas/v1.NodeConfigSource" + description: >- + Assigned reports the checkpointed config the node will try to use. + + When Node.Spec.ConfigSource is updated, the node checkpoints the associated + + config payload to local disk, along with a record indicating intended + + config. The node refers to this record to choose its config checkpoint, and + + reports this record in Assigned. Assigned only updates in the status after + + the record has been checkpointed to disk. When the Kubelet is restarted, + + it tries to make the Assigned config the Active config by loading and + + validating the checkpointed payload identified by Assigned. + + +optional + error: + description: >- + Error describes any problems reconciling the Spec.ConfigSource to + the Active config. + + Errors may occur, for example, attempting to checkpoint Spec.ConfigSource to the local Assigned + + record, attempting to checkpoint the payload associated with Spec.ConfigSource, attempting + + to load or validate the Assigned config, etc. + + Errors may occur at different points while syncing config. Earlier errors (e.g. download or + + checkpointing errors) will not result in a rollback to LastKnownGood, and may resolve across + + Kubelet retries. Later errors (e.g. loading or validating a checkpointed config) will result in + + a rollback to LastKnownGood. In the latter case, it is usually possible to resolve the error + + by fixing the config assigned in Spec.ConfigSource. + + You can find additional information for debugging by searching the error message in the Kubelet log. + + Error is a human-readable description of the error state; machines can check whether or not Error + + is empty, but should not rely on the stability of the Error text across Kubelet versions. + + +optional + type: string + lastKnownGood: + allOf: + - $ref: "#/components/schemas/v1.NodeConfigSource" + description: >- + LastKnownGood reports the checkpointed config the node will fall + back to + + when it encounters an error attempting to use the Assigned config. + + The Assigned config becomes the LastKnownGood config when the node determines + + that the Assigned config is stable and correct. + + This is currently implemented as a 10-minute soak period starting when the local + + record of Assigned config is updated. If the Assigned config is Active at the end + + of this period, it becomes the LastKnownGood. Note that if Spec.ConfigSource is + + reset to nil (use local defaults), the LastKnownGood is also immediately reset to nil, + + because the local default config is always assumed good. + + You should not make assumptions about the node's method of determining config stability + + and correctness, as this may change or become configurable in the future. + + +optional + type: object + v1.NodeDaemonEndpoints: + properties: + kubeletEndpoint: + allOf: + - $ref: "#/components/schemas/v1.DaemonEndpoint" + description: |- + Endpoint on which Kubelet is listening. + +optional + type: object + v1.NodeFeatures: + properties: + supplementalGroupsPolicy: + description: >- + SupplementalGroupsPolicy is set to true if the runtime supports + SupplementalGroupsPolicy and ContainerUser. + + +optional + type: boolean + type: object + v1.NodePhase: + enum: + - Pending + - Running + - Terminated + type: string + x-enum-varnames: + - NodePending + - NodeRunning + - NodeTerminated + v1.NodeRuntimeHandler: + properties: + features: + allOf: + - $ref: "#/components/schemas/v1.NodeRuntimeHandlerFeatures" + description: |- + Supported features. + +optional + name: + description: |- + Runtime handler name. + Empty for the default runtime handler. + +optional + type: string + type: object + v1.NodeRuntimeHandlerFeatures: + properties: + recursiveReadOnlyMounts: + description: >- + RecursiveReadOnlyMounts is set to true if the runtime handler + supports RecursiveReadOnlyMounts. + + +optional + type: boolean + userNamespaces: + description: >- + UserNamespaces is set to true if the runtime handler supports + UserNamespaces, including for volumes. + + +featureGate=UserNamespacesSupport + + +optional + type: boolean + type: object + v1.NodeSpec: + properties: + configSource: + allOf: + - $ref: "#/components/schemas/v1.NodeConfigSource" + description: >- + Deprecated: Previously used to specify the source of the node's + configuration for the DynamicKubeletConfig feature. This feature is + removed. + + +optional + externalID: + description: >- + Deprecated. Not all kubelets will set this field. Remove field after + 1.13. + + see: https://issues.k8s.io/61966 + + +optional + type: string + podCIDR: + description: |- + PodCIDR represents the pod IP range assigned to the node. + +optional + type: string + podCIDRs: + description: >- + podCIDRs represents the IP ranges assigned to the node for usage by + Pods on that node. If this + + field is specified, the 0th entry must match the podCIDR field. It may contain at most 1 value for + + each of IPv4 and IPv6. + + +optional + + +patchStrategy=merge + + +listType=set + items: + type: string + type: array + providerID: + description: >- + ID of the node assigned by the cloud provider in the format: + :// + + +optional + type: string + taints: + description: |- + If specified, the node's taints. + +optional + +listType=atomic + items: + $ref: "#/components/schemas/v1.Taint" + type: array + unschedulable: + description: >- + Unschedulable controls node schedulability of new pods. By default, + node is schedulable. + + More info: https://kubernetes.io/docs/concepts/nodes/node/#manual-node-administration + + +optional + type: boolean + type: object + v1.NodeStatus: + properties: + addresses: + description: >- + List of addresses reachable to the node. + + Queried from cloud provider, if available. + + More info: https://kubernetes.io/docs/reference/node/node-status/#addresses + + Note: This field is declared as mergeable, but the merge key is not sufficiently + + unique, which can cause data corruption when it is merged. Callers should instead + + use a full-replacement patch. See https://pr.k8s.io/79391 for an example. + + Consumers should assume that addresses can change during the + + lifetime of a Node. However, there are some exceptions where this may not + + be possible, such as Pods that inherit a Node's address in its own status or + + consumers of the downward API (status.hostIP). + + +optional + + +patchMergeKey=type + + +patchStrategy=merge + + +listType=map + + +listMapKey=type + items: + $ref: "#/components/schemas/v1.NodeAddress" + type: array + allocatable: + allOf: + - $ref: "#/components/schemas/v1.ResourceList" + description: >- + Allocatable represents the resources of a node that are available + for scheduling. + + Defaults to Capacity. + + +optional + capacity: + allOf: + - $ref: "#/components/schemas/v1.ResourceList" + description: >- + Capacity represents the total resources of a node. + + More info: https://kubernetes.io/docs/reference/node/node-status/#capacity + + +optional + conditions: + description: >- + Conditions is an array of current observed node conditions. + + More info: https://kubernetes.io/docs/reference/node/node-status/#condition + + +optional + + +patchMergeKey=type + + +patchStrategy=merge + + +listType=map + + +listMapKey=type + items: + $ref: "#/components/schemas/v1.NodeCondition" + type: array + config: + allOf: + - $ref: "#/components/schemas/v1.NodeConfigStatus" + description: >- + Status of the config assigned to the node via the dynamic Kubelet + config feature. + + +optional + daemonEndpoints: + allOf: + - $ref: "#/components/schemas/v1.NodeDaemonEndpoints" + description: |- + Endpoints of daemons running on the Node. + +optional + declaredFeatures: + description: >- + DeclaredFeatures represents the features related to feature gates + that are declared by the node. + + +featureGate=NodeDeclaredFeatures + + +optional + + +listType=atomic + items: + type: string + type: array + features: + allOf: + - $ref: "#/components/schemas/v1.NodeFeatures" + description: >- + Features describes the set of features implemented by the CRI + implementation. + + +featureGate=SupplementalGroupsPolicy + + +optional + images: + description: |- + List of container images on this node + +optional + +listType=atomic + items: + $ref: "#/components/schemas/v1.ContainerImage" + type: array + nodeInfo: + allOf: + - $ref: "#/components/schemas/v1.NodeSystemInfo" + description: >- + Set of ids/uuids to uniquely identify the node. + + More info: https://kubernetes.io/docs/reference/node/node-status/#info + + +optional + phase: + allOf: + - $ref: "#/components/schemas/v1.NodePhase" + description: |- + NodePhase is the recently observed lifecycle phase of the node. + More info: https://kubernetes.io/docs/concepts/nodes/node/#phase + The field is never populated, and now is deprecated. + +optional + runtimeHandlers: + description: |- + The available runtime handlers. + +featureGate=UserNamespacesSupport + +optional + +listType=atomic + items: + $ref: "#/components/schemas/v1.NodeRuntimeHandler" + type: array + volumesAttached: + description: |- + List of volumes that are attached to the node. + +optional + +listType=atomic + items: + $ref: "#/components/schemas/v1.AttachedVolume" + type: array + volumesInUse: + description: |- + List of attachable volumes in use (mounted) by the node. + +optional + +listType=atomic + items: + type: string + type: array + type: object + v1.NodeSwapStatus: + properties: + capacity: + description: |- + Total amount of swap memory in bytes. + +optional + type: integer + type: object + v1.NodeSystemInfo: + properties: + architecture: + description: The Architecture reported by the node + type: string + bootID: + description: Boot ID reported by the node. + type: string + containerRuntimeVersion: + description: ContainerRuntime Version reported by the node through runtime + remote API (e.g. containerd://1.4.2). + type: string + kernelVersion: + description: Kernel Version reported by the node from 'uname -r' (e.g. + 3.16.0-0.bpo.4-amd64). + type: string + kubeProxyVersion: + description: "Deprecated: KubeProxy Version reported by the node." + type: string + kubeletVersion: + description: Kubelet Version reported by the node. + type: string + machineID: + description: |- + MachineID reported by the node. For unique machine identification + in the cluster this field is preferred. Learn more from man(5) + machine-id: http://man7.org/linux/man-pages/man5/machine-id.5.html + type: string + operatingSystem: + description: The Operating System reported by the node + type: string + osImage: + description: OS Image reported by the node from /etc/os-release (e.g. Debian + GNU/Linux 7 (wheezy)). + type: string + swap: + allOf: + - $ref: "#/components/schemas/v1.NodeSwapStatus" + description: Swap Info reported by the node. + systemUUID: + description: >- + SystemUUID reported by the node. For unique machine identification + + MachineID is preferred. This field is specific to Red Hat hosts + + https://access.redhat.com/documentation/en-us/red_hat_subscription_management/1/html/rhsm/uuid + type: string + type: object + v1.ObjectFieldSelector: + properties: + apiVersion: + description: >- + Version of the schema the FieldPath is written in terms of, defaults + to "v1". + + +optional + type: string + fieldPath: + description: Path of the field to select in the specified API version. + type: string + type: object + v1.ObjectMeta: + properties: + annotations: + additionalProperties: + type: string + description: >- + Annotations is an unstructured key value map stored with a resource + that may be + + set by external tools to store and retrieve arbitrary metadata. They are not + + queryable and should be preserved when modifying objects. + + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations + + +optional + type: object + creationTimestamp: + description: >- + CreationTimestamp is a timestamp representing the server time when + this object was + + created. It is not guaranteed to be set in happens-before order across separate operations. + + Clients may not set this value. It is represented in RFC3339 form and is in UTC. + + + Populated by the system. + + Read-only. + + Null for lists. + + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata + + +optional + type: string + deletionGracePeriodSeconds: + description: >- + Number of seconds allowed for this object to gracefully terminate + before + + it will be removed from the system. Only set when deletionTimestamp is also set. + + May only be shortened. + + Read-only. + + +optional + type: integer + deletionTimestamp: + description: >- + DeletionTimestamp is RFC 3339 date and time at which this resource + will be deleted. This + + field is set by the server when a graceful deletion is requested by the user, and is not + + directly settable by a client. The resource is expected to be deleted (no longer visible + + from resource lists, and not reachable by name) after the time in this field, once the + + finalizers list is empty. As long as the finalizers list contains items, deletion is blocked. + + Once the deletionTimestamp is set, this value may not be unset or be set further into the + + future, although it may be shortened or the resource may be deleted prior to this time. + + For example, a user may request that a pod is deleted in 30 seconds. The Kubelet will react + + by sending a graceful termination signal to the containers in the pod. After that 30 seconds, + + the Kubelet will send a hard termination signal (SIGKILL) to the container and after cleanup, + + remove the pod from the API. In the presence of network partitions, this object may still + + exist after this timestamp, until an administrator or automated process can determine the + + resource is fully terminated. + + If not set, graceful deletion of the object has not been requested. + + + Populated by the system when a graceful deletion is requested. + + Read-only. + + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata + + +optional + type: string + finalizers: + description: >- + Must be empty before the object is deleted from the registry. Each + entry + + is an identifier for the responsible component that will remove the entry + + from the list. If the deletionTimestamp of the object is non-nil, entries + + in this list can only be removed. + + Finalizers may be processed and removed in any order. Order is NOT enforced + + because it introduces significant risk of stuck finalizers. + + finalizers is a shared field, any actor with permission can reorder it. + + If the finalizer list is processed in order, then this can lead to a situation + + in which the component responsible for the first finalizer in the list is + + waiting for a signal (field value, external system, or other) produced by a + + component responsible for a finalizer later in the list, resulting in a deadlock. + + Without enforced ordering finalizers are free to order amongst themselves and + + are not vulnerable to ordering changes in the list. + + +optional + + +patchStrategy=merge + + +listType=set + items: + type: string + type: array + generateName: + description: >- + GenerateName is an optional prefix, used by the server, to generate + a unique + + name ONLY IF the Name field has not been provided. + + If this field is used, the name returned to the client will be different + + than the name passed. This value will also be combined with a unique suffix. + + The provided value has the same validation rules as the Name field, + + and may be truncated by the length of the suffix required to make the value + + unique on the server. + + + If this field is specified and the generated name exists, the server will return a 409. + + + Applied only if Name is not specified. + + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#idempotency + + +optional + type: string + generation: + description: >- + A sequence number representing a specific generation of the desired + state. + + Populated by the system. Read-only. + + +optional + type: integer + labels: + additionalProperties: + type: string + description: >- + Map of string keys and values that can be used to organize and + categorize + + (scope and select) objects. May match selectors of replication controllers + + and services. + + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/labels + + +optional + type: object + managedFields: + description: |- + ManagedFields maps workflow-id and version to the set of fields + that are managed by that workflow. This is mostly for internal + housekeeping, and users typically shouldn't need to set or + understand this field. A workflow can be the user's name, a + controller's name, or the name of a specific apply path like + "ci-cd". The set of fields is always in the version that the + workflow used when modifying the object. + + +optional + +listType=atomic + items: + $ref: "#/components/schemas/v1.ManagedFieldsEntry" + type: array + name: + description: >- + Name must be unique within a namespace. Is required when creating + resources, although + + some resources may allow a client to request the generation of an appropriate name + + automatically. Name is primarily intended for creation idempotence and configuration + + definition. + + Cannot be updated. + + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#names + + +optional + type: string + namespace: + description: >- + Namespace defines the space within which each name must be unique. + An empty namespace is + + equivalent to the "default" namespace, but "default" is the canonical representation. + + Not all objects are required to be scoped to a namespace - the value of this field for + + those objects will be empty. + + + Must be a DNS_LABEL. + + Cannot be updated. + + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces + + +optional + type: string + ownerReferences: + description: >- + List of objects depended by this object. If ALL objects in the list + have + + been deleted, this object will be garbage collected. If this object is managed by a controller, + + then an entry in this list will point to this controller, with the controller field set to true. + + There cannot be more than one managing controller. + + +optional + + +patchMergeKey=uid + + +patchStrategy=merge + + +listType=map + + +listMapKey=uid + items: + $ref: "#/components/schemas/v1.OwnerReference" + type: array + resourceVersion: + description: >- + An opaque value that represents the internal version of this object + that can + + be used by clients to determine when objects have changed. May be used for optimistic + + concurrency, change detection, and the watch operation on a resource or set of resources. + + Clients must treat these values as opaque and passed unmodified back to the server. + + They may only be valid for a particular resource or set of resources. + + + Populated by the system. + + Read-only. + + Value must be treated as opaque by clients and . + + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency + + +optional + type: string + selfLink: + description: >- + Deprecated: selfLink is a legacy read-only field that is no longer + populated by the system. + + +optional + type: string + uid: + description: >- + UID is the unique in time and space value for this object. It is + typically generated by + + the server on successful creation of a resource and is not allowed to change on PUT + + operations. + + + Populated by the system. + + Read-only. + + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#uids + + +optional + type: string + type: object + v1.OwnerReference: + properties: + apiVersion: + description: API version of the referent. + type: string + blockOwnerDeletion: + description: >- + If true, AND if the owner has the "foregroundDeletion" finalizer, + then + + the owner cannot be deleted from the key-value store until this + + reference is removed. + + See https://kubernetes.io/docs/concepts/architecture/garbage-collection/#foreground-deletion + + for how the garbage collector interacts with this field and enforces the foreground deletion. + + Defaults to false. + + To set this field, a user needs "delete" permission of the owner, + + otherwise 422 (Unprocessable Entity) will be returned. + + +optional + type: boolean + controller: + description: |- + If true, this reference points to the managing controller. + +optional + type: boolean + kind: + description: >- + Kind of the referent. + + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + name: + description: >- + Name of the referent. + + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#names + type: string + uid: + description: >- + UID of the referent. + + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#uids + type: string + type: object + v1.PersistentVolumeAccessMode: + enum: + - ReadWriteOnce + - ReadOnlyMany + - ReadWriteMany + - ReadWriteOncePod + type: string + x-enum-varnames: + - ReadWriteOnce + - ReadOnlyMany + - ReadWriteMany + - ReadWriteOncePod + v1.PersistentVolumeClaimPhase: + enum: + - Pending + - Bound + - Lost + type: string + x-enum-varnames: + - ClaimPending + - ClaimBound + - ClaimLost + v1.PersistentVolumeMode: + enum: + - Block + - Filesystem + type: string + x-enum-varnames: + - PersistentVolumeBlock + - PersistentVolumeFilesystem + v1.PersistentVolumePhase: + enum: + - Pending + - Available + - Bound + - Released + - Failed + type: string + x-enum-varnames: + - VolumePending + - VolumeAvailable + - VolumeBound + - VolumeReleased + - VolumeFailed + v1.PersistentVolumeReclaimPolicy: + enum: + - Recycle + - Delete + - Retain + type: string + x-enum-varnames: + - PersistentVolumeReclaimRecycle + - PersistentVolumeReclaimDelete + - PersistentVolumeReclaimRetain + v1.Probe: + properties: + exec: + allOf: + - $ref: "#/components/schemas/v1.ExecAction" + description: |- + Exec specifies a command to execute in the container. + +optional + failureThreshold: + description: >- + Minimum consecutive failures for the probe to be considered failed + after having succeeded. + + Defaults to 3. Minimum value is 1. + + +optional + type: integer + grpc: + allOf: + - $ref: "#/components/schemas/v1.GRPCAction" + description: |- + GRPC specifies a GRPC HealthCheckRequest. + +optional + httpGet: + allOf: + - $ref: "#/components/schemas/v1.HTTPGetAction" + description: |- + HTTPGet specifies an HTTP GET request to perform. + +optional + initialDelaySeconds: + description: >- + Number of seconds after the container has started before liveness + probes are initiated. + + More info: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle#container-probes + + +optional + type: integer + periodSeconds: + description: |- + How often (in seconds) to perform the probe. + Default to 10 seconds. Minimum value is 1. + +optional + type: integer + successThreshold: + description: >- + Minimum consecutive successes for the probe to be considered + successful after having failed. + + Defaults to 1. Must be 1 for liveness and startup. Minimum value is 1. + + +optional + type: integer + tcpSocket: + allOf: + - $ref: "#/components/schemas/v1.TCPSocketAction" + description: |- + TCPSocket specifies a connection to a TCP port. + +optional + terminationGracePeriodSeconds: + description: >- + Optional duration in seconds the pod needs to terminate gracefully + upon probe failure. + + The grace period is the duration in seconds after the processes running in the pod are sent + + a termination signal and the time when the processes are forcibly halted with a kill signal. + + Set this value longer than the expected cleanup time for your process. + + If this value is nil, the pod's terminationGracePeriodSeconds will be used. Otherwise, this + + value overrides the value provided by the pod spec. + + Value must be non-negative integer. The value zero indicates stop immediately via + + the kill signal (no opportunity to shut down). + + This is a beta field and requires enabling ProbeTerminationGracePeriod feature gate. + + Minimum value is 1. spec.terminationGracePeriodSeconds is used if unset. + + +optional + type: integer + timeoutSeconds: + description: >- + Number of seconds after which the probe times out. + + Defaults to 1 second. Minimum value is 1. + + More info: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle#container-probes + + +optional + type: integer + type: object + v1.ProcMountType: + enum: + - Default + - Unmasked + type: string + x-enum-varnames: + - DefaultProcMount + - UnmaskedProcMount + v1.Protocol: + enum: + - TCP + - UDP + - SCTP + type: string + x-enum-varnames: + - ProtocolTCP + - ProtocolUDP + - ProtocolSCTP + v1.PullPolicy: + enum: + - Always + - Never + - IfNotPresent + type: string + x-enum-varnames: + - PullAlways + - PullNever + - PullIfNotPresent + v1.RecursiveReadOnlyMode: + enum: + - Disabled + - IfPossible + - Enabled + type: string + x-enum-varnames: + - RecursiveReadOnlyDisabled + - RecursiveReadOnlyIfPossible + - RecursiveReadOnlyEnabled + v1.ResourceFieldSelector: + properties: + containerName: + description: |- + Container name: required for volumes, optional for env vars + +optional + type: string + divisor: + allOf: + - $ref: "#/components/schemas/resource.Quantity" + description: >- + Specifies the output format of the exposed resources, defaults to + "1" + + +optional + resource: + description: "Required: resource to select" + type: string + type: object + v1.ResourceList: + additionalProperties: + $ref: "#/components/schemas/resource.Quantity" + type: object + v1.ResourceName: + enum: + - cpu + - memory + - storage + - ephemeral-storage + - pods + - services + - replicationcontrollers + - resourcequotas + - secrets + - configmaps + - persistentvolumeclaims + - services.nodeports + - services.loadbalancers + - requests.cpu + - requests.memory + - requests.storage + - requests.ephemeral-storage + - limits.cpu + - limits.memory + - limits.ephemeral-storage + type: string + x-enum-varnames: + - ResourceCPU + - ResourceMemory + - ResourceStorage + - ResourceEphemeralStorage + - ResourcePods + - ResourceServices + - ResourceReplicationControllers + - ResourceQuotas + - ResourceSecrets + - ResourceConfigMaps + - ResourcePersistentVolumeClaims + - ResourceServicesNodePorts + - ResourceServicesLoadBalancers + - ResourceRequestsCPU + - ResourceRequestsMemory + - ResourceRequestsStorage + - ResourceRequestsEphemeralStorage + - ResourceLimitsCPU + - ResourceLimitsMemory + - ResourceLimitsEphemeralStorage + v1.ResourceQuota: + properties: + apiVersion: + description: >- + APIVersion defines the versioned schema of this representation of an + object. + + Servers should convert recognized schemas to the latest internal value, and + + may reject unrecognized values. + + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + + +optional + type: string + kind: + description: >- + Kind is a string value representing the REST resource this object + represents. + + Servers may infer this from the endpoint the client submits requests to. + + Cannot be updated. + + In CamelCase. + + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + + +optional + type: string + metadata: + allOf: + - $ref: "#/components/schemas/v1.ObjectMeta" + description: >- + Standard object's metadata. + + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata + + +optional + spec: + allOf: + - $ref: "#/components/schemas/v1.ResourceQuotaSpec" + description: >- + Spec defines the desired quota. + + https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status + + +optional + status: + allOf: + - $ref: "#/components/schemas/v1.ResourceQuotaStatus" + description: >- + Status defines the actual enforced quota and its current usage. + + https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status + + +optional + type: object + v1.ResourceQuotaScope: + enum: + - Terminating + - NotTerminating + - BestEffort + - NotBestEffort + - PriorityClass + - CrossNamespacePodAffinity + - VolumeAttributesClass + type: string + x-enum-varnames: + - ResourceQuotaScopeTerminating + - ResourceQuotaScopeNotTerminating + - ResourceQuotaScopeBestEffort + - ResourceQuotaScopeNotBestEffort + - ResourceQuotaScopePriorityClass + - ResourceQuotaScopeCrossNamespacePodAffinity + - ResourceQuotaScopeVolumeAttributesClass + v1.ResourceQuotaSpec: + properties: + hard: + allOf: + - $ref: "#/components/schemas/v1.ResourceList" + description: >- + hard is the set of desired hard limits for each named resource. + + More info: https://kubernetes.io/docs/concepts/policy/resource-quotas/ + + +optional + scopeSelector: + allOf: + - $ref: "#/components/schemas/v1.ScopeSelector" + description: >- + scopeSelector is also a collection of filters like scopes that must + match each object tracked by a quota + + but expressed using ScopeSelectorOperator in combination with possible values. + + For a resource to match, both scopes AND scopeSelector (if specified in spec), must be matched. + + +optional + scopes: + description: >- + A collection of filters that must match each object tracked by a + quota. + + If not specified, the quota matches all objects. + + +optional + + +listType=atomic + items: + $ref: "#/components/schemas/v1.ResourceQuotaScope" + type: array + type: object + v1.ResourceQuotaStatus: + properties: + hard: + allOf: + - $ref: "#/components/schemas/v1.ResourceList" + description: >- + Hard is the set of enforced hard limits for each named resource. + + More info: https://kubernetes.io/docs/concepts/policy/resource-quotas/ + + +optional + used: + allOf: + - $ref: "#/components/schemas/v1.ResourceList" + description: >- + Used is the current observed total usage of the resource in the + namespace. + + +optional + type: object + v1.ResourceRequirements: + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + + This field depends on the + DynamicResourceAllocation feature gate. + + This field is immutable. It can only be set for containers. + + +listType=map + +listMapKey=name + +featureGate=DynamicResourceAllocation + +optional + items: + $ref: "#/components/schemas/k8s_io_api_core_v1.ResourceClaim" + type: array + limits: + allOf: + - $ref: "#/components/schemas/v1.ResourceList" + description: >- + Limits describes the maximum amount of compute resources allowed. + + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + + +optional + requests: + allOf: + - $ref: "#/components/schemas/v1.ResourceList" + description: >- + Requests describes the minimum amount of compute resources required. + + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + + otherwise to an implementation-defined value. Requests cannot exceed Limits. + + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + + +optional + type: object + v1.ResourceResizeRestartPolicy: + enum: + - NotRequired + - RestartContainer + type: string + x-enum-varnames: + - NotRequired + - RestartContainer + v1.RoleRef: + properties: + apiGroup: + description: APIGroup is the group for the resource being referenced + type: string + kind: + description: Kind is the type of resource being referenced + type: string + name: + description: |- + Name is the name of resource being referenced + +required + +k8s:required + type: string + type: object + v1.SELinuxOptions: + properties: + level: + description: |- + Level is SELinux level label that applies to the container. + +optional + type: string + role: + description: |- + Role is a SELinux role label that applies to the container. + +optional + type: string + type: + description: |- + Type is a SELinux type label that applies to the container. + +optional + type: string + user: + description: |- + User is a SELinux user label that applies to the container. + +optional + type: string + type: object + v1.ScopeSelector: + properties: + matchExpressions: + description: |- + A list of scope selector requirements by scope of the resources. + +optional + +listType=atomic + items: + $ref: "#/components/schemas/v1.ScopedResourceSelectorRequirement" + type: array + type: object + v1.ScopeSelectorOperator: + enum: + - In + - NotIn + - Exists + - DoesNotExist + type: string + x-enum-varnames: + - ScopeSelectorOpIn + - ScopeSelectorOpNotIn + - ScopeSelectorOpExists + - ScopeSelectorOpDoesNotExist + v1.ScopedResourceSelectorRequirement: + properties: + operator: + allOf: + - $ref: "#/components/schemas/v1.ScopeSelectorOperator" + description: |- + Represents a scope's relationship to a set of values. + Valid operators are In, NotIn, Exists, DoesNotExist. + scopeName: + allOf: + - $ref: "#/components/schemas/v1.ResourceQuotaScope" + description: The name of the scope that the selector applies to. + values: + description: >- + An array of string values. If the operator is In or NotIn, + + the values array must be non-empty. If the operator is Exists or DoesNotExist, + + the values array must be empty. + + This array is replaced during a strategic merge patch. + + +optional + + +listType=atomic + items: + type: string + type: array + type: object + v1.SeccompProfile: + properties: + localhostProfile: + description: >- + localhostProfile indicates a profile defined in a file on the node + should be used. + + The profile must be preconfigured on the node to work. + + Must be a descending path, relative to the kubelet's configured seccomp profile location. + + Must be set if type is "Localhost". Must NOT be set for any other type. + + +optional + type: string + type: + allOf: + - $ref: "#/components/schemas/v1.SeccompProfileType" + description: >- + type indicates which kind of seccomp profile will be applied. + + Valid options are: + + + Localhost - a profile defined in a file on the node should be used. + + RuntimeDefault - the container runtime default profile should be used. + + Unconfined - no profile should be applied. + + +unionDiscriminator + type: object + v1.SeccompProfileType: + enum: + - Unconfined + - RuntimeDefault + - Localhost + type: string + x-enum-varnames: + - SeccompProfileTypeUnconfined + - SeccompProfileTypeRuntimeDefault + - SeccompProfileTypeLocalhost + v1.SecretEnvSource: + properties: + name: + description: >- + Name of the referent. + + This field is effectively required, but due to backwards compatibility is + + allowed to be empty. Instances of this type with an empty value here are + + almost certainly wrong. + + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + + +optional + + +default="" + + +kubebuilder:default="" + + TODO: Drop `kubebuilder:default` when controller-gen doesn't need it https://github.com/kubernetes-sigs/kubebuilder/issues/3896. + type: string + optional: + description: |- + Specify whether the Secret must be defined + +optional + type: boolean + type: object + v1.SecretKeySelector: + properties: + key: + description: The key of the secret to select from. Must be a valid secret key. + type: string + name: + description: >- + Name of the referent. + + This field is effectively required, but due to backwards compatibility is + + allowed to be empty. Instances of this type with an empty value here are + + almost certainly wrong. + + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + + +optional + + +default="" + + +kubebuilder:default="" + + TODO: Drop `kubebuilder:default` when controller-gen doesn't need it https://github.com/kubernetes-sigs/kubebuilder/issues/3896. + type: string + optional: + description: |- + Specify whether the Secret or its key must be defined + +optional + type: boolean + type: object + v1.SecretReference: + properties: + name: + description: |- + name is unique within a namespace to reference a secret resource. + +optional + type: string + namespace: + description: >- + namespace defines the space within which the secret name must be + unique. + + +optional + type: string + type: object + v1.SecurityContext: + properties: + allowPrivilegeEscalation: + description: |- + AllowPrivilegeEscalation controls whether a process can gain more + privileges than its parent process. This bool directly controls if + the no_new_privs flag will be set on the container process. + AllowPrivilegeEscalation is true always when the container is: + 1) run as Privileged + 2) has CAP_SYS_ADMIN + Note that this field cannot be set when spec.os.name is windows. + +optional + type: boolean + appArmorProfile: + allOf: + - $ref: "#/components/schemas/v1.AppArmorProfile" + description: >- + appArmorProfile is the AppArmor options to use by this container. If + set, this profile + + overrides the pod's appArmorProfile. + + Note that this field cannot be set when spec.os.name is windows. + + +optional + capabilities: + allOf: + - $ref: "#/components/schemas/v1.Capabilities" + description: >- + The capabilities to add/drop when running containers. + + Defaults to the default set of capabilities granted by the container runtime. + + Note that this field cannot be set when spec.os.name is windows. + + +optional + privileged: + description: >- + Run container in privileged mode. + + Processes in privileged containers are essentially equivalent to root on the host. + + Defaults to false. + + Note that this field cannot be set when spec.os.name is windows. + + +optional + type: boolean + procMount: + allOf: + - $ref: "#/components/schemas/v1.ProcMountType" + description: >- + procMount denotes the type of proc mount to use for the containers. + + The default value is Default which uses the container runtime defaults for + + readonly paths and masked paths. + + This requires the ProcMountType feature flag to be enabled. + + Note that this field cannot be set when spec.os.name is windows. + + +optional + readOnlyRootFilesystem: + description: |- + Whether this container has a read-only root filesystem. + Default is false. + Note that this field cannot be set when spec.os.name is windows. + +optional + type: boolean + runAsGroup: + description: >- + The GID to run the entrypoint of the container process. + + Uses runtime default if unset. + + May also be set in PodSecurityContext. If set in both SecurityContext and + + PodSecurityContext, the value specified in SecurityContext takes precedence. + + Note that this field cannot be set when spec.os.name is windows. + + +optional + type: integer + runAsNonRoot: + description: >- + Indicates that the container must run as a non-root user. + + If true, the Kubelet will validate the image at runtime to ensure that it + + does not run as UID 0 (root) and fail to start the container if it does. + + If unset or false, no such validation will be performed. + + May also be set in PodSecurityContext. If set in both SecurityContext and + + PodSecurityContext, the value specified in SecurityContext takes precedence. + + +optional + type: boolean + runAsUser: + description: >- + The UID to run the entrypoint of the container process. + + Defaults to user specified in image metadata if unspecified. + + May also be set in PodSecurityContext. If set in both SecurityContext and + + PodSecurityContext, the value specified in SecurityContext takes precedence. + + Note that this field cannot be set when spec.os.name is windows. + + +optional + type: integer + seLinuxOptions: + allOf: + - $ref: "#/components/schemas/v1.SELinuxOptions" + description: >- + The SELinux context to be applied to the container. + + If unspecified, the container runtime will allocate a random SELinux context for each + + container. May also be set in PodSecurityContext. If set in both SecurityContext and + + PodSecurityContext, the value specified in SecurityContext takes precedence. + + Note that this field cannot be set when spec.os.name is windows. + + +optional + seccompProfile: + allOf: + - $ref: "#/components/schemas/v1.SeccompProfile" + description: |- + The seccomp options to use by this container. If seccomp options are + provided at both the pod & container level, the container options + override the pod options. + Note that this field cannot be set when spec.os.name is windows. + +optional + windowsOptions: + allOf: + - $ref: "#/components/schemas/v1.WindowsSecurityContextOptions" + description: >- + The Windows specific settings applied to all containers. + + If unspecified, the options from the PodSecurityContext will be used. + + If set in both SecurityContext and PodSecurityContext, the value specified in SecurityContext takes precedence. + + Note that this field cannot be set when spec.os.name is linux. + + +optional + type: object + v1.Signal: + enum: + - SIGABRT + - SIGALRM + - SIGBUS + - SIGCHLD + - SIGCLD + - SIGCONT + - SIGFPE + - SIGHUP + - SIGILL + - SIGINT + - SIGIO + - SIGIOT + - SIGKILL + - SIGPIPE + - SIGPOLL + - SIGPROF + - SIGPWR + - SIGQUIT + - SIGSEGV + - SIGSTKFLT + - SIGSTOP + - SIGSYS + - SIGTERM + - SIGTRAP + - SIGTSTP + - SIGTTIN + - SIGTTOU + - SIGURG + - SIGUSR1 + - SIGUSR2 + - SIGVTALRM + - SIGWINCH + - SIGXCPU + - SIGXFSZ + - SIGRTMIN + - SIGRTMIN+1 + - SIGRTMIN+2 + - SIGRTMIN+3 + - SIGRTMIN+4 + - SIGRTMIN+5 + - SIGRTMIN+6 + - SIGRTMIN+7 + - SIGRTMIN+8 + - SIGRTMIN+9 + - SIGRTMIN+10 + - SIGRTMIN+11 + - SIGRTMIN+12 + - SIGRTMIN+13 + - SIGRTMIN+14 + - SIGRTMIN+15 + - SIGRTMAX-14 + - SIGRTMAX-13 + - SIGRTMAX-12 + - SIGRTMAX-11 + - SIGRTMAX-10 + - SIGRTMAX-9 + - SIGRTMAX-8 + - SIGRTMAX-7 + - SIGRTMAX-6 + - SIGRTMAX-5 + - SIGRTMAX-4 + - SIGRTMAX-3 + - SIGRTMAX-2 + - SIGRTMAX-1 + - SIGRTMAX + type: string + x-enum-varnames: + - SIGABRT + - SIGALRM + - SIGBUS + - SIGCHLD + - SIGCLD + - SIGCONT + - SIGFPE + - SIGHUP + - SIGILL + - SIGINT + - SIGIO + - SIGIOT + - SIGKILL + - SIGPIPE + - SIGPOLL + - SIGPROF + - SIGPWR + - SIGQUIT + - SIGSEGV + - SIGSTKFLT + - SIGSTOP + - SIGSYS + - SIGTERM + - SIGTRAP + - SIGTSTP + - SIGTTIN + - SIGTTOU + - SIGURG + - SIGUSR1 + - SIGUSR2 + - SIGVTALRM + - SIGWINCH + - SIGXCPU + - SIGXFSZ + - SIGRTMIN + - SIGRTMINPLUS1 + - SIGRTMINPLUS2 + - SIGRTMINPLUS3 + - SIGRTMINPLUS4 + - SIGRTMINPLUS5 + - SIGRTMINPLUS6 + - SIGRTMINPLUS7 + - SIGRTMINPLUS8 + - SIGRTMINPLUS9 + - SIGRTMINPLUS10 + - SIGRTMINPLUS11 + - SIGRTMINPLUS12 + - SIGRTMINPLUS13 + - SIGRTMINPLUS14 + - SIGRTMINPLUS15 + - SIGRTMAXMINUS14 + - SIGRTMAXMINUS13 + - SIGRTMAXMINUS12 + - SIGRTMAXMINUS11 + - SIGRTMAXMINUS10 + - SIGRTMAXMINUS9 + - SIGRTMAXMINUS8 + - SIGRTMAXMINUS7 + - SIGRTMAXMINUS6 + - SIGRTMAXMINUS5 + - SIGRTMAXMINUS4 + - SIGRTMAXMINUS3 + - SIGRTMAXMINUS2 + - SIGRTMAXMINUS1 + - SIGRTMAX + v1.SleepAction: + properties: + seconds: + description: Seconds is the number of seconds to sleep. + type: integer + type: object + v1.TCPSocketAction: + properties: + host: + description: |- + Optional: Host name to connect to, defaults to the pod IP. + +optional + type: string + port: + allOf: + - $ref: "#/components/schemas/intstr.IntOrString" + description: |- + Number or name of the port to access on the container. + Number must be in the range 1 to 65535. + Name must be an IANA_SVC_NAME. + type: object + v1.Taint: + properties: + effect: + allOf: + - $ref: "#/components/schemas/v1.TaintEffect" + description: |- + Required. The effect of the taint on pods + that do not tolerate the taint. + Valid effects are NoSchedule, PreferNoSchedule and NoExecute. + key: + description: Required. The taint key to be applied to a node. + type: string + timeAdded: + description: |- + TimeAdded represents the time at which the taint was added. + +optional + type: string + value: + description: |- + The taint value corresponding to the taint key. + +optional + type: string + type: object + v1.TaintEffect: + enum: + - NoSchedule + - PreferNoSchedule + - NoExecute + type: string + x-enum-varnames: + - TaintEffectNoSchedule + - TaintEffectPreferNoSchedule + - TaintEffectNoExecute + v1.TerminationMessagePolicy: + enum: + - File + - FallbackToLogsOnError + type: string + x-enum-varnames: + - TerminationMessageReadFile + - TerminationMessageFallbackToLogsOnError + v1.URIScheme: + enum: + - HTTP + - HTTPS + type: string + x-enum-varnames: + - URISchemeHTTP + - URISchemeHTTPS + v1.VolumeDevice: + properties: + devicePath: + description: devicePath is the path inside of the container that the device will + be mapped to. + type: string + name: + description: name must match the name of a persistentVolumeClaim in the pod + type: string + type: object + v1.VolumeMount: + properties: + mountPath: + description: >- + Path within the container at which the volume should be + mounted. Must + + not contain ':'. + type: string + mountPropagation: + allOf: + - $ref: "#/components/schemas/v1.MountPropagationMode" + description: >- + mountPropagation determines how mounts are propagated from the host + + to container and the other way around. + + When not set, MountPropagationNone is used. + + This field is beta in 1.10. + + When RecursiveReadOnly is set to IfPossible or to Enabled, MountPropagation must be None or unspecified + + (which defaults to None). + + +optional + name: + description: This must match the Name of a Volume. + type: string + readOnly: + description: >- + Mounted read-only if true, read-write otherwise (false or + unspecified). + + Defaults to false. + + +optional + type: boolean + recursiveReadOnly: + allOf: + - $ref: "#/components/schemas/v1.RecursiveReadOnlyMode" + description: >- + RecursiveReadOnly specifies whether read-only mounts should be + handled + + recursively. + + + If ReadOnly is false, this field has no meaning and must be unspecified. + + + If ReadOnly is true, and this field is set to Disabled, the mount is not made + + recursively read-only. If this field is set to IfPossible, the mount is made + + recursively read-only, if it is supported by the container runtime. If this + + field is set to Enabled, the mount is made recursively read-only if it is + + supported by the container runtime, otherwise the pod will not be started and + + an error will be generated to indicate the reason. + + + If this field is set to IfPossible or Enabled, MountPropagation must be set to + + None (or be unspecified, which defaults to None). + + + If this field is not specified, it is treated as an equivalent of Disabled. + + +optional + subPath: + description: >- + Path within the volume from which the container's volume should be + mounted. + + Defaults to "" (volume's root). + + +optional + type: string + subPathExpr: + description: >- + Expanded path within the volume from which the container's volume + should be mounted. + + Behaves similarly to SubPath but environment variable references $(VAR_NAME) are expanded using the container's environment. + + Defaults to "" (volume's root). + + SubPathExpr and SubPath are mutually exclusive. + + +optional + type: string + type: object + v1.WindowsSecurityContextOptions: + properties: + gmsaCredentialSpec: + description: >- + GMSACredentialSpec is where the GMSA admission webhook + + (https://github.com/kubernetes-sigs/windows-gmsa) inlines the contents of the + + GMSA credential spec named by the GMSACredentialSpecName field. + + +optional + type: string + gmsaCredentialSpecName: + description: >- + GMSACredentialSpecName is the name of the GMSA credential spec to + use. + + +optional + type: string + hostProcess: + description: >- + HostProcess determines if a container should be run as a 'Host + Process' container. + + All of a Pod's containers must have the same effective HostProcess value + + (it is not allowed to have a mix of HostProcess containers and non-HostProcess containers). + + In addition, if HostProcess is true then HostNetwork must also be set to true. + + +optional + type: boolean + runAsUserName: + description: >- + The UserName in Windows to run the entrypoint of the container + process. + + Defaults to the user specified in image metadata if unspecified. + + May also be set in PodSecurityContext. If set in both SecurityContext and + + PodSecurityContext, the value specified in SecurityContext takes precedence. + + +optional + type: string + type: object + v1beta1.ContainerMetrics: + properties: + name: + description: Container name corresponding to the one from pod.spec.containers. + type: string + usage: + allOf: + - $ref: "#/components/schemas/v1.ResourceList" + description: The memory usage is the memory working set. + type: object + v1beta1.NodeMetrics: + properties: + apiVersion: + description: >- + APIVersion defines the versioned schema of this representation of an + object. + + Servers should convert recognized schemas to the latest internal value, and + + may reject unrecognized values. + + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + + +optional + type: string + kind: + description: >- + Kind is a string value representing the REST resource this object + represents. + + Servers may infer this from the endpoint the client submits requests to. + + Cannot be updated. + + In CamelCase. + + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + + +optional + type: string + metadata: + allOf: + - $ref: "#/components/schemas/v1.ObjectMeta" + description: >- + Standard object's metadata. + + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata + + +optional + timestamp: + description: |- + The following fields define time interval from which metrics were + collected from the interval [Timestamp-Window, Timestamp]. + type: string + usage: + allOf: + - $ref: "#/components/schemas/v1.ResourceList" + description: The memory usage is the memory working set. + window: + type: string + type: object + v1beta1.NodeMetricsList: + properties: + apiVersion: + description: >- + APIVersion defines the versioned schema of this representation of an + object. + + Servers should convert recognized schemas to the latest internal value, and + + may reject unrecognized values. + + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + + +optional + type: string + items: + description: List of node metrics. + items: + $ref: "#/components/schemas/v1beta1.NodeMetrics" + type: array + kind: + description: >- + Kind is a string value representing the REST resource this object + represents. + + Servers may infer this from the endpoint the client submits requests to. + + Cannot be updated. + + In CamelCase. + + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + + +optional + type: string + metadata: + allOf: + - $ref: "#/components/schemas/v1.ListMeta" + description: >- + Standard list metadata. + + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: object + v1beta1.PodMetrics: + properties: + apiVersion: + description: >- + APIVersion defines the versioned schema of this representation of an + object. + + Servers should convert recognized schemas to the latest internal value, and + + may reject unrecognized values. + + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + + +optional + type: string + containers: + description: >- + Metrics for all containers are collected within the same time + window. + + +listType=atomic + items: + $ref: "#/components/schemas/v1beta1.ContainerMetrics" + type: array + kind: + description: >- + Kind is a string value representing the REST resource this object + represents. + + Servers may infer this from the endpoint the client submits requests to. + + Cannot be updated. + + In CamelCase. + + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + + +optional + type: string + metadata: + allOf: + - $ref: "#/components/schemas/v1.ObjectMeta" + description: >- + Standard object's metadata. + + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata + + +optional + timestamp: + description: |- + The following fields define time interval from which metrics were + collected from the interval [Timestamp-Window, Timestamp]. + type: string + window: + type: string + type: object + v1beta1.PodMetricsList: + properties: + apiVersion: + description: >- + APIVersion defines the versioned schema of this representation of an + object. + + Servers should convert recognized schemas to the latest internal value, and + + may reject unrecognized values. + + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + + +optional + type: string + items: + description: List of pod metrics. + items: + $ref: "#/components/schemas/v1beta1.PodMetrics" + type: array + kind: + description: >- + Kind is a string value representing the REST resource this object + represents. + + Servers may infer this from the endpoint the client submits requests to. + + Cannot be updated. + + In CamelCase. + + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + + +optional + type: string + metadata: + allOf: + - $ref: "#/components/schemas/v1.ListMeta" + description: >- + Standard list metadata. + + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: object + webhooks.webhookCreatePayload: + properties: + EndpointID: + type: integer + RegistryID: + type: integer + ResourceID: + type: string + WebhookType: + allOf: + - $ref: "#/components/schemas/portainer.WebhookType" + description: Type of webhook (1 - service) + type: object + webhooks.webhookUpdatePayload: + properties: + RegistryID: + type: integer + type: object + workflows.ArtifactDetail: + properties: + autoUpdate: + $ref: "#/components/schemas/portainer.AutoUpdateSettings" + creationDate: + type: integer + files: + items: + $ref: "#/components/schemas/workflows.ArtifactFileDetail" + type: array + id: + type: integer + lastSyncDate: + type: integer + name: + type: string + platform: + $ref: "#/components/schemas/workflows.DeploymentPlatform" + status: + $ref: "#/components/schemas/workflows.WorkflowStatusObject" + target: + $ref: "#/components/schemas/workflows.Target" + type: + $ref: "#/components/schemas/workflows.Type" + required: + - id + - name + - type + type: object + workflows.ArtifactFileDetail: + properties: + hash: + example: abc123 + type: string + path: + example: portainer.yaml + type: string + pathError: + type: string + pathStatus: + $ref: "#/components/schemas/sources.Status" + ref: + example: refs/heads/main + type: string + refError: + type: string + refStatus: + $ref: "#/components/schemas/sources.Status" + sourceId: + type: integer + type: object + workflows.DeploymentPlatform: + enum: + - dockerStandalone + - dockerSwarm + - kubernetes + type: string + x-enum-varnames: + - DeploymentPlatformDockerStandalone + - DeploymentPlatformDockerSwarm + - DeploymentPlatformKubernetes + workflows.Status: + enum: + - healthy + - syncing + - error + - paused + - unknown + type: string + x-enum-varnames: + - StatusHealthy + - StatusSyncing + - StatusError + - StatusPaused + - StatusUnknown + workflows.StatusSummary: + properties: + error: + type: integer + healthy: + type: integer + paused: + type: integer + syncing: + type: integer + unknown: + type: integer + type: object + workflows.Target: + properties: + edgeGroupIds: + items: + type: integer + type: array + endpointId: + type: integer + groupStatus: + additionalProperties: + $ref: "#/components/schemas/workflows.Status" + type: object + namespace: + type: string + resolvedEndpointIds: + items: + type: integer + type: array + type: object + workflows.Type: + enum: + - stack + - edgeStack + type: string + x-enum-varnames: + - TypeStack + - TypeEdgeStack + workflows.Workflow: + properties: + autoUpdate: + $ref: "#/components/schemas/portainer.AutoUpdateSettings" + creationDate: + type: integer + gitConfig: + $ref: "#/components/schemas/gittypes.RepoConfig" + id: + type: integer + lastSyncDate: + type: integer + name: + type: string + platform: + $ref: "#/components/schemas/workflows.DeploymentPlatform" + sourceId: + type: integer + status: + $ref: "#/components/schemas/workflows.WorkflowStatusObject" + target: + $ref: "#/components/schemas/workflows.Target" + type: + $ref: "#/components/schemas/workflows.Type" + required: + - id + - name + - platform + - status + - target + - type + type: object + workflows.WorkflowDetail: + properties: + artifacts: + items: + $ref: "#/components/schemas/workflows.ArtifactDetail" + type: array + id: + type: integer + name: + type: string + required: + - id + - name + type: object + workflows.WorkflowPhaseStatus: + properties: + error: + type: string + status: + $ref: "#/components/schemas/workflows.Status" + type: object + workflows.WorkflowStatusObject: + properties: + artifact: + $ref: "#/components/schemas/workflows.WorkflowPhaseStatus" + source: + $ref: "#/components/schemas/workflows.WorkflowPhaseStatus" + target: + $ref: "#/components/schemas/workflows.WorkflowPhaseStatus" + type: object diff --git a/api/docs/swagger.yaml b/api/docs/swagger.yaml new file mode 100644 index 0000000..7651b3b --- /dev/null +++ b/api/docs/swagger.yaml @@ -0,0 +1,20045 @@ +basePath: /api +definitions: + auth.authenticatePayload: + properties: + Password: + description: Password + example: mypassword + type: string + Username: + description: Username + example: admin + type: string + required: + - Password + - Username + type: object + auth.authenticateResponse: + properties: + jwt: + description: JWT token used to authenticate against the API + example: abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzAB + type: string + type: object + auth.oauthPayload: + properties: + Code: + description: OAuth code returned from OAuth Provided + type: string + type: object + backup.backupPayload: + properties: + Password: + type: string + type: object + backup.restorePayload: + properties: + FileContent: + items: + format: int32 + type: integer + type: array + FileName: + type: string + Password: + type: string + type: object + build.BuildInfo: + properties: + BuildNumber: + type: string + GitCommit: + type: string + GoVersion: + type: string + ImageTag: + type: string + NodejsVersion: + type: string + PnpmVersion: + type: string + WebpackVersion: + type: string + type: object + build.DependenciesInfo: + properties: + ComposeVersion: + type: string + DockerVersion: + type: string + HelmVersion: + type: string + KubectlVersion: + type: string + type: object + build.RuntimeInfo: + properties: + Env: + items: + type: string + type: array + type: object + containers.containerGpusResponse: + properties: + gpus: + type: string + type: object + customtemplates.customTemplateFromFileContentPayload: + properties: + Description: + description: Description of the template + example: High performance web server + type: string + EdgeTemplate: + description: EdgeTemplate indicates if this template purpose for Edge Stack + example: false + type: boolean + FileContent: + description: Content of stack file + type: string + Logo: + description: URL of the template's logo + example: https://portainer.io/img/logo.svg + type: string + Note: + description: A note that will be displayed in the UI. Supports HTML content + example: This is my custom template + type: string + Platform: + allOf: + - $ref: '#/definitions/portainer.CustomTemplatePlatform' + description: |- + Platform associated to the template. + Valid values are: 1 - 'linux', 2 - 'windows' + Required for Docker stacks + enum: + - 1 + - 2 + example: 1 + Title: + description: Title of the template + example: Nginx + type: string + Type: + allOf: + - $ref: '#/definitions/portainer.StackType' + description: |- + Type of created stack: + * 1 - swarm + * 2 - compose + * 3 - kubernetes + enum: + - 1 + - 2 + - 3 + example: 1 + Variables: + description: Definitions of variables in the stack file + items: + $ref: '#/definitions/portainer.CustomTemplateVariableDefinition' + type: array + required: + - Description + - FileContent + - Title + - Type + type: object + customtemplates.customTemplateFromGitRepositoryPayload: + properties: + ComposeFilePathInRepository: + default: docker-compose.yml + description: Path to the Stack file inside the Git repository + example: docker-compose.yml + type: string + Description: + description: Description of the template + example: High performance web server + type: string + EdgeTemplate: + description: EdgeTemplate indicates if this template purpose for Edge Stack + example: false + type: boolean + IsComposeFormat: + description: IsComposeFormat indicates if the Kubernetes template is created + from a Docker Compose file + example: false + type: boolean + Logo: + description: URL of the template's logo + example: https://portainer.io/img/logo.svg + type: string + Note: + description: A note that will be displayed in the UI. Supports HTML content + example: This is my custom template + type: string + Platform: + allOf: + - $ref: '#/definitions/portainer.CustomTemplatePlatform' + description: |- + Platform associated to the template. + Valid values are: 1 - 'linux', 2 - 'windows' + Required for Docker stacks + enum: + - 1 + - 2 + example: 1 + RepositoryAuthentication: + description: 'Deprecated: use SourceID instead. Use basic authentication to + clone the Git repository.' + example: true + type: boolean + RepositoryPassword: + description: 'Deprecated: use SourceID instead. Password used in basic authentication. + Required when RepositoryAuthentication is true.' + example: myGitPassword + type: string + RepositoryReferenceName: + description: Reference name of a Git repository hosting the Stack file + example: refs/heads/master + type: string + RepositoryURL: + description: 'Deprecated: use SourceID instead. URL of a Git repository hosting + the Stack file.' + example: https://github.com/openfaas/faas + type: string + RepositoryUsername: + description: 'Deprecated: use SourceID instead. Username used in basic authentication. + Required when RepositoryAuthentication is true.' + example: myGitUsername + type: string + SourceID: + description: |- + SourceID references an existing Source for git credentials/URL. + When set, the inline URL and authentication fields are ignored. + example: 1 + type: integer + TLSSkipVerify: + description: 'Deprecated: use SourceID instead. TLSSkipVerify skips SSL verification + when cloning the Git repository.' + example: false + type: boolean + Title: + description: Title of the template + example: Nginx + type: string + Type: + allOf: + - $ref: '#/definitions/portainer.StackType' + description: |- + Type of created stack: + * 1 - swarm + * 2 - compose + * 3 - kubernetes + enum: + - 1 + - 2 + example: 1 + Variables: + description: Definitions of variables in the stack file + items: + $ref: '#/definitions/portainer.CustomTemplateVariableDefinition' + type: array + required: + - Description + - SourceID + - Title + - Type + type: object + customtemplates.customTemplateUpdatePayload: + properties: + ComposeFilePathInRepository: + default: docker-compose.yml + description: Path to the Stack file inside the Git repository + example: docker-compose.yml + type: string + Description: + description: Description of the template + example: High performance web server + type: string + EdgeTemplate: + description: EdgeTemplate indicates if this template purpose for Edge Stack + example: false + type: boolean + FileContent: + description: Content of stack file + type: string + IsComposeFormat: + description: IsComposeFormat indicates if the Kubernetes template is created + from a Docker Compose file + example: false + type: boolean + Logo: + description: URL of the template's logo + example: https://portainer.io/img/logo.svg + type: string + Note: + description: A note that will be displayed in the UI. Supports HTML content + example: This is my custom template + type: string + Platform: + allOf: + - $ref: '#/definitions/portainer.CustomTemplatePlatform' + description: |- + Platform associated to the template. + Valid values are: 1 - 'linux', 2 - 'windows' + Required for Docker stacks + enum: + - 1 + - 2 + example: 1 + RepositoryAuthentication: + description: 'Deprecated: use SourceID instead. Use authentication to clone + the Git repository.' + example: true + type: boolean + RepositoryPassword: + description: 'Deprecated: use SourceID instead. Password used in basic authentication + or token used in token authentication. Required when RepositoryAuthentication + is true.' + example: myGitPassword + type: string + RepositoryReferenceName: + description: Reference name of a Git repository hosting the Stack file + example: refs/heads/master + type: string + RepositoryURL: + description: 'Deprecated: use SourceID instead. URL of a Git repository hosting + the Stack file.' + example: https://github.com/openfaas/faas + type: string + RepositoryUsername: + description: 'Deprecated: use SourceID instead. Username used in basic authentication. + Required when RepositoryAuthentication is true.' + example: myGitUsername + type: string + SourceID: + description: |- + SourceID references an existing Source for git credentials/URL. + When set, the inline URL and authentication fields are ignored. + example: 1 + type: integer + TLSSkipVerify: + description: 'Deprecated: use SourceID instead. TLSSkipVerify skips SSL verification + when cloning the Git repository.' + example: false + type: boolean + Title: + description: Title of the template + example: Nginx + type: string + Type: + allOf: + - $ref: '#/definitions/portainer.StackType' + description: Type of created stack (1 - swarm, 2 - compose, 3 - kubernetes) + enum: + - 1 + - 2 + - 3 + example: 1 + Variables: + description: Definitions of variables in the stack file + items: + $ref: '#/definitions/portainer.CustomTemplateVariableDefinition' + type: array + required: + - Description + - FileContent + - Title + - Type + type: object + customtemplates.fileResponse: + properties: + FileContent: + type: string + type: object + docker.dashboardResponse: + properties: + containers: + $ref: '#/definitions/stats.ContainerStats' + images: + $ref: '#/definitions/docker.imagesCounters' + networks: + type: integer + services: + type: integer + stacks: + type: integer + volumes: + type: integer + type: object + docker.imagesCounters: + properties: + size: + type: integer + total: + type: integer + type: object + edge.DeployerOptionsPayload: + properties: + ForceRecreate: + description: |- + ForceRecreate is a flag indicating if the agent must force the redeployment of the stack. + This field is only used when the Force Redeployment is triggered. + Once the stack is redeployed, this field will be reset to false. + For standard edge agent, this field is used in agent side + For async edge agent, this field is used in both agent side and server side. + This flag drives `docker compose up --force-recreate` option + type: boolean + Prune: + description: |- + Prune is a flag indicating if the agent must prune the containers or not when creating/updating an edge stack + This flag drives `docker compose up --remove-orphans` and `docker stack up --prune` options + Used only for EE + type: boolean + RemoveVolumes: + description: |- + RemoveVolumes is a flag indicating if the agent must remove the named volumes declared + in the compose file and anonymouse volumes attached to containers + This flag drives `docker compose down --volumes` option + Used only for EE + type: boolean + type: object + edge.RegistryCredentials: + properties: + Secret: + type: string + ServerURL: + type: string + Username: + type: string + type: object + edge.StackPayload: + properties: + AlwaysCloneGitRepoForRelativePath: + description: |- + AlwaysCloneGitRepoForRelativePath is a flag indicating if the agent must always clone the git repository for relative path. + This field is only valid when SupportRelativePath is true. + Used only for EE + type: boolean + CreatedBy: + description: |- + CreatedBy is the username that created this stack + Used for adding labels to Kubernetes manifests + type: string + CreatedByUserId: + description: |- + CreatedByUserId is the user ID that created this stack + Used for adding labels to Kubernetes manifests + type: string + DeployerOptionsPayload: + $ref: '#/definitions/edge.DeployerOptionsPayload' + DirEntries: + description: Content of stack folder + items: + $ref: '#/definitions/filesystem.DirEntry' + type: array + EdgeUpdateID: + description: |- + EdgeUpdateID is the ID of the edge update related to this stack. + Used only for EE + type: integer + EntryFileName: + description: Name of the stack entry file + type: string + EnvVars: + description: |- + Used only for EE + EnvVars is a list of environment variables to inject into the stack + items: + $ref: '#/definitions/portainer.Pair' + type: array + FilesystemPath: + description: Mount point for relative path + type: string + ForceUpdate: + description: |- + ForceUpdate is a flag indicating if the agent must force the update of the stack. + Used only for EE + type: boolean + HelmConfig: + allOf: + - $ref: '#/definitions/portainer.HelmConfig' + description: HelmConfig represents the Helm configuration for an edge stack + ID: + description: ID of the stack + type: integer + Name: + description: Name of the stack + type: string + Namespace: + description: Namespace to use for kubernetes stack. Keep empty to use the + manifest namespace. + type: string + PrePullImage: + description: |- + PrePullImage is a flag indicating if the agent must pull the image before deploying the stack. + Used only for EE + type: boolean + RePullImage: + description: |- + RePullImage is a flag indicating if the agent must pull the image if it is already present on the node. + Used only for EE + type: boolean + ReadyRePullImage: + description: |- + Used only for EE async edge agent + ReadyRePullImage is a flag to indicate whether the auto update is trigger to re-pull image + Deprecated(2.36): use DeployerOptionsPayload.ForceRecreate instead + type: boolean + RegistryCredentials: + description: |- + RegistryCredentials holds the credentials for a Docker registry. + Used only for EE + items: + $ref: '#/definitions/edge.RegistryCredentials' + type: array + RetryDeploy: + description: |- + RetryDeploy is a flag indicating if the agent must retry to deploy the stack if it fails. + Used only for EE + type: boolean + RetryPeriod: + description: |- + RetryPeriod specifies the duration, in seconds, for which the agent should continue attempting to deploy the stack after a failure + Used only for EE + type: integer + RollbackTo: + description: RollbackTo specifies the stack file version to rollback to (only + support to rollback to the last version currently) + type: integer + StackFileContent: + description: Content of the stack file (for compatibility to agent version + less than 2.19.0) + type: string + SupportPerDeviceConfigs: + description: Whether the edge stack supports per device configs + type: boolean + SupportRelativePath: + description: Is relative path supported + type: boolean + Version: + description: Version of the stack file + type: integer + type: object + edgegroups.decoratedEdgeGroup: + properties: + Dynamic: + type: boolean + EndpointIds: + description: Shadow to avoid exposing in the API + type: integer + EndpointTypes: + items: + $ref: '#/definitions/portainer.EndpointType' + type: array + Endpoints: + description: 'Deprecated: only used for API responses' + items: + type: integer + type: array + HasEdgeJob: + type: boolean + HasEdgeStack: + type: boolean + Id: + description: EdgeGroup Identifier + example: 1 + type: integer + Name: + type: string + PartialMatch: + type: boolean + TagIds: + items: + type: integer + type: array + TrustedEndpoints: + items: + type: integer + type: array + type: object + edgegroups.edgeGroupCreatePayload: + properties: + Dynamic: + type: boolean + Endpoints: + items: + type: integer + type: array + Name: + type: string + PartialMatch: + type: boolean + TagIDs: + items: + type: integer + type: array + type: object + edgegroups.edgeGroupUpdatePayload: + properties: + Dynamic: + type: boolean + Endpoints: + items: + type: integer + type: array + Name: + type: string + PartialMatch: + type: boolean + TagIDs: + items: + type: integer + type: array + type: object + edgejobs.edgeJobCreateFromFileContentPayload: + properties: + CronExpression: + type: string + EdgeGroups: + items: + type: integer + type: array + Endpoints: + items: + type: integer + type: array + FileContent: + type: string + Name: + type: string + Recurring: + type: boolean + type: object + edgejobs.edgeJobFileResponse: + properties: + FileContent: + type: string + type: object + edgejobs.edgeJobUpdatePayload: + properties: + CronExpression: + type: string + EdgeGroups: + items: + type: integer + type: array + Endpoints: + items: + type: integer + type: array + FileContent: + type: string + Name: + type: string + Recurring: + type: boolean + type: object + edgejobs.fileResponse: + properties: + FileContent: + type: string + type: object + edgejobs.taskContainer: + properties: + EndpointId: + type: integer + EndpointName: + type: string + Id: + type: string + LogsStatus: + $ref: '#/definitions/portainer.EdgeJobLogsStatus' + type: object + edgestacks.edgeStackFromGitRepositoryPayload: + properties: + DeploymentType: + allOf: + - $ref: '#/definitions/portainer.EdgeStackDeploymentType' + description: |- + Deployment type to deploy this stack + Valid values are: 0 - 'compose', 1 - 'kubernetes' + compose is enabled only for docker environments + kubernetes is enabled only for kubernetes environments + enum: + - 0 + - 1 + - 2 + example: 0 + EdgeGroups: + description: List of identifiers of EdgeGroups + example: + - 1 + items: + type: integer + type: array + FilePathInRepository: + default: docker-compose.yml + description: Path to the Stack file inside the Git repository + example: docker-compose.yml + type: string + Name: + description: |- + Name of the stack + Max length: 255 + Name must only contains lowercase characters, numbers, hyphens, or underscores + Name must start with a lowercase character or number + Example: stack-name or stack_123 or stackName + example: stack-name + type: string + Registries: + description: List of Registries to use for this stack + items: + type: integer + type: array + RepositoryAuthentication: + description: 'Deprecated: Use SourceID instead. Use basic authentication to + clone the Git repository.' + example: true + type: boolean + RepositoryPassword: + description: 'Deprecated: Use SourceID instead. Password used in basic authentication.' + example: myGitPassword + type: string + RepositoryReferenceName: + description: Reference name of a Git repository hosting the Stack file + example: refs/heads/master + type: string + RepositoryURL: + description: 'Deprecated: Use SourceID instead. URL of a Git repository hosting + the Stack file.' + example: https://github.com/openfaas/faas + type: string + RepositoryUsername: + description: 'Deprecated: Use SourceID instead. Username used in basic authentication.' + example: myGitUsername + type: string + SourceID: + description: |- + SourceID references an existing Source for git credentials/URL. + When set, the inline URL and authentication fields are ignored. + example: 1 + type: integer + TLSSkipVerify: + description: 'Deprecated: Use SourceID instead. TLSSkipVerify skips SSL verification + when cloning the Git repository.' + example: false + type: boolean + UseManifestNamespaces: + description: Uses the manifest's namespaces instead of the default one + type: boolean + required: + - EdgeGroups + - Name + type: object + edgestacks.edgeStackFromStringPayload: + properties: + DeploymentType: + allOf: + - $ref: '#/definitions/portainer.EdgeStackDeploymentType' + description: |- + Deployment type to deploy this stack + Valid values are: 0 - 'compose', 1 - 'kubernetes' + compose is enabled only for docker environments + kubernetes is enabled only for kubernetes environments + enum: + - 0 + - 1 + - 2 + example: 0 + EdgeGroups: + description: List of identifiers of EdgeGroups + example: + - 1 + items: + type: integer + type: array + Name: + description: |- + Name of the stack + Max length: 255 + Name must only contains lowercase characters, numbers, hyphens, or underscores + Name must start with a lowercase character or number + Example: stack-name or stack_123 or stackName + example: stack-name + type: string + Registries: + description: List of Registries to use for this stack + items: + type: integer + type: array + StackFileContent: + description: Content of the Stack file + example: |- + version: 3 + services: + web: + image:nginx + type: string + UseManifestNamespaces: + description: Uses the manifest's namespaces instead of the default one + type: boolean + required: + - Name + - StackFileContent + type: object + edgestacks.stackFileResponse: + properties: + StackFileContent: + type: string + type: object + edgestacks.updateEdgeStackPayload: + properties: + DeploymentType: + $ref: '#/definitions/portainer.EdgeStackDeploymentType' + EdgeGroups: + items: + type: integer + type: array + StackFileContent: + type: string + UpdateVersion: + type: boolean + UseManifestNamespaces: + description: Uses the manifest's namespaces instead of the default one + type: boolean + type: object + edgestacks.updateStatusPayload: + properties: + EndpointID: + type: integer + Error: + type: string + Status: + $ref: '#/definitions/portainer.EdgeStackStatusType' + Time: + format: int64 + type: integer + Version: + type: integer + type: object + endpointedge.edgeJobResponse: + properties: + CollectLogs: + description: Whether to collect logs + example: true + type: boolean + CronExpression: + description: A cron expression to schedule this job + example: '* * * * *' + type: string + Id: + description: EdgeJob Identifier + example: 2 + type: integer + Script: + description: Script to run + example: echo hello + type: string + Version: + description: Version of this EdgeJob + example: 2 + type: integer + type: object + endpointedge.endpointEdgeStatusInspectResponse: + properties: + checkin: + description: The current value of CheckinInterval + example: 5 + type: integer + credentials: + type: string + port: + description: The tunnel port + example: 8732 + type: integer + schedules: + description: List of requests for jobs to run on the environment(endpoint) + items: + $ref: '#/definitions/endpointedge.edgeJobResponse' + type: array + stacks: + description: List of stacks to be deployed on the environments(endpoints) + items: + $ref: '#/definitions/endpointedge.stackStatusResponse' + type: array + status: + description: Status represents the environment(endpoint) status + example: REQUIRED + type: string + type: object + endpointedge.stackStatusResponse: + properties: + ID: + description: EdgeStack Identifier + example: 1 + type: integer + Version: + description: Version of this stack + example: 3 + type: integer + type: object + endpointgroups.EndpointGroupResponse: + properties: + Description: + description: Description associated to the environment(endpoint) group + example: Environment(Endpoint) group description + type: string + Id: + description: Environment(Endpoint) group Identifier + example: 1 + type: integer + Name: + description: Environment(Endpoint) group name + example: my-environment-group + type: string + TagIds: + description: List of tags associated to this environment(endpoint) group + items: + type: integer + type: array + TeamAccessPolicies: + $ref: '#/definitions/portainer.TeamAccessPolicies' + Total: + type: integer + TypeInfo: + $ref: '#/definitions/endpointgroups.EndpointGroupTypeInfo' + UserAccessPolicies: + $ref: '#/definitions/portainer.UserAccessPolicies' + required: + - Description + - Id + - Name + type: object + endpointgroups.EndpointGroupTypeInfo: + properties: + Docker: + type: integer + Kubernetes: + type: integer + Mixed: + type: boolean + Podman: + type: integer + required: + - Docker + - Kubernetes + - Mixed + - Podman + type: object + endpointgroups.endpointGroupCreatePayload: + properties: + AssociatedEndpoints: + description: List of environment(endpoint) identifiers that will be part of + this group + example: + - 1 + - 3 + items: + type: integer + type: array + Description: + description: Environment(Endpoint) group description + example: description + type: string + Name: + description: Environment(Endpoint) group name + example: my-environment-group + type: string + TagIDs: + description: List of tag identifiers to which this environment(endpoint) group + is associated + example: + - 1 + - 2 + items: + type: integer + type: array + required: + - Name + type: object + endpointgroups.endpointGroupUpdatePayload: + properties: + AssociatedEndpoints: + description: List of environment(endpoint) identifiers that will be part of + this group + example: + - 1 + - 3 + items: + type: integer + type: array + Description: + description: Environment(Endpoint) group description + example: description + type: string + Name: + description: Environment(Endpoint) group name + example: my-environment-group + type: string + TagIDs: + description: List of tag identifiers associated to the environment(endpoint) + group + example: + - 3 + - 4 + items: + type: integer + type: array + TeamAccessPolicies: + $ref: '#/definitions/portainer.TeamAccessPolicies' + UserAccessPolicies: + $ref: '#/definitions/portainer.UserAccessPolicies' + type: object + endpoints.EnvironmentSummaryCountsResponse: + properties: + byGroup: + items: + $ref: '#/definitions/endpoints.groupCount' + type: array + byHealth: + $ref: '#/definitions/endpoints.healthCounts' + byPlatformType: + $ref: '#/definitions/endpoints.platformCounts' + down: + type: integer + outdated: + type: integer + total: + type: integer + unassigned: + type: integer + up: + type: integer + type: object + endpoints.dockerhubStatusResponse: + properties: + limit: + description: Daily limit + type: integer + remaining: + description: Remaiming images to pull + type: integer + type: object + endpoints.endpointCreateGlobalKeyResponse: + properties: + endpointID: + type: integer + type: object + endpoints.endpointDeleteBatchPartialResponse: + properties: + deleted: + items: + type: integer + type: array + errors: + items: + type: integer + type: array + type: object + endpoints.endpointDeleteBatchPayload: + properties: + endpoints: + items: + $ref: '#/definitions/endpoints.endpointDeleteRequest' + type: array + type: object + endpoints.endpointDeleteRequest: + properties: + deleteCluster: + type: boolean + id: + type: integer + type: object + endpoints.endpointSettingsUpdatePayload: + properties: + allowBindMountsForRegularUsers: + description: Whether non-administrator should be able to use bind mounts when + creating containers + example: false + type: boolean + allowContainerCapabilitiesForRegularUsers: + description: Whether non-administrator should be able to use container capabilities + example: true + type: boolean + allowDeviceMappingForRegularUsers: + description: Whether non-administrator should be able to use device mapping + example: true + type: boolean + allowHostNamespaceForRegularUsers: + description: Whether non-administrator should be able to use the host pid + example: true + type: boolean + allowPrivilegedModeForRegularUsers: + description: Whether non-administrator should be able to use privileged mode + when creating containers + example: false + type: boolean + allowSecurityOptForRegularUsers: + description: Whether non-administrator should be able to use security-opt + settings + example: true + type: boolean + allowStackManagementForRegularUsers: + description: Whether non-administrator should be able to manage stacks + example: true + type: boolean + allowSysctlSettingForRegularUsers: + description: Whether non-administrator should be able to use sysctl settings + example: true + type: boolean + allowVolumeBrowserForRegularUsers: + description: Whether non-administrator should be able to browse volumes + example: true + type: boolean + enableGPUManagement: + example: false + type: boolean + enableHostManagementFeatures: + description: Whether host management features are enabled + example: true + type: boolean + gpus: + items: + $ref: '#/definitions/portainer.Pair' + type: array + type: object + endpoints.endpointUpdatePayload: + properties: + AzureApplicationID: + description: Azure application ID + example: eag7cdo9-o09l-9i83-9dO9-f0b23oe78db4 + type: string + AzureAuthenticationKey: + description: Azure authentication key + example: cOrXoK/1D35w8YQ8nH1/8ZGwzz45JIYD5jxHKXEQknk= + type: string + AzureTenantID: + description: Azure tenant ID + example: 34ddc78d-4fel-2358-8cc1-df84c8o839f5 + type: string + EdgeCheckinInterval: + description: The check in interval for edge agent (in seconds) + example: 5 + type: integer + Gpus: + description: GPUs information + items: + $ref: '#/definitions/portainer.Pair' + type: array + GroupID: + description: Group identifier + example: 1 + type: integer + Kubernetes: + allOf: + - $ref: '#/definitions/portainer.KubernetesData' + description: Associated Kubernetes data + Name: + description: Name that will be used to identify this environment(endpoint) + example: my-environment + type: string + PublicURL: + description: |- + URL or IP address where exposed containers will be reachable.\ + Defaults to URL if not specified + example: docker.mydomain.tld:2375 + type: string + Status: + description: The status of the environment(endpoint) (1 - up, 2 - down) + example: 1 + type: integer + TLS: + description: Require TLS to connect against this environment(endpoint) + example: true + type: boolean + TLSSkipClientVerify: + description: Skip client verification when using TLS + example: false + type: boolean + TLSSkipVerify: + description: Skip server verification when using TLS + example: false + type: boolean + TagIDs: + description: List of tag identifiers to which this environment(endpoint) is + associated + example: + - 1 + - 2 + items: + type: integer + type: array + TeamAccessPolicies: + $ref: '#/definitions/portainer.TeamAccessPolicies' + URL: + description: URL or IP address of a Docker host + example: docker.mydomain.tld:2375 + type: string + UserAccessPolicies: + $ref: '#/definitions/portainer.UserAccessPolicies' + type: object + endpoints.endpointUpdateRelationsPayload: + properties: + Relations: + additionalProperties: + properties: + EdgeGroups: + items: + type: integer + type: array + Group: + type: integer + Tags: + items: + type: integer + type: array + type: object + type: object + type: object + endpoints.forceUpdateServicePayload: + properties: + PullImage: + description: PullImage if true will pull the image + type: boolean + ServiceID: + description: ServiceId to update + type: string + type: object + endpoints.groupCount: + properties: + count: + type: integer + groupID: + type: integer + groupName: + type: string + type: object + endpoints.healthCounts: + properties: + down: + type: integer + heartbeat: + type: integer + outdated: + type: integer + up: + type: integer + type: object + endpoints.platformCounts: + properties: + azure: + type: integer + docker: + type: integer + kubernetes: + type: integer + podman: + type: integer + type: object + endpoints.registryAccessPayload: + properties: + Namespaces: + items: + type: string + type: array + TeamAccessPolicies: + $ref: '#/definitions/portainer.TeamAccessPolicies' + UserAccessPolicies: + $ref: '#/definitions/portainer.UserAccessPolicies' + type: object + filesystem.DirEntry: + properties: + Content: + type: string + IsFile: + type: boolean + Name: + type: string + Permissions: + type: integer + type: object + github_com_portainer_portainer_pkg_libhelm_release.Hook: + properties: + delete_policies: + description: DeletePolicies are the policies that indicate when to delete + the hook + items: + type: string + type: array + events: + description: Events are the events that this hook fires on. + items: + type: string + type: array + kind: + description: Kind is the Kubernetes kind. + type: string + last_run: + allOf: + - $ref: '#/definitions/release.HookExecution' + description: LastRun indicates the date/time this was last run. + manifest: + description: Manifest is the manifest contents. + type: string + name: + type: string + path: + description: Path is the chart-relative path to the template. + type: string + weight: + description: Weight indicates the sort order for execution among similar Hook + type + type: integer + type: object + gitops.fileResponse: + properties: + FileContent: + type: string + type: object + gitops.repositoryFilePreviewPayload: + properties: + password: + description: |- + Password for git authentication. + Deprecated: use SourceID instead + example: myGitPassword + type: string + reference: + example: refs/heads/master + type: string + repository: + description: |- + URL of a Git repository to preview. + Deprecated: use SourceID instead + example: https://github.com/openfaas/faas + type: string + sourceID: + description: |- + SourceID resolves URL and auth from the stored Source record. + When set, the inline Repository/Username/Password/TLSSkipVerify fields are ignored. + example: 1 + type: integer + targetFile: + description: Path to file whose content will be read + example: docker-compose.yml + type: string + tlsSkipVerify: + description: |- + TLSSkipVerify skips SSL verification when cloning the Git repository. + Deprecated: use SourceID instead + example: false + type: boolean + username: + description: |- + Username for git authentication. + Deprecated: use SourceID instead + example: myGitUsername + type: string + type: object + gittypes.GitAuthentication: + properties: + AuthorizationType: + type: integer + Password: + type: string + Provider: + type: integer + Username: + type: string + type: object + gittypes.GitSource: + properties: + Authentication: + $ref: '#/definitions/gittypes.GitAuthentication' + TLSSkipVerify: + example: false + type: boolean + URL: + example: https://github.com/portainer/portainer.git + type: string + type: object + gittypes.RepoConfig: + properties: + Authentication: + allOf: + - $ref: '#/definitions/gittypes.GitAuthentication' + description: Git credentials + ConfigFilePath: + description: |- + ConfigFilePath is the path to the config file within the repository. + NOTE: For stacks, this mirrors Stack.EntryPoint and the two are kept in sync by stackUpdateGit. + example: docker-compose.yml + type: string + ConfigHash: + description: Repository hash + example: bc4c183d756879ea4d173315338110b31004b8e0 + type: string + ReferenceName: + description: The reference name + example: refs/heads/branch_name + type: string + TLSSkipVerify: + description: TLSSkipVerify skips SSL verification when cloning the Git repository + example: false + type: boolean + URL: + description: The repo url + example: https://github.com/portainer/portainer.git + type: string + type: object + helm.installChartPayload: + properties: + atomic: + type: boolean + chart: + type: string + name: + type: string + namespace: + type: string + repo: + type: string + values: + type: string + version: + type: string + type: object + images.ImageResponse: + properties: + created: + type: integer + id: + type: string + nodeName: + type: string + size: + type: integer + tags: + items: + type: string + type: array + used: + description: |- + Used is true if the image is used by at least one container + supplied only when withUsage is true + type: boolean + type: object + intstr.IntOrString: + properties: + IntVal: + type: integer + StrVal: + type: string + Type: + $ref: '#/definitions/intstr.Type' + type: object + intstr.Type: + enum: + - 0 + - 1 + format: int64 + type: integer + x-enum-comments: + Int: The IntOrString holds an int. + String: The IntOrString holds a string. + x-enum-descriptions: + - The IntOrString holds an int. + - The IntOrString holds a string. + x-enum-varnames: + - Int + - String + k8s_io_api_core_v1.ConditionStatus: + enum: + - "True" + - "False" + - Unknown + type: string + x-enum-varnames: + - ConditionTrue + - ConditionFalse + - ConditionUnknown + k8s_io_api_core_v1.HTTPHeader: + properties: + name: + description: |- + The header field name. + This will be canonicalized upon output, so case-variant names will be understood as the same header. + type: string + value: + description: The header field value + type: string + type: object + k8s_io_api_core_v1.LocalObjectReference: + properties: + name: + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + +optional + +default="" + +kubebuilder:default="" + TODO: Drop `kubebuilder:default` when controller-gen doesn't need it https://github.com/kubernetes-sigs/kubebuilder/issues/3896. + type: string + type: object + k8s_io_api_core_v1.ObjectReference: + properties: + apiVersion: + description: |- + API version of the referent. + +optional + type: string + fieldPath: + description: |- + If referring to a piece of an object instead of an entire object, this string + should contain a valid JSON/Go field access statement, such as desiredState.manifest.containers[2]. + For example, if the object reference is to a container within a pod, this would take on a value like: + "spec.containers{name}" (where "name" refers to the name of the container that triggered + the event) or if no container name is specified "spec.containers[2]" (container with + index 2 in this pod). This syntax is chosen only to have some well-defined way of + referencing a part of an object. + TODO: this design is not final and this field is subject to change in the future. + +optional + type: string + kind: + description: |- + Kind of the referent. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + +optional + type: string + name: + description: |- + Name of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + +optional + type: string + namespace: + description: |- + Namespace of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces/ + +optional + type: string + resourceVersion: + description: |- + Specific resourceVersion to which this reference is made, if any. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency + +optional + type: string + uid: + description: |- + UID of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#uids + +optional + type: string + type: object + k8s_io_api_core_v1.ResourceClaim: + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. + type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + + +optional + type: string + type: object + k8s_io_api_rbac_v1.Subject: + properties: + apiGroup: + description: |- + APIGroup holds the API group of the referenced subject. + Defaults to "" for ServiceAccount subjects. + Defaults to "rbac.authorization.k8s.io" for User and Group subjects. + +optional + type: string + kind: + description: |- + Kind of object being referenced. Values defined by this API group are "User", "Group", and "ServiceAccount". + If the Authorizer does not recognized the kind value, the Authorizer should report an error. + type: string + name: + description: |- + Name of the object being referenced. + +required + +k8s:required + type: string + namespace: + description: |- + Namespace of the referenced object. If the object kind is non-namespace, such as "User" or "Group", and this value is not empty + the Authorizer should report an error. + +optional + type: string + type: object + kubernetes.Configuration: + properties: + ConfigurationOwner: + type: string + Data: + additionalProperties: {} + type: object + Kind: + type: string + type: object + kubernetes.CustomResourceMetadata: + properties: + apiVersion: + type: string + kind: + type: string + name: + type: string + plural: + type: string + scope: + type: string + type: object + kubernetes.IngressRule: + properties: + Host: + type: string + IP: + type: string + Path: + type: string + TLS: + items: + $ref: '#/definitions/kubernetes.TLSInfo' + type: array + type: object + kubernetes.K8sApplication: + properties: + Annotations: + additionalProperties: + type: string + type: object + ApplicationOwner: + type: string + ApplicationType: + type: string + Configurations: + items: + $ref: '#/definitions/kubernetes.Configuration' + type: array + Containers: + items: {} + type: array + CreationDate: + type: string + CustomResourceMetadata: + $ref: '#/definitions/kubernetes.CustomResourceMetadata' + DeploymentType: + type: string + Id: + type: string + Image: + type: string + Kind: + type: string + Labels: + additionalProperties: + type: string + type: object + LoadBalancerIPAddress: + type: string + MatchLabels: + additionalProperties: + type: string + type: object + Metadata: + $ref: '#/definitions/kubernetes.Metadata' + Name: + type: string + Namespace: + type: string + Pods: + items: + $ref: '#/definitions/kubernetes.Pod' + type: array + PublishedPorts: + items: + $ref: '#/definitions/kubernetes.PublishedPort' + type: array + Resource: + $ref: '#/definitions/kubernetes.K8sApplicationResource' + ResourcePool: + type: string + RunningPodsCount: + type: integer + ServiceId: + type: string + ServiceName: + type: string + ServiceType: + type: string + StackId: + type: string + StackKind: + type: string + StackName: + type: string + Status: + type: string + TotalPodsCount: + type: integer + Uid: + type: string + type: object + kubernetes.K8sApplicationResource: + properties: + CpuLimit: + type: number + CpuRequest: + type: number + MemoryLimit: + type: integer + MemoryRequest: + type: integer + type: object + kubernetes.K8sClusterRole: + properties: + creationDate: + type: string + isSystem: + type: boolean + name: + type: string + uid: + type: string + type: object + kubernetes.K8sClusterRoleBinding: + properties: + creationDate: + type: string + isSystem: + type: boolean + name: + type: string + namespace: + type: string + roleRef: + $ref: '#/definitions/v1.RoleRef' + subjects: + items: + $ref: '#/definitions/k8s_io_api_rbac_v1.Subject' + type: array + uid: + type: string + type: object + kubernetes.K8sConfigMap: + properties: + Annotations: + additionalProperties: + type: string + type: object + ConfigurationOwner: + type: string + ConfigurationOwnerId: + type: string + ConfigurationOwners: + items: + $ref: '#/definitions/kubernetes.K8sConfigurationOwnerResource' + type: array + CreationDate: + type: string + Data: + additionalProperties: + type: string + type: object + IsUsed: + type: boolean + Labels: + additionalProperties: + type: string + type: object + Name: + type: string + Namespace: + type: string + UID: + type: string + type: object + kubernetes.K8sConfigurationOwnerResource: + properties: + Id: + type: string + Name: + type: string + ResourceKind: + type: string + type: object + kubernetes.K8sCronJob: + properties: + Command: + type: string + Id: + type: string + IsSystem: + type: boolean + Jobs: + items: + $ref: '#/definitions/kubernetes.K8sJob' + type: array + Name: + type: string + Namespace: + type: string + Schedule: + type: string + Suspend: + type: boolean + Timezone: + type: string + type: object + kubernetes.K8sCronJobDeleteRequests: + additionalProperties: + items: + type: string + type: array + type: object + kubernetes.K8sDashboard: + properties: + applicationsCount: + type: integer + configMapsCount: + type: integer + ingressesCount: + type: integer + namespacesCount: + type: integer + secretsCount: + type: integer + servicesCount: + type: integer + volumesCount: + type: integer + type: object + kubernetes.K8sEvent: + properties: + count: + type: integer + eventTime: + type: string + firstTimestamp: + type: string + involvedObject: + $ref: '#/definitions/kubernetes.K8sEventInvolvedObject' + kind: + type: string + lastTimestamp: + type: string + message: + type: string + name: + type: string + namespace: + type: string + reason: + type: string + type: + type: string + uid: + type: string + type: object + kubernetes.K8sEventInvolvedObject: + properties: + kind: + type: string + name: + type: string + namespace: + type: string + uid: + type: string + type: object + kubernetes.K8sIngressController: + properties: + Availability: + type: boolean + ClassName: + type: string + Name: + type: string + New: + type: boolean + Type: + type: string + Used: + type: boolean + type: object + kubernetes.K8sIngressDeleteRequests: + additionalProperties: + items: + type: string + type: array + type: object + kubernetes.K8sIngressInfo: + properties: + Annotations: + additionalProperties: + type: string + type: object + ClassName: + type: string + CreationDate: + type: string + Hosts: + items: + type: string + type: array + Labels: + additionalProperties: + type: string + type: object + Name: + type: string + Namespace: + type: string + Paths: + items: + $ref: '#/definitions/kubernetes.K8sIngressPath' + type: array + TLS: + items: + $ref: '#/definitions/kubernetes.K8sIngressTLS' + type: array + Type: + type: string + UID: + type: string + type: object + kubernetes.K8sIngressPath: + properties: + HasService: + type: boolean + Host: + type: string + IngressName: + type: string + Path: + type: string + PathType: + type: string + Port: + type: integer + ServiceName: + type: string + type: object + kubernetes.K8sIngressTLS: + properties: + Hosts: + items: + type: string + type: array + SecretName: + type: string + type: object + kubernetes.K8sJob: + properties: + BackoffLimit: + type: integer + Command: + type: string + Completions: + type: integer + Container: + $ref: '#/definitions/v1.Container' + Duration: + type: string + FailedReason: + type: string + FinishTime: + type: string + Id: + type: string + IsSystem: + type: boolean + Name: + type: string + Namespace: + type: string + PodName: + type: string + StartTime: + type: string + Status: + type: string + type: object + kubernetes.K8sJobDeleteRequests: + additionalProperties: + items: + type: string + type: array + type: object + kubernetes.K8sNamespaceDetails: + properties: + Annotations: + additionalProperties: + type: string + type: object + Name: + type: string + Owner: + type: string + ResourceQuota: + $ref: '#/definitions/kubernetes.K8sResourceQuota' + type: object + kubernetes.K8sPVCResizeRequest: + properties: + name: + type: string + namespace: + type: string + newSize: + type: string + type: object + kubernetes.K8sPVReclaimPolicyRequest: + properties: + name: + type: string + reclaimPolicy: + $ref: '#/definitions/v1.PersistentVolumeReclaimPolicy' + type: object + kubernetes.K8sPersistentVolume: + properties: + accessModes: + items: + type: string + type: array + annotations: + additionalProperties: + type: string + type: object + capacity: + $ref: '#/definitions/v1.ResourceList' + claimRef: + $ref: '#/definitions/k8s_io_api_core_v1.ObjectReference' + creationDate: + type: string + csi: + $ref: '#/definitions/v1.CSIPersistentVolumeSource' + humanReadableAccessModes: + items: + $ref: '#/definitions/v1.PersistentVolumeAccessMode' + type: array + labels: + additionalProperties: + type: string + type: object + name: + type: string + persistentVolumeReclaimPolicy: + $ref: '#/definitions/v1.PersistentVolumeReclaimPolicy' + status: + $ref: '#/definitions/v1.PersistentVolumePhase' + storageClassName: + type: string + volumeMode: + $ref: '#/definitions/v1.PersistentVolumeMode' + type: object + kubernetes.K8sPersistentVolumeClaim: + properties: + accessModes: + items: + type: string + type: array + allowVolumeExpansion: + type: boolean + creationDate: + type: string + humanReadableAccessModes: + items: + $ref: '#/definitions/v1.PersistentVolumeAccessMode' + type: array + id: + type: string + labels: + additionalProperties: + type: string + type: object + name: + type: string + namespace: + type: string + owningApplications: + items: + $ref: '#/definitions/kubernetes.K8sApplication' + type: array + phase: + $ref: '#/definitions/v1.PersistentVolumeClaimPhase' + resourcesRequests: + $ref: '#/definitions/v1.ResourceList' + storage: + type: integer + storageClass: + type: string + storageRequest: + type: string + volumeMode: + $ref: '#/definitions/v1.PersistentVolumeMode' + volumeName: + type: string + type: object + kubernetes.K8sResourceQuota: + properties: + cpu: + type: string + enabled: + type: boolean + memory: + type: string + type: object + kubernetes.K8sRole: + properties: + creationDate: + type: string + isSystem: + description: |- + isSystem is true if prefixed with "system:" or exists in the kube-system namespace + or is one of the portainer roles + type: boolean + name: + type: string + namespace: + type: string + uid: + type: string + type: object + kubernetes.K8sRoleBinding: + properties: + creationDate: + type: string + isSystem: + type: boolean + name: + type: string + namespace: + type: string + roleRef: + $ref: '#/definitions/v1.RoleRef' + subjects: + items: + $ref: '#/definitions/k8s_io_api_rbac_v1.Subject' + type: array + uid: + type: string + type: object + kubernetes.K8sRoleBindingDeleteRequests: + additionalProperties: + items: + type: string + type: array + type: object + kubernetes.K8sRoleDeleteRequests: + additionalProperties: + items: + type: string + type: array + type: object + kubernetes.K8sSecret: + properties: + Annotations: + additionalProperties: + type: string + type: object + ConfigurationOwner: + type: string + ConfigurationOwnerId: + type: string + ConfigurationOwners: + items: + $ref: '#/definitions/kubernetes.K8sConfigurationOwnerResource' + type: array + CreationDate: + type: string + Data: + additionalProperties: + type: string + type: object + IsUsed: + type: boolean + Labels: + additionalProperties: + type: string + type: object + Name: + type: string + Namespace: + type: string + SecretType: + type: string + UID: + type: string + type: object + kubernetes.K8sServiceAccount: + properties: + annotations: + additionalProperties: + type: string + type: object + automountServiceAccountToken: + type: boolean + creationDate: + type: string + imagePullSecrets: + items: + $ref: '#/definitions/k8s_io_api_core_v1.LocalObjectReference' + type: array + isSystem: + type: boolean + labels: + additionalProperties: + type: string + type: object + name: + type: string + namespace: + type: string + uid: + type: string + type: object + kubernetes.K8sServiceAccountDeleteRequests: + additionalProperties: + items: + type: string + type: array + type: object + kubernetes.K8sServiceAccountImagePullSecretsUpdatePayload: + properties: + secretNames: + items: + type: string + type: array + type: object + kubernetes.K8sServiceDeleteRequests: + additionalProperties: + items: + type: string + type: array + type: object + kubernetes.K8sServiceInfo: + properties: + AllocateLoadBalancerNodePorts: + type: boolean + Annotations: + additionalProperties: + type: string + type: object + Applications: + description: serviceList screen + items: + $ref: '#/definitions/kubernetes.K8sApplication' + type: array + ClusterIPs: + items: + type: string + type: array + CreationDate: + type: string + ExternalIPs: + items: + type: string + type: array + ExternalName: + type: string + IngressStatus: + items: + $ref: '#/definitions/kubernetes.K8sServiceIngress' + type: array + Labels: + additionalProperties: + type: string + type: object + Name: + type: string + Namespace: + type: string + Ports: + items: + $ref: '#/definitions/kubernetes.K8sServicePort' + type: array + Selector: + additionalProperties: + type: string + type: object + Type: + type: string + UID: + type: string + type: object + kubernetes.K8sServiceIngress: + properties: + Hostname: + type: string + IP: + type: string + type: object + kubernetes.K8sServicePort: + properties: + Name: + type: string + NodePort: + type: integer + Port: + type: integer + Protocol: + type: string + TargetPort: + type: string + type: object + kubernetes.K8sStorageClass: + properties: + allowVolumeExpansion: + type: boolean + annotations: + additionalProperties: + type: string + type: object + creationDate: + type: string + isDefault: + type: boolean + labels: + additionalProperties: + type: string + type: object + mountOptions: + items: + type: string + type: array + name: + type: string + parameters: + additionalProperties: + type: string + type: object + provisioner: + type: string + reclaimPolicy: + $ref: '#/definitions/v1.PersistentVolumeReclaimPolicy' + type: object + kubernetes.K8sVolumeDeleteRequest: + properties: + name: + type: string + namespace: + type: string + type: object + kubernetes.K8sVolumeInfo: + properties: + persistentVolume: + $ref: '#/definitions/kubernetes.K8sPersistentVolume' + persistentVolumeClaim: + $ref: '#/definitions/kubernetes.K8sPersistentVolumeClaim' + storageClass: + $ref: '#/definitions/kubernetes.K8sStorageClass' + type: object + kubernetes.KubernetesNodeResponse: + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + +optional + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + +optional + type: string + metadata: + allOf: + - $ref: '#/definitions/v1.ObjectMeta' + description: |- + Standard object's metadata. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata + +optional + spec: + allOf: + - $ref: '#/definitions/v1.NodeSpec' + description: |- + Spec defines the behavior of a node. + https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status + +optional + status: + allOf: + - $ref: '#/definitions/v1.NodeStatus' + description: |- + Most recently observed status of the node. + Populated by the system. + Read-only. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status + +optional + type: object + kubernetes.Metadata: + properties: + annotations: + additionalProperties: + type: string + type: object + labels: + additionalProperties: + type: string + type: object + type: object + kubernetes.Pod: + properties: + ContainerName: + type: string + CreationDate: + type: string + Image: + type: string + ImagePullPolicy: + type: string + Name: + type: string + NodeName: + type: string + PodIP: + type: string + Resource: + $ref: '#/definitions/kubernetes.K8sApplicationResource' + Status: + type: string + Uid: + type: string + type: object + kubernetes.PublishedPort: + properties: + IngressRules: + items: + $ref: '#/definitions/kubernetes.IngressRule' + type: array + Port: + type: integer + type: object + kubernetes.TLSInfo: + properties: + hosts: + items: + type: string + type: array + type: object + kubernetes.describeResourceResponse: + properties: + describe: + type: string + type: object + kubernetes.kubernetesVersionResponse: + properties: + buildDate: + type: string + compiler: + type: string + emulationMajor: + description: EmulationMajor is the major version of the emulation version + type: string + emulationMinor: + description: EmulationMinor is the minor version of the emulation version + type: string + gitCommit: + type: string + gitTreeState: + type: string + gitVersion: + type: string + goVersion: + type: string + major: + description: Major is the major version of the binary version + type: string + minCompatibilityMajor: + description: MinCompatibilityMajor is the major version of the minimum compatibility + version + type: string + minCompatibilityMinor: + description: MinCompatibilityMinor is the minor version of the minimum compatibility + version + type: string + minor: + description: Minor is the minor version of the binary version + type: string + platform: + type: string + supportsPodRestart: + description: |- + SupportsPodRestart is true when the cluster exposes the `pods/restart` + subresource via API discovery — i.e. the feature gate is enabled and + the cluster version is recent enough. This is the authoritative + signal for whether Portainer can call the pod-restart endpoint, and + is preferred over a raw Kubernetes-version comparison. + type: boolean + type: object + kubernetes.namespacesToggleSystemPayload: + properties: + System: + description: Toggle the system state of this namespace to true or false + example: true + type: boolean + type: object + ldap.checkPayload: + properties: + LDAPSettings: + $ref: '#/definitions/portainer.LDAPSettings' + type: object + motd.Motd: + properties: + ContentLayout: + additionalProperties: + type: string + type: object + Hash: + items: + type: integer + type: array + Message: + type: string + Style: + type: string + Title: + type: string + type: object + oauth2.AuthStyle: + enum: + - 0 + - 1 + - 2 + type: integer + x-enum-varnames: + - AuthStyleAutoDetect + - AuthStyleInParams + - AuthStyleInHeader + platform.ContainerPlatform: + enum: + - Docker + - Docker Standalone + - Docker Swarm + - Kubernetes + - Podman + type: string + x-enum-varnames: + - PlatformDocker + - PlatformDockerStandalone + - PlatformDockerSwarm + - PlatformKubernetes + - PlatformPodman + portainer.APIKey: + properties: + dateCreated: + description: Unix timestamp (UTC) when the API key was created + type: integer + description: + example: portainer-api-key + type: string + digest: + description: Digest represents SHA256 hash of the raw API key + type: string + id: + example: 1 + type: integer + lastUsed: + description: Unix timestamp (UTC) when the API key was last used + type: integer + prefix: + description: API key identifier (7 char prefix) + type: string + userId: + example: 1 + type: integer + type: object + portainer.AccessPolicy: + properties: + Namespaces: + description: Namespaces is a list of namespaces that this access policy applies + to. Only used for namespaced level roles + items: + type: string + type: array + RoleId: + description: Role identifier. Reference the role that will be associated to + this access policy + example: 1 + type: integer + required: + - RoleId + type: object + portainer.Artifact: + properties: + edgeGroups: + items: + type: integer + type: array + edgeStackId: + type: integer + envGroups: + items: + type: integer + type: array + envIds: + items: + type: integer + type: array + files: + items: + $ref: '#/definitions/portainer.ArtifactFile' + type: array + stackId: + type: integer + type: object + portainer.ArtifactFile: + properties: + hash: + example: abc123 + type: string + path: + example: portainer.yaml + type: string + pathError: + type: string + pathStatus: + $ref: '#/definitions/portainer.SourceStatus' + ref: + example: refs/heads/main + type: string + refError: + type: string + refStatus: + $ref: '#/definitions/portainer.SourceStatus' + sourceId: + type: integer + type: object + portainer.AuthenticationMethod: + enum: + - 0 + - 1 + - 2 + - 3 + type: integer + x-enum-varnames: + - _ + - AuthenticationInternal + - AuthenticationLDAP + - AuthenticationOAuth + portainer.Authorizations: + additionalProperties: + type: boolean + type: object + portainer.AutoUpdateSettings: + properties: + ForcePullImage: + description: Pull latest image + example: false + type: boolean + ForceUpdate: + description: Force update ignores repo changes + example: false + type: boolean + Interval: + description: Auto update interval + example: 1m30s + type: string + JobID: + description: Autoupdate job id + example: "15" + type: string + Webhook: + description: A UUID generated from client + example: 05de31a2-79fa-4644-9c12-faa67e5c49f0 + type: string + type: object + portainer.AzureCredentials: + properties: + ApplicationID: + description: Azure application ID + example: eag7cdo9-o09l-9i83-9dO9-f0b23oe78db4 + type: string + AuthenticationKey: + description: Azure authentication key + example: cOrXoK/1D35w8YQ8nH1/8ZGwzz45JIYD5jxHKXEQknk= + type: string + TenantID: + description: Azure tenant ID + example: 34ddc78d-4fel-2358-8cc1-df84c8o839f5 + type: string + required: + - ApplicationID + - AuthenticationKey + - TenantID + type: object + portainer.CustomTemplate: + properties: + CreatedByUserId: + description: User identifier who created this template + example: 3 + type: integer + Description: + description: Description of the template + example: High performance web server + type: string + EdgeTemplate: + description: EdgeTemplate indicates if this template purpose for Edge Stack + example: false + type: boolean + EntryPoint: + description: Path to the Stack file + example: docker-compose.yml + type: string + GitConfig: + $ref: '#/definitions/gittypes.RepoConfig' + Id: + description: CustomTemplate Identifier + example: 1 + type: integer + IsComposeFormat: + description: IsComposeFormat indicates if the Kubernetes template is created + from a Docker Compose file + example: false + type: boolean + Logo: + description: URL of the template's logo + example: https://portainer.io/img/logo.svg + type: string + Note: + description: A note that will be displayed in the UI. Supports HTML content + example: This is my custom template + type: string + Platform: + allOf: + - $ref: '#/definitions/portainer.CustomTemplatePlatform' + description: |- + Platform associated to the template. + Valid values are: 1 - 'linux', 2 - 'windows' + enum: + - 1 + - 2 + example: 1 + ProjectPath: + description: Path on disk to the repository hosting the Stack file + example: /data/custom_template/3 + type: string + ResourceControl: + $ref: '#/definitions/portainer.ResourceControl' + Title: + description: Title of the template + example: Nginx + type: string + Type: + allOf: + - $ref: '#/definitions/portainer.StackType' + description: |- + Type of created stack: + * 1 - swarm + * 2 - compose + * 3 - kubernetes + enum: + - 1 + - 2 + - 3 + example: 1 + Variables: + items: + $ref: '#/definitions/portainer.CustomTemplateVariableDefinition' + type: array + artifact: + $ref: '#/definitions/portainer.Artifact' + type: object + portainer.CustomTemplatePlatform: + enum: + - 0 + - 1 + - 2 + type: integer + x-enum-varnames: + - _ + - CustomTemplatePlatformLinux + - CustomTemplatePlatformWindows + portainer.CustomTemplateVariableDefinition: + properties: + defaultValue: + example: default value + type: string + description: + example: Description + type: string + label: + example: My Variable + type: string + name: + example: MY_VAR + type: string + type: object + portainer.DiagnosticsData: + properties: + DNS: + additionalProperties: + type: string + type: object + Log: + type: string + Proxy: + additionalProperties: + type: string + type: object + Telnet: + additionalProperties: + type: string + type: object + type: object + portainer.DockerSnapshot: + properties: + ContainerCount: + type: integer + DiagnosticsData: + $ref: '#/definitions/portainer.DiagnosticsData' + DockerSnapshotRaw: + $ref: '#/definitions/portainer.DockerSnapshotRaw' + DockerVersion: + type: string + GpuUseAll: + type: boolean + GpuUseList: + items: + type: string + type: array + HealthyContainerCount: + type: integer + ImageCount: + type: integer + IsPodman: + type: boolean + NodeCount: + type: integer + PerformanceMetrics: + $ref: '#/definitions/portainer.PerformanceMetrics' + RunningContainerCount: + type: integer + ServiceCount: + type: integer + StackCount: + type: integer + StoppedContainerCount: + type: integer + Swarm: + type: boolean + Time: + type: integer + TotalCPU: + type: integer + TotalMemory: + type: integer + UnhealthyContainerCount: + type: integer + VolumeCount: + type: integer + required: + - ContainerCount + - DockerVersion + - GpuUseAll + - HealthyContainerCount + - ImageCount + - IsPodman + - NodeCount + - RunningContainerCount + - ServiceCount + - StackCount + - StoppedContainerCount + - Swarm + - Time + - TotalCPU + - TotalMemory + - UnhealthyContainerCount + - VolumeCount + type: object + portainer.DockerSnapshotRaw: + type: object + portainer.EcrData: + properties: + Region: + example: ap-southeast-2 + type: string + type: object + portainer.Edge: + properties: + AsyncMode: + description: Deprecated 2.18 + example: false + type: boolean + CommandInterval: + description: The command list interval for edge agent - used in edge async + mode (in seconds) + example: 5 + type: integer + PingInterval: + description: The ping interval for edge agent - used in edge async mode (in + seconds) + example: 5 + type: integer + SnapshotInterval: + description: The snapshot interval for edge agent - used in edge async mode + (in seconds) + example: 5 + type: integer + type: object + portainer.EdgeGroup: + properties: + Dynamic: + type: boolean + EndpointIds: + $ref: '#/definitions/roar.Roar-portainer_EndpointID' + Endpoints: + description: 'Deprecated: only used for API responses' + items: + type: integer + type: array + Id: + description: EdgeGroup Identifier + example: 1 + type: integer + Name: + type: string + PartialMatch: + type: boolean + TagIds: + items: + type: integer + type: array + type: object + portainer.EdgeJob: + properties: + Created: + type: integer + CronExpression: + type: string + EdgeGroups: + items: + type: integer + type: array + Endpoints: + additionalProperties: + $ref: '#/definitions/portainer.EdgeJobEndpointMeta' + type: object + GroupLogsCollection: + additionalProperties: + $ref: '#/definitions/portainer.EdgeJobEndpointMeta' + description: Field used for log collection of Endpoints belonging to EdgeGroups + type: object + Id: + description: EdgeJob Identifier + example: 1 + type: integer + Name: + type: string + Recurring: + type: boolean + ScriptPath: + type: string + Version: + type: integer + type: object + portainer.EdgeJobEndpointMeta: + properties: + CollectLogs: + type: boolean + LogsStatus: + $ref: '#/definitions/portainer.EdgeJobLogsStatus' + type: object + portainer.EdgeJobLogsStatus: + enum: + - 0 + - 1 + - 2 + - 3 + type: integer + x-enum-varnames: + - _ + - EdgeJobLogsStatusIdle + - EdgeJobLogsStatusPending + - EdgeJobLogsStatusCollected + portainer.EdgeStack: + properties: + CreatedBy: + description: The username which created this stack + example: admin + type: string + CreatedByUserId: + description: The username id which created this stack + example: "1" + type: string + CreationDate: + description: StatusArray map[EndpointID][]EdgeStackStatus `json:"StatusArray"` + type: integer + DeploymentType: + $ref: '#/definitions/portainer.EdgeStackDeploymentType' + EdgeGroups: + items: + type: integer + type: array + EntryPoint: + type: string + Id: + description: EdgeStack Identifier + example: 1 + type: integer + ManifestPath: + type: string + Name: + type: string + NumDeployments: + type: integer + ProjectPath: + type: string + Status: + additionalProperties: + $ref: '#/definitions/portainer.EdgeStackStatus' + type: object + UseManifestNamespaces: + description: Uses the manifest's namespaces instead of the default one + type: boolean + Version: + type: integer + type: object + portainer.EdgeStackDeploymentStatus: + properties: + Error: + type: string + RollbackTo: + description: EE only feature + type: integer + Time: + format: int64 + type: integer + Type: + $ref: '#/definitions/portainer.EdgeStackStatusType' + Version: + type: integer + type: object + portainer.EdgeStackDeploymentType: + enum: + - 0 + - 1 + type: integer + x-enum-varnames: + - EdgeStackDeploymentCompose + - EdgeStackDeploymentKubernetes + portainer.EdgeStackStatus: + properties: + DeploymentInfo: + allOf: + - $ref: '#/definitions/portainer.StackDeploymentInfo' + description: EE only feature + Details: + allOf: + - $ref: '#/definitions/portainer.EdgeStackStatusDetails' + description: Deprecated + EndpointID: + type: integer + Error: + description: Deprecated + type: string + ReadyRePullImage: + description: ReadyRePullImage is a flag to indicate whether the auto update + is trigger to re-pull image + type: boolean + Status: + items: + $ref: '#/definitions/portainer.EdgeStackDeploymentStatus' + type: array + Type: + allOf: + - $ref: '#/definitions/portainer.EdgeStackStatusType' + description: Deprecated + type: object + portainer.EdgeStackStatusDetails: + properties: + Acknowledged: + type: boolean + Error: + type: boolean + ImagesPulled: + type: boolean + Ok: + type: boolean + Pending: + type: boolean + RemoteUpdateSuccess: + type: boolean + Remove: + type: boolean + type: object + portainer.EdgeStackStatusType: + enum: + - 0 + - 1 + - 2 + - 3 + - 4 + - 5 + - 6 + - 7 + - 8 + - 9 + - 10 + - 11 + - 12 + - 13 + type: integer + x-enum-varnames: + - EdgeStackStatusPending + - EdgeStackStatusDeploymentReceived + - EdgeStackStatusError + - EdgeStackStatusAcknowledged + - EdgeStackStatusRemoved + - EdgeStackStatusRemoteUpdateSuccess + - EdgeStackStatusImagesPulled + - EdgeStackStatusRunning + - EdgeStackStatusDeploying + - EdgeStackStatusRemoving + - EdgeStackStatusPausedDeploying + - EdgeStackStatusRollingBack + - EdgeStackStatusRolledBack + - EdgeStackStatusCompleted + portainer.Endpoint: + properties: + Agent: + $ref: '#/definitions/portainer.EnvironmentAgentData' + AzureCredentials: + $ref: '#/definitions/portainer.AzureCredentials' + ComposeSyntaxMaxVersion: + description: Maximum version of docker-compose + example: "3.8" + type: string + ContainerEngine: + description: ContainerEngine represents the container engine type. This can + be 'docker' or 'podman' when interacting directly with these environments, + otherwise '' for kubernetes environments. + example: docker + type: string + Edge: + $ref: '#/definitions/portainer.EnvironmentEdgeSettings' + EdgeCheckinInterval: + description: The check in interval for edge agent (in seconds) + example: 5 + type: integer + EdgeID: + description: The identifier of the edge agent associated with this environment(endpoint) + type: string + EdgeKey: + description: The key which is used to map the agent to Portainer + type: string + EnableGPUManagement: + type: boolean + Gpus: + items: + $ref: '#/definitions/portainer.Pair' + type: array + GroupId: + description: Environment(Endpoint) group identifier + example: 1 + type: integer + Heartbeat: + description: Heartbeat indicates the heartbeat status of an edge environment + example: true + type: boolean + Id: + description: Environment(Endpoint) Identifier + example: 1 + type: integer + Kubernetes: + allOf: + - $ref: '#/definitions/portainer.KubernetesData' + description: Associated Kubernetes data + LastCheckInDate: + description: LastCheckInDate mark last check-in date on checkin + type: integer + Name: + description: Environment(Endpoint) name + example: my-environment + type: string + PublicURL: + description: URL or IP address where exposed containers will be reachable + example: docker.mydomain.tld:2375 + type: string + SecuritySettings: + allOf: + - $ref: '#/definitions/portainer.EndpointSecuritySettings' + description: Environment(Endpoint) specific security settings + Snapshots: + description: List of snapshots + items: + $ref: '#/definitions/portainer.DockerSnapshot' + type: array + Status: + allOf: + - $ref: '#/definitions/portainer.EndpointStatus' + description: The status of the environment(endpoint) (1 - up, 2 - down, 3 + - provisioning, 4 - error) + enum: + - 1 + - 2 + - 3 + - 4 + example: 1 + TLSConfig: + $ref: '#/definitions/portainer.TLSConfiguration' + TagIds: + description: List of tag identifiers to which this environment(endpoint) is + associated + items: + type: integer + type: array + TeamAccessPolicies: + allOf: + - $ref: '#/definitions/portainer.TeamAccessPolicies' + description: List of team identifiers authorized to connect to this environment(endpoint) + Type: + allOf: + - $ref: '#/definitions/portainer.EndpointType' + description: Environment(Endpoint) environment(endpoint) type. 1 for a Docker + environment(endpoint), 2 for an agent on Docker environment(endpoint) or + 3 for an Azure environment(endpoint). + example: 1 + URL: + description: URL or IP address of the Docker host associated to this environment(endpoint) + example: docker.mydomain.tld:2375 + type: string + UserAccessPolicies: + allOf: + - $ref: '#/definitions/portainer.UserAccessPolicies' + description: List of user identifiers authorized to connect to this environment(endpoint) + UserTrusted: + description: Whether the device has been trusted or not by the user + type: boolean + required: + - Agent + - ComposeSyntaxMaxVersion + - ContainerEngine + - Edge + - EdgeCheckinInterval + - EdgeKey + - GroupId + - Id + - Kubernetes + - LastCheckInDate + - Name + - PublicURL + - SecuritySettings + - TLSConfig + - Type + - URL + type: object + portainer.EndpointGroup: + properties: + Description: + description: Description associated to the environment(endpoint) group + example: Environment(Endpoint) group description + type: string + Id: + description: Environment(Endpoint) group Identifier + example: 1 + type: integer + Name: + description: Environment(Endpoint) group name + example: my-environment-group + type: string + TagIds: + description: List of tags associated to this environment(endpoint) group + items: + type: integer + type: array + TeamAccessPolicies: + $ref: '#/definitions/portainer.TeamAccessPolicies' + UserAccessPolicies: + $ref: '#/definitions/portainer.UserAccessPolicies' + required: + - Description + - Id + - Name + type: object + portainer.EndpointSecuritySettings: + properties: + allowBindMountsForRegularUsers: + description: Whether non-administrator should be able to use bind mounts when + creating containers + example: false + type: boolean + allowContainerCapabilitiesForRegularUsers: + description: Whether non-administrator should be able to use container capabilities + example: true + type: boolean + allowDeviceMappingForRegularUsers: + description: Whether non-administrator should be able to use device mapping + example: true + type: boolean + allowHostNamespaceForRegularUsers: + description: Whether non-administrator should be able to use the host pid + example: true + type: boolean + allowPrivilegedModeForRegularUsers: + description: Whether non-administrator should be able to use privileged mode + when creating containers + example: false + type: boolean + allowSecurityOptForRegularUsers: + description: Whether non-administrator should be able to use security-opt + settings + example: true + type: boolean + allowStackManagementForRegularUsers: + description: Whether non-administrator should be able to manage stacks + example: true + type: boolean + allowSysctlSettingForRegularUsers: + description: Whether non-administrator should be able to use sysctl settings + example: true + type: boolean + allowVolumeBrowserForRegularUsers: + description: Whether non-administrator should be able to browse volumes + example: true + type: boolean + enableHostManagementFeatures: + description: Whether host management features are enabled + example: true + type: boolean + required: + - allowBindMountsForRegularUsers + - allowContainerCapabilitiesForRegularUsers + - allowDeviceMappingForRegularUsers + - allowHostNamespaceForRegularUsers + - allowPrivilegedModeForRegularUsers + - allowSecurityOptForRegularUsers + - allowStackManagementForRegularUsers + - allowSysctlSettingForRegularUsers + - allowVolumeBrowserForRegularUsers + - enableHostManagementFeatures + type: object + portainer.EndpointStatus: + enum: + - 0 + - 1 + - 2 + type: integer + x-enum-varnames: + - _ + - EndpointStatusUp + - EndpointStatusDown + portainer.EndpointType: + enum: + - 0 + - 1 + - 2 + - 3 + - 4 + - 5 + - 6 + - 7 + type: integer + x-enum-varnames: + - _ + - DockerEnvironment + - AgentOnDockerEnvironment + - AzureEnvironment + - EdgeAgentOnDockerEnvironment + - KubernetesLocalEnvironment + - AgentOnKubernetesEnvironment + - EdgeAgentOnKubernetesEnvironment + portainer.EnvironmentAgentData: + properties: + Version: + example: 1.0.0 + type: string + type: object + portainer.EnvironmentEdgeSettings: + properties: + AsyncMode: + description: Whether the device has been started in edge async mode + type: boolean + CommandInterval: + description: The command list interval for edge agent - used in edge async + mode [seconds] + example: 60 + type: integer + PingInterval: + description: The ping interval for edge agent - used in edge async mode [seconds] + example: 60 + type: integer + SnapshotInterval: + description: The snapshot interval for edge agent - used in edge async mode + [seconds] + example: 60 + type: integer + required: + - AsyncMode + - CommandInterval + - PingInterval + - SnapshotInterval + type: object + portainer.GithubRegistryData: + properties: + OrganisationName: + type: string + UseOrganisation: + type: boolean + type: object + portainer.GitlabRegistryData: + properties: + InstanceURL: + type: string + ProjectId: + type: integer + ProjectPath: + type: string + type: object + portainer.GlobalDeploymentOptions: + properties: + hideStacksFunctionality: + example: false + type: boolean + type: object + portainer.HelmConfig: + properties: + Atomic: + description: |- + Atomic enables automatic rollback on deployment failure (equivalent to helm --atomic). + Used by both git repo and Helm repository deployments. + example: true + type: boolean + ChartName: + description: |- + ChartName is the name of the Helm chart within the repository. + Required for Helm repository deployments. + example: nginx + type: string + ChartPath: + description: |- + ChartPath is the path to a Helm chart folder within the cloned git repository. + Used exclusively for git repo helm deployments. Mutually exclusive with ChartURL. + example: charts/my-app + type: string + ChartURL: + description: |- + ChartURL is the URL of a Helm chart repository. + Used exclusively for Helm repository deployments. Mutually exclusive with ChartPath. + example: https://charts.bitnami.com/bitnami + type: string + ChartVersion: + description: |- + ChartVersion is the version of the Helm chart to deploy. Empty means latest. + Used exclusively for Helm repository deployments. + example: 15.0.0 + type: string + Namespace: + description: |- + Namespace is the Kubernetes namespace to deploy the Helm chart into. + Used by both git repo and Helm repository deployments. + example: default + type: string + Timeout: + description: |- + Timeout sets the deadline for Helm operations (equivalent to helm --timeout, e.g. "5m0s"). + Used by both git repo and Helm repository deployments. + example: 5m0s + type: string + ValuesFiles: + description: |- + ValuesFiles is a list of relative paths to Helm values YAML files within the cloned git repository. + Used exclusively for git repo helm deployments. + example: + - '[''values/prod.yaml''' + - ' ''values/secrets.yaml'']' + items: + type: string + type: array + ValuesInline: + description: |- + ValuesInline is the inline YAML string of Helm values. + Used exclusively for Helm repository deployments. + example: 'replicaCount: 2' + type: string + type: object + portainer.HelmUserRepository: + properties: + Id: + description: Membership Identifier + example: 1 + type: integer + URL: + description: Helm repository URL + example: https://charts.bitnami.com/bitnami + type: string + UserId: + description: User identifier + example: 1 + type: integer + type: object + portainer.InternalAuthSettings: + properties: + RequiredPasswordLength: + type: integer + type: object + portainer.K8sNamespaceInfo: + properties: + Annotations: + additionalProperties: + type: string + type: object + CreationDate: + type: string + Id: + type: string + IsDefault: + type: boolean + IsSystem: + type: boolean + Name: + type: string + NamespaceOwner: + type: string + ResourceQuota: + $ref: '#/definitions/v1.ResourceQuota' + Status: + $ref: '#/definitions/v1.NamespaceStatus' + UnhealthyEventCount: + type: integer + type: object + portainer.K8sNodeLimits: + properties: + CPU: + type: integer + Memory: + type: integer + type: object + portainer.K8sNodesLimits: + additionalProperties: + $ref: '#/definitions/portainer.K8sNodeLimits' + type: object + portainer.KubernetesConfiguration: + properties: + AllowNoneIngressClass: + type: boolean + EnableResourceOverCommit: + type: boolean + IngressAvailabilityPerNamespace: + type: boolean + IngressClasses: + items: + $ref: '#/definitions/portainer.KubernetesIngressClassConfig' + type: array + ResourceOverCommitPercentage: + type: integer + RestrictDefaultNamespace: + type: boolean + StorageClasses: + items: + $ref: '#/definitions/portainer.KubernetesStorageClassConfig' + type: array + UseLoadBalancer: + type: boolean + UseServerMetrics: + type: boolean + required: + - AllowNoneIngressClass + - IngressAvailabilityPerNamespace + type: object + portainer.KubernetesData: + properties: + Configuration: + $ref: '#/definitions/portainer.KubernetesConfiguration' + Flags: + $ref: '#/definitions/portainer.KubernetesFlags' + Snapshots: + items: + $ref: '#/definitions/portainer.KubernetesSnapshot' + type: array + required: + - Configuration + - Flags + type: object + portainer.KubernetesFlags: + properties: + GPUOperator: + type: boolean + IsServerIngressClassDetected: + type: boolean + IsServerMetricsDetected: + type: boolean + IsServerStorageDetected: + type: boolean + required: + - IsServerIngressClassDetected + - IsServerMetricsDetected + - IsServerStorageDetected + type: object + portainer.KubernetesIngressClassConfig: + properties: + Blocked: + type: boolean + BlockedNamespaces: + items: + type: string + type: array + Name: + type: string + Type: + type: string + required: + - Name + - Type + type: object + portainer.KubernetesSnapshot: + properties: + ClusterType: + type: string + DiagnosticsData: + $ref: '#/definitions/portainer.DiagnosticsData' + GPUNodeCount: + type: integer + KubernetesVersion: + type: string + NodeCount: + type: integer + PerformanceMetrics: + $ref: '#/definitions/portainer.PerformanceMetrics' + Time: + type: integer + TotalCPU: + type: integer + TotalGPU: + additionalProperties: + format: int64 + type: integer + type: object + TotalMemory: + type: integer + required: + - KubernetesVersion + - NodeCount + - Time + - TotalCPU + - TotalMemory + type: object + portainer.KubernetesStorageClassConfig: + properties: + AccessModes: + items: + type: string + type: array + AllowVolumeExpansion: + type: boolean + Name: + type: string + Provisioner: + type: string + required: + - AllowVolumeExpansion + - Name + - Provisioner + type: object + portainer.LDAPGroupSearchSettings: + properties: + GroupAttribute: + description: LDAP attribute which denotes the group membership + example: member + type: string + GroupBaseDN: + description: The distinguished name of the element from which the LDAP server + will search for groups + example: dc=ldap,dc=domain,dc=tld + type: string + GroupFilter: + description: The LDAP search filter used to select group elements, optional + example: (objectClass=account + type: string + type: object + portainer.LDAPSearchSettings: + properties: + BaseDN: + description: The distinguished name of the element from which the LDAP server + will search for users + example: dc=ldap,dc=domain,dc=tld + type: string + Filter: + description: Optional LDAP search filter used to select user elements + example: (objectClass=account) + type: string + UserNameAttribute: + description: LDAP attribute which denotes the username + example: uid + type: string + type: object + portainer.LDAPSettings: + properties: + AnonymousMode: + description: Enable this option if the server is configured for Anonymous + access. When enabled, ReaderDN and Password will not be used + example: true + type: boolean + AutoCreateUsers: + description: Automatically provision users and assign them to matching LDAP + group names + example: true + type: boolean + GroupSearchSettings: + items: + $ref: '#/definitions/portainer.LDAPGroupSearchSettings' + type: array + Password: + description: Password of the account that will be used to search users + example: readonly-password + type: string + ReaderDN: + description: Account that will be used to search for users + example: cn=readonly-account,dc=ldap,dc=domain,dc=tld + type: string + SearchSettings: + items: + $ref: '#/definitions/portainer.LDAPSearchSettings' + type: array + StartTLS: + description: Whether LDAP connection should use StartTLS + example: true + type: boolean + TLSConfig: + $ref: '#/definitions/portainer.TLSConfiguration' + URL: + description: URL or IP address of the LDAP server + example: myldap.domain.tld:389 + type: string + type: object + portainer.MembershipRole: + enum: + - 0 + - 1 + - 2 + type: integer + x-enum-varnames: + - _ + - TeamLeader + - TeamMember + portainer.OAuthSettings: + properties: + AccessTokenURI: + type: string + AuthStyle: + $ref: '#/definitions/oauth2.AuthStyle' + AuthorizationURI: + type: string + ClientID: + type: string + ClientSecret: + type: string + DefaultTeamID: + type: integer + KubeSecretKey: + items: + type: integer + type: array + LogoutURI: + type: string + OAuthAutoCreateUsers: + type: boolean + RedirectURI: + type: string + ResourceURI: + type: string + SSO: + type: boolean + Scopes: + type: string + UserIdentifier: + type: string + type: object + portainer.Pair: + properties: + name: + example: name + type: string + value: + example: value + type: string + required: + - name + - value + type: object + portainer.PerformanceMetrics: + properties: + CPUUsage: + type: number + DiskUsage: + type: number + MemoryUsage: + type: number + NetworkUsage: + type: number + type: object + portainer.QuayRegistryData: + properties: + OrganisationName: + type: string + UseOrganisation: + type: boolean + type: object + portainer.Registry: + properties: + AccessToken: + description: Stores temporary access token + type: string + AccessTokenExpiry: + type: integer + Authentication: + description: Is authentication against this registry enabled + example: true + type: boolean + AuthorizedTeams: + description: Deprecated in DBVersion == 18 + items: + type: integer + type: array + AuthorizedUsers: + description: Deprecated in DBVersion == 18 + items: + type: integer + type: array + BaseURL: + description: Base URL, introduced for ProGet registry + example: registry.mydomain.tld:2375 + type: string + Ecr: + $ref: '#/definitions/portainer.EcrData' + Github: + $ref: '#/definitions/portainer.GithubRegistryData' + Gitlab: + $ref: '#/definitions/portainer.GitlabRegistryData' + Id: + description: Registry Identifier + example: 1 + type: integer + ManagementConfiguration: + $ref: '#/definitions/portainer.RegistryManagementConfiguration' + Name: + description: Registry Name + example: my-registry + type: string + Password: + description: Password or SecretAccessKey used to authenticate against this + registry + example: registry_password + type: string + Quay: + $ref: '#/definitions/portainer.QuayRegistryData' + RegistryAccesses: + $ref: '#/definitions/portainer.RegistryAccesses' + TeamAccessPolicies: + allOf: + - $ref: '#/definitions/portainer.TeamAccessPolicies' + description: Deprecated in DBVersion == 31 + Type: + allOf: + - $ref: '#/definitions/portainer.RegistryType' + description: Registry Type (1 - Quay, 2 - Azure, 3 - Custom, 4 - Gitlab, 5 + - ProGet, 6 - DockerHub, 7 - ECR) + enum: + - 1 + - 2 + - 3 + - 4 + - 5 + - 6 + - 7 + URL: + description: URL or IP address of the Docker registry + example: registry.mydomain.tld:2375 + type: string + UserAccessPolicies: + allOf: + - $ref: '#/definitions/portainer.UserAccessPolicies' + description: |- + Deprecated fields + Deprecated in DBVersion == 31 + Username: + description: Username or AccessKeyID used to authenticate against this registry + example: registry user + type: string + type: object + portainer.RegistryAccessPolicies: + properties: + Namespaces: + description: Kubernetes specific fields (with kubernetes, namespaces have + access to a registry, if users/teams have access to the same namespace, + they have access to the registry) + items: + type: string + type: array + TeamAccessPolicies: + $ref: '#/definitions/portainer.TeamAccessPolicies' + UserAccessPolicies: + allOf: + - $ref: '#/definitions/portainer.UserAccessPolicies' + description: Docker specific fields (with docker, users/teams have access + to a registry) + type: object + portainer.RegistryAccesses: + additionalProperties: + $ref: '#/definitions/portainer.RegistryAccessPolicies' + type: object + portainer.RegistryManagementConfiguration: + properties: + AccessToken: + type: string + AccessTokenExpiry: + type: integer + Authentication: + type: boolean + Ecr: + $ref: '#/definitions/portainer.EcrData' + Password: + type: string + TLSConfig: + $ref: '#/definitions/portainer.TLSConfiguration' + Type: + $ref: '#/definitions/portainer.RegistryType' + Username: + type: string + type: object + portainer.RegistryType: + enum: + - 0 + - 1 + - 2 + - 3 + - 4 + - 5 + - 6 + - 7 + - 8 + type: integer + x-enum-varnames: + - _ + - QuayRegistry + - AzureRegistry + - CustomRegistry + - GitlabRegistry + - ProGetRegistry + - DockerHubRegistry + - EcrRegistry + - GithubRegistry + portainer.ResourceAccessLevel: + enum: + - 0 + - 1 + type: integer + x-enum-varnames: + - _ + - ReadWriteAccessLevel + portainer.ResourceControl: + properties: + AccessLevel: + $ref: '#/definitions/portainer.ResourceAccessLevel' + AdministratorsOnly: + description: Permit access to resource only to admins + example: true + type: boolean + Id: + description: ResourceControl Identifier + example: 1 + type: integer + OwnerId: + description: |- + Deprecated fields + Deprecated in DBVersion == 2 + type: integer + Public: + description: Permit access to the associated resource to any user + example: true + type: boolean + ResourceId: + 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 + example: 617c5f22bb9b023d6daab7cba43a57576f83492867bc767d1c59416b065e5f08 + type: string + SubResourceIds: + description: List of Docker resources that will inherit this access control + example: + - 617c5f22bb9b023d6daab7cba43a57576f83492867bc767d1c59416b065e5f08 + items: + type: string + type: array + System: + type: boolean + TeamAccesses: + items: + $ref: '#/definitions/portainer.TeamResourceAccess' + type: array + Type: + allOf: + - $ref: '#/definitions/portainer.ResourceControlType' + description: |- + Type of Docker resource. Valid values are: 1- container, 2 -service + 3 - volume, 4 - secret, 5 - stack, 6 - config or 7 - custom template + example: 1 + UserAccesses: + items: + $ref: '#/definitions/portainer.UserResourceAccess' + type: array + type: object + portainer.ResourceControlType: + enum: + - 0 + - 1 + - 2 + - 3 + - 4 + - 5 + - 6 + - 7 + - 8 + - 9 + type: integer + x-enum-varnames: + - _ + - ContainerResourceControl + - ServiceResourceControl + - VolumeResourceControl + - NetworkResourceControl + - SecretResourceControl + - StackResourceControl + - ConfigResourceControl + - CustomTemplateResourceControl + - ContainerGroupResourceControl + portainer.Role: + properties: + Authorizations: + allOf: + - $ref: '#/definitions/portainer.Authorizations' + description: Authorizations associated to a role + Description: + description: Role description + example: Read-only access of all resources in an environment(endpoint) + type: string + Id: + description: Role Identifier + example: 1 + type: integer + Name: + description: Role name + example: HelpDesk + type: string + Priority: + type: integer + type: object + portainer.SSLSettings: + properties: + certPath: + type: string + httpEnabled: + type: boolean + keyPath: + type: string + selfSigned: + type: boolean + type: object + portainer.Settings: + properties: + AgentSecret: + description: Container environment parameter AGENT_SECRET + type: string + AllowBindMountsForRegularUsers: + type: boolean + AllowContainerCapabilitiesForRegularUsers: + type: boolean + AllowDeviceMappingForRegularUsers: + type: boolean + AllowHostNamespaceForRegularUsers: + type: boolean + AllowPrivilegedModeForRegularUsers: + type: boolean + AllowStackManagementForRegularUsers: + type: boolean + AllowVolumeBrowserForRegularUsers: + type: boolean + AuthenticationMethod: + allOf: + - $ref: '#/definitions/portainer.AuthenticationMethod' + description: 'Active authentication method for the Portainer instance. Valid + values are: 1 for internal, 2 for LDAP, or 3 for oauth' + example: 1 + BlackListedLabels: + description: A list of label name & value that will be used to hide containers + when querying containers + items: + $ref: '#/definitions/portainer.Pair' + type: array + DisplayDonationHeader: + description: Deprecated fields + type: boolean + DisplayExternalContributors: + type: boolean + Edge: + $ref: '#/definitions/portainer.Edge' + EdgeAgentCheckinInterval: + description: The default check in interval for edge agent (in seconds) + example: 5 + type: integer + EdgePortainerUrl: + description: EdgePortainerURL is the URL that is exposed to edge agents + type: string + EnableEdgeComputeFeatures: + description: Whether edge compute features are enabled + type: boolean + EnableHostManagementFeatures: + description: Deprecated fields v26 + type: boolean + EnforceEdgeID: + description: EnforceEdgeID makes Portainer store the Edge ID instead of accepting + anyone + example: false + type: boolean + FeatureFlagSettings: + additionalProperties: + type: boolean + type: object + ForceSecureCookies: + description: |- + ForceSecureCookies forces the Secure attribute on auth cookies regardless of detected scheme. + Enable when Portainer runs behind a TLS-terminating proxy. + example: false + type: boolean + GlobalDeploymentOptions: + allOf: + - $ref: '#/definitions/portainer.GlobalDeploymentOptions' + description: Deployment options for encouraging git ops workflows + HelmRepositoryURL: + description: Helm repository URL, defaults to "https://charts.bitnami.com/bitnami" + example: https://charts.bitnami.com/bitnami + type: string + InternalAuthSettings: + $ref: '#/definitions/portainer.InternalAuthSettings' + IsDockerDesktopExtension: + type: boolean + KubeconfigExpiry: + description: The expiry of a Kubeconfig + example: 24h + type: string + KubectlShellImage: + description: KubectlImage, defaults to portainer/kubectl-shell + example: portainer/kubectl-shell + type: string + LDAPSettings: + $ref: '#/definitions/portainer.LDAPSettings' + LogoURL: + 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 + example: https://mycompany.mydomain.tld/logo.png + type: string + OAuthSettings: + $ref: '#/definitions/portainer.OAuthSettings' + SnapshotInterval: + description: The interval in which environment(endpoint) snapshots are created + example: 5m + type: string + TemplatesURL: + description: URL to the templates that will be displayed in the UI when navigating + to App Templates + example: https://raw.githubusercontent.com/portainer/templates/master/templates.json + type: string + TrustOnFirstConnect: + description: TrustOnFirstConnect makes Portainer accepting edge agent connection + by default + example: false + type: boolean + UserSessionTimeout: + description: The duration of a user session + example: 5m + type: string + type: object + portainer.Source: + properties: + administratorsOnly: + type: boolean + git: + $ref: '#/definitions/gittypes.GitSource' + helm: + $ref: '#/definitions/portainer.HelmConfig' + id: + example: 1 + type: integer + lastSync: + example: 1587399600 + type: integer + name: + example: my-source + type: string + ownerID: + type: integer + public: + type: boolean + registry: + $ref: '#/definitions/portainer.Registry' + status: + $ref: '#/definitions/portainer.SourceStatus' + statusError: + type: string + teamAccesses: + items: + type: integer + type: array + type: + allOf: + - $ref: '#/definitions/portainer.SourceType' + example: 1 + userAccesses: + items: + type: integer + type: array + type: object + portainer.SourceStatus: + enum: + - 0 + - 1 + - 2 + type: integer + x-enum-varnames: + - SourceStatusUnknown + - SourceStatusHealthy + - SourceStatusError + portainer.SourceType: + enum: + - 0 + - 1 + - 2 + - 3 + type: integer + x-enum-varnames: + - _ + - SourceTypeGit + - SourceTypeRegistry + - SourceTypeHelm + portainer.Stack: + properties: + AdditionalFiles: + description: Only applies when deploying stack with multiple files + items: + type: string + type: array + AutoUpdate: + allOf: + - $ref: '#/definitions/portainer.AutoUpdateSettings' + description: The GitOps update settings of a git stack + CreatedBy: + description: The username which created this stack + example: admin + type: string + CreationDate: + description: The date in unix time when stack was created + example: 1587399600 + type: integer + CurrentDeploymentInfo: + allOf: + - $ref: '#/definitions/portainer.StackDeploymentInfo' + description: CurrentDeploymentInfo records the git repository state at the + time of the last actual deployment. + DeploymentStartStatus: + allOf: + - $ref: '#/definitions/portainer.StackStatus' + description: |- + DeploymentStartStatus is the stack status captured when the current + deployment starts. It is used by deployment logic during the current + deployment attempt and is cleared/replaced when a new deployment begins. + example: 1 + DeploymentStatus: + description: |- + DeploymentStatus records the status progression of the current deployment. + Cleared when a new deployment starts. + items: + $ref: '#/definitions/portainer.StackDeploymentStatus' + type: array + EndpointId: + description: Environment(Endpoint) identifier. Reference the environment(endpoint) + that will be used for deployment + example: 1 + type: integer + EntryPoint: + description: |- + EntryPoint is the path to the config file relative to the project root. + NOTE: For git stacks this mirrors GitConfig.ConfigFilePath and the two are kept in sync + by stackUpdateGit. The deploy command builder (compose_unpacker_cmd_builder) uses this + field directly; Kubernetes deploy and git clone operations use GitConfig.ConfigFilePath. + example: docker-compose.yml + type: string + Env: + description: A list of environment(endpoint) variables used during stack deployment + items: + $ref: '#/definitions/portainer.Pair' + type: array + FromAppTemplate: + description: Whether the stack is from a app template + example: false + type: boolean + GitConfig: + allOf: + - $ref: '#/definitions/gittypes.RepoConfig' + description: |- + GitConfig is the git repository configuration for git-backed stacks. + Deprecated: loaded from Source via WorkflowID; kept for DB backwards-compatibility only. + Non-migration code must not read or write this field; use Source records instead. + Id: + description: Stack Identifier + example: 1 + type: integer + Name: + description: Stack name + example: myStack + type: string + Namespace: + description: Kubernetes namespace if stack is a kube application + example: default + type: string + Option: + allOf: + - $ref: '#/definitions/portainer.StackOption' + description: The stack deployment option + ProjectPath: + description: Path on disk to the repository hosting the Stack file + example: /data/compose/myStack_jpofkc0i9uo9wtx1zesuk649w + type: string + ResourceControl: + $ref: '#/definitions/portainer.ResourceControl' + Status: + allOf: + - $ref: '#/definitions/portainer.StackStatus' + description: Stack status (1 - active, 2 - inactive, 3 - deploying, 4 - error) + example: 1 + SwarmId: + description: Cluster identifier of the Swarm cluster where the stack is deployed + example: jpofkc0i9uo9wtx1zesuk649w + type: string + Type: + allOf: + - $ref: '#/definitions/portainer.StackType' + description: Stack type. 1 for a Swarm stack, 2 for a Compose stack + example: 2 + UpdateDate: + description: The date in unix time when stack was last updated + example: 1587399600 + type: integer + UpdatedBy: + description: The username which last updated this stack + example: bob + type: string + WorkflowID: + description: WorkflowID is the ID of the Workflow that owns the Source for + this stack. + type: integer + type: object + portainer.StackDeploymentInfo: + properties: + AdditionalFiles: + description: AdditionalFiles are the additional files used for deploying the + stack + items: + type: string + type: array + ConfigFilePath: + description: ConfigFilePath is the path to the config file in the git repository + used for deploying the stack + type: string + ConfigHash: + description: ConfigHash is the commit hash of the git repository used for + deploying the stack + type: string + FileVersion: + description: FileVersion is the version of the stack file, used to detect + changes + type: integer + ReferenceName: + description: ReferenceName is the git reference (branch/tag) used for deploying + the stack + type: string + RepositoryURL: + description: RepositoryURL is the git repository URL used for deploying the + stack + type: string + SourceID: + description: SourceID is the Source used for deploying the stack + type: integer + Version: + description: Version is the version of the stack and also is the deployed + version in edge agent + type: integer + type: object + portainer.StackDeploymentStatus: + properties: + Message: + description: populated on Error entries + type: string + Status: + $ref: '#/definitions/portainer.StackStatus' + Time: + type: integer + type: object + portainer.StackOption: + properties: + HelmAtomic: + description: Enable atomic rollback on failure (Helm --atomic flag for Kubernetes + Helm stacks) + example: false + type: boolean + Prune: + description: Prune services that are no longer referenced + example: false + type: boolean + type: object + portainer.StackStatus: + enum: + - 0 + - 1 + - 2 + - 3 + - 4 + type: integer + x-enum-comments: + StackStatusActive: 1 - deployed and running + StackStatusDeploying: 3 - deployment in progress + StackStatusError: 4 - deployment failed + StackStatusInactive: 2 - intentionally stopped + x-enum-descriptions: + - "" + - 1 - deployed and running + - 2 - intentionally stopped + - 3 - deployment in progress + - 4 - deployment failed + x-enum-varnames: + - _ + - StackStatusActive + - StackStatusInactive + - StackStatusDeploying + - StackStatusError + portainer.StackType: + enum: + - 0 + - 1 + - 2 + - 3 + type: integer + x-enum-varnames: + - _ + - DockerSwarmStack + - DockerComposeStack + - KubernetesStack + portainer.TLSConfiguration: + properties: + TLS: + description: Use TLS + example: true + type: boolean + TLSCACert: + description: Path to the TLS CA certificate file + example: /data/tls/ca.pem + type: string + TLSCert: + description: Path to the TLS client certificate file + example: /data/tls/cert.pem + type: string + TLSKey: + description: Path to the TLS client key file + example: /data/tls/key.pem + type: string + TLSSkipVerify: + description: Skip the verification of the server TLS certificate + example: false + type: boolean + required: + - TLS + - TLSSkipVerify + type: object + portainer.Tag: + properties: + EndpointGroups: + additionalProperties: + type: boolean + description: A set of environment(endpoint) group ids that have this tag + type: object + Endpoints: + additionalProperties: + type: boolean + description: A set of environment(endpoint) ids that have this tag + type: object + ID: + description: Tag identifier + example: 1 + type: integer + Name: + description: Tag name + example: org/acme + type: string + type: object + portainer.Team: + properties: + Id: + description: Team Identifier + example: 1 + type: integer + Name: + description: Team name + example: developers + type: string + type: object + portainer.TeamAccessPolicies: + additionalProperties: + $ref: '#/definitions/portainer.AccessPolicy' + type: object + portainer.TeamMembership: + properties: + Id: + description: Membership Identifier + example: 1 + type: integer + Role: + allOf: + - $ref: '#/definitions/portainer.MembershipRole' + description: Team role (1 for team leader and 2 for team member) + example: 1 + TeamID: + description: Team identifier + example: 1 + type: integer + UserID: + description: User identifier + example: 1 + type: integer + type: object + portainer.TeamResourceAccess: + properties: + AccessLevel: + $ref: '#/definitions/portainer.ResourceAccessLevel' + TeamId: + type: integer + type: object + portainer.Template: + properties: + administrator_only: + description: Whether the template should be available to administrators only + example: true + type: boolean + categories: + description: A list of categories associated to the template + example: + - database + items: + type: string + type: array + command: + description: The command that will be executed in a container template + example: ls -lah + type: string + description: + description: Description of the template + example: High performance web server + type: string + env: + description: A list of environment(endpoint) variables used during the template + deployment + items: + $ref: '#/definitions/portainer.TemplateEnv' + type: array + hostname: + description: Container hostname + example: mycontainer + type: string + id: + description: |- + Mandatory container/stack fields + Template Identifier + example: 1 + type: integer + image: + description: |- + Mandatory container fields + Image associated to a container template. Mandatory for a container template + example: nginx:latest + type: string + interactive: + description: |- + Whether the container should be started in + interactive mode (-i -t equivalent on the CLI) + example: true + type: boolean + labels: + description: Container labels + items: + $ref: '#/definitions/portainer.Pair' + type: array + logo: + description: URL of the template's logo + example: https://portainer.io/img/logo.svg + type: string + name: + description: |- + Optional stack/container fields + Default name for the stack/container to be used on deployment + example: mystackname + type: string + network: + description: Name of a network that will be used on container deployment if + it exists inside the environment(endpoint) + example: mynet + type: string + note: + description: A note that will be displayed in the UI. Supports HTML content + example: This is my custom template + type: string + platform: + description: |- + Platform associated to the template. + Valid values are: 'linux', 'windows' or leave empty for multi-platform + example: linux + type: string + ports: + description: A list of ports exposed by the container + example: + - 8080:80/tcp + items: + type: string + type: array + privileged: + description: Whether the container should be started in privileged mode + example: true + type: boolean + registry: + description: |- + Optional container fields + The URL of a registry associated to the image for a container template + example: quay.io + type: string + repository: + allOf: + - $ref: '#/definitions/portainer.TemplateRepository' + description: Mandatory stack fields + restart_policy: + description: Container restart policy + example: on-failure + type: string + stackFile: + description: |- + Mandatory Edge stack fields + Stack file used for this template + type: string + title: + description: Title of the template + example: Nginx + type: string + type: + allOf: + - $ref: '#/definitions/portainer.TemplateType' + description: 'Template type. Valid values are: 1 (container), 2 (Swarm stack), + 3 (Compose stack), 4 (Compose edge stack)' + example: 1 + volumes: + description: A list of volumes used during the container template deployment + items: + $ref: '#/definitions/portainer.TemplateVolume' + type: array + type: object + portainer.TemplateEnv: + properties: + default: + description: Default value that will be set for the variable + example: default_value + type: string + description: + description: Content of the tooltip that will be generated in the UI + example: MySQL root account password + type: string + label: + description: Text for the label that will be generated in the UI + example: Root password + type: string + name: + description: name of the environment(endpoint) variable + example: MYSQL_ROOT_PASSWORD + type: string + preset: + description: If set to true, will not generate any input for this variable + in the UI + example: false + type: boolean + select: + description: A list of name/value that will be used to generate a dropdown + in the UI + items: + $ref: '#/definitions/portainer.TemplateEnvSelect' + type: array + type: object + portainer.TemplateEnvSelect: + properties: + default: + description: Will set this choice as the default choice + example: false + type: boolean + text: + description: Some text that will displayed as a choice + example: text value + type: string + value: + description: A value that will be associated to the choice + example: value + type: string + type: object + portainer.TemplateRepository: + properties: + stackfile: + description: Path to the stack file inside the git repository + example: ./subfolder/docker-compose.yml + type: string + url: + description: URL of a git repository used to deploy a stack template. Mandatory + for a Swarm/Compose stack template + example: https://github.com/portainer/portainer-compose + type: string + type: object + portainer.TemplateType: + enum: + - 0 + - 1 + - 2 + - 3 + type: integer + x-enum-varnames: + - _ + - ContainerTemplate + - SwarmStackTemplate + - ComposeStackTemplate + portainer.TemplateVolume: + properties: + bind: + description: Path on the host + example: /tmp + type: string + container: + description: Path inside the container + example: /data + type: string + readonly: + description: Whether the volume used should be readonly + example: true + type: boolean + type: object + portainer.User: + properties: + Id: + description: User Identifier + example: 1 + type: integer + Role: + allOf: + - $ref: '#/definitions/portainer.UserRole' + description: User role (1 for administrator account and 2 for regular account) + example: 1 + ThemeSettings: + $ref: '#/definitions/portainer.UserThemeSettings' + TokenIssueAt: + example: 1 + type: integer + UseCache: + example: true + type: boolean + Username: + example: bob + type: string + required: + - Id + - Role + - Username + type: object + portainer.UserAccessPolicies: + additionalProperties: + $ref: '#/definitions/portainer.AccessPolicy' + type: object + portainer.UserResourceAccess: + properties: + AccessLevel: + $ref: '#/definitions/portainer.ResourceAccessLevel' + UserId: + type: integer + type: object + portainer.UserRole: + enum: + - 0 + - 1 + - 2 + type: integer + x-enum-varnames: + - _ + - AdministratorRole + - StandardUserRole + portainer.UserThemeSettings: + properties: + color: + description: Color represents the color theme of the UI + enum: + - dark + - light + - highcontrast + - auto + - "" + example: dark + type: string + type: object + portainer.Webhook: + properties: + EndpointId: + type: integer + Id: + description: Webhook Identifier + example: 1 + type: integer + RegistryId: + type: integer + ResourceId: + type: string + Token: + type: string + Type: + allOf: + - $ref: '#/definitions/portainer.WebhookType' + description: Type of webhook (1 - service) + type: object + portainer.WebhookType: + enum: + - 0 + - 1 + type: integer + x-enum-varnames: + - _ + - ServiceWebhook + registries.registryConfigurePayload: + properties: + Authentication: + description: Is authentication against this registry enabled + example: false + type: boolean + Password: + description: Password used to authenticate against this registry. required + when Authentication is true + example: registry_password + type: string + Region: + description: ECR region + type: string + TLS: + description: Use TLS + example: true + type: boolean + TLSCACertFile: + description: The TLS CA certificate file + items: + format: int32 + type: integer + type: array + TLSCertFile: + description: The TLS client certificate file + items: + format: int32 + type: integer + type: array + TLSKeyFile: + description: The TLS client key file + items: + format: int32 + type: integer + type: array + TLSSkipVerify: + description: Skip the verification of the server TLS certificate + example: false + type: boolean + Username: + description: Username used to authenticate against this registry. Required + when Authentication is true + example: registry_user + type: string + required: + - Authentication + type: object + registries.registryCreatePayload: + properties: + Authentication: + description: Is authentication against this registry enabled + example: false + type: boolean + BaseURL: + description: BaseURL required for ProGet registry + example: registry.mydomain.tld:2375 + type: string + Ecr: + allOf: + - $ref: '#/definitions/portainer.EcrData' + description: ECR specific details, required when type = 7 + Gitlab: + allOf: + - $ref: '#/definitions/portainer.GitlabRegistryData' + description: Gitlab specific details, required when type = 4 + Name: + description: Name that will be used to identify this registry + example: my-registry + type: string + Password: + description: Password used to authenticate against this registry. required + when Authentication is true + example: registry_password + type: string + Quay: + allOf: + - $ref: '#/definitions/portainer.QuayRegistryData' + description: Quay specific details, required when type = 1 + TLS: + description: Use TLS + example: true + type: boolean + Type: + allOf: + - $ref: '#/definitions/portainer.RegistryType' + description: "Registry Type. Valid values are:\n\t1 (Quay.io),\n\t2 (Azure + container registry),\n\t3 (custom registry),\n\t4 (Gitlab registry),\n\t5 + (ProGet registry),\n\t6 (DockerHub)\n\t7 (ECR)" + enum: + - 1 + - 2 + - 3 + - 4 + - 5 + - 6 + - 7 + example: 1 + URL: + description: URL or IP address of the Docker registry + example: registry.mydomain.tld:2375/feed + type: string + Username: + description: Username used to authenticate against this registry. Required + when Authentication is true + example: registry_user + type: string + required: + - Authentication + - Name + - Type + - URL + type: object + registries.registryPingPayload: + properties: + Password: + description: Password used to authenticate against this registry + example: registry_password + type: string + TLS: + description: Use TLS + example: true + type: boolean + Type: + allOf: + - $ref: '#/definitions/portainer.RegistryType' + description: "Registry Type. Valid values are:\n\t1 (Quay.io),\n\t2 (Azure + container registry),\n\t3 (custom registry),\n\t4 (Gitlab registry),\n\t5 + (ProGet registry),\n\t6 (DockerHub)\n\t7 (ECR)\n\t8 (Github registry)" + enum: + - 1 + - 2 + - 3 + - 4 + - 5 + - 6 + - 7 + - 8 + example: 6 + URL: + description: URL or IP address of the Docker registry + example: registry-1.docker.io + type: string + Username: + description: Username used to authenticate against this registry + example: registry_user + type: string + required: + - Type + - URL + type: object + registries.registryPingResponse: + properties: + message: + description: Message provides details about the connection test result + example: Registry connection successful + type: string + success: + description: Success indicates if the registry connection was successful + example: true + type: boolean + type: object + registries.registryUpdatePayload: + properties: + Authentication: + description: Is authentication against this registry enabled + example: false + type: boolean + BaseURL: + description: BaseURL is used for quay registry + example: registry.mydomain.tld:2375 + type: string + Ecr: + allOf: + - $ref: '#/definitions/portainer.EcrData' + description: ECR data + Name: + description: Name that will be used to identify this registry + example: my-registry + type: string + Password: + description: Password used to authenticate against this registry. required + when Authentication is true + example: registry_password + type: string + Quay: + allOf: + - $ref: '#/definitions/portainer.QuayRegistryData' + description: Quay data + RegistryAccesses: + allOf: + - $ref: '#/definitions/portainer.RegistryAccesses' + description: Registry access control + URL: + description: URL or IP address of the Docker registry + example: registry.mydomain.tld:2375 + type: string + Username: + description: Username used to authenticate against this registry. Required + when Authentication is true + example: registry_user + type: string + required: + - Authentication + - Name + - URL + type: object + release.Chart: + properties: + files: + description: |- + Files are miscellaneous files in a chart archive, + e.g. README, LICENSE, etc. + items: + $ref: '#/definitions/release.File' + type: array + lock: + allOf: + - $ref: '#/definitions/release.Lock' + description: Lock is the contents of Chart.lock. + metadata: + allOf: + - $ref: '#/definitions/release.Metadata' + description: Metadata is the contents of the Chartfile. + schema: + description: Schema is an optional JSON schema for imposing structure on Values + items: + type: integer + type: array + templates: + description: Templates for this chart. + items: + $ref: '#/definitions/release.File' + type: array + values: + additionalProperties: {} + description: Values are default config for this chart. + type: object + type: object + release.ChartReference: + properties: + chartPath: + type: string + registryID: + type: integer + repoURL: + type: string + type: object + release.Dependency: + properties: + alias: + description: Alias usable alias to be used for the chart + type: string + condition: + description: A yaml path that resolves to a boolean, used for enabling/disabling + charts (e.g. subchart1.enabled ) + type: string + enabled: + description: Enabled bool determines if chart should be loaded + type: boolean + import-values: + description: |- + ImportValues holds the mapping of source values to parent key to be imported. Each item can be a + string or pair of child/parent sublist items. + items: {} + type: array + name: + description: |- + Name is the name of the dependency. + + This must mach the name in the dependency's Chart.yaml. + type: string + repository: + description: |- + The URL to the repository. + + Appending `index.yaml` to this string should result in a URL that can be + used to fetch the repository index. + type: string + tags: + description: Tags can be used to group charts for enabling/disabling together + items: + type: string + type: array + version: + description: |- + Version is the version (range) of this chart. + + A lock file will always produce a single version, while a dependency + may contain a semantic version range. + type: string + type: object + release.File: + properties: + data: + description: Data is the template as byte data. + items: + type: integer + type: array + name: + description: Name is the path-like name of the template. + type: string + type: object + release.HookExecution: + properties: + completed_at: + description: CompletedAt indicates the date/time this hook was completed. + type: string + phase: + description: Phase indicates whether the hook completed successfully + type: string + started_at: + description: StartedAt indicates the date/time this hook was started + type: string + type: object + release.Info: + properties: + deleted: + description: Deleted tracks when this object was deleted. + type: string + description: + description: Description is human-friendly "log entry" about this release. + type: string + first_deployed: + description: FirstDeployed is when the release was first deployed. + type: string + last_deployed: + description: LastDeployed is when the release was last deployed. + type: string + notes: + description: Contains the rendered templates/NOTES.txt if available + type: string + resources: + description: Resources is the list of resources that are part of the release + items: + $ref: '#/definitions/unstructured.Unstructured' + type: array + status: + description: Status is the current state of the release + type: string + type: object + release.Lock: + properties: + dependencies: + description: Dependencies is the list of dependencies that this lock file + has locked. + items: + $ref: '#/definitions/release.Dependency' + type: array + digest: + description: Digest is a hash of the dependencies in Chart.yaml. + type: string + generated: + description: Generated is the date the lock file was last generated. + type: string + type: object + release.Maintainer: + properties: + email: + description: Email is an optional email address to contact the named maintainer + type: string + name: + description: Name is a user name or organization name + type: string + url: + description: URL is an optional URL to an address for the named maintainer + type: string + type: object + release.Metadata: + properties: + annotations: + additionalProperties: + type: string + description: |- + Annotations are additional mappings uninterpreted by Helm, + made available for inspection by other applications. + type: object + apiVersion: + description: The API Version of this chart. Required. + type: string + appVersion: + description: The version of the application enclosed inside of this chart. + type: string + condition: + description: The condition to check to enable chart + type: string + dependencies: + description: Dependencies are a list of dependencies for a chart. + items: + $ref: '#/definitions/release.Dependency' + type: array + deprecated: + description: Whether or not this chart is deprecated + type: boolean + description: + description: A one-sentence description of the chart + type: string + home: + description: The URL to a relevant project page, git repo, or contact person + type: string + icon: + description: The URL to an icon file. + type: string + keywords: + description: A list of string keywords + items: + type: string + type: array + kubeVersion: + description: KubeVersion is a SemVer constraint specifying the version of + Kubernetes required. + type: string + maintainers: + description: A list of name and URL/email address combinations for the maintainer(s) + items: + $ref: '#/definitions/release.Maintainer' + type: array + name: + description: The name of the chart. Required. + type: string + sources: + description: Source is the URL to the source code of this chart + items: + type: string + type: array + tags: + description: The tags to check to enable chart + type: string + type: + description: 'Specifies the chart type: application or library' + type: string + version: + description: A SemVer 2 conformant version string of the chart. Required. + type: string + type: object + release.Release: + properties: + appVersion: + description: AppVersion is the app version of the release. + type: string + chart: + allOf: + - $ref: '#/definitions/release.Chart' + description: Chart is the chart that was released. + chartReference: + allOf: + - $ref: '#/definitions/release.ChartReference' + description: ChartReference are the labels that are used to identify the chart + source. + config: + additionalProperties: {} + description: |- + Config is the set of extra Values added to the chart. + These values override the default values inside of the chart. + type: object + hooks: + description: Hooks are all of the hooks declared for this release. + items: + $ref: '#/definitions/github_com_portainer_portainer_pkg_libhelm_release.Hook' + type: array + info: + allOf: + - $ref: '#/definitions/release.Info' + description: Info provides information about a release + manifest: + description: Manifest is the string representation of the rendered template. + type: string + name: + description: Name is the name of the release + type: string + namespace: + description: Namespace is the kubernetes namespace of the release. + type: string + stackID: + description: StackID is the ID of the Portainer stack associated with this + release (if using GitOps) + type: integer + values: + allOf: + - $ref: '#/definitions/release.Values' + description: Values are the values used to deploy the chart. + version: + description: Version is an int which represents the revision of the release. + type: integer + type: object + release.ReleaseElement: + properties: + appVersion: + type: string + chart: + type: string + name: + type: string + namespace: + type: string + revision: + type: string + status: + type: string + updated: + type: string + type: object + release.Values: + properties: + computedValues: + type: string + userSuppliedValues: + type: string + type: object + resource.Quantity: + properties: + Format: + enum: + - DecimalExponent + - BinarySI + - DecimalSI + type: string + x-enum-comments: + BinarySI: e.g., 12Mi (12 * 2^20) + DecimalExponent: e.g., 12e6 + DecimalSI: e.g., 12M (12 * 10^6) + x-enum-descriptions: + - e.g., 12e6 + - e.g., 12Mi (12 * 2^20) + - e.g., 12M (12 * 10^6) + x-enum-varnames: + - DecimalExponent + - BinarySI + - DecimalSI + type: object + resourcecontrols.resourceControlCreatePayload: + properties: + AdministratorsOnly: + description: Permit access to resource only to admins + example: true + type: boolean + Public: + description: Permit access to the associated resource to any user + example: true + type: boolean + ResourceID: + example: 617c5f22bb9b023d6daab7cba43a57576f83492867bc767d1c59416b065e5f08 + type: string + SubResourceIDs: + description: List of Docker resources that will inherit this access control + example: + - 617c5f22bb9b023d6daab7cba43a57576f83492867bc767d1c59416b065e5f08 + items: + type: string + type: array + Teams: + description: List of team identifiers with access to the associated resource + example: + - 56 + - 7 + items: + type: integer + type: array + Type: + allOf: + - $ref: '#/definitions/portainer.ResourceControlType' + description: |- + Type of Resource. Valid values are: 1 - container, 2 - service + 3 - volume, 4 - network, 5 - secret, 6 - stack, 7 - config, 8 - custom template, 9 - azure-container-group + enum: + - 1 + - 2 + - 3 + - 4 + - 5 + - 6 + - 7 + - 8 + - 9 + example: 1 + Users: + description: List of user identifiers with access to the associated resource + example: + - 1 + - 4 + items: + type: integer + type: array + required: + - ResourceID + - Type + type: object + resourcecontrols.resourceControlUpdatePayload: + properties: + AdministratorsOnly: + description: Permit access to resource only to admins + example: true + type: boolean + Public: + description: Permit access to the associated resource to any user + example: true + type: boolean + Teams: + description: List of team identifiers with access to the associated resource + example: + - 7 + items: + type: integer + type: array + Users: + description: List of user identifiers with access to the associated resource + example: + - 4 + items: + type: integer + type: array + type: object + roar.Roar-portainer_EndpointID: + type: object + settings.publicSettingsResponse: + properties: + AuthenticationMethod: + allOf: + - $ref: '#/definitions/portainer.AuthenticationMethod' + description: 'Active authentication method for the Portainer instance. Valid + values are: 1 for internal, 2 for LDAP, or 3 for oauth' + example: 1 + Edge: + properties: + CheckinInterval: + description: The check in interval for edge agent (in seconds) - used + in non async mode [seconds] + example: 60 + type: integer + CommandInterval: + description: The command list interval for edge agent - used in edge async + mode [seconds] + example: 60 + type: integer + PingInterval: + description: The ping interval for edge agent - used in edge async mode + [seconds] + example: 60 + type: integer + SnapshotInterval: + description: The snapshot interval for edge agent - used in edge async + mode [seconds] + example: 60 + type: integer + type: object + EnableEdgeComputeFeatures: + description: Whether edge compute features are enabled + example: true + type: boolean + Features: + additionalProperties: + type: boolean + description: Supported feature flags + type: object + GlobalDeploymentOptions: + allOf: + - $ref: '#/definitions/portainer.GlobalDeploymentOptions' + description: Deployment options for encouraging deployment as code + IsDockerDesktopExtension: + example: false + type: boolean + KubeconfigExpiry: + default: "0" + description: The expiry of a Kubeconfig + example: 24h + type: string + LogoURL: + 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 + example: https://mycompany.mydomain.tld/logo.png + type: string + OAuthLoginURI: + description: The URL used for oauth login + example: https://gitlab.com/oauth + type: string + OAuthLogoutURI: + description: The URL used for oauth logout + example: https://gitlab.com/oauth/logout + type: string + RequiredPasswordLength: + description: The minimum required length for a password of any user when using + internal auth mode + example: 1 + type: integer + RequiresSetupToken: + description: Whether the setup wizard must send the X-Setup-Token header for + admin init / restore + example: false + type: boolean + TeamSync: + description: Whether team sync is enabled + example: true + type: boolean + type: object + settings.settingsUpdatePayload: + properties: + AuthenticationMethod: + description: 'Active authentication method for the Portainer instance. Valid + values are: 1 for internal, 2 for LDAP, or 3 for oauth' + example: 1 + type: integer + BlackListedLabels: + description: A list of label name & value that will be used to hide containers + when querying containers + items: + $ref: '#/definitions/portainer.Pair' + type: array + EdgeAgentCheckinInterval: + example: 5 + type: integer + EdgePortainerURL: + description: EdgePortainerURL is the URL that is exposed to edge agents + type: string + EnableEdgeComputeFeatures: + description: Whether edge compute features are enabled + example: true + type: boolean + EnforceEdgeID: + description: EnforceEdgeID makes Portainer store the Edge ID instead of accepting + anyone + example: false + type: boolean + ForceSecureCookies: + description: ForceSecureCookies forces the Secure attribute on auth cookies + regardless of the detected scheme + example: false + type: boolean + GlobalDeploymentOptions: + allOf: + - $ref: '#/definitions/portainer.GlobalDeploymentOptions' + description: Deployment options for encouraging deployment as code + HelmRepositoryURL: + description: Helm repository URL + example: https://charts.bitnami.com/bitnami + type: string + InternalAuthSettings: + $ref: '#/definitions/portainer.InternalAuthSettings' + KubeconfigExpiry: + default: "0" + description: The expiry of a Kubeconfig + example: 24h + type: string + KubectlShellImage: + description: Kubectl Shell Image + example: portainer/kubectl-shell:latest + type: string + LDAPSettings: + $ref: '#/definitions/portainer.LDAPSettings' + LogoURL: + 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 + example: https://mycompany.mydomain.tld/logo.png + type: string + OAuthSettings: + $ref: '#/definitions/portainer.OAuthSettings' + SnapshotInterval: + description: The interval in which environment(endpoint) snapshots are created + example: 5m + type: string + TemplatesURL: + description: URL to the templates that will be displayed in the UI when navigating + to App Templates + example: https://raw.githubusercontent.com/portainer/templates/master/templates.json + type: string + TrustOnFirstConnect: + description: TrustOnFirstConnect makes Portainer accepting edge agent connection + by default + example: false + type: boolean + UserSessionTimeout: + description: The duration of a user session + example: 5m + type: string + type: object + sources.AutoUpdateInfo: + properties: + fetchInterval: + type: string + mechanism: + type: string + type: object + sources.ConnectionTestResult: + properties: + error: + type: string + success: + type: boolean + type: object + sources.GitAuthenticationPayload: + properties: + password: + type: string + username: + type: string + type: object + sources.GitAuthenticationUpdatePayload: + properties: + password: + type: string + username: + type: string + type: object + sources.GitSourceCreatePayload: + properties: + administratorsOnly: + example: true + type: boolean + authentication: + $ref: '#/definitions/sources.GitAuthenticationPayload' + name: + type: string + public: + example: true + type: boolean + teamAccesses: + items: + type: integer + type: array + tlsSkipVerify: + type: boolean + url: + type: string + userAccesses: + items: + type: integer + type: array + required: + - url + type: object + sources.GitSourceUpdatePayload: + properties: + authentication: + $ref: '#/definitions/sources.GitAuthenticationUpdatePayload' + name: + type: string + tlsSkipVerify: + type: boolean + url: + type: string + type: object + sources.Source: + properties: + environments: + type: integer + error: + type: string + id: + type: integer + lastSync: + type: integer + name: + type: string + status: + $ref: '#/definitions/workflows.Status' + type: + $ref: '#/definitions/sources.SourceType' + url: + type: string + usedBy: + type: integer + required: + - id + - name + - status + - type + - url + type: object + sources.SourceAccess: + properties: + public: + type: boolean + teams: + items: + type: integer + type: array + users: + items: + type: integer + type: array + type: object + sources.SourceAccessUpdatePayload: + properties: + public: + type: boolean + teams: + items: + type: integer + type: array + users: + items: + type: integer + type: array + type: object + sources.SourceDetail: + properties: + access: + $ref: '#/definitions/sources.SourceAccess' + autoUpdate: + $ref: '#/definitions/sources.AutoUpdateInfo' + connection: + $ref: '#/definitions/sources.connectionInfo' + environments: + type: integer + error: + type: string + id: + type: integer + lastSync: + type: integer + name: + type: string + status: + $ref: '#/definitions/workflows.Status' + type: + $ref: '#/definitions/sources.SourceType' + url: + type: string + usedBy: + type: integer + workflows: + items: + $ref: '#/definitions/workflows.Workflow' + type: array + required: + - connection + - id + - name + - status + - type + - url + type: object + sources.SourceType: + enum: + - git + - helm + - oci + type: string + x-enum-varnames: + - SourceTypeGit + - SourceTypeHelm + - SourceTypeOCI + sources.Status: + enum: + - unknown + - healthy + - error + type: string + x-enum-varnames: + - SourceStatusUnknown + - SourceStatusHealthy + - SourceStatusError + sources.connectionInfo: + properties: + authentication: + $ref: '#/definitions/sources.gitAuthInfo' + tlsSkipVerify: + type: boolean + type: object + sources.gitAuthInfo: + properties: + username: + type: string + type: object + ssl.sslUpdatePayload: + properties: + Cert: + description: SSL Certificates + type: string + HTTPEnabled: + type: boolean + Key: + type: string + type: object + stacks.composeStackFromFileContentPayload: + properties: + Env: + description: A list of environment variables used during stack deployment + items: + $ref: '#/definitions/portainer.Pair' + type: array + FromAppTemplate: + description: Whether the stack is from a app template + example: false + type: boolean + Name: + description: Name of the stack + example: myStack + type: string + StackFileContent: + description: Content of the Stack file + example: |- + version: 3 + services: + web: + image:nginx + type: string + required: + - Name + - StackFileContent + type: object + stacks.composeStackFromGitRepositoryPayload: + properties: + AdditionalFiles: + description: Applicable when deploying with multiple stack files + example: + - '[nz.compose.yml' + - ' uat.compose.yml]' + items: + type: string + type: array + AutoUpdate: + allOf: + - $ref: '#/definitions/portainer.AutoUpdateSettings' + description: Optional GitOps update configuration + ComposeFile: + default: docker-compose.yml + description: Path to the Stack file inside the Git repository + example: docker-compose.yml + type: string + Env: + description: A list of environment variables used during stack deployment + items: + $ref: '#/definitions/portainer.Pair' + type: array + FromAppTemplate: + description: Whether the stack is from a app template + example: false + type: boolean + Name: + description: Name of the stack + example: myStack + type: string + RepositoryAuthentication: + description: 'Deprecated: use SourceID instead. Use basic authentication to + clone the Git repository.' + example: true + type: boolean + RepositoryPassword: + description: 'Deprecated: use SourceID instead. Password used in basic authentication.' + example: myGitPassword + type: string + RepositoryReferenceName: + description: Reference name of a Git repository hosting the Stack file + example: refs/heads/master + type: string + RepositoryURL: + description: 'Deprecated: use SourceID instead. URL of a Git repository hosting + the Stack file.' + example: https://github.com/openfaas/faas + type: string + RepositoryUsername: + description: 'Deprecated: use SourceID instead. Username used in basic authentication.' + example: myGitUsername + type: string + SourceID: + description: |- + SourceID references an existing Source for git credentials/URL. + When set, the inline URL and authentication fields are ignored. + example: 1 + type: integer + TLSSkipVerify: + description: 'Deprecated: use SourceID instead. TLSSkipVerify skips SSL verification + when cloning the Git repository.' + example: false + type: boolean + required: + - Name + type: object + stacks.kubernetesGitDeploymentPayload: + properties: + AdditionalFiles: + items: + type: string + type: array + AutoUpdate: + $ref: '#/definitions/portainer.AutoUpdateSettings' + ComposeFormat: + type: boolean + ManifestFile: + type: string + Namespace: + type: string + RepositoryAuthentication: + description: 'Deprecated: use SourceID instead. Use basic authentication to + clone the Git repository.' + type: boolean + RepositoryPassword: + description: 'Deprecated: use SourceID instead. Password used in basic authentication.' + type: string + RepositoryReferenceName: + description: 'Deprecated: use SourceID instead. Reference name of a Git repository + hosting the Stack file.' + type: string + RepositoryURL: + description: 'Deprecated: use SourceID instead. URL of a Git repository hosting + the Stack file.' + type: string + RepositoryUsername: + description: 'Deprecated: use SourceID instead. Username used in basic authentication.' + type: string + SourceID: + description: |- + SourceID references an existing Source for git credentials/URL. + When set, the inline URL and authentication fields are ignored. + example: 1 + type: integer + StackName: + type: string + TLSSkipVerify: + description: 'Deprecated: use SourceID instead. TLSSkipVerify skips SSL verification + when cloning the Git repository.' + example: false + type: boolean + type: object + stacks.kubernetesManifestURLDeploymentPayload: + properties: + ComposeFormat: + type: boolean + ManifestURL: + type: string + Namespace: + type: string + StackName: + type: string + type: object + stacks.kubernetesStringDeploymentPayload: + properties: + ComposeFormat: + type: boolean + FromAppTemplate: + description: Whether the stack is from a app template + example: false + type: boolean + Namespace: + type: string + StackFileContent: + type: string + StackName: + type: string + type: object + stacks.stackFileResponse: + properties: + StackFileContent: + description: Content of the Stack file + example: |- + version: 3 + services: + web: + image:nginx + type: string + type: object + stacks.stackGitRedeployPayload: + properties: + Env: + items: + $ref: '#/definitions/portainer.Pair' + type: array + Prune: + type: boolean + PullImage: + description: |- + Deprecated(2.36): use RepullImageAndRedeploy instead for cleaner responsibility + Force a pulling to current image with the original tag though the image is already the latest + example: false + type: boolean + RepositoryAuthentication: + description: When true and RepositoryPassword is non-empty, stored credentials + are replaced. + type: boolean + RepositoryPassword: + description: Non-empty value (with RepositoryAuthentication=true) replaces + stored credentials; leave blank to keep them. + type: string + RepositoryReferenceName: + type: string + RepositoryUsername: + type: string + RepullImageAndRedeploy: + description: RepullImageAndRedeploy indicates whether to force repulling images + and redeploying the stack + type: boolean + StackName: + type: string + type: object + stacks.stackGitUpdatePayload: + properties: + AdditionalFiles: + items: + type: string + type: array + AutoUpdate: + $ref: '#/definitions/portainer.AutoUpdateSettings' + ConfigFilePath: + type: string + Env: + items: + $ref: '#/definitions/portainer.Pair' + type: array + Prune: + type: boolean + RepositoryAuthentication: + description: 'Deprecated: use SourceID instead. Use basic authentication to + clone the Git repository.' + type: boolean + RepositoryPassword: + description: 'Deprecated: use SourceID instead. Password used in basic authentication.' + type: string + RepositoryReferenceName: + type: string + RepositoryURL: + description: 'Deprecated: use SourceID instead. URL of a Git repository hosting + the Stack file.' + type: string + RepositoryUsername: + description: 'Deprecated: use SourceID instead. Username used in basic authentication.' + type: string + SourceID: + description: |- + SourceID references an existing Source for git credentials/URL. + When set, the inline URL and authentication fields are ignored. + type: integer + TLSSkipVerify: + description: 'Deprecated: use SourceID instead. Skip TLS verification when + cloning the Git repository.' + type: boolean + type: object + stacks.stackMigratePayload: + properties: + EndpointID: + description: Environment(Endpoint) identifier of the target environment(endpoint) + where the stack will be relocated + example: 2 + type: integer + Name: + description: If provided will rename the migrated stack + example: new-stack + type: string + SwarmID: + description: Swarm cluster identifier, must match the identifier of the cluster + where the stack will be relocated + example: jpofkc0i9uo9wtx1zesuk649w + type: string + required: + - EndpointID + type: object + stacks.stackResponse: + properties: + AdditionalFiles: + description: Only applies when deploying stack with multiple files + items: + type: string + type: array + AutoUpdate: + allOf: + - $ref: '#/definitions/portainer.AutoUpdateSettings' + description: The GitOps update settings of a git stack + CreatedBy: + description: The username which created this stack + example: admin + type: string + CreationDate: + description: The date in unix time when stack was created + example: 1587399600 + type: integer + CurrentDeploymentInfo: + allOf: + - $ref: '#/definitions/portainer.StackDeploymentInfo' + description: CurrentDeploymentInfo records the git repository state at the + time of the last actual deployment. + DeploymentStartStatus: + allOf: + - $ref: '#/definitions/portainer.StackStatus' + description: |- + DeploymentStartStatus is the stack status captured when the current + deployment starts. It is used by deployment logic during the current + deployment attempt and is cleared/replaced when a new deployment begins. + example: 1 + DeploymentStatus: + description: |- + DeploymentStatus records the status progression of the current deployment. + Cleared when a new deployment starts. + items: + $ref: '#/definitions/portainer.StackDeploymentStatus' + type: array + EndpointId: + description: Environment(Endpoint) identifier. Reference the environment(endpoint) + that will be used for deployment + example: 1 + type: integer + EntryPoint: + description: |- + EntryPoint is the path to the config file relative to the project root. + NOTE: For git stacks this mirrors GitConfig.ConfigFilePath and the two are kept in sync + by stackUpdateGit. The deploy command builder (compose_unpacker_cmd_builder) uses this + field directly; Kubernetes deploy and git clone operations use GitConfig.ConfigFilePath. + example: docker-compose.yml + type: string + Env: + description: A list of environment(endpoint) variables used during stack deployment + items: + $ref: '#/definitions/portainer.Pair' + type: array + FromAppTemplate: + description: Whether the stack is from a app template + example: false + type: boolean + GitConfig: + allOf: + - $ref: '#/definitions/gittypes.RepoConfig' + description: |- + GitConfig is the git repository configuration for git-backed stacks. + Deprecated: loaded from Source via WorkflowID; kept for DB backwards-compatibility only. + Non-migration code must not read or write this field; use Source records instead. + GitSourceId: + type: integer + Id: + description: Stack Identifier + example: 1 + type: integer + Name: + description: Stack name + example: myStack + type: string + Namespace: + description: Kubernetes namespace if stack is a kube application + example: default + type: string + Option: + allOf: + - $ref: '#/definitions/portainer.StackOption' + description: The stack deployment option + ProjectPath: + description: Path on disk to the repository hosting the Stack file + example: /data/compose/myStack_jpofkc0i9uo9wtx1zesuk649w + type: string + ResourceControl: + $ref: '#/definitions/portainer.ResourceControl' + Status: + allOf: + - $ref: '#/definitions/portainer.StackStatus' + description: Stack status (1 - active, 2 - inactive, 3 - deploying, 4 - error) + example: 1 + SwarmId: + description: Cluster identifier of the Swarm cluster where the stack is deployed + example: jpofkc0i9uo9wtx1zesuk649w + type: string + Type: + allOf: + - $ref: '#/definitions/portainer.StackType' + description: Stack type. 1 for a Swarm stack, 2 for a Compose stack + example: 2 + UpdateDate: + description: The date in unix time when stack was last updated + example: 1587399600 + type: integer + UpdatedBy: + description: The username which last updated this stack + example: bob + type: string + WorkflowID: + description: WorkflowID is the ID of the Workflow that owns the Source for + this stack. + type: integer + type: object + stacks.swarmStackFromFileContentPayload: + properties: + Env: + description: A list of environment variables used during stack deployment + items: + $ref: '#/definitions/portainer.Pair' + type: array + FromAppTemplate: + description: Whether the stack is from a app template + example: false + type: boolean + Name: + description: Name of the stack + example: myStack + type: string + StackFileContent: + description: Content of the Stack file + example: |- + version: 3 + services: + web: + image:nginx + type: string + SwarmID: + description: Swarm cluster identifier + example: jpofkc0i9uo9wtx1zesuk649w + type: string + required: + - Name + - StackFileContent + - SwarmID + type: object + stacks.swarmStackFromGitRepositoryPayload: + properties: + AdditionalFiles: + description: Applicable when deploying with multiple stack files + example: + - '[nz.compose.yml' + - ' uat.compose.yml]' + items: + type: string + type: array + AutoUpdate: + allOf: + - $ref: '#/definitions/portainer.AutoUpdateSettings' + description: Optional GitOps update configuration + ComposeFile: + default: docker-compose.yml + description: Path to the Stack file inside the Git repository + example: docker-compose.yml + type: string + Env: + description: A list of environment variables used during stack deployment + items: + $ref: '#/definitions/portainer.Pair' + type: array + FromAppTemplate: + description: Whether the stack is from a app template + example: false + type: boolean + Name: + description: Name of the stack + example: myStack + type: string + RepositoryAuthentication: + description: 'Deprecated: use SourceID instead. Use basic authentication to + clone the Git repository.' + example: true + type: boolean + RepositoryPassword: + description: 'Deprecated: use SourceID instead. Password used in basic authentication.' + example: myGitPassword + type: string + RepositoryReferenceName: + description: Reference name of a Git repository hosting the Stack file + example: refs/heads/master + type: string + RepositoryURL: + description: 'Deprecated: use SourceID instead. URL of a Git repository hosting + the Stack file.' + example: https://github.com/openfaas/faas + type: string + RepositoryUsername: + description: 'Deprecated: use SourceID instead. Username used in basic authentication.' + example: myGitUsername + type: string + SourceID: + description: |- + SourceID references an existing Source for git credentials/URL. + When set, the inline URL and authentication fields are ignored. + example: 1 + type: integer + SwarmID: + description: Swarm cluster identifier + example: jpofkc0i9uo9wtx1zesuk649w + type: string + TLSSkipVerify: + description: 'Deprecated: use SourceID instead. TLSSkipVerify skips SSL verification + when cloning the Git repository.' + example: false + type: boolean + required: + - Name + - SwarmID + type: object + stacks.updateSwarmStackPayload: + properties: + Env: + description: A list of environment(endpoint) variables used during stack deployment + items: + $ref: '#/definitions/portainer.Pair' + type: array + Prune: + description: Prune services that are no longer referenced + example: true + type: boolean + PullImage: + description: |- + Deprecated(2.36): use RepullImageAndRedeploy instead for cleaner responsibility + Force a pulling to current image with the original tag though the image is already the latest + example: false + type: boolean + RepullImageAndRedeploy: + description: RepullImageAndRedeploy indicates whether to force repulling images + and redeploying the stack + type: boolean + StackFileContent: + description: New content of the Stack file + example: |- + version: 3 + services: + web: + image:nginx + type: string + type: object + stats.ContainerStats: + properties: + healthy: + type: integer + running: + type: integer + stopped: + type: integer + total: + type: integer + unhealthy: + type: integer + type: object + swarm.ServiceUpdateResponse: + properties: + Warnings: + description: Optional warning messages + items: + type: string + type: array + type: object + system.nodesCountResponse: + properties: + nodes: + type: integer + type: object + system.status: + properties: + InstanceID: + description: Server Instance ID + example: 299ab403-70a8-4c05-92f7-bf7a994d50df + type: string + Version: + description: Portainer API version + example: 2.0.0 + type: string + type: object + system.systemInfoResponse: + properties: + agents: + type: integer + edgeAgents: + type: integer + platform: + $ref: '#/definitions/platform.ContainerPlatform' + type: object + system.versionResponse: + properties: + Build: + $ref: '#/definitions/build.BuildInfo' + DatabaseVersion: + type: string + Dependencies: + $ref: '#/definitions/build.DependenciesInfo' + LatestVersion: + description: The latest version available + example: 2.0.0 + type: string + Runtime: + $ref: '#/definitions/build.RuntimeInfo' + ServerEdition: + example: CE/EE + type: string + ServerVersion: + type: string + UpdateAvailable: + description: Whether portainer has an update available + example: false + type: boolean + VersionSupport: + example: STS/LTS + type: string + type: object + tags.tagCreatePayload: + properties: + Name: + example: org/acme + type: string + required: + - Name + type: object + teammemberships.teamMembershipCreatePayload: + properties: + Role: + description: Role for the user inside the team (1 for leader and 2 for regular + member) + enum: + - 1 + - 2 + example: 1 + type: integer + TeamID: + description: Team identifier + example: 1 + type: integer + UserID: + description: User identifier + example: 1 + type: integer + required: + - Role + - TeamID + - UserID + type: object + teammemberships.teamMembershipUpdatePayload: + properties: + Role: + description: Role for the user inside the team (1 for leader and 2 for regular + member) + enum: + - 1 + - 2 + example: 1 + type: integer + TeamID: + description: Team identifier + example: 1 + type: integer + UserID: + description: User identifier + example: 1 + type: integer + required: + - Role + - TeamID + - UserID + type: object + teams.teamCreatePayload: + properties: + Name: + description: Name + example: developers + type: string + TeamLeaders: + description: TeamLeaders + example: + - 3 + - 5 + items: + type: integer + type: array + required: + - Name + type: object + teams.teamUpdatePayload: + properties: + Name: + description: Name + example: developers + type: string + type: object + templates.fileResponse: + properties: + FileContent: + description: The requested file content + example: version:2 + type: string + type: object + templates.listResponse: + properties: + templates: + items: + $ref: '#/definitions/portainer.Template' + type: array + version: + type: string + type: object + unstructured.Unstructured: + properties: + Object: + additionalProperties: true + description: |- + Object is a JSON compatible map with string, float, int, bool, []interface{}, or + map[string]interface{} + children. + type: object + type: object + users.AccessLocation: + enum: + - environment + - environmentGroup + type: string + x-enum-varnames: + - AccessLocationEnvironment + - AccessLocationEnvironmentGroup + users.EffectiveAccessEntry: + properties: + accessLocation: + $ref: '#/definitions/users.AccessLocation' + endpointId: + type: integer + endpointName: + type: string + groupId: + type: integer + groupName: + type: string + roleId: + type: integer + roleName: + type: string + rolePriority: + type: integer + teamId: + type: integer + teamName: + type: string + type: object + users.accessTokenResponse: + properties: + apiKey: + $ref: '#/definitions/portainer.APIKey' + rawAPIKey: + type: string + type: object + users.addHelmRepoUrlPayload: + properties: + url: + type: string + type: object + users.adminInitPayload: + properties: + Password: + description: Password for the admin user + example: admin-password + type: string + Username: + description: Username for the admin user + example: admin + type: string + required: + - Password + - Username + type: object + users.helmUserRepositoryResponse: + properties: + GlobalRepository: + type: string + UserRepositories: + items: + $ref: '#/definitions/portainer.HelmUserRepository' + type: array + type: object + users.themePayload: + properties: + color: + description: Color represents the color theme of the UI + enum: + - dark + - light + - highcontrast + - auto + example: dark + type: string + type: object + users.userAccessTokenCreatePayload: + properties: + description: + example: github-api-key + type: string + password: + example: password + type: string + required: + - description + - password + type: object + users.userCreatePayload: + properties: + Password: + example: cg9Wgky3 + type: string + Role: + description: User role (1 for administrator account and 2 for regular account) + enum: + - 1 + - 2 + example: 2 + type: integer + Username: + example: bob + type: string + required: + - Password + - Role + - Username + type: object + users.userUpdatePasswordPayload: + properties: + NewPassword: + description: New Password + example: new_passwd + type: string + Password: + description: Current Password + example: passwd + type: string + required: + - NewPassword + - Password + type: object + users.userUpdatePayload: + properties: + NewPassword: + example: asfj2emv + type: string + Password: + example: cg9Wgky3 + type: string + Role: + description: User role (1 for administrator account and 2 for regular account) + enum: + - 1 + - 2 + example: 2 + type: integer + Theme: + $ref: '#/definitions/users.themePayload' + UseCache: + example: true + type: boolean + Username: + example: bob + type: string + required: + - NewPassword + - Password + - Role + - UseCache + - Username + type: object + v1.AppArmorProfile: + properties: + localhostProfile: + description: |- + localhostProfile indicates a profile loaded on the node that should be used. + The profile must be preconfigured on the node to work. + Must match the loaded name of the profile. + Must be set if and only if type is "Localhost". + +optional + type: string + type: + allOf: + - $ref: '#/definitions/v1.AppArmorProfileType' + description: |- + type indicates which kind of AppArmor profile will be applied. + Valid options are: + Localhost - a profile pre-loaded on the node. + RuntimeDefault - the container runtime's default profile. + Unconfined - no AppArmor enforcement. + +unionDiscriminator + type: object + v1.AppArmorProfileType: + enum: + - Unconfined + - RuntimeDefault + - Localhost + type: string + x-enum-varnames: + - AppArmorProfileTypeUnconfined + - AppArmorProfileTypeRuntimeDefault + - AppArmorProfileTypeLocalhost + v1.AttachedVolume: + properties: + devicePath: + description: DevicePath represents the device path where the volume should + be available + type: string + name: + description: Name of the attached volume + type: string + type: object + v1.CSIPersistentVolumeSource: + properties: + controllerExpandSecretRef: + allOf: + - $ref: '#/definitions/v1.SecretReference' + description: |- + controllerExpandSecretRef is a reference to the secret object containing + sensitive information to pass to the CSI driver to complete the CSI + ControllerExpandVolume call. + This field is optional, and may be empty if no secret is required. If the + secret object contains more than one secret, all secrets are passed. + +optional + controllerPublishSecretRef: + allOf: + - $ref: '#/definitions/v1.SecretReference' + description: |- + controllerPublishSecretRef is a reference to the secret object containing + sensitive information to pass to the CSI driver to complete the CSI + ControllerPublishVolume and ControllerUnpublishVolume calls. + This field is optional, and may be empty if no secret is required. If the + secret object contains more than one secret, all secrets are passed. + +optional + driver: + description: |- + driver is the name of the driver to use for this volume. + Required. + type: string + fsType: + description: |- + fsType to mount. Must be a filesystem type supported by the host operating system. + Ex. "ext4", "xfs", "ntfs". + +optional + type: string + nodeExpandSecretRef: + allOf: + - $ref: '#/definitions/v1.SecretReference' + description: |- + nodeExpandSecretRef is a reference to the secret object containing + sensitive information to pass to the CSI driver to complete the CSI + NodeExpandVolume call. + This field is optional, may be omitted if no secret is required. If the + secret object contains more than one secret, all secrets are passed. + +optional + nodePublishSecretRef: + allOf: + - $ref: '#/definitions/v1.SecretReference' + description: |- + nodePublishSecretRef is a reference to the secret object containing + sensitive information to pass to the CSI driver to complete the CSI + NodePublishVolume and NodeUnpublishVolume calls. + This field is optional, and may be empty if no secret is required. If the + secret object contains more than one secret, all secrets are passed. + +optional + nodeStageSecretRef: + allOf: + - $ref: '#/definitions/v1.SecretReference' + description: |- + nodeStageSecretRef is a reference to the secret object containing sensitive + information to pass to the CSI driver to complete the CSI NodeStageVolume + and NodeStageVolume and NodeUnstageVolume calls. + This field is optional, and may be empty if no secret is required. If the + secret object contains more than one secret, all secrets are passed. + +optional + readOnly: + description: |- + readOnly value to pass to ControllerPublishVolumeRequest. + Defaults to false (read/write). + +optional + type: boolean + volumeAttributes: + additionalProperties: + type: string + description: |- + volumeAttributes of the volume to publish. + +optional + type: object + volumeHandle: + description: |- + volumeHandle is the unique volume name returned by the CSI volume + plugin’s CreateVolume to refer to the volume on all subsequent calls. + Required. + type: string + type: object + v1.Capabilities: + properties: + add: + description: |- + Added capabilities + +optional + +listType=atomic + items: + type: string + type: array + drop: + description: |- + Removed capabilities + +optional + +listType=atomic + items: + type: string + type: array + type: object + v1.ConfigMapEnvSource: + properties: + name: + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + +optional + +default="" + +kubebuilder:default="" + TODO: Drop `kubebuilder:default` when controller-gen doesn't need it https://github.com/kubernetes-sigs/kubebuilder/issues/3896. + type: string + optional: + description: |- + Specify whether the ConfigMap must be defined + +optional + type: boolean + type: object + v1.ConfigMapKeySelector: + properties: + key: + description: The key to select. + type: string + name: + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + +optional + +default="" + +kubebuilder:default="" + TODO: Drop `kubebuilder:default` when controller-gen doesn't need it https://github.com/kubernetes-sigs/kubebuilder/issues/3896. + type: string + optional: + description: |- + Specify whether the ConfigMap or its key must be defined + +optional + type: boolean + type: object + v1.ConfigMapNodeConfigSource: + properties: + kubeletConfigKey: + description: |- + KubeletConfigKey declares which key of the referenced ConfigMap corresponds to the KubeletConfiguration structure + This field is required in all cases. + type: string + name: + description: |- + Name is the metadata.name of the referenced ConfigMap. + This field is required in all cases. + type: string + namespace: + description: |- + Namespace is the metadata.namespace of the referenced ConfigMap. + This field is required in all cases. + type: string + resourceVersion: + description: |- + ResourceVersion is the metadata.ResourceVersion of the referenced ConfigMap. + This field is forbidden in Node.Spec, and required in Node.Status. + +optional + type: string + uid: + description: |- + UID is the metadata.UID of the referenced ConfigMap. + This field is forbidden in Node.Spec, and required in Node.Status. + +optional + type: string + type: object + v1.Container: + properties: + args: + description: |- + Arguments to the entrypoint. + The container image's CMD is used if this is not provided. + Variable references $(VAR_NAME) are expanded using the container's environment. If a variable + cannot be resolved, the reference in the input string will be unchanged. Double $$ are reduced + to a single $, which allows for escaping the $(VAR_NAME) syntax: i.e. "$$(VAR_NAME)" will + produce the string literal "$(VAR_NAME)". Escaped references will never be expanded, regardless + of whether the variable exists or not. Cannot be updated. + More info: https://kubernetes.io/docs/tasks/inject-data-application/define-command-argument-container/#running-a-command-in-a-shell + +optional + +listType=atomic + items: + type: string + type: array + command: + description: |- + Entrypoint array. Not executed within a shell. + The container image's ENTRYPOINT is used if this is not provided. + Variable references $(VAR_NAME) are expanded using the container's environment. If a variable + cannot be resolved, the reference in the input string will be unchanged. Double $$ are reduced + to a single $, which allows for escaping the $(VAR_NAME) syntax: i.e. "$$(VAR_NAME)" will + produce the string literal "$(VAR_NAME)". Escaped references will never be expanded, regardless + of whether the variable exists or not. Cannot be updated. + More info: https://kubernetes.io/docs/tasks/inject-data-application/define-command-argument-container/#running-a-command-in-a-shell + +optional + +listType=atomic + items: + type: string + type: array + env: + description: |- + List of environment variables to set in the container. + Cannot be updated. + +optional + +patchMergeKey=name + +patchStrategy=merge + +listType=map + +listMapKey=name + items: + $ref: '#/definitions/v1.EnvVar' + type: array + envFrom: + description: |- + List of sources to populate environment variables in the container. + The keys defined within a source may consist of any printable ASCII characters except '='. + When a key exists in multiple + sources, the value associated with the last source will take precedence. + Values defined by an Env with a duplicate key will take precedence. + Cannot be updated. + +optional + +listType=atomic + items: + $ref: '#/definitions/v1.EnvFromSource' + type: array + image: + description: |- + Container image name. + More info: https://kubernetes.io/docs/concepts/containers/images + This field is optional to allow higher level config management to default or override + container images in workload controllers like Deployments and StatefulSets. + +optional + type: string + imagePullPolicy: + allOf: + - $ref: '#/definitions/v1.PullPolicy' + description: |- + Image pull policy. + One of Always, Never, IfNotPresent. + Defaults to Always if :latest tag is specified, or IfNotPresent otherwise. + Cannot be updated. + More info: https://kubernetes.io/docs/concepts/containers/images#updating-images + +optional + lifecycle: + allOf: + - $ref: '#/definitions/v1.Lifecycle' + description: |- + Actions that the management system should take in response to container lifecycle events. + Cannot be updated. + +optional + livenessProbe: + allOf: + - $ref: '#/definitions/v1.Probe' + description: |- + Periodic probe of container liveness. + Container will be restarted if the probe fails. + Cannot be updated. + More info: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle#container-probes + +optional + name: + description: |- + Name of the container specified as a DNS_LABEL. + Each container in a pod must have a unique name (DNS_LABEL). + Cannot be updated. + type: string + ports: + description: |- + List of ports to expose from the container. Not specifying a port here + DOES NOT prevent that port from being exposed. Any port which is + listening on the default "0.0.0.0" address inside a container will be + accessible from the network. + Modifying this array with strategic merge patch may corrupt the data. + For more information See https://github.com/kubernetes/kubernetes/issues/108255. + Cannot be updated. + +optional + +patchMergeKey=containerPort + +patchStrategy=merge + +listType=map + +listMapKey=containerPort + +listMapKey=protocol + items: + $ref: '#/definitions/v1.ContainerPort' + type: array + readinessProbe: + allOf: + - $ref: '#/definitions/v1.Probe' + description: |- + Periodic probe of container service readiness. + Container will be removed from service endpoints if the probe fails. + Cannot be updated. + More info: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle#container-probes + +optional + resizePolicy: + description: |- + Resources resize policy for the container. + This field cannot be set on ephemeral containers. + +featureGate=InPlacePodVerticalScaling + +optional + +listType=atomic + items: + $ref: '#/definitions/v1.ContainerResizePolicy' + type: array + resources: + allOf: + - $ref: '#/definitions/v1.ResourceRequirements' + description: |- + Compute Resources required by this container. + Cannot be updated. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + +optional + restartPolicy: + allOf: + - $ref: '#/definitions/v1.ContainerRestartPolicy' + description: |- + RestartPolicy defines the restart behavior of individual containers in a pod. + This overrides the pod-level restart policy. When this field is not specified, + the restart behavior is defined by the Pod's restart policy and the container type. + Additionally, setting the RestartPolicy as "Always" for the init container will + have the following effect: + this init container will be continually restarted on + exit until all regular containers have terminated. Once all regular + containers have completed, all init containers with restartPolicy "Always" + will be shut down. This lifecycle differs from normal init containers and + is often referred to as a "sidecar" container. Although this init + container still starts in the init container sequence, it does not wait + for the container to complete before proceeding to the next init + container. Instead, the next init container starts immediately after this + init container is started, or after any startupProbe has successfully + completed. + +optional + restartPolicyRules: + description: |- + Represents a list of rules to be checked to determine if the + container should be restarted on exit. The rules are evaluated in + order. Once a rule matches a container exit condition, the remaining + rules are ignored. If no rule matches the container exit condition, + the Container-level restart policy determines the whether the container + is restarted or not. Constraints on the rules: + - At most 20 rules are allowed. + - Rules can have the same action. + - Identical rules are not forbidden in validations. + When rules are specified, container MUST set RestartPolicy explicitly + even it if matches the Pod's RestartPolicy. + +featureGate=ContainerRestartRules + +optional + +listType=atomic + items: + $ref: '#/definitions/v1.ContainerRestartRule' + type: array + securityContext: + allOf: + - $ref: '#/definitions/v1.SecurityContext' + description: |- + SecurityContext defines the security options the container should be run with. + If set, the fields of SecurityContext override the equivalent fields of PodSecurityContext. + More info: https://kubernetes.io/docs/tasks/configure-pod-container/security-context/ + +optional + startupProbe: + allOf: + - $ref: '#/definitions/v1.Probe' + description: |- + StartupProbe indicates that the Pod has successfully initialized. + If specified, no other probes are executed until this completes successfully. + If this probe fails, the Pod will be restarted, just as if the livenessProbe failed. + This can be used to provide different probe parameters at the beginning of a Pod's lifecycle, + when it might take a long time to load data or warm a cache, than during steady-state operation. + This cannot be updated. + More info: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle#container-probes + +optional + stdin: + description: |- + Whether this container should allocate a buffer for stdin in the container runtime. If this + is not set, reads from stdin in the container will always result in EOF. + Default is false. + +optional + type: boolean + stdinOnce: + description: |- + Whether the container runtime should close the stdin channel after it has been opened by + a single attach. When stdin is true the stdin stream will remain open across multiple attach + sessions. If stdinOnce is set to true, stdin is opened on container start, is empty until the + first client attaches to stdin, and then remains open and accepts data until the client disconnects, + at which time stdin is closed and remains closed until the container is restarted. If this + flag is false, a container processes that reads from stdin will never receive an EOF. + Default is false + +optional + type: boolean + terminationMessagePath: + description: |- + Optional: Path at which the file to which the container's termination message + will be written is mounted into the container's filesystem. + Message written is intended to be brief final status, such as an assertion failure message. + Will be truncated by the node if greater than 4096 bytes. The total message length across + all containers will be limited to 12kb. + Defaults to /dev/termination-log. + Cannot be updated. + +optional + type: string + terminationMessagePolicy: + allOf: + - $ref: '#/definitions/v1.TerminationMessagePolicy' + description: |- + Indicate how the termination message should be populated. File will use the contents of + terminationMessagePath to populate the container status message on both success and failure. + FallbackToLogsOnError will use the last chunk of container log output if the termination + message file is empty and the container exited with an error. + The log output is limited to 2048 bytes or 80 lines, whichever is smaller. + Defaults to File. + Cannot be updated. + +optional + tty: + description: |- + Whether this container should allocate a TTY for itself, also requires 'stdin' to be true. + Default is false. + +optional + type: boolean + volumeDevices: + description: |- + volumeDevices is the list of block devices to be used by the container. + +patchMergeKey=devicePath + +patchStrategy=merge + +listType=map + +listMapKey=devicePath + +optional + items: + $ref: '#/definitions/v1.VolumeDevice' + type: array + volumeMounts: + description: |- + Pod volumes to mount into the container's filesystem. + Cannot be updated. + +optional + +patchMergeKey=mountPath + +patchStrategy=merge + +listType=map + +listMapKey=mountPath + items: + $ref: '#/definitions/v1.VolumeMount' + type: array + workingDir: + description: |- + Container's working directory. + If not specified, the container runtime's default will be used, which + might be configured in the container image. + Cannot be updated. + +optional + type: string + type: object + v1.ContainerImage: + properties: + names: + description: |- + Names by which this image is known. + e.g. ["kubernetes.example/hyperkube:v1.0.7", "cloud-vendor.registry.example/cloud-vendor/hyperkube:v1.0.7"] + +optional + +listType=atomic + items: + type: string + type: array + sizeBytes: + description: |- + The size of the image in bytes. + +optional + type: integer + type: object + v1.ContainerPort: + properties: + containerPort: + description: |- + Number of port to expose on the pod's IP address. + This must be a valid port number, 0 < x < 65536. + type: integer + hostIP: + description: |- + What host IP to bind the external port to. + +optional + type: string + hostPort: + description: |- + Number of port to expose on the host. + If specified, this must be a valid port number, 0 < x < 65536. + If HostNetwork is specified, this must match ContainerPort. + Most containers do not need this. + +optional + type: integer + name: + description: |- + If specified, this must be an IANA_SVC_NAME and unique within the pod. Each + named port in a pod must have a unique name. Name for the port that can be + referred to by services. + +optional + type: string + protocol: + allOf: + - $ref: '#/definitions/v1.Protocol' + description: |- + Protocol for port. Must be UDP, TCP, or SCTP. + Defaults to "TCP". + +optional + +default="TCP" + type: object + v1.ContainerResizePolicy: + properties: + resourceName: + allOf: + - $ref: '#/definitions/v1.ResourceName' + description: |- + Name of the resource to which this resource resize policy applies. + Supported values: cpu, memory. + restartPolicy: + allOf: + - $ref: '#/definitions/v1.ResourceResizeRestartPolicy' + description: |- + Restart policy to apply when specified resource is resized. + If not specified, it defaults to NotRequired. + type: object + v1.ContainerRestartPolicy: + enum: + - Always + - Never + - OnFailure + type: string + x-enum-varnames: + - ContainerRestartPolicyAlways + - ContainerRestartPolicyNever + - ContainerRestartPolicyOnFailure + v1.ContainerRestartRule: + properties: + action: + allOf: + - $ref: '#/definitions/v1.ContainerRestartRuleAction' + description: |- + Specifies the action taken on a container exit if the requirements + are satisfied. The only possible value is "Restart" to restart the + container. + +required + exitCodes: + allOf: + - $ref: '#/definitions/v1.ContainerRestartRuleOnExitCodes' + description: |- + Represents the exit codes to check on container exits. + +optional + +oneOf=when + type: object + v1.ContainerRestartRuleAction: + enum: + - Restart + - RestartAllContainers + type: string + x-enum-varnames: + - ContainerRestartRuleActionRestart + - ContainerRestartRuleActionRestartAllContainers + v1.ContainerRestartRuleOnExitCodes: + properties: + operator: + allOf: + - $ref: '#/definitions/v1.ContainerRestartRuleOnExitCodesOperator' + description: |- + Represents the relationship between the container exit code(s) and the + specified values. Possible values are: + - In: the requirement is satisfied if the container exit code is in the + set of specified values. + - NotIn: the requirement is satisfied if the container exit code is + not in the set of specified values. + +required + values: + description: |- + Specifies the set of values to check for container exit codes. + At most 255 elements are allowed. + +optional + +listType=set + items: + type: integer + type: array + type: object + v1.ContainerRestartRuleOnExitCodesOperator: + enum: + - In + - NotIn + type: string + x-enum-varnames: + - ContainerRestartRuleOnExitCodesOpIn + - ContainerRestartRuleOnExitCodesOpNotIn + v1.DaemonEndpoint: + properties: + Port: + description: Port number of the given endpoint. + type: integer + type: object + v1.EnvFromSource: + properties: + configMapRef: + allOf: + - $ref: '#/definitions/v1.ConfigMapEnvSource' + description: |- + The ConfigMap to select from + +optional + prefix: + description: |- + Optional text to prepend to the name of each environment variable. + May consist of any printable ASCII characters except '='. + +optional + type: string + secretRef: + allOf: + - $ref: '#/definitions/v1.SecretEnvSource' + description: |- + The Secret to select from + +optional + type: object + v1.EnvVar: + properties: + name: + description: |- + Name of the environment variable. + May consist of any printable ASCII characters except '='. + type: string + value: + description: |- + Variable references $(VAR_NAME) are expanded + using the previously defined environment variables in the container and + any service environment variables. If a variable cannot be resolved, + the reference in the input string will be unchanged. Double $$ are reduced + to a single $, which allows for escaping the $(VAR_NAME) syntax: i.e. + "$$(VAR_NAME)" will produce the string literal "$(VAR_NAME)". + Escaped references will never be expanded, regardless of whether the variable + exists or not. + Defaults to "". + +optional + type: string + valueFrom: + allOf: + - $ref: '#/definitions/v1.EnvVarSource' + description: |- + Source for the environment variable's value. Cannot be used if value is not empty. + +optional + type: object + v1.EnvVarSource: + properties: + configMapKeyRef: + allOf: + - $ref: '#/definitions/v1.ConfigMapKeySelector' + description: |- + Selects a key of a ConfigMap. + +optional + fieldRef: + allOf: + - $ref: '#/definitions/v1.ObjectFieldSelector' + description: |- + Selects a field of the pod: supports metadata.name, metadata.namespace, `metadata.labels['']`, `metadata.annotations['']`, + spec.nodeName, spec.serviceAccountName, status.hostIP, status.podIP, status.podIPs. + +optional + fileKeyRef: + allOf: + - $ref: '#/definitions/v1.FileKeySelector' + description: |- + FileKeyRef selects a key of the env file. + Requires the EnvFiles feature gate to be enabled. + + +featureGate=EnvFiles + +optional + resourceFieldRef: + allOf: + - $ref: '#/definitions/v1.ResourceFieldSelector' + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, limits.ephemeral-storage, requests.cpu, requests.memory and requests.ephemeral-storage) are currently supported. + +optional + secretKeyRef: + allOf: + - $ref: '#/definitions/v1.SecretKeySelector' + description: |- + Selects a key of a secret in the pod's namespace + +optional + type: object + v1.ExecAction: + properties: + command: + description: |- + Command is the command line to execute inside the container, the working directory for the + command is root ('/') in the container's filesystem. The command is simply exec'd, it is + not run inside a shell, so traditional shell instructions ('|', etc) won't work. To use + a shell, you need to explicitly call out to that shell. + Exit status of 0 is treated as live/healthy and non-zero is unhealthy. + +optional + +listType=atomic + items: + type: string + type: array + type: object + v1.FieldsV1: + type: object + v1.FileKeySelector: + properties: + key: + description: |- + The key within the env file. An invalid key will prevent the pod from starting. + The keys defined within a source may consist of any printable ASCII characters except '='. + During Alpha stage of the EnvFiles feature gate, the key size is limited to 128 characters. + +required + type: string + optional: + description: |- + Specify whether the file or its key must be defined. If the file or key + does not exist, then the env var is not published. + If optional is set to true and the specified key does not exist, + the environment variable will not be set in the Pod's containers. + + If optional is set to false and the specified key does not exist, + an error will be returned during Pod creation. + +optional + +default=false + type: boolean + path: + description: |- + The path within the volume from which to select the file. + Must be relative and may not contain the '..' path or start with '..'. + +required + type: string + volumeName: + description: |- + The name of the volume mount containing the env file. + +required + type: string + type: object + v1.GRPCAction: + properties: + port: + description: Port number of the gRPC service. Number must be in the range + 1 to 65535. + type: integer + service: + description: |- + Service is the name of the service to place in the gRPC HealthCheckRequest + (see https://github.com/grpc/grpc/blob/master/doc/health-checking.md). + + If this is not specified, the default behavior is defined by gRPC. + +optional + +default="" + type: string + type: object + v1.HTTPGetAction: + properties: + host: + description: |- + Host name to connect to, defaults to the pod IP. You probably want to set + "Host" in httpHeaders instead. + +optional + type: string + httpHeaders: + description: |- + Custom headers to set in the request. HTTP allows repeated headers. + +optional + +listType=atomic + items: + $ref: '#/definitions/k8s_io_api_core_v1.HTTPHeader' + type: array + path: + description: |- + Path to access on the HTTP server. + +optional + type: string + port: + allOf: + - $ref: '#/definitions/intstr.IntOrString' + description: |- + Name or number of the port to access on the container. + Number must be in the range 1 to 65535. + Name must be an IANA_SVC_NAME. + scheme: + allOf: + - $ref: '#/definitions/v1.URIScheme' + description: |- + Scheme to use for connecting to the host. + Defaults to HTTP. + +optional + type: object + v1.Lifecycle: + properties: + postStart: + allOf: + - $ref: '#/definitions/v1.LifecycleHandler' + description: |- + PostStart is called immediately after a container is created. If the handler fails, + the container is terminated and restarted according to its restart policy. + Other management of the container blocks until the hook completes. + More info: https://kubernetes.io/docs/concepts/containers/container-lifecycle-hooks/#container-hooks + +optional + preStop: + allOf: + - $ref: '#/definitions/v1.LifecycleHandler' + description: |- + PreStop is called immediately before a container is terminated due to an + API request or management event such as liveness/startup probe failure, + preemption, resource contention, etc. The handler is not called if the + container crashes or exits. The Pod's termination grace period countdown begins before the + PreStop hook is executed. Regardless of the outcome of the handler, the + container will eventually terminate within the Pod's termination grace + period (unless delayed by finalizers). Other management of the container blocks until the hook completes + or until the termination grace period is reached. + More info: https://kubernetes.io/docs/concepts/containers/container-lifecycle-hooks/#container-hooks + +optional + stopSignal: + allOf: + - $ref: '#/definitions/v1.Signal' + description: |- + StopSignal defines which signal will be sent to a container when it is being stopped. + If not specified, the default is defined by the container runtime in use. + StopSignal can only be set for Pods with a non-empty .spec.os.name + +optional + type: object + v1.LifecycleHandler: + properties: + exec: + allOf: + - $ref: '#/definitions/v1.ExecAction' + description: |- + Exec specifies a command to execute in the container. + +optional + httpGet: + allOf: + - $ref: '#/definitions/v1.HTTPGetAction' + description: |- + HTTPGet specifies an HTTP GET request to perform. + +optional + sleep: + allOf: + - $ref: '#/definitions/v1.SleepAction' + description: |- + Sleep represents a duration that the container should sleep. + +optional + tcpSocket: + allOf: + - $ref: '#/definitions/v1.TCPSocketAction' + description: |- + Deprecated. TCPSocket is NOT supported as a LifecycleHandler and kept + for backward compatibility. There is no validation of this field and + lifecycle hooks will fail at runtime when it is specified. + +optional + type: object + v1.ListMeta: + properties: + continue: + description: |- + continue may be set if the user set a limit on the number of items returned, and indicates that + the server has more data available. The value is opaque and may be used to issue another request + to the endpoint that served this list to retrieve the next set of available objects. Continuing a + consistent list may not be possible if the server configuration has changed or more than a few + minutes have passed. The resourceVersion field returned when using this continue value will be + identical to the value in the first response, unless you have received this token from an error + message. + type: string + remainingItemCount: + description: |- + remainingItemCount is the number of subsequent items in the list which are not included in this + list response. If the list request contained label or field selectors, then the number of + remaining items is unknown and the field will be left unset and omitted during serialization. + If the list is complete (either because it is not chunking or because this is the last chunk), + then there are no more remaining items and this field will be left unset and omitted during + serialization. + Servers older than v1.15 do not set this field. + The intended use of the remainingItemCount is *estimating* the size of a collection. Clients + should not rely on the remainingItemCount to be set or to be exact. + +optional + type: integer + resourceVersion: + description: |- + String that identifies the server's internal version of this object that + can be used by clients to determine when objects have changed. + Value must be treated as opaque by clients and passed unmodified back to the server. + Populated by the system. + Read-only. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency + +optional + type: string + selfLink: + description: |- + Deprecated: selfLink is a legacy read-only field that is no longer populated by the system. + +optional + type: string + type: object + v1.ManagedFieldsEntry: + properties: + apiVersion: + description: |- + APIVersion defines the version of this resource that this field set + applies to. The format is "group/version" just like the top-level + APIVersion field. It is necessary to track the version of a field + set because it cannot be automatically converted. + type: string + fieldsType: + description: |- + FieldsType is the discriminator for the different fields format and version. + There is currently only one possible value: "FieldsV1" + type: string + fieldsV1: + allOf: + - $ref: '#/definitions/v1.FieldsV1' + description: |- + FieldsV1 holds the first JSON version format as described in the "FieldsV1" type. + +optional + manager: + description: Manager is an identifier of the workflow managing these fields. + type: string + operation: + allOf: + - $ref: '#/definitions/v1.ManagedFieldsOperationType' + description: |- + Operation is the type of operation which lead to this ManagedFieldsEntry being created. + The only valid values for this field are 'Apply' and 'Update'. + subresource: + description: |- + Subresource is the name of the subresource used to update that object, or + empty string if the object was updated through the main resource. The + value of this field is used to distinguish between managers, even if they + share the same name. For example, a status update will be distinct from a + regular update using the same manager name. + Note that the APIVersion field is not related to the Subresource field and + it always corresponds to the version of the main resource. + type: string + time: + description: |- + Time is the timestamp of when the ManagedFields entry was added. The + timestamp will also be updated if a field is added, the manager + changes any of the owned fields value or removes a field. The + timestamp does not update when a field is removed from the entry + because another manager took it over. + +optional + type: string + type: object + v1.ManagedFieldsOperationType: + enum: + - Apply + - Update + type: string + x-enum-varnames: + - ManagedFieldsOperationApply + - ManagedFieldsOperationUpdate + v1.MountPropagationMode: + enum: + - None + - HostToContainer + - Bidirectional + type: string + x-enum-varnames: + - MountPropagationNone + - MountPropagationHostToContainer + - MountPropagationBidirectional + v1.NamespaceCondition: + properties: + lastTransitionTime: + description: |- + Last time the condition transitioned from one status to another. + +optional + type: string + message: + description: |- + Human-readable message indicating details about last transition. + +optional + type: string + reason: + description: |- + Unique, one-word, CamelCase reason for the condition's last transition. + +optional + type: string + status: + allOf: + - $ref: '#/definitions/k8s_io_api_core_v1.ConditionStatus' + description: Status of the condition, one of True, False, Unknown. + type: + allOf: + - $ref: '#/definitions/v1.NamespaceConditionType' + description: Type of namespace controller condition. + type: object + v1.NamespaceConditionType: + enum: + - NamespaceDeletionDiscoveryFailure + - NamespaceDeletionContentFailure + - NamespaceDeletionGroupVersionParsingFailure + - NamespaceContentRemaining + - NamespaceFinalizersRemaining + type: string + x-enum-varnames: + - NamespaceDeletionDiscoveryFailure + - NamespaceDeletionContentFailure + - NamespaceDeletionGVParsingFailure + - NamespaceContentRemaining + - NamespaceFinalizersRemaining + v1.NamespacePhase: + enum: + - Active + - Terminating + type: string + x-enum-varnames: + - NamespaceActive + - NamespaceTerminating + v1.NamespaceStatus: + properties: + conditions: + description: |- + Represents the latest available observations of a namespace's current state. + +optional + +patchMergeKey=type + +patchStrategy=merge + +listType=map + +listMapKey=type + items: + $ref: '#/definitions/v1.NamespaceCondition' + type: array + phase: + allOf: + - $ref: '#/definitions/v1.NamespacePhase' + description: |- + Phase is the current lifecycle phase of the namespace. + More info: https://kubernetes.io/docs/tasks/administer-cluster/namespaces/ + +optional + type: object + v1.NodeAddress: + properties: + address: + description: The node address. + type: string + type: + allOf: + - $ref: '#/definitions/v1.NodeAddressType' + description: Node address type, one of Hostname, ExternalIP or InternalIP. + type: object + v1.NodeAddressType: + enum: + - Hostname + - InternalIP + - ExternalIP + - InternalDNS + - ExternalDNS + type: string + x-enum-varnames: + - NodeHostName + - NodeInternalIP + - NodeExternalIP + - NodeInternalDNS + - NodeExternalDNS + v1.NodeCondition: + properties: + lastHeartbeatTime: + description: |- + Last time we got an update on a given condition. + +optional + type: string + lastTransitionTime: + description: |- + Last time the condition transit from one status to another. + +optional + type: string + message: + description: |- + Human readable message indicating details about last transition. + +optional + type: string + reason: + description: |- + (brief) reason for the condition's last transition. + +optional + type: string + status: + allOf: + - $ref: '#/definitions/k8s_io_api_core_v1.ConditionStatus' + description: Status of the condition, one of True, False, Unknown. + type: + allOf: + - $ref: '#/definitions/v1.NodeConditionType' + description: Type of node condition. + type: object + v1.NodeConditionType: + enum: + - Ready + - MemoryPressure + - DiskPressure + - PIDPressure + - NetworkUnavailable + type: string + x-enum-varnames: + - NodeReady + - NodeMemoryPressure + - NodeDiskPressure + - NodePIDPressure + - NodeNetworkUnavailable + v1.NodeConfigSource: + properties: + configMap: + allOf: + - $ref: '#/definitions/v1.ConfigMapNodeConfigSource' + description: ConfigMap is a reference to a Node's ConfigMap + type: object + v1.NodeConfigStatus: + properties: + active: + allOf: + - $ref: '#/definitions/v1.NodeConfigSource' + description: |- + Active reports the checkpointed config the node is actively using. + Active will represent either the current version of the Assigned config, + or the current LastKnownGood config, depending on whether attempting to use the + Assigned config results in an error. + +optional + assigned: + allOf: + - $ref: '#/definitions/v1.NodeConfigSource' + description: |- + Assigned reports the checkpointed config the node will try to use. + When Node.Spec.ConfigSource is updated, the node checkpoints the associated + config payload to local disk, along with a record indicating intended + config. The node refers to this record to choose its config checkpoint, and + reports this record in Assigned. Assigned only updates in the status after + the record has been checkpointed to disk. When the Kubelet is restarted, + it tries to make the Assigned config the Active config by loading and + validating the checkpointed payload identified by Assigned. + +optional + error: + description: |- + Error describes any problems reconciling the Spec.ConfigSource to the Active config. + Errors may occur, for example, attempting to checkpoint Spec.ConfigSource to the local Assigned + record, attempting to checkpoint the payload associated with Spec.ConfigSource, attempting + to load or validate the Assigned config, etc. + Errors may occur at different points while syncing config. Earlier errors (e.g. download or + checkpointing errors) will not result in a rollback to LastKnownGood, and may resolve across + Kubelet retries. Later errors (e.g. loading or validating a checkpointed config) will result in + a rollback to LastKnownGood. In the latter case, it is usually possible to resolve the error + by fixing the config assigned in Spec.ConfigSource. + You can find additional information for debugging by searching the error message in the Kubelet log. + Error is a human-readable description of the error state; machines can check whether or not Error + is empty, but should not rely on the stability of the Error text across Kubelet versions. + +optional + type: string + lastKnownGood: + allOf: + - $ref: '#/definitions/v1.NodeConfigSource' + description: |- + LastKnownGood reports the checkpointed config the node will fall back to + when it encounters an error attempting to use the Assigned config. + The Assigned config becomes the LastKnownGood config when the node determines + that the Assigned config is stable and correct. + This is currently implemented as a 10-minute soak period starting when the local + record of Assigned config is updated. If the Assigned config is Active at the end + of this period, it becomes the LastKnownGood. Note that if Spec.ConfigSource is + reset to nil (use local defaults), the LastKnownGood is also immediately reset to nil, + because the local default config is always assumed good. + You should not make assumptions about the node's method of determining config stability + and correctness, as this may change or become configurable in the future. + +optional + type: object + v1.NodeDaemonEndpoints: + properties: + kubeletEndpoint: + allOf: + - $ref: '#/definitions/v1.DaemonEndpoint' + description: |- + Endpoint on which Kubelet is listening. + +optional + type: object + v1.NodeFeatures: + properties: + supplementalGroupsPolicy: + description: |- + SupplementalGroupsPolicy is set to true if the runtime supports SupplementalGroupsPolicy and ContainerUser. + +optional + type: boolean + type: object + v1.NodePhase: + enum: + - Pending + - Running + - Terminated + type: string + x-enum-varnames: + - NodePending + - NodeRunning + - NodeTerminated + v1.NodeRuntimeHandler: + properties: + features: + allOf: + - $ref: '#/definitions/v1.NodeRuntimeHandlerFeatures' + description: |- + Supported features. + +optional + name: + description: |- + Runtime handler name. + Empty for the default runtime handler. + +optional + type: string + type: object + v1.NodeRuntimeHandlerFeatures: + properties: + recursiveReadOnlyMounts: + description: |- + RecursiveReadOnlyMounts is set to true if the runtime handler supports RecursiveReadOnlyMounts. + +optional + type: boolean + userNamespaces: + description: |- + UserNamespaces is set to true if the runtime handler supports UserNamespaces, including for volumes. + +featureGate=UserNamespacesSupport + +optional + type: boolean + type: object + v1.NodeSpec: + properties: + configSource: + allOf: + - $ref: '#/definitions/v1.NodeConfigSource' + description: |- + Deprecated: Previously used to specify the source of the node's configuration for the DynamicKubeletConfig feature. This feature is removed. + +optional + externalID: + description: |- + Deprecated. Not all kubelets will set this field. Remove field after 1.13. + see: https://issues.k8s.io/61966 + +optional + type: string + podCIDR: + description: |- + PodCIDR represents the pod IP range assigned to the node. + +optional + type: string + podCIDRs: + description: |- + podCIDRs represents the IP ranges assigned to the node for usage by Pods on that node. If this + field is specified, the 0th entry must match the podCIDR field. It may contain at most 1 value for + each of IPv4 and IPv6. + +optional + +patchStrategy=merge + +listType=set + items: + type: string + type: array + providerID: + description: |- + ID of the node assigned by the cloud provider in the format: :// + +optional + type: string + taints: + description: |- + If specified, the node's taints. + +optional + +listType=atomic + items: + $ref: '#/definitions/v1.Taint' + type: array + unschedulable: + description: |- + Unschedulable controls node schedulability of new pods. By default, node is schedulable. + More info: https://kubernetes.io/docs/concepts/nodes/node/#manual-node-administration + +optional + type: boolean + type: object + v1.NodeStatus: + properties: + addresses: + description: |- + List of addresses reachable to the node. + Queried from cloud provider, if available. + More info: https://kubernetes.io/docs/reference/node/node-status/#addresses + Note: This field is declared as mergeable, but the merge key is not sufficiently + unique, which can cause data corruption when it is merged. Callers should instead + use a full-replacement patch. See https://pr.k8s.io/79391 for an example. + Consumers should assume that addresses can change during the + lifetime of a Node. However, there are some exceptions where this may not + be possible, such as Pods that inherit a Node's address in its own status or + consumers of the downward API (status.hostIP). + +optional + +patchMergeKey=type + +patchStrategy=merge + +listType=map + +listMapKey=type + items: + $ref: '#/definitions/v1.NodeAddress' + type: array + allocatable: + allOf: + - $ref: '#/definitions/v1.ResourceList' + description: |- + Allocatable represents the resources of a node that are available for scheduling. + Defaults to Capacity. + +optional + capacity: + allOf: + - $ref: '#/definitions/v1.ResourceList' + description: |- + Capacity represents the total resources of a node. + More info: https://kubernetes.io/docs/reference/node/node-status/#capacity + +optional + conditions: + description: |- + Conditions is an array of current observed node conditions. + More info: https://kubernetes.io/docs/reference/node/node-status/#condition + +optional + +patchMergeKey=type + +patchStrategy=merge + +listType=map + +listMapKey=type + items: + $ref: '#/definitions/v1.NodeCondition' + type: array + config: + allOf: + - $ref: '#/definitions/v1.NodeConfigStatus' + description: |- + Status of the config assigned to the node via the dynamic Kubelet config feature. + +optional + daemonEndpoints: + allOf: + - $ref: '#/definitions/v1.NodeDaemonEndpoints' + description: |- + Endpoints of daemons running on the Node. + +optional + declaredFeatures: + description: |- + DeclaredFeatures represents the features related to feature gates that are declared by the node. + +featureGate=NodeDeclaredFeatures + +optional + +listType=atomic + items: + type: string + type: array + features: + allOf: + - $ref: '#/definitions/v1.NodeFeatures' + description: |- + Features describes the set of features implemented by the CRI implementation. + +featureGate=SupplementalGroupsPolicy + +optional + images: + description: |- + List of container images on this node + +optional + +listType=atomic + items: + $ref: '#/definitions/v1.ContainerImage' + type: array + nodeInfo: + allOf: + - $ref: '#/definitions/v1.NodeSystemInfo' + description: |- + Set of ids/uuids to uniquely identify the node. + More info: https://kubernetes.io/docs/reference/node/node-status/#info + +optional + phase: + allOf: + - $ref: '#/definitions/v1.NodePhase' + description: |- + NodePhase is the recently observed lifecycle phase of the node. + More info: https://kubernetes.io/docs/concepts/nodes/node/#phase + The field is never populated, and now is deprecated. + +optional + runtimeHandlers: + description: |- + The available runtime handlers. + +featureGate=UserNamespacesSupport + +optional + +listType=atomic + items: + $ref: '#/definitions/v1.NodeRuntimeHandler' + type: array + volumesAttached: + description: |- + List of volumes that are attached to the node. + +optional + +listType=atomic + items: + $ref: '#/definitions/v1.AttachedVolume' + type: array + volumesInUse: + description: |- + List of attachable volumes in use (mounted) by the node. + +optional + +listType=atomic + items: + type: string + type: array + type: object + v1.NodeSwapStatus: + properties: + capacity: + description: |- + Total amount of swap memory in bytes. + +optional + type: integer + type: object + v1.NodeSystemInfo: + properties: + architecture: + description: The Architecture reported by the node + type: string + bootID: + description: Boot ID reported by the node. + type: string + containerRuntimeVersion: + description: ContainerRuntime Version reported by the node through runtime + remote API (e.g. containerd://1.4.2). + type: string + kernelVersion: + description: Kernel Version reported by the node from 'uname -r' (e.g. 3.16.0-0.bpo.4-amd64). + type: string + kubeProxyVersion: + description: 'Deprecated: KubeProxy Version reported by the node.' + type: string + kubeletVersion: + description: Kubelet Version reported by the node. + type: string + machineID: + description: |- + MachineID reported by the node. For unique machine identification + in the cluster this field is preferred. Learn more from man(5) + machine-id: http://man7.org/linux/man-pages/man5/machine-id.5.html + type: string + operatingSystem: + description: The Operating System reported by the node + type: string + osImage: + description: OS Image reported by the node from /etc/os-release (e.g. Debian + GNU/Linux 7 (wheezy)). + type: string + swap: + allOf: + - $ref: '#/definitions/v1.NodeSwapStatus' + description: Swap Info reported by the node. + systemUUID: + description: |- + SystemUUID reported by the node. For unique machine identification + MachineID is preferred. This field is specific to Red Hat hosts + https://access.redhat.com/documentation/en-us/red_hat_subscription_management/1/html/rhsm/uuid + type: string + type: object + v1.ObjectFieldSelector: + properties: + apiVersion: + description: |- + Version of the schema the FieldPath is written in terms of, defaults to "v1". + +optional + type: string + fieldPath: + description: Path of the field to select in the specified API version. + type: string + type: object + v1.ObjectMeta: + properties: + annotations: + additionalProperties: + type: string + description: |- + Annotations is an unstructured key value map stored with a resource that may be + set by external tools to store and retrieve arbitrary metadata. They are not + queryable and should be preserved when modifying objects. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations + +optional + type: object + creationTimestamp: + description: |- + CreationTimestamp is a timestamp representing the server time when this object was + created. It is not guaranteed to be set in happens-before order across separate operations. + Clients may not set this value. It is represented in RFC3339 form and is in UTC. + + Populated by the system. + Read-only. + Null for lists. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata + +optional + type: string + deletionGracePeriodSeconds: + description: |- + Number of seconds allowed for this object to gracefully terminate before + it will be removed from the system. Only set when deletionTimestamp is also set. + May only be shortened. + Read-only. + +optional + type: integer + deletionTimestamp: + description: |- + DeletionTimestamp is RFC 3339 date and time at which this resource will be deleted. This + field is set by the server when a graceful deletion is requested by the user, and is not + directly settable by a client. The resource is expected to be deleted (no longer visible + from resource lists, and not reachable by name) after the time in this field, once the + finalizers list is empty. As long as the finalizers list contains items, deletion is blocked. + Once the deletionTimestamp is set, this value may not be unset or be set further into the + future, although it may be shortened or the resource may be deleted prior to this time. + For example, a user may request that a pod is deleted in 30 seconds. The Kubelet will react + by sending a graceful termination signal to the containers in the pod. After that 30 seconds, + the Kubelet will send a hard termination signal (SIGKILL) to the container and after cleanup, + remove the pod from the API. In the presence of network partitions, this object may still + exist after this timestamp, until an administrator or automated process can determine the + resource is fully terminated. + If not set, graceful deletion of the object has not been requested. + + Populated by the system when a graceful deletion is requested. + Read-only. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata + +optional + type: string + finalizers: + description: |- + Must be empty before the object is deleted from the registry. Each entry + is an identifier for the responsible component that will remove the entry + from the list. If the deletionTimestamp of the object is non-nil, entries + in this list can only be removed. + Finalizers may be processed and removed in any order. Order is NOT enforced + because it introduces significant risk of stuck finalizers. + finalizers is a shared field, any actor with permission can reorder it. + If the finalizer list is processed in order, then this can lead to a situation + in which the component responsible for the first finalizer in the list is + waiting for a signal (field value, external system, or other) produced by a + component responsible for a finalizer later in the list, resulting in a deadlock. + Without enforced ordering finalizers are free to order amongst themselves and + are not vulnerable to ordering changes in the list. + +optional + +patchStrategy=merge + +listType=set + items: + type: string + type: array + generateName: + description: |- + GenerateName is an optional prefix, used by the server, to generate a unique + name ONLY IF the Name field has not been provided. + If this field is used, the name returned to the client will be different + than the name passed. This value will also be combined with a unique suffix. + The provided value has the same validation rules as the Name field, + and may be truncated by the length of the suffix required to make the value + unique on the server. + + If this field is specified and the generated name exists, the server will return a 409. + + Applied only if Name is not specified. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#idempotency + +optional + type: string + generation: + description: |- + A sequence number representing a specific generation of the desired state. + Populated by the system. Read-only. + +optional + type: integer + labels: + additionalProperties: + type: string + description: |- + Map of string keys and values that can be used to organize and categorize + (scope and select) objects. May match selectors of replication controllers + and services. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/labels + +optional + type: object + managedFields: + description: |- + ManagedFields maps workflow-id and version to the set of fields + that are managed by that workflow. This is mostly for internal + housekeeping, and users typically shouldn't need to set or + understand this field. A workflow can be the user's name, a + controller's name, or the name of a specific apply path like + "ci-cd". The set of fields is always in the version that the + workflow used when modifying the object. + + +optional + +listType=atomic + items: + $ref: '#/definitions/v1.ManagedFieldsEntry' + type: array + name: + description: |- + Name must be unique within a namespace. Is required when creating resources, although + some resources may allow a client to request the generation of an appropriate name + automatically. Name is primarily intended for creation idempotence and configuration + definition. + Cannot be updated. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#names + +optional + type: string + namespace: + description: |- + Namespace defines the space within which each name must be unique. An empty namespace is + equivalent to the "default" namespace, but "default" is the canonical representation. + Not all objects are required to be scoped to a namespace - the value of this field for + those objects will be empty. + + Must be a DNS_LABEL. + Cannot be updated. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces + +optional + type: string + ownerReferences: + description: |- + List of objects depended by this object. If ALL objects in the list have + been deleted, this object will be garbage collected. If this object is managed by a controller, + then an entry in this list will point to this controller, with the controller field set to true. + There cannot be more than one managing controller. + +optional + +patchMergeKey=uid + +patchStrategy=merge + +listType=map + +listMapKey=uid + items: + $ref: '#/definitions/v1.OwnerReference' + type: array + resourceVersion: + description: |- + An opaque value that represents the internal version of this object that can + be used by clients to determine when objects have changed. May be used for optimistic + concurrency, change detection, and the watch operation on a resource or set of resources. + Clients must treat these values as opaque and passed unmodified back to the server. + They may only be valid for a particular resource or set of resources. + + Populated by the system. + Read-only. + Value must be treated as opaque by clients and . + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency + +optional + type: string + selfLink: + description: |- + Deprecated: selfLink is a legacy read-only field that is no longer populated by the system. + +optional + type: string + uid: + description: |- + UID is the unique in time and space value for this object. It is typically generated by + the server on successful creation of a resource and is not allowed to change on PUT + operations. + + Populated by the system. + Read-only. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#uids + +optional + type: string + type: object + v1.OwnerReference: + properties: + apiVersion: + description: API version of the referent. + type: string + blockOwnerDeletion: + description: |- + If true, AND if the owner has the "foregroundDeletion" finalizer, then + the owner cannot be deleted from the key-value store until this + reference is removed. + See https://kubernetes.io/docs/concepts/architecture/garbage-collection/#foreground-deletion + for how the garbage collector interacts with this field and enforces the foreground deletion. + Defaults to false. + To set this field, a user needs "delete" permission of the owner, + otherwise 422 (Unprocessable Entity) will be returned. + +optional + type: boolean + controller: + description: |- + If true, this reference points to the managing controller. + +optional + type: boolean + kind: + description: |- + Kind of the referent. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + name: + description: |- + Name of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#names + type: string + uid: + description: |- + UID of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#uids + type: string + type: object + v1.PersistentVolumeAccessMode: + enum: + - ReadWriteOnce + - ReadOnlyMany + - ReadWriteMany + - ReadWriteOncePod + type: string + x-enum-varnames: + - ReadWriteOnce + - ReadOnlyMany + - ReadWriteMany + - ReadWriteOncePod + v1.PersistentVolumeClaimPhase: + enum: + - Pending + - Bound + - Lost + type: string + x-enum-varnames: + - ClaimPending + - ClaimBound + - ClaimLost + v1.PersistentVolumeMode: + enum: + - Block + - Filesystem + type: string + x-enum-varnames: + - PersistentVolumeBlock + - PersistentVolumeFilesystem + v1.PersistentVolumePhase: + enum: + - Pending + - Available + - Bound + - Released + - Failed + type: string + x-enum-varnames: + - VolumePending + - VolumeAvailable + - VolumeBound + - VolumeReleased + - VolumeFailed + v1.PersistentVolumeReclaimPolicy: + enum: + - Recycle + - Delete + - Retain + type: string + x-enum-varnames: + - PersistentVolumeReclaimRecycle + - PersistentVolumeReclaimDelete + - PersistentVolumeReclaimRetain + v1.Probe: + properties: + exec: + allOf: + - $ref: '#/definitions/v1.ExecAction' + description: |- + Exec specifies a command to execute in the container. + +optional + failureThreshold: + description: |- + Minimum consecutive failures for the probe to be considered failed after having succeeded. + Defaults to 3. Minimum value is 1. + +optional + type: integer + grpc: + allOf: + - $ref: '#/definitions/v1.GRPCAction' + description: |- + GRPC specifies a GRPC HealthCheckRequest. + +optional + httpGet: + allOf: + - $ref: '#/definitions/v1.HTTPGetAction' + description: |- + HTTPGet specifies an HTTP GET request to perform. + +optional + initialDelaySeconds: + description: |- + Number of seconds after the container has started before liveness probes are initiated. + More info: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle#container-probes + +optional + type: integer + periodSeconds: + description: |- + How often (in seconds) to perform the probe. + Default to 10 seconds. Minimum value is 1. + +optional + type: integer + successThreshold: + description: |- + Minimum consecutive successes for the probe to be considered successful after having failed. + Defaults to 1. Must be 1 for liveness and startup. Minimum value is 1. + +optional + type: integer + tcpSocket: + allOf: + - $ref: '#/definitions/v1.TCPSocketAction' + description: |- + TCPSocket specifies a connection to a TCP port. + +optional + terminationGracePeriodSeconds: + description: |- + Optional duration in seconds the pod needs to terminate gracefully upon probe failure. + The grace period is the duration in seconds after the processes running in the pod are sent + a termination signal and the time when the processes are forcibly halted with a kill signal. + Set this value longer than the expected cleanup time for your process. + If this value is nil, the pod's terminationGracePeriodSeconds will be used. Otherwise, this + value overrides the value provided by the pod spec. + Value must be non-negative integer. The value zero indicates stop immediately via + the kill signal (no opportunity to shut down). + This is a beta field and requires enabling ProbeTerminationGracePeriod feature gate. + Minimum value is 1. spec.terminationGracePeriodSeconds is used if unset. + +optional + type: integer + timeoutSeconds: + description: |- + Number of seconds after which the probe times out. + Defaults to 1 second. Minimum value is 1. + More info: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle#container-probes + +optional + type: integer + type: object + v1.ProcMountType: + enum: + - Default + - Unmasked + type: string + x-enum-varnames: + - DefaultProcMount + - UnmaskedProcMount + v1.Protocol: + enum: + - TCP + - UDP + - SCTP + type: string + x-enum-varnames: + - ProtocolTCP + - ProtocolUDP + - ProtocolSCTP + v1.PullPolicy: + enum: + - Always + - Never + - IfNotPresent + type: string + x-enum-varnames: + - PullAlways + - PullNever + - PullIfNotPresent + v1.RecursiveReadOnlyMode: + enum: + - Disabled + - IfPossible + - Enabled + type: string + x-enum-varnames: + - RecursiveReadOnlyDisabled + - RecursiveReadOnlyIfPossible + - RecursiveReadOnlyEnabled + v1.ResourceFieldSelector: + properties: + containerName: + description: |- + Container name: required for volumes, optional for env vars + +optional + type: string + divisor: + allOf: + - $ref: '#/definitions/resource.Quantity' + description: |- + Specifies the output format of the exposed resources, defaults to "1" + +optional + resource: + description: 'Required: resource to select' + type: string + type: object + v1.ResourceList: + additionalProperties: + $ref: '#/definitions/resource.Quantity' + type: object + v1.ResourceName: + enum: + - cpu + - memory + - storage + - ephemeral-storage + - pods + - services + - replicationcontrollers + - resourcequotas + - secrets + - configmaps + - persistentvolumeclaims + - services.nodeports + - services.loadbalancers + - requests.cpu + - requests.memory + - requests.storage + - requests.ephemeral-storage + - limits.cpu + - limits.memory + - limits.ephemeral-storage + type: string + x-enum-varnames: + - ResourceCPU + - ResourceMemory + - ResourceStorage + - ResourceEphemeralStorage + - ResourcePods + - ResourceServices + - ResourceReplicationControllers + - ResourceQuotas + - ResourceSecrets + - ResourceConfigMaps + - ResourcePersistentVolumeClaims + - ResourceServicesNodePorts + - ResourceServicesLoadBalancers + - ResourceRequestsCPU + - ResourceRequestsMemory + - ResourceRequestsStorage + - ResourceRequestsEphemeralStorage + - ResourceLimitsCPU + - ResourceLimitsMemory + - ResourceLimitsEphemeralStorage + v1.ResourceQuota: + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + +optional + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + +optional + type: string + metadata: + allOf: + - $ref: '#/definitions/v1.ObjectMeta' + description: |- + Standard object's metadata. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata + +optional + spec: + allOf: + - $ref: '#/definitions/v1.ResourceQuotaSpec' + description: |- + Spec defines the desired quota. + https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status + +optional + status: + allOf: + - $ref: '#/definitions/v1.ResourceQuotaStatus' + description: |- + Status defines the actual enforced quota and its current usage. + https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status + +optional + type: object + v1.ResourceQuotaScope: + enum: + - Terminating + - NotTerminating + - BestEffort + - NotBestEffort + - PriorityClass + - CrossNamespacePodAffinity + - VolumeAttributesClass + type: string + x-enum-varnames: + - ResourceQuotaScopeTerminating + - ResourceQuotaScopeNotTerminating + - ResourceQuotaScopeBestEffort + - ResourceQuotaScopeNotBestEffort + - ResourceQuotaScopePriorityClass + - ResourceQuotaScopeCrossNamespacePodAffinity + - ResourceQuotaScopeVolumeAttributesClass + v1.ResourceQuotaSpec: + properties: + hard: + allOf: + - $ref: '#/definitions/v1.ResourceList' + description: |- + hard is the set of desired hard limits for each named resource. + More info: https://kubernetes.io/docs/concepts/policy/resource-quotas/ + +optional + scopeSelector: + allOf: + - $ref: '#/definitions/v1.ScopeSelector' + description: |- + scopeSelector is also a collection of filters like scopes that must match each object tracked by a quota + but expressed using ScopeSelectorOperator in combination with possible values. + For a resource to match, both scopes AND scopeSelector (if specified in spec), must be matched. + +optional + scopes: + description: |- + A collection of filters that must match each object tracked by a quota. + If not specified, the quota matches all objects. + +optional + +listType=atomic + items: + $ref: '#/definitions/v1.ResourceQuotaScope' + type: array + type: object + v1.ResourceQuotaStatus: + properties: + hard: + allOf: + - $ref: '#/definitions/v1.ResourceList' + description: |- + Hard is the set of enforced hard limits for each named resource. + More info: https://kubernetes.io/docs/concepts/policy/resource-quotas/ + +optional + used: + allOf: + - $ref: '#/definitions/v1.ResourceList' + description: |- + Used is the current observed total usage of the resource in the namespace. + +optional + type: object + v1.ResourceRequirements: + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + + This field depends on the + DynamicResourceAllocation feature gate. + + This field is immutable. It can only be set for containers. + + +listType=map + +listMapKey=name + +featureGate=DynamicResourceAllocation + +optional + items: + $ref: '#/definitions/k8s_io_api_core_v1.ResourceClaim' + type: array + limits: + allOf: + - $ref: '#/definitions/v1.ResourceList' + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + +optional + requests: + allOf: + - $ref: '#/definitions/v1.ResourceList' + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + +optional + type: object + v1.ResourceResizeRestartPolicy: + enum: + - NotRequired + - RestartContainer + type: string + x-enum-varnames: + - NotRequired + - RestartContainer + v1.RoleRef: + properties: + apiGroup: + description: APIGroup is the group for the resource being referenced + type: string + kind: + description: Kind is the type of resource being referenced + type: string + name: + description: |- + Name is the name of resource being referenced + +required + +k8s:required + type: string + type: object + v1.SELinuxOptions: + properties: + level: + description: |- + Level is SELinux level label that applies to the container. + +optional + type: string + role: + description: |- + Role is a SELinux role label that applies to the container. + +optional + type: string + type: + description: |- + Type is a SELinux type label that applies to the container. + +optional + type: string + user: + description: |- + User is a SELinux user label that applies to the container. + +optional + type: string + type: object + v1.ScopeSelector: + properties: + matchExpressions: + description: |- + A list of scope selector requirements by scope of the resources. + +optional + +listType=atomic + items: + $ref: '#/definitions/v1.ScopedResourceSelectorRequirement' + type: array + type: object + v1.ScopeSelectorOperator: + enum: + - In + - NotIn + - Exists + - DoesNotExist + type: string + x-enum-varnames: + - ScopeSelectorOpIn + - ScopeSelectorOpNotIn + - ScopeSelectorOpExists + - ScopeSelectorOpDoesNotExist + v1.ScopedResourceSelectorRequirement: + properties: + operator: + allOf: + - $ref: '#/definitions/v1.ScopeSelectorOperator' + description: |- + Represents a scope's relationship to a set of values. + Valid operators are In, NotIn, Exists, DoesNotExist. + scopeName: + allOf: + - $ref: '#/definitions/v1.ResourceQuotaScope' + description: The name of the scope that the selector applies to. + values: + description: |- + An array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. + This array is replaced during a strategic merge patch. + +optional + +listType=atomic + items: + type: string + type: array + type: object + v1.SeccompProfile: + properties: + localhostProfile: + description: |- + localhostProfile indicates a profile defined in a file on the node should be used. + The profile must be preconfigured on the node to work. + Must be a descending path, relative to the kubelet's configured seccomp profile location. + Must be set if type is "Localhost". Must NOT be set for any other type. + +optional + type: string + type: + allOf: + - $ref: '#/definitions/v1.SeccompProfileType' + description: |- + type indicates which kind of seccomp profile will be applied. + Valid options are: + + Localhost - a profile defined in a file on the node should be used. + RuntimeDefault - the container runtime default profile should be used. + Unconfined - no profile should be applied. + +unionDiscriminator + type: object + v1.SeccompProfileType: + enum: + - Unconfined + - RuntimeDefault + - Localhost + type: string + x-enum-varnames: + - SeccompProfileTypeUnconfined + - SeccompProfileTypeRuntimeDefault + - SeccompProfileTypeLocalhost + v1.SecretEnvSource: + properties: + name: + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + +optional + +default="" + +kubebuilder:default="" + TODO: Drop `kubebuilder:default` when controller-gen doesn't need it https://github.com/kubernetes-sigs/kubebuilder/issues/3896. + type: string + optional: + description: |- + Specify whether the Secret must be defined + +optional + type: boolean + type: object + v1.SecretKeySelector: + properties: + key: + description: The key of the secret to select from. Must be a valid secret + key. + type: string + name: + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + +optional + +default="" + +kubebuilder:default="" + TODO: Drop `kubebuilder:default` when controller-gen doesn't need it https://github.com/kubernetes-sigs/kubebuilder/issues/3896. + type: string + optional: + description: |- + Specify whether the Secret or its key must be defined + +optional + type: boolean + type: object + v1.SecretReference: + properties: + name: + description: |- + name is unique within a namespace to reference a secret resource. + +optional + type: string + namespace: + description: |- + namespace defines the space within which the secret name must be unique. + +optional + type: string + type: object + v1.SecurityContext: + properties: + allowPrivilegeEscalation: + description: |- + AllowPrivilegeEscalation controls whether a process can gain more + privileges than its parent process. This bool directly controls if + the no_new_privs flag will be set on the container process. + AllowPrivilegeEscalation is true always when the container is: + 1) run as Privileged + 2) has CAP_SYS_ADMIN + Note that this field cannot be set when spec.os.name is windows. + +optional + type: boolean + appArmorProfile: + allOf: + - $ref: '#/definitions/v1.AppArmorProfile' + description: |- + appArmorProfile is the AppArmor options to use by this container. If set, this profile + overrides the pod's appArmorProfile. + Note that this field cannot be set when spec.os.name is windows. + +optional + capabilities: + allOf: + - $ref: '#/definitions/v1.Capabilities' + description: |- + The capabilities to add/drop when running containers. + Defaults to the default set of capabilities granted by the container runtime. + Note that this field cannot be set when spec.os.name is windows. + +optional + privileged: + description: |- + Run container in privileged mode. + Processes in privileged containers are essentially equivalent to root on the host. + Defaults to false. + Note that this field cannot be set when spec.os.name is windows. + +optional + type: boolean + procMount: + allOf: + - $ref: '#/definitions/v1.ProcMountType' + description: |- + procMount denotes the type of proc mount to use for the containers. + The default value is Default which uses the container runtime defaults for + readonly paths and masked paths. + This requires the ProcMountType feature flag to be enabled. + Note that this field cannot be set when spec.os.name is windows. + +optional + readOnlyRootFilesystem: + description: |- + Whether this container has a read-only root filesystem. + Default is false. + Note that this field cannot be set when spec.os.name is windows. + +optional + type: boolean + runAsGroup: + description: |- + The GID to run the entrypoint of the container process. + Uses runtime default if unset. + May also be set in PodSecurityContext. If set in both SecurityContext and + PodSecurityContext, the value specified in SecurityContext takes precedence. + Note that this field cannot be set when spec.os.name is windows. + +optional + type: integer + runAsNonRoot: + description: |- + Indicates that the container must run as a non-root user. + If true, the Kubelet will validate the image at runtime to ensure that it + does not run as UID 0 (root) and fail to start the container if it does. + If unset or false, no such validation will be performed. + May also be set in PodSecurityContext. If set in both SecurityContext and + PodSecurityContext, the value specified in SecurityContext takes precedence. + +optional + type: boolean + runAsUser: + description: |- + The UID to run the entrypoint of the container process. + Defaults to user specified in image metadata if unspecified. + May also be set in PodSecurityContext. If set in both SecurityContext and + PodSecurityContext, the value specified in SecurityContext takes precedence. + Note that this field cannot be set when spec.os.name is windows. + +optional + type: integer + seLinuxOptions: + allOf: + - $ref: '#/definitions/v1.SELinuxOptions' + description: |- + The SELinux context to be applied to the container. + If unspecified, the container runtime will allocate a random SELinux context for each + container. May also be set in PodSecurityContext. If set in both SecurityContext and + PodSecurityContext, the value specified in SecurityContext takes precedence. + Note that this field cannot be set when spec.os.name is windows. + +optional + seccompProfile: + allOf: + - $ref: '#/definitions/v1.SeccompProfile' + description: |- + The seccomp options to use by this container. If seccomp options are + provided at both the pod & container level, the container options + override the pod options. + Note that this field cannot be set when spec.os.name is windows. + +optional + windowsOptions: + allOf: + - $ref: '#/definitions/v1.WindowsSecurityContextOptions' + description: |- + The Windows specific settings applied to all containers. + If unspecified, the options from the PodSecurityContext will be used. + If set in both SecurityContext and PodSecurityContext, the value specified in SecurityContext takes precedence. + Note that this field cannot be set when spec.os.name is linux. + +optional + type: object + v1.Signal: + enum: + - SIGABRT + - SIGALRM + - SIGBUS + - SIGCHLD + - SIGCLD + - SIGCONT + - SIGFPE + - SIGHUP + - SIGILL + - SIGINT + - SIGIO + - SIGIOT + - SIGKILL + - SIGPIPE + - SIGPOLL + - SIGPROF + - SIGPWR + - SIGQUIT + - SIGSEGV + - SIGSTKFLT + - SIGSTOP + - SIGSYS + - SIGTERM + - SIGTRAP + - SIGTSTP + - SIGTTIN + - SIGTTOU + - SIGURG + - SIGUSR1 + - SIGUSR2 + - SIGVTALRM + - SIGWINCH + - SIGXCPU + - SIGXFSZ + - SIGRTMIN + - SIGRTMIN+1 + - SIGRTMIN+2 + - SIGRTMIN+3 + - SIGRTMIN+4 + - SIGRTMIN+5 + - SIGRTMIN+6 + - SIGRTMIN+7 + - SIGRTMIN+8 + - SIGRTMIN+9 + - SIGRTMIN+10 + - SIGRTMIN+11 + - SIGRTMIN+12 + - SIGRTMIN+13 + - SIGRTMIN+14 + - SIGRTMIN+15 + - SIGRTMAX-14 + - SIGRTMAX-13 + - SIGRTMAX-12 + - SIGRTMAX-11 + - SIGRTMAX-10 + - SIGRTMAX-9 + - SIGRTMAX-8 + - SIGRTMAX-7 + - SIGRTMAX-6 + - SIGRTMAX-5 + - SIGRTMAX-4 + - SIGRTMAX-3 + - SIGRTMAX-2 + - SIGRTMAX-1 + - SIGRTMAX + type: string + x-enum-varnames: + - SIGABRT + - SIGALRM + - SIGBUS + - SIGCHLD + - SIGCLD + - SIGCONT + - SIGFPE + - SIGHUP + - SIGILL + - SIGINT + - SIGIO + - SIGIOT + - SIGKILL + - SIGPIPE + - SIGPOLL + - SIGPROF + - SIGPWR + - SIGQUIT + - SIGSEGV + - SIGSTKFLT + - SIGSTOP + - SIGSYS + - SIGTERM + - SIGTRAP + - SIGTSTP + - SIGTTIN + - SIGTTOU + - SIGURG + - SIGUSR1 + - SIGUSR2 + - SIGVTALRM + - SIGWINCH + - SIGXCPU + - SIGXFSZ + - SIGRTMIN + - SIGRTMINPLUS1 + - SIGRTMINPLUS2 + - SIGRTMINPLUS3 + - SIGRTMINPLUS4 + - SIGRTMINPLUS5 + - SIGRTMINPLUS6 + - SIGRTMINPLUS7 + - SIGRTMINPLUS8 + - SIGRTMINPLUS9 + - SIGRTMINPLUS10 + - SIGRTMINPLUS11 + - SIGRTMINPLUS12 + - SIGRTMINPLUS13 + - SIGRTMINPLUS14 + - SIGRTMINPLUS15 + - SIGRTMAXMINUS14 + - SIGRTMAXMINUS13 + - SIGRTMAXMINUS12 + - SIGRTMAXMINUS11 + - SIGRTMAXMINUS10 + - SIGRTMAXMINUS9 + - SIGRTMAXMINUS8 + - SIGRTMAXMINUS7 + - SIGRTMAXMINUS6 + - SIGRTMAXMINUS5 + - SIGRTMAXMINUS4 + - SIGRTMAXMINUS3 + - SIGRTMAXMINUS2 + - SIGRTMAXMINUS1 + - SIGRTMAX + v1.SleepAction: + properties: + seconds: + description: Seconds is the number of seconds to sleep. + type: integer + type: object + v1.TCPSocketAction: + properties: + host: + description: |- + Optional: Host name to connect to, defaults to the pod IP. + +optional + type: string + port: + allOf: + - $ref: '#/definitions/intstr.IntOrString' + description: |- + Number or name of the port to access on the container. + Number must be in the range 1 to 65535. + Name must be an IANA_SVC_NAME. + type: object + v1.Taint: + properties: + effect: + allOf: + - $ref: '#/definitions/v1.TaintEffect' + description: |- + Required. The effect of the taint on pods + that do not tolerate the taint. + Valid effects are NoSchedule, PreferNoSchedule and NoExecute. + key: + description: Required. The taint key to be applied to a node. + type: string + timeAdded: + description: |- + TimeAdded represents the time at which the taint was added. + +optional + type: string + value: + description: |- + The taint value corresponding to the taint key. + +optional + type: string + type: object + v1.TaintEffect: + enum: + - NoSchedule + - PreferNoSchedule + - NoExecute + type: string + x-enum-varnames: + - TaintEffectNoSchedule + - TaintEffectPreferNoSchedule + - TaintEffectNoExecute + v1.TerminationMessagePolicy: + enum: + - File + - FallbackToLogsOnError + type: string + x-enum-varnames: + - TerminationMessageReadFile + - TerminationMessageFallbackToLogsOnError + v1.URIScheme: + enum: + - HTTP + - HTTPS + type: string + x-enum-varnames: + - URISchemeHTTP + - URISchemeHTTPS + v1.VolumeDevice: + properties: + devicePath: + description: devicePath is the path inside of the container that the device + will be mapped to. + type: string + name: + description: name must match the name of a persistentVolumeClaim in the pod + type: string + type: object + v1.VolumeMount: + properties: + mountPath: + description: |- + Path within the container at which the volume should be mounted. Must + not contain ':'. + type: string + mountPropagation: + allOf: + - $ref: '#/definitions/v1.MountPropagationMode' + description: |- + mountPropagation determines how mounts are propagated from the host + to container and the other way around. + When not set, MountPropagationNone is used. + This field is beta in 1.10. + When RecursiveReadOnly is set to IfPossible or to Enabled, MountPropagation must be None or unspecified + (which defaults to None). + +optional + name: + description: This must match the Name of a Volume. + type: string + readOnly: + description: |- + Mounted read-only if true, read-write otherwise (false or unspecified). + Defaults to false. + +optional + type: boolean + recursiveReadOnly: + allOf: + - $ref: '#/definitions/v1.RecursiveReadOnlyMode' + description: |- + RecursiveReadOnly specifies whether read-only mounts should be handled + recursively. + + If ReadOnly is false, this field has no meaning and must be unspecified. + + If ReadOnly is true, and this field is set to Disabled, the mount is not made + recursively read-only. If this field is set to IfPossible, the mount is made + recursively read-only, if it is supported by the container runtime. If this + field is set to Enabled, the mount is made recursively read-only if it is + supported by the container runtime, otherwise the pod will not be started and + an error will be generated to indicate the reason. + + If this field is set to IfPossible or Enabled, MountPropagation must be set to + None (or be unspecified, which defaults to None). + + If this field is not specified, it is treated as an equivalent of Disabled. + +optional + subPath: + description: |- + Path within the volume from which the container's volume should be mounted. + Defaults to "" (volume's root). + +optional + type: string + subPathExpr: + description: |- + Expanded path within the volume from which the container's volume should be mounted. + Behaves similarly to SubPath but environment variable references $(VAR_NAME) are expanded using the container's environment. + Defaults to "" (volume's root). + SubPathExpr and SubPath are mutually exclusive. + +optional + type: string + type: object + v1.WindowsSecurityContextOptions: + properties: + gmsaCredentialSpec: + description: |- + GMSACredentialSpec is where the GMSA admission webhook + (https://github.com/kubernetes-sigs/windows-gmsa) inlines the contents of the + GMSA credential spec named by the GMSACredentialSpecName field. + +optional + type: string + gmsaCredentialSpecName: + description: |- + GMSACredentialSpecName is the name of the GMSA credential spec to use. + +optional + type: string + hostProcess: + description: |- + HostProcess determines if a container should be run as a 'Host Process' container. + All of a Pod's containers must have the same effective HostProcess value + (it is not allowed to have a mix of HostProcess containers and non-HostProcess containers). + In addition, if HostProcess is true then HostNetwork must also be set to true. + +optional + type: boolean + runAsUserName: + description: |- + The UserName in Windows to run the entrypoint of the container process. + Defaults to the user specified in image metadata if unspecified. + May also be set in PodSecurityContext. If set in both SecurityContext and + PodSecurityContext, the value specified in SecurityContext takes precedence. + +optional + type: string + type: object + v1beta1.ContainerMetrics: + properties: + name: + description: Container name corresponding to the one from pod.spec.containers. + type: string + usage: + allOf: + - $ref: '#/definitions/v1.ResourceList' + description: The memory usage is the memory working set. + type: object + v1beta1.NodeMetrics: + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + +optional + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + +optional + type: string + metadata: + allOf: + - $ref: '#/definitions/v1.ObjectMeta' + description: |- + Standard object's metadata. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata + +optional + timestamp: + description: |- + The following fields define time interval from which metrics were + collected from the interval [Timestamp-Window, Timestamp]. + type: string + usage: + allOf: + - $ref: '#/definitions/v1.ResourceList' + description: The memory usage is the memory working set. + window: + type: string + type: object + v1beta1.NodeMetricsList: + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + +optional + type: string + items: + description: List of node metrics. + items: + $ref: '#/definitions/v1beta1.NodeMetrics' + type: array + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + +optional + type: string + metadata: + allOf: + - $ref: '#/definitions/v1.ListMeta' + description: |- + Standard list metadata. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: object + v1beta1.PodMetrics: + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + +optional + type: string + containers: + description: |- + Metrics for all containers are collected within the same time window. + +listType=atomic + items: + $ref: '#/definitions/v1beta1.ContainerMetrics' + type: array + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + +optional + type: string + metadata: + allOf: + - $ref: '#/definitions/v1.ObjectMeta' + description: |- + Standard object's metadata. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata + +optional + timestamp: + description: |- + The following fields define time interval from which metrics were + collected from the interval [Timestamp-Window, Timestamp]. + type: string + window: + type: string + type: object + v1beta1.PodMetricsList: + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + +optional + type: string + items: + description: List of pod metrics. + items: + $ref: '#/definitions/v1beta1.PodMetrics' + type: array + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + +optional + type: string + metadata: + allOf: + - $ref: '#/definitions/v1.ListMeta' + description: |- + Standard list metadata. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: object + webhooks.webhookCreatePayload: + properties: + EndpointID: + type: integer + RegistryID: + type: integer + ResourceID: + type: string + WebhookType: + allOf: + - $ref: '#/definitions/portainer.WebhookType' + description: Type of webhook (1 - service) + type: object + webhooks.webhookUpdatePayload: + properties: + RegistryID: + type: integer + type: object + workflows.ArtifactDetail: + properties: + autoUpdate: + $ref: '#/definitions/portainer.AutoUpdateSettings' + creationDate: + type: integer + files: + items: + $ref: '#/definitions/workflows.ArtifactFileDetail' + type: array + id: + type: integer + lastSyncDate: + type: integer + name: + type: string + platform: + $ref: '#/definitions/workflows.DeploymentPlatform' + status: + $ref: '#/definitions/workflows.WorkflowStatusObject' + target: + $ref: '#/definitions/workflows.Target' + type: + $ref: '#/definitions/workflows.Type' + required: + - id + - name + - type + type: object + workflows.ArtifactFileDetail: + properties: + hash: + example: abc123 + type: string + path: + example: portainer.yaml + type: string + pathError: + type: string + pathStatus: + $ref: '#/definitions/sources.Status' + ref: + example: refs/heads/main + type: string + refError: + type: string + refStatus: + $ref: '#/definitions/sources.Status' + sourceId: + type: integer + type: object + workflows.DeploymentPlatform: + enum: + - dockerStandalone + - dockerSwarm + - kubernetes + type: string + x-enum-varnames: + - DeploymentPlatformDockerStandalone + - DeploymentPlatformDockerSwarm + - DeploymentPlatformKubernetes + workflows.Status: + enum: + - healthy + - syncing + - error + - paused + - unknown + type: string + x-enum-varnames: + - StatusHealthy + - StatusSyncing + - StatusError + - StatusPaused + - StatusUnknown + workflows.StatusSummary: + properties: + error: + type: integer + healthy: + type: integer + paused: + type: integer + syncing: + type: integer + unknown: + type: integer + type: object + workflows.Target: + properties: + edgeGroupIds: + items: + type: integer + type: array + endpointId: + type: integer + groupStatus: + additionalProperties: + $ref: '#/definitions/workflows.Status' + type: object + namespace: + type: string + resolvedEndpointIds: + items: + type: integer + type: array + type: object + workflows.Type: + enum: + - stack + - edgeStack + type: string + x-enum-varnames: + - TypeStack + - TypeEdgeStack + workflows.Workflow: + properties: + autoUpdate: + $ref: '#/definitions/portainer.AutoUpdateSettings' + creationDate: + type: integer + gitConfig: + $ref: '#/definitions/gittypes.RepoConfig' + id: + type: integer + lastSyncDate: + type: integer + name: + type: string + platform: + $ref: '#/definitions/workflows.DeploymentPlatform' + sourceId: + type: integer + status: + $ref: '#/definitions/workflows.WorkflowStatusObject' + target: + $ref: '#/definitions/workflows.Target' + type: + $ref: '#/definitions/workflows.Type' + required: + - id + - name + - platform + - status + - target + - type + type: object + workflows.WorkflowDetail: + properties: + artifacts: + items: + $ref: '#/definitions/workflows.ArtifactDetail' + type: array + id: + type: integer + name: + type: string + required: + - id + - name + type: object + workflows.WorkflowPhaseStatus: + properties: + error: + type: string + status: + $ref: '#/definitions/workflows.Status' + type: object + workflows.WorkflowStatusObject: + properties: + artifact: + $ref: '#/definitions/workflows.WorkflowPhaseStatus' + source: + $ref: '#/definitions/workflows.WorkflowPhaseStatus' + target: + $ref: '#/definitions/workflows.WorkflowPhaseStatus' + type: object +info: + contact: + email: info@portainer.io + description: | + The Portainer API is an HTTP API served by Portainer. It is used by the Portainer UI, and anything you can do in the UI can also be done via the HTTP API. + + API examples are available in the [Portainer documentation](https://documentation.portainer.io/api/api-examples/) + + You can find out more about Portainer [on our website](http://portainer.io) and get some support on [Slack](http://portainer.io/slack/). + + # Authentication + + Most of the API endpoints require authentication, as well as some level of authorization. + Portainer uses JSON Web Tokens to manage authentication. You must provide a token in the **Authorization** header of each request using the **Bearer** scheme. + + Example: + + ``` + Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidXNlcm5hbWUiOiJhZG1pbiIsInJvbGUiOjEsImV4cCI6MTQ5OTM3NjE1NH0.NJ6vE8FY1WG6jsRQzfMqeatJ4vh2TWAeeYfDhP71YEE + ``` + + # Security + + Each API endpoint has an associated access policy, documented in its description. + + The following policies are available: + + - Public access + - Authenticated access + - Restricted access + - Administrator access + + ### Public access + + No authentication is required. + + ### Authenticated access + + Authentication is required. + + ### Restricted access + + Authentication is required. Additional checks may apply to verify access to the resource, and returned data may be filtered. + + ### Administrator access + + Authentication and an administrator role are both required. + + # Execute Docker requests + + Portainer does not expose dedicated endpoints for managing Docker resources (create a container, remove a volume, etc). + + Instead, it acts as a reverse-proxy to the Docker HTTP API, allowing you to execute Docker requests via the Portainer HTTP API. + + To do so, use the `/endpoints/{id}/docker` endpoint. Note that this endpoint is not documented below due to Swagger limitations. It has a restricted access policy, so authentication is still required. Any request made to this endpoint is proxied to the Docker API of the associated environment - request and response objects are identical to those in the [Docker official documentation](https://docs.docker.com/engine/api). + + # Private Registry + + When using a private registry, include a Base64-encoded JSON string in the request header. The header parameter name is `X-Registry-Auth` and the value should encode the following structure: ‘{"registryId":\}’ where `` is the ID of the registry where the repository was created. + + Example encoded value: + + ``` + eyJyZWdpc3RyeUlkIjoxfQ== + ``` + license: + name: zlib + url: https://github.com/portainer/portainer/blob/develop/LICENSE + title: PortainerCE API + version: 2.43.0 +paths: + /auth: + post: + consumes: + - application/json + description: |- + **Access policy**: public + Use this environment(endpoint) to authenticate against Portainer using a username and password. + operationId: AuthenticateUser + parameters: + - description: Credentials used for authentication + in: body + name: body + required: true + schema: + $ref: '#/definitions/auth.authenticatePayload' + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/auth.authenticateResponse' + "400": + description: Invalid request + "422": + description: Invalid Credentials + "500": + description: Server error + summary: Authenticate + tags: + - auth + /auth/logout: + post: + description: '**Access policy**: public' + operationId: Logout + responses: + "204": + description: Success + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Logout + tags: + - auth + /auth/oauth/validate: + post: + consumes: + - application/json + description: '**Access policy**: public' + operationId: ValidateOAuth + parameters: + - description: OAuth Credentials used for authentication + in: body + name: body + required: true + schema: + $ref: '#/definitions/auth.oauthPayload' + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/auth.authenticateResponse' + "400": + description: Invalid request + "422": + description: Invalid Credentials + "500": + description: Server error + summary: Authenticate with OAuth + tags: + - auth + /backup: + post: + consumes: + - application/json + description: |- + Creates an archive with a system data snapshot that could be used to restore the system. + **Access policy**: admin + operationId: Backup + parameters: + - description: An object contains the password to encrypt the backup with + in: body + name: body + schema: + $ref: '#/definitions/backup.backupPayload' + produces: + - application/octet-stream + responses: + "200": + description: Success + "400": + description: Invalid request + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Creates an archive with a system data snapshot that could be used to + restore the system. + tags: + - backup + /custom_templates: + get: + description: |- + List available custom templates. + **Access policy**: authenticated + operationId: CustomTemplateList + parameters: + - collectionFormat: csv + description: Template types + in: query + items: + enum: + - 1 + - 2 + - 3 + type: integer + name: type + required: true + type: array + - description: Filter by edge templates + in: query + name: edge + type: boolean + produces: + - application/json + responses: + "200": + description: Success + schema: + items: + $ref: '#/definitions/portainer.CustomTemplate' + type: array + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: List available custom templates + tags: + - custom_templates + /custom_templates/{id}: + delete: + description: |- + Remove a template. + **Access policy**: authenticated + operationId: CustomTemplateDelete + parameters: + - description: Template identifier + in: path + name: id + required: true + type: integer + responses: + "204": + description: Success + "400": + description: Invalid request + "403": + description: Access denied to resource + "404": + description: Template not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Remove a template + tags: + - custom_templates + get: + description: |- + Retrieve details about a template. + **Access policy**: authenticated + operationId: CustomTemplateInspect + parameters: + - description: Template identifier + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/portainer.CustomTemplate' + "400": + description: Invalid request + "404": + description: Template not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Inspect a custom template + tags: + - custom_templates + put: + consumes: + - application/json + description: |- + Update a template. + **Access policy**: authenticated + operationId: CustomTemplateUpdate + parameters: + - description: Template identifier + in: path + name: id + required: true + type: integer + - description: Template details + in: body + name: body + required: true + schema: + $ref: '#/definitions/customtemplates.customTemplateUpdatePayload' + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/portainer.CustomTemplate' + "400": + description: Invalid request + "403": + description: Permission denied to access template + "404": + description: Template not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Update a template + tags: + - custom_templates + /custom_templates/{id}/file: + get: + description: |- + Retrieve the content of the Stack file for the specified custom template + **Access policy**: authenticated + operationId: CustomTemplateFile + parameters: + - description: Template identifier + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/customtemplates.fileResponse' + "400": + description: Invalid request + "404": + description: Custom template not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Get Template stack file content. + tags: + - custom_templates + /custom_templates/{id}/git_fetch: + put: + description: |- + Retrieve details about a template created from git repository method. + **Access policy**: authenticated + operationId: CustomTemplateGitFetch + parameters: + - description: Template identifier + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/customtemplates.fileResponse' + "400": + description: Invalid request + "404": + description: Custom template not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Fetch the latest config file content based on custom template's git + repository configuration + tags: + - custom_templates + /custom_templates/create/file: + post: + consumes: + - multipart/form-data + description: |- + Create a custom template. + **Access policy**: authenticated + operationId: CustomTemplateCreateFile + parameters: + - description: Title of the template + in: formData + name: Title + required: true + type: string + - description: Description of the template + in: formData + name: Description + required: true + type: string + - description: A note that will be displayed in the UI. Supports HTML content + in: formData + name: Note + required: true + type: string + - description: Platform associated to the template (1 - 'linux', 2 - 'windows') + enum: + - 1 + - 2 + in: formData + name: Platform + required: true + type: integer + - description: Type of created stack (1 - swarm, 2 - compose, 3 - kubernetes) + enum: + - 1 + - 2 + - 3 + in: formData + name: Type + required: true + type: integer + - description: File + in: formData + name: File + required: true + type: file + - description: URL of the template's logo + in: formData + name: Logo + type: string + - description: A json array of variables definitions + in: formData + name: Variables + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/portainer.CustomTemplate' + "400": + description: Invalid request + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Create a custom template + tags: + - custom_templates + /custom_templates/create/repository: + post: + consumes: + - application/json + description: |- + Create a custom template. + **Access policy**: authenticated + operationId: CustomTemplateCreateRepository + parameters: + - description: Required when using method=repository + in: body + name: body + required: true + schema: + $ref: '#/definitions/customtemplates.customTemplateFromGitRepositoryPayload' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/portainer.CustomTemplate' + "400": + description: Invalid request + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Create a custom template + tags: + - custom_templates + /custom_templates/create/string: + post: + consumes: + - application/json + description: |- + Create a custom template. + **Access policy**: authenticated + operationId: CustomTemplateCreateString + parameters: + - description: body + in: body + name: body + required: true + schema: + $ref: '#/definitions/customtemplates.customTemplateFromFileContentPayload' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/portainer.CustomTemplate' + "400": + description: Invalid request + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Create a custom template + tags: + - custom_templates + /docker/{environmentId}/containers/{containerId}/gpus: + get: + consumes: + - application/json + description: '**Access policy**:' + operationId: dockerContainerGpusInspect + parameters: + - description: Environment identifier + in: path + name: environmentId + required: true + type: integer + - description: Container identifier + in: path + name: containerId + required: true + type: integer + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/containers.containerGpusResponse' + "400": + description: Bad request + "404": + description: Environment or container not found + "500": + description: Internal server error + security: + - jwt: [] + summary: Fetch container gpus data + tags: + - docker + /docker/{environmentId}/dashboard: + get: + consumes: + - application/json + description: '**Access policy**: restricted' + operationId: dockerDashboard + parameters: + - description: Environment identifier + in: path + name: environmentId + required: true + type: integer + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/docker.dashboardResponse' + "400": + description: Bad request + "500": + description: Internal server error + security: + - jwt: [] + summary: Get counters for the dashboard + tags: + - docker + /docker/{environmentId}/images: + get: + description: '**Access policy**:' + operationId: dockerImagesList + parameters: + - description: Environment identifier + in: path + name: environmentId + required: true + type: integer + - description: Include image usage information + in: query + name: withUsage + type: boolean + produces: + - application/json + responses: + "200": + description: Success + schema: + items: + $ref: '#/definitions/images.ImageResponse' + type: array + "400": + description: Bad request + "500": + description: Internal server error + security: + - jwt: [] + summary: Fetch images + tags: + - docker + /edge_groups: + get: + description: '**Access policy**: administrator' + operationId: EdgeGroupList + produces: + - application/json + responses: + "200": + description: EdgeGroups + schema: + items: + $ref: '#/definitions/edgegroups.decoratedEdgeGroup' + type: array + "500": + description: Internal Server Error + "503": + description: Edge compute features are disabled + security: + - ApiKeyAuth: [] + - jwt: [] + summary: list EdgeGroups + tags: + - edge_groups + post: + consumes: + - application/json + description: '**Access policy**: administrator' + operationId: EdgeGroupCreate + parameters: + - description: EdgeGroup data + in: body + name: body + required: true + schema: + $ref: '#/definitions/edgegroups.edgeGroupCreatePayload' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/portainer.EdgeGroup' + "500": + description: Internal Server Error + "503": + description: Edge compute features are disabled + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Create an EdgeGroup + tags: + - edge_groups + /edge_groups/{id}: + delete: + description: '**Access policy**: administrator' + operationId: EdgeGroupDelete + parameters: + - description: EdgeGroup Id + in: path + name: id + required: true + type: integer + responses: + "204": + description: No Content + "409": + description: Edge group is in use by an Edge stack or Edge job + "500": + description: Server error + "503": + description: Edge compute features are disabled + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Deletes an EdgeGroup + tags: + - edge_groups + get: + description: '**Access policy**: administrator' + operationId: EdgeGroupInspect + parameters: + - description: EdgeGroup Id + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/portainer.EdgeGroup' + "500": + description: Internal Server Error + "503": + description: Edge compute features are disabled + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Inspects an EdgeGroup + tags: + - edge_groups + put: + consumes: + - application/json + description: '**Access policy**: administrator' + operationId: EdgeGroupUpdate + parameters: + - description: EdgeGroup Id + in: path + name: id + required: true + type: integer + - description: EdgeGroup data + in: body + name: body + required: true + schema: + $ref: '#/definitions/edgegroups.edgeGroupUpdatePayload' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/portainer.EdgeGroup' + "500": + description: Internal Server Error + "503": + description: Edge compute features are disabled + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Updates an EdgeGroup + tags: + - edge_groups + /edge_jobs: + get: + description: '**Access policy**: administrator' + operationId: EdgeJobList + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/portainer.EdgeJob' + type: array + "400": + description: Bad Request + "500": + description: Internal Server Error + "503": + description: Edge compute features are disabled + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Fetch EdgeJobs list + tags: + - edge_jobs + /edge_jobs/{id}: + delete: + description: '**Access policy**: administrator' + operationId: EdgeJobDelete + parameters: + - description: EdgeJob Id + in: path + name: id + required: true + type: integer + responses: + "204": + description: No Content + "400": + description: Bad Request + "500": + description: Internal Server Error + "503": + description: Edge compute features are disabled + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Delete an EdgeJob + tags: + - edge_jobs + get: + description: '**Access policy**: administrator' + operationId: EdgeJobInspect + parameters: + - description: EdgeJob Id + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/portainer.EdgeJob' + "400": + description: Bad Request + "500": + description: Internal Server Error + "503": + description: Edge compute features are disabled + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Inspect an EdgeJob + tags: + - edge_jobs + put: + consumes: + - application/json + description: '**Access policy**: administrator' + operationId: EdgeJobUpdate + parameters: + - description: EdgeJob Id + in: path + name: id + required: true + type: integer + - description: EdgeGroup data + in: body + name: body + required: true + schema: + $ref: '#/definitions/edgejobs.edgeJobUpdatePayload' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/portainer.EdgeJob' + "400": + description: Bad Request + "500": + description: Internal Server Error + "503": + description: Edge compute features are disabled + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Update an EdgeJob + tags: + - edge_jobs + /edge_jobs/{id}/file: + get: + description: '**Access policy**: administrator' + operationId: EdgeJobFile + parameters: + - description: EdgeJob Id + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/edgejobs.edgeJobFileResponse' + "400": + description: Bad Request + "500": + description: Internal Server Error + "503": + description: Edge compute features are disabled + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Fetch a file of an EdgeJob + tags: + - edge_jobs + /edge_jobs/{id}/tasks: + get: + description: '**Access policy**: administrator' + operationId: EdgeJobTasksList + parameters: + - description: EdgeJob Id + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/edgejobs.taskContainer' + type: array + "400": + description: Bad Request + "500": + description: Internal Server Error + "503": + description: Edge compute features are disabled + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Fetch the list of tasks on an EdgeJob + tags: + - edge_jobs + /edge_jobs/{id}/tasks/{taskID}/logs: + delete: + description: '**Access policy**: administrator' + operationId: EdgeJobTasksClear + parameters: + - description: EdgeJob Id + in: path + name: id + required: true + type: integer + - description: Task Id + in: path + name: taskID + required: true + type: integer + produces: + - application/json + responses: + "204": + description: No Content + "400": + description: Bad Request + "500": + description: Internal Server Error + "503": + description: Edge compute features are disabled + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Clear the log for a specifc task on an EdgeJob + tags: + - edge_jobs + get: + description: '**Access policy**: administrator' + operationId: EdgeJobTaskLogsInspect + parameters: + - description: EdgeJob Id + in: path + name: id + required: true + type: integer + - description: Task Id + in: path + name: taskID + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/edgejobs.fileResponse' + "400": + description: Bad Request + "500": + description: Internal Server Error + "503": + description: Edge compute features are disabled + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Fetch the log for a specifc task on an EdgeJob + tags: + - edge_jobs + post: + description: '**Access policy**: administrator' + operationId: EdgeJobTasksCollect + parameters: + - description: EdgeJob Id + in: path + name: id + required: true + type: integer + - description: Task Id + in: path + name: taskID + required: true + type: integer + produces: + - application/json + responses: + "204": + description: No Content + "400": + description: Bad Request + "500": + description: Internal Server Error + "503": + description: Edge compute features are disabled + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Collect the log for a specifc task on an EdgeJob + tags: + - edge_jobs + /edge_jobs/create/file: + post: + consumes: + - multipart/form-data + description: '**Access policy**: administrator' + operationId: EdgeJobCreateFile + parameters: + - description: Content of the Stack file + in: formData + name: file + required: true + type: file + - description: Name of the stack + in: formData + name: Name + required: true + type: string + - description: A cron expression to schedule this job + in: formData + name: CronExpression + required: true + type: string + - description: JSON stringified array of Edge Groups ids + in: formData + name: EdgeGroups + required: true + type: string + - description: JSON stringified array of Environment ids + in: formData + name: Endpoints + required: true + type: string + - description: If recurring + in: formData + name: Recurring + type: boolean + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/portainer.EdgeGroup' + "500": + description: Internal Server Error + "503": + description: Edge compute features are disabled + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Create an EdgeJob from a file + tags: + - edge_jobs + /edge_jobs/create/string: + post: + description: '**Access policy**: administrator' + operationId: EdgeJobCreateString + parameters: + - description: EdgeGroup data when method is string + in: body + name: body + required: true + schema: + $ref: '#/definitions/edgejobs.edgeJobCreateFromFileContentPayload' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/portainer.EdgeGroup' + "500": + description: Internal Server Error + "503": + description: Edge compute features are disabled + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Create an EdgeJob from a text + tags: + - edge_jobs + /edge_stacks: + get: + description: '**Access policy**: administrator' + operationId: EdgeStackList + parameters: + - description: will summarize the statuses + in: query + name: summarizeStatuses + type: boolean + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/portainer.EdgeStack' + type: array + "400": + description: Bad Request + "500": + description: Internal Server Error + "503": + description: Edge compute features are disabled + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Fetches the list of EdgeStacks + tags: + - edge_stacks + /edge_stacks/{id}: + delete: + description: '**Access policy**: administrator' + operationId: EdgeStackDelete + parameters: + - description: EdgeStack Id + in: path + name: id + required: true + type: integer + responses: + "204": + description: No Content + "400": + description: Bad Request + "500": + description: Internal Server Error + "503": + description: Edge compute features are disabled + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Delete an EdgeStack + tags: + - edge_stacks + get: + description: '**Access policy**: administrator' + operationId: EdgeStackInspect + parameters: + - description: EdgeStack Id + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/portainer.EdgeStack' + "400": + description: Bad Request + "500": + description: Internal Server Error + "503": + description: Edge compute features are disabled + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Inspect an EdgeStack + tags: + - edge_stacks + put: + consumes: + - application/json + description: '**Access policy**: administrator' + operationId: EdgeStackUpdate + parameters: + - description: EdgeStack Id + in: path + name: id + required: true + type: integer + - description: EdgeStack data + in: body + name: body + required: true + schema: + $ref: '#/definitions/edgestacks.updateEdgeStackPayload' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/portainer.EdgeStack' + "400": + description: Bad Request + "500": + description: Internal Server Error + "503": + description: Edge compute features are disabled + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Update an EdgeStack + tags: + - edge_stacks + /edge_stacks/{id}/file: + get: + description: '**Access policy**: administrator' + operationId: EdgeStackFile + parameters: + - description: EdgeStack Id + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/edgestacks.stackFileResponse' + "400": + description: Bad Request + "500": + description: Internal Server Error + "503": + description: Edge compute features are disabled + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Fetches the stack file for an EdgeStack + tags: + - edge_stacks + /edge_stacks/{id}/status: + put: + consumes: + - application/json + description: Authorized only if the request is done by an Edge Environment(Endpoint) + operationId: EdgeStackStatusUpdate + parameters: + - description: EdgeStack Id + in: path + name: id + required: true + type: integer + - description: EdgeStack status payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/edgestacks.updateStatusPayload' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/portainer.EdgeStack' + "400": + description: Bad Request + "403": + description: Forbidden + "404": + description: Not Found + "500": + description: Internal Server Error + summary: Update an EdgeStack status + tags: + - edge_stacks + /edge_stacks/create/file: + post: + consumes: + - multipart/form-data + description: '**Access policy**: administrator' + operationId: EdgeStackCreateFile + parameters: + - description: Name of the stack. it must only consist of lowercase alphanumeric + characters, hyphens, or underscores as well as start with a letter or number + in: formData + name: Name + required: true + type: string + - description: Content of the Stack file + in: formData + name: file + required: true + type: file + - description: JSON stringified array of Edge Groups ids + in: formData + name: EdgeGroups + required: true + type: string + - description: deploy type 0 - 'compose', 1 - 'kubernetes' + in: formData + name: DeploymentType + required: true + type: integer + - description: JSON stringified array of Registry ids to use for this stack + in: formData + name: Registries + type: string + - description: Uses the manifest's namespaces instead of the default one, relevant + only for kube environments + in: formData + name: UseManifestNamespaces + type: boolean + - description: Pre Pull image + in: formData + name: PrePullImage + type: boolean + - description: Retry deploy + in: formData + name: RetryDeploy + type: boolean + - description: if true, will not create an edge stack, but just will check the + settings and return a non-persisted edge stack object + in: query + name: dryrun + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/portainer.EdgeStack' + "400": + description: Bad request + "500": + description: Internal server error + "503": + description: Edge compute features are disabled + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Create an EdgeStack from file + tags: + - edge_stacks + /edge_stacks/create/repository: + post: + description: '**Access policy**: administrator' + operationId: EdgeStackCreateRepository + parameters: + - description: stack config + in: body + name: body + required: true + schema: + $ref: '#/definitions/edgestacks.edgeStackFromGitRepositoryPayload' + - description: if true, will not create an edge stack, but just will check the + settings and return a non-persisted edge stack object + in: query + name: dryrun + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/portainer.EdgeStack' + "400": + description: Bad request + "500": + description: Internal server error + "503": + description: Edge compute features are disabled + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Create an EdgeStack from a git repository + tags: + - edge_stacks + /edge_stacks/create/string: + post: + description: '**Access policy**: administrator' + operationId: EdgeStackCreateString + parameters: + - description: stack config + in: body + name: body + required: true + schema: + $ref: '#/definitions/edgestacks.edgeStackFromStringPayload' + - description: if true, will not create an edge stack, but just will check the + settings and return a non-persisted edge stack object + in: query + name: dryrun + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/portainer.EdgeStack' + "400": + description: Bad request + "500": + description: Internal server error + "503": + description: Edge compute features are disabled + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Create an EdgeStack from a text + tags: + - edge_stacks + /endpoint_groups: + get: + description: |- + List all environment(endpoint) groups based on the current user authorizations. Will + return all environment(endpoint) groups if using an administrator account otherwise it will + only return authorized environment(endpoint) groups. + **Access policy**: restricted + operationId: EndpointGroupList + parameters: + - description: If true, each environment(endpoint) group will include the number + of environments(endpoints) associated to it and breakdown by type + in: query + name: size + type: boolean + produces: + - application/json + responses: + "200": + description: Environment(Endpoint) group + schema: + items: + $ref: '#/definitions/endpointgroups.EndpointGroupResponse' + type: array + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: List Environment(Endpoint) groups + tags: + - endpoint_groups + post: + consumes: + - application/json + description: |- + Create a new environment(endpoint) group. + **Access policy**: administrator + parameters: + - description: Environment(Endpoint) Group details + in: body + name: body + required: true + schema: + $ref: '#/definitions/endpointgroups.endpointGroupCreatePayload' + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/portainer.EndpointGroup' + "400": + description: Invalid request + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Create an Environment(Endpoint) Group + tags: + - endpoint_groups + /endpoint_groups/{id}: + delete: + description: |- + Remove an environment(endpoint) group. + **Access policy**: administrator + operationId: EndpointGroupDelete + parameters: + - description: EndpointGroup identifier + in: path + name: id + required: true + type: integer + responses: + "204": + description: Success + "400": + description: Invalid request + "404": + description: EndpointGroup not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Remove an environment(endpoint) group + tags: + - endpoint_groups + get: + consumes: + - application/json + description: |- + Retrieve details abont an environment(endpoint) group. + **Access policy**: administrator + parameters: + - description: Environment(Endpoint) group identifier + in: path + name: id + required: true + type: integer + - description: If true, include the number of environments and breakdown by + type + in: query + name: size + type: boolean + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/endpointgroups.EndpointGroupResponse' + "400": + description: Invalid request + "404": + description: EndpointGroup not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Inspect an Environment(Endpoint) group + tags: + - endpoint_groups + put: + consumes: + - application/json + description: |- + Update an environment(endpoint) group. + **Access policy**: administrator + operationId: EndpointGroupUpdate + parameters: + - description: EndpointGroup identifier + in: path + name: id + required: true + type: integer + - description: EndpointGroup details + in: body + name: body + required: true + schema: + $ref: '#/definitions/endpointgroups.endpointGroupUpdatePayload' + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/portainer.EndpointGroup' + "400": + description: Invalid request + "404": + description: EndpointGroup not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Update an environment(endpoint) group + tags: + - endpoint_groups + /endpoint_groups/{id}/endpoints/{endpointId}: + delete: + description: '**Access policy**: administrator' + operationId: EndpointGroupDeleteEndpoint + parameters: + - description: EndpointGroup identifier + in: path + name: id + required: true + type: integer + - description: Environment(Endpoint) identifier + in: path + name: endpointId + required: true + type: integer + responses: + "204": + description: Success + "400": + description: Invalid request + "404": + description: EndpointGroup not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Removes environment(endpoint) from an environment(endpoint) group + tags: + - endpoint_groups + put: + description: |- + Add an environment(endpoint) to an environment(endpoint) group + **Access policy**: administrator + operationId: EndpointGroupAddEndpoint + parameters: + - description: EndpointGroup identifier + in: path + name: id + required: true + type: integer + - description: Environment(Endpoint) identifier + in: path + name: endpointId + required: true + type: integer + responses: + "204": + description: Success + "400": + description: Invalid request + "404": + description: EndpointGroup not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Add an environment(endpoint) to an environment(endpoint) group + tags: + - endpoint_groups + /endpoints: + delete: + consumes: + - application/json + deprecated: true + description: |- + Deprecated: use the `POST` endpoint instead. + Remove multiple environments and optionally clean-up associated resources. + **Access policy**: Administrator only. + operationId: EndpointDeleteBatchDeprecated + parameters: + - description: List of environments to delete, with optional deleteCluster flag + to clean-up associated resources (cloud environments only) + in: body + name: body + required: true + schema: + $ref: '#/definitions/endpoints.endpointDeleteBatchPayload' + produces: + - application/json + responses: + "204": + description: Environment(s) successfully deleted. + "207": + description: Partial success. Some environments were deleted successfully, + while others failed. + schema: + $ref: '#/definitions/endpoints.endpointDeleteBatchPartialResponse' + "400": + description: Invalid request payload, such as missing required fields or + fields not meeting validation criteria. + "403": + description: Unauthorized access or operation not allowed. + "500": + description: Server error occurred while attempting to delete the specified + environments. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Remove multiple environments + tags: + - endpoints + get: + description: |- + List all environments(endpoints) based on the current user authorizations. Will + return all environments(endpoints) if using an administrator or team leader account otherwise it will + only return authorized environments(endpoints). + **Access policy**: restricted + operationId: EndpointList + parameters: + - description: Start searching from + in: query + name: start + type: integer + - description: Limit results to this value + in: query + name: limit + type: integer + - description: Sort results by this value + enum: + - Name + - Group + - Status + - LastCheckIn + - EdgeID + - PlatformType + - Health + - Id + in: query + name: sort + type: string + - description: Order sorted results by desc/asc + in: query + name: order + type: string + - description: Search query + in: query + name: search + type: string + - collectionFormat: csv + description: List environments(endpoints) of these groups + in: query + items: + type: integer + name: groupIds + type: array + - collectionFormat: csv + description: List environments(endpoints) by this status + in: query + items: + type: integer + name: status + type: array + - collectionFormat: csv + description: List environments(endpoints) of this type + in: query + items: + enum: + - 0 + - 1 + - 2 + - 3 + - 4 + - 5 + - 6 + - 7 + type: integer + name: types + type: array + - collectionFormat: csv + description: Filter environments by platform type + in: query + items: + enum: + - 0 + - 1 + - 2 + - 3 + - 4 + type: integer + name: platformTypes + type: array + - description: If true, return only environments with an outdated agent + in: query + name: outdated + type: boolean + - collectionFormat: csv + description: Exclude environments of these groups + in: query + items: + type: integer + name: excludeGroupIds + type: array + - collectionFormat: csv + description: search environments(endpoints) with these tags (depends on tagsPartialMatch) + in: query + items: + type: integer + name: tagIds + type: array + - description: If true, will return environment(endpoint) which has one of tagIds, + if false (or missing) will return only environments(endpoints) that has + all the tags + in: query + name: tagsPartialMatch + type: boolean + - collectionFormat: csv + description: will return only these environments(endpoints) + in: query + items: + type: integer + name: endpointIds + type: array + - collectionFormat: csv + description: will exclude these environments(endpoints) + in: query + items: + type: integer + name: excludeIds + type: array + - collectionFormat: csv + description: will return only environments with on of these agent versions + in: query + items: + type: string + name: agentVersions + type: array + - description: if exists true show only edge async agents, false show only standard + edge agents. if missing, will show both types (relevant only for edge agents) + in: query + name: edgeAsync + type: boolean + - description: if true, show only untrusted edge agents, if false show only + trusted edge agents (relevant only for edge agents) + in: query + name: edgeDeviceUntrusted + type: boolean + - description: if bigger then zero, show only edge agents that checked-in in + the last provided seconds (relevant only for edge agents) + in: query + name: edgeCheckInPassedSeconds + type: number + - description: if true, the snapshot data won't be retrieved + in: query + name: excludeSnapshots + type: boolean + - description: will return only environments(endpoints) with this name + in: query + name: name + type: string + - description: will return the environments of the specified edge stack + in: query + name: edgeStackId + type: integer + - description: only applied when edgeStackId exists. Filter the returned environments + based on their deployment status in the stack (not the environment status!) + enum: + - 0 + - 1 + - 2 + - 3 + - 4 + - 5 + - 6 + - 7 + - 8 + - 9 + - 10 + - 11 + - 12 + - 13 + in: query + name: edgeStackStatus + type: integer + - collectionFormat: csv + description: List environments(endpoints) of these edge groups + in: query + items: + type: integer + name: edgeGroupIds + type: array + - collectionFormat: csv + description: Exclude environments(endpoints) of these edge groups + in: query + items: + type: integer + name: excludeEdgeGroupIds + type: array + produces: + - application/json + responses: + "200": + description: Endpoints + schema: + items: + $ref: '#/definitions/portainer.Endpoint' + type: array + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: List environments(endpoints) + tags: + - endpoints + post: + consumes: + - multipart/form-data + description: |- + Create a new environment(endpoint) that will be used to manage an environment(endpoint). + **Access policy**: administrator + operationId: EndpointCreate + parameters: + - description: 'Name that will be used to identify this environment(endpoint) + (example: my-environment)' + in: formData + name: Name + required: true + type: string + - description: 'Environment(Endpoint) type. Value must be one of: 1 (Local Docker + environment), 2 (Agent environment), 3 (Azure environment), 4 (Edge agent + environment) or 5 (Local Kubernetes Environment)' + enum: + - 0 + - 1 + - 2 + - 3 + - 4 + - 5 + in: formData + name: EndpointCreationType + required: true + type: integer + - description: 'Container engine used by the environment(endpoint). Value must + be one of: ''docker'' or ''podman''' + in: formData + name: ContainerEngine + 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). Cannot be empty if EndpointCreationType is set + to 4 (Edge agent environment)' + in: formData + name: URL + type: string + - description: 'URL or IP address where exposed containers will be reachable. + Defaults to URL if not specified (example: docker.mydomain.tld:2375)' + in: formData + name: PublicURL + type: string + - description: Environment(Endpoint) group identifier. If not specified will + default to 1 (unassigned). + in: formData + name: GroupID + type: integer + - description: Require TLS to connect against this environment(endpoint). Must + be true if EndpointCreationType is set to 2 (Agent environment) + in: formData + name: TLS + type: boolean + - description: Skip server verification when using TLS. Must be true if EndpointCreationType + is set to 2 (Agent environment) + in: formData + name: TLSSkipVerify + type: boolean + - description: Skip client verification when using TLS. Must be true if EndpointCreationType + is set to 2 (Agent environment) + in: formData + name: TLSSkipClientVerify + type: boolean + - description: TLS CA certificate file + in: formData + name: TLSCACertFile + type: file + - description: TLS client certificate file + in: formData + name: TLSCertFile + type: file + - description: TLS client key file + in: formData + name: TLSKeyFile + type: file + - description: Azure application ID. Required if environment(endpoint) type + is set to 3 + in: formData + name: AzureApplicationID + type: string + - description: Azure tenant ID. Required if environment(endpoint) type is set + to 3 + in: formData + name: AzureTenantID + type: string + - description: Azure authentication key. Required if environment(endpoint) type + is set to 3 + in: formData + name: AzureAuthenticationKey + type: string + - description: JSON-parsable array of tag identifiers to which this environment(endpoint) + is associated + in: formData + name: TagIds + type: string + - description: The check in interval for edge agent (in seconds) + in: formData + name: EdgeCheckinInterval + type: integer + - description: URL or IP address that will be used to establish a reverse tunnel + in: formData + name: EdgeTunnelServerAddress + type: string + - description: List of GPUs - json stringified array of {name, value} structs + in: formData + name: Gpus + type: string + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/portainer.Endpoint' + "400": + description: Invalid request + "409": + description: Name is not unique + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Create a new environment(endpoint) + tags: + - endpoints + /endpoints/{id}: + delete: + description: |- + Remove the environment associated to the specified identifier and optionally clean-up associated resources. + **Access policy**: Administrator only. + operationId: EndpointDelete + parameters: + - description: Environment(Endpoint) identifier + in: path + name: id + required: true + type: integer + responses: + "204": + description: Environment successfully deleted. + "400": + description: Invalid request payload, such as missing required fields or + fields not meeting validation criteria. + "403": + description: Unauthorized access or operation not allowed. + "404": + description: Unable to find the environment with the specified identifier + inside the database. + "500": + description: Server error occurred while attempting to delete the environment. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Remove an environment + tags: + - endpoints + get: + description: |- + Retrieve details about an environment(endpoint). + **Access policy**: restricted + operationId: EndpointInspect + parameters: + - description: Environment(Endpoint) identifier + in: path + name: id + required: true + type: integer + - description: if true, the snapshot data won't be retrieved + in: query + name: excludeSnapshot + type: boolean + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/portainer.Endpoint' + "400": + description: Invalid request + "404": + description: Environment(Endpoint) not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Inspect an environment(endpoint) + tags: + - endpoints + put: + consumes: + - application/json + description: |- + Update an environment(endpoint). + **Access policy**: authenticated + operationId: EndpointUpdate + parameters: + - description: Environment(Endpoint) identifier + in: path + name: id + required: true + type: integer + - description: Environment(Endpoint) details + in: body + name: body + required: true + schema: + $ref: '#/definitions/endpoints.endpointUpdatePayload' + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/portainer.Endpoint' + "400": + description: Invalid request + "404": + description: Environment(Endpoint) not found + "409": + description: Name is not unique + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Update an environment(endpoint) + tags: + - endpoints + /endpoints/{id}/association: + put: + description: |- + De-association an edge environment(endpoint). + **Access policy**: administrator + operationId: EndpointAssociationDelete + parameters: + - description: Environment(Endpoint) identifier + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "204": + description: Success + "400": + description: Invalid request + "404": + description: Environment(Endpoint) not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: De-association an edge environment(endpoint) + tags: + - endpoints + /endpoints/{id}/docker/v2/browse/put: + post: + consumes: + - multipart/form-data + description: |- + Use this environment(endpoint) to upload TLS files. + **Access policy**: administrator + parameters: + - description: Environment(Endpoint) identifier + in: path + name: id + required: true + type: integer + - description: Optional volume identifier to upload the file + in: query + name: volumeID + type: string + - description: The destination path to upload the file to + in: formData + name: Path + required: true + type: string + - description: The file to upload + in: formData + name: file + required: true + type: file + produces: + - application/json + responses: + "204": + description: Success + "400": + description: Invalid request + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Upload a file under a specific path on the file system of an environment + (endpoint) + tags: + - endpoints + /endpoints/{id}/dockerhub/{registryId}: + get: + description: |- + get docker pull limits for a docker hub registry in the environment + **Access policy**: + operationId: endpointDockerhubStatus + parameters: + - description: endpoint ID + in: path + name: id + required: true + type: integer + - description: registry ID + in: path + name: registryId + required: true + type: integer + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/endpoints.dockerhubStatusResponse' + "400": + description: Invalid request + "403": + description: Permission denied + "404": + description: registry or endpoint not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: fetch docker pull limits + tags: + - endpoints + /endpoints/{id}/edge/jobs/{jobID}/logs: + post: + consumes: + - application/json + description: '**Access policy**: Edge agent only — requires X-PortainerAgent-EdgeID + header' + parameters: + - description: environment Id + in: path + name: id + required: true + type: integer + - description: Job Id + in: path + name: jobID + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + "400": + description: Bad Request + "403": + description: Forbidden + "500": + description: Internal Server Error + summary: Update the logs collected from an Edge Job + tags: + - edge_agent + /endpoints/{id}/edge/stacks/{stackId}: + get: + consumes: + - application/json + description: '**Access policy**: Edge agent only — requires X-PortainerAgent-EdgeID + header' + parameters: + - description: environment Id + in: path + name: id + required: true + type: integer + - description: EdgeStack Id + in: path + name: stackId + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/edge.StackPayload' + "400": + description: Bad Request + "404": + description: Not Found + "500": + description: Internal Server Error + summary: Inspect an Edge Stack for an Environment + tags: + - edge_agent + - edge_stacks + /endpoints/{id}/edge/status: + get: + description: |- + Endpoint for edge agent to check status of environment + **Access policy**: Edge agent only — requires X-PortainerAgent-EdgeID header + operationId: EndpointEdgeStatusInspect + parameters: + - description: Environment identifier + in: path + name: id + required: true + type: integer + responses: + "200": + description: Success + schema: + $ref: '#/definitions/endpointedge.endpointEdgeStatusInspectResponse' + "400": + description: Invalid request + "403": + description: Permission denied to access environment + "404": + description: Environment not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Get environment status + tags: + - edge_agent + /endpoints/{id}/forceupdateservice: + put: + consumes: + - application/json + description: |- + force update a docker service + **Access policy**: authenticated + operationId: endpointForceUpdateService + parameters: + - description: endpoint identifier + in: path + name: id + required: true + type: integer + - description: details + in: body + name: body + required: true + schema: + $ref: '#/definitions/endpoints.forceUpdateServicePayload' + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/swarm.ServiceUpdateResponse' + "400": + description: Invalid request + "403": + description: Permission denied + "404": + description: endpoint not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: force update a docker service + tags: + - endpoints + /endpoints/{id}/kubernetes/helm: + get: + description: '**Access policy**: authenticated' + operationId: HelmList + parameters: + - description: Environment(Endpoint) identifier + in: path + name: id + required: true + type: integer + - description: specify an optional namespace + in: query + name: namespace + type: string + - description: specify an optional filter + in: query + name: filter + type: string + - description: specify an optional selector + in: query + name: selector + type: string + produces: + - application/json + responses: + "200": + description: Success + schema: + items: + $ref: '#/definitions/release.ReleaseElement' + type: array + "400": + description: Invalid environment(endpoint) identifier + "401": + description: Unauthorized + "404": + description: Environment(Endpoint) or ServiceAccount not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: List Helm Releases + tags: + - helm + post: + consumes: + - application/json + description: '**Access policy**: authenticated' + operationId: HelmInstall + parameters: + - description: Environment(Endpoint) identifier + in: path + name: id + required: true + type: integer + - description: Chart details + in: body + name: payload + required: true + schema: + $ref: '#/definitions/helm.installChartPayload' + - description: Dry run + in: query + name: dryRun + type: boolean + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/release.Release' + "400": + description: Invalid request payload + "401": + description: Unauthorized + "404": + description: Environment(Endpoint) or ServiceAccount not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Install Helm Chart + tags: + - helm + /endpoints/{id}/kubernetes/helm/{release}: + delete: + description: '**Access policy**: authenticated' + operationId: HelmDelete + parameters: + - description: Environment(Endpoint) identifier + in: path + name: id + required: true + type: integer + - description: The name of the release/application to uninstall + in: path + name: release + required: true + type: string + - description: An optional namespace + in: query + name: namespace + type: string + responses: + "204": + description: Success + "400": + description: Invalid environment(endpoint) id or bad request + "401": + description: Unauthorized + "404": + description: Environment(Endpoint) or ServiceAccount not found + "500": + description: Server error or helm error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Delete Helm Release + tags: + - helm + get: + description: |- + Get details of a helm release by release name + **Access policy**: authenticated + operationId: HelmGet + parameters: + - description: Environment(Endpoint) identifier + in: path + name: id + required: true + type: integer + - description: Helm release name + in: path + name: release + required: true + type: string + - description: specify an optional namespace + in: query + name: namespace + type: string + - description: show resources of the release + in: query + name: showResources + type: boolean + - description: specify an optional revision + in: query + name: revision + type: integer + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/release.Release' + "400": + description: Invalid request payload, such as missing required fields or + fields not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does + not have the necessary permissions. Ensure that you have provided a valid + API key or JWT token, and that you have the required permissions. + "403": + description: Permission denied - the user is authenticated but does not + have the necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier. + "500": + description: Server error occurred while attempting to retrieve the release. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get a helm release + tags: + - helm + /endpoints/{id}/kubernetes/helm/{release}/history: + get: + description: |- + Get a historical list of releases by release name + **Access policy**: authenticated + operationId: HelmGetHistory + parameters: + - description: Environment(Endpoint) identifier + in: path + name: id + required: true + type: integer + - description: Helm release name + in: path + name: release + required: true + type: string + - description: specify an optional namespace + in: query + name: namespace + type: string + produces: + - application/json + responses: + "200": + description: Success + schema: + items: + $ref: '#/definitions/release.Release' + type: array + "400": + description: Invalid request payload, such as missing required fields or + fields not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does + not have the necessary permissions. Ensure that you have provided a valid + API key or JWT token, and that you have the required permissions. + "403": + description: Permission denied - the user is authenticated but does not + have the necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier. + "500": + description: Server error occurred while attempting to retrieve the historical + list of releases. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get a historical list of releases + tags: + - helm + /endpoints/{id}/kubernetes/helm/{release}/rollback: + post: + description: |- + Rollback a helm release to a previous revision + **Access policy**: authenticated + operationId: HelmRollback + parameters: + - description: Environment(Endpoint) identifier + in: path + name: id + required: true + type: integer + - description: Helm release name + in: path + name: release + required: true + type: string + - description: specify an optional namespace + in: query + name: namespace + type: string + - description: specify the revision to rollback to (defaults to previous revision + if not specified) + in: query + name: revision + type: integer + - description: 'wait for resources to be ready (default: false)' + in: query + name: wait + type: boolean + - description: 'wait for jobs to complete before marking the release as successful + (default: false)' + in: query + name: waitForJobs + type: boolean + - description: 'performs pods restart for the resource if applicable (default: + true)' + in: query + name: recreate + type: boolean + - description: 'force resource update through delete/recreate if needed (default: + false)' + in: query + name: force + type: boolean + - description: 'time to wait for any individual Kubernetes operation in seconds + (default: 300)' + in: query + name: timeout + type: integer + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/release.Release' + "400": + description: Invalid request payload, such as missing required fields or + fields not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does + not have the necessary permissions. Ensure that you have provided a valid + API key or JWT token, and that you have the required permissions. + "403": + description: Permission denied - the user is authenticated but does not + have the necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier + or release name. + "500": + description: Server error occurred while attempting to rollback the release. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Rollback a helm release + tags: + - helm + /endpoints/{id}/registries: + get: + description: |- + List all registries based on the current user authorizations in current environment. + **Access policy**: authenticated + operationId: endpointRegistriesList + parameters: + - description: required if kubernetes environment, will show registries by namespace + in: query + name: namespace + type: string + - description: Environment(Endpoint) identifier + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: Success + schema: + items: + $ref: '#/definitions/portainer.Registry' + type: array + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: List Registries on environment + tags: + - endpoints + /endpoints/{id}/registries/{registryId}: + put: + consumes: + - application/json + description: '**Access policy**: authenticated' + operationId: endpointRegistryAccess + parameters: + - description: Environment(Endpoint) identifier + in: path + name: id + required: true + type: integer + - description: Registry identifier + in: path + name: registryId + required: true + type: integer + - description: details + in: body + name: body + required: true + schema: + $ref: '#/definitions/endpoints.registryAccessPayload' + produces: + - application/json + responses: + "204": + description: Success + "400": + description: Invalid request + "403": + description: Permission denied + "404": + description: Endpoint not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: update registry access for environment + tags: + - endpoints + /endpoints/{id}/settings: + put: + consumes: + - application/json + description: |- + Update settings for an environment(endpoint). + **Access policy**: authenticated + operationId: EndpointSettingsUpdate + parameters: + - description: Environment(Endpoint) identifier + in: path + name: id + required: true + type: integer + - description: Environment(Endpoint) details + in: body + name: body + required: true + schema: + $ref: '#/definitions/endpoints.endpointSettingsUpdatePayload' + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/portainer.Endpoint' + "400": + description: Invalid request + "404": + description: Environment(Endpoint) not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Update settings for an environment(endpoint) + tags: + - endpoints + /endpoints/{id}/snapshot: + post: + description: |- + Snapshots an environment(endpoint) + **Access policy**: administrator + operationId: EndpointSnapshot + parameters: + - description: Environment(Endpoint) identifier + in: path + name: id + required: true + type: integer + responses: + "204": + description: Success + "400": + description: Invalid request + "404": + description: Environment(Endpoint) not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Snapshots an environment(endpoint) + tags: + - endpoints + /endpoints/delete: + post: + consumes: + - application/json + description: |- + Remove multiple environments and optionally clean-up associated resources. + **Access policy**: Administrator only. + operationId: EndpointDeleteBatch + parameters: + - description: List of environments to delete, with optional deleteCluster flag + to clean-up associated resources (cloud environments only) + in: body + name: body + required: true + schema: + $ref: '#/definitions/endpoints.endpointDeleteBatchPayload' + produces: + - application/json + responses: + "204": + description: Environment(s) successfully deleted. + "207": + description: Partial success. Some environments were deleted successfully, + while others failed. + schema: + $ref: '#/definitions/endpoints.endpointDeleteBatchPartialResponse' + "400": + description: Invalid request payload, such as missing required fields or + fields not meeting validation criteria. + "403": + description: Unauthorized access or operation not allowed. + "500": + description: Server error occurred while attempting to delete the specified + environments. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Remove multiple environments + tags: + - endpoints + /endpoints/global-key: + post: + operationId: EndpointCreateGlobalKey + responses: + "200": + description: Success + schema: + $ref: '#/definitions/endpoints.endpointCreateGlobalKeyResponse' + "400": + description: Invalid request + "500": + description: Server error + summary: Create or retrieve the endpoint for an EdgeID + tags: + - endpoints + /endpoints/relations: + put: + consumes: + - application/json + description: |- + Update relations for a list of environments + Edge groups, tags and environment group can be updated. + + **Access policy**: administrator + operationId: EndpointUpdateRelations + parameters: + - description: Environment relations data + in: body + name: body + required: true + schema: + $ref: '#/definitions/endpoints.endpointUpdateRelationsPayload' + responses: + "204": + description: Success + "400": + description: Invalid request + "401": + description: Unauthorized + "404": + description: Not found + "500": + description: Server error + security: + - jwt: [] + summary: Update relations for a list of environments + tags: + - endpoints + /endpoints/snapshot: + post: + description: |- + Snapshot all environments(endpoints) + **Access policy**: administrator + operationId: EndpointSnapshots + responses: + "204": + description: Success + "500": + description: Server Error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Snapshot all environments(endpoints) + tags: + - endpoints + /endpoints/summary: + get: + description: |- + Returns counts of environments by status (up, down) and ungrouped environments (unassigned), plus breakdowns by group, type, and health. + **Access policy**: restricted + operationId: EndpointSummaryCounts + produces: + - application/json + responses: + "200": + description: Environment summary counts + schema: + $ref: '#/definitions/endpoints.EnvironmentSummaryCountsResponse' + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Get environment summary counts + tags: + - endpoints + /gitops/repo/file/preview: + post: + description: |- + Retrieve the compose file content based on git repository configuration + **Access policy**: authenticated + operationId: GitOperationRepoFilePreview + parameters: + - description: Template details + in: body + name: body + required: true + schema: + $ref: '#/definitions/gitops.repositoryFilePreviewPayload' + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/gitops.fileResponse' + "400": + description: Invalid request + "404": + description: Source not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: preview the content of target file in the git repository + tags: + - gitops + /gitops/sources: + get: + description: |- + Returns a deduplicated list of git repositories used across all GitOps workflows. + **Access policy**: authenticated + operationId: GitOpsSourcesList + parameters: + - description: Search term (matches URL) + in: query + name: search + type: string + - description: 'Sort field: name | status | type' + in: query + name: sort + type: string + - description: 'Sort order: asc or desc' + in: query + name: order + type: string + - description: Pagination start index + in: query + name: start + type: integer + - description: Pagination limit (0 = unlimited) + in: query + name: limit + type: integer + - description: 'Filter by status: healthy | syncing | error | paused | unknown' + in: query + name: status + type: string + - description: 'Filter by source type: git | oci | helm' + enum: + - git + - helm + - oci + in: query + name: type + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/sources.Source' + type: array + "400": + description: Invalid status parameter + "403": + description: Access denied + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: List all GitOps sources + tags: + - gitops + /gitops/sources/{id}: + delete: + description: |- + Deletes an existing GitOps source. Returns 409 if the source is referenced by any workflow or custom template. + **Access policy**: authenticated + operationId: GitOpsSourcesDelete + parameters: + - description: Source identifier + in: path + name: id + required: true + type: integer + responses: + "204": + description: Source deleted + "400": + description: Invalid request + "403": + description: Access denied + "404": + description: Source not found + "409": + description: Source is in use by one or more workflows or custom templates + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Delete a source + tags: + - gitops + get: + description: |- + Returns a single GitOps source with its connection settings and linked workflows. + **Access policy**: authenticated + operationId: GitOpsSourceGet + parameters: + - description: Source identifier + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/sources.SourceDetail' + "400": + description: Invalid request + "403": + description: Access denied + "404": + description: Source not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Get a GitOps source by ID + tags: + - gitops + put: + consumes: + - application/json + description: |- + Updates an existing GitOps source backed by a Git repository. + **Access policy**: authenticated + operationId: GitOpsSourcesUpdateGit + parameters: + - description: Source identifier + in: path + name: id + required: true + type: integer + - description: Git source details + in: body + name: body + required: true + schema: + $ref: '#/definitions/sources.GitSourceUpdatePayload' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/portainer.Source' + "400": + description: Invalid request payload + "403": + description: Access denied + "404": + description: Source not found + "409": + description: A source with this URL and credentials already exists + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Update a Git source + tags: + - gitops + /gitops/sources/{id}/access: + put: + consumes: + - application/json + description: |- + Updates the access control settings for an existing GitOps source. + **Access policy**: administrator + operationId: GitOpsSourcesUpdateAccess + parameters: + - description: Source identifier + in: path + name: id + required: true + type: integer + - description: Source access control + in: body + name: body + required: true + schema: + $ref: '#/definitions/sources.SourceAccessUpdatePayload' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/portainer.Source' + "400": + description: Invalid request payload + "403": + description: Access denied + "404": + description: Source not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Update a GitOps source's access control + tags: + - gitops + /gitops/sources/{id}/test: + post: + consumes: + - application/json + description: |- + Tests connectivity for a GitOps source, applying optional overrides to the stored configuration. + **Access policy**: authenticated + operationId: GitOpsSourcesTestById + parameters: + - description: Source identifier + in: path + name: id + required: true + type: integer + - description: Optional connection overrides; omitted fields fall back to stored + values + in: body + name: body + schema: + $ref: '#/definitions/sources.GitSourceUpdatePayload' + produces: + - application/json + responses: + "200": + description: Connection test result + schema: + $ref: '#/definitions/sources.ConnectionTestResult' + "400": + description: Invalid request payload + "403": + description: Access denied + "404": + description: Source not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Test the connection of a stored source + tags: + - gitops + /gitops/sources/git: + post: + consumes: + - application/json + description: |- + Creates a new GitOps source backed by a Git repository. + **Access policy**: authenticated + operationId: GitOpsSourcesCreateGit + parameters: + - description: Git source details + in: body + name: body + required: true + schema: + $ref: '#/definitions/sources.GitSourceCreatePayload' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/portainer.Source' + "400": + description: Invalid request payload + "403": + description: Access denied + "409": + description: A source with this URL and credentials already exists + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Create a Git source + tags: + - gitops + /gitops/sources/summary: + get: + description: |- + Returns a count of sources per status. + **Access policy**: authenticated + operationId: GitOpsSourcesSummary + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/workflows.StatusSummary' + "403": + description: Access denied + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Summarize GitOps source status counts + tags: + - gitops + /gitops/sources/test: + post: + consumes: + - application/json + description: |- + Tests connectivity for Git connection details that have not been persisted yet. + **Access policy**: authenticated + operationId: GitOpsSourcesTest + parameters: + - description: Git connection details + in: body + name: body + required: true + schema: + $ref: '#/definitions/sources.GitSourceCreatePayload' + produces: + - application/json + responses: + "200": + description: Connection test result + schema: + $ref: '#/definitions/sources.ConnectionTestResult' + "400": + description: Invalid request payload + "403": + description: Access denied + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Test a Git source connection + tags: + - gitops + /gitops/workflows: + get: + description: |- + Returns a unified list of all stacks that have GitOps (GitConfig) configured. + **Access policy**: authenticated + operationId: GitOpsWorkflowsList + parameters: + - description: Search term (matches name or repository URL) + in: query + name: search + type: string + - description: 'Sort field: name | type | status | creationDate | lastSyncDate' + in: query + name: sort + type: string + - description: 'Sort order: asc or desc' + in: query + name: order + type: string + - description: Pagination start index + in: query + name: start + type: integer + - description: Pagination limit (0 = unlimited) + in: query + name: limit + type: integer + - collectionFormat: csv + description: Filter by environment IDs (e.g. endpointIds[]=1&endpointIds[]=2) + in: query + items: + type: integer + name: endpointIds + type: array + - description: 'Filter by status: healthy | syncing | error | paused | unknown' + in: query + name: status + type: string + - description: 'Filter by type: stack' + in: query + name: type + type: string + - description: 'Filter by platform: dockerStandalone | dockerSwarm | kubernetes' + in: query + name: platform + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/workflows.Workflow' + type: array + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: List all GitOps workflows + tags: + - gitops + /gitops/workflows/{id}: + get: + description: |- + Returns the detail view of a single GitOps workflow, with one entry per backing + stack or edge stack artifact. + **Access policy**: authenticated + operationId: GitOpsWorkflowGet + parameters: + - description: Workflow identifier + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/workflows.WorkflowDetail' + "400": + description: Invalid request + "404": + description: Workflow not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Get a GitOps workflow by ID + tags: + - gitops + /gitops/workflows/summary: + get: + description: |- + Returns a count of workflows per status across all environments. + **Access policy**: authenticated + operationId: GitOpsWorkflowsSummary + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/workflows.StatusSummary' + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Summarize GitOps workflow status counts + tags: + - gitops + /kubernetes/{id}/applications: + get: + description: |- + Get a list of applications across all namespaces in the cluster. If the nodeName is provided, it will return the applications running on that node. + **Access policy**: authenticated + operationId: GetAllKubernetesApplications + parameters: + - description: Environment(Endpoint) identifier + in: path + name: id + required: true + type: integer + - description: Namespace name + in: query + name: namespace + required: true + type: string + - description: Node name + in: query + name: nodeName + required: true + type: string + produces: + - application/json + responses: + "200": + description: Success + schema: + items: + $ref: '#/definitions/kubernetes.K8sApplication' + type: array + "400": + description: Invalid request payload, such as missing required fields or + fields not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does + not have the necessary permissions. Ensure that you have provided a valid + API key or JWT token, and that you have the required permissions. + "403": + description: Permission denied - the user is authenticated but does not + have the necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier. + "500": + description: Server error occurred while attempting to retrieve the list + of applications from the cluster. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get a list of applications across all namespaces in the cluster. If + the nodeName is provided, it will return the applications running on that + node. + tags: + - kubernetes + /kubernetes/{id}/applications/count: + get: + description: |- + Get the count of Applications across all namespaces in the cluster. If the nodeName is provided, it will return the count of applications running on that node. + **Access policy**: Authenticated user. + operationId: GetAllKubernetesApplicationsCount + parameters: + - description: Environment identifier + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: Success + schema: + type: integer + "400": + description: Invalid request payload, such as missing required fields or + fields not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does + not have the necessary permissions. Ensure that you have provided a valid + API key or JWT token, and that you have the required permissions. + "403": + description: Permission denied - the user is authenticated but does not + have the necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier. + "500": + description: Server error occurred while attempting to retrieve the count + of all applications from the cluster. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get Applications count + tags: + - kubernetes + /kubernetes/{id}/cluster_role_bindings/delete: + post: + consumes: + - application/json + description: |- + Delete the provided list of cluster role bindings. + **Access policy**: Authenticated user. + operationId: DeleteClusterRoleBindings + parameters: + - description: Environment identifier + in: path + name: id + required: true + type: integer + - description: A list of cluster role bindings to delete + in: body + name: payload + required: true + schema: + items: + type: string + type: array + responses: + "204": + description: Success + "400": + description: Invalid request payload, such as missing required fields or + fields not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does + not have the necessary permissions. Ensure that you have provided a valid + API key or JWT token, and that you have the required permissions. + "403": + description: Permission denied - the user is authenticated but does not + have the necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier + or unable to find a specific cluster role binding. + "500": + description: Server error occurred while attempting to delete cluster role + bindings. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Delete cluster role bindings + tags: + - kubernetes + /kubernetes/{id}/cluster_roles/delete: + post: + consumes: + - application/json + description: |- + Delete the provided list of cluster roles. + **Access policy**: Authenticated user. + operationId: DeleteClusterRoles + parameters: + - description: Environment identifier + in: path + name: id + required: true + type: integer + - description: A list of cluster roles to delete + in: body + name: payload + required: true + schema: + items: + type: string + type: array + responses: + "204": + description: Success + "400": + description: Invalid request payload, such as missing required fields or + fields not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does + not have the necessary permissions. Ensure that you have provided a valid + API key or JWT token, and that you have the required permissions. + "403": + description: Permission denied - the user is authenticated but does not + have the necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier + or unable to find a specific cluster role. + "500": + description: Server error occurred while attempting to delete cluster roles. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Delete cluster roles + tags: + - kubernetes + /kubernetes/{id}/clusterrolebindings: + get: + description: |- + Get a list of kubernetes cluster role bindings within the given environment at the cluster level. + **Access policy**: Authenticated user. + operationId: GetAllKubernetesClusterRoleBindings + parameters: + - description: Environment identifier + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: Success + schema: + items: + $ref: '#/definitions/kubernetes.K8sClusterRoleBinding' + type: array + "400": + description: Invalid request payload, such as missing required fields or + fields not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does + not have the necessary permissions. Ensure that you have provided a valid + API key or JWT token, and that you have the required permissions. + "403": + description: Permission denied - the user is authenticated but does not + have the necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier. + "500": + description: Server error occurred while attempting to retrieve the list + of cluster role bindings. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get a list of kubernetes cluster role bindings + tags: + - kubernetes + /kubernetes/{id}/clusterroles: + get: + description: |- + Get a list of kubernetes cluster roles within the given environment at the cluster level. + **Access policy**: Authenticated user. + operationId: GetAllKubernetesClusterRoles + parameters: + - description: Environment identifier + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: Success + schema: + items: + $ref: '#/definitions/kubernetes.K8sClusterRole' + type: array + "400": + description: Invalid request payload, such as missing required fields or + fields not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does + not have the necessary permissions. Ensure that you have provided a valid + API key or JWT token, and that you have the required permissions. + "403": + description: Permission denied - the user is authenticated but does not + have the necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier. + "500": + description: Server error occurred while attempting to retrieve the list + of cluster roles. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get a list of kubernetes cluster roles + tags: + - kubernetes + /kubernetes/{id}/configmaps: + get: + description: |- + Get a list of ConfigMaps across all namespaces in the cluster. For non-admin users, it will only return ConfigMaps based on the namespaces that they have access to. + **Access policy**: Authenticated user. + operationId: GetAllKubernetesConfigMaps + parameters: + - description: Environment identifier + in: path + name: id + required: true + type: integer + - description: Set to true to include information about applications that use + the ConfigMaps in the response + in: query + name: isUsed + required: true + type: boolean + produces: + - application/json + responses: + "200": + description: Success + schema: + items: + $ref: '#/definitions/kubernetes.K8sConfigMap' + type: array + "400": + description: Invalid request payload, such as missing required fields or + fields not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does + not have the necessary permissions. Ensure that you have provided a valid + API key or JWT token, and that you have the required permissions. + "403": + description: Permission denied - the user is authenticated but does not + have the necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier. + "500": + description: Server error occurred while attempting to retrieve all configmaps + from the cluster. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get a list of ConfigMaps + tags: + - kubernetes + /kubernetes/{id}/configmaps/count: + get: + description: |- + Get the count of ConfigMaps across all namespaces in the cluster. For non-admin users, it will only return the count of ConfigMaps based on the namespaces that they have access to. + **Access policy**: Authenticated user. + operationId: GetAllKubernetesConfigMapsCount + parameters: + - description: Environment identifier + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: Success + schema: + type: integer + "400": + description: Invalid request payload, such as missing required fields or + fields not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does + not have the necessary permissions. Ensure that you have provided a valid + API key or JWT token, and that you have the required permissions. + "403": + description: Permission denied - the user is authenticated but does not + have the necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier. + "500": + description: Server error occurred while attempting to retrieve the count + of all configmaps from the cluster. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get ConfigMaps count + tags: + - kubernetes + /kubernetes/{id}/cron_jobs: + get: + description: |- + Get a list of kubernetes Cron Jobs that the user has access to. + **Access policy**: Authenticated user. + operationId: GetKubernetesCronJobs + parameters: + - description: Environment identifier + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: Success + schema: + items: + $ref: '#/definitions/kubernetes.K8sCronJob' + type: array + "400": + description: Invalid request payload, such as missing required fields or + fields not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does + not have the necessary permissions. Ensure that you have provided a valid + API key or JWT token, and that you have the required permissions. + "403": + description: Permission denied - the user is authenticated but does not + have the necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier. + "500": + description: Server error occurred while attempting to retrieve the list + of Cron Jobs. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get a list of kubernetes Cron Jobs + tags: + - kubernetes + /kubernetes/{id}/cron_jobs/delete: + post: + consumes: + - application/json + description: |- + Delete the provided list of Cron Jobs. + **Access policy**: Authenticated user. + operationId: DeleteCronJobs + parameters: + - description: Environment identifier + in: path + name: id + required: true + type: integer + - description: A map where the key is the namespace and the value is an array + of Cron Jobs to delete + in: body + name: payload + required: true + schema: + $ref: '#/definitions/kubernetes.K8sCronJobDeleteRequests' + responses: + "204": + description: Success + "400": + description: Invalid request payload, such as missing required fields or + fields not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does + not have the necessary permissions. Ensure that you have provided a valid + API key or JWT token, and that you have the required permissions. + "403": + description: Permission denied - the user is authenticated but does not + have the necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier + or unable to find a specific service account. + "500": + description: Server error occurred while attempting to delete Cron Jobs. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Delete Cron Jobs + tags: + - kubernetes + /kubernetes/{id}/dashboard: + get: + consumes: + - application/json + description: |- + Get the dashboard summary data which is simply a count of a range of different commonly used kubernetes resources. + **Access policy**: Authenticated user. + operationId: GetKubernetesDashboard + parameters: + - description: Environment (Endpoint) identifier + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: Success + schema: + items: + $ref: '#/definitions/kubernetes.K8sDashboard' + type: array + "400": + description: Invalid request + "500": + description: Server error + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get the dashboard summary data + tags: + - kubernetes + /kubernetes/{id}/describe: + get: + description: |- + Get a description of a kubernetes resource. + **Access policy**: Authenticated user. + operationId: DescribeResource + parameters: + - description: Environment identifier + in: path + name: id + required: true + type: integer + - description: Resource name + in: query + name: name + required: true + type: string + - description: Resource kind + in: query + name: kind + required: true + type: string + - description: Namespace + in: query + name: namespace + type: string + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/kubernetes.describeResourceResponse' + "400": + description: Invalid request payload, such as missing required fields or + fields not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does + not have the necessary permissions. Ensure that you have provided a valid + API key or JWT token, and that you have the required permissions. + "403": + description: Permission denied - the user is authenticated but does not + have the necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier. + "500": + description: Server error occurred while attempting to retrieve resource + description + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get a description of a kubernetes resource + tags: + - kubernetes + /kubernetes/{id}/events: + get: + description: |- + Get events by query param resourceId + **Access policy**: Authenticated user. + operationId: getAllKubernetesEvents + parameters: + - description: Environment identifier + in: path + name: id + required: true + type: integer + - description: The resource id of the involved kubernetes object + in: query + name: resourceId + type: string + produces: + - application/json + responses: + "200": + description: Success + schema: + items: + $ref: '#/definitions/kubernetes.K8sEvent' + type: array + "400": + description: Invalid request payload, such as missing required fields or + fields not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does + not have the necessary permissions. Ensure that you have provided a valid + API key or JWT token, and that you have the required permissions. + "403": + description: Permission denied - the user is authenticated but does not + have the necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "500": + description: Server error occurred while attempting to retrieve the events. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Gets kubernetes events + tags: + - kubernetes + /kubernetes/{id}/ingresscontrollers: + get: + description: |- + Get a list of ingress controllers for the given environment. If the allowedOnly query parameter is set, only ingress controllers that are allowed by the environment's ingress configuration will be returned. + **Access policy**: Authenticated user. + operationId: GetAllKubernetesIngressControllers + parameters: + - description: Environment identifier + in: path + name: id + required: true + type: integer + - description: Only return allowed ingress controllers + in: query + name: allowedOnly + type: boolean + produces: + - application/json + responses: + "200": + description: Success + schema: + items: + $ref: '#/definitions/kubernetes.K8sIngressController' + type: array + "400": + description: Invalid request payload, such as missing required fields or + fields not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does + not have the necessary permissions. Ensure that you have provided a valid + API key or JWT token, and that you have the required permissions. + "403": + description: Permission denied - the user is authenticated but does not + have the necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier. + "500": + description: Server error occurred while attempting to retrieve ingress + controllers + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get a list of ingress controllers + tags: + - kubernetes + put: + consumes: + - application/json + description: |- + Update (block/unblock) ingress controllers for the provided environment. + **Access policy**: Authenticated user. + operationId: UpdateKubernetesIngressControllers + parameters: + - description: Environment identifier + in: path + name: id + required: true + type: integer + - description: Ingress controllers + in: body + name: body + required: true + schema: + items: + $ref: '#/definitions/kubernetes.K8sIngressController' + type: array + produces: + - application/json + responses: + "204": + description: Success + "400": + description: Invalid request payload, such as missing required fields or + fields not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does + not have the necessary permissions. Ensure that you have provided a valid + API key or JWT token, and that you have the required permissions. + "403": + description: Permission denied - the user is authenticated but does not + have the necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier + or unable to find the ingress controllers to update. + "500": + description: Server error occurred while attempting to update ingress controllers. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Update (block/unblock) ingress controllers + tags: + - kubernetes + /kubernetes/{id}/ingresses: + get: + description: |- + Get kubernetes ingresses at the cluster level for the provided environment. + **Access policy**: Authenticated user. + operationId: GetAllKubernetesClusterIngresses + parameters: + - description: Environment identifier + in: path + name: id + required: true + type: integer + - description: Lookup services associated with each ingress + in: query + name: withServices + type: boolean + produces: + - application/json + responses: + "200": + description: Success + schema: + items: + $ref: '#/definitions/kubernetes.K8sIngressInfo' + type: array + "400": + description: Invalid request payload, such as missing required fields or + fields not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does + not have the necessary permissions. Ensure that you have provided a valid + API key or JWT token, and that you have the required permissions. + "403": + description: Permission denied - the user is authenticated but does not + have the necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier. + "500": + description: Server error occurred while attempting to retrieve ingresses. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get kubernetes ingresses at the cluster level + tags: + - kubernetes + /kubernetes/{id}/ingresses/count: + get: + description: |- + Get the number of kubernetes ingresses within the given environment. + **Access policy**: Authenticated user. + operationId: GetAllKubernetesClusterIngressesCount + parameters: + - description: Environment identifier + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: Success + schema: + type: integer + "400": + description: Invalid request payload, such as missing required fields or + fields not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does + not have the necessary permissions. Ensure that you have provided a valid + API key or JWT token, and that you have the required permissions. + "403": + description: Permission denied - the user is authenticated but does not + have the necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier. + "500": + description: Server error occurred while attempting to retrieve ingresses + count. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get Ingresses count + tags: + - kubernetes + /kubernetes/{id}/ingresses/delete: + post: + consumes: + - application/json + description: |- + Delete one or more Ingresses in the provided environment. + **Access policy**: Authenticated user. + operationId: DeleteKubernetesIngresses + parameters: + - description: Environment identifier + in: path + name: id + required: true + type: integer + - description: Ingress details + in: body + name: body + required: true + schema: + $ref: '#/definitions/kubernetes.K8sIngressDeleteRequests' + produces: + - application/json + responses: + "204": + description: Success + "400": + description: Invalid request payload, such as missing required fields or + fields not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does + not have the necessary permissions. Ensure that you have provided a valid + API key or JWT token, and that you have the required permissions. + "403": + description: Permission denied - the user is authenticated but does not + have the necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier + or unable to find a specific ingress. + "500": + description: Server error occurred while attempting to delete specified + ingresses. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Delete one or more Ingresses + tags: + - kubernetes + /kubernetes/{id}/jobs: + get: + description: |- + Get a list of kubernetes Jobs that the user has access to. + **Access policy**: Authenticated user. + operationId: GetKubernetesJobs + parameters: + - description: Environment identifier + in: path + name: id + required: true + type: integer + - description: Whether to include Jobs that have a cronjob owner + in: query + name: includeCronJobChildren + type: boolean + produces: + - application/json + responses: + "200": + description: Success + schema: + items: + $ref: '#/definitions/kubernetes.K8sJob' + type: array + "400": + description: Invalid request payload, such as missing required fields or + fields not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does + not have the necessary permissions. Ensure that you have provided a valid + API key or JWT token, and that you have the required permissions. + "403": + description: Permission denied - the user is authenticated but does not + have the necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier. + "500": + description: Server error occurred while attempting to retrieve the list + of Jobs. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get a list of kubernetes Jobs + tags: + - kubernetes + /kubernetes/{id}/jobs/delete: + post: + consumes: + - application/json + description: |- + Delete the provided list of Jobs. + **Access policy**: Authenticated user. + operationId: DeleteJobs + parameters: + - description: Environment identifier + in: path + name: id + required: true + type: integer + - description: A map where the key is the namespace and the value is an array + of Jobs to delete + in: body + name: payload + required: true + schema: + $ref: '#/definitions/kubernetes.K8sJobDeleteRequests' + responses: + "204": + description: Success + "400": + description: Invalid request payload, such as missing required fields or + fields not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does + not have the necessary permissions. Ensure that you have provided a valid + API key or JWT token, and that you have the required permissions. + "403": + description: Permission denied - the user is authenticated but does not + have the necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier + or unable to find a specific service account. + "500": + description: Server error occurred while attempting to delete Jobs. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Delete Jobs + tags: + - kubernetes + /kubernetes/{id}/max_resource_limits: + get: + description: |- + Get max CPU and memory limits (unused resources) of all nodes within k8s cluster. + **Access policy**: Authenticated user. + operationId: GetKubernetesMaxResourceLimits + parameters: + - description: Environment(Endpoint) identifier + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/portainer.K8sNodesLimits' + "400": + description: Invalid request + "401": + description: Unauthorized access - the user is not authenticated or does + not have the necessary permissions. Ensure that you have provided a valid + API key or JWT token, and that you have the required permissions. + "403": + description: Permission denied - the user is authenticated but does not + have the necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier. + "500": + description: Server error occurred while attempting to retrieve nodes limits. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get max CPU and memory limits of all nodes within k8s cluster + tags: + - kubernetes + /kubernetes/{id}/metrics/applications_resources: + get: + description: |- + Get the total CPU (cores) and memory (bytes) requests and limits of all applications across all namespaces. + **Access policy**: Authenticated user. + operationId: GetApplicationsResources + parameters: + - description: Environment(Endpoint) identifier + in: path + name: id + required: true + type: integer + - description: Node name + in: query + name: node + required: true + type: string + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/kubernetes.K8sApplicationResource' + "400": + description: Invalid request payload, such as missing required fields or + fields not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does + not have the necessary permissions. Ensure that you have provided a valid + API key or JWT token, and that you have the required permissions. + "403": + description: Permission denied - the user is authenticated but does not + have the necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier. + "500": + description: Server error occurred while attempting to retrieve the total + resource requests and limits for all applications from the cluster. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get the total resource requests and limits of all applications + tags: + - kubernetes + /kubernetes/{id}/metrics/nodes: + get: + description: |- + Get a list of metrics associated with all nodes of a cluster. + **Access policy**: Authenticated user. + operationId: GetKubernetesMetricsForAllNodes + parameters: + - description: Environment identifier + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/v1beta1.NodeMetricsList' + "401": + description: Unauthorized access - the user is not authenticated or does + not have the necessary permissions. Ensure that you have provided a valid + API key or JWT token, and that you have the required permissions. + "500": + description: Server error occurred while attempting to retrieve the list + of nodes with their live metrics. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get a list of nodes with their live metrics + tags: + - kubernetes + /kubernetes/{id}/metrics/nodes/{name}: + get: + description: |- + Get live metrics for the specified node. + **Access policy**: Authenticated user. + operationId: GetKubernetesMetricsForNode + parameters: + - description: Environment identifier + in: path + name: id + required: true + type: integer + - description: Node identifier + in: path + name: name + required: true + type: string + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/v1beta1.NodeMetrics' + "400": + description: Invalid request payload, such as missing required fields or + fields not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does + not have the necessary permissions. Ensure that you have provided a valid + API key or JWT token, and that you have the required permissions. + "500": + description: Server error occurred while attempting to retrieve the live + metrics for the specified node. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get live metrics for a node + tags: + - kubernetes + /kubernetes/{id}/metrics/pods/{namespace}: + get: + description: |- + Get a list of pods with their live metrics for the specified namespace. + **Access policy**: Authenticated user. + operationId: GetKubernetesMetricsForAllPods + parameters: + - description: Environment identifier + in: path + name: id + required: true + type: integer + - description: Namespace + in: path + name: namespace + required: true + type: string + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/v1beta1.PodMetricsList' + "400": + description: Invalid request payload, such as missing required fields or + fields not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does + not have the necessary permissions. Ensure that you have provided a valid + API key or JWT token, and that you have the required permissions. + "500": + description: Server error occurred while attempting to retrieve the list + of pods with their live metrics. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get a list of pods with their live metrics + tags: + - kubernetes + /kubernetes/{id}/metrics/pods/{namespace}/{name}: + get: + description: |- + Get live metrics for the specified pod. + **Access policy**: Authenticated user. + operationId: GetKubernetesMetricsForPod + parameters: + - description: Environment identifier + in: path + name: id + required: true + type: integer + - description: Namespace + in: path + name: namespace + required: true + type: string + - description: Pod identifier + in: path + name: name + required: true + type: string + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/v1beta1.PodMetrics' + "400": + description: Invalid request payload, such as missing required fields or + fields not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does + not have the necessary permissions. Ensure that you have provided a valid + API key or JWT token, and that you have the required permissions. + "500": + description: Server error occurred while attempting to retrieve the live + metrics for the specified pod. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get live metrics for a pod + tags: + - kubernetes + /kubernetes/{id}/namespaces: + delete: + description: |- + Delete a kubernetes namespace within the given environment. + **Access policy**: Authenticated user. + operationId: DeleteKubernetesNamespace + parameters: + - description: Environment identifier + in: path + name: id + required: true + type: integer + responses: + "200": + description: Success + schema: + type: string + "400": + description: Invalid request payload, such as missing required fields or + fields not meeting validation criteria. + "403": + description: Unauthorized access or operation not allowed. + "500": + description: Server error occurred while attempting to delete the namespace. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Delete a kubernetes namespace + tags: + - kubernetes + get: + description: |- + Get a list of all namespaces within the given environment based on the user role and permissions. If the user is an admin, they can access all namespaces. If the user is not an admin, they can only access namespaces that they have access to. + **Access policy**: Authenticated user. + operationId: GetKubernetesNamespaces + parameters: + - description: Environment identifier + in: path + name: id + required: true + type: integer + - description: When set to true, include the resource quota information as part + of the Namespace information. Default is false + in: query + name: withResourceQuota + required: true + type: boolean + - description: When set to true, include the unhealthy events information as + part of the Namespace information. Default is false + in: query + name: withUnhealthyEvents + required: true + type: boolean + produces: + - application/json + responses: + "200": + description: Success + schema: + items: + $ref: '#/definitions/portainer.K8sNamespaceInfo' + type: array + "400": + description: Invalid request payload, such as missing required fields or + fields not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does + not have the necessary permissions. Ensure that you have provided a valid + API key or JWT token, and that you have the required permissions. + "403": + description: Permission denied - the user is authenticated but does not + have the necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier. + "500": + description: Server error occurred while attempting to retrieve the list + of namespaces. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get a list of namespaces + tags: + - kubernetes + post: + consumes: + - application/json + description: |- + Create a namespace within the given environment. + **Access policy**: Authenticated user. + operationId: CreateKubernetesNamespace + parameters: + - description: Environment identifier + in: path + name: id + required: true + type: integer + - description: Namespace configuration details + in: body + name: body + required: true + schema: + $ref: '#/definitions/kubernetes.K8sNamespaceDetails' + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/portainer.K8sNamespaceInfo' + "400": + description: Invalid request payload, such as missing required fields or + fields not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does + not have the necessary permissions. Ensure that you have provided a valid + API key or JWT token, and that you have the required permissions. + "403": + description: Permission denied - the user is authenticated but does not + have the necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "409": + description: Conflict - the namespace already exists. + "500": + description: Server error occurred while attempting to create the namespace. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Create a namespace + tags: + - kubernetes + put: + consumes: + - application/json + description: |- + Update a namespace within the given environment. + **Access policy**: Authenticated user. + operationId: UpdateKubernetesNamespaceDeprecated + parameters: + - description: Environment identifier + in: path + name: id + required: true + type: integer + - description: Namespace + in: path + name: namespace + required: true + type: string + - description: Namespace details + in: body + name: body + required: true + schema: + $ref: '#/definitions/kubernetes.K8sNamespaceDetails' + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/portainer.K8sNamespaceInfo' + "400": + description: Invalid request payload, such as missing required fields or + fields not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does + not have the necessary permissions. Ensure that you have provided a valid + API key or JWT token, and that you have the required permissions. + "403": + description: Permission denied - the user is authenticated but does not + have the necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier + or unable to find a specific namespace. + "500": + description: Server error occurred while attempting to update the namespace. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Update a namespace + tags: + - kubernetes + /kubernetes/{id}/namespaces/{namespace}: + get: + description: |- + Get namespace details for the provided namespace within the given environment. + **Access policy**: Authenticated user. + operationId: GetKubernetesNamespace + parameters: + - description: Environment identifier + in: path + name: id + required: true + type: integer + - description: The namespace name to get details for + in: path + name: namespace + required: true + type: string + - description: When set to true, include the resource quota information as part + of the Namespace information. Default is false + in: query + name: withResourceQuota + required: true + type: boolean + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/portainer.K8sNamespaceInfo' + "400": + description: Invalid request payload, such as missing required fields or + fields not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does + not have the necessary permissions. Ensure that you have provided a valid + API key or JWT token, and that you have the required permissions. + "403": + description: Permission denied - the user is authenticated but does not + have the necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier + or unable to find a specific namespace. + "500": + description: Server error occurred while attempting to retrieve specified + namespace information. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get namespace details + tags: + - kubernetes + put: + consumes: + - application/json + description: |- + Update a namespace within the given environment. + **Access policy**: Authenticated user. + operationId: UpdateKubernetesNamespace + parameters: + - description: Environment identifier + in: path + name: id + required: true + type: integer + - description: Namespace + in: path + name: namespace + required: true + type: string + - description: Namespace details + in: body + name: body + required: true + schema: + $ref: '#/definitions/kubernetes.K8sNamespaceDetails' + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/portainer.K8sNamespaceInfo' + "400": + description: Invalid request payload, such as missing required fields or + fields not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does + not have the necessary permissions. Ensure that you have provided a valid + API key or JWT token, and that you have the required permissions. + "403": + description: Permission denied - the user is authenticated but does not + have the necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier + or unable to find a specific namespace. + "500": + description: Server error occurred while attempting to update the namespace. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Update a namespace + tags: + - kubernetes + /kubernetes/{id}/namespaces/{namespace}/configmaps/{configmap}: + get: + description: |- + Get a ConfigMap by name for a given namespace. + **Access policy**: Authenticated user. + operationId: GetKubernetesConfigMap + parameters: + - description: Environment identifier + in: path + name: id + required: true + type: integer + - description: The namespace name where the configmap is located + in: path + name: namespace + required: true + type: string + - description: The configmap name to get details for + in: path + name: configmap + required: true + type: string + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/kubernetes.K8sConfigMap' + "400": + description: Invalid request payload, such as missing required fields or + fields not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does + not have the necessary permissions. Ensure that you have provided a valid + API key or JWT token, and that you have the required permissions. + "403": + description: Permission denied - the user is authenticated but does not + have the necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier + or a configmap with the specified name in the given namespace. + "500": + description: Server error occurred while attempting to retrieve a configmap + by name within the specified namespace. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get a ConfigMap + tags: + - kubernetes + /kubernetes/{id}/namespaces/{namespace}/events: + get: + description: |- + Get events by optional query param resourceId for a given namespace. + **Access policy**: Authenticated user. + operationId: getKubernetesEventsForNamespace + parameters: + - description: Environment identifier + in: path + name: id + required: true + type: integer + - description: The namespace name the events are associated to + in: path + name: namespace + required: true + type: string + - description: The resource id of the involved kubernetes object + in: query + name: resourceId + type: string + produces: + - application/json + responses: + "200": + description: Success + schema: + items: + $ref: '#/definitions/kubernetes.K8sEvent' + type: array + "400": + description: Invalid request payload, such as missing required fields or + fields not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does + not have the necessary permissions. Ensure that you have provided a valid + API key or JWT token, and that you have the required permissions. + "403": + description: Permission denied - the user is authenticated but does not + have the necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "500": + description: Server error occurred while attempting to retrieve the events + within the specified namespace. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Gets kubernetes events for namespace + tags: + - kubernetes + /kubernetes/{id}/namespaces/{namespace}/ingresscontrollers: + get: + description: |- + Get a list of ingress controllers for the given environment in the provided namespace. + **Access policy**: Authenticated user. + operationId: GetKubernetesIngressControllersByNamespace + parameters: + - description: Environment identifier + in: path + name: id + required: true + type: integer + - description: Namespace + in: path + name: namespace + required: true + type: string + produces: + - application/json + responses: + "200": + description: Success + schema: + items: + $ref: '#/definitions/kubernetes.K8sIngressController' + type: array + "400": + description: Invalid request payload, such as missing required fields or + fields not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does + not have the necessary permissions. Ensure that you have provided a valid + API key or JWT token, and that you have the required permissions. + "403": + description: Permission denied - the user is authenticated but does not + have the necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier + or a namespace with the specified name. + "500": + description: Server error occurred while attempting to retrieve ingress + controllers by a namespace + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get a list ingress controllers by namespace + tags: + - kubernetes + put: + consumes: + - application/json + description: |- + Update (block/unblock) ingress controllers by namespace for the provided environment. + **Access policy**: Authenticated user. + operationId: UpdateKubernetesIngressControllersByNamespace + parameters: + - description: Environment identifier + in: path + name: id + required: true + type: integer + - description: Namespace name + in: path + name: namespace + required: true + type: string + - description: Ingress controllers + in: body + name: body + required: true + schema: + items: + $ref: '#/definitions/kubernetes.K8sIngressController' + type: array + produces: + - application/json + responses: + "204": + description: Success + "400": + description: Invalid request payload, such as missing required fields or + fields not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does + not have the necessary permissions. Ensure that you have provided a valid + API key or JWT token, and that you have the required permissions. + "403": + description: Permission denied - the user is authenticated but does not + have the necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier. + "500": + description: Server error occurred while attempting to update ingress controllers + by namespace. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Update (block/unblock) ingress controllers by namespace + tags: + - kubernetes + /kubernetes/{id}/namespaces/{namespace}/ingresses: + get: + description: |- + Get a list of Ingresses. If namespace is provided, it will return the list of Ingresses in that namespace. + **Access policy**: Authenticated user. + operationId: GetAllKubernetesIngresses + parameters: + - description: Environment identifier + in: path + name: id + required: true + type: integer + - description: Namespace name + in: path + name: namespace + required: true + type: string + produces: + - application/json + responses: + "200": + description: Success + schema: + items: + $ref: '#/definitions/kubernetes.K8sIngressInfo' + type: array + "400": + description: Invalid request payload, such as missing required fields or + fields not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does + not have the necessary permissions. Ensure that you have provided a valid + API key or JWT token, and that you have the required permissions. + "403": + description: Permission denied - the user is authenticated but does not + have the necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier. + "500": + description: Server error occurred while attempting to retrieve ingresses + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get a list of Ingresses + tags: + - kubernetes + post: + consumes: + - application/json + description: |- + Create an Ingress for the provided environment. + **Access policy**: Authenticated user. + operationId: CreateKubernetesIngress + parameters: + - description: Environment identifier + in: path + name: id + required: true + type: integer + - description: Namespace name + in: path + name: namespace + required: true + type: string + - description: Ingress details + in: body + name: body + required: true + schema: + $ref: '#/definitions/kubernetes.K8sIngressInfo' + produces: + - application/json + responses: + "204": + description: Success + "400": + description: Invalid request payload, such as missing required fields or + fields not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does + not have the necessary permissions. Ensure that you have provided a valid + API key or JWT token, and that you have the required permissions. + "403": + description: Permission denied - the user is authenticated but does not + have the necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier. + "409": + description: Conflict - an ingress with the same name already exists in + the specified namespace. + "500": + description: Server error occurred while attempting to create an ingress. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Create an Ingress + tags: + - kubernetes + put: + consumes: + - application/json + description: |- + Update an Ingress for the provided environment. + **Access policy**: Authenticated user. + operationId: UpdateKubernetesIngress + parameters: + - description: Environment identifier + in: path + name: id + required: true + type: integer + - description: Namespace name + in: path + name: namespace + required: true + type: string + - description: Ingress details + in: body + name: body + required: true + schema: + $ref: '#/definitions/kubernetes.K8sIngressInfo' + produces: + - application/json + responses: + "204": + description: Success + "400": + description: Invalid request payload, such as missing required fields or + fields not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does + not have the necessary permissions. Ensure that you have provided a valid + API key or JWT token, and that you have the required permissions. + "403": + description: Permission denied - the user is authenticated but does not + have the necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier + or unable to find the specified ingress. + "500": + description: Server error occurred while attempting to update the specified + ingress. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Update an Ingress + tags: + - kubernetes + /kubernetes/{id}/namespaces/{namespace}/ingresses/{ingress}: + get: + description: |- + Get an Ingress by name for the provided environment. + **Access policy**: Authenticated user. + operationId: GetKubernetesIngress + parameters: + - description: Environment identifier + in: path + name: id + required: true + type: integer + - description: Namespace name + in: path + name: namespace + required: true + type: string + - description: Ingress name + in: path + name: ingress + required: true + type: string + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/kubernetes.K8sIngressInfo' + "400": + description: Invalid request payload, such as missing required fields or + fields not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does + not have the necessary permissions. Ensure that you have provided a valid + API key or JWT token, and that you have the required permissions. + "403": + description: Permission denied - the user is authenticated but does not + have the necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier + or unable to find an ingress with the specified name. + "500": + description: Server error occurred while attempting to retrieve an ingress. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get an Ingress by name + tags: + - kubernetes + /kubernetes/{id}/namespaces/{namespace}/persistent_volume_claims: + get: + description: |- + Get a list of PersistentVolumeClaims in the specified namespace. + **Access policy**: Authenticated user. + operationId: GetKubernetesPersistentVolumeClaimsInNamespace + parameters: + - description: Environment identifier + in: path + name: id + required: true + type: integer + - description: Namespace name + in: path + name: namespace + required: true + type: string + produces: + - application/json + responses: + "200": + description: Success + schema: + items: + $ref: '#/definitions/kubernetes.K8sPersistentVolumeClaim' + type: array + "400": + description: Invalid request payload. + "403": + description: Unauthorized access or operation not allowed. + "500": + description: Server error occurred while attempting to retrieve persistent + volume claims. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get PersistentVolumeClaims in a namespace + tags: + - kubernetes + /kubernetes/{id}/namespaces/{namespace}/persistent_volume_claims/{name}: + get: + description: |- + Get a PersistentVolumeClaim by name within a namespace. + **Access policy**: Authenticated user. + operationId: GetKubernetesPersistentVolumeClaim + parameters: + - description: Environment identifier + in: path + name: id + required: true + type: integer + - description: Namespace name + in: path + name: namespace + required: true + type: string + - description: PVC name + in: path + name: name + required: true + type: string + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/kubernetes.K8sPersistentVolumeClaim' + "400": + description: Invalid request payload. + "403": + description: Unauthorized access or operation not allowed. + "404": + description: PVC not found. + "500": + description: Server error occurred while attempting to retrieve the persistent + volume claim. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get a specific PersistentVolumeClaim + tags: + - kubernetes + /kubernetes/{id}/namespaces/{namespace}/pods/{name}: + delete: + description: |- + Delete a single Kubernetes pod in the given namespace. The owning + controller (Deployment, StatefulSet, DaemonSet, ...) is responsible + for recreating the pod. For naked pods the pod is removed permanently. + **Access policy**: Authenticated user. + operationId: DeleteKubernetesPod + parameters: + - description: Environment(Endpoint) identifier + in: path + name: id + required: true + type: integer + - description: Namespace + in: path + name: namespace + required: true + type: string + - description: Pod name + in: path + name: name + required: true + type: string + produces: + - application/json + responses: + "204": + description: Success + "400": + description: Invalid request payload, such as missing required fields or + fields not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does + not have the necessary permissions. Ensure that you have provided a valid + API key or JWT token, and that you have the required permissions. + "403": + description: Permission denied - the user is authenticated but does not + have the necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier + or unable to find the specified pod. + "500": + description: Server error occurred while attempting to delete the pod. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Delete a kubernetes pod + tags: + - kubernetes + /kubernetes/{id}/namespaces/{namespace}/pods/{name}/restart: + post: + description: |- + Restart all containers in a single Kubernetes pod in place using + the Kubernetes 1.35 alpha pod-restart subresource. The pod itself + is preserved. Requires the cluster to expose the corresponding + subresource (and the matching feature gate to be enabled). + **Access policy**: Authenticated user. + operationId: RestartKubernetesPod + parameters: + - description: Environment(Endpoint) identifier + in: path + name: id + required: true + type: integer + - description: Namespace + in: path + name: namespace + required: true + type: string + - description: Pod name + in: path + name: name + required: true + type: string + produces: + - application/json + responses: + "204": + description: Success + "400": + description: Invalid request payload, such as missing required fields or + fields not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does + not have the necessary permissions. Ensure that you have provided a valid + API key or JWT token, and that you have the required permissions. + "403": + description: Permission denied - the user is authenticated but does not + have the necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment, the specified pod, or the cluster + does not expose the pod-restart subresource (Kubernetes <1.35 or feature + gate disabled). + "405": + description: The cluster does not support the pod-restart subresource (Kubernetes + <1.35 or feature gate disabled). + "500": + description: Server error occurred while attempting to restart the pod. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Restart all containers in a Kubernetes pod + tags: + - kubernetes + /kubernetes/{id}/namespaces/{namespace}/secrets/{secret}: + get: + description: |- + Get a Secret by name for a given namespace. + **Access policy**: Authenticated user. + operationId: GetKubernetesSecret + parameters: + - description: Environment identifier + in: path + name: id + required: true + type: integer + - description: The namespace name where the secret is located + in: path + name: namespace + required: true + type: string + - description: The secret name to get details for + in: path + name: secret + required: true + type: string + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/kubernetes.K8sSecret' + "400": + description: Invalid request payload, such as missing required fields or + fields not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does + not have the necessary permissions. Ensure that you have provided a valid + API key or JWT token, and that you have the required permissions. + "403": + description: Permission denied - the user is authenticated but does not + have the necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier. + "500": + description: Server error occurred while attempting to retrieve a secret + by name belong in a namespace. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get a Secret + tags: + - kubernetes + /kubernetes/{id}/namespaces/{namespace}/service_accounts/{name}: + get: + description: |- + Get a kubernetes service account in the given namespace. + **Access policy**: Authenticated user. + operationId: GetKubernetesServiceAccount + parameters: + - description: Environment identifier + in: path + name: id + required: true + type: integer + - description: Namespace + in: path + name: namespace + required: true + type: string + - description: Service account name + in: path + name: name + required: true + type: string + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/kubernetes.K8sServiceAccount' + "400": + description: Invalid request + "401": + description: Unauthorized + "403": + description: Permission denied + "404": + description: Service account not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get a kubernetes service account + tags: + - kubernetes + /kubernetes/{id}/namespaces/{namespace}/service_accounts/{name}/image_pull_secrets: + put: + consumes: + - application/json + description: |- + Replace the imagePullSecrets list on a service account with the provided list. + **Access policy**: Authenticated user. + operationId: UpdateKubernetesServiceAccountImagePullSecrets + parameters: + - description: Environment identifier + in: path + name: id + required: true + type: integer + - description: Namespace + in: path + name: namespace + required: true + type: string + - description: Service account name + in: path + name: name + required: true + type: string + - description: New imagePullSecrets list + in: body + name: payload + required: true + schema: + $ref: '#/definitions/kubernetes.K8sServiceAccountImagePullSecretsUpdatePayload' + responses: + "204": + description: Success + "400": + description: Invalid request payload, such as missing required fields or + fields not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does + not have the necessary permissions. Ensure that you have provided a valid + API key or JWT token, and that you have the required permissions. + "403": + description: Permission denied - the user is authenticated but does not + have the necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier, + namespace, or service account. + "500": + description: Server error occurred while attempting to update image pull + secrets for the service account. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Update image pull secrets for a service account + tags: + - kubernetes + /kubernetes/{id}/namespaces/{namespace}/services: + get: + description: |- + Get a list of services for a given namespace. + **Access policy**: Authenticated user. + operationId: GetKubernetesServicesByNamespace + parameters: + - description: Environment identifier + in: path + name: id + required: true + type: integer + - description: Namespace name + in: path + name: namespace + required: true + type: string + produces: + - application/json + responses: + "200": + description: Success + schema: + items: + $ref: '#/definitions/kubernetes.K8sServiceInfo' + type: array + "400": + description: Invalid request payload, such as missing required fields or + fields not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does + not have the necessary permissions. Ensure that you have provided a valid + API key or JWT token, and that you have the required permissions. + "403": + description: Permission denied - the user is authenticated but does not + have the necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier. + "500": + description: Server error occurred while attempting to retrieve all services + for a namespace. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get a list of services for a given namespace + tags: + - kubernetes + post: + consumes: + - application/json + description: |- + Create a service within a given namespace + **Access policy**: Authenticated user. + operationId: CreateKubernetesService + parameters: + - description: Environment identifier + in: path + name: id + required: true + type: integer + - description: Namespace name + in: path + name: namespace + required: true + type: string + - description: Service definition + in: body + name: body + required: true + schema: + $ref: '#/definitions/kubernetes.K8sServiceInfo' + produces: + - application/json + responses: + "204": + description: Success + "400": + description: Invalid request payload, such as missing required fields or + fields not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does + not have the necessary permissions. Ensure that you have provided a valid + API key or JWT token, and that you have the required permissions. + "403": + description: Permission denied - the user is authenticated but does not + have the necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier. + "500": + description: Server error occurred while attempting to create a service. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Create a service + tags: + - kubernetes + put: + consumes: + - application/json + description: |- + Update a service within a given namespace. + **Access policy**: Authenticated user. + operationId: UpdateKubernetesService + parameters: + - description: Environment identifier + in: path + name: id + required: true + type: integer + - description: Namespace name + in: path + name: namespace + required: true + type: string + - description: Service definition + in: body + name: body + required: true + schema: + $ref: '#/definitions/kubernetes.K8sServiceInfo' + produces: + - application/json + responses: + "204": + description: Success + "400": + description: Invalid request payload, such as missing required fields or + fields not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does + not have the necessary permissions. Ensure that you have provided a valid + API key or JWT token, and that you have the required permissions. + "403": + description: Permission denied - the user is authenticated but does not + have the necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier + or unable to find the service to update. + "500": + description: Server error occurred while attempting to update a service. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Update a service + tags: + - kubernetes + /kubernetes/{id}/namespaces/{namespace}/system: + put: + consumes: + - application/json + description: |- + Toggle the system state for a namespace + **Access policy**: Administrator or environment administrator. + operationId: KubernetesNamespacesToggleSystem + parameters: + - description: Environment identifier + in: path + name: id + required: true + type: integer + - description: Namespace name + in: path + name: namespace + required: true + type: string + - description: Update details + in: body + name: body + required: true + schema: + $ref: '#/definitions/kubernetes.namespacesToggleSystemPayload' + responses: + "204": + description: Success + "400": + description: Invalid request payload, such as missing required fields or + fields not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does + not have the necessary permissions. Ensure that you have provided a valid + API key or JWT token, and that you have the required permissions. + "403": + description: Permission denied - the user is authenticated but does not + have the necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier + or unable to find the namespace to update. + "500": + description: Server error occurred while attempting to update the system + state of the namespace. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Toggle the system state for a namespace + tags: + - kubernetes + /kubernetes/{id}/namespaces/{namespace}/volumes: + get: + description: |- + Get a list of kubernetes volumes within the specified namespace in the given environment (Endpoint). The Endpoint ID must be a valid Portainer environment identifier. + **Access policy**: Authenticated user. + operationId: GetKubernetesVolumesInNamespace + parameters: + - description: Environment identifier + in: path + name: id + required: true + type: integer + - description: Namespace identifier + in: path + name: namespace + required: true + type: string + - description: When set to True, include the applications that are using the + volumes. It is set to false by default + in: query + name: withApplications + type: boolean + produces: + - application/json + responses: + "200": + description: Success + schema: + additionalProperties: + $ref: '#/definitions/kubernetes.K8sVolumeInfo' + type: object + "400": + description: Invalid request payload, such as missing required fields or + fields not meeting validation criteria. + "403": + description: Unauthorized access or operation not allowed. + "500": + description: Server error occurred while attempting to retrieve kubernetes + volumes in the namespace. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get Kubernetes volumes within a namespace in the given Portainer environment + tags: + - kubernetes + /kubernetes/{id}/namespaces/count: + get: + description: |- + Get the total number of kubernetes namespaces within the given environment, including the system namespaces. The total count depends on the user's role and permissions. + **Access policy**: Authenticated user. + operationId: GetKubernetesNamespacesCount + parameters: + - description: Environment identifier + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: Success + schema: + type: integer + "400": + description: Invalid request payload, such as missing required fields or + fields not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does + not have the necessary permissions. Ensure that you have provided a valid + API key or JWT token, and that you have the required permissions. + "403": + description: Permission denied - the user is authenticated but does not + have the necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier. + "500": + description: Server error occurred while attempting to compute the namespace + count. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get the total number of kubernetes namespaces within the given Portainer + environment. + tags: + - kubernetes + /kubernetes/{id}/nodes: + get: + description: |- + Returns the list of Kubernetes nodes for the selected environment. + **Access policy**: Authenticated user. + operationId: GetKubernetesNodes + parameters: + - description: Environment(Endpoint) identifier + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: Success + schema: + items: + $ref: '#/definitions/kubernetes.KubernetesNodeResponse' + type: array + "401": + description: Unauthorized access - the user is not authenticated or does + not have the necessary permissions. + "403": + description: Permission denied - the user is authenticated but does not + have the necessary permissions to access the requested resource. + "500": + description: Server error occurred while attempting to retrieve the list + of nodes. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get Kubernetes cluster nodes + tags: + - kubernetes + /kubernetes/{id}/nodes/{name}/drain: + post: + consumes: + - application/json + description: |- + Drain a Kubernetes node by safely evicting all pods from the node, preparing it for maintenance or removal + **Access policy**: authenticated + operationId: drainNode + parameters: + - description: Environment(Endpoint) identifier + in: path + name: id + required: true + type: integer + - description: Name of the node to drain + in: path + name: name + required: true + type: string + responses: + "204": + description: Success + "400": + description: Invalid request, such as missing required fields or fields + not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does + not have the necessary permissions. Ensure that you have provided a valid + API key or JWT token, and that you have the required permissions. + "403": + description: Permission denied - the user is authenticated but does not + have the necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier + or unable to find the specified node. + "500": + description: Server error occurred while attempting to drain node. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Drain a Kubernetes node + tags: + - kubernetes + /kubernetes/{id}/nodes_limits: + get: + description: |- + Get CPU and memory limits of all nodes within k8s cluster. + **Access policy**: Authenticated user. + operationId: GetKubernetesNodesLimits + parameters: + - description: Environment(Endpoint) identifier + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/portainer.K8sNodesLimits' + "400": + description: Invalid request + "401": + description: Unauthorized access - the user is not authenticated or does + not have the necessary permissions. Ensure that you have provided a valid + API key or JWT token, and that you have the required permissions. + "403": + description: Permission denied - the user is authenticated but does not + have the necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier. + "500": + description: Server error occurred while attempting to retrieve nodes limits. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get CPU and memory limits of all nodes within k8s cluster + tags: + - kubernetes + /kubernetes/{id}/persistent_volume_claims: + get: + description: |- + Get a list of all PersistentVolumeClaims within the given environment. Scoped by namespace for non-admin users. + **Access policy**: Authenticated user. + operationId: GetAllKubernetesPersistentVolumeClaims + parameters: + - description: Environment identifier + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: Success + schema: + items: + $ref: '#/definitions/kubernetes.K8sPersistentVolumeClaim' + type: array + "400": + description: Invalid request payload. + "403": + description: Unauthorized access or operation not allowed. + "500": + description: Server error occurred while attempting to retrieve persistent + volume claims. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get all PersistentVolumeClaims + tags: + - kubernetes + /kubernetes/{id}/persistent_volume_claims/delete: + post: + consumes: + - application/json + description: |- + Delete the provided list of PersistentVolumeClaims. + **Access policy**: Authenticated user. + operationId: DeleteKubernetesPersistentVolumeClaims + parameters: + - description: Environment identifier + in: path + name: id + required: true + type: integer + - description: List of PVCs to delete (namespace + name) + in: body + name: body + required: true + schema: + items: + $ref: '#/definitions/kubernetes.K8sVolumeDeleteRequest' + type: array + produces: + - application/json + responses: + "204": + description: Success + "400": + description: Invalid request payload. + "403": + description: Unauthorized access or operation not allowed. + "500": + description: Server error occurred while attempting to delete persistent + volume claims. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Delete PersistentVolumeClaims + tags: + - kubernetes + /kubernetes/{id}/persistent_volume_claims/resize: + put: + consumes: + - application/json + description: |- + Resize a PVC to a new size. The StorageClass must support volume expansion. + **Access policy**: Authenticated user. + operationId: ResizeKubernetesPersistentVolumeClaim + parameters: + - description: Environment identifier + in: path + name: id + required: true + type: integer + - description: PVC resize request + in: body + name: body + required: true + schema: + $ref: '#/definitions/kubernetes.K8sPVCResizeRequest' + produces: + - application/json + responses: + "204": + description: Success + "400": + description: Invalid request payload. + "403": + description: Unauthorized access or operation not allowed. + "500": + description: Server error occurred while attempting to resize the persistent + volume claim. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Resize a PersistentVolumeClaim + tags: + - kubernetes + /kubernetes/{id}/persistent_volumes: + get: + description: |- + Get a list of all PersistentVolumes in the given environment. + **Access policy**: Authenticated user. + operationId: GetAllKubernetesPersistentVolumes + parameters: + - description: Environment identifier + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: Success + schema: + items: + $ref: '#/definitions/kubernetes.K8sPersistentVolume' + type: array + "400": + description: Invalid request payload, such as missing required fields or + fields not meeting validation criteria. + "403": + description: Unauthorized access or operation not allowed. + "500": + description: Server error occurred while attempting to retrieve persistent + volumes. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get all PersistentVolumes in the cluster + tags: + - kubernetes + /kubernetes/{id}/persistent_volumes/{name}: + get: + description: |- + Get a PersistentVolume by name in the given environment. + **Access policy**: Authenticated user. + operationId: GetKubernetesPersistentVolume + parameters: + - description: Environment identifier + in: path + name: id + required: true + type: integer + - description: PersistentVolume name + in: path + name: name + required: true + type: string + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/kubernetes.K8sPersistentVolume' + "400": + description: Invalid request payload. + "403": + description: Unauthorized access or operation not allowed. + "404": + description: PersistentVolume not found. + "500": + description: Server error occurred while attempting to retrieve the persistent + volume. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get a specific PersistentVolume + tags: + - kubernetes + /kubernetes/{id}/persistent_volumes/delete: + post: + consumes: + - application/json + description: |- + Delete the provided list of PersistentVolumes. + **Access policy**: Authenticated user. + operationId: DeleteKubernetesPersistentVolumes + parameters: + - description: Environment identifier + in: path + name: id + required: true + type: integer + - description: List of PV names to delete + in: body + name: body + required: true + schema: + items: + type: string + type: array + produces: + - application/json + responses: + "204": + description: Success + "400": + description: Invalid request payload. + "403": + description: Unauthorized access or operation not allowed. + "500": + description: Server error occurred while attempting to delete persistent + volumes. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Delete PersistentVolumes + tags: + - kubernetes + /kubernetes/{id}/persistent_volumes/reclaim_policy: + put: + consumes: + - application/json + description: |- + Update the reclaim policy of a PersistentVolume. + **Access policy**: Authenticated user. + operationId: UpdateKubernetesPersistentVolumeReclaimPolicy + parameters: + - description: Environment identifier + in: path + name: id + required: true + type: integer + - description: Reclaim policy update request + in: body + name: body + required: true + schema: + $ref: '#/definitions/kubernetes.K8sPVReclaimPolicyRequest' + produces: + - application/json + responses: + "204": + description: Success + "400": + description: Invalid request payload. + "403": + description: Unauthorized access or operation not allowed. + "500": + description: Server error occurred while attempting to update reclaim policy. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Update reclaim policy of a PersistentVolume + tags: + - kubernetes + /kubernetes/{id}/rbac_enabled: + get: + description: |- + Check if RBAC is enabled in the specified Kubernetes cluster. + **Access policy**: Authenticated user. + operationId: GetKubernetesRBACStatus + parameters: + - description: Environment(Endpoint) identifier + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: RBAC status + schema: + type: boolean + "401": + description: Unauthorized access - the user is not authenticated or does + not have the necessary permissions. Ensure that you have provided a valid + API key or JWT token, and that you have the required permissions. + "403": + description: Permission denied - the user is authenticated but does not + have the necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier. + "500": + description: Server error occurred while attempting to retrieve the RBAC + status. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Check if RBAC is enabled + tags: + - kubernetes + /kubernetes/{id}/role_bindings/delete: + post: + consumes: + - application/json + description: |- + Delete the provided list of role bindings. + **Access policy**: Authenticated user. + operationId: DeleteRoleBindings + parameters: + - description: Environment identifier + in: path + name: id + required: true + type: integer + - description: A map where the key is the namespace and the value is an array + of role bindings to delete + in: body + name: payload + required: true + schema: + $ref: '#/definitions/kubernetes.K8sRoleBindingDeleteRequests' + responses: + "204": + description: Success + "400": + description: Invalid request payload, such as missing required fields or + fields not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does + not have the necessary permissions. Ensure that you have provided a valid + API key or JWT token, and that you have the required permissions. + "403": + description: Permission denied - the user is authenticated but does not + have the necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier + or unable to find a specific role binding. + "500": + description: Server error occurred while attempting to delete role bindings. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Delete role bindings + tags: + - kubernetes + /kubernetes/{id}/rolebindings: + get: + description: |- + Get a list of kubernetes role bindings that the user has access to. + **Access policy**: Authenticated user. + operationId: GetKubernetesRoleBindings + parameters: + - description: Environment identifier + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: Success + schema: + items: + $ref: '#/definitions/kubernetes.K8sRoleBinding' + type: array + "400": + description: Invalid request payload, such as missing required fields or + fields not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does + not have the necessary permissions. Ensure that you have provided a valid + API key or JWT token, and that you have the required permissions. + "403": + description: Permission denied - the user is authenticated but does not + have the necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier. + "500": + description: Server error occurred while attempting to retrieve the list + of role bindings. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get a list of kubernetes role bindings + tags: + - kubernetes + /kubernetes/{id}/roles: + get: + description: |- + Get a list of kubernetes roles that the user has access to. + **Access policy**: Authenticated user. + operationId: GetKubernetesRoles + parameters: + - description: Environment identifier + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: Success + schema: + items: + $ref: '#/definitions/kubernetes.K8sRole' + type: array + "400": + description: Invalid request payload, such as missing required fields or + fields not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does + not have the necessary permissions. Ensure that you have provided a valid + API key or JWT token, and that you have the required permissions. + "403": + description: Permission denied - the user is authenticated but does not + have the necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier. + "500": + description: Server error occurred while attempting to retrieve the list + of roles. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get a list of kubernetes roles + tags: + - kubernetes + /kubernetes/{id}/roles/delete: + post: + consumes: + - application/json + description: |- + Delete the provided list of roles. + **Access policy**: Authenticated user. + operationId: DeleteRoles + parameters: + - description: Environment identifier + in: path + name: id + required: true + type: integer + - description: A map where the key is the namespace and the value is an array + of roles to delete + in: body + name: payload + required: true + schema: + $ref: '#/definitions/kubernetes.K8sRoleDeleteRequests' + responses: + "204": + description: Success + "400": + description: Invalid request payload, such as missing required fields or + fields not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does + not have the necessary permissions. Ensure that you have provided a valid + API key or JWT token, and that you have the required permissions. + "403": + description: Permission denied - the user is authenticated but does not + have the necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier + or unable to find a specific role. + "500": + description: Server error occurred while attempting to delete roles. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Delete roles + tags: + - kubernetes + /kubernetes/{id}/secrets: + get: + description: |- + Get a list of Secrets for a given namespace. If isUsed is set to true, information about the applications that use the secrets is also returned. + **Access policy**: Authenticated user. + operationId: GetKubernetesSecrets + parameters: + - description: Environment identifier + in: path + name: id + required: true + type: integer + - description: When set to true, associate the Secrets with the applications + that use them + in: query + name: isUsed + required: true + type: boolean + produces: + - application/json + responses: + "200": + description: Success + schema: + items: + $ref: '#/definitions/kubernetes.K8sSecret' + type: array + "400": + description: Invalid request payload, such as missing required fields or + fields not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does + not have the necessary permissions. Ensure that you have provided a valid + API key or JWT token, and that you have the required permissions. + "403": + description: Permission denied - the user is authenticated but does not + have the necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier. + "500": + description: Server error occurred while attempting to retrieve all secrets + from the cluster. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get a list of Secrets + tags: + - kubernetes + /kubernetes/{id}/secrets/count: + get: + description: |- + Get the count of Secrets across all namespaces that the user has access to. + **Access policy**: Authenticated user. + operationId: GetKubernetesSecretsCount + parameters: + - description: Environment identifier + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: Success + schema: + type: integer + "400": + description: Invalid request payload, such as missing required fields or + fields not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does + not have the necessary permissions. Ensure that you have provided a valid + API key or JWT token, and that you have the required permissions. + "403": + description: Permission denied - the user is authenticated but does not + have the necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier. + "500": + description: Server error occurred while attempting to retrieve the count + of all secrets from the cluster. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get Secrets count + tags: + - kubernetes + /kubernetes/{id}/service_accounts/delete: + post: + consumes: + - application/json + description: |- + Delete the provided list of service accounts. + **Access policy**: Authenticated user. + operationId: DeleteServiceAccounts + parameters: + - description: Environment identifier + in: path + name: id + required: true + type: integer + - description: A map where the key is the namespace and the value is an array + of service accounts to delete + in: body + name: payload + required: true + schema: + $ref: '#/definitions/kubernetes.K8sServiceAccountDeleteRequests' + responses: + "204": + description: Success + "400": + description: Invalid request payload, such as missing required fields or + fields not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does + not have the necessary permissions. Ensure that you have provided a valid + API key or JWT token, and that you have the required permissions. + "403": + description: Permission denied - the user is authenticated but does not + have the necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier + or unable to find a specific service account. + "500": + description: Server error occurred while attempting to delete service accounts. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Delete service accounts + tags: + - kubernetes + /kubernetes/{id}/serviceaccounts: + get: + description: |- + Get a list of kubernetes service accounts that the user has access to. + **Access policy**: Authenticated user. + operationId: GetKubernetesServiceAccounts + parameters: + - description: Environment identifier + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: Success + schema: + items: + $ref: '#/definitions/kubernetes.K8sServiceAccount' + type: array + "400": + description: Invalid request payload, such as missing required fields or + fields not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does + not have the necessary permissions. Ensure that you have provided a valid + API key or JWT token, and that you have the required permissions. + "403": + description: Permission denied - the user is authenticated but does not + have the necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier. + "500": + description: Server error occurred while attempting to retrieve the list + of service accounts. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get a list of kubernetes service accounts + tags: + - kubernetes + /kubernetes/{id}/services: + get: + description: |- + Get a list of services that the user has access to. + **Access policy**: Authenticated user. + operationId: GetKubernetesServices + parameters: + - description: Environment identifier + in: path + name: id + required: true + type: integer + - description: Lookup applications associated with each service + in: query + name: withApplications + type: boolean + produces: + - application/json + responses: + "200": + description: Success + schema: + items: + $ref: '#/definitions/kubernetes.K8sServiceInfo' + type: array + "400": + description: Invalid request payload, such as missing required fields or + fields not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does + not have the necessary permissions. Ensure that you have provided a valid + API key or JWT token, and that you have the required permissions. + "403": + description: Permission denied - the user is authenticated but does not + have the necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier. + "500": + description: Server error occurred while attempting to retrieve all services. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get a list of services + tags: + - kubernetes + /kubernetes/{id}/services/count: + get: + description: |- + Get the count of services that the user has access to. + **Access policy**: Authenticated user. + operationId: GetAllKubernetesServicesCount + parameters: + - description: Environment identifier + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: Success + schema: + type: integer + "400": + description: Invalid request payload, such as missing required fields or + fields not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does + not have the necessary permissions. Ensure that you have provided a valid + API key or JWT token, and that you have the required permissions. + "403": + description: Permission denied - the user is authenticated but does not + have the necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier. + "500": + description: Server error occurred while attempting to retrieve the total + count of all services. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get services count + tags: + - kubernetes + /kubernetes/{id}/services/delete: + post: + consumes: + - application/json + description: |- + Delete the provided list of services. + **Access policy**: Authenticated user. + operationId: DeleteKubernetesServices + parameters: + - description: Environment identifier + in: path + name: id + required: true + type: integer + - description: A map where the key is the namespace and the value is an array + of services to delete + in: body + name: body + required: true + schema: + $ref: '#/definitions/kubernetes.K8sServiceDeleteRequests' + produces: + - application/json + responses: + "204": + description: Success + "400": + description: Invalid request payload, such as missing required fields or + fields not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does + not have the necessary permissions. Ensure that you have provided a valid + API key or JWT token, and that you have the required permissions. + "403": + description: Permission denied - the user is authenticated but does not + have the necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier + or unable to find a specific service. + "500": + description: Server error occurred while attempting to delete services. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Delete services + tags: + - kubernetes + /kubernetes/{id}/storage_classes: + get: + description: |- + Get a list of all StorageClasses in the given environment. + **Access policy**: Authenticated user. + operationId: GetAllKubernetesStorageClasses + parameters: + - description: Environment identifier + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: Success + schema: + items: + $ref: '#/definitions/kubernetes.K8sStorageClass' + type: array + "400": + description: Invalid request payload. + "403": + description: Unauthorized access or operation not allowed. + "500": + description: Server error occurred while attempting to retrieve storage + classes. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get all StorageClasses + tags: + - kubernetes + /kubernetes/{id}/storage_classes/{name}: + get: + description: |- + Get a StorageClass by name in the given environment. + **Access policy**: Authenticated user. + operationId: GetKubernetesStorageClass + parameters: + - description: Environment identifier + in: path + name: id + required: true + type: integer + - description: StorageClass name + in: path + name: name + required: true + type: string + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/kubernetes.K8sStorageClass' + "400": + description: Invalid request payload. + "403": + description: Unauthorized access or operation not allowed. + "404": + description: StorageClass not found. + "500": + description: Server error occurred while attempting to retrieve the storage + class. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get a specific StorageClass + tags: + - kubernetes + /kubernetes/{id}/storage_classes/{name}/default: + put: + consumes: + - application/json + description: |- + Set the specified StorageClass as the cluster default, removing default from any other. + **Access policy**: Authenticated user. + operationId: SetDefaultKubernetesStorageClass + parameters: + - description: Environment identifier + in: path + name: id + required: true + type: integer + - description: StorageClass name + in: path + name: name + required: true + type: string + produces: + - application/json + responses: + "204": + description: Success + "400": + description: Invalid request payload. + "403": + description: Unauthorized access or operation not allowed. + "500": + description: Server error occurred while attempting to set default storage + class. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Set a StorageClass as default + tags: + - kubernetes + /kubernetes/{id}/storage_classes/delete: + post: + consumes: + - application/json + description: |- + Delete the provided list of StorageClasses. + **Access policy**: Authenticated user. + operationId: DeleteKubernetesStorageClasses + parameters: + - description: Environment identifier + in: path + name: id + required: true + type: integer + - description: List of StorageClass names to delete + in: body + name: body + required: true + schema: + items: + type: string + type: array + produces: + - application/json + responses: + "204": + description: Success + "400": + description: Invalid request payload. + "403": + description: Unauthorized access or operation not allowed. + "500": + description: Server error occurred while attempting to delete storage classes. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Delete StorageClasses + tags: + - kubernetes + /kubernetes/{id}/version: + get: + description: |- + Get the Kubernetes cluster version (major, minor, gitVersion, ...) + as reported by the cluster's discovery API, augmented with capability + flags Portainer uses to gate UI features (e.g. supportsPodRestart). + **Access policy**: Authenticated user. + operationId: GetKubernetesVersion + parameters: + - description: Environment(Endpoint) identifier + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/kubernetes.kubernetesVersionResponse' + "401": + description: Unauthorized access - the user is not authenticated or does + not have the necessary permissions. + "403": + description: Permission denied - the user is authenticated but does not + have the necessary permissions to access the requested resource or perform + the specified operation. + "404": + description: Unable to find an environment with the specified identifier. + "500": + description: Server error occurred while attempting to retrieve the cluster + version. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get the Kubernetes cluster version and Portainer-relevant capabilities + tags: + - kubernetes + /kubernetes/{id}/volumes: + get: + description: |- + Get a list of all kubernetes volumes within the given environment (Endpoint). The Endpoint ID must be a valid Portainer environment identifier. + **Access policy**: Authenticated user. + operationId: GetAllKubernetesVolumes + parameters: + - description: Environment identifier + in: path + name: id + required: true + type: integer + - description: When set to True, include the applications that are using the + volumes. It is set to false by default + in: query + name: withApplications + type: boolean + produces: + - application/json + responses: + "200": + description: Success + schema: + additionalProperties: + $ref: '#/definitions/kubernetes.K8sVolumeInfo' + type: object + "400": + description: Invalid request payload, such as missing required fields or + fields not meeting validation criteria. + "403": + description: Unauthorized access or operation not allowed. + "500": + description: Server error occurred while attempting to retrieve kubernetes + volumes. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get Kubernetes volumes within the given Portainer environment + tags: + - kubernetes + /kubernetes/{id}/volumes/{namespace}/{volume}: + get: + description: |- + Get a Kubernetes volume within the given environment (Endpoint). The Endpoint ID must be a valid Portainer environment identifier. + **Access policy**: Authenticated user. + operationId: GetKubernetesVolume + parameters: + - description: Environment identifier + in: path + name: id + required: true + type: integer + - description: Namespace identifier + in: path + name: namespace + required: true + type: string + - description: Volume name + in: path + name: volume + required: true + type: string + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/kubernetes.K8sVolumeInfo' + "400": + description: Invalid request + "500": + description: Server error + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get a Kubernetes volume within the given Portainer environment + tags: + - kubernetes + /kubernetes/{id}/volumes/count: + get: + description: |- + Get the total number of kubernetes volumes within the given environment (Endpoint). The total count depends on the user's role and permissions. The Endpoint ID must be a valid Portainer environment identifier. + **Access policy**: Authenticated user. + operationId: getAllKubernetesVolumesCount + parameters: + - description: Environment identifier + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: Success + schema: + type: integer + "400": + description: Invalid request payload, such as missing required fields or + fields not meeting validation criteria. + "403": + description: Unauthorized access or operation not allowed. + "500": + description: Server error occurred while attempting to retrieve kubernetes + volumes count. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Get the total number of kubernetes volumes within the given Portainer + environment. + tags: + - kubernetes + /kubernetes/config: + get: + description: |- + Generate a kubeconfig file that allows a client to communicate with the Kubernetes API server + **Access policy**: Authenticated user. + operationId: GetKubernetesConfig + parameters: + - collectionFormat: csv + description: will include only these environments(endpoints) + in: query + items: + type: integer + name: ids + type: array + - collectionFormat: csv + description: will exclude these environments(endpoints) + in: query + items: + type: integer + name: excludeIds + type: array + produces: + - application/json + - ' application/yaml' + responses: + "200": + description: Success + schema: {} + "400": + description: Invalid request payload, such as missing required fields or + fields not meeting validation criteria. + "401": + description: Unauthorized access - the user is not authenticated or does + not have the necessary permissions. Ensure that you have provided a valid + API key or JWT token, and that you have the required permissions. + "403": + description: Permission denied - the user is authenticated but does not + have the necessary permissions to access the requested resource or perform + the specified operation. Check your user roles and permissions. + "404": + description: Unable to find an environment with the specified identifier. + "500": + description: Server error occurred while attempting to generate the kubeconfig + file. + security: + - ApiKeyAuth: [] + jwt: [] + summary: Generate a kubeconfig file + tags: + - kubernetes + /ldap/check: + post: + consumes: + - application/json + description: |- + Test LDAP connectivity using LDAP details + **Access policy**: administrator + operationId: LDAPCheck + parameters: + - description: details + in: body + name: body + required: true + schema: + $ref: '#/definitions/ldap.checkPayload' + responses: + "204": + description: Success + "400": + description: Invalid request + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Test LDAP connectivity + tags: + - ldap + /motd: + get: + description: '**Access policy**: restricted' + operationId: MOTD + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/motd.Motd' + security: + - ApiKeyAuth: [] + - jwt: [] + summary: fetches the message of the day + tags: + - motd + /registries: + get: + 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 + produces: + - application/json + responses: + "200": + description: Success + schema: + items: + $ref: '#/definitions/portainer.Registry' + type: array + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: List Registries + tags: + - registries + post: + consumes: + - application/json + description: |- + Create a new registry. + **Access policy**: restricted + operationId: RegistryCreate + parameters: + - description: Registry details + in: body + name: body + required: true + schema: + $ref: '#/definitions/registries.registryCreatePayload' + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/portainer.Registry' + "400": + description: Invalid request + "409": + description: Another registry with the same name or same URL & credentials + already exists + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Create a new registry + tags: + - registries + /registries/{id}: + delete: + description: |- + Remove a registry + **Access policy**: restricted + operationId: RegistryDelete + parameters: + - description: Registry identifier + in: path + name: id + required: true + type: integer + responses: + "204": + description: Success + "400": + description: Invalid request + "404": + description: Registry not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Remove a registry + tags: + - registries + get: + description: |- + Retrieve details about a registry. + **Access policy**: restricted + operationId: RegistryInspect + parameters: + - description: Registry identifier + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/portainer.Registry' + "400": + description: Invalid request + "403": + description: Permission denied to access registry + "404": + description: Registry not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Inspect a registry + tags: + - registries + put: + consumes: + - application/json + description: |- + Update a registry + **Access policy**: restricted + operationId: RegistryUpdate + parameters: + - description: Registry identifier + in: path + name: id + required: true + type: integer + - description: Registry details + in: body + name: body + required: true + schema: + $ref: '#/definitions/registries.registryUpdatePayload' + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/portainer.Registry' + "400": + description: Invalid request + "404": + description: Registry not found + "409": + description: Another registry with the same name or same URL & credentials + already exists + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Update a registry + tags: + - registries + /registries/{id}/configure: + post: + consumes: + - application/json + description: |- + Configures a registry. + **Access policy**: restricted + operationId: RegistryConfigure + parameters: + - description: Registry identifier + in: path + name: id + required: true + type: integer + - description: Registry configuration + in: body + name: body + required: true + schema: + $ref: '#/definitions/registries.registryConfigurePayload' + produces: + - application/json + responses: + "204": + description: Success + "400": + description: Invalid request + "403": + description: Permission denied + "404": + description: Registry not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Configures a registry + tags: + - registries + /registries/ping: + post: + consumes: + - application/json + description: |- + Test connection to a registry with provided credentials + **Access policy**: authenticated + operationId: RegistryPing + parameters: + - description: Registry credentials to test + in: body + name: body + required: true + schema: + $ref: '#/definitions/registries.registryPingPayload' + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/registries.registryPingResponse' + "400": + description: Invalid request + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Test registry connection + tags: + - registries + /resource_controls: + post: + consumes: + - application/json + description: |- + Create a new resource control to restrict access to a Docker resource. + **Access policy**: administrator + operationId: ResourceControlCreate + parameters: + - description: Resource control details + in: body + name: body + required: true + schema: + $ref: '#/definitions/resourcecontrols.resourceControlCreatePayload' + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/portainer.ResourceControl' + "400": + description: Invalid request + "409": + description: A resource control is already associated to this resource + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Create a new resource control + tags: + - resource_controls + /resource_controls/{id}: + delete: + description: |- + Remove a resource control. + **Access policy**: administrator + operationId: ResourceControlDelete + parameters: + - description: Resource control identifier + in: path + name: id + required: true + type: integer + responses: + "204": + description: Success + "400": + description: Invalid request + "404": + description: Resource control not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Remove a resource control + tags: + - resource_controls + put: + consumes: + - application/json + description: |- + Update a resource control + **Access policy**: authenticated + operationId: ResourceControlUpdate + parameters: + - description: Resource control identifier + in: path + name: id + required: true + type: integer + - description: Resource control details + in: body + name: body + required: true + schema: + $ref: '#/definitions/resourcecontrols.resourceControlUpdatePayload' + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/portainer.ResourceControl' + "400": + description: Invalid request + "403": + description: Unauthorized + "404": + description: Resource control not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Update a resource control + tags: + - resource_controls + /restore: + post: + consumes: + - application/json + description: |- + Triggers a system restore using provided backup file + **Access policy**: public (requires the X-Setup-Token header on an uninitialized instance unless --no-setup-token is set) + operationId: Restore + parameters: + - description: Setup token (required when instance is uninitialized and --no-setup-token + is not set) + in: header + name: X-Setup-Token + type: string + - description: Restore request payload + in: body + name: restorePayload + required: true + schema: + $ref: '#/definitions/backup.restorePayload' + responses: + "200": + description: Success + "400": + description: Invalid request + "403": + description: Access denied - invalid or missing setup token + "500": + description: Server error + summary: Triggers a system restore using provided backup file + tags: + - backup + /roles: + get: + description: |- + List all roles available for use + **Access policy**: administrator + operationId: RoleList + produces: + - application/json + responses: + "200": + description: Success + schema: + items: + $ref: '#/definitions/portainer.Role' + type: array + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: List roles + tags: + - roles + /settings: + get: + description: |- + Retrieve Portainer settings. + **Access policy**: administrator + operationId: SettingsInspect + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/portainer.Settings' + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Retrieve Portainer settings + tags: + - settings + put: + consumes: + - application/json + description: |- + Update Portainer settings. + **Access policy**: administrator + operationId: SettingsUpdate + parameters: + - description: New settings + in: body + name: body + required: true + schema: + $ref: '#/definitions/settings.settingsUpdatePayload' + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/portainer.Settings' + "400": + description: Invalid request + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Update Portainer settings + tags: + - settings + /settings/public: + get: + description: |- + Retrieve public settings. Returns a small set of settings that are not reserved to administrators only. + **Access policy**: public + operationId: SettingsPublic + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/settings.publicSettingsResponse' + "500": + description: Server error + summary: Retrieve Portainer public settings + tags: + - settings + /ssl: + get: + description: |- + Retrieve the ssl settings. + **Access policy**: administrator + operationId: SSLInspect + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/portainer.SSLSettings' + "400": + description: Invalid request + "403": + description: Permission denied to access settings + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Inspect the ssl settings + tags: + - ssl + put: + consumes: + - application/json + description: |- + Update the ssl settings. + **Access policy**: administrator + operationId: SSLUpdate + parameters: + - description: SSL Settings + in: body + name: body + required: true + schema: + $ref: '#/definitions/ssl.sslUpdatePayload' + produces: + - application/json + responses: + "204": + description: Success + "400": + description: Invalid request + "403": + description: Permission denied to access settings + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Update the ssl settings + tags: + - ssl + /stacks: + get: + 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. + Limited stacks will not be returned by this endpoint. + **Access policy**: authenticated + operationId: StackList + parameters: + - 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.' + in: query + name: filters + type: string + responses: + "200": + description: Success + schema: + items: + $ref: '#/definitions/portainer.Stack' + type: array + "204": + description: Success + "400": + description: Invalid request + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: List stacks + tags: + - stacks + /stacks/{id}: + delete: + description: |- + Remove a stack. + **Access policy**: restricted + operationId: StackDelete + parameters: + - description: Stack identifier + in: path + name: id + required: true + type: integer + - description: Set to true to delete an external stack. Only external Swarm + stacks are supported + in: query + name: external + type: boolean + - description: Environment identifier + in: query + name: endpointId + required: true + type: integer + responses: + "204": + description: Success + "400": + description: Invalid request + "403": + description: Permission denied + "404": + description: Not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Remove a stack + tags: + - stacks + get: + description: |- + Retrieve details about a stack. + **Access policy**: restricted + operationId: StackInspect + parameters: + - description: Stack identifier + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/stacks.stackResponse' + "400": + description: Invalid request + "403": + description: Permission denied + "404": + description: Stack not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Inspect a stack + tags: + - stacks + put: + consumes: + - application/json + description: |- + Update a stack, only for file based stacks. + **Access policy**: authenticated + operationId: StackUpdate + parameters: + - description: Stack identifier + in: path + name: id + required: true + type: integer + - description: Environment identifier + in: query + name: endpointId + required: true + type: integer + - description: Stack details + in: body + name: body + required: true + schema: + $ref: '#/definitions/stacks.updateSwarmStackPayload' + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/portainer.Stack' + "400": + description: Invalid request + "403": + description: Permission denied + "404": + description: Not found + "409": + description: Conflict + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Update a stack + tags: + - stacks + /stacks/{id}/associate: + put: + description: '**Access policy**: administrator' + operationId: StackAssociate + parameters: + - description: Stack identifier + in: path + name: id + required: true + type: integer + - description: Environment identifier + in: query + name: endpointId + required: true + type: integer + - description: Swarm identifier + in: query + name: swarmId + required: true + type: integer + - description: Indicates whether the stack is orphaned + in: query + name: orphanedRunning + required: true + type: boolean + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/portainer.Stack' + "400": + description: Invalid request + "403": + description: Permission denied + "404": + description: Stack not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Associate an orphaned stack to a new environment(endpoint) + tags: + - stacks + /stacks/{id}/file: + get: + description: |- + Get Stack file content. + **Access policy**: restricted + operationId: StackFileInspect + parameters: + - description: Stack identifier + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/stacks.stackFileResponse' + "400": + description: Invalid request + "403": + description: Permission denied + "404": + description: Stack not found + "409": + description: Git settings changed, redeploy required + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Retrieve the content of the Stack file for the specified stack + tags: + - stacks + /stacks/{id}/git: + post: + consumes: + - application/json + description: |- + Update the Git settings in a stack, e.g., RepositoryReferenceName and AutoUpdate. When SourceID is set, URL/auth/TLS are taken from the referenced Source. + **Access policy**: authenticated + operationId: StackUpdateGit + parameters: + - description: Stack identifier + in: path + name: id + required: true + type: integer + - description: Stacks created before version 1.18.0 might not have an associated + environment(endpoint) identifier. Use this optional parameter to set the + environment(endpoint) identifier used by the stack. + in: query + name: endpointId + type: integer + - description: Git configs for pull and redeploy a stack + in: body + name: body + required: true + schema: + $ref: '#/definitions/stacks.stackGitUpdatePayload' + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/stacks.stackResponse' + "400": + description: Invalid request + "403": + description: Permission denied + "404": + description: Not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Update a stack's Git configs + tags: + - stacks + /stacks/{id}/git/redeploy: + put: + consumes: + - application/json + description: |- + Pull and redeploy a stack via Git + **Access policy**: authenticated + operationId: StackGitRedeploy + parameters: + - description: Stack identifier + in: path + name: id + required: true + type: integer + - description: Stacks created before version 1.18.0 might not have an associated + environment(endpoint) identifier. Use this optional parameter to set the + environment(endpoint) identifier used by the stack. + in: query + name: endpointId + type: integer + - description: Git configs for pull and redeploy of a stack. **StackName** may + only be populated for Kuberenetes stacks, and if specified with a blank + string, it will be set to blank + in: body + name: body + required: true + schema: + $ref: '#/definitions/stacks.stackGitRedeployPayload' + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/portainer.Stack' + "400": + description: Invalid request + "403": + description: Permission denied + "404": + description: Not found + "409": + description: Conflict + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Redeploy a stack + tags: + - stacks + /stacks/{id}/migrate: + post: + description: |- + Migrate a stack from an environment(endpoint) to another environment(endpoint). It will re-create the stack inside the target environment(endpoint) before removing the original stack. + **Access policy**: authenticated + operationId: StackMigrate + parameters: + - description: Stack identifier + in: path + name: id + required: true + type: integer + - description: Stacks created before version 1.18.0 might not have an associated + environment(endpoint) identifier. Use this optional parameter to set the + environment(endpoint) identifier used by the stack. + in: query + name: endpointId + type: integer + - description: Stack migration details + in: body + name: body + required: true + schema: + $ref: '#/definitions/stacks.stackMigratePayload' + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/portainer.Stack' + "400": + description: Invalid request + "403": + description: Permission denied + "404": + description: Stack not found + "409": + description: A stack with the same name is already running on the target + environment(endpoint) + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Migrate a stack to another environment(endpoint) + tags: + - stacks + /stacks/{id}/start: + post: + description: |- + Starts a stopped Stack. + **Access policy**: authenticated + operationId: StackStart + parameters: + - description: Stack identifier + in: path + name: id + required: true + type: integer + - description: Environment identifier + in: query + name: endpointId + required: true + type: integer + responses: + "200": + description: Success + schema: + $ref: '#/definitions/portainer.Stack' + "400": + description: Invalid request + "403": + description: Permission denied + "404": + description: Not found + "409": + description: Stack is already active, deploying, or in error state + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Starts a stopped Stack + tags: + - stacks + /stacks/{id}/stop: + post: + description: |- + Stop a running Stack. + **Access policy**: authenticated + operationId: StackStop + parameters: + - description: Stack identifier + in: path + name: id + required: true + type: integer + - description: Environment identifier + in: query + name: endpointId + required: true + type: integer + responses: + "200": + description: Success + schema: + $ref: '#/definitions/portainer.Stack' + "400": + description: Invalid request + "403": + description: Permission denied + "404": + description: Not found + "409": + description: Conflict + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Stop a running Stack + tags: + - stacks + /stacks/create/kubernetes/repository: + post: + description: |- + Deploy a new stack into a Docker environment specified via the environment identifier. + **Access policy**: authenticated + operationId: StackCreateKubernetesGit + parameters: + - description: stack config + in: body + name: body + required: true + schema: + $ref: '#/definitions/stacks.kubernetesGitDeploymentPayload' + - description: Identifier of the environment that will be used to deploy the + stack + in: query + name: endpointId + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/portainer.Stack' + "400": + description: Invalid request + "409": + description: Stack name or webhook ID already exists + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Deploy a new kubernetes stack from a git repository + tags: + - stacks + /stacks/create/kubernetes/string: + post: + description: |- + Deploy a new stack into a Docker environment specified via the environment identifier. + **Access policy**: authenticated + operationId: StackCreateKubernetesFile + parameters: + - description: stack config + in: body + name: body + required: true + schema: + $ref: '#/definitions/stacks.kubernetesStringDeploymentPayload' + - description: Identifier of the environment that will be used to deploy the + stack + in: query + name: endpointId + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/portainer.Stack' + "400": + description: Invalid request + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Deploy a new kubernetes stack from a file + tags: + - stacks + /stacks/create/kubernetes/url: + post: + description: |- + Deploy a new stack into a Docker environment specified via the environment identifier. + **Access policy**: authenticated + operationId: StackCreateKubernetesUrl + parameters: + - description: stack config + in: body + name: body + required: true + schema: + $ref: '#/definitions/stacks.kubernetesManifestURLDeploymentPayload' + - description: Identifier of the environment that will be used to deploy the + stack + in: query + name: endpointId + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/portainer.Stack' + "400": + description: Invalid request + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Deploy a new kubernetes stack from a url + tags: + - stacks + /stacks/create/standalone/file: + post: + consumes: + - multipart/form-data + description: |- + Deploy a new stack into a Docker environment specified via the environment identifier. + **Access policy**: authenticated + operationId: StackCreateDockerStandaloneFile + parameters: + - description: Name of the stack + in: formData + name: Name + required: true + type: string + - description: 'Environment variables passed during deployment, represented + as a JSON array [{''name'': ''name'', ''value'': ''value''}].' + in: formData + name: Env + type: string + - description: Stack file + in: formData + name: file + type: file + - description: Identifier of the environment that will be used to deploy the + stack + in: query + name: endpointId + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/portainer.Stack' + "400": + description: Invalid request + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Deploy a new compose stack from a file + tags: + - stacks + /stacks/create/standalone/repository: + post: + consumes: + - application/json + description: |- + Deploy a new stack into a Docker environment specified via the environment identifier. + **Access policy**: authenticated + operationId: StackCreateDockerStandaloneRepository + parameters: + - description: Identifier of the environment that will be used to deploy the + stack + in: query + name: endpointId + required: true + type: integer + - description: stack config + in: body + name: body + required: true + schema: + $ref: '#/definitions/stacks.composeStackFromGitRepositoryPayload' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/portainer.Stack' + "400": + description: Invalid request + "409": + description: Stack name or webhook ID already exists + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Deploy a new compose stack from repository + tags: + - stacks + /stacks/create/standalone/string: + post: + consumes: + - application/json + description: |- + Deploy a new stack into a Docker environment specified via the environment identifier. + **Access policy**: authenticated + operationId: StackCreateDockerStandaloneString + parameters: + - description: stack config + in: body + name: body + required: true + schema: + $ref: '#/definitions/stacks.composeStackFromFileContentPayload' + - description: Identifier of the environment that will be used to deploy the + stack + in: query + name: endpointId + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/portainer.Stack' + "400": + description: Invalid request + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Deploy a new compose stack from a text + tags: + - stacks + /stacks/create/swarm/file: + post: + consumes: + - multipart/form-data + description: |- + Deploy a new stack into a Docker environment specified via the environment identifier. + **Access policy**: authenticated + operationId: StackCreateDockerSwarmFile + parameters: + - description: Name of the stack + in: formData + name: Name + type: string + - description: Swarm cluster identifier. + in: formData + name: SwarmID + type: string + - description: 'Environment variables passed during deployment, represented + as a JSON array [{''name'': ''name'', ''value'': ''value''}]. Optional' + in: formData + name: Env + type: string + - description: Stack file + in: formData + name: file + type: file + - description: Identifier of the environment that will be used to deploy the + stack + in: query + name: endpointId + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/portainer.Stack' + "400": + description: Invalid request + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Deploy a new swarm stack from a file + tags: + - stacks + /stacks/create/swarm/repository: + post: + consumes: + - application/json + description: |- + Deploy a new stack into a Docker environment specified via the environment identifier. + **Access policy**: authenticated + operationId: StackCreateDockerSwarmRepository + parameters: + - description: Identifier of the environment that will be used to deploy the + stack + in: query + name: endpointId + required: true + type: integer + - description: stack config + in: body + name: body + required: true + schema: + $ref: '#/definitions/stacks.swarmStackFromGitRepositoryPayload' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/portainer.Stack' + "400": + description: Invalid request + "409": + description: Stack name or webhook ID already exists + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Deploy a new swarm stack from a git repository + tags: + - stacks + /stacks/create/swarm/string: + post: + consumes: + - application/json + description: |- + Deploy a new stack into a Docker environment specified via the environment identifier. + **Access policy**: authenticated + operationId: StackCreateDockerSwarmString + parameters: + - description: stack config + in: body + name: body + required: true + schema: + $ref: '#/definitions/stacks.swarmStackFromFileContentPayload' + - description: Identifier of the environment that will be used to deploy the + stack + in: query + name: endpointId + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/portainer.Stack' + "400": + description: Invalid request + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Deploy a new swarm stack from a text + tags: + - stacks + /stacks/name/{name}: + delete: + description: |- + Remove a stack. + **Access policy**: restricted + operationId: StackDeleteKubernetesByName + parameters: + - description: Stack name + in: path + name: name + required: true + type: string + - description: Set to true to delete an external stack. Only external Swarm + stacks are supported + in: query + name: external + type: boolean + - description: Environment identifier + in: query + name: endpointId + required: true + type: integer + responses: + "204": + description: Success + "400": + description: Invalid request + "403": + description: Permission denied + "404": + description: Not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Remove Kubernetes stacks by name + tags: + - stacks + /stacks/webhooks/{webhookID}: + post: + description: '**Access policy**: public' + operationId: WebhookInvoke + parameters: + - description: Stack identifier + in: path + name: webhookID + required: true + type: string + responses: + "200": + description: Success + "400": + description: Invalid request + "409": + description: Autoupdate for the stack isn't available" or "Stack deployment + is already in progress + "500": + description: Server error + summary: Webhook for triggering stack updates from git + tags: + - stacks + /status: + get: + deprecated: true + description: |- + Deprecated: use the `/system/status` endpoint instead. + Retrieve Portainer status + **Access policy**: public + operationId: StatusInspect + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/system.status' + summary: Check Portainer status + tags: + - status + /system/info: + get: + description: '**Access policy**: authenticated' + operationId: systemInfo + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/system.systemInfoResponse' + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Retrieve system info + tags: + - system + /system/nodes: + get: + description: '**Access policy**: authenticated' + operationId: systemNodesCount + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/system.nodesCountResponse' + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Retrieve the count of nodes + tags: + - system + /system/status: + get: + description: |- + Retrieve Portainer status + **Access policy**: public + operationId: systemStatus + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/system.status' + summary: Check Portainer status + tags: + - system + /system/upgrade: + post: + description: |- + Upgrade Portainer to BE + **Access policy**: administrator + operationId: systemUpgrade + produces: + - application/json + responses: + "204": + description: Success + schema: + $ref: '#/definitions/system.status' + summary: Upgrade Portainer to BE + tags: + - system + /system/version: + get: + description: |- + Check if portainer has an update available + **Access policy**: authenticated + operationId: systemVersion + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/system.versionResponse' + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Check for portainer updates + tags: + - system + /tags: + get: + description: |- + List tags. + **Access policy**: authenticated + operationId: TagList + produces: + - application/json + responses: + "200": + description: Success + schema: + items: + $ref: '#/definitions/portainer.Tag' + type: array + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: List tags + tags: + - tags + post: + consumes: + - application/json + description: |- + Create a new tag. + **Access policy**: administrator + operationId: TagCreate + parameters: + - description: Tag details + in: body + name: body + required: true + schema: + $ref: '#/definitions/tags.tagCreatePayload' + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/portainer.Tag' + "409": + description: This name is already associated to a tag + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Create a new tag + tags: + - tags + /tags/{id}: + delete: + description: |- + Remove a tag. + **Access policy**: administrator + operationId: TagDelete + parameters: + - description: Tag identifier + in: path + name: id + required: true + type: integer + responses: + "204": + description: Success + "400": + description: Invalid request + "403": + description: Permission denied + "404": + description: Tag not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Remove a tag + tags: + - tags + /team_memberships: + get: + description: |- + List team memberships. Access is only available to administrators and team leaders. + **Access policy**: administrator + operationId: TeamMembershipList + produces: + - application/json + responses: + "200": + description: Success + schema: + items: + $ref: '#/definitions/portainer.TeamMembership' + type: array + "400": + description: Invalid request + "403": + description: Permission denied + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: List team memberships + tags: + - team_memberships + post: + consumes: + - application/json + description: |- + Create a new team memberships. Access is only available to administrators leaders of the associated team. + **Access policy**: administrator + operationId: TeamMembershipCreate + parameters: + - description: Team membership details + in: body + name: body + required: true + schema: + $ref: '#/definitions/teammemberships.teamMembershipCreatePayload' + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/portainer.TeamMembership' + "400": + description: Invalid request + "403": + description: Permission denied to manage memberships + "409": + description: Team membership already registered + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Create a new team membership + tags: + - team_memberships + /team_memberships/{id}: + delete: + description: |- + Remove a team membership. Access is only available to administrators leaders of the associated team. + **Access policy**: administrator + operationId: TeamMembershipDelete + parameters: + - description: TeamMembership identifier + in: path + name: id + required: true + type: integer + responses: + "204": + description: Success + "400": + description: Invalid request + "403": + description: Permission denied + "404": + description: TeamMembership not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Remove a team membership + tags: + - team_memberships + put: + consumes: + - application/json + description: |- + Update a team membership. Access is only available to administrators or leaders of the associated team. + **Access policy**: administrator or leaders of the associated team + operationId: TeamMembershipUpdate + parameters: + - description: Team membership identifier + in: path + name: id + required: true + type: integer + - description: Team membership details + in: body + name: body + required: true + schema: + $ref: '#/definitions/teammemberships.teamMembershipUpdatePayload' + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/portainer.TeamMembership' + "400": + description: Invalid request + "403": + description: Permission denied + "404": + description: TeamMembership not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Update a team membership + tags: + - team_memberships + /teams: + get: + description: |- + List teams. For non-administrator users, will only list the teams they are member of. + **Access policy**: restricted + operationId: TeamList + parameters: + - description: Only list teams that the user is leader of + in: query + name: onlyLedTeams + type: boolean + - description: Identifier of the environment(endpoint) that will be used to + filter the authorized teams + in: query + name: environmentId + type: integer + produces: + - application/json + responses: + "200": + description: Success + schema: + items: + $ref: '#/definitions/portainer.Team' + type: array + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: List teams + tags: + - teams + post: + consumes: + - application/json + description: |- + Create a new team. + **Access policy**: administrator + operationId: TeamCreate + parameters: + - description: details + in: body + name: body + required: true + schema: + $ref: '#/definitions/teams.teamCreatePayload' + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/portainer.Team' + "400": + description: Invalid request + "409": + description: A team with the same name already exists + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Create a new team + tags: + - teams + /teams/{id}: + delete: + description: |- + Remove a team. + **Access policy**: administrator + operationId: TeamDelete + parameters: + - description: Team Id + in: path + name: id + required: true + type: integer + responses: + "204": + description: Success + "400": + description: Invalid request + "403": + description: Permission denied + "404": + description: Team not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Remove a team + tags: + - teams + get: + description: |- + Retrieve details about a team. Access is only available for administrator and leaders of that team. + **Access policy**: administrator + operationId: TeamInspect + parameters: + - description: Team identifier + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/portainer.Team' + "400": + description: Invalid request + "403": + description: Permission denied + "404": + description: Team not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Inspect a team + tags: + - teams + put: + consumes: + - application/json + description: |- + Update a team. + **Access policy**: administrator + operationId: TeamUpdate + parameters: + - description: Team identifier + in: path + name: id + required: true + type: integer + - description: Team details + in: body + name: body + required: true + schema: + $ref: '#/definitions/teams.teamUpdatePayload' + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/portainer.Team' + "400": + description: Invalid request + "403": + description: Permission denied + "404": + description: Team not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Update a team + tags: + - teams + /teams/{id}/memberships: + get: + description: |- + List team memberships. Access is only available to administrators and team leaders. + **Access policy**: restricted + operationId: TeamMemberships + parameters: + - description: Team Id + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: Success + schema: + items: + $ref: '#/definitions/portainer.TeamMembership' + type: array + "400": + description: Invalid request + "403": + description: Permission denied + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: List team memberships + tags: + - team_memberships + /templates: + get: + description: |- + List available templates. + **Access policy**: authenticated + operationId: TemplateList + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/templates.listResponse' + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: List available templates + tags: + - templates + /templates/{id}/file: + post: + consumes: + - application/json + description: |- + Get a template's file + **Access policy**: authenticated + operationId: TemplateFile + parameters: + - description: Template identifier + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/templates.fileResponse' + "400": + description: Invalid request + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Get a template's file + tags: + - templates + /templates/helm: + get: + description: '**Access policy**: authenticated' + operationId: HelmRepoSearch + parameters: + - description: Helm repository URL + in: query + name: repo + required: true + type: string + - description: Helm chart name + in: query + name: chart + type: string + - description: If true will use cache to search + in: query + name: useCache + type: string + produces: + - application/json + responses: + "200": + description: Success + schema: + type: string + "400": + description: Bad request + "401": + description: Unauthorized + "404": + description: Not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Search Helm Charts + tags: + - helm + /templates/helm/{command}: + get: + consumes: + - application/json + description: '**Access policy**: authenticated' + operationId: HelmShow + parameters: + - description: Helm repository URL + in: query + name: repo + required: true + type: string + - description: Chart name + in: query + name: chart + required: true + type: string + - description: Chart version + in: query + name: version + type: string + - description: chart/values/readme + in: path + name: command + required: true + type: string + produces: + - text/plain + responses: + "200": + description: Success + schema: + type: string + "401": + description: Unauthorized + "404": + description: Environment(Endpoint) or ServiceAccount not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Show Helm Chart Information + tags: + - helm + /upload/tls/{certificate}: + post: + consumes: + - multipart/form-data + description: |- + Use this environment(endpoint) to upload TLS files. + **Access policy**: administrator + operationId: UploadTLS + parameters: + - description: TLS file type. Valid values are 'ca', 'cert' or 'key'. + enum: + - ca + - cert + - key + in: path + name: certificate + required: true + type: string + - description: Folder where the TLS file will be stored. Will be created if + not existing + in: formData + name: folder + required: true + type: string + - description: The file to upload + in: formData + name: file + required: true + type: file + produces: + - application/json + responses: + "204": + description: Success + "400": + description: Invalid request + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Upload TLS files + tags: + - upload + /users: + get: + description: |- + List Portainer users. + Non-administrator users will only be able to list other non-administrator user accounts. + User passwords are filtered out, and should never be accessible. + **Access policy**: restricted + operationId: UserList + parameters: + - description: Identifier of the environment(endpoint) that will be used to + filter the authorized users + in: query + name: environmentId + type: integer + produces: + - application/json + responses: + "200": + description: Success + schema: + items: + $ref: '#/definitions/portainer.User' + type: array + "400": + description: Invalid request + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: List users + tags: + - users + post: + consumes: + - application/json + description: |- + Create a new Portainer user. + Only administrators can create users. + **Access policy**: restricted + operationId: UserCreate + parameters: + - description: User details + in: body + name: body + required: true + schema: + $ref: '#/definitions/users.userCreatePayload' + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/portainer.User' + "400": + description: Invalid request + "403": + description: Permission denied + "409": + description: User already exists + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Create a new user + tags: + - users + /users/{id}: + delete: + description: |- + Remove a user. + **Access policy**: administrator + operationId: UserDelete + parameters: + - description: User identifier + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "204": + description: Success + "400": + description: Invalid request + "403": + description: Permission denied + "404": + description: User not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Remove a user + tags: + - users + get: + description: |- + Retrieve details about a user. + User passwords are filtered out, and should never be accessible. + **Access policy**: authenticated + operationId: UserInspect + parameters: + - description: User identifier + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/portainer.User' + "400": + description: Invalid request + "403": + description: Permission denied + "404": + description: User not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Inspect a user + tags: + - users + put: + consumes: + - application/json + description: |- + Update user details. A regular user account can only update his details. + A regular user account cannot change their username or role. + **Access policy**: authenticated + operationId: UserUpdate + parameters: + - description: User identifier + in: path + name: id + required: true + type: integer + - description: User details + in: body + name: body + required: true + schema: + $ref: '#/definitions/users.userUpdatePayload' + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/portainer.User' + "400": + description: Invalid request + "403": + description: Permission denied + "404": + description: User not found + "409": + description: Username already exist + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Update a user + tags: + - users + /users/{id}/effective-access: + get: + description: |- + Returns the resolved role for each environment the user can access, + following the policy precedence used by the access viewer + (user-endpoint, user-group, team-endpoint, team-group). + Environments where the user has no role are omitted. + **Access policy**: restricted + operationId: UserEffectiveAccessInspect + parameters: + - description: User identifier + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: Success + schema: + items: + $ref: '#/definitions/users.EffectiveAccessEntry' + type: array + "400": + description: Invalid request + "403": + description: Permission denied + "404": + description: User not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + jwt: [] + summary: Inspect a user's effective access on every environment + tags: + - users + /users/{id}/helm/repositories: + get: + description: |- + Inspect a user helm repositories. + **Access policy**: authenticated + operationId: HelmUserRepositoriesList + parameters: + - description: User identifier + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/users.helmUserRepositoryResponse' + "400": + description: Invalid request + "403": + description: Permission denied + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: List a users helm repositories + tags: + - helm + post: + consumes: + - application/json + description: |- + Create a user helm repository. + **Access policy**: authenticated + operationId: HelmUserRepositoryCreate + parameters: + - description: User identifier + in: path + name: id + required: true + type: integer + - description: Helm Repository + in: body + name: payload + required: true + schema: + $ref: '#/definitions/users.addHelmRepoUrlPayload' + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/portainer.HelmUserRepository' + "400": + description: Invalid request + "403": + description: Permission denied + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Create a user helm repository + tags: + - helm + /users/{id}/helm/repositories/{repositoryID}: + delete: + description: '**Access policy**: authenticated' + operationId: HelmUserRepositoryDelete + parameters: + - description: User identifier + in: path + name: id + required: true + type: integer + - description: Repository identifier + in: path + name: repositoryID + required: true + type: integer + produces: + - application/json + responses: + "204": + description: Success + "400": + description: Invalid request + "403": + description: Permission denied + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Delete a users helm repositoryies + tags: + - helm + /users/{id}/memberships: + get: + description: |- + Inspect a user memberships. + **Access policy**: restricted + operationId: UserMembershipsInspect + parameters: + - description: User identifier + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/portainer.TeamMembership' + "400": + description: Invalid request + "403": + description: Permission denied + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Inspect a user memberships + tags: + - users + /users/{id}/passwd: + put: + consumes: + - application/json + description: |- + Update password for the specified user. + **Access policy**: authenticated + operationId: UserUpdatePassword + parameters: + - description: identifier + in: path + name: id + required: true + type: integer + - description: details + in: body + name: body + required: true + schema: + $ref: '#/definitions/users.userUpdatePasswordPayload' + produces: + - application/json + responses: + "204": + description: Success + "400": + description: Invalid request + "403": + description: Permission denied + "404": + description: User not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Update password for a user + tags: + - users + /users/{id}/tokens: + get: + description: |- + Gets all API keys for a user. + Only the calling user or admin can retrieve api-keys. + **Access policy**: authenticated + operationId: UserGetAPIKeys + parameters: + - description: User identifier + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: Success + schema: + items: + $ref: '#/definitions/portainer.APIKey' + type: array + "400": + description: Invalid request + "403": + description: Permission denied + "404": + description: User not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Get all API keys for a user + tags: + - users + post: + consumes: + - application/json + description: |- + Generates an API key for a user. + Only the calling user can generate a token for themselves. + Password is required only for internal authentication. + **Access policy**: restricted + operationId: UserGenerateAPIKey + parameters: + - description: User identifier + in: path + name: id + required: true + type: integer + - description: details + in: body + name: body + required: true + schema: + $ref: '#/definitions/users.userAccessTokenCreatePayload' + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/users.accessTokenResponse' + "400": + description: Invalid request + "401": + description: Unauthorized + "403": + description: Permission denied + "404": + description: User not found + "500": + description: Server error + security: + - jwt: [] + summary: Generate an API key for a user + tags: + - users + /users/{id}/tokens/{keyID}: + delete: + description: |- + Remove an api-key associated to a user.. + Only the calling user or admin can remove api-key. + **Access policy**: authenticated + operationId: UserRemoveAPIKey + parameters: + - description: User identifier + in: path + name: id + required: true + type: integer + - description: Api Key identifier + in: path + name: keyID + required: true + type: integer + responses: + "204": + description: Success + "400": + description: Invalid request + "403": + description: Permission denied + "404": + description: Not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Remove an api-key associated to a user + tags: + - users + /users/admin/check: + get: + description: |- + Check if an administrator account exists in the database. + **Access policy**: public + operationId: UserAdminCheck + responses: + "204": + description: Success + "404": + description: User not found + summary: Check administrator account existence + tags: + - users + /users/admin/init: + post: + consumes: + - application/json + description: |- + Initialize the 'admin' user account. + **Access policy**: public (requires the X-Setup-Token header on an uninitialized instance unless --no-setup-token is set) + operationId: UserAdminInit + parameters: + - description: Setup token (required when instance is uninitialized and --no-setup-token + is not set) + in: header + name: X-Setup-Token + type: string + - description: User details + in: body + name: body + required: true + schema: + $ref: '#/definitions/users.adminInitPayload' + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/portainer.User' + "400": + description: Invalid request + "403": + description: Access denied - invalid or missing setup token + "409": + description: Admin user already initialized + "500": + description: Server error + summary: Initialize administrator account + tags: + - users + /users/me: + get: + description: |- + Retrieve details about the current user. + User passwords are filtered out, and should never be accessible. + **Access policy**: authenticated + operationId: CurrentUserInspect + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/portainer.User' + "400": + description: Invalid request + "403": + description: Permission denied + "404": + description: User not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Inspect the current user user + tags: + - users + /webhooks: + get: + consumes: + - application/json + description: '**Access policy**: authenticated' + parameters: + - description: Filters (json-string) + example: '{"EndpointID":1,"ResourceID":"abc12345-abcd-2345-ab12-58005b4a0260"}' + in: query + name: filters + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/portainer.Webhook' + type: array + "400": + description: Bad Request + "500": + description: Internal Server Error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: List webhooks + tags: + - webhooks + post: + consumes: + - application/json + description: '**Access policy**: authenticated' + parameters: + - description: Webhook data + in: body + name: body + required: true + schema: + $ref: '#/definitions/webhooks.webhookCreatePayload' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/portainer.Webhook' + "400": + description: Invalid request + "409": + description: A webhook for this resource already exists + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Create a webhook + tags: + - webhooks + /webhooks/{id}: + delete: + description: '**Access policy**: authenticated' + parameters: + - description: Webhook id + in: path + name: id + required: true + type: integer + responses: + "202": + description: Webhook deleted + "400": + description: Bad Request + "500": + description: Internal Server Error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Delete a webhook + tags: + - webhooks + post: + description: |- + Acts on a passed in token UUID to restart the docker service + **Access policy**: public + parameters: + - description: Webhook token + in: path + name: id + required: true + type: string + responses: + "202": + description: Webhook executed + "400": + description: Bad Request + "500": + description: Internal Server Error + summary: Execute a webhook + tags: + - webhooks + put: + consumes: + - application/json + description: '**Access policy**: authenticated' + parameters: + - description: Webhook id + in: path + name: id + required: true + type: integer + - description: Webhook data + in: body + name: body + required: true + schema: + $ref: '#/definitions/webhooks.webhookUpdatePayload' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/portainer.Webhook' + "400": + description: Bad Request + "409": + description: Conflict + "500": + description: Internal Server Error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Update a webhook + tags: + - webhooks + /websocket/attach: + get: + consumes: + - application/json + description: |- + If the nodeName query parameter is present, the request will be proxied to the underlying agent environment(endpoint). + If the nodeName query parameter is not specified, the request will be upgraded to the websocket protocol and + an AttachStart operation HTTP request will be created and hijacked. + **Access policy**: authenticated + parameters: + - description: environment(endpoint) ID of the environment(endpoint) where the + resource is located + in: query + name: endpointId + required: true + type: integer + - description: node name + in: query + name: nodeName + type: string + produces: + - application/json + responses: + "200": + description: OK + "400": + description: Bad Request + "403": + description: Forbidden + "404": + description: Not Found + "500": + description: Internal Server Error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Attach a websocket + tags: + - websocket + /websocket/exec: + get: + consumes: + - application/json + description: |- + If the nodeName query parameter is present, the request will be proxied to the underlying agent environment(endpoint). + If the nodeName query parameter is not specified, the request will be upgraded to the websocket protocol and + an ExecStart operation HTTP request will be created and hijacked. + parameters: + - description: environment(endpoint) ID of the environment(endpoint) where the + resource is located + in: query + name: endpointId + required: true + type: integer + - description: node name + in: query + name: nodeName + type: string + produces: + - application/json + responses: + "200": + description: OK + "400": + description: Bad Request + "409": + description: Conflict + "500": + description: Internal Server Error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Execute a websocket + tags: + - websocket + /websocket/kubernetes-shell: + get: + consumes: + - application/json + description: |- + The request will be upgraded to the websocket protocol. The request will proxy input from the client to the pod via long-lived websocket connection. + **Access policy**: authenticated + parameters: + - description: environment(endpoint) ID of the environment(endpoint) where the + resource is located + in: query + name: endpointId + required: true + type: integer + produces: + - application/json + responses: + "200": + description: Success + "400": + description: Invalid request + "403": + description: Permission denied + "404": + description: Environment not found + "500": + description: Server error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Execute a websocket on kubectl shell pod + tags: + - websocket + /websocket/pod: + get: + consumes: + - application/json + description: |- + The request will be upgraded to the websocket protocol. + **Access policy**: authenticated + parameters: + - description: environment(endpoint) ID of the environment(endpoint) where the + resource is located + in: query + name: endpointId + required: true + type: integer + - description: namespace where the container is located + in: query + name: namespace + required: true + type: string + - description: name of the pod containing the container + in: query + name: podName + required: true + type: string + - description: name of the container + in: query + name: containerName + required: true + type: string + - description: command to execute in the container + in: query + name: command + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + "400": + description: Bad Request + "403": + description: Forbidden + "404": + description: Not Found + "500": + description: Internal Server Error + security: + - ApiKeyAuth: [] + - jwt: [] + summary: Execute a websocket on pod + tags: + - websocket +schemes: +- http +- https +securityDefinitions: + ApiKeyAuth: + in: header + name: X-API-KEY + type: apiKey + jwt: + in: header + name: Authorization + type: apiKey +swagger: "2.0" +tags: +- description: Authenticate against Portainer HTTP API + name: auth + x-displayName: Authentication +- description: Manage backups + name: backup + x-displayName: Backup +- description: Manage Custom Templates + name: custom_templates + x-displayName: Custom templates +- description: Manage Docker resources + name: docker + x-displayName: Docker resources +- description: Manage Edge related settings + name: edge + x-displayName: Edge settings +- description: Manage an Edge agent + name: edge_agent + x-displayName: Edge agent +- description: Manage Edge Groups + name: edge_groups + x-displayName: Edge groups +- description: Manage Edge Jobs + name: edge_jobs + x-displayName: Edge jobs +- description: Manage Edge Stacks + name: edge_stacks + x-displayName: Edge stacks +- description: Manage Edge Templates + name: edge_templates + x-displayName: Edge templates +- description: Manage environment groups + name: endpoint_groups + x-displayName: Environment groups +- description: Manage environments + name: endpoints + x-displayName: Environments +- description: Operate git repository + name: gitops + x-displayName: GitOps +- description: Manage Helm charts + name: helm + x-displayName: Helm charts +- description: Manage Kubernetes cluster + name: kubernetes + x-displayName: Kubernetes +- description: Manage LDAP settings + name: ldap + x-displayName: LDAP +- description: Fetch the message of the day + name: motd + x-displayName: Message of the day +- description: Manage Docker registries + name: registries + x-displayName: Registries +- description: Manage access control on Docker resources + name: resource_controls + x-displayName: Resource controls +- description: Manage roles + name: roles + x-displayName: Roles +- description: Manage Portainer settings + name: settings + x-displayName: Portainer settings +- description: Manage ssl settings + name: ssl + x-displayName: SSL +- description: Manage stacks + name: stacks + x-displayName: Stacks +- description: Information about the Portainer instance + name: status + x-displayName: Portainer status +- description: Manage Portainer system + name: system + x-displayName: Portainer system +- description: Manage tags + name: tags + x-displayName: Tags +- description: Manage team memberships + name: team_memberships + x-displayName: Team memberships +- description: Manage teams + name: teams + x-displayName: Teams +- description: Manage App Templates + name: templates + x-displayName: App templates +- description: Upload files + name: upload + x-displayName: Upload files +- description: Manage users + name: users + x-displayName: Users +- description: Manage webhooks + name: webhooks + x-displayName: Webhooks +- description: Create exec sessions using websockets + name: websocket + x-displayName: Websocket +x-tagGroups: +- name: Access Control + tags: + - auth + - roles + - team_memberships + - teams + - users +- name: Administration + tags: + - backup + - ldap + - motd + - settings + - status + - system + - ssl + - upload +- name: Docker + tags: + - templates + - custom_templates + - docker + - registries + - resource_controls + - stacks + - webhooks + - websocket +- name: Edge Compute + tags: + - edge_agent + - edge_groups + - edge_jobs + - edge + - edge_stacks +- name: Environment Management + tags: + - endpoint_groups + - endpoints + - tags +- name: GitOps + tags: + - gitops +- name: Kubernetes + tags: + - helm + - kubernetes diff --git a/api/edge/edge.go b/api/edge/edge.go new file mode 100644 index 0000000..a73d582 --- /dev/null +++ b/api/edge/edge.go @@ -0,0 +1,114 @@ +package edge + +import ( + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/filesystem" +) + +type ( + + // StackPayload represents the payload sent to the agent + StackPayload struct { + // ID of the stack + ID int + // Name of the stack + Name string + + // Content of the stack file (for compatibility to agent version less than 2.19.0) + StackFileContent string + + // Content of stack folder + DirEntries []filesystem.DirEntry + // Name of the stack entry file + EntryFileName string + // Namespace to use for kubernetes stack. Keep empty to use the manifest namespace. + Namespace string + // Version of the stack file + Version int + // RollbackTo specifies the stack file version to rollback to (only support to rollback to the last version currently) + RollbackTo *int + + // RegistryCredentials holds the credentials for a Docker registry. + // Used only for EE + RegistryCredentials []RegistryCredentials + // PrePullImage is a flag indicating if the agent must pull the image before deploying the stack. + // Used only for EE + PrePullImage bool + // RePullImage is a flag indicating if the agent must pull the image if it is already present on the node. + // Used only for EE + RePullImage bool + // RetryDeploy is a flag indicating if the agent must retry to deploy the stack if it fails. + // Used only for EE + RetryDeploy bool + // RetryPeriod specifies the duration, in seconds, for which the agent should continue attempting to deploy the stack after a failure + // Used only for EE + RetryPeriod int + // EdgeUpdateID is the ID of the edge update related to this stack. + // Used only for EE + EdgeUpdateID int + + // Is relative path supported + SupportRelativePath bool + // AlwaysCloneGitRepoForRelativePath is a flag indicating if the agent must always clone the git repository for relative path. + // This field is only valid when SupportRelativePath is true. + // Used only for EE + AlwaysCloneGitRepoForRelativePath bool + + // Whether the edge stack supports per device configs + SupportPerDeviceConfigs bool + + // Mount point for relative path + FilesystemPath string + // Used only for EE + // EnvVars is a list of environment variables to inject into the stack + EnvVars []portainer.Pair + + // ForceUpdate is a flag indicating if the agent must force the update of the stack. + // Used only for EE + ForceUpdate bool + + DeployerOptionsPayload DeployerOptionsPayload + + // Used only for EE async edge agent + // ReadyRePullImage is a flag to indicate whether the auto update is trigger to re-pull image + // Deprecated(2.36): use DeployerOptionsPayload.ForceRecreate instead + ReadyRePullImage bool + + // CreatedBy is the username that created this stack + // Used for adding labels to Kubernetes manifests + CreatedBy string + // CreatedByUserId is the user ID that created this stack + // Used for adding labels to Kubernetes manifests + CreatedByUserId string + + // HelmConfig represents the Helm configuration for an edge stack + HelmConfig portainer.HelmConfig + } + + DeployerOptionsPayload struct { + // Prune is a flag indicating if the agent must prune the containers or not when creating/updating an edge stack + // This flag drives `docker compose up --remove-orphans` and `docker stack up --prune` options + // Used only for EE + Prune bool + // RemoveVolumes is a flag indicating if the agent must remove the named volumes declared + // in the compose file and anonymouse volumes attached to containers + // This flag drives `docker compose down --volumes` option + // Used only for EE + RemoveVolumes bool + + // ForceRecreate is a flag indicating if the agent must force the redeployment of the stack. + // This field is only used when the Force Redeployment is triggered. + // Once the stack is redeployed, this field will be reset to false. + // For standard edge agent, this field is used in agent side + // For async edge agent, this field is used in both agent side and server side. + // This flag drives `docker compose up --force-recreate` option + ForceRecreate bool + } + + // RegistryCredentials holds the credentials for a Docker registry. + RegistryCredentials struct { + ServerURL string + Username string + Secret string + } +) diff --git a/api/exec/common.go b/api/exec/common.go new file mode 100644 index 0000000..593b204 --- /dev/null +++ b/api/exec/common.go @@ -0,0 +1,79 @@ +package exec + +import ( + "fmt" + "regexp" + "strings" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/proxy" + "github.com/portainer/portainer/api/http/proxy/factory" + "github.com/portainer/portainer/api/internal/registryutils" + + "github.com/docker/cli/cli/config/types" + "github.com/rs/zerolog/log" +) + +var stackNameNormalizeRegex = regexp.MustCompile("[^-_a-z0-9]+") + +func normalizeStackName(name string) string { + return stackNameNormalizeRegex.ReplaceAllString(strings.ToLower(name), "") +} + +// fetchEndpointProxy returns the Docker host URL for the given endpoint. +// For remote endpoints it creates a local proxy that handles TLS termination and +// Portainer agent header injection; for local unix/npipe sockets no proxy is needed. +func fetchEndpointProxy(proxyManager *proxy.Manager, endpoint *portainer.Endpoint) (string, *factory.ProxyServer, error) { + if strings.HasPrefix(endpoint.URL, "unix://") || strings.HasPrefix(endpoint.URL, "npipe://") { + return "", nil, nil + } + + proxy, err := proxyManager.CreateAgentProxyServer(endpoint) + if err != nil { + return "", nil, err + } + + return fmt.Sprintf("tcp://127.0.0.1:%d", proxy.Port), proxy, nil +} + +// portainerRegistriesToAuthConfigs converts registries to Docker auth configs. +// Callers must ensure ECR tokens are valid before calling this function (e.g. via +// registryutils.RefreshAndPersistECRTokens with a real DataStoreTx). This function +// intentionally performs no DB writes to avoid write-lock contention when called inside +// an active BoltDB write transaction. +func portainerRegistriesToAuthConfigs(registries []portainer.Registry) []types.AuthConfig { + var authConfigs []types.AuthConfig + + for _, r := range registries { + ac := types.AuthConfig{ + Username: r.Username, + Password: r.Password, + ServerAddress: r.URL, + } + + if r.Authentication { + var err error + + ac.Username, ac.Password, err = getEffectiveRegUsernamePassword(&r) + if err != nil { + continue + } + } + + authConfigs = append(authConfigs, ac) + } + + return authConfigs +} + +func getEffectiveRegUsernamePassword(registry *portainer.Registry) (string, string, error) { + username, password, err := registryutils.GetRegEffectiveCredential(registry) + if err != nil { + log.Warn(). + Err(err). + Str("RegistryName", registry.Name). + Msg("Failed to get effective credential. Skip logging with this registry.") + } + + return username, password, err +} diff --git a/api/exec/compose_stack.go b/api/exec/compose_stack.go new file mode 100644 index 0000000..4b133b3 --- /dev/null +++ b/api/exec/compose_stack.go @@ -0,0 +1,215 @@ +package exec + +import ( + "context" + "fmt" + "io" + "os" + "path" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/proxy" + "github.com/portainer/portainer/api/logs" + "github.com/portainer/portainer/api/stacks/stackutils" + "github.com/portainer/portainer/pkg/libstack" +) + +// ComposeStackManager is a wrapper for docker-compose binary +type ComposeStackManager struct { + deployer libstack.Deployer + proxyManager *proxy.Manager +} + +// NewComposeStackManager returns a Compose stack manager +func NewComposeStackManager(deployer libstack.Deployer, proxyManager *proxy.Manager) *ComposeStackManager { + return &ComposeStackManager{ + deployer: deployer, + proxyManager: proxyManager, + } +} + +// ComposeSyntaxMaxVersion returns the maximum supported version of the docker compose syntax +func (manager *ComposeStackManager) ComposeSyntaxMaxVersion() string { + return portainer.ComposeSyntaxMaxVersion +} + +// Up builds, (re)creates and starts containers in the background. Wraps `docker-compose up -d` command +func (manager *ComposeStackManager) Up(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint, options portainer.ComposeUpOptions) error { + url, proxy, err := fetchEndpointProxy(manager.proxyManager, endpoint) + if err != nil { + return fmt.Errorf("failed to fetch environment proxy: %w", err) + } + + if proxy != nil { + defer proxy.Close() + } + + envFilePath, err := createEnvFile(stack) + if err != nil { + return fmt.Errorf("failed to create env file: %w", err) + } + + filePaths := stackutils.GetStackFilePaths(stack, true) + if err = manager.deployer.Deploy(ctx, filePaths, libstack.DeployOptions{ + Options: libstack.Options{ + WorkingDir: stack.ProjectPath, + EnvFilePath: envFilePath, + Host: url, + ProjectName: stack.Name, + Registries: portainerRegistriesToAuthConfigs(options.Registries), + }, + ForceRecreate: options.ForceRecreate, + AbortOnContainerExit: options.AbortOnContainerExit, + RemoveOrphans: options.Prune, + }); err != nil { + return fmt.Errorf("failed to deploy a stack: %w", err) + } + return nil +} + +// Run runs a one-off command on a service. Wraps `docker-compose run` command +func (manager *ComposeStackManager) Run(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint, serviceName string, options portainer.ComposeRunOptions) error { + url, proxy, err := fetchEndpointProxy(manager.proxyManager, endpoint) + if err != nil { + return fmt.Errorf("failed to fetch environment proxy: %w", err) + } + + if proxy != nil { + defer proxy.Close() + } + + envFilePath, err := createEnvFile(stack) + if err != nil { + return fmt.Errorf("failed to create env file: %w", err) + } + + filePaths := stackutils.GetStackFilePaths(stack, true) + if err = manager.deployer.Run(ctx, filePaths, serviceName, libstack.RunOptions{ + Options: libstack.Options{ + WorkingDir: stack.ProjectPath, + EnvFilePath: envFilePath, + Host: url, + ProjectName: stack.Name, + Registries: portainerRegistriesToAuthConfigs(options.Registries), + }, + Remove: options.Remove, + Args: options.Args, + Detached: options.Detached, + }); err != nil { + return fmt.Errorf("failed to deploy a stack: %w", err) + } + return nil +} + +// Down stops and removes containers, networks, images, and volumes +func (manager *ComposeStackManager) Down(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint) error { + url, proxy, err := fetchEndpointProxy(manager.proxyManager, endpoint) + if err != nil { + return fmt.Errorf("failed to fetch environment proxy: %w", err) + } else if proxy != nil { + defer proxy.Close() + } + + if err = manager.deployer.Remove(ctx, stack.Name, nil, libstack.RemoveOptions{ + Options: libstack.Options{ + WorkingDir: "", + Host: url, + }, + }); err != nil { + return fmt.Errorf("failed to remove a stack: %w", err) + } + return nil +} + +// Pull an image associated with a service defined in a docker-compose.yml or docker-stack.yml file, +// but does not start containers based on those images. +func (manager *ComposeStackManager) Pull(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint, options portainer.ComposeOptions) error { + url, proxy, err := fetchEndpointProxy(manager.proxyManager, endpoint) + if err != nil { + return fmt.Errorf("failed to fetch environment proxy: %w", err) + } else if proxy != nil { + defer proxy.Close() + } + + envFilePath, err := createEnvFile(stack) + if err != nil { + return fmt.Errorf("failed to create env file: %w", err) + } + + filePaths := stackutils.GetStackFilePaths(stack, true) + if err = manager.deployer.Pull(ctx, filePaths, libstack.Options{ + WorkingDir: stack.ProjectPath, + EnvFilePath: envFilePath, + Host: url, + ProjectName: stack.Name, + Registries: portainerRegistriesToAuthConfigs(options.Registries), + }); err != nil { + return fmt.Errorf("failed to pull images of the stack: %w", err) + } + return nil +} + +// NormalizeStackName returns a new stack name with unsupported characters replaced +func (manager *ComposeStackManager) NormalizeStackName(name string) string { + return normalizeStackName(name) +} + +// createEnvFile creates a file that would hold both "in-place" and default environment variables. +// It will return the name of the file if the stack has "in-place" env vars, otherwise empty string. +func createEnvFile(stack *portainer.Stack) (string, error) { + if len(stack.Env) == 0 { + return "", nil + } + + envFilePath := path.Join(stack.ProjectPath, "stack.env") + envfile, err := os.OpenFile(envFilePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) + if err != nil { + return "", err + } + defer logs.CloseAndLogErr(envfile) + + // Copy from default .env file + defaultEnvPath := path.Join(stack.ProjectPath, path.Dir(stack.EntryPoint), ".env") + if err := copyDefaultEnvFile(envfile, defaultEnvPath); err != nil { + return "", err + } + + // Copy from stack env vars + if err := copyConfigEnvVars(envfile, stack.Env); err != nil { + return "", err + } + + return envFilePath, nil +} + +// copyDefaultEnvFile copies the default .env file if it exists to the provided writer +func copyDefaultEnvFile(w io.Writer, defaultEnvFilePath string) error { + defaultEnvFile, err := os.Open(defaultEnvFilePath) + if err != nil { + // If cannot open a default file, then don't need to copy it. + // We could as well stat it and check if it exists, but this is more efficient. + return nil + } + + defer logs.CloseAndLogErr(defaultEnvFile) + + if _, err = io.Copy(w, defaultEnvFile); err == nil { + if _, err = fmt.Fprintf(w, "\n"); err != nil { + return fmt.Errorf("failed to copy default env file: %w", err) + } + } + + return nil + // If couldn't copy the .env file, then ignore the error and try to continue +} + +// copyConfigEnvVars write the environment variables from stack configuration to the writer +func copyConfigEnvVars(w io.Writer, envs []portainer.Pair) error { + for _, v := range envs { + if _, err := fmt.Fprintf(w, "%s=%s\n", v.Name, v.Value); err != nil { + return fmt.Errorf("failed to copy config env vars: %w", err) + } + } + + return nil +} diff --git a/api/exec/compose_stack_integration_test.go b/api/exec/compose_stack_integration_test.go new file mode 100644 index 0000000..9d52edf --- /dev/null +++ b/api/exec/compose_stack_integration_test.go @@ -0,0 +1,79 @@ +package exec + +import ( + "os" + "os/exec" + "strings" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/filesystem" + "github.com/portainer/portainer/pkg/libstack/compose" + "github.com/portainer/portainer/pkg/testhelpers" + "github.com/stretchr/testify/require" + + "github.com/rs/zerolog/log" +) + +const composeFile = `version: "3.9" +services: + busybox: + image: "alpine:latest" + container_name: "compose_wrapper_test"` +const composedContainerName = "compose_wrapper_test" + +func setup(t *testing.T) (*portainer.Stack, *portainer.Endpoint) { + dir := t.TempDir() + composeFileName := "compose_wrapper_test.yml" + f, err := os.Create(filesystem.JoinPaths(dir, composeFileName)) + require.NoError(t, err) + + _, err = f.WriteString(composeFile) + require.NoError(t, err) + + stack := &portainer.Stack{ + ProjectPath: dir, + EntryPoint: composeFileName, + Name: "project-name", + } + + return stack, &portainer.Endpoint{URL: "unix://"} +} + +func Test_UpAndDown(t *testing.T) { + t.Parallel() + testhelpers.IntegrationTest(t) + + stack, endpoint := setup(t) + + deployer := compose.NewComposeDeployer() + + w := NewComposeStackManager(deployer, nil) + + if err := w.Up(t.Context(), stack, endpoint, portainer.ComposeUpOptions{}); err != nil { + t.Fatalf("Error calling docker-compose up: %s", err) + } + + if !containerExists(composedContainerName) { + t.Fatal("container should exist") + } + + if err := w.Down(t.Context(), stack, endpoint); err != nil { + t.Fatalf("Error calling docker-compose down: %s", err) + } + + if containerExists(composedContainerName) { + t.Fatal("container should be removed") + } +} + +func containerExists(containerName string) bool { + cmd := exec.Command("docker", "ps", "-a", "-f", "name="+containerName) + + out, err := cmd.Output() + if err != nil { + log.Fatal().Err(err).Msg("failed to list containers") + } + + return strings.Contains(string(out), containerName) +} diff --git a/api/exec/compose_stack_test.go b/api/exec/compose_stack_test.go new file mode 100644 index 0000000..f277b77 --- /dev/null +++ b/api/exec/compose_stack_test.go @@ -0,0 +1,169 @@ +package exec + +import ( + "io" + "os" + "testing" + "time" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/filesystem" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_createEnvFile(t *testing.T) { + t.Parallel() + dir := t.TempDir() + + tests := []struct { + name string + stack *portainer.Stack + expected string + expectedFile bool + }{ + { + name: "should not add env file option if stack doesn't have env variables", + stack: &portainer.Stack{ + ProjectPath: dir, + Env: nil, + }, + expected: "", + }, + { + name: "should not add env file option if stack's env variables are empty", + stack: &portainer.Stack{ + ProjectPath: dir, + Env: []portainer.Pair{}, + }, + expected: "", + }, + { + name: "should add env file option if stack has env variables", + stack: &portainer.Stack{ + ProjectPath: dir, + Env: []portainer.Pair{ + {Name: "var1", Value: "value1"}, + {Name: "var2", Value: "value2"}, + }, + }, + expected: "var1=value1\nvar2=value2\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, _ := createEnvFile(tt.stack) + + if tt.expected != "" { + assert.Equal(t, filesystem.JoinPaths(tt.stack.ProjectPath, "stack.env"), result) + + f, _ := os.Open(filesystem.JoinPaths(dir, "stack.env")) + content, _ := io.ReadAll(f) + + assert.Equal(t, tt.expected, string(content)) + } else { + assert.Empty(t, result) + } + }) + } +} + +func Test_createEnvFile_mergesDefultAndInplaceEnvVars(t *testing.T) { + t.Parallel() + dir := t.TempDir() + err := os.WriteFile(filesystem.JoinPaths(dir, ".env"), []byte("VAR1=VAL1\nVAR2=VAL2\n"), 0600) + require.NoError(t, err) + + stack := &portainer.Stack{ + ProjectPath: dir, + Env: []portainer.Pair{ + {Name: "VAR1", Value: "NEW_VAL1"}, + {Name: "VAR3", Value: "VAL3"}, + }, + } + result, err := createEnvFile(stack) + assert.Equal(t, filesystem.JoinPaths(stack.ProjectPath, "stack.env"), result) + require.NoError(t, err) + assert.FileExists(t, filesystem.JoinPaths(dir, "stack.env")) + + f, err := os.Open(filesystem.JoinPaths(dir, "stack.env")) + require.NoError(t, err) + + content, err := io.ReadAll(f) + require.NoError(t, err) + + assert.Equal(t, []byte("VAR1=VAL1\nVAR2=VAL2\n\nVAR1=NEW_VAL1\nVAR3=VAL3\n"), content) +} + +func Test_portainerRegistriesToAuthConfigs(t *testing.T) { + t.Parallel() + + t.Run("returns empty slice for empty input", func(t *testing.T) { + t.Parallel() + result := portainerRegistriesToAuthConfigs([]portainer.Registry{}) + require.Nil(t, result) + }) + + t.Run("uses registry URL, username and password for non-authenticated registry", func(t *testing.T) { + t.Parallel() + registries := []portainer.Registry{ + {URL: "registry.example.com", Username: "user", Password: "pass", Authentication: false}, + } + result := portainerRegistriesToAuthConfigs(registries) + require.Len(t, result, 1) + require.Equal(t, "registry.example.com", result[0].ServerAddress) + require.Equal(t, "user", result[0].Username) + require.Equal(t, "pass", result[0].Password) + }) + + t.Run("uses username and password for authenticated non-ECR registry", func(t *testing.T) { + t.Parallel() + registries := []portainer.Registry{ + {URL: "registry.example.com", Username: "user", Password: "pass", Authentication: true, Type: portainer.CustomRegistry}, + } + result := portainerRegistriesToAuthConfigs(registries) + require.Len(t, result, 1) + require.Equal(t, "user", result[0].Username) + require.Equal(t, "pass", result[0].Password) + }) + + t.Run("parses ECR access token for authenticated ECR registry with valid token", func(t *testing.T) { + t.Parallel() + registries := []portainer.Registry{ + { + URL: "123456789.dkr.ecr.us-east-1.amazonaws.com", + Username: "AKIAIOSFODNN7EXAMPLE", + Password: "secretkey", + Authentication: true, + Type: portainer.EcrRegistry, + Ecr: portainer.EcrData{Region: "us-east-1"}, + AccessToken: "AWS:ecr-password", + AccessTokenExpiry: time.Now().Add(time.Hour).Unix(), + }, + } + result := portainerRegistriesToAuthConfigs(registries) + require.Len(t, result, 1) + require.Equal(t, "AWS", result[0].Username) + require.Equal(t, "ecr-password", result[0].Password) + }) + + t.Run("includes valid registries and skips ones with credential errors", func(t *testing.T) { + t.Parallel() + registries := []portainer.Registry{ + {URL: "valid.example.com", Username: "user", Password: "pass", Authentication: false}, + { + URL: "123456789.dkr.ecr.us-east-1.amazonaws.com", + Authentication: true, + Type: portainer.EcrRegistry, + Ecr: portainer.EcrData{Region: "us-east-1"}, + AccessToken: "no-colon-token", + AccessTokenExpiry: time.Now().Add(time.Hour).Unix(), + }, + } + result := portainerRegistriesToAuthConfigs(registries) + require.Len(t, result, 1) + require.Equal(t, "valid.example.com", result[0].ServerAddress) + }) +} diff --git a/api/exec/exectest/kubernetes_mocks.go b/api/exec/exectest/kubernetes_mocks.go new file mode 100644 index 0000000..3b2de18 --- /dev/null +++ b/api/exec/exectest/kubernetes_mocks.go @@ -0,0 +1,28 @@ +package exectest + +import ( + "context" + + portainer "github.com/portainer/portainer/api" +) + +type kubernetesMockDeployer struct { + portainer.KubernetesDeployer +} + +// NewKubernetesDeployer creates a mock kubernetes deployer +func NewKubernetesDeployer() *kubernetesMockDeployer { + return &kubernetesMockDeployer{} +} + +func (deployer *kubernetesMockDeployer) Deploy(_ context.Context, userID portainer.UserID, endpoint *portainer.Endpoint, manifestFiles []string, namespace string) (string, error) { + return "", nil +} + +func (deployer *kubernetesMockDeployer) Remove(_ context.Context, userID portainer.UserID, endpoint *portainer.Endpoint, manifestFiles []string, namespace string) (string, error) { + return "", nil +} + +func (deployer *kubernetesMockDeployer) Restart(_ context.Context, userID portainer.UserID, endpoint *portainer.Endpoint, manifestFiles []string, namespace string) (string, error) { + return "", nil +} diff --git a/api/exec/kubernetes_deploy.go b/api/exec/kubernetes_deploy.go new file mode 100644 index 0000000..db6d9b4 --- /dev/null +++ b/api/exec/kubernetes_deploy.go @@ -0,0 +1,138 @@ +package exec + +import ( + "context" + "fmt" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/http/proxy" + "github.com/portainer/portainer/api/http/proxy/factory" + "github.com/portainer/portainer/api/http/proxy/factory/kubernetes" + "github.com/portainer/portainer/api/kubernetes/cli" + "github.com/portainer/portainer/pkg/libkubectl" + + "github.com/pkg/errors" +) + +const ( + defaultServerURL = "https://kubernetes.default.svc" +) + +// KubernetesDeployer represents a service to deploy resources inside a Kubernetes environment(endpoint). +type KubernetesDeployer struct { + dataStore dataservices.DataStore + reverseTunnelService portainer.ReverseTunnelService + signatureService portainer.DigitalSignatureService + kubernetesClientFactory *cli.ClientFactory + kubernetesTokenCacheManager *kubernetes.TokenCacheManager + proxyManager *proxy.Manager +} + +// NewKubernetesDeployer initializes a new KubernetesDeployer service. +func NewKubernetesDeployer(kubernetesTokenCacheManager *kubernetes.TokenCacheManager, kubernetesClientFactory *cli.ClientFactory, datastore dataservices.DataStore, reverseTunnelService portainer.ReverseTunnelService, signatureService portainer.DigitalSignatureService, proxyManager *proxy.Manager) *KubernetesDeployer { + return &KubernetesDeployer{ + dataStore: datastore, + reverseTunnelService: reverseTunnelService, + signatureService: signatureService, + kubernetesClientFactory: kubernetesClientFactory, + kubernetesTokenCacheManager: kubernetesTokenCacheManager, + proxyManager: proxyManager, + } +} + +func (deployer *KubernetesDeployer) getToken(userID portainer.UserID, endpoint *portainer.Endpoint, setLocalAdminToken bool) (string, error) { + kubeCLI, err := deployer.kubernetesClientFactory.GetPrivilegedKubeClient(endpoint) + if err != nil { + return "", err + } + + tokenCache := deployer.kubernetesTokenCacheManager.GetOrCreateTokenCache(endpoint.ID) + + tokenManager, err := kubernetes.NewTokenManager(kubeCLI, deployer.dataStore, tokenCache, setLocalAdminToken) + if err != nil { + return "", err + } + + user, err := deployer.dataStore.User().Read(userID) + if err != nil { + return "", errors.Wrap(err, "failed to fetch the user") + } + + if user.Role == portainer.AdministratorRole { + return tokenManager.GetAdminServiceAccountToken(), nil + } + + token, err := tokenManager.GetUserServiceAccountToken(int(user.ID), endpoint.ID) + if err != nil { + return "", err + } + + if token == "" { + return "", errors.New("can not get a valid user service account token") + } + + return token, nil +} + +// Deploy upserts Kubernetes resources defined in manifest(s) +func (deployer *KubernetesDeployer) Deploy(ctx context.Context, userID portainer.UserID, endpoint *portainer.Endpoint, resources []string, namespace string) (string, error) { + return deployer.command(ctx, "apply", userID, endpoint, resources, namespace) +} + +// Remove deletes Kubernetes resources defined in manifest(s) +func (deployer *KubernetesDeployer) Remove(ctx context.Context, userID portainer.UserID, endpoint *portainer.Endpoint, resources []string, namespace string) (string, error) { + return deployer.command(ctx, "delete", userID, endpoint, resources, namespace) +} + +func (deployer *KubernetesDeployer) command(ctx context.Context, operation string, userID portainer.UserID, endpoint *portainer.Endpoint, resources []string, namespace string) (string, error) { + token, err := deployer.getToken(userID, endpoint, endpoint.Type == portainer.KubernetesLocalEnvironment) + if err != nil { + return "", errors.Wrap(err, "failed generating a user token") + } + + serverURL := defaultServerURL + if endpoint.Type == portainer.AgentOnKubernetesEnvironment || endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment { + url, proxy, err := deployer.getAgentURL(endpoint) + if err != nil { + return "", errors.WithMessage(err, "failed generating endpoint URL") + } + defer proxy.Close() + + serverURL = url + } + + client, err := libkubectl.NewClient(&libkubectl.ClientAccess{ + Token: token, + ServerUrl: serverURL, + }, namespace, "", true) + if err != nil { + return "", errors.Wrap(err, "failed to create kubectl client") + } + + operations := map[string]func(context.Context, []string) (string, error){ + "apply": client.ApplyDynamic, + "delete": client.DeleteDynamic, + } + + operationFunc, ok := operations[operation] + if !ok { + return "", errors.Errorf("unsupported operation: %s", operation) + } + + output, err := operationFunc(ctx, resources) + if err != nil { + return "", errors.Wrapf(err, "failed to execute kubectl %s command", operation) + } + + return output, nil +} + +func (deployer *KubernetesDeployer) getAgentURL(endpoint *portainer.Endpoint) (string, *factory.ProxyServer, error) { + proxy, err := deployer.proxyManager.CreateAgentProxyServer(endpoint) + if err != nil { + return "", nil, err + } + + return fmt.Sprintf("http://127.0.0.1:%d/kubernetes", proxy.Port), proxy, nil +} diff --git a/api/exec/kubernetes_deploy_test.go b/api/exec/kubernetes_deploy_test.go new file mode 100644 index 0000000..8fb357e --- /dev/null +++ b/api/exec/kubernetes_deploy_test.go @@ -0,0 +1,181 @@ +package exec + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type mockKubectlClient struct { + applyFunc func(ctx context.Context, files []string) error + deleteFunc func(ctx context.Context, files []string) error + rolloutRestartFunc func(ctx context.Context, resources []string) error +} + +func (m *mockKubectlClient) Apply(ctx context.Context, files []string) error { + if m.applyFunc != nil { + return m.applyFunc(ctx, files) + } + return nil +} + +func (m *mockKubectlClient) Delete(ctx context.Context, files []string) error { + if m.deleteFunc != nil { + return m.deleteFunc(ctx, files) + } + return nil +} + +func (m *mockKubectlClient) RolloutRestart(ctx context.Context, resources []string) error { + if m.rolloutRestartFunc != nil { + return m.rolloutRestartFunc(ctx, resources) + } + return nil +} + +func testExecuteKubectlOperation(client *mockKubectlClient, operation string, manifestFiles []string) error { + operations := map[string]func(context.Context, []string) error{ + "apply": client.Apply, + "delete": client.Delete, + "rollout-restart": client.RolloutRestart, + } + + operationFunc, ok := operations[operation] + if !ok { + return fmt.Errorf("unsupported operation: %s", operation) + } + + if err := operationFunc(context.Background(), manifestFiles); err != nil { + return fmt.Errorf("failed to execute kubectl %s command: %w", operation, err) + } + + return nil +} + +func TestExecuteKubectlOperation_Apply_Success(t *testing.T) { + t.Parallel() + called := false + mockClient := &mockKubectlClient{ + applyFunc: func(ctx context.Context, files []string) error { + called = true + assert.Equal(t, []string{"manifest1.yaml", "manifest2.yaml"}, files) + return nil + }, + } + + manifests := []string{"manifest1.yaml", "manifest2.yaml"} + err := testExecuteKubectlOperation(mockClient, "apply", manifests) + + require.NoError(t, err) + assert.True(t, called) +} + +func TestExecuteKubectlOperation_Apply_Error(t *testing.T) { + t.Parallel() + expectedErr := errors.New("kubectl apply failed") + called := false + mockClient := &mockKubectlClient{ + applyFunc: func(ctx context.Context, files []string) error { + called = true + assert.Equal(t, []string{"error.yaml"}, files) + return expectedErr + }, + } + + manifests := []string{"error.yaml"} + err := testExecuteKubectlOperation(mockClient, "apply", manifests) + + require.Error(t, err) + assert.Contains(t, err.Error(), expectedErr.Error()) + assert.True(t, called) +} + +func TestExecuteKubectlOperation_Delete_Success(t *testing.T) { + t.Parallel() + called := false + mockClient := &mockKubectlClient{ + deleteFunc: func(ctx context.Context, files []string) error { + called = true + assert.Equal(t, []string{"manifest1.yaml"}, files) + return nil + }, + } + + manifests := []string{"manifest1.yaml"} + err := testExecuteKubectlOperation(mockClient, "delete", manifests) + + require.NoError(t, err) + assert.True(t, called) +} + +func TestExecuteKubectlOperation_Delete_Error(t *testing.T) { + t.Parallel() + expectedErr := errors.New("kubectl delete failed") + called := false + mockClient := &mockKubectlClient{ + deleteFunc: func(ctx context.Context, files []string) error { + called = true + assert.Equal(t, []string{"error.yaml"}, files) + return expectedErr + }, + } + + manifests := []string{"error.yaml"} + err := testExecuteKubectlOperation(mockClient, "delete", manifests) + + require.Error(t, err) + assert.Contains(t, err.Error(), expectedErr.Error()) + assert.True(t, called) +} + +func TestExecuteKubectlOperation_RolloutRestart_Success(t *testing.T) { + t.Parallel() + called := false + mockClient := &mockKubectlClient{ + rolloutRestartFunc: func(ctx context.Context, resources []string) error { + called = true + assert.Equal(t, []string{"deployment/nginx"}, resources) + return nil + }, + } + + resources := []string{"deployment/nginx"} + err := testExecuteKubectlOperation(mockClient, "rollout-restart", resources) + + require.NoError(t, err) + assert.True(t, called) +} + +func TestExecuteKubectlOperation_RolloutRestart_Error(t *testing.T) { + t.Parallel() + expectedErr := errors.New("kubectl rollout restart failed") + called := false + mockClient := &mockKubectlClient{ + rolloutRestartFunc: func(ctx context.Context, resources []string) error { + called = true + assert.Equal(t, []string{"deployment/error"}, resources) + return expectedErr + }, + } + + resources := []string{"deployment/error"} + err := testExecuteKubectlOperation(mockClient, "rollout-restart", resources) + + require.Error(t, err) + assert.Contains(t, err.Error(), expectedErr.Error()) + assert.True(t, called) +} + +func TestExecuteKubectlOperation_UnsupportedOperation(t *testing.T) { + t.Parallel() + mockClient := &mockKubectlClient{} + + err := testExecuteKubectlOperation(mockClient, "unsupported", []string{}) + + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported operation") +} diff --git a/api/exec/swarm_stack.go b/api/exec/swarm_stack.go new file mode 100644 index 0000000..ca7cf18 --- /dev/null +++ b/api/exec/swarm_stack.go @@ -0,0 +1,112 @@ +package exec + +import ( + "context" + "fmt" + "time" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/proxy" + "github.com/portainer/portainer/api/stacks/stackutils" + "github.com/portainer/portainer/pkg/libstack" + "github.com/portainer/portainer/pkg/libstack/swarm" +) + +// postDeployFailureCheckTimeout bounds how long Deploy waits for tasks to start or fail after being accepted by Swarm. +const postDeployFailureCheckTimeout = 30 * time.Second + +// SwarmStackManager represents a service for managing stacks. +type SwarmStackManager struct { + deployer swarm.Deployer + proxyManager *proxy.Manager +} + +// NewSwarmStackManager creates a new SwarmStackManager. +func NewSwarmStackManager( + deployer swarm.Deployer, + proxyManager *proxy.Manager, +) *SwarmStackManager { + return &SwarmStackManager{ + deployer: deployer, + proxyManager: proxyManager, + } +} + +// Deploy creates or updates a Docker Swarm stack. +func (manager *SwarmStackManager) Deploy( + ctx context.Context, + stack *portainer.Stack, + prune bool, + pullImage bool, + endpoint *portainer.Endpoint, + registries []portainer.Registry, +) error { + url, proxy, err := fetchEndpointProxy(manager.proxyManager, endpoint) + if err != nil { + return fmt.Errorf("failed to fetch environment proxy: %w", err) + } + + if proxy != nil { + defer proxy.Close() + } + + filePaths := stackutils.GetStackFilePaths(stack, true) + + env := make([]string, 0, len(stack.Env)) + for _, ev := range stack.Env { + env = append(env, ev.Name+"="+ev.Value) + } + + options := swarm.Options{ + ProjectName: stack.Name, + Host: url, + Env: env, + Registries: portainerRegistriesToAuthConfigs(registries), + } + + if err := manager.deployer.Deploy(context.TODO(), filePaths, swarm.DeployOptions{ + Options: options, + RemoveOrphans: prune, + PullImage: pullImage, + }); err != nil { + return err + } + + // Swarm schedules and pulls images asynchronously, so check for early failures before reporting success. + waitCtx, cancel := context.WithTimeout(ctx, postDeployFailureCheckTimeout) + defer cancel() + + result := manager.deployer.WaitForStatus(waitCtx, stack.Name, options, libstack.StatusRunning) + if result.Status == libstack.StatusError { + return fmt.Errorf("deployment failed: %s", result.ErrorMsg) + } + + return nil +} + +// Remove deletes all resources belonging to a Swarm stack. +func (manager *SwarmStackManager) Remove( + ctx context.Context, + stack *portainer.Stack, + endpoint *portainer.Endpoint, +) error { + url, proxy, err := fetchEndpointProxy(manager.proxyManager, endpoint) + if err != nil { + return fmt.Errorf("failed to fetch environment proxy: %w", err) + } + + if proxy != nil { + defer proxy.Close() + } + + return manager.deployer.Remove(context.TODO(), stack.Name, swarm.RemoveOptions{ + Options: swarm.Options{ + Host: url, + }, + }) +} + +// NormalizeStackName returns a new stack name with unsupported characters replaced. +func (manager *SwarmStackManager) NormalizeStackName(name string) string { + return normalizeStackName(name) +} diff --git a/api/filesystem/copy.go b/api/filesystem/copy.go new file mode 100644 index 0000000..40d3974 --- /dev/null +++ b/api/filesystem/copy.go @@ -0,0 +1,86 @@ +package filesystem + +import ( + "errors" + "io" + "os" + "path/filepath" + "strings" + + "github.com/portainer/portainer/api/logs" +) + +// CopyPath copies file or directory defined by the path to the toDir path +func CopyPath(path string, toDir string) error { + info, err := os.Stat(path) + if err != nil && errors.Is(err, os.ErrNotExist) { + // skip copy if file does not exist + return nil + } else if err != nil { + return err + } + + if !info.IsDir() { + destination := filepath.Join(toDir, info.Name()) + return copyFile(path, destination) + } + + return CopyDir(path, toDir, true) +} + +// CopyDir copies contents of fromDir to toDir. +// When keepParent is true, contents will be copied with their immediate parent dir, +// i.e. given /from/dirA and /to/dirB with keepParent == true, result will be /to/dirB/dirA/ +func CopyDir(fromDir, toDir string, keepParent bool) error { + cleanedSourcePath := filepath.Clean(fromDir) + parentDirectory := filepath.Dir(cleanedSourcePath) + err := filepath.Walk(cleanedSourcePath, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + var destination string + if keepParent { + destination = filepath.Join(toDir, strings.TrimPrefix(path, parentDirectory)) + } else { + destination = filepath.Join(toDir, strings.TrimPrefix(path, cleanedSourcePath)) + } + + if destination == "" { + return nil + } + + if info.IsDir() { + return nil // skip directory creations + } + + if info.Mode()&os.ModeSymlink != 0 { // entry is a symlink + return nil // don't copy symlinks + } + + return copyFile(path, destination) + }) + + return err +} + +// copies regular a file from src to dst +func copyFile(src, dst string) error { + from, err := os.Open(src) + if err != nil { + return err + } + defer logs.CloseAndLogErr(from) + + // has to include 'execute' bit, otherwise fails. MkdirAll follows `mkdir -m` restrictions + if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { + return err + } + to, err := os.Create(dst) + if err != nil { + return err + } + defer logs.CloseAndLogErr(to) + + _, err = io.Copy(to, from) + return err +} diff --git a/api/filesystem/copy_test.go b/api/filesystem/copy_test.go new file mode 100644 index 0000000..93bea75 --- /dev/null +++ b/api/filesystem/copy_test.go @@ -0,0 +1,111 @@ +package filesystem + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_copyFile_returnsError_whenSourceDoesNotExist(t *testing.T) { + t.Parallel() + tmpdir := t.TempDir() + err := copyFile("does-not-exist", tmpdir) + require.Error(t, err) +} + +func Test_copyFile_shouldMakeAbackup(t *testing.T) { + t.Parallel() + tmpdir := t.TempDir() + content := []byte("content") + + err := os.WriteFile(JoinPaths(tmpdir, "origin"), content, 0600) + require.NoError(t, err) + + err = copyFile(JoinPaths(tmpdir, "origin"), JoinPaths(tmpdir, "copy")) + require.NoError(t, err) + + copyContent, err := os.ReadFile(JoinPaths(tmpdir, "copy")) + require.NoError(t, err) + assert.Equal(t, content, copyContent) +} + +func Test_CopyDir_shouldCopyAllFilesAndDirectories(t *testing.T) { + t.Parallel() + destination := t.TempDir() + err := CopyDir("./testdata/copy_test", destination, true) + require.NoError(t, err) + + assert.FileExists(t, JoinPaths(destination, "copy_test", "outer")) + assert.FileExists(t, JoinPaths(destination, "copy_test", "dir", ".dotfile")) + assert.FileExists(t, JoinPaths(destination, "copy_test", "dir", "inner")) +} + +func Test_CopyDir_shouldCopyOnlyDirContents(t *testing.T) { + t.Parallel() + destination := t.TempDir() + err := CopyDir("./testdata/copy_test", destination, false) + require.NoError(t, err) + + assert.FileExists(t, JoinPaths(destination, "outer")) + assert.FileExists(t, JoinPaths(destination, "dir", ".dotfile")) + assert.FileExists(t, JoinPaths(destination, "dir", "inner")) +} + +func Test_CopyPath_shouldSkipWhenNotExist(t *testing.T) { + t.Parallel() + tmpdir := t.TempDir() + err := CopyPath("does-not-exists", tmpdir) + require.NoError(t, err) + + assert.NoFileExists(t, tmpdir) +} + +func Test_CopyPath_shouldCopyFile(t *testing.T) { + t.Parallel() + tmpdir := t.TempDir() + content := []byte("content") + + err := os.WriteFile(JoinPaths(tmpdir, "file"), content, 0600) + require.NoError(t, err) + + err = os.MkdirAll(JoinPaths(tmpdir, "backup"), 0700) + require.NoError(t, err) + + err = CopyPath(JoinPaths(tmpdir, "file"), JoinPaths(tmpdir, "backup")) + require.NoError(t, err) + + copyContent, err := os.ReadFile(JoinPaths(tmpdir, "backup", "file")) + require.NoError(t, err) + assert.Equal(t, content, copyContent) +} + +func Test_CopyPath_shouldCopyDir(t *testing.T) { + t.Parallel() + destination := t.TempDir() + err := CopyPath("./testdata/copy_test", destination) + require.NoError(t, err) + + assert.FileExists(t, JoinPaths(destination, "copy_test", "outer")) + assert.FileExists(t, JoinPaths(destination, "copy_test", "dir", ".dotfile")) + assert.FileExists(t, JoinPaths(destination, "copy_test", "dir", "inner")) +} + +func TestCopyPathPanic(t *testing.T) { + t.Parallel() + dir := t.TempDir() + p := JoinPaths(dir, "myfile") + + err := os.WriteFile(p, []byte("contents"), 0644) + require.NoError(t, err) + + err = os.Chmod(dir, 0) + require.NoError(t, err) + + err = CopyPath(p, t.TempDir()) + require.Error(t, err) + + err = os.Chmod(dir, 0755) + require.NoError(t, err) +} diff --git a/api/filesystem/filesystem.go b/api/filesystem/filesystem.go new file mode 100644 index 0000000..28afb38 --- /dev/null +++ b/api/filesystem/filesystem.go @@ -0,0 +1,1047 @@ +package filesystem + +import ( + "bytes" + "encoding/pem" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strconv" + "strings" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/logs" + + "github.com/google/uuid" + "github.com/rs/zerolog/log" + "github.com/segmentio/encoding/json" +) + +const ( + // TLSStorePath represents the subfolder where TLS files are stored in the file store folder. + TLSStorePath = "tls" + // LDAPStorePath represents the subfolder where LDAP TLS files are stored in the TLSStorePath. + LDAPStorePath = "ldap" + // TLSCACertFile represents the name on disk for a TLS CA file. + TLSCACertFile = "ca.pem" + // TLSCertFile represents the name on disk for a TLS certificate file. + TLSCertFile = "cert.pem" + // TLSKeyFile represents the name on disk for a TLS key file. + TLSKeyFile = "key.pem" + // ComposeStorePath represents the subfolder where compose files are stored in the file store folder. + ComposeStorePath = "compose" + // ComposeFileDefaultName represents the default name of a compose file. + ComposeFileDefaultName = "docker-compose.yml" + // ManifestFileDefaultName represents the default name of a k8s manifest file. + ManifestFileDefaultName = "k8s-deployment.yml" + // EdgeStackStorePath represents the subfolder where edge stack files are stored in the file store folder. + EdgeStackStorePath = "edge_stacks" + // PrivateKeyFile represents the name on disk of the file containing the private key. + PrivateKeyFile = "portainer.key" + // PublicKeyFile represents the name on disk of the file containing the public key. + PublicKeyFile = "portainer.pub" + // BinaryStorePath represents the subfolder where binaries are stored in the file store folder. + BinaryStorePath = "bin" + // 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" + // SSLCertPath represents the default ssl certificates path + SSLCertPath = "certs" + // SSLCertFilename represents the ssl certificate file name + SSLCertFilename = "cert.pem" + // SSLKeyFilename represents the ssl key file name + SSLKeyFilename = "key.pem" + // SSLCACertFilename represents the CA ssl certificate file name for mTLS + SSLCACertFilename = "ca-cert.pem" + + MTLSCertFilename = "mtls-cert.pem" + MTLSCACertFilename = "mtls-ca-cert.pem" + MTLSKeyFilename = "mtls-key.pem" + + // ChiselPath represents the default chisel path + ChiselPath = "chisel" + // ChiselPrivateKeyFilename represents the chisel private key file name + ChiselPrivateKeyFilename = "private-key.pem" +) + +// 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 + fileStorePath string +} + +// JoinPaths takes a trusted root path and a list of untrusted paths and joins +// them together using directory separators while enforcing that the untrusted +// paths cannot go higher up than the trusted root +func JoinPaths(trustedRoot string, untrustedPaths ...string) string { + if trustedRoot == "" { + trustedRoot = "." + } + + p := filepath.Join(trustedRoot, filepath.Join(append([]string{"/"}, untrustedPaths...)...)) + + // avoid setting a volume name from the untrusted paths + vnp := filepath.VolumeName(p) + if filepath.VolumeName(trustedRoot) == "" && vnp != "" { + return strings.TrimPrefix(strings.TrimPrefix(p, vnp), `\`) + } + + return p +} + +// NewService initializes a new service. It creates a data directory and a directory to store files +// inside this directory if they don't exist. +func NewService(dataStorePath, fileStorePath string) (*Service, error) { + service := &Service{ + dataStorePath: dataStorePath, + fileStorePath: JoinPaths(dataStorePath, fileStorePath), + } + + err := os.MkdirAll(dataStorePath, 0755) + if err != nil { + return nil, err + } + + err = service.createDirectoryInStore(SSLCertPath) + if err != nil { + return nil, err + } + + err = service.createDirectoryInStore(TLSStorePath) + if err != nil { + return nil, err + } + + err = service.createDirectoryInStore(ComposeStorePath) + if err != nil { + return nil, err + } + + err = service.createDirectoryInStore(BinaryStorePath) + if err != nil { + return nil, err + } + + return service, nil +} + +// GetBinaryFolder returns the full path to the binary store on the filesystem +func (service *Service) GetBinaryFolder() string { + return JoinPaths(service.fileStorePath, BinaryStorePath) +} + +// RemoveDirectory removes a directory on the filesystem. +func (service *Service) RemoveDirectory(directoryPath string) error { + return os.RemoveAll(directoryPath) +} + +// GetStackProjectPath returns the absolute path on the FS for a stack based +// on its identifier. +func (service *Service) GetStackProjectPath(stackIdentifier string) string { + return JoinPaths(service.wrapFileStore(ComposeStorePath), stackIdentifier) +} + +// GetStackProjectPathByVersion returns the absolute path on the FS for a stack based +// on its identifier and version. +func (service *Service) GetStackProjectPathByVersion(stackIdentifier string, version int, commitHash string) string { + versionStr := "" + if version != 0 { + versionStr = fmt.Sprintf("v%d", version) + } + + if commitHash != "" { + versionStr = commitHash + } + return JoinPaths(service.wrapFileStore(ComposeStorePath), stackIdentifier, versionStr) +} + +// Copy copies the file on fromFilePath to toFilePath +// if toFilePath exists func will fail unless deleteIfExists is true +func (service *Service) Copy(fromFilePath string, toFilePath string, deleteIfExists bool) error { + exists, err := service.FileExists(fromFilePath) + if err != nil { + return err + } + + if !exists { + return fmt.Errorf("File (%s) doesn't exist", fromFilePath) + } + + finput, err := os.Open(fromFilePath) + if err != nil { + return err + } + + defer logs.CloseAndLogErr(finput) + + exists, err = service.FileExists(toFilePath) + if err != nil { + return err + } + + if exists { + if !deleteIfExists { + return errors.New("Destination file exists") + } + + err := os.Remove(toFilePath) + if err != nil { + return err + } + } + + foutput, err := os.Create(toFilePath) + if err != nil { + return err + } + + defer logs.CloseAndLogErr(foutput) + + buf := make([]byte, 1024) + for { + n, err := finput.Read(buf) + if err != nil && err != io.EOF { + return err + } + if n == 0 { + break + } + + if _, err := foutput.Write(buf[:n]); err != nil { + return err + } + } + + return nil +} + +// StoreStackFileFromBytes creates a subfolder in the ComposeStorePath and stores a new file from bytes. +// It returns the path to the folder where the file is stored. +func (service *Service) StoreStackFileFromBytes(stackIdentifier, fileName string, data []byte) (string, error) { + stackStorePath := JoinPaths(ComposeStorePath, stackIdentifier) + err := service.createDirectoryInStore(stackStorePath) + if err != nil { + return "", err + } + + composeFilePath := JoinPaths(stackStorePath, fileName) + r := bytes.NewReader(data) + + err = service.createFileInStore(composeFilePath, r) + if err != nil { + return "", err + } + + return service.wrapFileStore(stackStorePath), nil +} + +// StoreStackFileFromBytesByVersion creates a version subfolder in the ComposeStorePath and stores a new file from bytes. +// It returns the path to the folder where version folders are stored. +func (service *Service) StoreStackFileFromBytesByVersion(stackIdentifier, fileName string, version int, data []byte) (string, error) { + versionStr := "" + if version != 0 { + versionStr = fmt.Sprintf("v%d", version) + } + stackStorePath := JoinPaths(ComposeStorePath, stackIdentifier) + stackVersionPath := JoinPaths(stackStorePath, versionStr) + err := service.createDirectoryInStore(stackVersionPath) + if err != nil { + return "", err + } + + composeFilePath := JoinPaths(stackVersionPath, fileName) + r := bytes.NewReader(data) + + err = service.createFileInStore(composeFilePath, r) + if err != nil { + return "", err + } + + return service.wrapFileStore(stackStorePath), nil +} + +// UpdateStoreStackFileFromBytes makes stack file backup and updates a new file from bytes. +// It returns the path to the folder where the file is stored. +func (service *Service) UpdateStoreStackFileFromBytes(stackIdentifier, fileName string, data []byte) (string, error) { + stackStorePath := JoinPaths(ComposeStorePath, stackIdentifier) + composeFilePath := JoinPaths(stackStorePath, fileName) + err := service.createBackupFileInStore(composeFilePath) + if err != nil { + return "", err + } + + r := bytes.NewReader(data) + err = service.createFileInStore(composeFilePath, r) + if err != nil { + return "", err + } + + return service.wrapFileStore(stackStorePath), nil +} + +// UpdateStoreStackFileFromBytesByVersion makes stack file backup and updates a new file from bytes. +// It returns the path to the folder where the file is stored. +func (service *Service) UpdateStoreStackFileFromBytesByVersion(stackIdentifier, fileName string, version int, commitHash string, data []byte) (string, error) { + stackStorePath := JoinPaths(ComposeStorePath, stackIdentifier) + + versionStr := "" + if version != 0 { + versionStr = fmt.Sprintf("v%d", version) + } + if commitHash != "" { + versionStr = commitHash + } + + if versionStr != "" { + stackStorePath = JoinPaths(stackStorePath, versionStr) + } + + composeFilePath := JoinPaths(stackStorePath, fileName) + err := service.createBackupFileInStore(composeFilePath) + if err != nil { + return "", err + } + + r := bytes.NewReader(data) + err = service.createFileInStore(composeFilePath, r) + if err != nil { + return "", err + } + + return service.wrapFileStore(stackStorePath), nil +} + +// RemoveStackFileBackup removes the stack file backup in the ComposeStorePath. +func (service *Service) RemoveStackFileBackup(stackIdentifier, fileName string) error { + stackStorePath := JoinPaths(ComposeStorePath, stackIdentifier) + composeFilePath := JoinPaths(stackStorePath, fileName) + + return service.removeBackupFileInStore(composeFilePath) +} + +// RemoveStackFileBackupByVersion removes the stack file backup by version in the ComposeStorePath. +func (service *Service) RemoveStackFileBackupByVersion(stackIdentifier string, version int, fileName string) error { + versionStr := "" + if version != 0 { + versionStr = fmt.Sprintf("v%d", version) + } + stackStorePath := JoinPaths(ComposeStorePath, stackIdentifier, versionStr) + composeFilePath := JoinPaths(stackStorePath, fileName) + + return service.removeBackupFileInStore(composeFilePath) +} + +// RollbackStackFile rollbacks the stack file backup in the ComposeStorePath. +func (service *Service) RollbackStackFile(stackIdentifier, fileName string) error { + stackStorePath := JoinPaths(ComposeStorePath, stackIdentifier) + composeFilePath := JoinPaths(stackStorePath, fileName) + path := service.wrapFileStore(composeFilePath) + backupPath := path + ".bak" + + exists, err := service.FileExists(backupPath) + if err != nil { + return err + } + + if !exists { + // keep the updated/failed stack file + return nil + } + + err = service.Copy(backupPath, path, true) + if err != nil { + return err + } + + return os.Remove(backupPath) +} + +// RollbackStackFileByVersion rollbacks the stack file backup by version in the ComposeStorePath. +func (service *Service) RollbackStackFileByVersion(stackIdentifier string, version int, fileName string) error { + versionStr := "" + if version != 0 { + versionStr = "v" + strconv.Itoa(version) + } + stackStorePath := JoinPaths(ComposeStorePath, stackIdentifier, versionStr) + composeFilePath := JoinPaths(stackStorePath, fileName) + path := service.wrapFileStore(composeFilePath) + backupPath := path + ".bak" + + exists, err := service.FileExists(backupPath) + if err != nil { + return err + } + + if !exists { + // keep the updated/failed stack file + return nil + } + + err = service.Copy(backupPath, path, true) + if err != nil { + return err + } + + return os.Remove(backupPath) +} + +// GetEdgeStackProjectPath returns the absolute path on the FS for a edge stack based +// on its identifier. +func (service *Service) GetEdgeStackProjectPath(edgeStackIdentifier string) string { + return JoinPaths(service.wrapFileStore(EdgeStackStorePath), edgeStackIdentifier) +} + +// StoreEdgeStackFileFromBytes creates a subfolder in the EdgeStackStorePath and stores a new file from bytes. +// It returns the path to the folder where the file is stored. +func (service *Service) StoreEdgeStackFileFromBytes(edgeStackIdentifier, fileName string, data []byte) (string, error) { + stackStorePath := JoinPaths(EdgeStackStorePath, edgeStackIdentifier) + err := service.createDirectoryInStore(stackStorePath) + if err != nil { + return "", err + } + + composeFilePath := JoinPaths(stackStorePath, fileName) + r := bytes.NewReader(data) + + err = service.createFileInStore(composeFilePath, r) + if err != nil { + return "", err + } + + return service.wrapFileStore(stackStorePath), nil +} + +// GetEdgeStackProjectPathByVersion returns the absolute path on the FS for a edge stack based +// on its identifier and version. +// EE only feature +func (service *Service) GetEdgeStackProjectPathByVersion(edgeStackIdentifier string, version int, commitHash string) string { + versionStr := "" + if version != 0 { + versionStr = fmt.Sprintf("v%d", version) + } + + if commitHash != "" { + versionStr = commitHash + } + + return JoinPaths(service.wrapFileStore(EdgeStackStorePath), edgeStackIdentifier, versionStr) +} + +// StoreEdgeStackFileFromBytesByVersion creates a subfolder in the EdgeStackStorePath with version and stores a new file from bytes. +// It returns the path to the folder where the file is stored. +// EE only feature +func (service *Service) StoreEdgeStackFileFromBytesByVersion(edgeStackIdentifier, fileName string, version int, data []byte) (string, error) { + versionStr := "" + if version != 0 { + versionStr = fmt.Sprintf("v%d", version) + } + stackStorePath := JoinPaths(EdgeStackStorePath, edgeStackIdentifier, versionStr) + + err := service.createDirectoryInStore(stackStorePath) + if err != nil { + return "", err + } + + composeFilePath := JoinPaths(stackStorePath, fileName) + r := bytes.NewReader(data) + + err = service.createFileInStore(composeFilePath, r) + if err != nil { + return "", err + } + + return service.wrapFileStore(stackStorePath), nil +} + +// FormProjectPathByVersion returns the absolute path on the FS for a project based with version +func (service *Service) FormProjectPathByVersion(path string, version int, commitHash string) string { + versionStr := "" + if version != 0 { + versionStr = fmt.Sprintf("v%d", version) + } + + if commitHash != "" { + versionStr = commitHash + } + + return JoinPaths(path, versionStr) +} + +// StoreRegistryManagementFileFromBytes creates a subfolder in the +// ExtensionRegistryManagementStorePath and stores a new file from bytes. +// It returns the path to the folder where the file is stored. +func (service *Service) StoreRegistryManagementFileFromBytes(folder, fileName string, data []byte) (string, error) { + extensionStorePath := JoinPaths(ExtensionRegistryManagementStorePath, folder) + err := service.createDirectoryInStore(extensionStorePath) + if err != nil { + return "", err + } + + file := JoinPaths(extensionStorePath, fileName) + r := bytes.NewReader(data) + + err = service.createFileInStore(file, r) + if err != nil { + return "", err + } + + return service.wrapFileStore(file), nil +} + +// StoreTLSFileFromBytes creates a folder in the TLSStorePath and stores a new file from bytes. +// It returns the path to the newly created file. +func (service *Service) StoreTLSFileFromBytes(folder string, fileType portainer.TLSFileType, data []byte) (string, error) { + storePath := JoinPaths(TLSStorePath, folder) + err := service.createDirectoryInStore(storePath) + if err != nil { + return "", err + } + + var fileName string + switch fileType { + case portainer.TLSFileCA: + fileName = TLSCACertFile + case portainer.TLSFileCert: + fileName = TLSCertFile + case portainer.TLSFileKey: + fileName = TLSKeyFile + default: + return "", ErrUndefinedTLSFileType + } + + tlsFilePath := JoinPaths(storePath, fileName) + r := bytes.NewReader(data) + err = service.createFileInStore(tlsFilePath, r) + if err != nil { + return "", err + } + return service.wrapFileStore(tlsFilePath), nil +} + +// GetPathForTLSFile returns the absolute path to a specific TLS file for an environment(endpoint). +func (service *Service) GetPathForTLSFile(folder string, fileType portainer.TLSFileType) (string, error) { + var fileName string + switch fileType { + case portainer.TLSFileCA: + fileName = TLSCACertFile + case portainer.TLSFileCert: + fileName = TLSCertFile + case portainer.TLSFileKey: + fileName = TLSKeyFile + default: + return "", ErrUndefinedTLSFileType + } + return JoinPaths(service.wrapFileStore(TLSStorePath), folder, fileName), nil +} + +// DeleteTLSFiles deletes a folder in the TLS store path. +func (service *Service) DeleteTLSFiles(folder string) error { + storePath := JoinPaths(service.wrapFileStore(TLSStorePath), folder) + return os.RemoveAll(storePath) +} + +// DeleteTLSFile deletes a specific TLS file from a folder. +func (service *Service) DeleteTLSFile(folder string, fileType portainer.TLSFileType) error { + var fileName string + switch fileType { + case portainer.TLSFileCA: + fileName = TLSCACertFile + case portainer.TLSFileCert: + fileName = TLSCertFile + case portainer.TLSFileKey: + fileName = TLSKeyFile + default: + return ErrUndefinedTLSFileType + } + + filePath := JoinPaths(service.wrapFileStore(TLSStorePath), folder, fileName) + + return os.Remove(filePath) +} + +// GetFileContent returns the content of a file as bytes. +func (service *Service) GetFileContent(trustedRoot, filePath string) ([]byte, error) { + content, err := os.ReadFile(JoinPaths(trustedRoot, filePath)) + if err != nil { + if filePath == "" { + filePath = trustedRoot + } + return nil, fmt.Errorf("could not get the contents of the file '%s'", filePath) + } + + return content, nil +} + +// Rename renames a file or directory +func (service *Service) Rename(oldPath, newPath string) error { + return os.Rename(oldPath, newPath) +} + +// WriteJSONToFile writes JSON to the specified file. +func (service *Service) WriteJSONToFile(path string, content any) error { + jsonContent, err := json.Marshal(content) + if err != nil { + return err + } + + return os.WriteFile(path, jsonContent, 0644) +} + +// FileExists checks for the existence of the specified file. +func (service *Service) FileExists(filePath string) (bool, error) { + return FileExists(filePath) +} + +// KeyPairFilesExist checks for the existence of the key files. +func (service *Service) KeyPairFilesExist() (bool, error) { + privateKeyPath := JoinPaths(service.dataStorePath, PrivateKeyFile) + exists, err := service.FileExists(privateKeyPath) + if err != nil || !exists { + return false, err + } + + publicKeyPath := JoinPaths(service.dataStorePath, PublicKeyFile) + exists, err = service.FileExists(publicKeyPath) + if err != nil || !exists { + return false, err + } + + return true, nil +} + +// StoreKeyPair store the specified keys content as PEM files on disk. +func (service *Service) StoreKeyPair(private, public []byte, privatePEMHeader, publicPEMHeader string) error { + err := service.createPEMFileInStore(private, privatePEMHeader, PrivateKeyFile) + if err != nil { + return err + } + + return service.createPEMFileInStore(public, publicPEMHeader, PublicKeyFile) +} + +// LoadKeyPair retrieve the content of both key files on disk. +func (service *Service) LoadKeyPair() ([]byte, []byte, error) { + privateKey, err := service.getContentFromPEMFile(PrivateKeyFile) + if err != nil { + return nil, nil, err + } + + publicKey, err := service.getContentFromPEMFile(PublicKeyFile) + if err != nil { + return nil, nil, err + } + + return privateKey, publicKey, nil +} + +// createDirectoryInStore creates a new directory in the file store +func (service *Service) createDirectoryInStore(name string) error { + path := service.wrapFileStore(name) + return os.MkdirAll(path, 0700) +} + +// createFile creates a new file in the file store with the content from r. +func (service *Service) createFileInStore(filePath string, r io.Reader) error { + path := service.wrapFileStore(filePath) + + return CreateFile(path, r) +} + +// createBackupFileInStore makes a copy in the file store. +func (service *Service) createBackupFileInStore(filePath string) error { + path := service.wrapFileStore(filePath) + backupPath := path + ".bak" + + return service.Copy(path, backupPath, true) +} + +// removeBackupFileInStore removes the copy in the file store. +func (service *Service) removeBackupFileInStore(filePath string) error { + path := service.wrapFileStore(filePath) + backupPath := path + ".bak" + + exists, err := service.FileExists(backupPath) + if err != nil { + return err + } + + if exists { + return os.Remove(backupPath) + } + + return nil +} + +func (service *Service) createPEMFileInStore(content []byte, fileType, filePath string) error { + path := service.wrapFileStore(filePath) + block := &pem.Block{Type: fileType, Bytes: content} + + out, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) + if err != nil { + return err + } + defer logs.CloseAndLogErr(out) + + return pem.Encode(out, block) +} + +func (service *Service) getContentFromPEMFile(filePath string) ([]byte, error) { + path := service.wrapFileStore(filePath) + + fileContent, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + block, _ := pem.Decode(fileContent) + return block.Bytes, nil +} + +// GetCustomTemplateProjectPath returns the absolute path on the FS for a custom template based +// on its identifier. +func (service *Service) GetCustomTemplateProjectPath(identifier string) string { + return JoinPaths(service.wrapFileStore(CustomTemplateStorePath), identifier) +} + +// 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) StoreCustomTemplateFileFromBytes(identifier, fileName string, data []byte) (string, error) { + customTemplateStorePath := JoinPaths(CustomTemplateStorePath, identifier) + err := service.createDirectoryInStore(customTemplateStorePath) + if err != nil { + return "", err + } + + templateFilePath := JoinPaths(customTemplateStorePath, fileName) + r := bytes.NewReader(data) + + err = service.createFileInStore(templateFilePath, r) + if err != nil { + return "", err + } + + return service.wrapFileStore(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 JoinPaths(service.wrapFileStore(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 := JoinPaths(EdgeJobStorePath, identifier) + err := service.createDirectoryInStore(edgeJobStorePath) + if err != nil { + return "", err + } + + filePath := JoinPaths(edgeJobStorePath, createEdgeJobFileName(identifier)) + r := bytes.NewReader(data) + err = service.createFileInStore(filePath, r) + if err != nil { + return "", err + } + + return service.wrapFileStore(filePath), nil +} + +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) + return os.Remove(path) +} + +// GetEdgeJobTaskLogFileContent fetches the Edge job task logs +func (service *Service) GetEdgeJobTaskLogFileContent(edgeJobID string, taskID string) (string, error) { + path := service.getEdgeJobTaskLogPath(edgeJobID, taskID) + + fileContent, err := os.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 := JoinPaths(EdgeJobStorePath, edgeJobID) + err := service.createDirectoryInStore(edgeJobStorePath) + if err != nil { + return err + } + + filePath := JoinPaths(edgeJobStorePath, "logs_"+taskID) + r := bytes.NewReader(data) + return service.createFileInStore(filePath, r) +} + +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.NewRandom() + if err != nil { + return "", err + } + + return JoinPaths(service.wrapFileStore(TempPath), uid.String()), nil +} + +// GetDataStorePath returns path to data folder +func (service *Service) GetDatastorePath() string { + return service.dataStorePath +} + +func (service *Service) wrapFileStore(filepath string) string { + return JoinPaths(service.fileStorePath, filepath) +} + +func defaultCertPathUnderFileStore() (string, string) { + certPath := JoinPaths(SSLCertPath, SSLCertFilename) + keyPath := JoinPaths(SSLCertPath, SSLKeyFilename) + return certPath, keyPath +} + +// GetDefaultSSLCertsPath returns the ssl certs path +func (service *Service) GetDefaultSSLCertsPath() (string, string) { + certPath, keyPath := defaultCertPathUnderFileStore() + return service.wrapFileStore(certPath), service.wrapFileStore(keyPath) +} + +func defaultMTLSCertPathUnderFileStore() (string, string, string) { + caCertPath := JoinPaths(SSLCertPath, MTLSCACertFilename) + certPath := JoinPaths(SSLCertPath, MTLSCertFilename) + keyPath := JoinPaths(SSLCertPath, MTLSKeyFilename) + + return caCertPath, certPath, keyPath +} + +// GetDefaultChiselPrivateKeyPath returns the chisel private key path +func (service *Service) GetDefaultChiselPrivateKeyPath() string { + privateKeyPath := defaultChiselPrivateKeyPathUnderFileStore() + return service.wrapFileStore(privateKeyPath) +} + +func defaultChiselPrivateKeyPathUnderFileStore() string { + return JoinPaths(ChiselPath, ChiselPrivateKeyFilename) +} + +// StoreChiselPrivateKey store the specified chisel private key content on disk. +func (service *Service) StoreChiselPrivateKey(privateKey []byte) error { + err := service.createDirectoryInStore(ChiselPath) + if err != nil && !os.IsExist(err) { + return err + } + + r := bytes.NewReader(privateKey) + privateKeyPath := defaultChiselPrivateKeyPathUnderFileStore() + return service.createFileInStore(privateKeyPath, r) +} + +// StoreSSLCertPair stores a ssl certificate pair +func (service *Service) StoreSSLCertPair(cert, key []byte) (string, string, error) { + certPath, keyPath := defaultCertPathUnderFileStore() + + r := bytes.NewReader(cert) + err := service.createFileInStore(certPath, r) + if err != nil { + return "", "", err + } + + r = bytes.NewReader(key) + err = service.createFileInStore(keyPath, r) + if err != nil { + return "", "", err + } + + return service.wrapFileStore(certPath), service.wrapFileStore(keyPath), nil +} + +// CopySSLCertPair copies a ssl certificate pair +func (service *Service) CopySSLCertPair(certPath, keyPath string) (string, string, error) { + defCertPath, defKeyPath := service.GetDefaultSSLCertsPath() + + err := service.Copy(certPath, defCertPath, true) + if err != nil { + return "", "", err + } + + err = service.Copy(keyPath, defKeyPath, true) + if err != nil { + return "", "", err + } + + return defCertPath, defKeyPath, nil +} + +// CopySSLCACert copies the specified caCert pem file +func (service *Service) CopySSLCACert(caCertPath string) (string, error) { + toFilePath := service.wrapFileStore(JoinPaths(SSLCertPath, SSLCACertFilename)) + + err := service.Copy(caCertPath, toFilePath, true) + if err != nil { + return "", err + } + + return toFilePath, nil +} + +// FileExists checks for the existence of the specified file. +func FileExists(filePath string) (bool, error) { + if _, err := os.Stat(filePath); err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, err + } + return true, nil +} + +// SafeCopyDirectory copies a directory from src to dst in a safe way. +func (service *Service) SafeMoveDirectory(originalPath, newPath string) error { + // 1. Backup the source directory to a different folder + backupDir := fmt.Sprintf("%s-%s", filepath.Dir(originalPath), "backup") + err := MoveDirectory(originalPath, backupDir, false) + if err != nil { + return fmt.Errorf("failed to backup source directory: %w", err) + } + + defer func() { + if err != nil { + // If an error occurred, rollback the backup directory + restoreErr := restoreBackup(originalPath, backupDir) + if restoreErr != nil { + log.Warn().Err(restoreErr).Msg("failed to restore backup during creating versioning folder") + } + } + }() + + // 2. Copy the backup directory to the destination directory + err = CopyDir(backupDir, newPath, false) + if err != nil { + return fmt.Errorf("failed to copy backup directory to destination directory: %w", err) + } + + // 3. Delete the backup directory + err = os.RemoveAll(backupDir) + if err != nil { + return fmt.Errorf("failed to delete backup directory: %w", err) + } + + return nil +} + +func restoreBackup(src, backupDir string) error { + // Rollback by deleting the original directory and copying the + // backup direcotry back to the source directory, and then deleting + // the backup directory + err := os.RemoveAll(src) + if err != nil { + return fmt.Errorf("failed to delete destination directory: %w", err) + } + + err = MoveDirectory(backupDir, src, false) + if err != nil { + return fmt.Errorf("failed to restore backup directory: %w", err) + } + return nil +} + +func MoveDirectory(originalPath, newPath string, overwriteTargetPath bool) error { + if _, err := os.Stat(originalPath); err != nil { + return err + } + + alreadyExists, err := FileExists(newPath) + if err != nil { + return err + } + + if alreadyExists { + if !overwriteTargetPath { + return errors.New("Target path already exists") + } + + if err = os.RemoveAll(newPath); err != nil { + return fmt.Errorf("failed to overwrite path %s: %s", newPath, err.Error()) + } + } + + return os.Rename(originalPath, newPath) +} + +func CreateFile(path string, r io.Reader) error { + out, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) + if err != nil { + return err + } + + defer logs.CloseAndLogErr(out) + + _, err = io.Copy(out, r) + return err +} + +func (service *Service) StoreMTLSCertificates(caCert, cert, key []byte) (string, string, string, error) { + caCertPath, certPath, keyPath := defaultMTLSCertPathUnderFileStore() + + r := bytes.NewReader(caCert) + if err := service.createFileInStore(caCertPath, r); err != nil { + return "", "", "", err + } + + r = bytes.NewReader(cert) + if err := service.createFileInStore(certPath, r); err != nil { + return "", "", "", err + } + + r = bytes.NewReader(key) + if err := service.createFileInStore(keyPath, r); err != nil { + return "", "", "", err + } + + return service.wrapFileStore(caCertPath), service.wrapFileStore(certPath), service.wrapFileStore(keyPath), nil +} + +func (service *Service) GetMTLSCertificates() (string, string, string, error) { + caCertPath, certPath, keyPath := defaultMTLSCertPathUnderFileStore() + + caCertPath = service.wrapFileStore(caCertPath) + certPath = service.wrapFileStore(certPath) + keyPath = service.wrapFileStore(keyPath) + + paths := [...]string{caCertPath, certPath, keyPath} + for _, path := range paths { + exists, err := service.FileExists(path) + if err != nil { + return "", "", "", err + } + + if !exists { + return "", "", "", fmt.Errorf("file %s does not exist", path) + } + } + + return caCertPath, certPath, keyPath, nil +} diff --git a/api/filesystem/filesystem_fileexists_test.go b/api/filesystem/filesystem_fileexists_test.go new file mode 100644 index 0000000..1555604 --- /dev/null +++ b/api/filesystem/filesystem_fileexists_test.go @@ -0,0 +1,60 @@ +package filesystem + +import ( + "fmt" + "math/rand" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_fileSystemService_FileExists_whenFileExistsShouldReturnTrue(t *testing.T) { + t.Parallel() + service := createService(t) + testHelperFileExists_fileExists(t, service.FileExists) +} + +func Test_fileSystemService_FileExists_whenFileNotExistsShouldReturnFalse(t *testing.T) { + t.Parallel() + service := createService(t) + testHelperFileExists_fileNotExists(t, service.FileExists) +} + +func Test_FileExists_whenFileExistsShouldReturnTrue(t *testing.T) { + t.Parallel() + testHelperFileExists_fileExists(t, FileExists) +} + +func Test_FileExists_whenFileNotExistsShouldReturnFalse(t *testing.T) { + t.Parallel() + testHelperFileExists_fileNotExists(t, FileExists) +} + +func testHelperFileExists_fileExists(t *testing.T, checker func(path string) (bool, error)) { + file, err := os.CreateTemp(t.TempDir(), t.Name()) + require.NoError(t, err, "CreateTemp should not fail") + + t.Cleanup(func() { + err := os.RemoveAll(file.Name()) + require.NoError(t, err) + }) + + exists, err := checker(file.Name()) + require.NoError(t, err, "FileExists should not fail") + + assert.True(t, exists) +} + +func testHelperFileExists_fileNotExists(t *testing.T, checker func(path string) (bool, error)) { + filePath := JoinPaths(t.TempDir(), fmt.Sprintf("%s%d", t.Name(), rand.Int())) + + err := os.RemoveAll(filePath) + require.NoError(t, err, "RemoveAll should not fail") + + exists, err := checker(filePath) + require.NoError(t, err, "FileExists should not fail") + + assert.False(t, exists) +} diff --git a/api/filesystem/filesystem_linux_test.go b/api/filesystem/filesystem_linux_test.go new file mode 100644 index 0000000..58c77bd --- /dev/null +++ b/api/filesystem/filesystem_linux_test.go @@ -0,0 +1,71 @@ +package filesystem + +import "testing" + +func TestJoinPaths(t *testing.T) { + t.Parallel() + var ts = []struct { + trusted string + untrusted string + expected string + }{ + {"", "", "."}, + {"", ".", "."}, + {"", "d/e/f", "d/e/f"}, + {"", "./d/e/f", "d/e/f"}, + {"", "../d/e/f", "d/e/f"}, + {"", "/d/e/f", "d/e/f"}, + {"", "../../../etc/shadow", "etc/shadow"}, + + {".", "", "."}, + {".", ".", "."}, + {".", "d/e/f", "d/e/f"}, + {".", "./d/e/f", "d/e/f"}, + {".", "../d/e/f", "d/e/f"}, + {".", "/d/e/f", "d/e/f"}, + {".", "../../../etc/shadow", "etc/shadow"}, + + {"./", "", "."}, + {"./", ".", "."}, + {"./", "d/e/f", "d/e/f"}, + {"./", "./d/e/f", "d/e/f"}, + {"./", "../d/e/f", "d/e/f"}, + {"./", "/d/e/f", "d/e/f"}, + {"./", "../../../etc/shadow", "etc/shadow"}, + + {"a/b/c", "", "a/b/c"}, + {"a/b/c", ".", "a/b/c"}, + {"a/b/c", "d/e/f", "a/b/c/d/e/f"}, + {"a/b/c", "./d/e/f", "a/b/c/d/e/f"}, + {"a/b/c", "../d/e/f", "a/b/c/d/e/f"}, + {"a/b/c", "../../../etc/shadow", "a/b/c/etc/shadow"}, + + {"/a/b/c", "", "/a/b/c"}, + {"/a/b/c", ".", "/a/b/c"}, + {"/a/b/c", "d/e/f", "/a/b/c/d/e/f"}, + {"/a/b/c", "./d/e/f", "/a/b/c/d/e/f"}, + {"/a/b/c", "../d/e/f", "/a/b/c/d/e/f"}, + {"/a/b/c", "../../../etc/shadow", "/a/b/c/etc/shadow"}, + + {"./a/b/c", "", "a/b/c"}, + {"./a/b/c", ".", "a/b/c"}, + {"./a/b/c", "d/e/f", "a/b/c/d/e/f"}, + {"./a/b/c", "./d/e/f", "a/b/c/d/e/f"}, + {"./a/b/c", "../d/e/f", "a/b/c/d/e/f"}, + {"./a/b/c", "../../../etc/shadow", "a/b/c/etc/shadow"}, + + {"../a/b/c", "", "../a/b/c"}, + {"../a/b/c", ".", "../a/b/c"}, + {"../a/b/c", "d/e/f", "../a/b/c/d/e/f"}, + {"../a/b/c", "./d/e/f", "../a/b/c/d/e/f"}, + {"../a/b/c", "../d/e/f", "../a/b/c/d/e/f"}, + {"../a/b/c", "../../../etc/shadow", "../a/b/c/etc/shadow"}, + } + + for _, c := range ts { + r := JoinPaths(c.trusted, c.untrusted) + if r != c.expected { + t.Fatalf("expected '%s', got '%s'. Inputs = '%s', '%s'", c.expected, r, c.trusted, c.untrusted) + } + } +} diff --git a/api/filesystem/filesystem_move_test.go b/api/filesystem/filesystem_move_test.go new file mode 100644 index 0000000..e8a5354 --- /dev/null +++ b/api/filesystem/filesystem_move_test.go @@ -0,0 +1,97 @@ +package filesystem + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var content = []byte("content") + +func Test_movePath_shouldFailIfSourceDirDoesNotExist(t *testing.T) { + t.Parallel() + sourceDir := "missing" + destinationDir := t.TempDir() + file1 := addFile(t, destinationDir, "dir", "file") + file2 := addFile(t, destinationDir, "file") + + err := MoveDirectory(sourceDir, destinationDir, false) + require.Error(t, err, "move directory should fail when source path is missing") + assert.FileExists(t, file1, "destination dir contents should remain") + assert.FileExists(t, file2, "destination dir contents should remain") +} + +func Test_movePath_shouldFailIfDestinationDirExists(t *testing.T) { + t.Parallel() + sourceDir := t.TempDir() + file1 := addFile(t, sourceDir, "dir", "file") + file2 := addFile(t, sourceDir, "file") + destinationDir := t.TempDir() + file3 := addFile(t, destinationDir, "dir", "file") + file4 := addFile(t, destinationDir, "file") + + err := MoveDirectory(sourceDir, destinationDir, false) + require.Error(t, err, "move directory should fail when destination directory already exists") + assert.FileExists(t, file1, "source dir contents should remain") + assert.FileExists(t, file2, "source dir contents should remain") + assert.FileExists(t, file3, "destination dir contents should remain") + assert.FileExists(t, file4, "destination dir contents should remain") +} + +func Test_movePath_succesIfOverwriteSetWhenDestinationDirExists(t *testing.T) { + t.Parallel() + sourceDir := t.TempDir() + file1 := addFile(t, sourceDir, "dir", "file") + file2 := addFile(t, sourceDir, "file") + destinationDir := t.TempDir() + file3 := addFile(t, destinationDir, "dir", "file") + file4 := addFile(t, destinationDir, "file") + + err := MoveDirectory(sourceDir, destinationDir, true) + require.NoError(t, err) + assert.NoFileExists(t, file1, "source dir contents should be moved") + assert.NoFileExists(t, file2, "source dir contents should be moved") + assert.FileExists(t, file3, "destination dir contents should remain") + assert.FileExists(t, file4, "destination dir contents should remain") +} + +func Test_movePath_successWhenSourceExistsAndDestinationIsMissing(t *testing.T) { + t.Parallel() + tmp := t.TempDir() + sourceDir := JoinPaths(tmp, "source") + err := os.Mkdir(sourceDir, 0766) + require.NoError(t, err) + + file1 := addFile(t, sourceDir, "dir", "file") + file2 := addFile(t, sourceDir, "file") + destinationDir := JoinPaths(tmp, "destination") + + err = MoveDirectory(sourceDir, destinationDir, false) + require.NoError(t, err) + assert.NoFileExists(t, file1, "source dir contents should be moved") + assert.NoFileExists(t, file2, "source dir contents should be moved") + assertFileContent(t, JoinPaths(destinationDir, "file")) + assertFileContent(t, JoinPaths(destinationDir, "dir", "file")) +} + +func addFile(t *testing.T, fileParts ...string) (filepath string) { + if len(fileParts) > 2 { + dir := JoinPaths(fileParts[0], fileParts[1:len(fileParts)-1]...) + err := os.MkdirAll(dir, 0766) + require.NoError(t, err) + } + + p := JoinPaths(fileParts[0], fileParts[1:]...) + err := os.WriteFile(p, content, 0766) + require.NoError(t, err) + + return p +} + +func assertFileContent(t *testing.T, filePath string) { + actualContent, err := os.ReadFile(filePath) + require.NoError(t, err, "failed to read file %s", filePath) + assert.Equal(t, content, actualContent, "file %s content doesn't match", filePath) +} diff --git a/api/filesystem/filesystem_test.go b/api/filesystem/filesystem_test.go new file mode 100644 index 0000000..e2a702f --- /dev/null +++ b/api/filesystem/filesystem_test.go @@ -0,0 +1,22 @@ +package filesystem + +import ( + "os" + "testing" + + "github.com/stretchr/testify/require" +) + +func createService(t *testing.T) *Service { + dataStorePath := JoinPaths(t.TempDir(), t.Name()) + + service, err := NewService(dataStorePath, "") + require.NoError(t, err, "NewService should not fail") + + t.Cleanup(func() { + err := os.RemoveAll(dataStorePath) + require.NoError(t, err) + }) + + return service +} diff --git a/api/filesystem/filesystem_windows_test.go b/api/filesystem/filesystem_windows_test.go new file mode 100644 index 0000000..06f8378 --- /dev/null +++ b/api/filesystem/filesystem_windows_test.go @@ -0,0 +1,121 @@ +package filesystem + +import "testing" + +func TestJoinPaths(t *testing.T) { + t.Parallel() + var ts = []struct { + trusted string + untrusted string + expected string + }{ + {"", "", "."}, + {"", ".", "."}, + {"", "d/e/f", `d\e\f`}, + {"", "./d/e/f", `d\e\f`}, + {"", "../d/e/f", `d\e\f`}, + {"", "/d/e/f", `d\e\f`}, + {"", "../../../windows/system.ini", `windows\system.ini`}, + {"", `C:\windows\system.ini`, `windows\system.ini`}, + {"", `..\..\..\..\C:\windows\system.ini`, `windows\system.ini`}, + {"", `\\server\a\b\c`, `server\a\b\c`}, + {"", `..\..\..\..\\server\a\b\c`, `server\a\b\c`}, + + {".", "", "."}, + {".", ".", "."}, + {".", "d/e/f", `d\e\f`}, + {".", "./d/e/f", `d\e\f`}, + {".", "../d/e/f", `d\e\f`}, + {".", "/d/e/f", `d\e\f`}, + {".", "../../../windows/system.ini", `windows\system.ini`}, + {".", `C:\windows\system.ini`, `windows\system.ini`}, + {".", `..\..\..\..\C:\windows\system.ini`, `windows\system.ini`}, + {".", `\\server\a\b\c`, `server\a\b\c`}, + {".", `..\..\..\..\\server\a\b\c`, `server\a\b\c`}, + + {"./", "", "."}, + {"./", ".", "."}, + {"./", "d/e/f", `d\e\f`}, + {"./", "./d/e/f", `d\e\f`}, + {"./", "../d/e/f", `d\e\f`}, + {"./", "/d/e/f", `d\e\f`}, + {"./", "../../../windows/system.ini", `windows\system.ini`}, + {"./", `C:\windows\system.ini`, `windows\system.ini`}, + {"./", `..\..\..\..\C:\windows\system.ini`, `windows\system.ini`}, + {"./", `\\server\a\b\c`, `server\a\b\c`}, + {"./", `..\..\..\..\\server\a\b\c`, `server\a\b\c`}, + + {"a/b/c", "", `a\b\c`}, + {"a/b/c", ".", `a\b\c`}, + {"a/b/c", "d/e/f", `a\b\c\d\e\f`}, + {"a/b/c", "./d/e/f", `a\b\c\d\e\f`}, + {"a/b/c", "../d/e/f", `a\b\c\d\e\f`}, + {"a/b/c", "../../../windows/system.ini", `a\b\c\windows\system.ini`}, + {"a/b/c", `C:\windows\system.ini`, `a\b\c\C:\windows\system.ini`}, + {"a/b/c", `..\..\..\..\C:\windows\system.ini`, `a\b\c\C:\windows\system.ini`}, + {"a/b/c", `\\server\a\b\c`, `a\b\c\server\a\b\c`}, + {"a/b/c", `..\..\..\..\\server\a\b\c`, `a\b\c\server\a\b\c`}, + + {"/a/b/c", "", `\a\b\c`}, + {"/a/b/c", ".", `\a\b\c`}, + {"/a/b/c", "d/e/f", `\a\b\c\d\e\f`}, + {"/a/b/c", "./d/e/f", `\a\b\c\d\e\f`}, + {"/a/b/c", "../d/e/f", `\a\b\c\d\e\f`}, + {"/a/b/c", "../../../windows/system.ini", `\a\b\c\windows\system.ini`}, + {"/a/b/c", `C:\windows\system.ini`, `\a\b\c\C:\windows\system.ini`}, + {"/a/b/c", `..\..\..\..\C:\windows\system.ini`, `\a\b\c\C:\windows\system.ini`}, + {"/a/b/c", `\\server\a\b\c`, `\a\b\c\server\a\b\c`}, + {"/a/b/c", `..\..\..\..\\server\a\b\c`, `\a\b\c\server\a\b\c`}, + + {"./a/b/c", "", `a\b\c`}, + {"./a/b/c", ".", `a\b\c`}, + {"./a/b/c", "d/e/f", `a\b\c\d\e\f`}, + {"./a/b/c", "./d/e/f", `a\b\c\d\e\f`}, + {"./a/b/c", "../d/e/f", `a\b\c\d\e\f`}, + {"./a/b/c", "../../../windows/system.ini", `a\b\c\windows\system.ini`}, + {"./a/b/c", `C:\windows\system.ini`, `a\b\c\C:\windows\system.ini`}, + {"./a/b/c", `..\..\..\..\C:\windows\system.ini`, `a\b\c\C:\windows\system.ini`}, + {"./a/b/c", `\\server\a\b\c`, `a\b\c\server\a\b\c`}, + {"./a/b/c", `..\..\..\..\\server\a\b\c`, `a\b\c\server\a\b\c`}, + + {"../a/b/c", "", `..\a\b\c`}, + {"../a/b/c", ".", `..\a\b\c`}, + {"../a/b/c", "d/e/f", `..\a\b\c\d\e\f`}, + {"../a/b/c", "./d/e/f", `..\a\b\c\d\e\f`}, + {"../a/b/c", "../d/e/f", `..\a\b\c\d\e\f`}, + {"../a/b/c", "../../../windows/system.ini", `..\a\b\c\windows\system.ini`}, + {"../a/b/c", `C:\windows\system.ini`, `..\a\b\c\C:\windows\system.ini`}, + {"../a/b/c", `..\..\..\..\C:\windows\system.ini`, `..\a\b\c\C:\windows\system.ini`}, + {"../a/b/c", `\\server\a\b\c`, `..\a\b\c\server\a\b\c`}, + {"../a/b/c", `..\..\..\..\\server\a\b\c`, `..\a\b\c\server\a\b\c`}, + + {"C:/a/b/c", "", `C:\a\b\c`}, + {"C:/a/b/c", ".", `C:\a\b\c`}, + {"C:/a/b/c", "d/e/f", `C:\a\b\c\d\e\f`}, + {"C:/a/b/c", "./d/e/f", `C:\a\b\c\d\e\f`}, + {"C:/a/b/c", "../d/e/f", `C:\a\b\c\d\e\f`}, + {"C:/a/b/c", "../../../windows/system.ini", `C:\a\b\c\windows\system.ini`}, + {"C:/a/b/c", `C:\windows\system.ini`, `C:\a\b\c\C:\windows\system.ini`}, + {"C:/a/b/c", `..\..\..\..\C:\windows\system.ini`, `C:\a\b\c\C:\windows\system.ini`}, + {"C:/a/b/c", `\\server\a\b\c`, `C:\a\b\c\server\a\b\c`}, + {"C:/a/b/c", `..\..\..\..\\server\a\b\c`, `C:\a\b\c\server\a\b\c`}, + + {`\\server\a\b\c`, "", `\\server\a\b\c`}, + {`\\server\a\b\c`, ".", `\\server\a\b\c`}, + {`\\server\a\b\c`, "d/e/f", `\\server\a\b\c\d\e\f`}, + {`\\server\a\b\c`, "./d/e/f", `\\server\a\b\c\d\e\f`}, + {`\\server\a\b\c`, "../d/e/f", `\\server\a\b\c\d\e\f`}, + {`\\server\a\b\c`, "../../../windows/system.ini", `\\server\a\b\c\windows\system.ini`}, + {`\\server\a\b\c`, `C:\windows\system.ini`, `\\server\a\b\c\C:\windows\system.ini`}, + {`\\server\a\b\c`, `..\..\..\C:\windows\system.ini`, `\\server\a\b\c\C:\windows\system.ini`}, + {`\\server\a\b\c`, `\\server\a\b\c`, `\\server\a\b\c\server\a\b\c`}, + {`\\server\a\b\c`, `..\..\..\\server\a\b\c`, `\\server\a\b\c\server\a\b\c`}, + } + + for _, c := range ts { + r := JoinPaths(c.trusted, c.untrusted) + if r != c.expected { + t.Fatalf("expected '%s', got '%s'. Inputs = '%s', '%s'", c.expected, r, c.trusted, c.untrusted) + } + } +} diff --git a/api/filesystem/serialize.go b/api/filesystem/serialize.go new file mode 100644 index 0000000..8dfeca2 --- /dev/null +++ b/api/filesystem/serialize.go @@ -0,0 +1,185 @@ +package filesystem + +import ( + "encoding/base64" + "fmt" + "os" + "path/filepath" + "slices" + "strings" + + "golang.org/x/mod/semver" +) + +type DirEntry struct { + Name string + Content string + IsFile bool + Permissions os.FileMode `swaggertype:"integer"` +} + +// FilterDirForEntryFile filers the given dirEntries, returns entries of the entryFile and .env file +func FilterDirForEntryFile(dirEntries []DirEntry, entryFile string) []DirEntry { + var filteredDirEntries []DirEntry + + dotEnvFile := filepath.Join(filepath.Dir(entryFile), ".env") + filters := []string{entryFile, dotEnvFile} + + for _, dirEntry := range dirEntries { + match := false + if dirEntry.IsFile { + if slices.Contains(filters, dirEntry.Name) { + match = true + } + } else { + for _, filter := range filters { + if strings.HasPrefix(filter, dirEntry.Name) { + match = true + break + } + } + } + if match { + filteredDirEntries = append(filteredDirEntries, dirEntry) + } + } + + return filteredDirEntries +} + +// FilterDirForCompatibility returns the content of the entry file if agent version is less than 2.19.0 +func FilterDirForCompatibility(dirEntries []DirEntry, entryFilePath, agentVersion string) (string, error) { + if semver.Compare("v"+agentVersion, "v2.19.0") == -1 { + for _, dirEntry := range dirEntries { + if dirEntry.IsFile { + if dirEntry.Name == entryFilePath { + return DecodeFileContent(dirEntry.Content) + } + } + } + return "", fmt.Errorf("Entry file %s not found in dir entries", entryFilePath) + } + + return "", nil +} + +// LoadDir reads all files and folders recursively from the given directory +// File content is base64-encoded +func LoadDir(dir string) ([]DirEntry, error) { + var dirEntries []DirEntry + + err := filepath.WalkDir( + dir, + func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + + fileInfo, err := d.Info() + if err != nil { + return err + } + + relativePath, err := filepath.Rel(dir, path) + if err != nil { + return err + } + if relativePath == "." { + return nil + } + + dirEntry := DirEntry{ + Name: relativePath, + Permissions: fileInfo.Mode().Perm(), + } + + if !fileInfo.IsDir() { + // Read file contents + fileContent, err := os.ReadFile(path) + if err != nil { + return err + } + + dirEntry.Content = base64.StdEncoding.EncodeToString(fileContent) + dirEntry.IsFile = true + } + + dirEntries = append(dirEntries, dirEntry) + return nil + }) + + if err != nil { + return nil, err + } + + return dirEntries, nil +} + +// PersistDir writes the provided array of files and folders back to the given directory. +func PersistDir(dir string, dirEntries []DirEntry) error { + for _, dirEntry := range dirEntries { + path := filepath.Join(dir, dirEntry.Name) + + if dirEntry.IsFile { + // Create the directory path if it doesn't exist + err := os.MkdirAll(filepath.Dir(path), 0744) + if err != nil { + return err + } + + // Write file contents + err = os.WriteFile(path, []byte(dirEntry.Content), dirEntry.Permissions) + if err != nil { + return err + } + } else { + // Create the directory + err := os.MkdirAll(path, dirEntry.Permissions) + if err != nil { + return err + } + } + } + + return nil +} + +func DecodeFileContent(encodedFileContent string) (string, error) { + decodedBytes, err := base64.StdEncoding.DecodeString(encodedFileContent) + if err != nil { + return "", err + } + return string(decodedBytes), nil +} + +func DecodeDirEntries(dirEntries []DirEntry) error { + for index, dirEntry := range dirEntries { + if dirEntry.IsFile && dirEntry.Content != "" { + decodedFile, err := DecodeFileContent(dirEntry.Content) + if err != nil { + return err + } + dirEntries[index].Content = decodedFile + } + } + + return nil +} + +// GetDirEntriesByFilenames returns the dir entries that are files and match the provided filenames +func GetDirEntriesByFilenames(dirEntries []DirEntry, names []string) []DirEntry { + var filteredDirEntries []DirEntry + + for _, dirEntry := range dirEntries { + if !dirEntry.IsFile { + continue + } + for _, name := range names { + if dirEntry.Name == name { + filteredDirEntries = append(filteredDirEntries, dirEntry) + } + } + } + + return filteredDirEntries +} diff --git a/api/filesystem/serialize_per_dev_configs.go b/api/filesystem/serialize_per_dev_configs.go new file mode 100644 index 0000000..d603ec7 --- /dev/null +++ b/api/filesystem/serialize_per_dev_configs.go @@ -0,0 +1,156 @@ +package filesystem + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + portainer "github.com/portainer/portainer/api" +) + +type MultiFilterArgs []struct { + FilterKey string + FilterType portainer.PerDevConfigsFilterType +} + +// MultiFilterDirForPerDevConfigs filers the given dirEntries with multiple filter args, returns the merged entries for the given device +func MultiFilterDirForPerDevConfigs(dirEntries []DirEntry, configPath string, multiFilterArgs MultiFilterArgs) ([]DirEntry, []string) { + var filteredDirEntries []DirEntry + + var envFiles []string + + for _, multiFilterArg := range multiFilterArgs { + tmp, efs := FilterDirForPerDevConfigs(dirEntries, multiFilterArg.FilterKey, configPath, multiFilterArg.FilterType) + filteredDirEntries = append(filteredDirEntries, tmp...) + + envFiles = append(envFiles, efs...) + } + + return deduplicate(filteredDirEntries), envFiles +} + +// MultiFilterDirForPerDevConfigsWithDefaults filers the given dirEntries with multiple filter args, returns the merged entries for the given device +// and always includes the defaultFilenames +func MultiFilterDirForPerDevConfigsWithDefaults(dirEntries []DirEntry, configPath string, multiFilterArgs MultiFilterArgs, defaultFilenames []string) ([]DirEntry, []string) { + + filteredDirEntries, envFiles := MultiFilterDirForPerDevConfigs(dirEntries, configPath, multiFilterArgs) + + // Add files that should always be included + // e.g. entrypoint files + defaultDirEntries := GetDirEntriesByFilenames(dirEntries, defaultFilenames) + filteredDirEntries = append(filteredDirEntries, defaultDirEntries...) + + return deduplicate(filteredDirEntries), envFiles +} + +func deduplicate(dirEntries []DirEntry) []DirEntry { + var deduplicatedDirEntries []DirEntry + + marks := make(map[string]struct{}) + + for _, dirEntry := range dirEntries { + if _, ok := marks[dirEntry.Name]; !ok { + marks[dirEntry.Name] = struct{}{} + deduplicatedDirEntries = append(deduplicatedDirEntries, dirEntry) + } + } + + return deduplicatedDirEntries +} + +// FilterDirForPerDevConfigs filers the given dirEntries, returns entries for the given device +// For given configPath A/B/C, return entries: +// 1. all entries outside of dir A/B/C +// 2. For filterType file: +// file entries: A/B/C/ and A/B/C/.* +// 3. For filterType dir: +// dir entry: A/B/C/ +// all entries: A/B/C//* +func FilterDirForPerDevConfigs(dirEntries []DirEntry, deviceName, configPath string, filterType portainer.PerDevConfigsFilterType) ([]DirEntry, []string) { + var filteredDirEntries []DirEntry + + var envFiles []string + + for _, dirEntry := range dirEntries { + if shouldIncludeEntry(dirEntry, deviceName, configPath, filterType) { + filteredDirEntries = append(filteredDirEntries, dirEntry) + + if shouldParseEnvVars(dirEntry, deviceName, configPath, filterType) { + envFiles = append(envFiles, dirEntry.Name) + } + } + } + + return filteredDirEntries, envFiles +} + +func shouldIncludeEntry(dirEntry DirEntry, deviceName, configPath string, filterType portainer.PerDevConfigsFilterType) bool { + // Include all entries outside of dir A + if !isInConfigDir(dirEntry, configPath) { + return true + } + + if filterType == portainer.PerDevConfigsTypeFile { + // Include file entries A/B/C/ or A/B/C/.* + return shouldIncludeFile(dirEntry, deviceName, configPath) + } + + if filterType == portainer.PerDevConfigsTypeDir { + // Include: + // dir entry A/B/C/ + // all entries A/B/C//* + return shouldIncludeDir(dirEntry, deviceName, configPath) + } + + return false +} + +func isInConfigDir(dirEntry DirEntry, configPath string) bool { + // return true if entry name starts with "A/B" + return strings.HasPrefix(dirEntry.Name, appendTailSeparator(configPath)) +} + +func shouldIncludeFile(dirEntry DirEntry, deviceName, configPath string) bool { + if !dirEntry.IsFile { + return false + } + + // example: A/B/C/ + filterEqual := filepath.Join(configPath, deviceName) + + // example: A/B/C// + filterPrefix := filterEqual + "." + + // include file entries: A/B/C/ or A/B/C/.* + return dirEntry.Name == filterEqual || strings.HasPrefix(dirEntry.Name, filterPrefix) +} + +func shouldIncludeDir(dirEntry DirEntry, deviceName, configPath string) bool { + // example: A/B/C/'/ + filterEqual := filepath.Join(configPath, deviceName) + + // example: A/B/C// + filterPrefix := appendTailSeparator(filterEqual) + + // include dir entry: A/B/C/ + if !dirEntry.IsFile && dirEntry.Name == filterEqual { + return true + } + + // include all entries A/B/C//* + return strings.HasPrefix(dirEntry.Name, filterPrefix) +} + +func shouldParseEnvVars(dirEntry DirEntry, deviceName, configPath string, filterType portainer.PerDevConfigsFilterType) bool { + if !dirEntry.IsFile { + return false + } + + return isInConfigDir(dirEntry, configPath) && + filepath.Base(dirEntry.Name) == deviceName+".env" +} + +func appendTailSeparator(path string) string { + return fmt.Sprintf("%s%c", path, os.PathSeparator) +} diff --git a/api/filesystem/serialize_per_dev_configs_test.go b/api/filesystem/serialize_per_dev_configs_test.go new file mode 100644 index 0000000..84e739a --- /dev/null +++ b/api/filesystem/serialize_per_dev_configs_test.go @@ -0,0 +1,242 @@ +package filesystem + +import ( + "testing" + + portainer "github.com/portainer/portainer/api" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMultiFilterDirForPerDevConfigs(t *testing.T) { + t.Parallel() + f := func(dirEntries []DirEntry, configPath string, multiFilterArgs MultiFilterArgs, wantDirEntries []DirEntry) { + t.Helper() + + dirEntries, _ = MultiFilterDirForPerDevConfigs(dirEntries, configPath, multiFilterArgs) + require.Equal(t, wantDirEntries, dirEntries) + } + + baseDirEntries := []DirEntry{ + {".env", "", true, 420}, + {"docker-compose.yaml", "", true, 420}, + {"configs", "", false, 420}, + {"configs/file1.conf", "", true, 420}, + {"configs/file2.conf", "", true, 420}, + {"configs/folder1", "", false, 420}, + {"configs/folder1/config1", "", true, 420}, + {"configs/folder2", "", false, 420}, + {"configs/folder2/config2", "", true, 420}, + } + + // Filter file1 + f( + baseDirEntries, + "configs", + MultiFilterArgs{{"file1", portainer.PerDevConfigsTypeFile}}, + []DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[3]}, + ) + + // Filter folder1 + f( + baseDirEntries, + "configs", + MultiFilterArgs{{"folder1", portainer.PerDevConfigsTypeDir}}, + []DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[5], baseDirEntries[6]}, + ) + + // Filter file1 and folder1 + f( + baseDirEntries, + "configs", + MultiFilterArgs{ + {"file1", portainer.PerDevConfigsTypeFile}, + {"folder1", portainer.PerDevConfigsTypeDir}, + }, + []DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[3], baseDirEntries[5], baseDirEntries[6]}, + ) + + // Filter file1 and file2 + f( + baseDirEntries, + "configs", + MultiFilterArgs{ + {"file1", portainer.PerDevConfigsTypeFile}, + {"file2", portainer.PerDevConfigsTypeFile}, + }, + []DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[3], baseDirEntries[4]}, + ) + + // Filter folder1 and folder2 + f( + baseDirEntries, + "configs", + MultiFilterArgs{ + {"folder1", portainer.PerDevConfigsTypeDir}, + {"folder2", portainer.PerDevConfigsTypeDir}, + }, + []DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[5], baseDirEntries[6], baseDirEntries[7], baseDirEntries[8]}, + ) +} + +func TestMultiFilterDirForPerDevConfigsWithDefaults(t *testing.T) { + t.Parallel() + f := func(dirEntries []DirEntry, configPath string, multiFilterArgs MultiFilterArgs, defaultFilenames []string, wantDirEntries []DirEntry) { + t.Helper() + + dirEntries, _ = MultiFilterDirForPerDevConfigsWithDefaults(dirEntries, configPath, multiFilterArgs, defaultFilenames) + require.Equal(t, wantDirEntries, dirEntries) + } + + baseDirEntries := []DirEntry{ + {".env", "", true, 420}, + {"docker-compose.yaml", "", true, 420}, + {"configs", "", false, 420}, + {"configs/file1.conf", "", true, 420}, + {"configs/file2.conf", "", true, 420}, + {"configs/folder1", "", false, 420}, + {"configs/folder1/config1", "", true, 420}, + {"configs/folder2", "", false, 420}, + {"configs/folder2/config2", "", true, 420}, + {"configs/docker-compose-2.yaml", "", true, 420}, + {"configs/folder2/docker-compose-3.yaml", "", true, 420}, + } + + // Filter file1 + f( + baseDirEntries, + "configs", + MultiFilterArgs{{"file1", portainer.PerDevConfigsTypeFile}}, + nil, + []DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[3]}, + ) + + // Filter folder1 + f( + baseDirEntries, + "configs", + MultiFilterArgs{{"folder1", portainer.PerDevConfigsTypeDir}}, + nil, + []DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[5], baseDirEntries[6]}, + ) + + // Filter file1 and folder1 + f( + baseDirEntries, + "configs", + MultiFilterArgs{ + {"file1", portainer.PerDevConfigsTypeFile}, + {"folder1", portainer.PerDevConfigsTypeDir}, + }, + nil, + []DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[3], baseDirEntries[5], baseDirEntries[6]}, + ) + + // Filter file1 and file2 + f( + baseDirEntries, + "configs", + MultiFilterArgs{ + {"file1", portainer.PerDevConfigsTypeFile}, + {"file2", portainer.PerDevConfigsTypeFile}, + }, + nil, + []DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[3], baseDirEntries[4]}, + ) + + // Filter folder1 and folder2 + f( + baseDirEntries, + "configs", + MultiFilterArgs{ + {"folder1", portainer.PerDevConfigsTypeDir}, + {"folder2", portainer.PerDevConfigsTypeDir}, + }, + nil, + []DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[5], baseDirEntries[6], baseDirEntries[7], baseDirEntries[8], baseDirEntries[10]}, + ) + + // Filter file1 and folder1 and docker-compose-2.yaml + f( + baseDirEntries, + "configs", + MultiFilterArgs{ + {"file1", portainer.PerDevConfigsTypeFile}, + {"folder1", portainer.PerDevConfigsTypeDir}, + }, + []string{"configs/docker-compose-2.yaml"}, + []DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[3], baseDirEntries[5], baseDirEntries[6], baseDirEntries[9]}, + ) + + // Filter file1 and docker-compose-3.yaml + f( + baseDirEntries, + "configs", + MultiFilterArgs{ + {"file1", portainer.PerDevConfigsTypeFile}, + }, + []string{"configs/folder2/docker-compose-3.yaml"}, + []DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[3], baseDirEntries[10]}, + ) +} + +func TestMultiFilterDirForPerDevConfigsEnvFiles(t *testing.T) { + t.Parallel() + f := func(dirEntries []DirEntry, configPath string, multiFilterArgs MultiFilterArgs, wantEnvFiles []string) { + t.Helper() + + _, envFiles := MultiFilterDirForPerDevConfigs(dirEntries, configPath, multiFilterArgs) + require.Equal(t, wantEnvFiles, envFiles) + } + + baseDirEntries := []DirEntry{ + {".env", "", true, 420}, + {"docker-compose.yaml", "", true, 420}, + {"configs", "", false, 420}, + {"configs/edge-id/edge-id.env", "", true, 420}, + } + + f( + baseDirEntries, + "configs", + MultiFilterArgs{{"edge-id", portainer.PerDevConfigsTypeDir}}, + []string{"configs/edge-id/edge-id.env"}, + ) + +} + +func TestIsInConfigDir(t *testing.T) { + t.Parallel() + f := func(dirEntry DirEntry, configPath string, expect bool) { + t.Helper() + + actual := isInConfigDir(dirEntry, configPath) + assert.Equal(t, expect, actual) + } + + f(DirEntry{Name: "edge-configs"}, "edge-configs", false) + f(DirEntry{Name: "edge-configs_backup"}, "edge-configs", false) + f(DirEntry{Name: "edge-configs/standalone-edge-agent-standard"}, "edge-configs", true) + f(DirEntry{Name: "parent/edge-configs/"}, "edge-configs", false) + f(DirEntry{Name: "edgestacktest"}, "edgestacktest/edge-configs", false) + f(DirEntry{Name: "edgestacktest/edgeconfigs-test.yaml"}, "edgestacktest/edge-configs", false) + f(DirEntry{Name: "edgestacktest/file1.conf"}, "edgestacktest/edge-configs", false) + f(DirEntry{Name: "edgeconfigs-test.yaml"}, "edgestacktest/edge-configs", false) + f(DirEntry{Name: "edgestacktest/edge-configs"}, "edgestacktest/edge-configs", false) + f(DirEntry{Name: "edgestacktest/edge-configs/standalone-edge-agent-async"}, "edgestacktest/edge-configs", true) + f(DirEntry{Name: "edgestacktest/edge-configs/abc.txt"}, "edgestacktest/edge-configs", true) +} + +func TestShouldIncludeDir(t *testing.T) { + t.Parallel() + f := func(dirEntry DirEntry, deviceName, configPath string, expect bool) { + t.Helper() + + actual := shouldIncludeDir(dirEntry, deviceName, configPath) + assert.Equal(t, expect, actual) + } + + f(DirEntry{Name: "app/blue-app", IsFile: false}, "blue-app", "app", true) + f(DirEntry{Name: "app/blue-app/values.yaml", IsFile: true}, "blue-app", "app", true) +} diff --git a/api/filesystem/testdata/copy_test/dir/.dotfile b/api/filesystem/testdata/copy_test/dir/.dotfile new file mode 100644 index 0000000..d95f3ad --- /dev/null +++ b/api/filesystem/testdata/copy_test/dir/.dotfile @@ -0,0 +1 @@ +content diff --git a/api/filesystem/testdata/copy_test/dir/inner b/api/filesystem/testdata/copy_test/dir/inner new file mode 100644 index 0000000..d95f3ad --- /dev/null +++ b/api/filesystem/testdata/copy_test/dir/inner @@ -0,0 +1 @@ +content diff --git a/api/filesystem/testdata/copy_test/outer b/api/filesystem/testdata/copy_test/outer new file mode 100644 index 0000000..d95f3ad --- /dev/null +++ b/api/filesystem/testdata/copy_test/outer @@ -0,0 +1 @@ +content diff --git a/api/filesystem/write.go b/api/filesystem/write.go new file mode 100644 index 0000000..be8cf7d --- /dev/null +++ b/api/filesystem/write.go @@ -0,0 +1,25 @@ +package filesystem + +import ( + "os" + "path/filepath" + + "github.com/pkg/errors" + "github.com/portainer/portainer/api/logs" +) + +// WriteToFile creates a file in the filesystem storage +func WriteToFile(dst string, content []byte) error { + if err := os.MkdirAll(filepath.Dir(dst), 0744); err != nil { + return errors.Wrapf(err, "failed to create filestructure for the path %q", dst) + } + + file, err := os.Create(dst) + if err != nil { + return errors.Wrapf(err, "failed to open a file %q", dst) + } + defer logs.CloseAndLogErr(file) + + _, err = file.Write(content) + return errors.Wrapf(err, "failed to write a file %q", dst) +} diff --git a/api/filesystem/write_test.go b/api/filesystem/write_test.go new file mode 100644 index 0000000..7a75625 --- /dev/null +++ b/api/filesystem/write_test.go @@ -0,0 +1,51 @@ +package filesystem + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_WriteFile_CanStoreContentInANewFile(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + tmpFilePath := JoinPaths(tmpDir, "dummy") + + content := []byte("content") + err := WriteToFile(tmpFilePath, content) + require.NoError(t, err) + + fileContent, _ := os.ReadFile(tmpFilePath) + assert.Equal(t, content, fileContent) +} + +func Test_WriteFile_CanOverwriteExistingFile(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + tmpFilePath := JoinPaths(tmpDir, "dummy") + + err := WriteToFile(tmpFilePath, []byte("content")) + require.NoError(t, err) + + content := []byte("new content") + err = WriteToFile(tmpFilePath, content) + require.NoError(t, err) + + fileContent, _ := os.ReadFile(tmpFilePath) + assert.Equal(t, content, fileContent) +} + +func Test_WriteFile_CanWriteANestedPath(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + tmpFilePath := JoinPaths(tmpDir, "dir", "sub-dir", "dummy") + + content := []byte("content") + err := WriteToFile(tmpFilePath, content) + require.NoError(t, err) + + fileContent, _ := os.ReadFile(tmpFilePath) + assert.Equal(t, content, fileContent) +} diff --git a/api/git/azure.go b/api/git/azure.go new file mode 100644 index 0000000..fa46f4b --- /dev/null +++ b/api/git/azure.go @@ -0,0 +1,589 @@ +package git + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "time" + + "github.com/portainer/portainer/api/archive" + "github.com/portainer/portainer/api/crypto" + gittypes "github.com/portainer/portainer/api/git/types" + "github.com/portainer/portainer/api/logs" + "github.com/portainer/portainer/pkg/libhttp/ssrf" + + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing/filemode" + githttp "github.com/go-git/go-git/v5/plumbing/transport/http" + "github.com/pkg/errors" + "github.com/rs/zerolog/log" + "github.com/segmentio/encoding/json" +) + +const ( + azureDevOpsHost = "dev.azure.com" + visualStudioHostSuffix = ".visualstudio.com" +) + +func IsAzureUrl(s string) bool { + return strings.Contains(s, azureDevOpsHost) || + strings.Contains(s, visualStudioHostSuffix) +} + +type azureOptions struct { + organisation, project, repository string + // a user may pass credentials in a repository URL, + // for example https://:@/ + username, password string +} + +// azureRef abstracts from the response of https://docs.microsoft.com/en-us/rest/api/azure/devops/git/refs/list?view=azure-devops-rest-6.0#refs +type azureRef struct { + Name string `json:"name"` + ObjectID string `json:"objectId"` +} + +// azureItem abstracts from the response of https://docs.microsoft.com/en-us/rest/api/azure/devops/git/items/get?view=azure-devops-rest-6.0#download +type azureItem struct { + ObjectID string `json:"objectId"` + CommitId string `json:"commitId"` + Path string `json:"path"` +} + +type azureClient struct { + baseUrl string +} + +func NewAzureClient() *azureClient { + return &azureClient{ + baseUrl: "https://dev.azure.com", + } +} + +func newHttpClientForAzure(insecureSkipVerify bool) *http.Client { + return &http.Client{ + Transport: ssrf.NewTransport(crypto.CreateTLSConfiguration(insecureSkipVerify)), + Timeout: 300 * time.Second, + } +} + +func (a *azureClient) Download(ctx context.Context, destination string, opt *git.CloneOptions) error { + if opt == nil { + return errors.New("options cannot be nil") + } + + zipFilepath, err := a.downloadZipFromAzureDevOps(ctx, opt) + if err != nil { + return errors.Wrap(err, "failed to download a zip file from Azure DevOps") + } + defer func() { + if err := os.Remove(zipFilepath); err != nil { + log.Warn().Err(err).Msg("failed to remove temporary zip file") + } + }() + + if err := archive.UnzipFile(zipFilepath, destination); err != nil { + return errors.Wrap(err, "failed to unzip file") + } + + return nil +} + +func (a *azureClient) downloadZipFromAzureDevOps(ctx context.Context, opt *git.CloneOptions) (string, error) { + config, err := parseUrl(opt.URL) + if err != nil { + return "", errors.WithMessage(err, "failed to parse url") + } + + downloadUrl, err := a.buildDownloadUrl(config, string(opt.ReferenceName)) + if err != nil { + return "", errors.WithMessage(err, "failed to build download url") + } + + zipFile, err := os.CreateTemp("", "azure-git-repo-*.zip") + if err != nil { + return "", errors.WithMessage(err, "failed to create temp file") + } + + defer logs.CloseAndLogErr(zipFile) + + var basicAuth *githttp.BasicAuth + if opt.Auth != nil { + var ok bool + basicAuth, ok = opt.Auth.(*githttp.BasicAuth) + if !ok { + return "", errors.New("only basic auth is supported for azure") + } + } + + req, err := http.NewRequestWithContext(ctx, "GET", downloadUrl, nil) + if basicAuth != nil { + req.SetBasicAuth(basicAuth.Username, basicAuth.Password) + } else if config.username != "" || config.password != "" { + req.SetBasicAuth(config.username, config.password) + } + + if err != nil { + return "", errors.WithMessage(err, "failed to create a new HTTP request") + } + + client := newHttpClientForAzure(opt.InsecureSkipTLS) + defer client.CloseIdleConnections() + + res, err := client.Do(req) + if err != nil { + return "", errors.WithMessage(err, "failed to make an HTTP request") + } + + defer func() { + if err := res.Body.Close(); err != nil { + log.Warn().Err(err).Msg("failed to close response body") + } + }() + + if res.StatusCode != http.StatusOK { + return "", fmt.Errorf("failed to download zip with a status \"%v\"", res.Status) + } + + if _, err := io.Copy(zipFile, res.Body); err != nil { + return "", errors.WithMessage(err, "failed to save HTTP response to a file") + } + + return zipFile.Name(), nil +} + +func (a *azureClient) LatestCommitID(ctx context.Context, repositoryUrl, referenceName string, opt *git.ListOptions) (string, error) { + if opt == nil { + return "", errors.New("options cannot be nil") + } + + rootItem, err := a.getRootItem(ctx, repositoryUrl, referenceName, opt) + if err != nil { + return "", err + } + + return rootItem.CommitId, nil +} + +func (a *azureClient) getRootItem(ctx context.Context, repositoryUrl, referenceName string, opt *git.ListOptions) (*azureItem, error) { + config, err := parseUrl(repositoryUrl) + if err != nil { + return nil, errors.WithMessage(err, "failed to parse url") + } + + rootItemUrl, err := a.buildRootItemUrl(config, referenceName) + if err != nil { + return nil, errors.WithMessage(err, "failed to build azure root item url") + } + + var basicAuth *githttp.BasicAuth + if opt.Auth != nil { + var ok bool + basicAuth, ok = opt.Auth.(*githttp.BasicAuth) + if !ok { + return nil, errors.New("only basic auth is supported for azure") + } + } + + req, err := http.NewRequestWithContext(ctx, "GET", rootItemUrl, nil) + if basicAuth != nil { + req.SetBasicAuth(basicAuth.Username, basicAuth.Password) + } else if config.username != "" || config.password != "" { + req.SetBasicAuth(config.username, config.password) + } + + if err != nil { + return nil, errors.WithMessage(err, "failed to create a new HTTP request") + } + + client := newHttpClientForAzure(opt.InsecureSkipTLS) + defer client.CloseIdleConnections() + + resp, err := client.Do(req) + if err != nil { + return nil, errors.WithMessage(err, "failed to make an HTTP request") + } + defer func() { + if err := resp.Body.Close(); err != nil { + log.Warn().Err(err).Msg("failed to close response body") + } + }() + + if resp.StatusCode != http.StatusOK { + return nil, checkAzureStatusCode(fmt.Errorf("failed to get repository root item with a status \"%v\"", resp.Status), resp.StatusCode) + } + + var items struct { + Value []azureItem + } + + if err := json.NewDecoder(resp.Body).Decode(&items); err != nil { + return nil, errors.Wrap(err, "could not parse Azure items response") + } + + if len(items.Value) == 0 || items.Value[0].CommitId == "" { + return nil, errors.Errorf("failed to get latest commitID in the repository") + } + + return &items.Value[0], nil +} + +func parseUrl(rawUrl string) (*azureOptions, error) { + if strings.HasPrefix(rawUrl, "https://") || strings.HasPrefix(rawUrl, "http://") { + return parseHttpUrl(rawUrl) + } + if strings.HasPrefix(rawUrl, "git@ssh") { + return parseSshUrl(rawUrl) + } + if strings.HasPrefix(rawUrl, "ssh://") { + r := []rune(rawUrl) + return parseSshUrl(string(r[6:])) // remove the prefix + } + + return nil, errors.Errorf("supported url schemes are https and ssh; recevied URL %s rawUrl", rawUrl) +} + +const expectedSshUrl = "git@ssh.dev.azure.com:v3/Organisation/Project/Repository" + +func parseSshUrl(rawUrl string) (*azureOptions, error) { + path := strings.Split(rawUrl, "/") + + unexpectedUrlErr := errors.Errorf("want url %s, got %s", expectedSshUrl, rawUrl) + if len(path) != 4 { + return nil, unexpectedUrlErr + } + return &azureOptions{ + organisation: path[1], + project: path[2], + repository: path[3], + }, nil +} + +const ( + expectedAzureDevOpsHttpUrl = "https://Organisation@dev.azure.com/Organisation/Project/_git/Repository" + expectedVisualStudioHttpUrl = "https://organisation.visualstudio.com/project/_git/repository" +) + +func parseHttpUrl(rawUrl string) (*azureOptions, error) { + u, err := url.Parse(rawUrl) + if err != nil { + return nil, errors.Wrap(err, "failed to parse HTTP url") + } + + opt := azureOptions{} + switch { + case u.Host == azureDevOpsHost: + path := strings.Split(u.Path, "/") + if len(path) != 5 { + return nil, errors.Errorf("want url %s, got %s", expectedAzureDevOpsHttpUrl, u) + } + opt.organisation = path[1] + opt.project = path[2] + opt.repository = path[4] + case strings.HasSuffix(u.Host, visualStudioHostSuffix): + path := strings.Split(u.Path, "/") + if len(path) != 4 { + return nil, errors.Errorf("want url %s, got %s", expectedVisualStudioHttpUrl, u) + } + opt.organisation = strings.TrimSuffix(u.Host, visualStudioHostSuffix) + opt.project = path[1] + opt.repository = path[3] + default: + return nil, errors.Errorf("unknown azure host in url \"%s\"", rawUrl) + } + + opt.username = u.User.Username() + opt.password, _ = u.User.Password() + + return &opt, nil +} + +func (a *azureClient) buildDownloadUrl(config *azureOptions, referenceName string) (string, error) { + rawUrl := fmt.Sprintf("%s/%s/%s/_apis/git/repositories/%s/items", + a.baseUrl, + url.PathEscape(config.organisation), + url.PathEscape(config.project), + url.PathEscape(config.repository)) + u, err := url.Parse(rawUrl) + if err != nil { + return "", errors.Wrapf(err, "failed to parse download url path %s", rawUrl) + } + q := u.Query() + // scopePath=/&download=true&versionDescriptor.version=main&$format=zip&recursionLevel=full&api-version=6.0 + q.Set("scopePath", "/") + q.Set("download", "true") + if referenceName != "" { + q.Set("versionDescriptor.versionType", getVersionType(referenceName)) + q.Set("versionDescriptor.version", formatReferenceName(referenceName)) + } + q.Set("$format", "zip") + q.Set("recursionLevel", "full") + q.Set("api-version", "6.0") + u.RawQuery = q.Encode() + + return u.String(), nil +} + +func (a *azureClient) buildRootItemUrl(config *azureOptions, referenceName string) (string, error) { + rawUrl := fmt.Sprintf("%s/%s/%s/_apis/git/repositories/%s/items", + a.baseUrl, + url.PathEscape(config.organisation), + url.PathEscape(config.project), + url.PathEscape(config.repository)) + u, err := url.Parse(rawUrl) + if err != nil { + return "", errors.Wrapf(err, "failed to parse root item url path %s", rawUrl) + } + + q := u.Query() + q.Set("scopePath", "/") + if referenceName != "" { + q.Set("versionDescriptor.versionType", getVersionType(referenceName)) + q.Set("versionDescriptor.version", formatReferenceName(referenceName)) + } + q.Set("api-version", "6.0") + u.RawQuery = q.Encode() + + return u.String(), nil +} + +func (a *azureClient) buildRefsUrl(config *azureOptions) (string, error) { + // ref@https://docs.microsoft.com/en-us/rest/api/azure/devops/git/refs/list?view=azure-devops-rest-6.0#gitref + rawUrl := fmt.Sprintf("%s/%s/%s/_apis/git/repositories/%s/refs", + a.baseUrl, + url.PathEscape(config.organisation), + url.PathEscape(config.project), + url.PathEscape(config.repository)) + u, err := url.Parse(rawUrl) + if err != nil { + return "", errors.Wrapf(err, "failed to parse list refs url path %s", rawUrl) + } + + q := u.Query() + q.Set("api-version", "6.0") + u.RawQuery = q.Encode() + + return u.String(), nil +} + +func (a *azureClient) buildTreeUrl(config *azureOptions, rootObjectHash string) (string, error) { + // ref@https://docs.microsoft.com/en-us/rest/api/azure/devops/git/trees/get?view=azure-devops-rest-6.0 + rawUrl := fmt.Sprintf("%s/%s/%s/_apis/git/repositories/%s/trees/%s", + a.baseUrl, + url.PathEscape(config.organisation), + url.PathEscape(config.project), + url.PathEscape(config.repository), + url.PathEscape(rootObjectHash), + ) + u, err := url.Parse(rawUrl) + if err != nil { + return "", errors.Wrapf(err, "failed to parse list tree url path %s", rawUrl) + } + + q := u.Query() + // projectId={projectId}&recursive=true&fileName={fileName}&$format={$format}&api-version=6.0 + q.Set("recursive", "true") + q.Set("api-version", "6.0") + u.RawQuery = q.Encode() + + return u.String(), nil +} + +const ( + branchPrefix = "refs/heads/" + tagPrefix = "refs/tags/" +) + +func formatReferenceName(name string) string { + if after, ok := strings.CutPrefix(name, branchPrefix); ok { + return after + } + + if after, ok := strings.CutPrefix(name, tagPrefix); ok { + return after + } + + return name +} + +func getVersionType(name string) string { + if strings.HasPrefix(name, branchPrefix) { + return "branch" + } + + if strings.HasPrefix(name, tagPrefix) { + return "tag" + } + + return "commit" +} + +func (a *azureClient) ListRefs(ctx context.Context, repositoryUrl string, opt *git.ListOptions) ([]string, error) { + if opt == nil { + return nil, errors.New("options cannot be nil") + } + + config, err := parseUrl(repositoryUrl) + if err != nil { + return nil, errors.WithMessage(err, "failed to parse url") + } + + listRefsUrl, err := a.buildRefsUrl(config) + if err != nil { + return nil, errors.WithMessage(err, "failed to build list refs url") + } + + var basicAuth *githttp.BasicAuth + if opt.Auth != nil { + var ok bool + basicAuth, ok = opt.Auth.(*githttp.BasicAuth) + if !ok { + return nil, errors.New("only basic auth is supported for azure") + } + } + + req, err := http.NewRequestWithContext(ctx, "GET", listRefsUrl, nil) + if basicAuth != nil { + req.SetBasicAuth(basicAuth.Username, basicAuth.Password) + } else if config.username != "" || config.password != "" { + req.SetBasicAuth(config.username, config.password) + } + + if err != nil { + return nil, errors.WithMessage(err, "failed to create a new HTTP request") + } + + client := newHttpClientForAzure(opt.InsecureSkipTLS) + defer client.CloseIdleConnections() + + resp, err := client.Do(req) + if err != nil { + return nil, errors.WithMessage(err, "failed to make an HTTP request") + } + defer func() { + if err := resp.Body.Close(); err != nil { + log.Warn().Err(err).Msg("failed to close response body") + } + }() + + if resp.StatusCode != http.StatusOK { + return nil, checkAzureStatusCode(fmt.Errorf("failed to list refs with a status \"%v\"", resp.Status), resp.StatusCode) + } + + var refs struct { + Value []azureRef + } + + if err := json.NewDecoder(resp.Body).Decode(&refs); err != nil { + return nil, errors.Wrap(err, "could not parse Azure refs response") + } + + var ret []string + for _, value := range refs.Value { + if value.Name == "HEAD" { + continue + } + ret = append(ret, value.Name) + } + + return ret, nil +} + +// listFiles list all filenames under the specific repository +func (a *azureClient) ListFiles(ctx context.Context, dirOnly bool, opt *git.CloneOptions) ([]string, error) { + if opt == nil { + return nil, errors.New("options cannot be nil") + } + + listOptions := &git.ListOptions{ + Auth: opt.Auth, + InsecureSkipTLS: opt.InsecureSkipTLS, + } + rootItem, err := a.getRootItem(ctx, opt.URL, string(opt.ReferenceName), listOptions) + if err != nil { + return nil, err + } + + config, err := parseUrl(opt.URL) + if err != nil { + return nil, errors.WithMessage(err, "failed to parse url") + } + + listTreeUrl, err := a.buildTreeUrl(config, rootItem.ObjectID) + if err != nil { + return nil, errors.WithMessage(err, "failed to build list tree url") + } + + var basicAuth *githttp.BasicAuth + if opt.Auth != nil { + var ok bool + basicAuth, ok = opt.Auth.(*githttp.BasicAuth) + if !ok { + return nil, errors.New("only basic auth is supported for azure") + } + } + + req, err := http.NewRequestWithContext(ctx, "GET", listTreeUrl, nil) + if basicAuth != nil { + req.SetBasicAuth(basicAuth.Username, basicAuth.Password) + } else if config.username != "" || config.password != "" { + req.SetBasicAuth(config.username, config.password) + } + + if err != nil { + return nil, errors.WithMessage(err, "failed to create a new HTTP request") + } + + client := newHttpClientForAzure(opt.InsecureSkipTLS) + defer client.CloseIdleConnections() + + resp, err := client.Do(req) + if err != nil { + return nil, errors.WithMessage(err, "failed to make an HTTP request") + } + defer func() { + if err := resp.Body.Close(); err != nil { + log.Warn().Err(err).Msg("failed to close response body") + } + }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to list tree url with a status \"%v\"", resp.Status) + } + + var tree struct { + TreeEntries []struct { + RelativePath string `json:"relativePath"` + Mode string `json:"mode"` + } `json:"treeEntries"` + } + + if err := json.NewDecoder(resp.Body).Decode(&tree); err != nil { + return nil, errors.Wrap(err, "could not parse Azure tree response") + } + + var allPaths []string + for _, treeEntry := range tree.TreeEntries { + mode, _ := filemode.New(treeEntry.Mode) + isDir := filemode.Dir == mode + if dirOnly == isDir { + allPaths = append(allPaths, treeEntry.RelativePath) + } + } + + return allPaths, nil +} + +func checkAzureStatusCode(err error, code int) error { + if code == http.StatusNotFound { + return gittypes.ErrIncorrectRepositoryURL + } else if code == http.StatusUnauthorized || code == http.StatusNonAuthoritativeInfo { + return gittypes.ErrAuthenticationFailure + } + + return err +} diff --git a/api/git/azure_integration_test.go b/api/git/azure_integration_test.go new file mode 100644 index 0000000..ac0b790 --- /dev/null +++ b/api/git/azure_integration_test.go @@ -0,0 +1,362 @@ +package git + +import ( + "fmt" + "os" + "testing" + "time" + + "github.com/portainer/portainer/api/filesystem" + gittypes "github.com/portainer/portainer/api/git/types" + + _ "github.com/joho/godotenv/autoload" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const privateAzureRepoURL = "https://portainer.visualstudio.com/gitops-test/_git/gitops-test" + +func TestService_ClonePublicRepository_Azure(t *testing.T) { + t.Parallel() + ensureIntegrationTest(t) + + pat := getRequiredValue(t, "AZURE_DEVOPS_PAT") + service := NewService(t.Context()) + + type args struct { + repositoryURLFormat string + referenceName string + username string + password string + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "Clone Azure DevOps repo branch", + args: args{ + repositoryURLFormat: "https://:%s@portainer.visualstudio.com/gitops-test/_git/gitops-test", + referenceName: "refs/heads/main", + username: "", + password: pat, + }, + wantErr: false, + }, + { + name: "Clone Azure DevOps repo tag", + args: args{ + repositoryURLFormat: "https://:%s@portainer.visualstudio.com/gitops-test/_git/gitops-test", + referenceName: "refs/heads/tags/v1.1", + username: "", + password: pat, + }, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dst := t.TempDir() + repositoryUrl := fmt.Sprintf(tt.args.repositoryURLFormat, tt.args.password) + err := service.CloneRepository( + t.Context(), + dst, + repositoryUrl, + tt.args.referenceName, + "", + "", + false, + ) + require.NoError(t, err) + assert.FileExists(t, filesystem.JoinPaths(dst, "README.md")) + }) + } +} + +func TestService_ClonePrivateRepository_Azure(t *testing.T) { + t.Parallel() + ensureIntegrationTest(t) + + pat := getRequiredValue(t, "AZURE_DEVOPS_PAT") + service := NewService(t.Context()) + + dst := t.TempDir() + + err := service.CloneRepository( + t.Context(), + dst, + privateAzureRepoURL, + "refs/heads/main", + "", + pat, + false, + ) + require.NoError(t, err) + assert.FileExists(t, filesystem.JoinPaths(dst, "README.md")) +} + +func TestService_LatestCommitID_Azure(t *testing.T) { + t.Parallel() + ensureIntegrationTest(t) + + pat := getRequiredValue(t, "AZURE_DEVOPS_PAT") + service := NewService(t.Context()) + + id, err := service.LatestCommitID( + t.Context(), + privateAzureRepoURL, + "refs/heads/main", + "", + pat, + false, + ) + require.NoError(t, err) + assert.NotEmpty(t, id, "cannot guarantee commit id, but it should be not empty") +} + +func TestService_ListRefs_Azure(t *testing.T) { + t.Parallel() + ensureIntegrationTest(t) + + accessToken := getRequiredValue(t, "AZURE_DEVOPS_PAT") + username := getRequiredValue(t, "AZURE_DEVOPS_USERNAME") + service := NewService(t.Context()) + + refs, err := service.ListRefs( + t.Context(), + privateAzureRepoURL, + username, + accessToken, + false, + false, + ) + require.NoError(t, err) + assert.GreaterOrEqual(t, len(refs), 1) +} + +func TestService_ListRefs_Azure_Concurrently(t *testing.T) { + t.Parallel() + ensureIntegrationTest(t) + + accessToken := getRequiredValue(t, "AZURE_DEVOPS_PAT") + username := getRequiredValue(t, "AZURE_DEVOPS_USERNAME") + service := newService(t.Context(), repositoryCacheSize, 200*time.Millisecond) + + go func() { + _, _ = service.ListRefs(t.Context(), privateAzureRepoURL, username, accessToken, false, false) + }() + + _, err := service.ListRefs(t.Context(), privateAzureRepoURL, username, accessToken, false, false) + require.NoError(t, err) + + time.Sleep(2 * time.Second) +} + +func TestService_ListFiles_Azure(t *testing.T) { + t.Parallel() + ensureIntegrationTest(t) + + type args struct { + repositoryUrl string + referenceName string + username string + password string + extensions []string + } + + type expectResult struct { + shouldFail bool + err error + matchedCount int + } + + service := newService(t.Context(), 0, 0) + accessToken := getRequiredValue(t, "AZURE_DEVOPS_PAT") + username := getRequiredValue(t, "AZURE_DEVOPS_USERNAME") + + tests := []struct { + name string + args args + expect expectResult + }{ + { + name: "list tree with real repository and head ref but incorrect credential", + args: args{ + repositoryUrl: privateAzureRepoURL, + referenceName: "refs/heads/main", + username: "test-username", + password: "test-token", + extensions: []string{}, + }, + expect: expectResult{ + shouldFail: true, + err: gittypes.ErrAuthenticationFailure, + }, + }, + { + name: "list tree with real repository and head ref but no credential", + args: args{ + repositoryUrl: privateAzureRepoURL, + referenceName: "refs/heads/main", + username: "", + password: "", + extensions: []string{}, + }, + expect: expectResult{ + shouldFail: true, + err: gittypes.ErrAuthenticationFailure, + }, + }, + { + name: "list tree with real repository and head ref", + args: args{ + repositoryUrl: privateAzureRepoURL, + referenceName: "refs/heads/main", + username: username, + password: accessToken, + extensions: []string{}, + }, + expect: expectResult{ + err: nil, + matchedCount: 19, + }, + }, + { + name: "list tree with real repository and head ref and existing file extension", + args: args{ + repositoryUrl: privateAzureRepoURL, + referenceName: "refs/heads/main", + username: username, + password: accessToken, + extensions: []string{"yml"}, + }, + expect: expectResult{ + err: nil, + matchedCount: 2, + }, + }, + { + name: "list tree with real repository and head ref and non-existing file extension", + args: args{ + repositoryUrl: privateAzureRepoURL, + referenceName: "refs/heads/main", + username: username, + password: accessToken, + extensions: []string{"hcl"}, + }, + expect: expectResult{ + err: nil, + matchedCount: 2, + }, + }, + { + name: "list tree with real repository but non-existing ref", + args: args{ + repositoryUrl: privateAzureRepoURL, + referenceName: "refs/fake/feature", + username: username, + password: accessToken, + extensions: []string{}, + }, + expect: expectResult{ + shouldFail: true, + }, + }, + { + name: "list tree with fake repository ", + args: args{ + repositoryUrl: privateAzureRepoURL + "fake", + referenceName: "refs/fake/feature", + username: username, + password: accessToken, + extensions: []string{}, + }, + expect: expectResult{ + shouldFail: true, + err: gittypes.ErrIncorrectRepositoryURL, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + paths, err := service.ListFiles( + t.Context(), + tt.args.repositoryUrl, + tt.args.referenceName, + tt.args.username, + tt.args.password, + false, + false, + tt.args.extensions, + false, + ) + + if tt.expect.shouldFail { + require.Error(t, err) + if tt.expect.err != nil { + assert.Equal(t, tt.expect.err, err) + } + } else { + require.NoError(t, err) + if tt.expect.matchedCount > 0 { + assert.NotEmpty(t, paths) + } + } + }) + } +} + +func TestService_ListFiles_Azure_Concurrently(t *testing.T) { + t.Parallel() + ensureIntegrationTest(t) + + accessToken := getRequiredValue(t, "AZURE_DEVOPS_PAT") + username := getRequiredValue(t, "AZURE_DEVOPS_USERNAME") + service := newService(t.Context(), repositoryCacheSize, 200*time.Millisecond) + + go func() { + _, _ = service.ListFiles( + t.Context(), + privateAzureRepoURL, + "refs/heads/main", + username, + accessToken, + false, + false, + []string{}, + false, + ) + }() + + _, err := service.ListFiles( + t.Context(), + privateAzureRepoURL, + "refs/heads/main", + username, + accessToken, + false, + false, + []string{}, + false, + ) + require.NoError(t, err) + + time.Sleep(2 * time.Second) +} + +func getRequiredValue(t *testing.T, name string) string { + value, ok := os.LookupEnv(name) + if !ok { + t.Fatalf("can't find required env var \"%s\"", name) + } + + return value +} + +func ensureIntegrationTest(t *testing.T) { + if _, ok := os.LookupEnv("INTEGRATION_TEST"); !ok { + t.Skip("skip an integration test") + } +} diff --git a/api/git/azure_test.go b/api/git/azure_test.go new file mode 100644 index 0000000..b3468c7 --- /dev/null +++ b/api/git/azure_test.go @@ -0,0 +1,662 @@ +package git + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" + githttp "github.com/go-git/go-git/v5/plumbing/transport/http" + gittypes "github.com/portainer/portainer/api/git/types" + "github.com/portainer/portainer/pkg/fips" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_buildDownloadUrl(t *testing.T) { + t.Parallel() + a := NewAzureClient() + u, err := a.buildDownloadUrl(&azureOptions{ + organisation: "organisation", + project: "project", + repository: "repository", + }, "refs/heads/main") + require.NoError(t, err) + + expectedUrl, err := url.Parse("https://dev.azure.com/organisation/project/_apis/git/repositories/repository/items?scopePath=/&download=true&versionDescriptor.version=main&$format=zip&recursionLevel=full&api-version=6.0&versionDescriptor.versionType=branch") + require.NoError(t, err) + + actualUrl, err := url.Parse(u) + require.NoError(t, err) + + assert.Equal(t, expectedUrl.Host, actualUrl.Host) + assert.Equal(t, expectedUrl.Scheme, actualUrl.Scheme) + assert.Equal(t, expectedUrl.Path, actualUrl.Path) + assert.Equal(t, expectedUrl.Query(), actualUrl.Query()) +} + +func Test_buildRootItemUrl(t *testing.T) { + t.Parallel() + a := NewAzureClient() + u, err := a.buildRootItemUrl(&azureOptions{ + organisation: "organisation", + project: "project", + repository: "repository", + }, "refs/heads/main") + + expectedUrl, _ := url.Parse("https://dev.azure.com/organisation/project/_apis/git/repositories/repository/items?scopePath=/&api-version=6.0&versionDescriptor.version=main&versionDescriptor.versionType=branch") + actualUrl, _ := url.Parse(u) + require.NoError(t, err) + assert.Equal(t, expectedUrl.Host, actualUrl.Host) + assert.Equal(t, expectedUrl.Scheme, actualUrl.Scheme) + assert.Equal(t, expectedUrl.Path, actualUrl.Path) + assert.Equal(t, expectedUrl.Query(), actualUrl.Query()) +} + +func Test_buildRefsUrl(t *testing.T) { + t.Parallel() + a := NewAzureClient() + u, err := a.buildRefsUrl(&azureOptions{ + organisation: "organisation", + project: "project", + repository: "repository", + }) + + expectedUrl, _ := url.Parse("https://dev.azure.com/organisation/project/_apis/git/repositories/repository/refs?api-version=6.0") + actualUrl, _ := url.Parse(u) + require.NoError(t, err) + assert.Equal(t, expectedUrl.Host, actualUrl.Host) + assert.Equal(t, expectedUrl.Scheme, actualUrl.Scheme) + assert.Equal(t, expectedUrl.Path, actualUrl.Path) + assert.Equal(t, expectedUrl.Query(), actualUrl.Query()) +} + +func Test_buildTreeUrl(t *testing.T) { + t.Parallel() + a := NewAzureClient() + u, err := a.buildTreeUrl(&azureOptions{ + organisation: "organisation", + project: "project", + repository: "repository", + }, "sha1") + + expectedUrl, _ := url.Parse("https://dev.azure.com/organisation/project/_apis/git/repositories/repository/trees/sha1?api-version=6.0&recursive=true") + actualUrl, _ := url.Parse(u) + require.NoError(t, err) + assert.Equal(t, expectedUrl.Host, actualUrl.Host) + assert.Equal(t, expectedUrl.Scheme, actualUrl.Scheme) + assert.Equal(t, expectedUrl.Path, actualUrl.Path) + assert.Equal(t, expectedUrl.Query(), actualUrl.Query()) +} + +func Test_parseAzureUrl(t *testing.T) { + t.Parallel() + type args struct { + url string + } + tests := []struct { + name string + args args + want *azureOptions + wantErr bool + }{ + { + name: "Expected SSH URL format starting with ssh://", + args: args{ + url: "ssh://git@ssh.dev.azure.com:v3/Organisation/Project/Repository", + }, + want: &azureOptions{ + organisation: "Organisation", + project: "Project", + repository: "Repository", + }, + wantErr: false, + }, + { + name: "Expected SSH URL format starting with git@ssh", + args: args{ + url: "git@ssh.dev.azure.com:v3/Organisation/Project/Repository", + }, + want: &azureOptions{ + organisation: "Organisation", + project: "Project", + repository: "Repository", + }, + wantErr: false, + }, + { + name: "Unexpected SSH URL format", + args: args{ + url: "git@ssh.dev.azure.com:v3/Organisation/Repository", + }, + wantErr: true, + }, + { + name: "Expected HTTPS URL format", + args: args{ + url: "https://Organisation@dev.azure.com/Organisation/Project/_git/Repository", + }, + want: &azureOptions{ + organisation: "Organisation", + project: "Project", + repository: "Repository", + username: "Organisation", + }, + wantErr: false, + }, + { + name: "HTTPS URL with credentials", + args: args{ + url: "https://username:password@dev.azure.com/Organisation/Project/_git/Repository", + }, + want: &azureOptions{ + organisation: "Organisation", + project: "Project", + repository: "Repository", + username: "username", + password: "password", + }, + wantErr: false, + }, + { + name: "HTTPS URL with password", + args: args{ + url: "https://:password@dev.azure.com/Organisation/Project/_git/Repository", + }, + want: &azureOptions{ + organisation: "Organisation", + project: "Project", + repository: "Repository", + password: "password", + }, + wantErr: false, + }, + { + name: "Visual Studio HTTPS URL with credentials", + args: args{ + url: "https://username:password@organisation.visualstudio.com/project/_git/repository", + }, + want: &azureOptions{ + organisation: "organisation", + project: "project", + repository: "repository", + username: "username", + password: "password", + }, + wantErr: false, + }, + { + name: "Unexpected HTTPS URL format", + args: args{ + url: "https://Organisation@dev.azure.com/Project/_git/Repository", + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseUrl(tt.args.url) + if (err != nil) != tt.wantErr { + t.Errorf("parseUrl() error = %v, wantErr %v", err, tt.wantErr) + return + } + assert.Equal(t, tt.want, got) + }) + } +} + +func Test_isAzureUrl(t *testing.T) { + t.Parallel() + type args struct { + s string + } + tests := []struct { + name string + args args + want bool + }{ + { + name: "Is Azure url", + args: args{ + s: "https://Organisation@dev.azure.com/Organisation/Project/_git/Repository", + }, + want: true, + }, + { + name: "Is Azure url", + args: args{ + s: "https://portainer.visualstudio.com/project/_git/repository", + }, + want: true, + }, + { + name: "Is NOT Azure url", + args: args{ + s: "https://github.com/Organisation/Repository", + }, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, IsAzureUrl(tt.args.s)) + }) + } +} + +func Test_azureDownloader_downloadZipFromAzureDevOps(t *testing.T) { + t.Parallel() + fips.InitFIPS(false) + + type args struct { + repositoryUrl string + username string + password string + } + type basicAuth struct { + username, password string + } + tests := []struct { + name string + args args + want *basicAuth + }{ + { + name: "username, password embedded", + args: args{ + repositoryUrl: "https://username:password@dev.azure.com/Organisation/Project/_git/Repository", + }, + want: &basicAuth{ + username: "username", + password: "password", + }, + }, + { + name: "username, password embedded, clone options take precedence", + args: args{ + repositoryUrl: "https://username:password@dev.azure.com/Organisation/Project/_git/Repository", + username: "u", + password: "p", + }, + want: &basicAuth{ + username: "u", + password: "p", + }, + }, + { + name: "no credentials", + args: args{ + repositoryUrl: "https://dev.azure.com/Organisation/Project/_git/Repository", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var zipRequestAuth *basicAuth + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if username, password, ok := r.BasicAuth(); ok { + zipRequestAuth = &basicAuth{username, password} + } + w.WriteHeader(http.StatusNotFound) // this makes function under test to return an error + })) + defer server.Close() + + a := &azureClient{ + baseUrl: server.URL, + } + + option := &git.CloneOptions{ + URL: tt.args.repositoryUrl, + } + if tt.args.username != "" || tt.args.password != "" { + option.Auth = &githttp.BasicAuth{ + Username: tt.args.username, + Password: tt.args.password, + } + } + _, err := a.downloadZipFromAzureDevOps(t.Context(), option) + require.Error(t, err) + assert.Equal(t, tt.want, zipRequestAuth) + }) + } +} + +func Test_azureDownloader_latestCommitID(t *testing.T) { + t.Parallel() + fips.InitFIPS(false) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + response := `{ + "count": 1, + "value": [ + { + "objectId": "1a5630f017127db7de24d8771da0f536ff98fc9b", + "gitObjectType": "tree", + "commitId": "27104ad7549d9e66685e115a497533f18024be9c", + "path": "/", + "isFolder": true, + "url": "https://dev.azure.com/simonmeng0474/4b546a97-c481-4506-bdd5-976e9592f91a/_apis/git/repositories/a22247ad-053f-43bc-88a7-62ff4846bb97/items?path=%2F&versionType=Branch&versionOptions=None" + } + ] + }` + w.Header().Set("Content-Type", "application/json") + + _, _ = w.Write([]byte(response)) + })) + defer server.Close() + + a := &azureClient{baseUrl: server.URL} + + type args struct { + repositoryUrl string + referenceName string + } + + tests := []struct { + name string + args args + want string + wantErr bool + }{ + { + name: "should be able to parse response", + args: args{ + repositoryUrl: "https://dev.azure.com/Organisation/Project/_git/Repository", + referenceName: "", + }, + want: "27104ad7549d9e66685e115a497533f18024be9c", + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + id, err := a.LatestCommitID(t.Context(), tt.args.repositoryUrl, tt.args.referenceName, &git.ListOptions{}) + if (err != nil) != tt.wantErr { + t.Errorf("azureDownloader.latestCommitID() error = %v, wantErr %v", err, tt.wantErr) + return + } + assert.Equal(t, tt.want, id) + }) + } +} + +type testRepoManager struct { + called bool +} + +func (t *testRepoManager) Download(_ context.Context, _ string, _ *git.CloneOptions) error { + t.called = true + return nil +} + +func (t *testRepoManager) LatestCommitID(_ context.Context, _, _ string, _ *git.ListOptions) (string, error) { + return "", nil +} + +func (t *testRepoManager) ListRefs(_ context.Context, _ string, _ *git.ListOptions) ([]string, error) { + return nil, nil +} + +func (t *testRepoManager) ListFiles(_ context.Context, _ bool, _ *git.CloneOptions) ([]string, error) { + return nil, nil +} + +func Test_cloneRepository_azure(t *testing.T) { + t.Parallel() + tests := []struct { + name string + url string + called bool + }{ + { + name: "Azure HTTP URL", + url: "https://Organisation@dev.azure.com/Organisation/Project/_git/Repository", + called: true, + }, + { + name: "Azure SSH URL", + url: "git@ssh.dev.azure.com:v3/Organisation/Project/Repository", + called: true, + }, + { + name: "Something else", + url: "https://example.com", + called: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + azure := &testRepoManager{} + git := &testRepoManager{} + + s := &Service{azure: azure, git: git} + err := s.CloneRepository(t.Context(), "", tt.url, "", "", "", false) + require.NoError(t, err) + + // if azure API is called, git isn't and vice versa + assert.Equal(t, tt.called, azure.called) + assert.Equal(t, tt.called, !git.called) + }) + } +} + +func Test_listRefs_azure(t *testing.T) { + t.Parallel() + ensureIntegrationTest(t) + + client := NewAzureClient() + + type args struct { + repositoryUrl string + username string + password string + } + + type expectResult struct { + err error + refsCount int + } + + accessToken := getRequiredValue(t, "AZURE_DEVOPS_PAT") + username := getRequiredValue(t, "AZURE_DEVOPS_USERNAME") + + tests := []struct { + name string + args args + expect expectResult + }{ + { + name: "list refs of a real repository", + args: args{ + repositoryUrl: privateAzureRepoURL, + username: username, + password: accessToken, + }, + expect: expectResult{ + err: nil, + refsCount: 2, + }, + }, + { + name: "list refs of a real repository with incorrect credential", + args: args{ + repositoryUrl: privateAzureRepoURL, + username: "test-username", + password: "test-token", + }, + expect: expectResult{ + err: gittypes.ErrAuthenticationFailure, + }, + }, + { + name: "list refs of a real repository without providing credential", + args: args{ + repositoryUrl: privateAzureRepoURL, + username: "", + password: "", + }, + expect: expectResult{ + err: gittypes.ErrAuthenticationFailure, + }, + }, + { + name: "list refs of a fake repository", + args: args{ + repositoryUrl: privateAzureRepoURL + "fake", + username: username, + password: accessToken, + }, + expect: expectResult{ + err: gittypes.ErrIncorrectRepositoryURL, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + option := &git.ListOptions{} + if tt.args.username != "" || tt.args.password != "" { + option.Auth = &githttp.BasicAuth{ + Username: tt.args.username, + Password: tt.args.password, + } + } + refs, err := client.ListRefs(t.Context(), tt.args.repositoryUrl, option) + if tt.expect.err == nil { + require.NoError(t, err) + if tt.expect.refsCount > 0 { + assert.NotEmpty(t, refs) + } + } else { + require.Error(t, err) + assert.Equal(t, tt.expect.err, err) + } + }) + } +} + +func Test_listFiles_azure(t *testing.T) { + t.Parallel() + ensureIntegrationTest(t) + + client := NewAzureClient() + + type args struct { + repositoryUrl string + referenceName string + username string + password string + } + + type expectResult struct { + shouldFail bool + err error + matchedCount int + } + + accessToken := getRequiredValue(t, "AZURE_DEVOPS_PAT") + username := getRequiredValue(t, "AZURE_DEVOPS_USERNAME") + tests := []struct { + name string + args args + expect expectResult + }{ + { + name: "list tree with real repository and head ref but incorrect credential", + args: args{ + repositoryUrl: privateAzureRepoURL, + referenceName: "refs/heads/main", + username: "test-username", + password: "test-token", + }, + expect: expectResult{ + shouldFail: true, + err: gittypes.ErrAuthenticationFailure, + }, + }, + { + name: "list tree with real repository and head ref but no credential", + args: args{ + repositoryUrl: privateAzureRepoURL, + referenceName: "refs/heads/main", + username: "", + password: "", + }, + expect: expectResult{ + shouldFail: true, + err: gittypes.ErrAuthenticationFailure, + }, + }, + { + name: "list tree with real repository and head ref", + args: args{ + repositoryUrl: privateAzureRepoURL, + referenceName: "refs/heads/main", + username: username, + password: accessToken, + }, + expect: expectResult{ + err: nil, + matchedCount: 19, + }, + }, + { + name: "list tree with real repository but non-existing ref", + args: args{ + repositoryUrl: privateAzureRepoURL, + referenceName: "refs/fake/feature", + username: username, + password: accessToken, + }, + expect: expectResult{ + shouldFail: true, + }, + }, + { + name: "list tree with fake repository ", + args: args{ + repositoryUrl: privateAzureRepoURL + "fake", + referenceName: "refs/fake/feature", + username: username, + password: accessToken, + }, + expect: expectResult{ + shouldFail: true, + err: gittypes.ErrIncorrectRepositoryURL, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + option := &git.CloneOptions{ + URL: tt.args.repositoryUrl, + ReferenceName: plumbing.ReferenceName(tt.args.referenceName), + } + if tt.args.username != "" || tt.args.password != "" { + option.Auth = &githttp.BasicAuth{ + Username: tt.args.username, + Password: tt.args.password, + } + } + paths, err := client.ListFiles(t.Context(), false, option) + if tt.expect.shouldFail { + require.Error(t, err) + if tt.expect.err != nil { + assert.Equal(t, tt.expect.err, err) + } + } else { + require.NoError(t, err) + if tt.expect.matchedCount > 0 { + assert.NotEmpty(t, paths) + } + } + }) + } +} diff --git a/api/git/backup.go b/api/git/backup.go new file mode 100644 index 0000000..3dbbc9b --- /dev/null +++ b/api/git/backup.go @@ -0,0 +1,69 @@ +package git + +import ( + "context" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/filesystem" + gittypes "github.com/portainer/portainer/api/git/types" + + "github.com/pkg/errors" + "github.com/rs/zerolog/log" +) + +var ( + ErrInvalidGitCredential = errors.New("Invalid git credential") +) + +type CloneOptions struct { + ProjectPath string + URL string + ReferenceName string + Username string + Password string + // TLSSkipVerify skips SSL verification when cloning the Git repository + TLSSkipVerify bool `example:"false"` +} + +func CloneWithBackup(ctx context.Context, gitService portainer.GitService, fileService portainer.FileService, options CloneOptions) (clean func(), err error) { + backupProjectPath := options.ProjectPath + "-old" + cleanUp := false + cleanFn := func() { + if !cleanUp { + return + } + + if err := fileService.RemoveDirectory(backupProjectPath); err != nil { + log.Warn().Err(err).Msg("unable to remove git repository directory") + } + } + + if err := filesystem.MoveDirectory(options.ProjectPath, backupProjectPath, true); err != nil { + return cleanFn, errors.WithMessage(err, "Unable to move git repository directory") + } + + cleanUp = true + + if err := gitService.CloneRepository( + ctx, + options.ProjectPath, + options.URL, + options.ReferenceName, + options.Username, + options.Password, + options.TLSSkipVerify, + ); err != nil { + cleanUp = false + if err := filesystem.MoveDirectory(backupProjectPath, options.ProjectPath, false); err != nil { + log.Warn().Err(err).Msg("failed restoring backup folder") + } + + if errors.Is(err, gittypes.ErrAuthenticationFailure) { + return cleanFn, errors.WithMessage(err, ErrInvalidGitCredential.Error()) + } + + return cleanFn, errors.WithMessage(err, "Unable to clone git repository") + } + + return cleanFn, nil +} diff --git a/api/git/credentials.go b/api/git/credentials.go new file mode 100644 index 0000000..3a1fd5c --- /dev/null +++ b/api/git/credentials.go @@ -0,0 +1,13 @@ +package git + +import ( + gittypes "github.com/portainer/portainer/api/git/types" +) + +func GetCredentials(auth *gittypes.GitAuthentication) (string, string) { + if auth == nil { + return "", "" + } + + return auth.Username, auth.Password +} diff --git a/api/git/git.go b/api/git/git.go new file mode 100644 index 0000000..79b5a21 --- /dev/null +++ b/api/git/git.go @@ -0,0 +1,189 @@ +package git + +import ( + "context" + "os" + "path/filepath" + "strings" + + "github.com/portainer/portainer/api/filesystem" + gittypes "github.com/portainer/portainer/api/git/types" + + "github.com/go-git/go-billy/v5" + "github.com/go-git/go-billy/v5/osfs" + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/config" + "github.com/go-git/go-git/v5/plumbing/cache" + "github.com/go-git/go-git/v5/plumbing/filemode" + "github.com/go-git/go-git/v5/plumbing/object" + gogitfs "github.com/go-git/go-git/v5/storage/filesystem" + "github.com/go-git/go-git/v5/storage/memory" + "github.com/pkg/errors" + "github.com/rs/zerolog/log" +) + +// noSymlinkFS wraps a billy.Filesystem and rejects symlink creation to prevent +// symlink traversal attacks from untrusted git repositories +type noSymlinkFS struct { + billy.Filesystem +} + +func (fs noSymlinkFS) Symlink(_, _ string) error { + return gittypes.ErrSymlinkDetected +} + +// NewNoSymlinkFS wraps fs and rejects any symlink creation +func NewNoSymlinkFS(fs billy.Filesystem) billy.Filesystem { + return noSymlinkFS{fs} +} + +type gitClient struct { + preserveGitDirectory bool +} + +func NewGitClient(preserveGitDir bool) *gitClient { + return &gitClient{ + preserveGitDirectory: preserveGitDir, + } +} + +func (c *gitClient) Download(ctx context.Context, dst string, opt *git.CloneOptions) error { + resolved, err := filepath.EvalSymlinks(dst) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return errors.Wrap(err, "failed to resolve destination path") + } + if err == nil { + dst = resolved + } + + wt := NewNoSymlinkFS(osfs.New(dst)) + dot := osfs.New(filesystem.JoinPaths(dst, ".git")) + storer := gogitfs.NewStorage(dot, cache.NewObjectLRU(0)) + + _, err = git.CloneContext(ctx, storer, wt, opt) + if err != nil { + if err.Error() == "authentication required" { + return gittypes.ErrAuthenticationFailure + } + + return errors.Wrap(err, "failed to clone git repository") + } + + if c.preserveGitDirectory { + return nil + } + + if err := os.RemoveAll(filesystem.JoinPaths(dst, ".git")); err != nil { + log.Error().Err(err).Msg("failed to remove .git directory") + } + + return nil +} + +func (c *gitClient) LatestCommitID(ctx context.Context, repositoryUrl, referenceName string, opt *git.ListOptions) (string, error) { + remote := git.NewRemote(memory.NewStorage(), &config.RemoteConfig{ + Name: "origin", + URLs: []string{repositoryUrl}, + }) + + refs, err := remote.ListContext(ctx, opt) + if err != nil { + if err.Error() == "authentication required" { + return "", gittypes.ErrAuthenticationFailure + } + + return "", errors.Wrap(err, "failed to list repository refs") + } + + if referenceName == "" { + for _, ref := range refs { + if strings.EqualFold(ref.Name().String(), "HEAD") { + referenceName = ref.Target().String() + } + } + } + + for _, ref := range refs { + if strings.EqualFold(ref.Name().String(), referenceName) { + return ref.Hash().String(), nil + } + } + + return "", errors.Errorf("could not find ref %q in the repository", referenceName) +} + +func (c *gitClient) ListRefs(ctx context.Context, repositoryUrl string, opt *git.ListOptions) ([]string, error) { + rem := git.NewRemote(memory.NewStorage(), &config.RemoteConfig{ + Name: "origin", + URLs: []string{repositoryUrl}, + }) + + refs, err := rem.ListContext(ctx, opt) + if err != nil { + return nil, checkGitError(err) + } + + var ret []string + for _, ref := range refs { + if ref.Name().String() == "HEAD" { + continue + } + + ret = append(ret, ref.Name().String()) + } + + return ret, nil +} + +// listFiles list all filenames under the specific repository +func (c *gitClient) ListFiles(ctx context.Context, dirOnly bool, opt *git.CloneOptions) ([]string, error) { + repo, err := git.Clone(memory.NewStorage(), nil, opt) + if err != nil { + return nil, checkGitError(err) + } + + head, err := repo.Head() + if err != nil { + return nil, err + } + + commit, err := repo.CommitObject(head.Hash()) + if err != nil { + return nil, err + } + + tree, err := commit.Tree() + if err != nil { + return nil, err + } + + var allPaths []string + + w := object.NewTreeWalker(tree, true, nil) + defer w.Close() + + for { + name, entry, err := w.Next() + if err != nil { + break + } + + isDir := entry.Mode == filemode.Dir + if dirOnly == isDir { + allPaths = append(allPaths, name) + } + } + + return allPaths, nil +} + +func checkGitError(err error) error { + errMsg := err.Error() + if strings.Contains(errMsg, "repository not found") { + return gittypes.ErrIncorrectRepositoryURL + } else if errMsg == "authentication required" { + return gittypes.ErrAuthenticationFailure + } + + return err +} diff --git a/api/git/git_integration_test.go b/api/git/git_integration_test.go new file mode 100644 index 0000000..af88544 --- /dev/null +++ b/api/git/git_integration_test.go @@ -0,0 +1,508 @@ +package git + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/portainer/portainer/api/filesystem" + gittypes "github.com/portainer/portainer/api/git/types" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + privateGitRepoURL string = "https://github.com/portainer/private-test-repository.git" +) + +func TestService_ClonePrivateRepository_GitHub(t *testing.T) { + t.Parallel() + ensureIntegrationTest(t) + + accessToken := getRequiredValue(t, "GITHUB_PAT") + username := getRequiredValue(t, "GITHUB_USERNAME") + service := newService(t.Context(), 0, 0) + + dst := t.TempDir() + + repositoryUrl := privateGitRepoURL + err := service.CloneRepository( + t.Context(), + dst, + repositoryUrl, + "refs/heads/main", + username, + accessToken, + false, + ) + require.NoError(t, err) + assert.FileExists(t, filesystem.JoinPaths(dst, "README.md")) +} + +func TestService_LatestCommitID_GitHub(t *testing.T) { + t.Parallel() + ensureIntegrationTest(t) + + accessToken := getRequiredValue(t, "GITHUB_PAT") + username := getRequiredValue(t, "GITHUB_USERNAME") + service := newService(t.Context(), 0, 0) + + repositoryUrl := privateGitRepoURL + id, err := service.LatestCommitID( + t.Context(), + repositoryUrl, + "refs/heads/main", + username, + accessToken, + false, + ) + require.NoError(t, err) + assert.NotEmpty(t, id, "cannot guarantee commit id, but it should be not empty") +} + +func TestService_ListRefs_GitHub(t *testing.T) { + t.Parallel() + ensureIntegrationTest(t) + + accessToken := getRequiredValue(t, "GITHUB_PAT") + username := getRequiredValue(t, "GITHUB_USERNAME") + service := newService(t.Context(), 0, 0) + + repositoryUrl := privateGitRepoURL + refs, err := service.ListRefs(t.Context(), repositoryUrl, username, accessToken, false, false) + require.NoError(t, err) + assert.GreaterOrEqual(t, len(refs), 1) +} + +func TestService_ListRefs_Github_Concurrently(t *testing.T) { + t.Parallel() + ensureIntegrationTest(t) + + accessToken := getRequiredValue(t, "GITHUB_PAT") + username := getRequiredValue(t, "GITHUB_USERNAME") + service := newService(t.Context(), repositoryCacheSize, 200*time.Millisecond) + + repositoryUrl := privateGitRepoURL + go func() { + _, _ = service.ListRefs(t.Context(), repositoryUrl, username, accessToken, false, false) + }() + + _, err := service.ListRefs(t.Context(), repositoryUrl, username, accessToken, false, false) + require.NoError(t, err) + + time.Sleep(2 * time.Second) +} + +func TestService_ListFiles_GitHub(t *testing.T) { + t.Parallel() + ensureIntegrationTest(t) + + type args struct { + repositoryUrl string + referenceName string + username string + password string + extensions []string + } + + type expectResult struct { + shouldFail bool + err error + matchedCount int + } + service := newService(t.Context(), 0, 0) + accessToken := getRequiredValue(t, "GITHUB_PAT") + username := getRequiredValue(t, "GITHUB_USERNAME") + + tests := []struct { + name string + args args + expect expectResult + }{ + { + name: "list tree with real repository and head ref but incorrect credential", + args: args{ + repositoryUrl: privateGitRepoURL, + referenceName: "refs/heads/main", + username: "test-username", + password: "test-token", + extensions: []string{}, + }, + expect: expectResult{ + shouldFail: true, + err: gittypes.ErrAuthenticationFailure, + }, + }, + { + name: "list tree with real repository and head ref but no credential", + args: args{ + repositoryUrl: privateGitRepoURL + "fake", + referenceName: "refs/heads/main", + username: "", + password: "", + extensions: []string{}, + }, + expect: expectResult{ + shouldFail: true, + err: gittypes.ErrAuthenticationFailure, + }, + }, + { + name: "list tree with real repository and head ref", + args: args{ + repositoryUrl: privateGitRepoURL, + referenceName: "refs/heads/main", + username: username, + password: accessToken, + extensions: []string{}, + }, + expect: expectResult{ + err: nil, + matchedCount: 15, + }, + }, + { + name: "list tree with real repository and head ref and existing file extension", + args: args{ + repositoryUrl: privateGitRepoURL, + referenceName: "refs/heads/main", + username: username, + password: accessToken, + extensions: []string{"yml"}, + }, + expect: expectResult{ + err: nil, + matchedCount: 2, + }, + }, + { + name: "list tree with real repository and head ref and non-existing file extension", + args: args{ + repositoryUrl: privateGitRepoURL, + referenceName: "refs/heads/main", + username: username, + password: accessToken, + extensions: []string{"hcl"}, + }, + expect: expectResult{ + err: nil, + matchedCount: 2, + }, + }, + { + name: "list tree with real repository but non-existing ref", + args: args{ + repositoryUrl: privateGitRepoURL, + referenceName: "refs/fake/feature", + username: username, + password: accessToken, + extensions: []string{}, + }, + expect: expectResult{ + shouldFail: true, + }, + }, + { + name: "list tree with fake repository ", + args: args{ + repositoryUrl: privateGitRepoURL + "fake", + referenceName: "refs/fake/feature", + username: username, + password: accessToken, + extensions: []string{}, + }, + expect: expectResult{ + shouldFail: true, + err: gittypes.ErrIncorrectRepositoryURL, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + paths, err := service.ListFiles( + t.Context(), + tt.args.repositoryUrl, + tt.args.referenceName, + tt.args.username, + tt.args.password, + false, + false, + tt.args.extensions, + false, + ) + if tt.expect.shouldFail { + require.Error(t, err) + if tt.expect.err != nil { + assert.Equal(t, tt.expect.err, err) + } + } else { + require.NoError(t, err) + if tt.expect.matchedCount > 0 { + assert.NotEmpty(t, paths) + } + } + }) + } +} + +func TestService_ListFiles_Github_Concurrently(t *testing.T) { + t.Parallel() + ensureIntegrationTest(t) + + repositoryUrl := privateGitRepoURL + accessToken := getRequiredValue(t, "GITHUB_PAT") + username := getRequiredValue(t, "GITHUB_USERNAME") + service := newService(t.Context(), repositoryCacheSize, 200*time.Millisecond) + + go func() { + _, _ = service.ListFiles( + t.Context(), + repositoryUrl, + "refs/heads/main", + username, + accessToken, + false, + false, + []string{}, + false, + ) + }() + + _, err := service.ListFiles( + t.Context(), + repositoryUrl, + "refs/heads/main", + username, + accessToken, + false, + false, + []string{}, + false, + ) + require.NoError(t, err) + + time.Sleep(2 * time.Second) +} + +func TestService_purgeCache_Github(t *testing.T) { + t.Parallel() + ensureIntegrationTest(t) + + repositoryUrl := privateGitRepoURL + accessToken := getRequiredValue(t, "GITHUB_PAT") + username := getRequiredValue(t, "GITHUB_USERNAME") + service := NewService(t.Context()) + + _, err := service.ListRefs(t.Context(), repositoryUrl, username, accessToken, false, false) + require.NoError(t, err) + + _, err = service.ListFiles( + t.Context(), + repositoryUrl, + "refs/heads/main", + username, + accessToken, + false, + false, + []string{}, + false, + ) + require.NoError(t, err) + + assert.Equal(t, 1, service.repoRefCache.Len()) + assert.Equal(t, 1, service.repoFileCache.Len()) + + service.purgeCache() + assert.Equal(t, 0, service.repoRefCache.Len()) + assert.Equal(t, 0, service.repoFileCache.Len()) +} + +func TestService_purgeCacheByTTL_Github(t *testing.T) { + t.Parallel() + ensureIntegrationTest(t) + + timeout := 100 * time.Millisecond + repositoryUrl := privateGitRepoURL + accessToken := getRequiredValue(t, "GITHUB_PAT") + username := getRequiredValue(t, "GITHUB_USERNAME") + // 40*timeout is designed for giving enough time for ListRefs and ListFiles to cache the result + service := newService(t.Context(), 2, 40*timeout) + + _, err := service.ListRefs(t.Context(), repositoryUrl, username, accessToken, false, false) + require.NoError(t, err) + _, err = service.ListFiles( + t.Context(), + repositoryUrl, + "refs/heads/main", + username, + accessToken, + false, + false, + []string{}, + false, + ) + require.NoError(t, err) + assert.Equal(t, 1, service.repoRefCache.Len()) + assert.Equal(t, 1, service.repoFileCache.Len()) + + // 40*timeout is designed for giving enough time for TTL being activated + time.Sleep(40 * timeout) + assert.Equal(t, 0, service.repoRefCache.Len()) + assert.Equal(t, 0, service.repoFileCache.Len()) +} + +func TestService_HardRefresh_ListRefs_GitHub(t *testing.T) { + t.Parallel() + ensureIntegrationTest(t) + + accessToken := getRequiredValue(t, "GITHUB_PAT") + username := getRequiredValue(t, "GITHUB_USERNAME") + service := newService(t.Context(), 2, 0) + + repositoryUrl := privateGitRepoURL + refs, err := service.ListRefs(t.Context(), repositoryUrl, username, accessToken, false, false) + require.NoError(t, err) + assert.GreaterOrEqual(t, len(refs), 1) + assert.Equal(t, 1, service.repoRefCache.Len()) + + _, err = service.ListRefs(t.Context(), repositoryUrl, username, "fake-token", false, false) + require.Error(t, err) + assert.Equal(t, 1, service.repoRefCache.Len()) +} + +func TestService_HardRefresh_ListRefs_And_RemoveAllCaches_GitHub(t *testing.T) { + t.Parallel() + ensureIntegrationTest(t) + + accessToken := getRequiredValue(t, "GITHUB_PAT") + username := getRequiredValue(t, "GITHUB_USERNAME") + service := newService(t.Context(), 2, 0) + + repositoryUrl := privateGitRepoURL + refs, err := service.ListRefs(t.Context(), repositoryUrl, username, accessToken, false, false) + require.NoError(t, err) + assert.GreaterOrEqual(t, len(refs), 1) + assert.Equal(t, 1, service.repoRefCache.Len()) + + files, err := service.ListFiles( + t.Context(), + repositoryUrl, + "refs/heads/main", + username, + accessToken, + false, + false, + []string{}, + false, + ) + require.NoError(t, err) + assert.GreaterOrEqual(t, len(files), 1) + assert.Equal(t, 1, service.repoFileCache.Len()) + + files, err = service.ListFiles( + t.Context(), + repositoryUrl, + "refs/heads/test", + username, + accessToken, + false, + false, + []string{}, + false, + ) + require.NoError(t, err) + assert.GreaterOrEqual(t, len(files), 1) + assert.Equal(t, 2, service.repoFileCache.Len()) + + _, err = service.ListRefs(t.Context(), repositoryUrl, username, "fake-token", false, false) + require.Error(t, err) + assert.Equal(t, 1, service.repoRefCache.Len()) + + _, err = service.ListRefs(t.Context(), repositoryUrl, username, "fake-token", true, false) + require.Error(t, err) + assert.Equal(t, 1, service.repoRefCache.Len()) + // The relevant file caches should be removed too + assert.Equal(t, 0, service.repoFileCache.Len()) +} + +func TestService_HardRefresh_ListFiles_GitHub(t *testing.T) { + t.Parallel() + ensureIntegrationTest(t) + + service := newService(t.Context(), 2, 0) + accessToken := getRequiredValue(t, "GITHUB_PAT") + username := getRequiredValue(t, "GITHUB_USERNAME") + repositoryUrl := privateGitRepoURL + files, err := service.ListFiles( + t.Context(), + repositoryUrl, + "refs/heads/main", + username, + accessToken, + false, + false, + []string{}, + false, + ) + require.NoError(t, err) + assert.GreaterOrEqual(t, len(files), 1) + assert.Equal(t, 1, service.repoFileCache.Len()) + + _, err = service.ListFiles( + t.Context(), + repositoryUrl, + "refs/heads/main", + username, + "fake-token", + false, + true, + []string{}, + false, + ) + require.Error(t, err) + assert.Equal(t, 0, service.repoFileCache.Len()) +} + +func TestService_CloneRepository_TokenAuth(t *testing.T) { + t.Parallel() + ensureIntegrationTest(t) + + service := newService(t.Context(), 2, 0) + var requests []*http.Request + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests = append(requests, r) + })) + accessToken := "test_access_token" + username := "test_username" + repositoryUrl := testServer.URL + + // Since we aren't hitting a real git server we ignore the error + _ = service.CloneRepository( + t.Context(), + "test_dir", + repositoryUrl, + "refs/heads/main", + username, + accessToken, + false, + ) + + testServer.Close() + + if len(requests) != 1 { + t.Fatalf("expected 1 request sent but got %d", len(requests)) + } + + gotAuthHeader := requests[0].Header.Get("Authorization") + if gotAuthHeader == "" { + t.Fatal("no Authorization header in git request") + } + + expectedAuthHeader := "Bearer test_access_token" + if gotAuthHeader != expectedAuthHeader { + t.Fatalf("expected Authorization header %q but got %q", expectedAuthHeader, gotAuthHeader) + } +} diff --git a/api/git/git_test.go b/api/git/git_test.go new file mode 100644 index 0000000..edd2b1c --- /dev/null +++ b/api/git/git_test.go @@ -0,0 +1,506 @@ +package git + +import ( + "os" + "testing" + "time" + + "github.com/portainer/portainer/api/archive" + "github.com/portainer/portainer/api/filesystem" + gittypes "github.com/portainer/portainer/api/git/types" + + "github.com/go-git/go-billy/v5/osfs" + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/filemode" + "github.com/go-git/go-git/v5/plumbing/object" + githttp "github.com/go-git/go-git/v5/plumbing/transport/http" + "github.com/pkg/errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func setup(t *testing.T) string { + dir := t.TempDir() + bareRepoDir := filesystem.JoinPaths(dir, "test-clone.git") + + file, err := os.OpenFile("./testdata/test-clone-git-repo.tar.gz", os.O_RDONLY, 0o755) + if err != nil { + t.Fatal(errors.Wrap(err, "failed to open an archive")) + } + + if err := archive.ExtractTarGz(file, dir); err != nil { + t.Fatal(errors.Wrapf(err, "failed to extract file from the archive to a folder %s", dir)) + } + + return bareRepoDir +} + +func Test_checkGitError(t *testing.T) { + t.Parallel() + tests := []struct { + name string + err error + expected error + }{ + { + name: "exact repository not found", + err: errors.New("repository not found"), + expected: gittypes.ErrIncorrectRepositoryURL, + }, + { + name: "repository not found with html body", + err: errors.New("repository not found: 404 Not Found"), + expected: gittypes.ErrIncorrectRepositoryURL, + }, + { + name: "authentication required", + err: errors.New("authentication required"), + expected: gittypes.ErrAuthenticationFailure, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := checkGitError(tt.err) + assert.Equal(t, tt.expected, result) + }) + } + + t.Run("other error is unchanged", func(t *testing.T) { + err := errors.New("some other git error") + assert.EqualError(t, checkGitError(err), "some other git error") + }) +} + +func Test_ClonePublicRepository_Shallow(t *testing.T) { + t.Parallel() + service := Service{git: NewGitClient(true)} // no need for http client since the test access the repo via file system. + repositoryURL := setup(t) + referenceName := "refs/heads/main" + + dir := t.TempDir() + t.Logf("Cloning into %s", dir) + err := service.CloneRepository(t.Context(), dir, repositoryURL, referenceName, "", "", false) + require.NoError(t, err) + assert.Equal(t, 1, getCommitHistoryLength(t, dir), "cloned repo has incorrect depth") +} + +func Test_ClonePublicRepository_NoGitDirectory(t *testing.T) { + t.Parallel() + service := Service{git: NewGitClient(false)} // no need for http client since the test access the repo via file system. + repositoryURL := setup(t) + referenceName := "refs/heads/main" + + dir := t.TempDir() + t.Logf("Cloning into %s", dir) + err := service.CloneRepository(t.Context(), dir, repositoryURL, referenceName, "", "", false) + require.NoError(t, err) + assert.NoDirExists(t, filesystem.JoinPaths(dir, ".git")) +} + +func Test_ClonePublicRepository_NonExistentDst(t *testing.T) { + t.Parallel() + service := Service{git: NewGitClient(false)} + repositoryURL := setup(t) + referenceName := "refs/heads/main" + + dir := filesystem.JoinPaths(t.TempDir(), "sub", "dir") + err := service.CloneRepository(t.Context(), dir, repositoryURL, referenceName, "", "", false) + require.NoError(t, err) + assert.DirExists(t, dir) + assert.NoDirExists(t, filesystem.JoinPaths(dir, ".git")) +} + +func Test_latestCommitID(t *testing.T) { + t.Parallel() + service := Service{git: NewGitClient(true)} // no need for http client since the test access the repo via file system. + + repositoryURL := setup(t) + referenceName := "refs/heads/main" + + id, err := service.LatestCommitID(t.Context(), repositoryURL, referenceName, "", "", false) + + require.NoError(t, err) + assert.Equal(t, "68dcaa7bd452494043c64252ab90db0f98ecf8d2", id) +} + +func Test_ListRefs(t *testing.T) { + t.Parallel() + service := Service{git: NewGitClient(true)} + + repositoryURL := setup(t) + + fs, err := service.ListRefs(t.Context(), repositoryURL, "", "", false, false) + + require.NoError(t, err) + assert.Equal(t, []string{"refs/heads/main"}, fs) +} + +func Test_ListFiles(t *testing.T) { + t.Parallel() + service := Service{git: NewGitClient(true)} + + repositoryURL := setup(t) + referenceName := "refs/heads/main" + + fs, err := service.ListFiles( + t.Context(), + repositoryURL, + referenceName, + "", + "", + false, + false, + []string{".yml"}, + false, + ) + + require.NoError(t, err) + assert.Equal(t, []string{"docker-compose.yml"}, fs) +} + +func getCommitHistoryLength(t *testing.T, dir string) int { + repo, err := git.PlainOpen(dir) + if err != nil { + t.Fatalf("can't open a git repo at %s with error %v", dir, err) + } + + iter, err := repo.Log(&git.LogOptions{All: true}) + if err != nil { + t.Fatalf("can't get a commit history iterator with error %v", err) + } + + count := 0 + if err := iter.ForEach(func(_ *object.Commit) error { + count++ + return nil + }); err != nil { + t.Fatalf("can't iterate over the commit history with error %v", err) + } + + return count +} + +func Test_noSymlinkFS_Symlink(t *testing.T) { + fs := NewNoSymlinkFS(osfs.New(t.TempDir())) + err := fs.Symlink("../../../etc/passwd", "evil-link") + require.ErrorIs(t, err, gittypes.ErrSymlinkDetected) +} + +func Test_noSymlinkFS_OtherOperations(t *testing.T) { + dir := t.TempDir() + fs := NewNoSymlinkFS(osfs.New(dir)) + + f, err := fs.Create("test.txt") + require.NoError(t, err) + + _, err = f.Write([]byte("hello")) + require.NoError(t, err) + + err = f.Close() + require.NoError(t, err) + + info, err := fs.Stat("test.txt") + require.NoError(t, err) + require.Equal(t, "test.txt", info.Name()) +} + +func createBareRepoWithSymlink(t *testing.T) string { + t.Helper() + + bareDir := filesystem.JoinPaths(t.TempDir(), "symlink-repo.git") + + repo, err := git.PlainInit(bareDir, true) + require.NoError(t, err) + + storer := repo.Storer + + fileBlob := &plumbing.MemoryObject{} + fileBlob.SetType(plumbing.BlobObject) + + _, err = fileBlob.Write([]byte("hello world\n")) + require.NoError(t, err) + + fileHash, err := storer.SetEncodedObject(fileBlob) + require.NoError(t, err) + + symlinkBlob := &plumbing.MemoryObject{} + symlinkBlob.SetType(plumbing.BlobObject) + + _, err = symlinkBlob.Write([]byte("../../../etc/passwd")) + require.NoError(t, err) + + symlinkHash, err := storer.SetEncodedObject(symlinkBlob) + require.NoError(t, err) + + tree := &object.Tree{ + Entries: []object.TreeEntry{ + {Name: "evil-link", Mode: filemode.Symlink, Hash: symlinkHash}, + {Name: "file.txt", Mode: filemode.Regular, Hash: fileHash}, + }, + } + + treeObj := &plumbing.MemoryObject{} + + err = tree.Encode(treeObj) + require.NoError(t, err) + + treeHash, err := storer.SetEncodedObject(treeObj) + require.NoError(t, err) + + sig := object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()} + commit := &object.Commit{ + Message: "add symlink", + Author: sig, + Committer: sig, + TreeHash: treeHash, + } + + commitObj := &plumbing.MemoryObject{} + + err = commit.Encode(commitObj) + require.NoError(t, err) + + commitHash, err := storer.SetEncodedObject(commitObj) + require.NoError(t, err) + + err = storer.SetReference(plumbing.NewHashReference("refs/heads/main", commitHash)) + require.NoError(t, err) + + err = storer.SetReference(plumbing.NewSymbolicReference(plumbing.HEAD, "refs/heads/main")) + require.NoError(t, err) + + return bareDir +} + +func Test_Download_RejectsSymlink(t *testing.T) { + t.Parallel() + client := NewGitClient(false) + repoURL := createBareRepoWithSymlink(t) + + err := client.Download(t.Context(), t.TempDir(), &git.CloneOptions{ + URL: repoURL, + Depth: 1, + SingleBranch: true, + Tags: git.NoTags, + }) + require.Error(t, err) + require.ErrorIs(t, err, gittypes.ErrSymlinkDetected) +} + +func Test_listRefsPrivateRepository(t *testing.T) { + t.Parallel() + ensureIntegrationTest(t) + + accessToken := getRequiredValue(t, "GITHUB_PAT") + username := getRequiredValue(t, "GITHUB_USERNAME") + + client := NewGitClient(false) + + type args struct { + repositoryUrl string + username string + password string + } + + type expectResult struct { + err error + refsCount int + } + + tests := []struct { + name string + args args + expect expectResult + }{ + { + name: "list refs of a real private repository", + args: args{ + repositoryUrl: privateGitRepoURL, + username: username, + password: accessToken, + }, + expect: expectResult{ + err: nil, + refsCount: 2, + }, + }, + { + name: "list refs of a real private repository with incorrect credential", + args: args{ + repositoryUrl: privateGitRepoURL, + username: "test-username", + password: "test-token", + }, + expect: expectResult{ + err: gittypes.ErrAuthenticationFailure, + }, + }, + { + name: "list refs of a fake repository without providing credential", + args: args{ + repositoryUrl: privateGitRepoURL + "fake", + username: "", + password: "", + }, + expect: expectResult{ + err: gittypes.ErrAuthenticationFailure, + }, + }, + { + name: "list refs of a fake repository", + args: args{ + repositoryUrl: privateGitRepoURL + "fake", + username: username, + password: accessToken, + }, + expect: expectResult{ + err: gittypes.ErrIncorrectRepositoryURL, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + option := &git.ListOptions{} + if tt.args.username != "" || tt.args.password != "" { + option.Auth = &githttp.BasicAuth{ + Username: tt.args.username, + Password: tt.args.password, + } + } + refs, err := client.ListRefs(t.Context(), tt.args.repositoryUrl, option) + if tt.expect.err == nil { + require.NoError(t, err) + if tt.expect.refsCount > 0 { + assert.NotEmpty(t, refs) + } + } else { + require.Error(t, err) + assert.Equal(t, tt.expect.err, err) + } + }) + } +} + +func Test_listFilesPrivateRepository(t *testing.T) { + t.Parallel() + ensureIntegrationTest(t) + + client := NewGitClient(false) + + type args struct { + repositoryUrl string + referenceName string + username string + password string + } + + type expectResult struct { + shouldFail bool + err error + matchedCount int + } + + accessToken := getRequiredValue(t, "GITHUB_PAT") + username := getRequiredValue(t, "GITHUB_USERNAME") + + tests := []struct { + name string + args args + expect expectResult + }{ + { + name: "list tree with real repository and head ref but incorrect credential", + args: args{ + repositoryUrl: privateGitRepoURL, + referenceName: "refs/heads/main", + username: "test-username", + password: "test-token", + }, + expect: expectResult{ + shouldFail: true, + err: gittypes.ErrAuthenticationFailure, + }, + }, + { + name: "list tree with real repository and head ref but no credential", + args: args{ + repositoryUrl: privateGitRepoURL, + referenceName: "refs/heads/main", + username: "", + password: "", + }, + expect: expectResult{ + shouldFail: true, + err: gittypes.ErrAuthenticationFailure, + }, + }, + { + name: "list tree with real repository and head ref", + args: args{ + repositoryUrl: privateGitRepoURL, + referenceName: "refs/heads/main", + username: username, + password: accessToken, + }, + expect: expectResult{ + err: nil, + matchedCount: 15, + }, + }, + { + name: "list tree with real repository but non-existing ref", + args: args{ + repositoryUrl: privateGitRepoURL, + referenceName: "refs/fake/feature", + username: username, + password: accessToken, + }, + expect: expectResult{ + shouldFail: true, + }, + }, + { + name: "list tree with fake repository ", + args: args{ + repositoryUrl: privateGitRepoURL + "fake", + referenceName: "refs/fake/feature", + username: username, + password: accessToken, + }, + expect: expectResult{ + shouldFail: true, + err: gittypes.ErrIncorrectRepositoryURL, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + option := &git.CloneOptions{ + URL: tt.args.repositoryUrl, + ReferenceName: plumbing.ReferenceName(tt.args.referenceName), + } + if tt.args.username != "" || tt.args.password != "" { + option.Auth = &githttp.BasicAuth{ + Username: tt.args.username, + Password: tt.args.password, + } + } + paths, err := client.ListFiles(t.Context(), false, option) + if tt.expect.shouldFail { + require.Error(t, err) + if tt.expect.err != nil { + assert.Equal(t, tt.expect.err, err) + } + } else { + require.NoError(t, err) + if tt.expect.matchedCount > 0 { + assert.NotEmpty(t, paths) + } + } + }) + } +} diff --git a/api/git/service.go b/api/git/service.go new file mode 100644 index 0000000..a48611a --- /dev/null +++ b/api/git/service.go @@ -0,0 +1,367 @@ +package git + +import ( + "context" + "strconv" + "strings" + "time" + + "github.com/portainer/portainer/pkg/schedule" + + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/transport" + githttp "github.com/go-git/go-git/v5/plumbing/transport/http" + lru "github.com/hashicorp/golang-lru" + "github.com/rs/zerolog/log" + "golang.org/x/sync/singleflight" +) + +const ( + repositoryCacheSize = 4 + repositoryCacheTTL = 5 * time.Minute +) + +type RepoManager interface { + Download(ctx context.Context, dst string, opt *git.CloneOptions) error + LatestCommitID(ctx context.Context, repositoryUrl, referenceName string, opt *git.ListOptions) (string, error) + ListRefs(ctx context.Context, repositoryUrl string, opt *git.ListOptions) ([]string, error) + ListFiles(ctx context.Context, dirOnly bool, opt *git.CloneOptions) ([]string, error) +} + +// Service represents a service for managing Git. +type Service struct { + azure RepoManager + git RepoManager + + cacheEnabled bool + // Cache the result of repository refs, key is repository URL + repoRefCache *lru.Cache + // Cache the result of repository file tree, key is the concatenated string of repository URL and ref value + repoFileCache *lru.Cache +} + +// NewService initializes a new service. +func NewService(ctx context.Context) *Service { + return newService(ctx, repositoryCacheSize, repositoryCacheTTL) +} + +func newService(ctx context.Context, cacheSize int, cacheTTL time.Duration) *Service { + service := &Service{ + azure: NewAzureClient(), + git: NewGitClient(false), + cacheEnabled: cacheSize > 0, + } + + if !service.cacheEnabled { + return service + } + + var err error + service.repoRefCache, err = lru.New(cacheSize) + if err != nil { + log.Debug().Err(err).Msg("failed to create ref cache") + } + + service.repoFileCache, err = lru.New(cacheSize) + if err != nil { + log.Debug().Err(err).Msg("failed to create file cache") + } + + if cacheTTL > 0 { + go schedule.RunOnInterval(ctx, cacheTTL, service.purgeCache, nil) + } + + return service +} + +// CloneRepository clones a git repository using the specified URL in the specified +// destination folder. +func (service *Service) CloneRepository( + ctx context.Context, + destination, + repositoryURL, + referenceName, + username, + password string, + tlsSkipVerify bool, +) error { + return service.CloneRepositoryWithAuth(ctx, destination, repositoryURL, referenceName, GetBasicAuth(username, password), tlsSkipVerify) +} + +// CloneRepositoryWithAuth clones a git repository using the specified URL in the specified +// destination folder, using the provided auth method. +func (service *Service) CloneRepositoryWithAuth( + ctx context.Context, + destination, + repositoryURL, + referenceName string, + auth transport.AuthMethod, + tlsSkipVerify bool, +) error { + gitOptions := &git.CloneOptions{ + URL: repositoryURL, + Depth: 1, + InsecureSkipTLS: tlsSkipVerify, + Auth: auth, + Tags: git.NoTags, + } + + if referenceName != "" { + gitOptions.ReferenceName = plumbing.ReferenceName(referenceName) + } + + return service.repoManager(repositoryURL).Download(ctx, destination, gitOptions) +} + +func (service *Service) repoManager(repositoryURL string) RepoManager { + repoManager := service.git + + if IsAzureUrl(repositoryURL) { + repoManager = service.azure + } + + return repoManager +} + +// LatestCommitID returns SHA1 of the latest commit of the specified reference +func (service *Service) LatestCommitID( + ctx context.Context, + repositoryURL, + referenceName, + username, + password string, + tlsSkipVerify bool, +) (string, error) { + return service.LatestCommitIDWithAuth(ctx, repositoryURL, referenceName, GetBasicAuth(username, password), tlsSkipVerify) +} + +// LatestCommitIDWithAuth returns SHA1 of the latest commit of the specified reference, +// using the provided auth method. +func (service *Service) LatestCommitIDWithAuth( + ctx context.Context, + repositoryURL, + referenceName string, + auth transport.AuthMethod, + tlsSkipVerify bool, +) (string, error) { + listOptions := &git.ListOptions{ + Auth: auth, + InsecureSkipTLS: tlsSkipVerify, + } + + return service.repoManager(repositoryURL).LatestCommitID(ctx, repositoryURL, referenceName, listOptions) +} + +// ListRefs will list target repository's references without cloning the repository +func (service *Service) ListRefs( + ctx context.Context, + repositoryURL, + username, + password string, + hardRefresh bool, + tlsSkipVerify bool, +) ([]string, error) { + cacheKey := GenerateCacheKey(repositoryURL, username, password, strconv.FormatBool(tlsSkipVerify)) + return service.ListRefsWithAuth(ctx, repositoryURL, hardRefresh, GetBasicAuth(username, password), tlsSkipVerify, cacheKey) +} + +// ListRefsWithAuth will list target repository's references without cloning the repository, +// using the provided auth method. The cacheKey is supplied by the caller. +func (service *Service) ListRefsWithAuth( + ctx context.Context, + repositoryURL string, + hardRefresh bool, + auth transport.AuthMethod, + tlsSkipVerify bool, + cacheKey string, +) ([]string, error) { + if service.cacheEnabled && hardRefresh { + // Should remove the cache explicitly, so that the following normal list can show the correct result + service.repoRefCache.Remove(cacheKey) + // Remove file caches pointed to the same repository + for _, fileCacheKey := range service.repoFileCache.Keys() { + if key, ok := fileCacheKey.(string); ok && strings.HasPrefix(key, repositoryURL) { + service.repoFileCache.Remove(key) + } + } + } + + if service.repoRefCache != nil { + // Lookup the refs cache first + if cache, ok := service.repoRefCache.Get(cacheKey); ok { + if refs, ok := cache.([]string); ok { + return refs, nil + } + } + } + + options := &git.ListOptions{ + Auth: auth, + InsecureSkipTLS: tlsSkipVerify, + } + + refs, err := service.repoManager(repositoryURL).ListRefs(ctx, repositoryURL, options) + if err != nil { + return nil, err + } + + if service.cacheEnabled && service.repoRefCache != nil { + service.repoRefCache.Add(cacheKey, refs) + } + + return refs, nil +} + +var singleflightGroup = &singleflight.Group{} + +// ListFiles will list all the files of the target repository with specific extensions. +// If extension is not provided, it will list all the files under the target repository +func (service *Service) ListFiles( + ctx context.Context, + repositoryURL, + referenceName, + username, + password string, + dirOnly, + hardRefresh bool, + includedExts []string, + tlsSkipVerify bool, +) ([]string, error) { + cacheKey := GenerateCacheKey( + repositoryURL, + referenceName, + username, + password, + strconv.FormatBool(tlsSkipVerify), + strconv.FormatBool(dirOnly), + ) + return service.ListFilesWithAuth(ctx, repositoryURL, referenceName, dirOnly, hardRefresh, GetBasicAuth(username, password), includedExts, tlsSkipVerify, cacheKey) +} + +// ListFilesWithAuth will list all the files of the target repository with specific extensions, +// using the provided auth method. The cacheKey is supplied by the caller. +func (service *Service) ListFilesWithAuth( + ctx context.Context, + repositoryURL, + referenceName string, + dirOnly, + hardRefresh bool, + auth transport.AuthMethod, + includedExts []string, + tlsSkipVerify bool, + cacheKey string, +) ([]string, error) { + fs, err, _ := singleflightGroup.Do(cacheKey, func() (any, error) { + return service.listFilesWithAuth(ctx, repositoryURL, referenceName, dirOnly, hardRefresh, auth, tlsSkipVerify, cacheKey) + }) + + return filterFiles(fs.([]string), includedExts), err +} + +func (service *Service) listFilesWithAuth( + ctx context.Context, + repositoryURL, + referenceName string, + dirOnly, + hardRefresh bool, + auth transport.AuthMethod, + tlsSkipVerify bool, + cacheKey string, +) ([]string, error) { + if service.cacheEnabled && hardRefresh { + // Should remove the cache explicitly, so that the following normal list can show the correct result + service.repoFileCache.Remove(cacheKey) + } + + if service.repoFileCache != nil { + // lookup the files cache first + if cache, ok := service.repoFileCache.Get(cacheKey); ok { + if files, ok := cache.([]string); ok { + return files, nil + } + } + } + + cloneOption := &git.CloneOptions{ + URL: repositoryURL, + NoCheckout: true, + Depth: 1, + SingleBranch: true, + ReferenceName: plumbing.ReferenceName(referenceName), + Auth: auth, + InsecureSkipTLS: tlsSkipVerify, + Tags: git.NoTags, + } + + files, err := service.repoManager(repositoryURL).ListFiles(ctx, dirOnly, cloneOption) + if err != nil { + return nil, err + } + + if service.cacheEnabled && service.repoFileCache != nil { + service.repoFileCache.Add(cacheKey, files) + } + + return files, nil +} + +func (service *Service) purgeCache() { + if service.repoRefCache != nil { + service.repoRefCache.Purge() + } + + if service.repoFileCache != nil { + service.repoFileCache.Purge() + } +} + +// GenerateCacheKey generates a cache key from the given parts. +func GenerateCacheKey(names ...string) string { + return strings.Join(names, "-") +} + +func matchExtensions(target string, exts []string) bool { + if len(exts) == 0 { + return true + } + + for _, ext := range exts { + if strings.HasSuffix(target, ext) { + return true + } + } + + return false +} + +func filterFiles(paths []string, includedExts []string) []string { + if len(includedExts) == 0 { + return paths + } + + var includedFiles []string + for _, filename := range paths { + // Filter out the filenames with non-included extension + if matchExtensions(filename, includedExts) { + includedFiles = append(includedFiles, filename) + } + } + + return includedFiles +} + +func GetBasicAuth(username, password string) *githttp.BasicAuth { + if password == "" { + return nil + } + + if username == "" { + username = "token" + } + + return &githttp.BasicAuth{ + Username: username, + Password: password, + } +} diff --git a/api/git/service_test.go b/api/git/service_test.go new file mode 100644 index 0000000..25a4bb7 --- /dev/null +++ b/api/git/service_test.go @@ -0,0 +1,343 @@ +package git + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" +) + +type mockRepoManager struct { + downloadErr error + commitID string + commitIDErr error + refs []string + refsErr error + files []string + filesErr error + + downloadCalled int + commitIDCalled int + listRefsCalled int + listFilesCalled int + lastCloneOptions *git.CloneOptions +} + +func (m *mockRepoManager) Download(_ context.Context, _ string, opts *git.CloneOptions) error { + m.downloadCalled++ + m.lastCloneOptions = opts + return m.downloadErr +} + +func (m *mockRepoManager) LatestCommitID(_ context.Context, _, _ string, _ *git.ListOptions) (string, error) { + m.commitIDCalled++ + return m.commitID, m.commitIDErr +} + +func (m *mockRepoManager) ListRefs(_ context.Context, _ string, _ *git.ListOptions) ([]string, error) { + m.listRefsCalled++ + return m.refs, m.refsErr +} + +func (m *mockRepoManager) ListFiles(_ context.Context, _ bool, _ *git.CloneOptions) ([]string, error) { + m.listFilesCalled++ + return m.files, m.filesErr +} + +func newTestService(ctx context.Context, cacheSize int, gitMgr, azureMgr RepoManager) *Service { + s := newService(ctx, cacheSize, 0) + s.git = gitMgr + s.azure = azureMgr + return s +} + +func TestCloneRepository(t *testing.T) { + t.Parallel() + downloadErr := errors.New("clone failed") + + testCases := []struct { + name string + url string + referenceName string + gitManagerDownloadCalled int + azureManagerDownloadCalled int + managerErr bool + expectedError error + expectedReferenceName string + }{ + { + name: "non-azure URL routes to git manager", + url: "https://github.com/example/repo.git", + gitManagerDownloadCalled: 1, + azureManagerDownloadCalled: 0, + }, + { + name: "azure URL routes to azure manager", + url: "https://dev.azure.com/org/project/_git/repo", + gitManagerDownloadCalled: 0, + azureManagerDownloadCalled: 1, + }, + { + name: "error from manager propagated", + url: "https://github.com/example/repo.git", + managerErr: true, + gitManagerDownloadCalled: 1, + expectedError: downloadErr, + }, + { + name: "ReferenceName is passed to clone options", + url: "https://github.com/example/repo.git", + referenceName: "refs/heads/feature-branch", + gitManagerDownloadCalled: 1, + expectedReferenceName: "refs/heads/feature-branch", + }, + { + name: "empty ReferenceName leaves clone options unset", + url: "https://github.com/example/repo.git", + referenceName: "", + gitManagerDownloadCalled: 1, + expectedReferenceName: "", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + gitMgr := &mockRepoManager{} + azureMgr := &mockRepoManager{} + if tc.managerErr { + gitMgr.downloadErr = downloadErr + azureMgr.downloadErr = downloadErr + } + s := newTestService(t.Context(), 4, gitMgr, azureMgr) + + err := s.CloneRepository(t.Context(), "/tmp", tc.url, tc.referenceName, "", "", false) + require.Equal(t, tc.expectedError, err) + require.Equal(t, tc.gitManagerDownloadCalled, gitMgr.downloadCalled) + require.Equal(t, tc.azureManagerDownloadCalled, azureMgr.downloadCalled) + + activeMgr := gitMgr + if tc.azureManagerDownloadCalled > 0 { + activeMgr = azureMgr + } + if activeMgr.lastCloneOptions != nil { + require.Equal(t, plumbing.ReferenceName(tc.expectedReferenceName), activeMgr.lastCloneOptions.ReferenceName) + } + }) + } +} + +func TestLatestCommitID(t *testing.T) { + t.Parallel() + commitLookupErr := errors.New("commit lookup failed") + + testCases := []struct { + name string + url string + gitCommitID string + azureCommitID string + commitIDErr error + expectedID string + expectedError error + gitCalled int + azureCalled int + }{ + { + name: "non-azure URL routes to git manager", + url: "https://github.com/example/repo.git", + gitCommitID: "abc123", + expectedID: "abc123", + gitCalled: 1, + }, + { + name: "azure URL routes to azure manager", + url: "https://dev.azure.com/org/project/_git/repo", + azureCommitID: "def456", + expectedID: "def456", + azureCalled: 1, + }, + { + name: "error propagated", + url: "https://github.com/example/repo.git", + commitIDErr: commitLookupErr, + expectedError: commitLookupErr, + gitCalled: 1, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + gitMgr := &mockRepoManager{commitID: tc.gitCommitID, commitIDErr: tc.commitIDErr} + azureMgr := &mockRepoManager{commitID: tc.azureCommitID} + s := newTestService(t.Context(), 4, gitMgr, azureMgr) + + id, err := s.LatestCommitID(t.Context(), tc.url, "", "", "", false) + require.Equal(t, tc.expectedError, err) + require.Equal(t, tc.expectedID, id) + require.Equal(t, tc.gitCalled, gitMgr.commitIDCalled) + require.Equal(t, tc.azureCalled, azureMgr.commitIDCalled) + }) + } +} + +func TestListRefs(t *testing.T) { + t.Parallel() + t.Run("cache hit on second call", func(t *testing.T) { + gitMgr := &mockRepoManager{refs: []string{"refs/heads/main", "refs/heads/develop"}} + s := newTestService(t.Context(), 4, gitMgr, &mockRepoManager{}) + + refs1, err := s.ListRefs(t.Context(), "https://github.com/example/repo.git", "", "", false, false) + require.NoError(t, err) + + refs2, err := s.ListRefs(t.Context(), "https://github.com/example/repo.git", "", "", false, false) + require.NoError(t, err) + + require.Equal(t, 1, gitMgr.listRefsCalled, "expected manager to be called once") + require.Equal(t, refs1, refs2) + }) + + t.Run("hard refresh clears cache and calls manager again", func(t *testing.T) { + gitMgr := &mockRepoManager{refs: []string{"refs/heads/main"}} + s := newTestService(t.Context(), 4, gitMgr, &mockRepoManager{}) + + _, err := s.ListRefs(t.Context(), "https://github.com/example/repo.git", "", "", false, false) + require.NoError(t, err) + _, err = s.ListRefs(t.Context(), "https://github.com/example/repo.git", "", "", true, false) + require.NoError(t, err) + + require.Equal(t, 2, gitMgr.listRefsCalled, "expected manager to be called twice with hard refresh") + }) + + t.Run("error propagated and not cached", func(t *testing.T) { + wantErr := errors.New("refs failed") + gitMgr := &mockRepoManager{refsErr: wantErr} + s := newTestService(t.Context(), 4, gitMgr, &mockRepoManager{}) + + _, err := s.ListRefs(t.Context(), "https://github.com/example/repo.git", "", "", false, false) + require.Equal(t, wantErr, err) + + _, err = s.ListRefs(t.Context(), "https://github.com/example/repo.git", "", "", true, false) + require.Equal(t, wantErr, err) + require.Equal(t, 2, gitMgr.listRefsCalled, "expected manager to be called twice after error") + }) + + t.Run("azure URL routes to azure manager", func(t *testing.T) { + gitMgr := &mockRepoManager{} + azureMgr := &mockRepoManager{refs: []string{"refs/heads/main"}} + s := newTestService(t.Context(), 4, gitMgr, azureMgr) + + _, err := s.ListRefs(t.Context(), "https://dev.azure.com/org/project/_git/repo", "", "", false, false) + require.NoError(t, err) + require.Equal(t, 1, azureMgr.listRefsCalled, "expected azure.ListRefs to be called once") + require.Equal(t, 0, gitMgr.listRefsCalled, "expected git.ListRefs to not be called") + }) + + t.Run("cache disabled: manager always called", func(t *testing.T) { + gitMgr := &mockRepoManager{refs: []string{"refs/heads/main"}} + s := newTestService(t.Context(), 0, gitMgr, &mockRepoManager{}) + + _, err := s.ListRefs(t.Context(), "https://github.com/example/repo.git", "", "", false, false) + require.NoError(t, err) + _, err = s.ListRefs(t.Context(), "https://github.com/example/repo.git", "", "", false, false) + require.NoError(t, err) + + require.Equal(t, 2, gitMgr.listRefsCalled, "expected manager to be called twice with cache disabled") + }) +} + +func TestListFiles(t *testing.T) { + t.Parallel() + t.Run("cache hit on second call", func(t *testing.T) { + gitMgr := &mockRepoManager{files: []string{"docker-compose.yml", "README.md"}} + s := newTestService(t.Context(), 4, gitMgr, &mockRepoManager{}) + + files1, err := s.ListFiles(t.Context(), "https://github.com/example/repo.git", "refs/heads/main", "", "", false, false, nil, false) + require.NoError(t, err) + + files2, err := s.ListFiles(t.Context(), "https://github.com/example/repo.git", "refs/heads/main", "", "", false, false, nil, false) + require.NoError(t, err) + + require.Equal(t, 1, gitMgr.listFilesCalled, "expected manager to be called once") + require.Equal(t, files1, files2) + }) + + t.Run("hard refresh clears file cache", func(t *testing.T) { + gitMgr := &mockRepoManager{files: []string{"docker-compose.yml"}} + s := newTestService(t.Context(), 4, gitMgr, &mockRepoManager{}) + + _, err := s.ListFiles(t.Context(), "https://github.com/example/repo.git", "refs/heads/main", "", "", false, false, nil, false) + require.NoError(t, err) + _, err = s.ListFiles(t.Context(), "https://github.com/example/repo.git", "refs/heads/main", "", "", false, true, nil, false) + require.NoError(t, err) + + require.Equal(t, 2, gitMgr.listFilesCalled, "expected manager to be called twice with hard refresh") + }) + + t.Run("azure URL routes to azure manager", func(t *testing.T) { + gitMgr := &mockRepoManager{} + azureMgr := &mockRepoManager{files: []string{"docker-compose.yml"}} + s := newTestService(t.Context(), 4, gitMgr, azureMgr) + + _, err := s.ListFiles(t.Context(), "https://dev.azure.com/org/project/_git/repo", "", "", "", false, false, nil, false) + require.NoError(t, err) + require.Equal(t, 1, azureMgr.listFilesCalled, "expected azure.ListFiles to be called once") + require.Equal(t, 0, gitMgr.listFilesCalled, "expected git.ListFiles to not be called") + }) + + t.Run("extension filter applied", func(t *testing.T) { + gitMgr := &mockRepoManager{files: []string{"docker-compose.yml", "README.md", "stack.yml", "config.json"}} + s := newTestService(t.Context(), 4, gitMgr, &mockRepoManager{}) + + files, err := s.ListFiles(t.Context(), "https://github.com/example/repo.git", "", "", "", false, false, []string{".yml"}, false) + require.NoError(t, err) + require.Equal(t, []string{"docker-compose.yml", "stack.yml"}, files) + }) + + t.Run("error is returned and not cached", func(t *testing.T) { + wantErr := errors.New("list files failed") + gitMgr := &mockRepoManager{filesErr: wantErr} + s := newTestService(t.Context(), 4, gitMgr, &mockRepoManager{}) + + files, err := s.ListFiles(t.Context(), "https://github.com/example/repo.git", "refs/heads/main", "", "", false, false, nil, false) + require.ErrorIs(t, err, wantErr) + require.Nil(t, files) + }) +} + +func TestFilterFiles(t *testing.T) { + t.Parallel() + testCases := []struct { + name string + files []string + exts []string + expectedFiles []string + }{ + { + name: "empty ext list returns all files", + files: []string{"a.yml", "b.json", "c.txt"}, + exts: nil, + expectedFiles: []string{"a.yml", "b.json", "c.txt"}, + }, + { + name: "non-matching exts returns empty", + files: []string{"a.yml", "b.json"}, + exts: []string{".txt"}, + expectedFiles: nil, + }, + { + name: "partial match returns only matching files", + files: []string{"a.yml", "b.json", "c.yml"}, + exts: []string{".yml"}, + expectedFiles: []string{"a.yml", "c.yml"}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.expectedFiles, filterFiles(tc.files, tc.exts)) + }) + } +} diff --git a/api/git/ssrf_transport.go b/api/git/ssrf_transport.go new file mode 100644 index 0000000..6ef4614 --- /dev/null +++ b/api/git/ssrf_transport.go @@ -0,0 +1,53 @@ +package git + +import ( + "context" + "fmt" + "net" + "strconv" + + "github.com/portainer/portainer/pkg/libhttp/ssrf" + + gittransport "github.com/go-git/go-git/v5/plumbing/transport" +) + +const gitDefaultPort = 9418 + +// ssrfGitTransport wraps a git:// transport and validates the resolved IP +// against the SSRF policy before establishing connections. +type ssrfGitTransport struct { + inner gittransport.Transport +} + +// NewSSRFGitTransport wraps inner and blocks connections to private IP ranges +// according to the active SSRF policy. +func NewSSRFGitTransport(inner gittransport.Transport) gittransport.Transport { + return &ssrfGitTransport{inner: inner} +} + +func (t *ssrfGitTransport) NewUploadPackSession(ep *gittransport.Endpoint, auth gittransport.AuthMethod) (gittransport.UploadPackSession, error) { + if err := checkEndpointSSRF(ep); err != nil { + return nil, err + } + + return t.inner.NewUploadPackSession(ep, auth) +} + +func (t *ssrfGitTransport) NewReceivePackSession(ep *gittransport.Endpoint, auth gittransport.AuthMethod) (gittransport.ReceivePackSession, error) { + if err := checkEndpointSSRF(ep); err != nil { + return nil, err + } + + return t.inner.NewReceivePackSession(ep, auth) +} + +func checkEndpointSSRF(ep *gittransport.Endpoint) error { + port := ep.Port + if port <= 0 { + port = gitDefaultPort + } + + rawURL := fmt.Sprintf("git://%s/", net.JoinHostPort(ep.Host, strconv.Itoa(port))) + + return ssrf.CheckURL(context.Background(), rawURL) +} diff --git a/api/git/testdata/azure-repo copy.zip b/api/git/testdata/azure-repo copy.zip new file mode 100644 index 0000000..d4b53d0 Binary files /dev/null and b/api/git/testdata/azure-repo copy.zip differ diff --git a/api/git/testdata/azure-repo.zip b/api/git/testdata/azure-repo.zip new file mode 100644 index 0000000..d4b53d0 Binary files /dev/null and b/api/git/testdata/azure-repo.zip differ diff --git a/api/git/testdata/test-clone-git-repo.tar.gz b/api/git/testdata/test-clone-git-repo.tar.gz new file mode 100644 index 0000000..cba76a0 Binary files /dev/null and b/api/git/testdata/test-clone-git-repo.tar.gz differ diff --git a/api/git/types/types.go b/api/git/types/types.go new file mode 100644 index 0000000..087f6bc --- /dev/null +++ b/api/git/types/types.go @@ -0,0 +1,135 @@ +package gittypes + +import ( + "cmp" + "errors" + "net/url" + "path" + "strings" +) + +var ( + ErrIncorrectRepositoryURL = errors.New("git repository could not be found, please ensure that the URL is correct") + ErrAuthenticationFailure = errors.New("authentication failed, please ensure that the git credentials are correct") + ErrSymlinkDetected = errors.New("repository contains a symlink, which is not allowed for security reasons") +) + +type GitCredentialAuthType int + +type GitProvider int + +// RepoConfig represents a configuration for a repo +type RepoConfig struct { + // The repo url + URL string `example:"https://github.com/portainer/portainer.git"` + // The reference name + ReferenceName string `example:"refs/heads/branch_name"` + // ConfigFilePath is the path to the config file within the repository. + // NOTE: For stacks, this mirrors Stack.EntryPoint and the two are kept in sync by stackUpdateGit. + ConfigFilePath string `example:"docker-compose.yml"` + // Git credentials + Authentication *GitAuthentication `json:",omitempty"` + // Repository hash + ConfigHash string `example:"bc4c183d756879ea4d173315338110b31004b8e0"` + // TLSSkipVerify skips SSL verification when cloning the Git repository + TLSSkipVerify bool `example:"false"` +} + +// GitSource holds the shared connection fields stored on a Source. +// Per-file fields (ref, path, hash) are stored on ArtifactFile instead. +type GitSource struct { + URL string `example:"https://github.com/portainer/portainer.git"` + Authentication *GitAuthentication `json:",omitempty"` + TLSSkipVerify bool `example:"false"` +} + +// ToRepoConfig returns a RepoConfig populated with the connection fields from gc +func (gc *GitSource) ToRepoConfig() *RepoConfig { + return &RepoConfig{ + URL: gc.URL, + Authentication: gc.Authentication, + TLSSkipVerify: gc.TLSSkipVerify, + } +} + +// SanitizeGitSource returns a copy of gc with the URL sanitized and password cleared, +// safe to return to clients +func SanitizeGitSource(gc *GitSource) *GitSource { + if gc == nil { + return nil + } + + result := *gc + result.URL = SanitizeURL(result.URL) + + if result.Authentication != nil && result.Authentication.Password != "" { + auth := *result.Authentication + auth.Password = "" + result.Authentication = &auth + } + + return &result +} + +// RepoName extracts the repository name from a git URL for use as a display name. +// e.g. "https://github.com/org/app-config.git" results in "app-config" +func RepoName(rawURL string) string { + return strings.TrimSuffix(path.Base(rawURL), ".git") +} + +// NormalizeURL returns a canonical form of rawURL for deduplication purposes: +// scheme and host are lowercased, embedded credentials are removed, trailing +// slashes and the .git suffix are stripped from the path. If the scheme is +// absent it defaults to https. +func NormalizeURL(rawURL string) (string, error) { + u, err := url.Parse(rawURL) + if err != nil { + return "", err + } + + u.Scheme = strings.ToLower(cmp.Or(u.Scheme, "https")) + u.Host = strings.ToLower(u.Host) + u.User = nil + u.Path = strings.TrimSuffix(strings.TrimRight(u.Path, "/"), ".git") + + return u.String(), nil +} + +// SanitizeURL strips any userinfo (username/password) embedded in rawURL, +// returning a URL safe to store or return to clients. +func SanitizeURL(rawURL string) string { + u, err := url.Parse(rawURL) + if err != nil || u.User == nil { + return rawURL + } + + u.User = nil + + return u.String() +} + +// SanitizeRepoConfig returns a copy of gc with the URL sanitized and password cleared, +// safe to return to clients. +func SanitizeRepoConfig(gc *RepoConfig) *RepoConfig { + if gc == nil { + return nil + } + + result := *gc + result.URL = SanitizeURL(result.URL) + + if result.Authentication != nil && result.Authentication.Password != "" { + auth := *result.Authentication + auth.Password = "" + result.Authentication = &auth + } + + return &result +} + +type GitAuthentication struct { + Username string + Password string + Provider GitProvider `json:",omitempty"` + AuthorizationType GitCredentialAuthType `json:",omitempty"` +} diff --git a/api/git/types/types_test.go b/api/git/types/types_test.go new file mode 100644 index 0000000..5e11378 --- /dev/null +++ b/api/git/types/types_test.go @@ -0,0 +1,26 @@ +package gittypes + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNormalizeURL(t *testing.T) { + t.Parallel() + + f := func(input, expected string) { + t.Helper() + got, err := NormalizeURL(input) + require.NoError(t, err) + require.Equal(t, expected, got) + } + + f("https://github.com/org/repo.git", "https://github.com/org/repo") + f("https://github.com/org/repo/", "https://github.com/org/repo") + f("https://github.com/org/repo.git/", "https://github.com/org/repo") + f("HTTPS://github.com/org/repo", "https://github.com/org/repo") + f("https://GitHub.COM/org/repo", "https://github.com/org/repo") + f("https://user:pass@github.com/org/repo.git", "https://github.com/org/repo") + f("https://github.com/org/repo", "https://github.com/org/repo") +} diff --git a/api/git/update/update.go b/api/git/update/update.go new file mode 100644 index 0000000..39965a7 --- /dev/null +++ b/api/git/update/update.go @@ -0,0 +1,130 @@ +package update + +import ( + "context" + "os" + "strings" + "time" + + "github.com/pkg/errors" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/filesystem" + "github.com/portainer/portainer/api/git" + gittypes "github.com/portainer/portainer/api/git/types" + "github.com/rs/zerolog/log" +) + +// UpdateGitObject updates a git object based on its config +func UpdateGitObject(ctx context.Context, gitService portainer.GitService, objId string, gitConfig *gittypes.RepoConfig, enableVersionFolder bool, projectPath string) (bool, string, error) { + if gitConfig == nil { + return false, "", nil + } + + log.Debug(). + Str("url", gitConfig.URL). + Str("ref", gitConfig.ReferenceName). + Str("object", objId). + Msg("the object has a git config, try to poll from git repository") + + username, password := git.GetCredentials(gitConfig.Authentication) + + fetchCtx, cancel := context.WithTimeout(ctx, time.Minute) + newHash, err := gitService.LatestCommitID( + fetchCtx, + gitConfig.URL, + gitConfig.ReferenceName, + username, + password, + gitConfig.TLSSkipVerify, + ) + cancel() + if err != nil { + if fetchCtx.Err() == context.DeadlineExceeded { + log.Error().Str("object", objId).Msg("git fetch timed out after 1 minute") + } + + return false, "", errors.WithMessagef(err, "failed to fetch latest commit id of %v", objId) + } + + hashChanged := !strings.EqualFold(newHash, gitConfig.ConfigHash) + + if !hashChanged { + log.Debug(). + Str("hash", newHash). + Str("url", gitConfig.URL). + Str("ref", gitConfig.ReferenceName). + Str("object", objId). + Msg("git repo is up to date") + + return false, newHash, nil + } + + toDir := projectPath + if enableVersionFolder { + toDir = filesystem.JoinPaths(projectPath, newHash) + } + + cloneParams := &cloneRepositoryParameters{ + url: gitConfig.URL, + ref: gitConfig.ReferenceName, + toDir: toDir, + tlsSkipVerify: gitConfig.TLSSkipVerify, + } + if gitConfig.Authentication != nil { + cloneParams.auth = &gitAuth{ + username: username, + password: password, + } + } + + if err := cloneGitRepository(ctx, gitService, cloneParams); err != nil { + if enableVersionFolder { + if removeErr := os.RemoveAll(toDir); removeErr != nil { + log.Warn().Err(removeErr).Str("dir", toDir).Msg("failed to remove partial clone directory") + } + } + return false, "", errors.WithMessagef(err, "failed to do a fresh clone of %v", objId) + } + + log.Debug(). + Str("hash", newHash). + Str("url", gitConfig.URL). + Str("ref", gitConfig.ReferenceName). + Str("object", objId). + Msg("git repo cloned updated") + + return true, newHash, nil +} + +type cloneRepositoryParameters struct { + url string + ref string + toDir string + auth *gitAuth + // tlsSkipVerify skips SSL verification when cloning the Git repository + tlsSkipVerify bool `example:"false"` +} + +type gitAuth struct { + username string + password string +} + +func cloneGitRepository(ctx context.Context, gitService portainer.GitService, cloneParams *cloneRepositoryParameters) error { + username, password := "", "" + if cloneParams.auth != nil { + username = cloneParams.auth.username + password = cloneParams.auth.password + } + + return gitService.CloneRepository( + ctx, + cloneParams.toDir, + cloneParams.url, + cloneParams.ref, + username, + password, + cloneParams.tlsSkipVerify, + ) +} diff --git a/api/git/update/validate.go b/api/git/update/validate.go new file mode 100644 index 0000000..8012836 --- /dev/null +++ b/api/git/update/validate.go @@ -0,0 +1,35 @@ +package update + +import ( + "time" + + portainer "github.com/portainer/portainer/api" + httperrors "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/pkg/validate" +) + +func ValidateAutoUpdateSettings(autoUpdate *portainer.AutoUpdateSettings) error { + if autoUpdate == nil { + return nil + } + + if autoUpdate.Webhook == "" && autoUpdate.Interval == "" { + return httperrors.NewInvalidPayloadError("Webhook or Interval must be provided") + } + + if autoUpdate.Webhook != "" && !validate.IsUUID(autoUpdate.Webhook) { + return httperrors.NewInvalidPayloadError("invalid Webhook format") + } + + if autoUpdate.Interval == "" { + return nil + } + + if d, err := time.ParseDuration(autoUpdate.Interval); err != nil { + return httperrors.NewInvalidPayloadError("invalid Interval format") + } else if d < time.Minute { + return httperrors.NewInvalidPayloadError("interval must be at least 1 minute") + } + + return nil +} diff --git a/api/git/update/validate_test.go b/api/git/update/validate_test.go new file mode 100644 index 0000000..1628626 --- /dev/null +++ b/api/git/update/validate_test.go @@ -0,0 +1,53 @@ +package update + +import ( + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/stretchr/testify/assert" +) + +func Test_ValidateAutoUpdate(t *testing.T) { + t.Parallel() + tests := []struct { + name string + value *portainer.AutoUpdateSettings + wantErr bool + }{ + { + name: "webhook is not a valid UUID", + value: &portainer.AutoUpdateSettings{Webhook: "fake-webhook"}, + wantErr: true, + }, + { + name: "incorrect interval value", + value: &portainer.AutoUpdateSettings{Interval: "1dd2hh3mm"}, + wantErr: true, + }, + { + name: "short interval value", + value: &portainer.AutoUpdateSettings{Interval: "1s"}, + wantErr: true, + }, + { + name: "valid webhook without interval", + value: &portainer.AutoUpdateSettings{Webhook: "8dce8c2f-9ca1-482b-ad20-271e86536ada"}, + wantErr: false, + }, + { + name: "valid auto update", + value: &portainer.AutoUpdateSettings{ + Webhook: "8dce8c2f-9ca1-482b-ad20-271e86536ada", + Interval: "5h30m40s10ms", + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateAutoUpdateSettings(tt.value) + assert.Equalf(t, tt.wantErr, err != nil, "received %+v", err) + }) + } +} diff --git a/api/gitops/sources/helpers_test.go b/api/gitops/sources/helpers_test.go new file mode 100644 index 0000000..7ab3154 --- /dev/null +++ b/api/gitops/sources/helpers_test.go @@ -0,0 +1,5 @@ +package sources + +import "github.com/portainer/portainer/api/dataservices/source" + +var adminUserContext = source.InsecureNewAdminContext() diff --git a/api/gitops/sources/repo_config.go b/api/gitops/sources/repo_config.go new file mode 100644 index 0000000..4e70954 --- /dev/null +++ b/api/gitops/sources/repo_config.go @@ -0,0 +1,56 @@ +package sources + +import ( + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices/source" + gittypes "github.com/portainer/portainer/api/git/types" + "github.com/portainer/portainer/pkg/fips" + httperror "github.com/portainer/portainer/pkg/libhttp/error" +) + +// RepoConfigInput holds the raw payload fields needed to resolve a git RepoConfig. +// Set SourceID to resolve URL/auth from a stored source; otherwise provide the inline fields. +type RepoConfigInput struct { + SourceID portainer.SourceID + ReferenceName string + ConfigFilePath string + RepositoryURL string + TLSSkipVerify bool + RepositoryAuthentication bool + Username string + Password string + Provider gittypes.GitProvider + AuthorizationType gittypes.GitCredentialAuthType +} + +// ResolveRepoConfig builds a RepoConfig from either a SourceID or inline URL/auth fields. +func ResolveRepoConfig(tx gitSourceStore, userContext source.UserContext, input RepoConfigInput) (gittypes.RepoConfig, *httperror.HandlerError) { + cfg := gittypes.RepoConfig{ + ReferenceName: input.ReferenceName, + ConfigFilePath: input.ConfigFilePath, + } + + if input.SourceID != 0 { + src, httpErr := ValidateGitSourceAccess(tx, userContext, input.SourceID) + if httpErr != nil { + return gittypes.RepoConfig{}, httpErr + } + cfg.URL = src.Git.URL + cfg.Authentication = src.Git.Authentication + cfg.TLSSkipVerify = src.Git.TLSSkipVerify + } else { + cfg.URL = input.RepositoryURL + cfg.TLSSkipVerify = input.TLSSkipVerify + if input.RepositoryAuthentication { + cfg.Authentication = &gittypes.GitAuthentication{ + Username: input.Username, + Password: input.Password, + Provider: input.Provider, + AuthorizationType: input.AuthorizationType, + } + } + } + + cfg.TLSSkipVerify = cfg.TLSSkipVerify && fips.CanTLSSkipVerify() + return cfg, nil +} diff --git a/api/gitops/sources/repo_config_test.go b/api/gitops/sources/repo_config_test.go new file mode 100644 index 0000000..abeec31 --- /dev/null +++ b/api/gitops/sources/repo_config_test.go @@ -0,0 +1,70 @@ +package sources + +import ( + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/datastore" + gittypes "github.com/portainer/portainer/api/git/types" + "github.com/portainer/portainer/pkg/fips" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func init() { + fips.InitFIPS(false) +} + +func TestResolveRepoConfig_WithSourceID_ReturnsSourceConfig(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, false) + + src := &portainer.Source{ + Type: portainer.SourceTypeGit, + Git: &gittypes.GitSource{ + URL: "https://github.com/org/repo", + TLSSkipVerify: true, + Authentication: &gittypes.GitAuthentication{ + Username: "user", + Password: "token", + }, + }, + } + require.NoError(t, store.Source().Create(adminUserContext, src)) + + cfg, httpErr := ResolveRepoConfig(store, adminUserContext, RepoConfigInput{ + SourceID: src.ID, + ReferenceName: "refs/heads/main", + ConfigFilePath: "docker-compose.yml", + RepositoryURL: "https://ignored.example.com", + }) + + require.Nil(t, httpErr) + assert.Equal(t, src.Git.URL, cfg.URL) + assert.Equal(t, src.Git.Authentication, cfg.Authentication) + assert.Equal(t, src.Git.TLSSkipVerify, cfg.TLSSkipVerify) + assert.Equal(t, "refs/heads/main", cfg.ReferenceName) + assert.Equal(t, "docker-compose.yml", cfg.ConfigFilePath) +} + +func TestResolveRepoConfig_WithInlineURL_ReturnsInlineConfig(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, false) + + cfg, httpErr := ResolveRepoConfig(store, adminUserContext, RepoConfigInput{ + ReferenceName: "refs/heads/main", + ConfigFilePath: "docker-compose.yml", + RepositoryURL: "https://github.com/org/repo", + TLSSkipVerify: true, + RepositoryAuthentication: true, + Username: "user", + Password: "pass", + }) + + require.Nil(t, httpErr) + assert.Equal(t, "https://github.com/org/repo", cfg.URL) + assert.True(t, cfg.TLSSkipVerify) + require.NotNil(t, cfg.Authentication) + assert.Equal(t, "user", cfg.Authentication.Username) + assert.Equal(t, "pass", cfg.Authentication.Password) +} diff --git a/api/gitops/sources/source_access.go b/api/gitops/sources/source_access.go new file mode 100644 index 0000000..5501121 --- /dev/null +++ b/api/gitops/sources/source_access.go @@ -0,0 +1,38 @@ +package sources + +import ( + "fmt" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/dataservices/source" + httperror "github.com/portainer/portainer/pkg/libhttp/error" +) + +// gitSourceStore is the minimal intersection of CE and EE DataStoreTx that these functions need. +// Both EE and CE DataStoreTx satisfy it, even though they are incompatible as full interface types. +type gitSourceStore interface { + Source() dataservices.SourceService + IsErrObjectNotFound(err error) bool +} + +// ValidateGitSourceAccess checks that the given Source exists and is a git Source, and returns it. +func ValidateGitSourceAccess(tx gitSourceStore, userContext source.UserContext, sourceID portainer.SourceID) (*portainer.Source, *httperror.HandlerError) { + src, err := tx.Source().Read(userContext, sourceID) + if err != nil { + if tx.IsErrObjectNotFound(err) { + return nil, httperror.NotFound("Source not found", err) + } + return nil, httperror.InternalServerError("Unable to read source", err) + } + + if src.Type != portainer.SourceTypeGit { + return nil, httperror.BadRequest(fmt.Sprintf("source %d is not a git source", sourceID), nil) + } + + if src.Git == nil { + return nil, httperror.BadRequest("Source has no git configuration", nil) + } + + return src, nil +} diff --git a/api/gitops/sources/source_access_test.go b/api/gitops/sources/source_access_test.go new file mode 100644 index 0000000..7c3a518 --- /dev/null +++ b/api/gitops/sources/source_access_test.go @@ -0,0 +1,35 @@ +package sources + +import ( + "net/http" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/datastore" + gittypes "github.com/portainer/portainer/api/git/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestValidateSourceForStack_ValidGitSource_ReturnsNil(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, false) + + src := &portainer.Source{ + Type: portainer.SourceTypeGit, + Git: &gittypes.GitSource{URL: "https://github.com/org/repo"}, + } + require.NoError(t, store.Source().Create(adminUserContext, src)) + + _, httpErr := ValidateGitSourceAccess(store, adminUserContext, src.ID) + assert.Nil(t, httpErr) +} + +func TestValidateSourceForStack_SourceNotFound_Returns404(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, false) + + _, httpErr := ValidateGitSourceAccess(store, adminUserContext, portainer.SourceID(999)) + require.NotNil(t, httpErr) + assert.Equal(t, http.StatusNotFound, httpErr.StatusCode) +} diff --git a/api/gitops/sources/status.go b/api/gitops/sources/status.go new file mode 100644 index 0000000..de8aa9c --- /dev/null +++ b/api/gitops/sources/status.go @@ -0,0 +1,23 @@ +package sources + +import portainer "github.com/portainer/portainer/api" + +// Status is the string representation of portainer.Status used in API responses. +type Status string + +const ( + SourceStatusUnknown Status = "unknown" + SourceStatusHealthy Status = "healthy" + SourceStatusError Status = "error" +) + +func StatusString(s portainer.SourceStatus) Status { + switch s { + case portainer.SourceStatusHealthy: + return SourceStatusHealthy + case portainer.SourceStatusError: + return SourceStatusError + default: + return SourceStatusUnknown + } +} diff --git a/api/gitops/workflows/fetch.go b/api/gitops/workflows/fetch.go new file mode 100644 index 0000000..b8bba6a --- /dev/null +++ b/api/gitops/workflows/fetch.go @@ -0,0 +1,218 @@ +package workflows + +import ( + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/dataservices/source" + gittypes "github.com/portainer/portainer/api/git/types" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/kubernetes/cli" + "github.com/portainer/portainer/api/set" +) + +// FetchWorkflows returns all GitOps workflows visible to the given user. +func FetchWorkflows( + tx dataservices.DataStoreTx, + k8sFactory *cli.ClientFactory, + sc *security.RestrictedRequestContext, + endpointIDSet set.Set[portainer.EndpointID], +) ([]Workflow, error) { + gitConfigs := map[portainer.StackID]*gittypes.RepoConfig{} + sourceIDs := map[portainer.StackID]portainer.SourceID{} + sourcePhases := map[portainer.StackID]WorkflowPhaseStatus{} + artifactPhases := map[portainer.StackID]WorkflowPhaseStatus{} + + userContext := source.NewUserContext(sc.User, sc.UserMemberships) + + stacks, err := tx.Stack().ReadAll(func(s portainer.Stack) bool { + return s.WorkflowID != 0 && (len(endpointIDSet) == 0 || endpointIDSet.Contains(s.EndpointID)) + }) + if err != nil { + return nil, err + } + + endpointMap, err := BuildEndpointMap(tx, stacks) + if err != nil { + return nil, err + } + + stacks, err = FilterDockerStacksByAccess(tx, stacks, sc) + if err != nil { + return nil, err + } + + // First pass: filter by endpoint/stack-type match and collect workflow IDs. + preFiltered := make([]portainer.Stack, 0, len(stacks)) + workflowIDSet := make(set.Set[portainer.WorkflowID], len(stacks)) + for _, stack := range stacks { + if ep, ok := endpointMap[stack.EndpointID]; ok && !EndpointMatchesStackType(ep, stack.Type) { + continue + } + preFiltered = append(preFiltered, stack) + workflowIDSet.Add(stack.WorkflowID) + } + + workflowMap, sourceMap, err := LoadWorkflowAndSourceMaps(tx, userContext, workflowIDSet) + if err != nil { + return nil, err + } + + // Second pass: build filtered list using in-memory lookups. + var filtered []portainer.Stack + for _, stack := range preFiltered { + wf := workflowMap[stack.WorkflowID] + + hasAccessibleSource := false + outer: + for _, as := range wf.Artifacts { + if as.StackID != stack.ID { + continue + } + + for _, f := range as.Files { + src, ok := sourceMap[f.SourceID] + if !ok { + continue + } + + hasAccessibleSource = true + + if src.Type == portainer.SourceTypeGit { + gitConfigs[stack.ID] = MergeSourceAndFile(&src, &f) + sourceIDs[stack.ID] = src.ID + sourcePhases[stack.ID] = SourceStatusToPhase(f.RefStatus, f.RefError) + artifactPhases[stack.ID] = SourceStatusToPhase(f.PathStatus, f.PathError) + break outer + } + } + } + + if stack.Type == portainer.KubernetesStack && !hasAccessibleSource { + continue + } + + filtered = append(filtered, stack) + } + stacks = filtered + + accessMap, err := buildEndpointAccessMap(k8sFactory, sc, endpointMap) + if err != nil { + return nil, err + } + + stacks, err = filterK8SStacks(stacks, endpointMap, k8sFactory, accessMap) + if err != nil { + return nil, err + } + + items := make([]Workflow, 0, len(stacks)) + for _, stack := range stacks { + gitConfig := gitConfigs[stack.ID] + items = append(items, MapStackToWorkflow(stack, sourceIDs[stack.ID], gitConfig, sourcePhases[stack.ID], artifactPhases[stack.ID])) + } + + return items, nil +} + +// SourceStats holds aggregated statistics for a GitOps source. +type SourceStats struct { + WorkflowCount int + EndpointIDs set.Set[portainer.EndpointID] +} + +// FetchSourceStats returns all sources and per-source stats for sources accessible to the given user. +// It applies the same access control as FetchWorkflows but skips git phase checks. +func FetchSourceStats( + tx dataservices.DataStoreTx, + k8sFactory *cli.ClientFactory, + sc *security.RestrictedRequestContext, +) ([]portainer.Source, map[portainer.SourceID]SourceStats, error) { + userContext := source.NewUserContext(sc.User, sc.UserMemberships) + + sources, err := tx.Source().ReadAll(userContext) + if err != nil { + return nil, nil, err + } + + allStacks, err := tx.Stack().ReadAll(func(s portainer.Stack) bool { return s.WorkflowID != 0 }) + if err != nil { + return nil, nil, err + } + + endpointMap, err := BuildEndpointMap(tx, allStacks) + if err != nil { + return nil, nil, err + } + + allStacks, err = FilterDockerStacksByAccess(tx, allStacks, sc) + if err != nil { + return nil, nil, err + } + + workflowIDSet := make(set.Set[portainer.WorkflowID], len(allStacks)) + preFiltered := make([]portainer.Stack, 0, len(allStacks)) + for _, stack := range allStacks { + if ep, ok := endpointMap[stack.EndpointID]; ok && !EndpointMatchesStackType(ep, stack.Type) { + continue + } + preFiltered = append(preFiltered, stack) + workflowIDSet.Add(stack.WorkflowID) + } + + wfMap, err := LoadWorkflowMap(tx, workflowIDSet) + if err != nil { + return nil, nil, err + } + + wfSources := make(map[portainer.WorkflowID][]portainer.SourceID, len(wfMap)) + for id, wf := range wfMap { + for _, as := range wf.Artifacts { + for _, f := range as.Files { + wfSources[id] = append(wfSources[id], f.SourceID) + } + } + } + + stackSourceIDs := make(map[portainer.StackID][]portainer.SourceID) + for _, stack := range preFiltered { + if srcIDs := wfSources[stack.WorkflowID]; len(srcIDs) > 0 { + stackSourceIDs[stack.ID] = srcIDs + } + } + + accessMap, err := buildEndpointAccessMap(k8sFactory, sc, endpointMap) + if err != nil { + return nil, nil, err + } + + stacks, err := filterK8SStacks(preFiltered, endpointMap, k8sFactory, accessMap) + if err != nil { + return nil, nil, err + } + + stats := make(map[portainer.SourceID]SourceStats) + + for _, stack := range stacks { + var epIDs []portainer.EndpointID + if stack.EndpointID != 0 { + epIDs = []portainer.EndpointID{stack.EndpointID} + } + addSourceStats(stats, stackSourceIDs[stack.ID], epIDs) + } + + return sources, stats, nil +} + +func addSourceStats(result map[portainer.SourceID]SourceStats, srcIDs []portainer.SourceID, epIDs []portainer.EndpointID) { + for _, srcID := range srcIDs { + st := result[srcID] + if st.EndpointIDs == nil { + st.EndpointIDs = make(set.Set[portainer.EndpointID]) + } + st.WorkflowCount++ + for _, epID := range epIDs { + st.EndpointIDs.Add(epID) + } + result[srcID] = st + } +} diff --git a/api/gitops/workflows/fetch_test.go b/api/gitops/workflows/fetch_test.go new file mode 100644 index 0000000..fe576a4 --- /dev/null +++ b/api/gitops/workflows/fetch_test.go @@ -0,0 +1,275 @@ +package workflows + +import ( + "strconv" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/datastore" + gittypes "github.com/portainer/portainer/api/git/types" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/set" + + "github.com/stretchr/testify/require" +) + +func adminContext() *security.RestrictedRequestContext { + return &security.RestrictedRequestContext{ + IsAdmin: true, + UserID: 1, + User: &portainer.User{ID: 1, Role: portainer.AdministratorRole}, + } +} + +func mustCreateGitWorkflow(t *testing.T, tx dataservices.DataStoreTx, stack *portainer.Stack) { + t.Helper() + + cfg := stack.GitConfig + + src := &portainer.Source{Type: portainer.SourceTypeGit, Git: &gittypes.GitSource{URL: cfg.URL, Authentication: cfg.Authentication, TLSSkipVerify: cfg.TLSSkipVerify}} + require.NoError(t, tx.Source().Create(adminUserContext, src)) + + wf := &portainer.Workflow{Artifacts: []portainer.Artifact{{ + StackID: stack.ID, + Files: []portainer.ArtifactFile{{SourceID: src.ID}}, + }}} + require.NoError(t, tx.Workflow().Create(wf)) + + stack.WorkflowID = wf.ID + stack.GitConfig = nil + + require.NoError(t, tx.Stack().Create(stack)) +} + +func TestAddSourceStats_NoOp(t *testing.T) { + t.Parallel() + + result := make(map[portainer.SourceID]SourceStats) + addSourceStats(result, nil, nil) + + require.Empty(t, result) +} + +func TestAddSourceStats_AccumulatesWorkflowCount(t *testing.T) { + t.Parallel() + + result := make(map[portainer.SourceID]SourceStats) + addSourceStats(result, []portainer.SourceID{1}, nil) + addSourceStats(result, []portainer.SourceID{1}, nil) + + require.Equal(t, 2, result[1].WorkflowCount) +} + +func TestAddSourceStats_CollectsUniqueEndpointIDs(t *testing.T) { + t.Parallel() + + result := make(map[portainer.SourceID]SourceStats) + addSourceStats(result, []portainer.SourceID{1}, []portainer.EndpointID{10, 20}) + addSourceStats(result, []portainer.SourceID{1}, []portainer.EndpointID{20, 30}) + + require.Len(t, result[1].EndpointIDs, 3) + require.True(t, result[1].EndpointIDs[10]) + require.True(t, result[1].EndpointIDs[20]) + require.True(t, result[1].EndpointIDs[30]) +} + +func TestAddSourceStats_MultipleSourceIDs(t *testing.T) { + t.Parallel() + + result := make(map[portainer.SourceID]SourceStats) + addSourceStats(result, []portainer.SourceID{1, 2}, []portainer.EndpointID{10}) + + require.Equal(t, 1, result[1].WorkflowCount) + require.Equal(t, 1, result[2].WorkflowCount) + require.True(t, result[1].EndpointIDs[10]) + require.True(t, result[2].EndpointIDs[10]) +} + +func TestFetchWorkflows_ReturnsOnlyGitopsStacks(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + mustCreateGitWorkflow(t, tx, &portainer.Stack{ + ID: 1, + Name: "gitops-stack", + GitConfig: &gittypes.RepoConfig{URL: "https://github.com/x/repo"}, + }) + require.NoError(t, tx.Stack().Create(&portainer.Stack{ID: 2, Name: "plain-stack"})) + + return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}) + })) + + var items []Workflow + require.NoError(t, store.ViewTx(func(tx dataservices.DataStoreTx) error { + var err error + items, err = FetchWorkflows(tx, nil, adminContext(), nil) + return err + })) + require.Len(t, items, 1) + require.Equal(t, "gitops-stack", items[0].Name) +} + +func TestFetchWorkflows_FiltersByEndpointID(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + for i := 1; i <= 3; i++ { + mustCreateGitWorkflow(t, tx, &portainer.Stack{ + ID: portainer.StackID(i), + Name: "stack-" + strconv.Itoa(i), + EndpointID: portainer.EndpointID(i), + GitConfig: &gittypes.RepoConfig{URL: "https://github.com/x/" + strconv.Itoa(i)}, + }) + } + + return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}) + })) + + var items []Workflow + require.NoError(t, store.ViewTx(func(tx dataservices.DataStoreTx) error { + var err error + items, err = FetchWorkflows(tx, nil, adminContext(), set.ToSet([]portainer.EndpointID{1, 2})) + return err + })) + require.Len(t, items, 2) + + names := []string{items[0].Name, items[1].Name} + require.Contains(t, names, "stack-1") + require.Contains(t, names, "stack-2") +} + +func TestFetchWorkflows_EmptyWhenNoGitopsStacks(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + require.NoError(t, tx.Stack().Create(&portainer.Stack{ID: 1, Name: "plain-1"})) + require.NoError(t, tx.Stack().Create(&portainer.Stack{ID: 2, Name: "plain-2"})) + + return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}) + })) + + var items []Workflow + require.NoError(t, store.ViewTx(func(tx dataservices.DataStoreTx) error { + var err error + items, err = FetchWorkflows(tx, nil, adminContext(), nil) + return err + })) + require.Empty(t, items) +} + +func TestFetchWorkflows_NilEndpointSetReturnsAll(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + for i := 1; i <= 3; i++ { + mustCreateGitWorkflow(t, tx, &portainer.Stack{ + ID: portainer.StackID(i), + Name: "stack-" + strconv.Itoa(i), + EndpointID: portainer.EndpointID(i), + GitConfig: &gittypes.RepoConfig{URL: "https://github.com/x/" + strconv.Itoa(i)}, + }) + } + + return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}) + })) + + var items []Workflow + require.NoError(t, store.ViewTx(func(tx dataservices.DataStoreTx) error { + var err error + items, err = FetchWorkflows(tx, nil, adminContext(), nil) + return err + })) + require.Len(t, items, 3) +} + +func TestFetchSourceStats_ReturnsAllSources(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + require.NoError(t, tx.Source().Create(adminUserContext, &portainer.Source{Name: "source-1", Type: portainer.SourceTypeGit, Git: &gittypes.GitSource{URL: "http://github.com/org/repo1"}})) + require.NoError(t, tx.Source().Create(adminUserContext, &portainer.Source{Name: "source-2", Type: portainer.SourceTypeGit, Git: &gittypes.GitSource{URL: "http://github.com/org/repo2"}})) + + return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}) + })) + + var sources []portainer.Source + require.NoError(t, store.ViewTx(func(tx dataservices.DataStoreTx) error { + var err error + sources, _, err = FetchSourceStats(tx, nil, adminContext()) + + return err + })) + + require.Len(t, sources, 2) +} + +func TestFetchSourceStats_TracksWorkflowCountAndEndpoints(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + var srcID portainer.SourceID + + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + src := &portainer.Source{Name: "shared", Type: portainer.SourceTypeGit, Git: &gittypes.GitSource{URL: "http://github.com/org/repo"}} + require.NoError(t, tx.Source().Create(adminUserContext, src)) + srcID = src.ID + + for i := 1; i <= 2; i++ { + wf := &portainer.Workflow{Artifacts: []portainer.Artifact{{Files: []portainer.ArtifactFile{{SourceID: srcID}}}}} + require.NoError(t, tx.Workflow().Create(wf)) + require.NoError(t, tx.Stack().Create(&portainer.Stack{ + ID: portainer.StackID(i), + Name: "stack-" + strconv.Itoa(i), + EndpointID: portainer.EndpointID(i), + WorkflowID: wf.ID, + })) + } + + return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}) + })) + + var stats map[portainer.SourceID]SourceStats + require.NoError(t, store.ViewTx(func(tx dataservices.DataStoreTx) error { + var err error + _, stats, err = FetchSourceStats(tx, nil, adminContext()) + + return err + })) + + st := stats[srcID] + require.Equal(t, 2, st.WorkflowCount) + require.Len(t, st.EndpointIDs, 2) +} + +func TestFetchSourceStats_UnusedSourceHasZeroStats(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + var unusedID portainer.SourceID + + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + src := &portainer.Source{Name: "unused", Type: portainer.SourceTypeGit, Git: &gittypes.GitSource{URL: "http://github.com/org/repo"}} + require.NoError(t, tx.Source().Create(adminUserContext, src)) + unusedID = src.ID + + return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}) + })) + + var stats map[portainer.SourceID]SourceStats + require.NoError(t, store.ViewTx(func(tx dataservices.DataStoreTx) error { + var err error + _, stats, err = FetchSourceStats(tx, nil, adminContext()) + + return err + })) + + st := stats[unusedID] + require.Zero(t, st.WorkflowCount) + require.Empty(t, st.EndpointIDs) +} diff --git a/api/gitops/workflows/filter.go b/api/gitops/workflows/filter.go new file mode 100644 index 0000000..3263f61 --- /dev/null +++ b/api/gitops/workflows/filter.go @@ -0,0 +1,189 @@ +package workflows + +import ( + "fmt" + "slices" + "strconv" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/http/models/kubernetes" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/authorization" + "github.com/portainer/portainer/api/internal/endpointutils" + "github.com/portainer/portainer/api/internal/snapshot" + "github.com/portainer/portainer/api/kubernetes/cli" + "github.com/portainer/portainer/api/set" + "github.com/portainer/portainer/api/slicesx" + "github.com/portainer/portainer/api/stacks/stackutils" + + "github.com/rs/zerolog/log" +) + +// EndpointMatchesStackType reports whether ep is a valid target for stackType. +func EndpointMatchesStackType(ep portainer.Endpoint, stackType portainer.StackType) bool { + switch stackType { + case portainer.DockerSwarmStack: + return len(ep.Snapshots) > 0 && ep.Snapshots[0].Swarm + case portainer.DockerComposeStack: + return len(ep.Snapshots) == 0 || !ep.Snapshots[0].Swarm + case portainer.KubernetesStack: + return endpointutils.IsKubernetesEndpoint(&ep) + default: + return true + } +} + +// BuildEndpointMap reads and returns the endpoints backing stacks, keyed by endpoint ID, with +// snapshot data filled in. +func BuildEndpointMap(tx dataservices.DataStoreTx, stacks []portainer.Stack) (map[portainer.EndpointID]portainer.Endpoint, error) { + ids := set.ToSet(slicesx.Map(stacks, func(s portainer.Stack) portainer.EndpointID { return s.EndpointID })) + + endpoints, err := tx.Endpoint().ReadAll(func(ep portainer.Endpoint) bool { return ids[ep.ID] }) + if err != nil { + return nil, err + } + + m := make(map[portainer.EndpointID]portainer.Endpoint, len(endpoints)) + for i := range endpoints { + if err := snapshot.FillSnapshotData(tx, &endpoints[i], false); err != nil { + return nil, fmt.Errorf("unable to fill snapshot data for endpoint %d: %w", endpoints[i].ID, err) + } + m[endpoints[i].ID] = endpoints[i] + } + + return m, nil +} + +// FilterDockerStacksByAccess filters stacks to only those the current user can access. +func FilterDockerStacksByAccess(tx dataservices.DataStoreTx, stacks []portainer.Stack, sc *security.RestrictedRequestContext) ([]portainer.Stack, error) { + if sc.IsAdmin { + return stacks, nil + } + + // do not try to check UAC on kube stacks + filtered, dockerStacks := slicesx.Partition(stacks, func(s portainer.Stack) bool { return s.Type == portainer.KubernetesStack }) + + stackResourceIDSet := set.ToSet(slicesx.Map(dockerStacks, func(s portainer.Stack) string { + return stackutils.ResourceControlID(s.EndpointID, s.Name) + })) + + resourceControls, err := tx.ResourceControl().ReadAll(func(rc portainer.ResourceControl) bool { + return rc.Type == portainer.StackResourceControl && stackResourceIDSet[rc.ResourceID] + }) + if err != nil { + return nil, err + } + + dockerStacks = authorization.DecorateStacks(dockerStacks, resourceControls) + + userTeamIDs := authorization.TeamIDs(sc.UserMemberships) + filtered = append(filtered, authorization.FilterAuthorizedStacks(dockerStacks, sc.UserID, userTeamIDs)...) + return filtered, nil +} + +// ResolveKubeAccess determines sc's Kubernetes admin/namespace access on ep. +func ResolveKubeAccess(k8sFactory *cli.ClientFactory, sc *security.RestrictedRequestContext, ep *portainer.Endpoint) (endpointAccess, error) { + if sc.IsAdmin { + return endpointAccess{IsKubeAdmin: true}, nil + } + + pcli, err := k8sFactory.GetPrivilegedKubeClient(ep) + if err != nil { + return endpointAccess{}, fmt.Errorf("unable to get privileged kube client for endpoint %d: %w", ep.ID, err) + } + + teamIDs := make([]int, 0, len(sc.UserMemberships)) + for _, m := range sc.UserMemberships { + teamIDs = append(teamIDs, int(m.TeamID)) + } + + nonAdminNamespaces, err := pcli.GetNonAdminNamespaces(int(sc.UserID), teamIDs, ep.Kubernetes.Configuration.RestrictDefaultNamespace) + if err != nil { + return endpointAccess{}, fmt.Errorf("unable to retrieve non-admin namespaces for endpoint %d: %w", ep.ID, err) + } + + return endpointAccess{IsKubeAdmin: false, NonAdminNamespaces: nonAdminNamespaces}, nil +} + +type endpointAccess struct { + IsKubeAdmin bool + NonAdminNamespaces []string +} + +// buildEndpointAccessMap resolves sc's Kubernetes access for every Kubernetes endpoint in +// endpointMap, skipping (and logging) any endpoint whose access cannot be resolved. +func buildEndpointAccessMap(k8sFactory *cli.ClientFactory, sc *security.RestrictedRequestContext, endpointMap map[portainer.EndpointID]portainer.Endpoint) (map[portainer.EndpointID]endpointAccess, error) { + result := make(map[portainer.EndpointID]endpointAccess, len(endpointMap)) + + for epID, ep := range endpointMap { + if !endpointutils.IsKubernetesEndpoint(&ep) { + continue + } + + access, err := ResolveKubeAccess(k8sFactory, sc, &ep) + if err != nil { + log.Warn().Err(err).Str("context", "buildEndpointAccessMap").Int("endpoint_id", int(epID)).Msg("Failed to resolve kube access for endpoint, skipping") + continue + } + + result[epID] = access + } + + return result, nil +} + +// lookup only if env is kube and either not edge or (edge + not async) +func ShouldPerformEnvLookup(endpoint *portainer.Endpoint) bool { + return endpointutils.IsKubernetesEndpoint(endpoint) && + (!endpointutils.IsEdgeEndpoint(endpoint) || + (endpointutils.IsEdgeEndpoint(endpoint) && !endpoint.Edge.AsyncMode)) +} + +func filterK8SStacks(items []portainer.Stack, endpointMap map[portainer.EndpointID]portainer.Endpoint, k8sFactory *cli.ClientFactory, accessMap map[portainer.EndpointID]endpointAccess) ([]portainer.Stack, error) { + k8sStacks, result := slicesx.Partition(items, func(s portainer.Stack) bool { + return s.Type == portainer.KubernetesStack + }) + + groupedByEnvId := slicesx.GroupBy(k8sStacks, func(s portainer.Stack) portainer.EndpointID { + return s.EndpointID + }) + + for envID, stacks := range groupedByEnvId { + ep, ok := endpointMap[envID] + if !ok || !ShouldPerformEnvLookup(&ep) { + continue + } + + kcl, err := k8sFactory.GetPrivilegedKubeClient(&ep) + if err != nil { + log.Warn().Err(err).Str("context", "filterK8SStacks").Int("endpoint_id", int(envID)).Msg("Failed to get kube client for endpoint, skipping") + continue + } + + access := accessMap[envID] + kcl.SetIsKubeAdmin(access.IsKubeAdmin) + kcl.SetClientNonAdminNamespaces(access.NonAdminNamespaces) + + apps, err := kcl.GetApplications("", "") + if err != nil { + log.Warn().Err(err).Str("context", "filterK8SStacks").Int("endpoint_id", int(envID)).Msg("Failed to get kube applications for endpoint, skipping") + continue + } + + for _, s := range stacks { + idx := slices.IndexFunc(apps, func(app kubernetes.K8sApplication) bool { + return app.StackKind != "edge" && app.StackID == strconv.Itoa(int(s.ID)) + }) + if idx == -1 { + continue + } + + app := apps[idx] + s.Name = app.Name + s.Namespace = app.ResourcePool + result = append(result, s) + } + } + return result, nil +} diff --git a/api/gitops/workflows/filter_test.go b/api/gitops/workflows/filter_test.go new file mode 100644 index 0000000..3693f85 --- /dev/null +++ b/api/gitops/workflows/filter_test.go @@ -0,0 +1,289 @@ +package workflows + +import ( + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/authorization" + "github.com/portainer/portainer/api/kubernetes/cli" + + appsv1 "k8s.io/api/apps/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + kfake "k8s.io/client-go/kubernetes/fake" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFilterDockerStacksByAccess_KubeStacksPassThrough(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + user := &portainer.User{ + ID: 1, + Username: "standard", + Role: portainer.StandardUserRole, + PortainerAuthorizations: authorization.DefaultPortainerAuthorizations(), + } + require.NoError(t, store.User().Create(user)) + + sc := &security.RestrictedRequestContext{ + IsAdmin: false, + UserID: 1, + } + + kubeStack := portainer.Stack{ID: 1, Name: "kube-stack", Type: portainer.KubernetesStack} + dockerStack := portainer.Stack{ID: 2, Name: "docker-stack", Type: portainer.DockerComposeStack} + + stacks := []portainer.Stack{kubeStack, dockerStack} + + var result []portainer.Stack + err := store.ViewTx(func(tx dataservices.DataStoreTx) error { + var txErr error + result, txErr = FilterDockerStacksByAccess(tx, stacks, sc) + return txErr + }) + require.NoError(t, err) + require.Len(t, result, 1) + require.Equal(t, "kube-stack", result[0].Name) +} + +func TestFilterDockerStacksByAccess_AdminGetsAll(t *testing.T) { + t.Parallel() + + sc := &security.RestrictedRequestContext{ + IsAdmin: true, + UserID: 1, + } + + stacks := []portainer.Stack{ + {ID: 1, Name: "kube-stack", Type: portainer.KubernetesStack}, + {ID: 2, Name: "docker-stack", Type: portainer.DockerComposeStack}, + } + + result, err := FilterDockerStacksByAccess(nil, stacks, sc) + require.NoError(t, err) + require.Len(t, result, 2) +} + +func TestBuildEndpointAccessMap_AdminIsKubeAdmin(t *testing.T) { + t.Parallel() + + sc := &security.RestrictedRequestContext{ + IsAdmin: true, + UserID: 1, + } + + endpointMap := map[portainer.EndpointID]portainer.Endpoint{ + 1: {ID: 1, Type: portainer.KubernetesLocalEnvironment}, + 2: {ID: 2, Type: portainer.DockerEnvironment}, + } + + result, err := buildEndpointAccessMap(nil, sc, endpointMap) + require.NoError(t, err) + require.Len(t, result, 1) + require.True(t, result[1].IsKubeAdmin) + require.Empty(t, result[1].NonAdminNamespaces) +} + +func TestFilterK8SStacks_IncludesMatchingStack(t *testing.T) { + t.Parallel() + + fakeKubeClient := kfake.NewSimpleClientset() + + deployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-app", + Namespace: "default", + Labels: map[string]string{ + "io.portainer.kubernetes.application.stackid": "1", + }, + }, + Spec: appsv1.DeploymentSpec{ + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "my-app"}}, + }, + } + + _, err := fakeKubeClient.AppsV1().Deployments("default").Create(t.Context(), deployment, metav1.CreateOptions{}) + require.NoError(t, err) + + kcl := cli.NewTestKubeClient(fakeKubeClient) + factory := cli.NewTestClientFactory(1, kcl) + + endpointMap := map[portainer.EndpointID]portainer.Endpoint{ + 1: {ID: 1, Type: portainer.KubernetesLocalEnvironment}, + } + + stacks := []portainer.Stack{ + {ID: 1, Name: "stack-name", EndpointID: 1, Type: portainer.KubernetesStack}, + } + + accessMap := map[portainer.EndpointID]endpointAccess{ + 1: {IsKubeAdmin: true}, + } + + result, err := filterK8SStacks(stacks, endpointMap, factory, accessMap) + require.NoError(t, err) + require.Len(t, result, 1) + assert.Equal(t, "my-app", result[0].Name) + assert.Equal(t, "default", result[0].Namespace) +} + +func TestFilterK8SStacks_ExcludesStackWhenNoMatchingDeployment(t *testing.T) { + t.Parallel() + + fakeKubeClient := kfake.NewSimpleClientset() + kcl := cli.NewTestKubeClient(fakeKubeClient) + factory := cli.NewTestClientFactory(1, kcl) + + endpointMap := map[portainer.EndpointID]portainer.Endpoint{ + 1: {ID: 1, Type: portainer.KubernetesLocalEnvironment}, + } + + stacks := []portainer.Stack{ + {ID: 1, Name: "stack-name", EndpointID: 1, Type: portainer.KubernetesStack}, + } + + accessMap := map[portainer.EndpointID]endpointAccess{ + 1: {IsKubeAdmin: true}, + } + + result, err := filterK8SStacks(stacks, endpointMap, factory, accessMap) + require.NoError(t, err) + require.Empty(t, result) +} + +func TestFilterK8SStacks_NonAdminWithNamespaceAccess(t *testing.T) { + t.Parallel() + + fakeKubeClient := kfake.NewSimpleClientset() + + deployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-app", + Namespace: "ns1", + Labels: map[string]string{ + "io.portainer.kubernetes.application.stackid": "1", + }, + }, + Spec: appsv1.DeploymentSpec{ + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "my-app"}}, + }, + } + + _, err := fakeKubeClient.AppsV1().Deployments("ns1").Create(t.Context(), deployment, metav1.CreateOptions{}) + require.NoError(t, err) + + kcl := cli.NewTestKubeClient(fakeKubeClient) + factory := cli.NewTestClientFactory(1, kcl) + + endpointMap := map[portainer.EndpointID]portainer.Endpoint{ + 1: {ID: 1, Type: portainer.KubernetesLocalEnvironment}, + } + + stacks := []portainer.Stack{ + {ID: 1, Name: "stack-name", EndpointID: 1, Type: portainer.KubernetesStack}, + } + + accessMap := map[portainer.EndpointID]endpointAccess{ + 1: {IsKubeAdmin: false, NonAdminNamespaces: []string{"ns1"}}, + } + + result, err := filterK8SStacks(stacks, endpointMap, factory, accessMap) + require.NoError(t, err) + require.Len(t, result, 1) + assert.Equal(t, "my-app", result[0].Name) +} + +func TestResolveKubeAccess_NonAdminWithTeamMemberships(t *testing.T) { + t.Parallel() + + fakeKubeClient := kfake.NewSimpleClientset() + kcl := cli.NewTestKubeClient(fakeKubeClient) + factory := cli.NewTestClientFactory(1, kcl) + + ep := &portainer.Endpoint{ + ID: 1, + Type: portainer.KubernetesLocalEnvironment, + } + + sc := &security.RestrictedRequestContext{ + IsAdmin: false, + UserID: 1, + UserMemberships: []portainer.TeamMembership{ + {TeamID: 5}, + }, + } + + access, err := ResolveKubeAccess(factory, sc, ep) + require.NoError(t, err) + require.False(t, access.IsKubeAdmin) + require.Equal(t, []string{"default"}, access.NonAdminNamespaces) +} + +func TestResolveKubeAccess_NonAdmin(t *testing.T) { + t.Parallel() + + fakeKubeClient := kfake.NewSimpleClientset() + kcl := cli.NewTestKubeClient(fakeKubeClient) + factory := cli.NewTestClientFactory(1, kcl) + + ep := &portainer.Endpoint{ + ID: 1, + Type: portainer.KubernetesLocalEnvironment, + } + + sc := &security.RestrictedRequestContext{ + IsAdmin: false, + UserID: 1, + } + + access, err := ResolveKubeAccess(factory, sc, ep) + require.NoError(t, err) + require.False(t, access.IsKubeAdmin) + require.Equal(t, []string{"default"}, access.NonAdminNamespaces) +} + +func TestFilterK8SStacks_NonAdminWithoutNamespaceAccess(t *testing.T) { + t.Parallel() + + fakeKubeClient := kfake.NewSimpleClientset() + + deployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-app", + Namespace: "ns1", + Labels: map[string]string{ + "io.portainer.kubernetes.application.stackid": "1", + }, + }, + Spec: appsv1.DeploymentSpec{ + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "my-app"}}, + }, + } + + _, err := fakeKubeClient.AppsV1().Deployments("ns1").Create(t.Context(), deployment, metav1.CreateOptions{}) + require.NoError(t, err) + + kcl := cli.NewTestKubeClient(fakeKubeClient) + factory := cli.NewTestClientFactory(1, kcl) + + endpointMap := map[portainer.EndpointID]portainer.Endpoint{ + 1: {ID: 1, Type: portainer.KubernetesLocalEnvironment}, + } + + stacks := []portainer.Stack{ + {ID: 1, Name: "stack-name", EndpointID: 1, Type: portainer.KubernetesStack}, + } + + accessMap := map[portainer.EndpointID]endpointAccess{ + 1: {IsKubeAdmin: false, NonAdminNamespaces: []string{}}, + } + + result, err := filterK8SStacks(stacks, endpointMap, factory, accessMap) + require.NoError(t, err) + require.Empty(t, result) +} diff --git a/api/gitops/workflows/git_phases.go b/api/gitops/workflows/git_phases.go new file mode 100644 index 0000000..4d0e5c2 --- /dev/null +++ b/api/gitops/workflows/git_phases.go @@ -0,0 +1,127 @@ +package workflows + +import ( + "context" + "fmt" + "path" + "slices" + + portainer "github.com/portainer/portainer/api" + gittypes "github.com/portainer/portainer/api/git/types" +) + +// ListRefsFunc lists all git refs for a repository. +type ListRefsFunc func(ctx context.Context) ([]string, error) + +// ListFilesFunc lists files in a repository branch filtered by extension. +type ListFilesFunc func(ctx context.Context, exts []string, dirOnly bool) ([]string, error) + +// GitEntries represents a git entry which can be either a file or a directory. +type GitEntries struct { + Name string + IsFile bool +} + +// ComputeGitPhasesForConfig computes source and artifact phases from a RepoConfig and a GitService. +func ComputeGitPhasesForConfig(ctx context.Context, gitSvc portainer.GitService, cfg *gittypes.RepoConfig) (source, artifact WorkflowPhaseStatus) { + if gitSvc == nil || cfg == nil { + return WorkflowPhaseStatus{Status: StatusUnknown}, WorkflowPhaseStatus{Status: StatusUnknown} + } + + username, password := gitCredentials(cfg) + return ComputeGitPhases(ctx, cfg.ReferenceName, []GitEntries{{Name: cfg.ConfigFilePath, IsFile: true}}, + func(ctx context.Context) ([]string, error) { + return gitSvc.ListRefs(ctx, cfg.URL, username, password, false, cfg.TLSSkipVerify) + }, + func(ctx context.Context, exts []string, dirOnly bool) ([]string, error) { + return gitSvc.ListFiles(ctx, cfg.URL, cfg.ReferenceName, username, password, dirOnly, false, exts, cfg.TLSSkipVerify) + }, + ) +} + +func gitCredentials(cfg *gittypes.RepoConfig) (username, password string) { + if cfg.Authentication != nil { + return cfg.Authentication.Username, cfg.Authentication.Password + } + return "", "" +} + +// ComputeGitPhases checks source (ref reachability) and artifact (config file presence). +// If source fails, artifact is returned as unknown without making a network call. +func ComputeGitPhases(ctx context.Context, referenceName string, configFilePath []GitEntries, listRefs ListRefsFunc, listFiles ListFilesFunc) (source, artifact WorkflowPhaseStatus) { + source = computeSourcePhase(ctx, referenceName, listRefs) + if source.Status == StatusError { + return source, WorkflowPhaseStatus{Status: StatusUnknown} + } + return source, computeArtifactPhase(ctx, configFilePath, listFiles) +} + +func computeSourcePhase(ctx context.Context, referenceName string, listRefs ListRefsFunc) WorkflowPhaseStatus { + refs, err := listRefs(ctx) + if err != nil { + return WorkflowPhaseStatus{Status: StatusError, Error: err.Error()} + } + if referenceName == "" { + return WorkflowPhaseStatus{Status: StatusHealthy} + } + if !slices.Contains(refs, referenceName) { + return WorkflowPhaseStatus{Status: StatusError, Error: fmt.Sprintf("ref %q not found", referenceName)} + } + return WorkflowPhaseStatus{Status: StatusHealthy} +} + +func computeArtifactPhase(ctx context.Context, gitEntries []GitEntries, listFiles ListFilesFunc) WorkflowPhaseStatus { + if len(gitEntries) == 0 { + return WorkflowPhaseStatus{Status: StatusError, Error: "no config file path specified"} + } + + var ( + exts []string + fileEntries []string + dirEntries []string + ) + for _, gitEntry := range gitEntries { + if gitEntry.IsFile { + ext := path.Ext(gitEntry.Name) + if len(ext) > 0 { + ext = ext[1:] + exts = append(exts, ext) + } + + fileEntries = append(fileEntries, gitEntry.Name) + continue + } + + dirEntries = append(dirEntries, gitEntry.Name) + } + + // Check file entries + if len(fileEntries) > 0 { + files, err := listFiles(ctx, exts, false) + if err != nil { + return WorkflowPhaseStatus{Status: StatusError, Error: err.Error()} + } + + for _, fileEntry := range fileEntries { + if !slices.Contains(files, fileEntry) { + return WorkflowPhaseStatus{Status: StatusError, Error: fmt.Sprintf("file %q not found", fileEntry)} + } + } + } + + // Check directory entries + if len(dirEntries) > 0 { + dirs, err := listFiles(ctx, nil, true) + if err != nil { + return WorkflowPhaseStatus{Status: StatusError, Error: err.Error()} + } + + for _, dirEntry := range dirEntries { + if !slices.Contains(dirs, dirEntry) { + return WorkflowPhaseStatus{Status: StatusError, Error: fmt.Sprintf("directory %q not found", dirEntry)} + } + } + } + + return WorkflowPhaseStatus{Status: StatusHealthy} +} diff --git a/api/gitops/workflows/git_phases_test.go b/api/gitops/workflows/git_phases_test.go new file mode 100644 index 0000000..956625c --- /dev/null +++ b/api/gitops/workflows/git_phases_test.go @@ -0,0 +1,162 @@ +package workflows + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestComputeGitPhases(t *testing.T) { + t.Parallel() + + okRefs := func(_ context.Context) ([]string, error) { + return []string{"refs/heads/main"}, nil + } + okFiles := func(_ context.Context, _ []string, _ bool) ([]string, error) { + return []string{"docker-compose.yml"}, nil + } + errRefs := func(_ context.Context) ([]string, error) { + return nil, errors.New("connection refused") + } + errFiles := func(_ context.Context, _ []string, _ bool) ([]string, error) { + return nil, errors.New("connection refused") + } + + cases := []struct { + name string + referenceName string + configFilePath []GitEntries + listRefs ListRefsFunc + listFiles ListFilesFunc + expectedSource Status + expectedArtifact Status + }{ + { + name: "listRefs errors: source error, artifact unknown", + referenceName: "refs/heads/main", + configFilePath: []GitEntries{{Name: "docker-compose.yml", IsFile: true}}, + listRefs: errRefs, + listFiles: okFiles, + expectedSource: StatusError, + expectedArtifact: StatusUnknown, + }, + { + name: "ref not in list: source error, artifact unknown", + referenceName: "refs/heads/missing", + configFilePath: []GitEntries{{Name: "docker-compose.yml", IsFile: true}}, + listRefs: func(_ context.Context) ([]string, error) { + return []string{"refs/heads/main"}, nil + }, + listFiles: okFiles, + expectedSource: StatusError, + expectedArtifact: StatusUnknown, + }, + { + name: "empty configFilePath: artifact error", + referenceName: "refs/heads/main", + configFilePath: []GitEntries{}, + listRefs: okRefs, + listFiles: okFiles, + expectedSource: StatusHealthy, + expectedArtifact: StatusError, + }, + { + name: "listFiles errors: artifact error", + referenceName: "refs/heads/main", + configFilePath: []GitEntries{{Name: "docker-compose.yml", IsFile: true}}, + listRefs: okRefs, + listFiles: errFiles, + expectedSource: StatusHealthy, + expectedArtifact: StatusError, + }, + { + name: "file not in list: artifact error", + referenceName: "refs/heads/main", + configFilePath: []GitEntries{{Name: "docker-compose.yml", IsFile: true}}, + listRefs: okRefs, + listFiles: func(_ context.Context, _ []string, _ bool) ([]string, error) { + return []string{"other.yml"}, nil + }, + expectedSource: StatusHealthy, + expectedArtifact: StatusError, + }, + { + name: "both healthy", + referenceName: "refs/heads/main", + configFilePath: []GitEntries{{Name: "docker-compose.yml", IsFile: true}}, + listRefs: okRefs, + listFiles: okFiles, + expectedSource: StatusHealthy, + expectedArtifact: StatusHealthy, + }, + { + name: "empty referenceName: source healthy (default HEAD)", + referenceName: "", + configFilePath: []GitEntries{{Name: "docker-compose.yml", IsFile: true}}, + listRefs: okRefs, + listFiles: okFiles, + expectedSource: StatusHealthy, + expectedArtifact: StatusHealthy, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + source, artifact := ComputeGitPhases(t.Context(), tc.referenceName, tc.configFilePath, tc.listRefs, tc.listFiles) + assert.Equal(t, tc.expectedSource, source.Status) + assert.Equal(t, tc.expectedArtifact, artifact.Status) + }) + } +} + +func TestComputeArtifactPhase_ExtensionFilter(t *testing.T) { + t.Parallel() + + cases := []struct { + configPath string + wantExts []string + }{ + {"docker-compose.yml", []string{"yml"}}, + {"stack.yaml", []string{"yaml"}}, + {"subdir/compose.yml", []string{"yml"}}, + {"Makefile", nil}, + {"archive.tar.gz", []string{"gz"}}, + } + + for _, tc := range cases { + t.Run(tc.configPath, func(t *testing.T) { + t.Parallel() + var capturedExts []string + ComputeGitPhases( + t.Context(), + "", + []GitEntries{{Name: tc.configPath, IsFile: true}}, + func(_ context.Context) ([]string, error) { return nil, nil }, + func(_ context.Context, exts []string, dirOnly bool) ([]string, error) { + capturedExts = exts + return []string{tc.configPath}, nil + }, + ) + assert.Equal(t, tc.wantExts, capturedExts) + }) + } +} + +func TestComputeGitPhases_ArtifactNotCalledOnSourceError(t *testing.T) { + t.Parallel() + + listFilesCalled := false + listRefs := func(_ context.Context) ([]string, error) { + return nil, errors.New("repo unreachable") + } + listFiles := func(_ context.Context, _ []string, _ bool) ([]string, error) { + listFilesCalled = true + return nil, nil + } + + ComputeGitPhases(t.Context(), "refs/heads/main", []GitEntries{{Name: "docker-compose.yml", IsFile: true}}, listRefs, listFiles) + + assert.False(t, listFilesCalled, "listFiles must not be called when source fails") +} diff --git a/api/gitops/workflows/helpers_test.go b/api/gitops/workflows/helpers_test.go new file mode 100644 index 0000000..5231abb --- /dev/null +++ b/api/gitops/workflows/helpers_test.go @@ -0,0 +1,7 @@ +package workflows + +import ( + "github.com/portainer/portainer/api/dataservices/source" +) + +var adminUserContext = source.InsecureNewAdminContext() diff --git a/api/gitops/workflows/mapping.go b/api/gitops/workflows/mapping.go new file mode 100644 index 0000000..6c80b94 --- /dev/null +++ b/api/gitops/workflows/mapping.go @@ -0,0 +1,238 @@ +package workflows + +import ( + "fmt" + "slices" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + gittypes "github.com/portainer/portainer/api/git/types" + "github.com/portainer/portainer/api/gitops/sources" + "github.com/portainer/portainer/api/internal/endpointutils" + "github.com/portainer/portainer/api/set" + "github.com/portainer/portainer/api/slicesx" +) + +// BuildGroupEndpoints builds a map between EdgeGroup id and its endpoints +func BuildGroupEndpoints(tx dataservices.DataStoreTx, groups []portainer.EdgeGroup) (map[portainer.EdgeGroupID][]portainer.EndpointID, error) { + m := make(map[portainer.EdgeGroupID][]portainer.EndpointID, len(groups)) + for _, g := range groups { + if g.Dynamic { + ids, err := endpointutils.GetEndpointsByTags(tx, g.TagIDs, g.PartialMatch) + if err != nil { + return nil, fmt.Errorf("failed to resolve endpoints for dynamic edge group: %w", err) + } + m[g.ID] = ids + } else { + m[g.ID] = g.EndpointIDs.ToSlice() + } + } + return m, nil +} + +// MapStackToWorkflow converts a stack to a Workflow. gitConfig is passed separately +// because EE embeds a different GitConfig type that shadows the CE field. +// source and artifact are the pre-computed git phase statuses from the caller. +func MapStackToWorkflow(s portainer.Stack, sourceID portainer.SourceID, gitConfig *gittypes.RepoConfig, source, artifact WorkflowPhaseStatus) Workflow { + return Workflow{ + ID: s.WorkflowID, + Name: s.Name, + Type: TypeStack, + Platform: platformFromStackType(s.Type), + Status: WorkflowStatusObject{ + Source: source, + Artifact: artifact, + Target: deriveStackTargetState(s), + }, + SourceID: sourceID, + GitConfig: gitConfig, + AutoUpdate: s.AutoUpdate, + Target: Target{ + EndpointID: s.EndpointID, + Namespace: s.Namespace, + }, + CreationDate: s.CreationDate, + LastSyncDate: StackLastSyncDate(s), + } +} + +// MapEdgeStackToWorkflow converts an edge stack to a Workflow. gitConfig is passed separately +// because EE embeds a different GitConfig type that shadows the CE field. +// source and artifact are the pre-computed git phase statuses from the caller. +func MapEdgeStackToWorkflow(wfID portainer.WorkflowID, es portainer.EdgeStack, sourceID portainer.SourceID, gitConfig *gittypes.RepoConfig, statuses []portainer.EdgeStackStatusForEnv, groupEndpoints map[portainer.EdgeGroupID][]portainer.EndpointID, source, artifact WorkflowPhaseStatus) Workflow { + platform := DeploymentPlatformDockerStandalone + if es.DeploymentType == portainer.EdgeStackDeploymentKubernetes { + platform = DeploymentPlatformKubernetes + } + return Workflow{ + ID: wfID, + Name: es.Name, + Type: TypeEdgeStack, + Platform: platform, + Status: WorkflowStatusObject{ + Source: source, + Artifact: artifact, + Target: deriveEdgeStackTargetState(statuses), + }, + SourceID: sourceID, + GitConfig: gitConfig, + Target: Target{ + EdgeGroupIDs: es.EdgeGroups, + GroupStatus: edgeStackTargetStatuses(es.EdgeGroups, statuses, groupEndpoints), + ResolvedEndpointIDs: resolveEdgeGroupEndpoints(es.EdgeGroups, groupEndpoints), + }, + CreationDate: es.CreationDate, + LastSyncDate: edgeStackLastSyncDate(statuses), + } +} + +// MapStackToArtifactDetail converts a stack to an ArtifactDetail. source and artifact are the +// pre-computed git phase statuses from the caller; files are the artifact's resolved file refs. +func MapStackToArtifactDetail(stack portainer.Stack, files []portainer.ArtifactFile, source, artifact WorkflowPhaseStatus) ArtifactDetail { + return ArtifactDetail{ + ID: int(stack.ID), + Type: TypeStack, + Name: stack.Name, + Platform: platformFromStackType(stack.Type), + Status: WorkflowStatusObject{ + Source: source, + Artifact: artifact, + Target: deriveStackTargetState(stack), + }, + AutoUpdate: stack.AutoUpdate, + Target: Target{ + EndpointID: stack.EndpointID, + Namespace: stack.Namespace, + }, + Files: mapFilesToFileDetails(files), + CreationDate: stack.CreationDate, + LastSyncDate: StackLastSyncDate(stack), + } +} + +// MapEdgeStackToArtifactDetail converts an edge stack to an ArtifactDetail. source and artifact are +// the pre-computed git phase statuses from the caller; files are the artifact's resolved file refs. +func MapEdgeStackToArtifactDetail(es portainer.EdgeStack, files []portainer.ArtifactFile, statuses []portainer.EdgeStackStatusForEnv, groupEndpoints map[portainer.EdgeGroupID][]portainer.EndpointID, source, artifact WorkflowPhaseStatus) ArtifactDetail { + platform := DeploymentPlatformDockerStandalone + if es.DeploymentType == portainer.EdgeStackDeploymentKubernetes { + platform = DeploymentPlatformKubernetes + } + return ArtifactDetail{ + ID: int(es.ID), + Type: TypeEdgeStack, + Name: es.Name, + Platform: platform, + Status: WorkflowStatusObject{ + Source: source, + Artifact: artifact, + Target: deriveEdgeStackTargetState(statuses), + }, + Target: Target{ + EdgeGroupIDs: es.EdgeGroups, + GroupStatus: edgeStackTargetStatuses(es.EdgeGroups, statuses, groupEndpoints), + ResolvedEndpointIDs: resolveEdgeGroupEndpoints(es.EdgeGroups, groupEndpoints), + }, + Files: mapFilesToFileDetails(files), + CreationDate: es.CreationDate, + LastSyncDate: edgeStackLastSyncDate(statuses), + } +} + +func StackLastSyncDate(s portainer.Stack) int64 { + for _, ds := range slices.Backward(s.DeploymentStatus) { + if ds.Status == portainer.StackStatusActive { + return ds.Time + } + } + return 0 +} + +func edgeStackLastSyncDate(statuses []portainer.EdgeStackStatusForEnv) int64 { + var oldest int64 + for _, epStatus := range statuses { + last := endpointLastSyncDate(epStatus) + if last == 0 { + return 0 + } + if oldest == 0 || last < oldest { + oldest = last + } + } + return oldest +} + +func endpointLastSyncDate(epStatus portainer.EdgeStackStatusForEnv) int64 { + for _, s := range slices.Backward(epStatus.Status) { + if isEdgeStackHealthyStatus(s.Type) { + return s.Time + } + } + return 0 +} + +func platformFromStackType(t portainer.StackType) DeploymentPlatform { + switch t { + case portainer.KubernetesStack: + return DeploymentPlatformKubernetes + case portainer.DockerSwarmStack: + return DeploymentPlatformDockerSwarm + default: + return DeploymentPlatformDockerStandalone + } +} + +func isEdgeStackHealthyStatus(t portainer.EdgeStackStatusType) bool { + switch t { + case portainer.EdgeStackStatusRunning, + portainer.EdgeStackStatusRolledBack, + portainer.EdgeStackStatusCompleted, + portainer.EdgeStackStatusRemoved, + portainer.EdgeStackStatusRemoteUpdateSuccess: + return true + } + return false +} + +func resolveEdgeGroupEndpoints(groups []portainer.EdgeGroupID, groupEndpoints map[portainer.EdgeGroupID][]portainer.EndpointID) []portainer.EndpointID { + seen := set.Set[portainer.EndpointID]{} + for _, gid := range groups { + for _, epID := range groupEndpoints[gid] { + seen.Add(epID) + } + } + return seen.Keys() +} + +func edgeStackTargetStatuses( + groups []portainer.EdgeGroupID, + statuses []portainer.EdgeStackStatusForEnv, + groupEndpoints map[portainer.EdgeGroupID][]portainer.EndpointID, +) map[portainer.EdgeGroupID]Status { + epMap := make(map[portainer.EndpointID]Status, len(statuses)) + for _, s := range statuses { + ws, _ := endpointWorkflowStatus(s) + epMap[s.EndpointID] = ws + } + + result := make(map[portainer.EdgeGroupID]Status, len(groups)) + for _, gid := range groups { + gStatus := StatusUnknown + for _, epID := range groupEndpoints[gid] { + if ws := epMap[epID]; statusPriority(ws) > statusPriority(gStatus) { + gStatus = ws + } + } + result[gid] = gStatus + } + return result +} + +func mapFilesToFileDetails(files []portainer.ArtifactFile) []ArtifactFileDetail { + return slicesx.Map(files, func(file portainer.ArtifactFile) ArtifactFileDetail { + return ArtifactFileDetail{ + ArtifactFile: file, + RefStatus: sources.StatusString(file.RefStatus), + PathStatus: sources.StatusString(file.PathStatus), + } + }) +} diff --git a/api/gitops/workflows/mapping_test.go b/api/gitops/workflows/mapping_test.go new file mode 100644 index 0000000..dfd7da4 --- /dev/null +++ b/api/gitops/workflows/mapping_test.go @@ -0,0 +1,268 @@ +package workflows + +import ( + "testing" + + portainer "github.com/portainer/portainer/api" + gittypes "github.com/portainer/portainer/api/git/types" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestStackLastSyncDate(t *testing.T) { + t.Parallel() + + t.Run("no deployment status", func(t *testing.T) { + t.Parallel() + assert.Equal(t, int64(0), StackLastSyncDate(portainer.Stack{})) + }) + + t.Run("no active entry", func(t *testing.T) { + t.Parallel() + s := portainer.Stack{DeploymentStatus: []portainer.StackDeploymentStatus{ + {Status: portainer.StackStatusDeploying, Time: 100}, + }} + assert.Equal(t, int64(0), StackLastSyncDate(s)) + }) + + t.Run("last entry is active", func(t *testing.T) { + t.Parallel() + s := portainer.Stack{DeploymentStatus: []portainer.StackDeploymentStatus{ + {Status: portainer.StackStatusDeploying, Time: 50}, + {Status: portainer.StackStatusActive, Time: 100}, + }} + assert.Equal(t, int64(100), StackLastSyncDate(s)) + }) + + t.Run("active followed by non-active returns the active time", func(t *testing.T) { + t.Parallel() + s := portainer.Stack{DeploymentStatus: []portainer.StackDeploymentStatus{ + {Status: portainer.StackStatusActive, Time: 100}, + {Status: portainer.StackStatusDeploying, Time: 200}, + }} + assert.Equal(t, int64(100), StackLastSyncDate(s)) + }) +} + +func TestEdgeStackLastSyncDate(t *testing.T) { + t.Parallel() + + t.Run("empty statuses", func(t *testing.T) { + t.Parallel() + assert.Equal(t, int64(0), edgeStackLastSyncDate(nil)) + }) + + t.Run("no healthy status for endpoint", func(t *testing.T) { + t.Parallel() + statuses := []portainer.EdgeStackStatusForEnv{ + {EndpointID: 1, Status: []portainer.EdgeStackDeploymentStatus{ + {Type: portainer.EdgeStackStatusDeploying, Time: 100}, + }}, + } + assert.Equal(t, int64(0), edgeStackLastSyncDate(statuses)) + }) + + t.Run("single endpoint with healthy status", func(t *testing.T) { + t.Parallel() + statuses := []portainer.EdgeStackStatusForEnv{ + {EndpointID: 1, Status: []portainer.EdgeStackDeploymentStatus{ + {Type: portainer.EdgeStackStatusRunning, Time: 200}, + }}, + } + assert.Equal(t, int64(200), edgeStackLastSyncDate(statuses)) + }) + + t.Run("returns minimum healthy time across endpoints", func(t *testing.T) { + t.Parallel() + statuses := []portainer.EdgeStackStatusForEnv{ + {EndpointID: 1, Status: []portainer.EdgeStackDeploymentStatus{ + {Type: portainer.EdgeStackStatusRunning, Time: 300}, + }}, + {EndpointID: 2, Status: []portainer.EdgeStackDeploymentStatus{ + {Type: portainer.EdgeStackStatusRunning, Time: 100}, + }}, + } + assert.Equal(t, int64(100), edgeStackLastSyncDate(statuses)) + }) + + t.Run("one endpoint not yet synced returns 0", func(t *testing.T) { + t.Parallel() + statuses := []portainer.EdgeStackStatusForEnv{ + {EndpointID: 1, Status: []portainer.EdgeStackDeploymentStatus{ + {Type: portainer.EdgeStackStatusRunning, Time: 200}, + }}, + {EndpointID: 2, Status: []portainer.EdgeStackDeploymentStatus{ + {Type: portainer.EdgeStackStatusDeploying, Time: 100}, + }}, + } + assert.Equal(t, int64(0), edgeStackLastSyncDate(statuses)) + }) +} + +func TestEdgeStackTargetStatuses(t *testing.T) { + t.Parallel() + + ep := func(id portainer.EndpointID, typ portainer.EdgeStackStatusType) portainer.EdgeStackStatusForEnv { + return portainer.EdgeStackStatusForEnv{ + EndpointID: id, + Status: []portainer.EdgeStackDeploymentStatus{{Type: typ}}, + } + } + + t.Run("group with no endpoints is unknown", func(t *testing.T) { + t.Parallel() + result := edgeStackTargetStatuses( + []portainer.EdgeGroupID{1}, + nil, + map[portainer.EdgeGroupID][]portainer.EndpointID{1: {}}, + ) + assert.Equal(t, StatusUnknown, result[portainer.EdgeGroupID(1)]) + }) + + t.Run("group inherits highest-priority endpoint status", func(t *testing.T) { + t.Parallel() + result := edgeStackTargetStatuses( + []portainer.EdgeGroupID{1}, + []portainer.EdgeStackStatusForEnv{ + ep(1, portainer.EdgeStackStatusRunning), + ep(2, portainer.EdgeStackStatusDeploying), + }, + map[portainer.EdgeGroupID][]portainer.EndpointID{1: {1, 2}}, + ) + assert.Equal(t, StatusSyncing, result[portainer.EdgeGroupID(1)]) + }) + + t.Run("multiple groups tracked separately", func(t *testing.T) { + t.Parallel() + result := edgeStackTargetStatuses( + []portainer.EdgeGroupID{10, 20}, + []portainer.EdgeStackStatusForEnv{ + ep(1, portainer.EdgeStackStatusRunning), + ep(2, portainer.EdgeStackStatusError), + }, + map[portainer.EdgeGroupID][]portainer.EndpointID{ + 10: {1}, + 20: {2}, + }, + ) + assert.Equal(t, StatusHealthy, result[portainer.EdgeGroupID(10)]) + assert.Equal(t, StatusError, result[portainer.EdgeGroupID(20)]) + }) +} + +func TestMapEdgeStackToWorkflow_DockerPlatform(t *testing.T) { + t.Parallel() + + es := portainer.EdgeStack{ + ID: 1, + Name: "docker-edge", + DeploymentType: portainer.EdgeStackDeploymentCompose, + EdgeGroups: []portainer.EdgeGroupID{1}, + CreationDate: 1587399600, + } + cfg := &gittypes.RepoConfig{URL: "https://github.com/x/repo"} + + w := MapEdgeStackToWorkflow(2, es, 7, cfg, nil, map[portainer.EdgeGroupID][]portainer.EndpointID{1: {10}}, WorkflowPhaseStatus{Status: StatusHealthy}, WorkflowPhaseStatus{Status: StatusHealthy}) + + require.Equal(t, portainer.WorkflowID(2), w.ID) + require.Equal(t, es.Name, w.Name) + require.Equal(t, TypeEdgeStack, w.Type) + require.Equal(t, DeploymentPlatformDockerStandalone, w.Platform) + require.Equal(t, es.CreationDate, w.CreationDate) + require.Equal(t, cfg, w.GitConfig) + require.Equal(t, portainer.SourceID(7), w.SourceID) + require.Equal(t, []portainer.EdgeGroupID{1}, w.Target.EdgeGroupIDs) +} + +func TestMapEdgeStackToWorkflow_KubernetesPlatform(t *testing.T) { + t.Parallel() + + es := portainer.EdgeStack{ + ID: 2, + Name: "kube-edge", + DeploymentType: portainer.EdgeStackDeploymentKubernetes, + EdgeGroups: []portainer.EdgeGroupID{1}, + } + + w := MapEdgeStackToWorkflow(1, es, 0, nil, nil, map[portainer.EdgeGroupID][]portainer.EndpointID{}, WorkflowPhaseStatus{Status: StatusUnknown}, WorkflowPhaseStatus{Status: StatusUnknown}) + + require.Equal(t, DeploymentPlatformKubernetes, w.Platform) +} + +func TestMapEdgeStackToWorkflow_GroupStatusesAndResolvedEndpoints(t *testing.T) { + t.Parallel() + + statuses := []portainer.EdgeStackStatusForEnv{ + {EndpointID: 10, Status: []portainer.EdgeStackDeploymentStatus{{Type: portainer.EdgeStackStatusRunning}}}, + {EndpointID: 20, Status: []portainer.EdgeStackDeploymentStatus{{Type: portainer.EdgeStackStatusError, Error: "boom"}}}, + } + groupEndpoints := map[portainer.EdgeGroupID][]portainer.EndpointID{ + 1: {10}, + 2: {20}, + } + es := portainer.EdgeStack{ + ID: 3, + Name: "multi-group", + EdgeGroups: []portainer.EdgeGroupID{1, 2}, + } + + w := MapEdgeStackToWorkflow(5, es, 0, nil, statuses, groupEndpoints, WorkflowPhaseStatus{Status: StatusUnknown}, WorkflowPhaseStatus{Status: StatusUnknown}) + + require.Equal(t, StatusHealthy, w.Target.GroupStatus[1]) + require.Equal(t, StatusError, w.Target.GroupStatus[2]) + require.Len(t, w.Target.ResolvedEndpointIDs, 2) +} + +func TestPlatformFromStackType(t *testing.T) { + t.Parallel() + + require.Equal(t, DeploymentPlatformKubernetes, platformFromStackType(portainer.KubernetesStack)) + require.Equal(t, DeploymentPlatformDockerSwarm, platformFromStackType(portainer.DockerSwarmStack)) + require.Equal(t, DeploymentPlatformDockerStandalone, platformFromStackType(portainer.DockerComposeStack)) + require.Equal(t, DeploymentPlatformDockerStandalone, platformFromStackType(portainer.StackType(99))) +} + +func TestResolveEdgeGroupEndpoints_Empty(t *testing.T) { + t.Parallel() + + result := resolveEdgeGroupEndpoints(nil, map[portainer.EdgeGroupID][]portainer.EndpointID{}) + require.Empty(t, result) +} + +func TestResolveEdgeGroupEndpoints_DeduplicatesAcrossGroups(t *testing.T) { + t.Parallel() + + groupEndpoints := map[portainer.EdgeGroupID][]portainer.EndpointID{ + 1: {10, 20}, + 2: {20, 30}, + } + + result := resolveEdgeGroupEndpoints([]portainer.EdgeGroupID{1, 2}, groupEndpoints) + + require.Len(t, result, 3) +} + +func TestIsEdgeStackHealthyStatus(t *testing.T) { + t.Parallel() + + healthyTypes := []portainer.EdgeStackStatusType{ + portainer.EdgeStackStatusRunning, + portainer.EdgeStackStatusRolledBack, + portainer.EdgeStackStatusCompleted, + portainer.EdgeStackStatusRemoved, + portainer.EdgeStackStatusRemoteUpdateSuccess, + } + for _, typ := range healthyTypes { + require.True(t, isEdgeStackHealthyStatus(typ)) + } + + unhealthyTypes := []portainer.EdgeStackStatusType{ + portainer.EdgeStackStatusError, + portainer.EdgeStackStatusDeploying, + portainer.EdgeStackStatusPending, + } + for _, typ := range unhealthyTypes { + require.False(t, isEdgeStackHealthyStatus(typ)) + } +} diff --git a/api/gitops/workflows/source_artifact.go b/api/gitops/workflows/source_artifact.go new file mode 100644 index 0000000..3df1825 --- /dev/null +++ b/api/gitops/workflows/source_artifact.go @@ -0,0 +1,404 @@ +package workflows + +import ( + "fmt" + "time" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/dataservices/source" + gittypes "github.com/portainer/portainer/api/git/types" + "github.com/portainer/portainer/api/set" +) + +// gitSourceStore is the minimal intersection of CE and EE DataStoreTx that these functions need. +// Both EE and CE DataStoreTx satisfy it, even though they are incompatible as full interface types. +type gitSourceStore interface { + Workflow() dataservices.WorkflowService + Source() dataservices.SourceService +} + +// GitSourceAndArtifactForStack returns the git Source and the ArtifactFile matching stackID +// from the workflow identified by workflowID. +// Source carries the shared fields (URL, auth, TLS); ArtifactFile carries the file-specific fields (ref, path, hash). +// Returns nil, nil, nil when workflowID is 0 or no matching entry is found. +func GitSourceAndArtifactForStack(tx gitSourceStore, userContext source.UserContext, workflowID portainer.WorkflowID, stackID portainer.StackID) (*portainer.Source, *portainer.ArtifactFile, error) { + if workflowID == 0 { + return nil, nil, nil + } + + wf, err := tx.Workflow().Read(workflowID) + if err != nil { + return nil, nil, err + } + + sourceMap, err := LoadWorkflowSources(tx, userContext, wf) + if err != nil { + return nil, nil, err + } + + for i, as := range wf.Artifacts { + if as.StackID != stackID { + continue + } + + for j, file := range as.Files { + src, ok := sourceMap[file.SourceID] + if !ok { + continue + } + + if src.Type == portainer.SourceTypeGit { + return &src, &wf.Artifacts[i].Files[j], nil + } + } + } + + return nil, nil, nil +} + +// GitSourceAndArtifactForEdgeStack returns the git Source and the ArtifactFile matching edgeStackID. +// Returns nil, nil, nil when workflowID is 0 or no matching entry is found. +func GitSourceAndArtifactForEdgeStack(tx gitSourceStore, userContext source.UserContext, workflowID portainer.WorkflowID, edgeStackID portainer.EdgeStackID) (*portainer.Source, *portainer.ArtifactFile, error) { + if workflowID == 0 { + return nil, nil, nil + } + + wf, err := tx.Workflow().Read(workflowID) + if err != nil { + return nil, nil, err + } + + sourceMap, err := LoadWorkflowSources(tx, userContext, wf) + if err != nil { + return nil, nil, err + } + + for i, as := range wf.Artifacts { + if as.EdgeStackID != edgeStackID { + continue + } + + for j, file := range as.Files { + src, ok := sourceMap[file.SourceID] + if !ok { + continue + } + + if src.Type == portainer.SourceTypeGit { + return &src, &wf.Artifacts[i].Files[j], nil + } + } + } + + return nil, nil, nil +} + +// MergeSourceAndFile builds a RepoConfig by combining shared fields from src (URL, auth, TLS) +// with file-specific fields from file (ref, path, hash). +func MergeSourceAndFile(src *portainer.Source, file *portainer.ArtifactFile) *gittypes.RepoConfig { + if src == nil || src.Git == nil { + return nil + } + + cfg := &gittypes.RepoConfig{ + URL: src.Git.URL, + Authentication: src.Git.Authentication, + TLSSkipVerify: src.Git.TLSSkipVerify, + } + + if file != nil { + cfg.ReferenceName = file.Ref + cfg.ConfigFilePath = file.Path + cfg.ConfigHash = file.Hash + } + + return cfg +} + +// UpdateArtifactFileForStack finds the ArtifactFile matching stackID and sourceID in the workflow +// and applies fn to it, then persists the updated Workflow. +// A no-op if no matching artifact or file is found. +func UpdateArtifactFileForStack(tx gitSourceStore, workflowID portainer.WorkflowID, stackID portainer.StackID, sourceID portainer.SourceID, fn func(*portainer.ArtifactFile)) error { + wf, err := tx.Workflow().Read(workflowID) + if err != nil { + return err + } + + for i, as := range wf.Artifacts { + if as.StackID != stackID { + continue + } + + for j, file := range as.Files { + if file.SourceID == sourceID { + fn(&wf.Artifacts[i].Files[j]) + + return tx.Workflow().Update(workflowID, wf) + } + } + } + + return nil +} + +// UpdateArtifactFileForEdgeStack finds the ArtifactFile matching edgeStackID and sourceID in the workflow +// and applies fn to it, then persists the updated Workflow. +// A no-op if no matching artifact or file is found. +func UpdateArtifactFileForEdgeStack(tx gitSourceStore, workflowID portainer.WorkflowID, edgeStackID portainer.EdgeStackID, sourceID portainer.SourceID, fn func(*portainer.ArtifactFile)) error { + wf, err := tx.Workflow().Read(workflowID) + if err != nil { + return err + } + + for i, as := range wf.Artifacts { + if as.EdgeStackID != edgeStackID { + continue + } + + for j, file := range as.Files { + if file.SourceID == sourceID { + fn(&wf.Artifacts[i].Files[j]) + + return tx.Workflow().Update(workflowID, wf) + } + } + } + + return nil +} + +func UpdateSourceSyncStatus(tx gitSourceStore, userContext source.UserContext, sourceID portainer.SourceID, status portainer.SourceStatus, statusError string) error { + if sourceID == 0 { + return nil + } + + src, err := tx.Source().Read(userContext, sourceID) + if err != nil { + return fmt.Errorf("failed to read source: %w", err) + } + + src.Status = status + src.StatusError = statusError + + if status == portainer.SourceStatusHealthy { + src.LastSync = time.Now().Unix() + } + + return tx.Source().Update(userContext, src.ID, src) +} + +func checkResultStatus(checkErr error) (portainer.SourceStatus, string) { + if checkErr != nil { + return portainer.SourceStatusError, checkErr.Error() + } + + return portainer.SourceStatusHealthy, "" +} + +func SaveSourceStatus(tx gitSourceStore, userContext source.UserContext, sourceID portainer.SourceID, checkErr error) error { + status, statusError := checkResultStatus(checkErr) + + return UpdateSourceSyncStatus(tx, userContext, sourceID, status, statusError) +} + +func SaveStackStatus(tx gitSourceStore, userContext source.UserContext, workflowID portainer.WorkflowID, stackID portainer.StackID, sourceID portainer.SourceID, checkErr error) error { + if workflowID == 0 { + return nil + } + + status, statusError := checkResultStatus(checkErr) + + if err := UpdateArtifactFileForStack(tx, workflowID, stackID, sourceID, func(a *portainer.ArtifactFile) { + a.RefStatus = status + a.RefError = statusError + a.PathStatus = status + a.PathError = statusError + }); err != nil { + return err + } + + return UpdateSourceSyncStatus(tx, userContext, sourceID, status, statusError) +} + +func SaveEdgeStackStatus(tx gitSourceStore, userContext source.UserContext, workflowID portainer.WorkflowID, edgeStackID portainer.EdgeStackID, sourceID portainer.SourceID, checkErr error) error { + if workflowID == 0 { + return nil + } + + status, statusError := checkResultStatus(checkErr) + + if err := UpdateArtifactFileForEdgeStack(tx, workflowID, edgeStackID, sourceID, func(a *portainer.ArtifactFile) { + a.RefStatus = status + a.RefError = statusError + a.PathStatus = status + a.PathError = statusError + }); err != nil { + return err + } + + return UpdateSourceSyncStatus(tx, userContext, sourceID, status, statusError) +} + +// FindOrCreateGitSource returns an existing Source whose URL and authentication match cfg, +// or creates a new one. Only URL, authentication, and TLSSkipVerify are stored on the Source; +// per-stack fields (ReferenceName, ConfigFilePath, ConfigHash) belong in the Artifact. +func FindOrCreateGitSource(tx gitSourceStore, userContext source.UserContext, src *portainer.Source) (*portainer.Source, error) { + return tx.Source().FindOrCreateGitSource(userContext, src) +} + +// SaveWorkflowGitConfig persists URL/auth/TLS on the Source and ref/path/hash on the Artifact +// matched by matchArtifact. When the URL changes, an existing or new Source is located via +// FindOrCreateGitSource and the Workflow's SourceID is updated atomically alongside the Artifact fields. +func SaveWorkflowGitConfig(tx gitSourceStore, userContext source.UserContext, workflowID portainer.WorkflowID, matchArtifact func(portainer.Artifact) bool, oldSourceID portainer.SourceID, cfg *gittypes.RepoConfig) error { + src, err := tx.Source().Read(userContext, oldSourceID) + if err != nil { + return fmt.Errorf("failed to read source: %w", err) + } + + if src.Git == nil { + return fmt.Errorf("source %d has no git configuration", oldSourceID) + } + + newSourceID := oldSourceID + + if cfg.URL != src.Git.URL { + newSrc, err := FindOrCreateGitSource(tx, userContext, &portainer.Source{ + Name: gittypes.RepoName(cfg.URL), + Type: portainer.SourceTypeGit, + Git: &gittypes.GitSource{ + URL: cfg.URL, + Authentication: cfg.Authentication, + TLSSkipVerify: cfg.TLSSkipVerify, + }, + }) + if err != nil { + return fmt.Errorf("failed to find or create source: %w", err) + } + + newSourceID = newSrc.ID + } else { + src.Git.Authentication = cfg.Authentication + src.Git.TLSSkipVerify = cfg.TLSSkipVerify + + if err := tx.Source().Update(userContext, src.ID, src); err != nil { + return fmt.Errorf("failed to update source: %w", err) + } + } + + return SaveWorkflowArtifact(tx, workflowID, matchArtifact, oldSourceID, portainer.ArtifactFile{ + SourceID: newSourceID, + Ref: cfg.ReferenceName, + Path: cfg.ConfigFilePath, + Hash: cfg.ConfigHash, + }) +} + +// SaveWorkflowArtifact replaces the ArtifactFile referencing oldSourceID on the Artifact matched by +// matchArtifact with update (its SourceID may repoint the Artifact to a different Source). It does not +// modify any Source's git config — the caller is responsible for ensuring update.SourceID +// references a valid existing Source. +func SaveWorkflowArtifact(tx gitSourceStore, workflowID portainer.WorkflowID, matchArtifact func(portainer.Artifact) bool, oldSourceID portainer.SourceID, update portainer.ArtifactFile) error { + wf, err := tx.Workflow().Read(workflowID) + if err != nil { + return fmt.Errorf("failed to read workflow: %w", err) + } + + for i, as := range wf.Artifacts { + if !matchArtifact(as) { + continue + } + + for j, file := range as.Files { + if file.SourceID != oldSourceID { + continue + } + + f := &wf.Artifacts[i].Files[j] + f.SourceID = update.SourceID + f.Ref = update.Ref + f.Path = update.Path + f.Hash = update.Hash + f.RefStatus = portainer.SourceStatusUnknown + f.RefError = "" + f.PathStatus = portainer.SourceStatusUnknown + f.PathError = "" + + break + } + + break + } + + return tx.Workflow().Update(workflowID, wf) +} + +// LoadWorkflowMap fetches workflows by their IDs and returns them keyed by ID. +func LoadWorkflowMap(tx gitSourceStore, ids set.Set[portainer.WorkflowID]) (map[portainer.WorkflowID]portainer.Workflow, error) { + result := make(map[portainer.WorkflowID]portainer.Workflow, len(ids)) + for id := range ids { + wf, err := tx.Workflow().Read(id) + if err != nil { + return nil, err + } + result[id] = *wf + } + + return result, nil +} + +// LoadWorkflowAndSourceMaps fetches workflows by their IDs and the sources they reference, +// collecting source IDs in a single pass over the workflows. +func LoadWorkflowAndSourceMaps(tx gitSourceStore, userContext source.UserContext, ids set.Set[portainer.WorkflowID]) (map[portainer.WorkflowID]portainer.Workflow, map[portainer.SourceID]portainer.Source, error) { + wfMap := make(map[portainer.WorkflowID]portainer.Workflow, len(ids)) + sourceIDs := make(set.Set[portainer.SourceID]) + for id := range ids { + wf, err := tx.Workflow().Read(id) + if err != nil { + return nil, nil, err + } + wfMap[id] = *wf + for _, as := range wf.Artifacts { + for _, f := range as.Files { + sourceIDs.Add(f.SourceID) + } + } + } + + srcMap, err := loadSourceMap(tx, userContext, sourceIDs) + if err != nil { + return nil, nil, err + } + + return wfMap, srcMap, nil +} + +// LoadWorkflowSources collects all unique SourceIDs referenced by wf and returns them as a map. +// This avoids reading the same Source record more than once when files share a SourceID. +func LoadWorkflowSources(tx gitSourceStore, userContext source.UserContext, wf *portainer.Workflow) (map[portainer.SourceID]portainer.Source, error) { + ids := make(set.Set[portainer.SourceID]) + for _, as := range wf.Artifacts { + for _, f := range as.Files { + ids.Add(f.SourceID) + } + } + + return loadSourceMap(tx, userContext, ids) +} + +// loadSourceMap fetches sources by their IDs and returns them keyed by ID. +func loadSourceMap(tx gitSourceStore, userContext source.UserContext, ids set.Set[portainer.SourceID]) (map[portainer.SourceID]portainer.Source, error) { + sources, err := tx.Source().ReadAll(userContext, func(s portainer.Source) bool { + return ids.Contains(s.ID) + }) + if err != nil { + return nil, err + } + + result := make(map[portainer.SourceID]portainer.Source, len(ids)) + for _, src := range sources { + result[src.ID] = src + } + + return result, nil +} diff --git a/api/gitops/workflows/source_artifact_test.go b/api/gitops/workflows/source_artifact_test.go new file mode 100644 index 0000000..f3a793a --- /dev/null +++ b/api/gitops/workflows/source_artifact_test.go @@ -0,0 +1,1068 @@ +package workflows + +import ( + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/datastore" + gittypes "github.com/portainer/portainer/api/git/types" + + "github.com/stretchr/testify/require" +) + +func TestMergeSourceAndFile_NilSourceReturnsNil(t *testing.T) { + t.Parallel() + + require.Nil(t, MergeSourceAndFile(nil, nil)) +} + +func TestMergeSourceAndFile_NilGitConfigReturnsNil(t *testing.T) { + t.Parallel() + + src := &portainer.Source{Type: portainer.SourceTypeGit} + require.Nil(t, MergeSourceAndFile(src, nil)) +} + +func TestMergeSourceAndFile_NilFileLeaveFileFieldsEmpty(t *testing.T) { + t.Parallel() + + src := &portainer.Source{ + Git: &gittypes.GitSource{ + URL: "https://github.com/example/repo", + TLSSkipVerify: true, + Authentication: &gittypes.GitAuthentication{ + Username: "user", + Password: "pass", + }, + }, + } + + cfg := MergeSourceAndFile(src, nil) + require.NotNil(t, cfg) + require.Equal(t, "https://github.com/example/repo", cfg.URL) + require.True(t, cfg.TLSSkipVerify) + require.Equal(t, "user", cfg.Authentication.Username) + require.Empty(t, cfg.ReferenceName) + require.Empty(t, cfg.ConfigFilePath) + require.Empty(t, cfg.ConfigHash) +} + +func TestMergeSourceAndFile_MergesAllFieldsFromFile(t *testing.T) { + t.Parallel() + + src := &portainer.Source{ + Git: &gittypes.GitSource{ + URL: "https://github.com/example/repo", + TLSSkipVerify: true, + }, + } + file := &portainer.ArtifactFile{ + Path: "docker-compose.yml", + Ref: "refs/heads/main", + Hash: "abc123", + } + + cfg := MergeSourceAndFile(src, file) + require.NotNil(t, cfg) + require.Equal(t, "https://github.com/example/repo", cfg.URL) + require.True(t, cfg.TLSSkipVerify) + require.Equal(t, "refs/heads/main", cfg.ReferenceName) + require.Equal(t, "docker-compose.yml", cfg.ConfigFilePath) + require.Equal(t, "abc123", cfg.ConfigHash) +} + +func TestGitSourceAndArtifactForStack_ZeroWorkflowIDReturnsNil(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + var src *portainer.Source + var file *portainer.ArtifactFile + err := store.ViewTx(func(tx dataservices.DataStoreTx) error { + var txErr error + src, file, txErr = GitSourceAndArtifactForStack(tx, adminUserContext, 0, 1) + return txErr + }) + require.NoError(t, err) + require.Nil(t, src) + require.Nil(t, file) +} + +func TestGitSourceAndArtifactForStack_ReturnsMatchingSourceAndFile(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + var workflowID portainer.WorkflowID + err := store.UpdateTx(func(tx dataservices.DataStoreTx) error { + gitSrc := &portainer.Source{ + Type: portainer.SourceTypeGit, + Git: &gittypes.GitSource{URL: "https://github.com/example/repo"}, + } + err := tx.Source().Create(adminUserContext, gitSrc) + require.NoError(t, err) + + wf := &portainer.Workflow{ + Artifacts: []portainer.Artifact{{ + StackID: 42, + Files: []portainer.ArtifactFile{{ + SourceID: gitSrc.ID, + Path: "docker-compose.yml", + Ref: "refs/heads/main", + Hash: "abc123", + }}, + }}, + } + err = tx.Workflow().Create(wf) + require.NoError(t, err) + workflowID = wf.ID + + return nil + }) + require.NoError(t, err) + + var src *portainer.Source + var file *portainer.ArtifactFile + err = store.ViewTx(func(tx dataservices.DataStoreTx) error { + var txErr error + src, file, txErr = GitSourceAndArtifactForStack(tx, adminUserContext, workflowID, 42) + return txErr + }) + require.NoError(t, err) + require.NotNil(t, src) + require.Equal(t, portainer.SourceTypeGit, src.Type) + require.NotNil(t, file) + require.Equal(t, "refs/heads/main", file.Ref) + require.Equal(t, "docker-compose.yml", file.Path) + require.Equal(t, "abc123", file.Hash) +} + +func TestGitSourceAndArtifactForStack_NoMatchingArtifactReturnsNil(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + var workflowID portainer.WorkflowID + err := store.UpdateTx(func(tx dataservices.DataStoreTx) error { + src := &portainer.Source{ + Type: portainer.SourceTypeGit, + Git: &gittypes.GitSource{URL: "https://github.com/example/repo"}, + } + err := tx.Source().Create(adminUserContext, src) + require.NoError(t, err) + + wf := &portainer.Workflow{ + Artifacts: []portainer.Artifact{{ + StackID: 1, + Files: []portainer.ArtifactFile{{SourceID: src.ID}}, + }}, + } + err = tx.Workflow().Create(wf) + require.NoError(t, err) + workflowID = wf.ID + + return nil + }) + require.NoError(t, err) + + var src *portainer.Source + var file *portainer.ArtifactFile + err = store.ViewTx(func(tx dataservices.DataStoreTx) error { + var txErr error + src, file, txErr = GitSourceAndArtifactForStack(tx, adminUserContext, workflowID, 99) + return txErr + }) + require.NoError(t, err) + require.Nil(t, src) + require.Nil(t, file) +} + +func TestGitSourceAndArtifactForEdgeStack_ZeroWorkflowIDReturnsNil(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + var src *portainer.Source + var file *portainer.ArtifactFile + err := store.ViewTx(func(tx dataservices.DataStoreTx) error { + var txErr error + src, file, txErr = GitSourceAndArtifactForEdgeStack(tx, adminUserContext, 0, 1) + return txErr + }) + require.NoError(t, err) + require.Nil(t, src) + require.Nil(t, file) +} + +func TestGitSourceAndArtifactForEdgeStack_ReturnsMatchingSourceAndFile(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + var workflowID portainer.WorkflowID + err := store.UpdateTx(func(tx dataservices.DataStoreTx) error { + gitSrc := &portainer.Source{ + Type: portainer.SourceTypeGit, + Git: &gittypes.GitSource{URL: "https://github.com/example/edge-repo"}, + } + err := tx.Source().Create(adminUserContext, gitSrc) + require.NoError(t, err) + + wf := &portainer.Workflow{ + Artifacts: []portainer.Artifact{{ + EdgeStackID: 5, + Files: []portainer.ArtifactFile{{ + SourceID: gitSrc.ID, + Path: "edge.yml", + Ref: "refs/heads/edge", + }}, + }}, + } + err = tx.Workflow().Create(wf) + require.NoError(t, err) + workflowID = wf.ID + + return nil + }) + require.NoError(t, err) + + var src *portainer.Source + var file *portainer.ArtifactFile + err = store.ViewTx(func(tx dataservices.DataStoreTx) error { + var txErr error + src, file, txErr = GitSourceAndArtifactForEdgeStack(tx, adminUserContext, workflowID, 5) + return txErr + }) + require.NoError(t, err) + require.NotNil(t, src) + require.Equal(t, portainer.SourceTypeGit, src.Type) + require.NotNil(t, file) + require.Equal(t, "refs/heads/edge", file.Ref) +} + +func TestUpdateArtifactFileForStack_NoMatchingArtifactIsNoOp(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + var workflowID portainer.WorkflowID + var sourceID portainer.SourceID + err := store.UpdateTx(func(tx dataservices.DataStoreTx) error { + src := &portainer.Source{Type: portainer.SourceTypeGit, Git: &gittypes.GitSource{URL: "https://example.com"}} + err := tx.Source().Create(adminUserContext, src) + require.NoError(t, err) + sourceID = src.ID + + wf := &portainer.Workflow{ + Artifacts: []portainer.Artifact{{ + StackID: 1, + Files: []portainer.ArtifactFile{{SourceID: sourceID, Hash: "original"}}, + }}, + } + err = tx.Workflow().Create(wf) + require.NoError(t, err) + workflowID = wf.ID + + return nil + }) + require.NoError(t, err) + + err = store.UpdateTx(func(tx dataservices.DataStoreTx) error { + return UpdateArtifactFileForStack(tx, workflowID, 99, sourceID, func(a *portainer.ArtifactFile) { + a.Hash = "changed" + }) + }) + require.NoError(t, err) + + wf, err := store.Workflow().Read(workflowID) + require.NoError(t, err) + require.Equal(t, "original", wf.Artifacts[0].Files[0].Hash) +} + +func TestUpdateArtifactFileForStack_AppliesFnAndPersists(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + var workflowID portainer.WorkflowID + var sourceID portainer.SourceID + err := store.UpdateTx(func(tx dataservices.DataStoreTx) error { + src := &portainer.Source{Type: portainer.SourceTypeGit, Git: &gittypes.GitSource{URL: "https://example.com"}} + err := tx.Source().Create(adminUserContext, src) + require.NoError(t, err) + sourceID = src.ID + + wf := &portainer.Workflow{ + Artifacts: []portainer.Artifact{{ + StackID: 1, + Files: []portainer.ArtifactFile{{SourceID: sourceID, Hash: "old-hash"}}, + }}, + } + err = tx.Workflow().Create(wf) + require.NoError(t, err) + workflowID = wf.ID + + return nil + }) + require.NoError(t, err) + + err = store.UpdateTx(func(tx dataservices.DataStoreTx) error { + return UpdateArtifactFileForStack(tx, workflowID, 1, sourceID, func(a *portainer.ArtifactFile) { + a.Hash = "new-hash" + }) + }) + require.NoError(t, err) + + wf, err := store.Workflow().Read(workflowID) + require.NoError(t, err) + require.Equal(t, "new-hash", wf.Artifacts[0].Files[0].Hash) +} + +func TestUpdateArtifactFileForEdgeStack_AppliesFnAndPersists(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + var workflowID portainer.WorkflowID + var sourceID portainer.SourceID + err := store.UpdateTx(func(tx dataservices.DataStoreTx) error { + src := &portainer.Source{Type: portainer.SourceTypeGit, Git: &gittypes.GitSource{URL: "https://example.com"}} + err := tx.Source().Create(adminUserContext, src) + require.NoError(t, err) + sourceID = src.ID + + wf := &portainer.Workflow{ + Artifacts: []portainer.Artifact{{ + EdgeStackID: 7, + Files: []portainer.ArtifactFile{{SourceID: sourceID, Hash: "old-hash"}}, + }}, + } + err = tx.Workflow().Create(wf) + require.NoError(t, err) + workflowID = wf.ID + + return nil + }) + require.NoError(t, err) + + err = store.UpdateTx(func(tx dataservices.DataStoreTx) error { + return UpdateArtifactFileForEdgeStack(tx, workflowID, 7, sourceID, func(a *portainer.ArtifactFile) { + a.Hash = "new-hash" + }) + }) + require.NoError(t, err) + + wf, err := store.Workflow().Read(workflowID) + require.NoError(t, err) + require.Equal(t, "new-hash", wf.Artifacts[0].Files[0].Hash) +} + +func TestFindOrCreateGitSource_CreatesNewSource(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + var src *portainer.Source + err := store.UpdateTx(func(tx dataservices.DataStoreTx) error { + var txErr error + src, txErr = FindOrCreateGitSource(tx, adminUserContext, &portainer.Source{ + Name: "my-repo", + Type: portainer.SourceTypeGit, + Git: &gittypes.GitSource{ + URL: "https://github.com/example/repo", + }, + }) + return txErr + }) + require.NoError(t, err) + require.NotNil(t, src) + require.NotZero(t, src.ID) + require.Equal(t, "https://github.com/example/repo", src.Git.URL) +} + +func TestFindOrCreateGitSource_ReusesExistingSourceForSameURLAndAuth(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + makeSource := func(tx dataservices.DataStoreTx) (*portainer.Source, error) { + return FindOrCreateGitSource(tx, adminUserContext, &portainer.Source{ + Type: portainer.SourceTypeGit, + Git: &gittypes.GitSource{ + URL: "https://github.com/example/repo", + }, + }) + } + + var firstID portainer.SourceID + err := store.UpdateTx(func(tx dataservices.DataStoreTx) error { + s, err := makeSource(tx) + if err != nil { + return err + } + firstID = s.ID + + return nil + }) + require.NoError(t, err) + + var secondID portainer.SourceID + err = store.UpdateTx(func(tx dataservices.DataStoreTx) error { + s, err := makeSource(tx) + if err != nil { + return err + } + secondID = s.ID + + return nil + }) + require.NoError(t, err) + require.Equal(t, firstID, secondID) + + sources, err := store.Source().ReadAll(adminUserContext) + require.NoError(t, err) + require.Len(t, sources, 1) +} + +func TestFindOrCreateGitSource_DifferentAuthCreatesNewSource(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + err := store.UpdateTx(func(tx dataservices.DataStoreTx) error { + _, txErr := FindOrCreateGitSource(tx, adminUserContext, &portainer.Source{ + Type: portainer.SourceTypeGit, + Git: &gittypes.GitSource{ + URL: "https://github.com/example/repo", + Authentication: &gittypes.GitAuthentication{Username: "alice", Password: "pass1"}, + }, + }) + return txErr + }) + require.NoError(t, err) + + err = store.UpdateTx(func(tx dataservices.DataStoreTx) error { + _, txErr := FindOrCreateGitSource(tx, adminUserContext, &portainer.Source{ + Type: portainer.SourceTypeGit, + Git: &gittypes.GitSource{ + URL: "https://github.com/example/repo", + Authentication: &gittypes.GitAuthentication{Username: "bob", Password: "pass2"}, + }, + }) + return txErr + }) + require.NoError(t, err) + + sources, err := store.Source().ReadAll(adminUserContext) + require.NoError(t, err) + require.Len(t, sources, 2) +} + +func TestSaveWorkflowGitConfig_UpdatesFileAndSourceWhenURLUnchanged(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + var workflowID portainer.WorkflowID + var sourceID portainer.SourceID + + err := store.UpdateTx(func(tx dataservices.DataStoreTx) error { + src := &portainer.Source{ + Type: portainer.SourceTypeGit, + Git: &gittypes.GitSource{ + URL: "https://github.com/example/repo", + TLSSkipVerify: false, + Authentication: &gittypes.GitAuthentication{ + Username: "old-user", + Password: "old-pass", + }, + }, + } + err := tx.Source().Create(adminUserContext, src) + require.NoError(t, err) + sourceID = src.ID + + wf := &portainer.Workflow{ + Artifacts: []portainer.Artifact{{ + StackID: 1, + Files: []portainer.ArtifactFile{{ + SourceID: sourceID, + Path: "docker-compose.yml", + Ref: "refs/heads/main", + Hash: "old-hash", + }}, + }}, + } + err = tx.Workflow().Create(wf) + require.NoError(t, err) + workflowID = wf.ID + + return nil + }) + require.NoError(t, err) + + newCfg := &gittypes.RepoConfig{ + URL: "https://github.com/example/repo", + TLSSkipVerify: true, + Authentication: &gittypes.GitAuthentication{ + Username: "new-user", + Password: "new-pass", + }, + ReferenceName: "refs/heads/dev", + ConfigFilePath: "compose.yml", + ConfigHash: "new-hash", + } + + err = store.UpdateTx(func(tx dataservices.DataStoreTx) error { + return SaveWorkflowGitConfig(tx, adminUserContext, workflowID, func(a portainer.Artifact) bool { + return a.StackID == 1 + }, sourceID, newCfg) + }) + require.NoError(t, err) + + wf, err := store.Workflow().Read(workflowID) + require.NoError(t, err) + require.Equal(t, "refs/heads/dev", wf.Artifacts[0].Files[0].Ref) + require.Equal(t, "compose.yml", wf.Artifacts[0].Files[0].Path) + require.Equal(t, "new-hash", wf.Artifacts[0].Files[0].Hash) + require.Equal(t, sourceID, wf.Artifacts[0].Files[0].SourceID) + + src, err := store.Source().Read(adminUserContext, sourceID) + require.NoError(t, err) + require.Equal(t, "new-user", src.Git.Authentication.Username) + require.Equal(t, "new-pass", src.Git.Authentication.Password) + require.True(t, src.Git.TLSSkipVerify) +} + +func TestSaveWorkflowGitConfig_CreatesNewSourceOnURLChange(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + var workflowID portainer.WorkflowID + var oldSourceID portainer.SourceID + + err := store.UpdateTx(func(tx dataservices.DataStoreTx) error { + src := &portainer.Source{ + Type: portainer.SourceTypeGit, + Git: &gittypes.GitSource{URL: "https://github.com/example/old-repo"}, + } + err := tx.Source().Create(adminUserContext, src) + require.NoError(t, err) + oldSourceID = src.ID + + wf := &portainer.Workflow{ + Artifacts: []portainer.Artifact{{ + StackID: 1, + Files: []portainer.ArtifactFile{{SourceID: oldSourceID}}, + }}, + } + err = tx.Workflow().Create(wf) + require.NoError(t, err) + workflowID = wf.ID + + return nil + }) + require.NoError(t, err) + + newCfg := &gittypes.RepoConfig{URL: "https://github.com/example/new-repo"} + + err = store.UpdateTx(func(tx dataservices.DataStoreTx) error { + return SaveWorkflowGitConfig(tx, adminUserContext, workflowID, func(a portainer.Artifact) bool { + return a.StackID == 1 + }, oldSourceID, newCfg) + }) + require.NoError(t, err) + + wf, err := store.Workflow().Read(workflowID) + require.NoError(t, err) + newSourceID := wf.Artifacts[0].Files[0].SourceID + require.NotEqual(t, oldSourceID, newSourceID) + + newSrc, err := store.Source().Read(adminUserContext, newSourceID) + require.NoError(t, err) + require.Equal(t, "https://github.com/example/new-repo", newSrc.Git.URL) +} + +func TestSaveWorkflowGitConfig_ReusesExistingSourceOnURLChange(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + var workflowID portainer.WorkflowID + var oldSourceID, existingSourceID portainer.SourceID + + err := store.UpdateTx(func(tx dataservices.DataStoreTx) error { + old := &portainer.Source{ + Type: portainer.SourceTypeGit, + Git: &gittypes.GitSource{URL: "https://github.com/example/old-repo"}, + } + err := tx.Source().Create(adminUserContext, old) + require.NoError(t, err) + oldSourceID = old.ID + + existing := &portainer.Source{ + Type: portainer.SourceTypeGit, + Git: &gittypes.GitSource{URL: "https://github.com/example/shared-repo"}, + } + err = tx.Source().Create(adminUserContext, existing) + require.NoError(t, err) + existingSourceID = existing.ID + + wf := &portainer.Workflow{ + Artifacts: []portainer.Artifact{{ + StackID: 1, + Files: []portainer.ArtifactFile{{SourceID: oldSourceID}}, + }}, + } + err = tx.Workflow().Create(wf) + require.NoError(t, err) + workflowID = wf.ID + + return nil + }) + require.NoError(t, err) + + newCfg := &gittypes.RepoConfig{URL: "https://github.com/example/shared-repo"} + + err = store.UpdateTx(func(tx dataservices.DataStoreTx) error { + return SaveWorkflowGitConfig(tx, adminUserContext, workflowID, func(a portainer.Artifact) bool { + return a.StackID == 1 + }, oldSourceID, newCfg) + }) + require.NoError(t, err) + + wf, err := store.Workflow().Read(workflowID) + require.NoError(t, err) + require.Equal(t, existingSourceID, wf.Artifacts[0].Files[0].SourceID) + + sources, err := store.Source().ReadAll(adminUserContext) + require.NoError(t, err) + require.Len(t, sources, 2) +} + +func TestSaveWorkflowGitConfig_OnlyMatchingArtifactUpdated(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + var workflowID portainer.WorkflowID + var sourceID portainer.SourceID + + err := store.UpdateTx(func(tx dataservices.DataStoreTx) error { + src := &portainer.Source{ + Type: portainer.SourceTypeGit, + Git: &gittypes.GitSource{URL: "https://github.com/example/repo"}, + } + err := tx.Source().Create(adminUserContext, src) + require.NoError(t, err) + sourceID = src.ID + + wf := &portainer.Workflow{ + Artifacts: []portainer.Artifact{ + { + StackID: 1, + Files: []portainer.ArtifactFile{{SourceID: sourceID, Hash: "hash-1"}}, + }, + { + StackID: 2, + Files: []portainer.ArtifactFile{{SourceID: sourceID, Hash: "hash-2"}}, + }, + }, + } + err = tx.Workflow().Create(wf) + require.NoError(t, err) + workflowID = wf.ID + + return nil + }) + require.NoError(t, err) + + err = store.UpdateTx(func(tx dataservices.DataStoreTx) error { + return SaveWorkflowGitConfig(tx, adminUserContext, workflowID, func(a portainer.Artifact) bool { + return a.StackID == 1 + }, sourceID, &gittypes.RepoConfig{ + URL: "https://github.com/example/repo", + ConfigHash: "updated-hash", + }) + }) + require.NoError(t, err) + + wf, err := store.Workflow().Read(workflowID) + require.NoError(t, err) + require.Equal(t, "updated-hash", wf.Artifacts[0].Files[0].Hash) + require.Equal(t, "hash-2", wf.Artifacts[1].Files[0].Hash) +} + +func TestUpdateArtifactFileForStack_MultipleArtifactsOnlyMatchingUpdated(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + var workflowID portainer.WorkflowID + var srcID portainer.SourceID + err := store.UpdateTx(func(tx dataservices.DataStoreTx) error { + src := &portainer.Source{Type: portainer.SourceTypeGit, Git: &gittypes.GitSource{URL: "https://example.com"}} + err := tx.Source().Create(adminUserContext, src) + require.NoError(t, err) + srcID = src.ID + + wf := &portainer.Workflow{ + Artifacts: []portainer.Artifact{ + {StackID: 10, Files: []portainer.ArtifactFile{{SourceID: srcID, Hash: "hash-10"}}}, + {StackID: 20, Files: []portainer.ArtifactFile{{SourceID: srcID, Hash: "hash-20"}}}, + }, + } + err = tx.Workflow().Create(wf) + require.NoError(t, err) + workflowID = wf.ID + + return nil + }) + require.NoError(t, err) + + err = store.UpdateTx(func(tx dataservices.DataStoreTx) error { + return UpdateArtifactFileForStack(tx, workflowID, 10, srcID, func(a *portainer.ArtifactFile) { + a.Hash = "updated-hash-10" + }) + }) + require.NoError(t, err) + + wf, err := store.Workflow().Read(workflowID) + require.NoError(t, err) + require.Equal(t, "updated-hash-10", wf.Artifacts[0].Files[0].Hash) + require.Equal(t, "hash-20", wf.Artifacts[1].Files[0].Hash) +} + +func TestSaveWorkflowArtifact_SwitchesSourceWithoutMutatingIt(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + var workflowID portainer.WorkflowID + var oldSourceID, newSourceID portainer.SourceID + + err := store.UpdateTx(func(tx dataservices.DataStoreTx) error { + // Two distinct sources sharing the same URL: the case where URL-based + // resolution would fail to switch. + old := &portainer.Source{ + Type: portainer.SourceTypeGit, + Git: &gittypes.GitSource{URL: "https://github.com/example/repo"}, + } + err := tx.Source().Create(adminUserContext, old) + require.NoError(t, err) + oldSourceID = old.ID + + selected := &portainer.Source{ + Type: portainer.SourceTypeGit, + Git: &gittypes.GitSource{ + URL: "https://github.com/example/repo", + Authentication: &gittypes.GitAuthentication{ + Username: "selected-user", + Password: "selected-pass", + }, + }, + } + err = tx.Source().Create(adminUserContext, selected) + require.NoError(t, err) + newSourceID = selected.ID + + wf := &portainer.Workflow{ + Artifacts: []portainer.Artifact{{ + StackID: 1, + Files: []portainer.ArtifactFile{{ + SourceID: oldSourceID, + Ref: "refs/heads/main", + Path: "docker-compose.yml", + Hash: "old-hash", + }}, + }}, + } + err = tx.Workflow().Create(wf) + require.NoError(t, err) + workflowID = wf.ID + + return nil + }) + require.NoError(t, err) + + err = store.UpdateTx(func(tx dataservices.DataStoreTx) error { + return SaveWorkflowArtifact(tx, workflowID, func(a portainer.Artifact) bool { + return a.StackID == 1 + }, oldSourceID, portainer.ArtifactFile{ + SourceID: newSourceID, + Ref: "refs/heads/dev", + Path: "compose.yml", + Hash: "new-hash", + }) + }) + require.NoError(t, err) + + wf, err := store.Workflow().Read(workflowID) + require.NoError(t, err) + require.Equal(t, newSourceID, wf.Artifacts[0].Files[0].SourceID) + require.Equal(t, "refs/heads/dev", wf.Artifacts[0].Files[0].Ref) + require.Equal(t, "compose.yml", wf.Artifacts[0].Files[0].Path) + require.Equal(t, "new-hash", wf.Artifacts[0].Files[0].Hash) + + // The selected source's git config must be left untouched. + selected, err := store.Source().Read(adminUserContext, newSourceID) + require.NoError(t, err) + require.Equal(t, "https://github.com/example/repo", selected.Git.URL) + require.Equal(t, "selected-user", selected.Git.Authentication.Username) + require.Equal(t, "selected-pass", selected.Git.Authentication.Password) +} + +func TestUpdateArtifactFileForEdgeStack_MultipleArtifactsOnlyMatchingUpdated(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + var workflowID portainer.WorkflowID + var srcID portainer.SourceID + err := store.UpdateTx(func(tx dataservices.DataStoreTx) error { + src := &portainer.Source{Type: portainer.SourceTypeGit, Git: &gittypes.GitSource{URL: "https://example.com"}} + err := tx.Source().Create(adminUserContext, src) + require.NoError(t, err) + srcID = src.ID + + wf := &portainer.Workflow{ + Artifacts: []portainer.Artifact{ + {EdgeStackID: 10, Files: []portainer.ArtifactFile{{SourceID: srcID, Hash: "hash-10"}}}, + {EdgeStackID: 20, Files: []portainer.ArtifactFile{{SourceID: srcID, Hash: "hash-20"}}}, + }, + } + err = tx.Workflow().Create(wf) + require.NoError(t, err) + workflowID = wf.ID + + return nil + }) + require.NoError(t, err) + + err = store.UpdateTx(func(tx dataservices.DataStoreTx) error { + return UpdateArtifactFileForEdgeStack(tx, workflowID, 10, srcID, func(a *portainer.ArtifactFile) { + a.Hash = "updated-hash-10" + }) + }) + require.NoError(t, err) + + wf, err := store.Workflow().Read(workflowID) + require.NoError(t, err) + require.Equal(t, "updated-hash-10", wf.Artifacts[0].Files[0].Hash) + require.Equal(t, "hash-20", wf.Artifacts[1].Files[0].Hash) +} + +func TestSaveWorkflowArtifact_SameSourceUpdatesArtifactOnly(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + var workflowID portainer.WorkflowID + var sourceID portainer.SourceID + + err := store.UpdateTx(func(tx dataservices.DataStoreTx) error { + src := &portainer.Source{ + Type: portainer.SourceTypeGit, + Git: &gittypes.GitSource{URL: "https://github.com/example/repo"}, + } + err := tx.Source().Create(adminUserContext, src) + require.NoError(t, err) + sourceID = src.ID + + wf := &portainer.Workflow{ + Artifacts: []portainer.Artifact{{ + StackID: 1, + Files: []portainer.ArtifactFile{{ + SourceID: sourceID, + Ref: "refs/heads/main", + }}, + }}, + } + err = tx.Workflow().Create(wf) + require.NoError(t, err) + workflowID = wf.ID + + return nil + }) + require.NoError(t, err) + + err = store.UpdateTx(func(tx dataservices.DataStoreTx) error { + return SaveWorkflowArtifact(tx, workflowID, func(a portainer.Artifact) bool { + return a.StackID == 1 + }, sourceID, portainer.ArtifactFile{ + SourceID: sourceID, + Ref: "refs/heads/dev", + Path: "compose.yml", + Hash: "new-hash", + }) + }) + require.NoError(t, err) + + wf, err := store.Workflow().Read(workflowID) + require.NoError(t, err) + require.Len(t, wf.Artifacts[0].Files, 1) + require.Equal(t, sourceID, wf.Artifacts[0].Files[0].SourceID) + require.Equal(t, "refs/heads/dev", wf.Artifacts[0].Files[0].Ref) + require.Equal(t, "compose.yml", wf.Artifacts[0].Files[0].Path) + require.Equal(t, "new-hash", wf.Artifacts[0].Files[0].Hash) +} + +func TestGitSourceAndArtifactForStack_MultipleArtifactsReturnsCorrectOne(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + var workflowID portainer.WorkflowID + err := store.UpdateTx(func(tx dataservices.DataStoreTx) error { + gitSrc := &portainer.Source{ + Type: portainer.SourceTypeGit, + Git: &gittypes.GitSource{URL: "https://github.com/example/shared-repo"}, + } + err := tx.Source().Create(adminUserContext, gitSrc) + require.NoError(t, err) + + wf := &portainer.Workflow{ + Artifacts: []portainer.Artifact{ + {StackID: 10, Files: []portainer.ArtifactFile{{SourceID: gitSrc.ID, Ref: "refs/heads/main", Hash: "hash-10"}}}, + {StackID: 20, Files: []portainer.ArtifactFile{{SourceID: gitSrc.ID, Ref: "refs/heads/dev", Hash: "hash-20"}}}, + }, + } + err = tx.Workflow().Create(wf) + require.NoError(t, err) + workflowID = wf.ID + + return nil + }) + require.NoError(t, err) + + var src *portainer.Source + var file *portainer.ArtifactFile + err = store.ViewTx(func(tx dataservices.DataStoreTx) error { + var txErr error + src, file, txErr = GitSourceAndArtifactForStack(tx, adminUserContext, workflowID, 20) + return txErr + }) + require.NoError(t, err) + require.NotNil(t, src) + require.NotNil(t, file) + require.Equal(t, "refs/heads/dev", file.Ref) + require.Equal(t, "hash-20", file.Hash) +} + +func TestGitSourceAndArtifactForEdgeStack_MultipleArtifactsReturnsCorrectOne(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + var workflowID portainer.WorkflowID + err := store.UpdateTx(func(tx dataservices.DataStoreTx) error { + gitSrc := &portainer.Source{ + Type: portainer.SourceTypeGit, + Git: &gittypes.GitSource{URL: "https://github.com/example/shared-edge-repo"}, + } + err := tx.Source().Create(adminUserContext, gitSrc) + require.NoError(t, err) + + wf := &portainer.Workflow{ + Artifacts: []portainer.Artifact{ + {EdgeStackID: 10, Files: []portainer.ArtifactFile{{SourceID: gitSrc.ID, Ref: "refs/heads/main", Hash: "hash-10"}}}, + {EdgeStackID: 20, Files: []portainer.ArtifactFile{{SourceID: gitSrc.ID, Ref: "refs/heads/dev", Hash: "hash-20"}}}, + }, + } + err = tx.Workflow().Create(wf) + require.NoError(t, err) + workflowID = wf.ID + + return nil + }) + require.NoError(t, err) + + var src *portainer.Source + var file *portainer.ArtifactFile + err = store.ViewTx(func(tx dataservices.DataStoreTx) error { + var txErr error + src, file, txErr = GitSourceAndArtifactForEdgeStack(tx, adminUserContext, workflowID, 20) + return txErr + }) + require.NoError(t, err) + require.NotNil(t, src) + require.NotNil(t, file) + require.Equal(t, "refs/heads/dev", file.Ref) + require.Equal(t, "hash-20", file.Hash) +} + +func TestMergeSourceAndFile_ConfigHashComesFromFileNotSource(t *testing.T) { + t.Parallel() + + src := &portainer.Source{ + Git: &gittypes.GitSource{URL: "https://github.com/example/repo"}, + } + file := &portainer.ArtifactFile{Hash: "artifact-hash"} + + cfg := MergeSourceAndFile(src, file) + require.NotNil(t, cfg) + require.Equal(t, "artifact-hash", cfg.ConfigHash) +} + +func TestUpdateSourceSyncStatus_HealthyBumpsLastSyncAndClearsError(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + var sourceID portainer.SourceID + err := store.UpdateTx(func(tx dataservices.DataStoreTx) error { + src := &portainer.Source{ + Type: portainer.SourceTypeGit, + Git: &gittypes.GitSource{URL: "https://example.com"}, + Status: portainer.SourceStatusError, + StatusError: "previous failure", + } + err := tx.Source().Create(adminUserContext, src) + require.NoError(t, err) + sourceID = src.ID + + return nil + }) + require.NoError(t, err) + + err = store.UpdateTx(func(tx dataservices.DataStoreTx) error { + return UpdateSourceSyncStatus(tx, adminUserContext, sourceID, portainer.SourceStatusHealthy, "") + }) + require.NoError(t, err) + + src, err := store.Source().Read(adminUserContext, sourceID) + require.NoError(t, err) + require.Equal(t, portainer.SourceStatusHealthy, src.Status) + require.Empty(t, src.StatusError) + require.NotZero(t, src.LastSync) +} + +func TestUpdateSourceSyncStatus_ErrorPreservesPriorLastSync(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + var sourceID portainer.SourceID + err := store.UpdateTx(func(tx dataservices.DataStoreTx) error { + src := &portainer.Source{ + Type: portainer.SourceTypeGit, + Git: &gittypes.GitSource{URL: "https://example.com"}, + LastSync: 12345, + } + err := tx.Source().Create(adminUserContext, src) + require.NoError(t, err) + sourceID = src.ID + + return nil + }) + require.NoError(t, err) + + err = store.UpdateTx(func(tx dataservices.DataStoreTx) error { + return UpdateSourceSyncStatus(tx, adminUserContext, sourceID, portainer.SourceStatusError, "git fetch failed") + }) + require.NoError(t, err) + + src, err := store.Source().Read(adminUserContext, sourceID) + require.NoError(t, err) + require.Equal(t, portainer.SourceStatusError, src.Status) + require.Equal(t, "git fetch failed", src.StatusError) + require.Equal(t, int64(12345), src.LastSync) +} + +func TestFindOrCreateGitSource_StripsEmbeddedCredentialsFromURL(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + var src *portainer.Source + err := store.UpdateTx(func(tx dataservices.DataStoreTx) error { + var txErr error + src, txErr = FindOrCreateGitSource(tx, adminUserContext, &portainer.Source{ + Type: portainer.SourceTypeGit, + Git: &gittypes.GitSource{ + URL: "https://user:secret@github.com/example/repo", + }, + }) + return txErr + }) + require.NoError(t, err) + require.Equal(t, "https://github.com/example/repo", src.Git.URL) +} diff --git a/api/gitops/workflows/status.go b/api/gitops/workflows/status.go new file mode 100644 index 0000000..d08adc7 --- /dev/null +++ b/api/gitops/workflows/status.go @@ -0,0 +1,162 @@ +package workflows + +import portainer "github.com/portainer/portainer/api" + +func SourceStatusToPhase(s portainer.SourceStatus, errMsg string) WorkflowPhaseStatus { + switch s { + case portainer.SourceStatusHealthy: + return WorkflowPhaseStatus{Status: StatusHealthy} + case portainer.SourceStatusError: + return WorkflowPhaseStatus{Status: StatusError, Error: errMsg} + default: + return WorkflowPhaseStatus{Status: StatusUnknown} + } +} + +// ArtifactPhases returns the source and artifact-path health phases for an artifact's files, +// aggregating the worst-priority status across all of its files. The source phase also folds in +// each file's Source connectivity status, since a broken source invalidates ref resolution +// regardless of the file's own cached RefStatus. +func ArtifactPhases(files []portainer.ArtifactFile, sourceMap map[portainer.SourceID]portainer.Source) (source, artifact WorkflowPhaseStatus) { + source = WorkflowPhaseStatus{Status: StatusUnknown} + artifact = WorkflowPhaseStatus{Status: StatusUnknown} + + for _, f := range files { + src, ok := sourceMap[f.SourceID] + if !ok { + continue + } + + if srcPhase := SourceStatusToPhase(src.Status, src.StatusError); statusPriority(srcPhase.Status) > statusPriority(source.Status) { + source = srcPhase + } + if refPhase := SourceStatusToPhase(f.RefStatus, f.RefError); statusPriority(refPhase.Status) > statusPriority(source.Status) { + source = refPhase + } + if artifactPhase := SourceStatusToPhase(f.PathStatus, f.PathError); statusPriority(artifactPhase.Status) > statusPriority(artifact.Status) { + artifact = artifactPhase + } + } + + return source, artifact +} + +func WorkflowPhaseToStatus(p WorkflowPhaseStatus) (portainer.SourceStatus, string) { + switch p.Status { + case StatusHealthy: + return portainer.SourceStatusHealthy, "" + case StatusError: + return portainer.SourceStatusError, p.Error + default: + return portainer.SourceStatusUnknown, "" + } +} + +func deriveStackTargetState(s portainer.Stack) WorkflowPhaseStatus { + if len(s.DeploymentStatus) == 0 { + return WorkflowPhaseStatus{Status: StatusHealthy} + } + last := s.DeploymentStatus[len(s.DeploymentStatus)-1] + switch last.Status { + case portainer.StackStatusActive: + return WorkflowPhaseStatus{Status: StatusHealthy} + case portainer.StackStatusError: + return WorkflowPhaseStatus{Status: StatusError, Error: last.Message} + case portainer.StackStatusDeploying: + return WorkflowPhaseStatus{Status: StatusSyncing} + case portainer.StackStatusInactive: + return WorkflowPhaseStatus{Status: StatusPaused} + default: + return WorkflowPhaseStatus{Status: StatusUnknown} + } +} + +func deriveEdgeStackTargetState(statuses []portainer.EdgeStackStatusForEnv) WorkflowPhaseStatus { + result := StatusUnknown + for _, epStatus := range statuses { + ws, msg := endpointWorkflowStatus(epStatus) + if ws == StatusError { + return WorkflowPhaseStatus{Status: ws, Error: msg} + } + if statusPriority(ws) > statusPriority(result) { + result = ws + } + } + return WorkflowPhaseStatus{Status: result} +} + +func endpointWorkflowStatus(epStatus portainer.EdgeStackStatusForEnv) (Status, string) { + if len(epStatus.Status) == 0 { + return StatusUnknown, "" + } + last := epStatus.Status[len(epStatus.Status)-1] + switch last.Type { + case portainer.EdgeStackStatusError: + return StatusError, last.Error + case portainer.EdgeStackStatusDeploying, + portainer.EdgeStackStatusRollingBack, + portainer.EdgeStackStatusRemoving, + portainer.EdgeStackStatusPending, + portainer.EdgeStackStatusDeploymentReceived, + portainer.EdgeStackStatusAcknowledged, + portainer.EdgeStackStatusImagesPulled: + return StatusSyncing, "" + case portainer.EdgeStackStatusPausedDeploying: + return StatusPaused, "" + case portainer.EdgeStackStatusRunning, + portainer.EdgeStackStatusRolledBack, + portainer.EdgeStackStatusCompleted, + portainer.EdgeStackStatusRemoved, + portainer.EdgeStackStatusRemoteUpdateSuccess: + return StatusHealthy, "" + default: + return StatusUnknown, "" + } +} + +// EffectiveStatus returns the highest-priority status across all three phases of a workflow. +func EffectiveStatus(w Workflow) Status { + s := w.Status.Target.Status + if statusPriority(w.Status.Source.Status) > statusPriority(s) { + s = w.Status.Source.Status + } + if statusPriority(w.Status.Artifact.Status) > statusPriority(s) { + s = w.Status.Artifact.Status + } + return s +} + +// CountByStatus counts workflows per effective status and returns a StatusSummary. +func CountByStatus(workflows []Workflow) StatusSummary { + var s StatusSummary + for _, w := range workflows { + switch EffectiveStatus(w) { + case StatusHealthy: + s.Healthy++ + case StatusSyncing: + s.Syncing++ + case StatusError: + s.Error++ + case StatusPaused: + s.Paused++ + default: + s.Unknown++ + } + } + return s +} + +func statusPriority(s Status) int { + switch s { + case StatusError: + return 4 + case StatusSyncing: + return 3 + case StatusPaused: + return 2 + case StatusHealthy: + return 1 + default: + return 0 + } +} diff --git a/api/gitops/workflows/status_test.go b/api/gitops/workflows/status_test.go new file mode 100644 index 0000000..d15f4ee --- /dev/null +++ b/api/gitops/workflows/status_test.go @@ -0,0 +1,289 @@ +package workflows + +import ( + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/stretchr/testify/assert" +) + +func TestEffectiveStatus(t *testing.T) { + t.Parallel() + + makeWorkflow := func(source, artifact, target Status) Workflow { + return Workflow{ + Status: WorkflowStatusObject{ + Source: WorkflowPhaseStatus{Status: source}, + Artifact: WorkflowPhaseStatus{Status: artifact}, + Target: WorkflowPhaseStatus{Status: target}, + }, + } + } + + cases := []struct { + name string + w Workflow + want Status + }{ + {"all healthy", makeWorkflow(StatusHealthy, StatusHealthy, StatusHealthy), StatusHealthy}, + {"all unknown", makeWorkflow(StatusUnknown, StatusUnknown, StatusUnknown), StatusUnknown}, + {"source error wins over syncing target", makeWorkflow(StatusError, StatusSyncing, StatusHealthy), StatusError}, + {"artifact error wins over syncing target", makeWorkflow(StatusHealthy, StatusError, StatusSyncing), StatusError}, + {"target error wins over healthy phases", makeWorkflow(StatusHealthy, StatusHealthy, StatusError), StatusError}, + {"syncing beats paused and healthy", makeWorkflow(StatusPaused, StatusSyncing, StatusHealthy), StatusSyncing}, + {"paused beats healthy", makeWorkflow(StatusHealthy, StatusPaused, StatusHealthy), StatusPaused}, + {"healthy beats unknown", makeWorkflow(StatusUnknown, StatusHealthy, StatusUnknown), StatusHealthy}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.want, EffectiveStatus(tc.w)) + }) + } +} + +func TestCountByStatus(t *testing.T) { + t.Parallel() + + makeW := func(s Status) Workflow { + return Workflow{ + Status: WorkflowStatusObject{ + Source: WorkflowPhaseStatus{Status: s}, + Artifact: WorkflowPhaseStatus{Status: s}, + Target: WorkflowPhaseStatus{Status: s}, + }, + } + } + + t.Run("empty list", func(t *testing.T) { + t.Parallel() + assert.Equal(t, StatusSummary{}, CountByStatus(nil)) + }) + + t.Run("single healthy", func(t *testing.T) { + t.Parallel() + assert.Equal(t, StatusSummary{Healthy: 1}, CountByStatus([]Workflow{makeW(StatusHealthy)})) + }) + + t.Run("mixed statuses", func(t *testing.T) { + t.Parallel() + workflows := []Workflow{ + makeW(StatusHealthy), + makeW(StatusError), + makeW(StatusSyncing), + makeW(StatusPaused), + makeW(StatusUnknown), + makeW(StatusError), + } + assert.Equal(t, StatusSummary{Healthy: 1, Error: 2, Syncing: 1, Paused: 1, Unknown: 1}, CountByStatus(workflows)) + }) + + t.Run("error phase overrides healthy target", func(t *testing.T) { + t.Parallel() + w := Workflow{ + Status: WorkflowStatusObject{ + Source: WorkflowPhaseStatus{Status: StatusError}, + Artifact: WorkflowPhaseStatus{Status: StatusUnknown}, + Target: WorkflowPhaseStatus{Status: StatusHealthy}, + }, + } + s := CountByStatus([]Workflow{w}) + assert.Equal(t, 1, s.Error) + assert.Equal(t, 0, s.Healthy) + }) +} + +func TestDeriveEdgeStackTargetState(t *testing.T) { + t.Parallel() + + ep := func(id portainer.EndpointID, typ portainer.EdgeStackStatusType) portainer.EdgeStackStatusForEnv { + return portainer.EdgeStackStatusForEnv{ + EndpointID: id, + Status: []portainer.EdgeStackDeploymentStatus{{Type: typ}}, + } + } + + cases := []struct { + name string + statuses []portainer.EdgeStackStatusForEnv + want Status + }{ + {"empty", nil, StatusUnknown}, + {"all per-env status slices empty", []portainer.EdgeStackStatusForEnv{{EndpointID: 1}}, StatusUnknown}, + {"running: healthy", []portainer.EdgeStackStatusForEnv{ep(1, portainer.EdgeStackStatusRunning)}, StatusHealthy}, + {"deploying: syncing", []portainer.EdgeStackStatusForEnv{ep(1, portainer.EdgeStackStatusDeploying)}, StatusSyncing}, + {"paused deploying: paused", []portainer.EdgeStackStatusForEnv{ep(1, portainer.EdgeStackStatusPausedDeploying)}, StatusPaused}, + {"error short-circuits", []portainer.EdgeStackStatusForEnv{ep(1, portainer.EdgeStackStatusError)}, StatusError}, + { + "error + running gives error (short-circuit, order matters)", + []portainer.EdgeStackStatusForEnv{ + ep(1, portainer.EdgeStackStatusError), + ep(2, portainer.EdgeStackStatusRunning), + }, + StatusError, + }, + { + "syncing beats paused", + []portainer.EdgeStackStatusForEnv{ + ep(1, portainer.EdgeStackStatusPausedDeploying), + ep(2, portainer.EdgeStackStatusDeploying), + }, + StatusSyncing, + }, + { + "healthy does not downgrade syncing", + []portainer.EdgeStackStatusForEnv{ + ep(1, portainer.EdgeStackStatusDeploying), + ep(2, portainer.EdgeStackStatusRunning), + }, + StatusSyncing, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + result := deriveEdgeStackTargetState(tc.statuses) + assert.Equal(t, tc.want, result.Status) + }) + } +} + +func TestArtifactPhases(t *testing.T) { + t.Parallel() + + src := func(id portainer.SourceID, status portainer.SourceStatus, statusError string) portainer.Source { + return portainer.Source{ID: id, Status: status, StatusError: statusError} + } + + file := func(sourceID portainer.SourceID, refStatus portainer.SourceStatus, refError string, pathStatus portainer.SourceStatus, pathError string) portainer.ArtifactFile { + return portainer.ArtifactFile{SourceID: sourceID, RefStatus: refStatus, RefError: refError, PathStatus: pathStatus, PathError: pathError} + } + + cases := []struct { + name string + files []portainer.ArtifactFile + sourceMap map[portainer.SourceID]portainer.Source + wantSource WorkflowPhaseStatus + wantArtifact WorkflowPhaseStatus + }{ + { + name: "no files", + files: nil, + sourceMap: map[portainer.SourceID]portainer.Source{}, + wantSource: WorkflowPhaseStatus{Status: StatusUnknown}, + wantArtifact: WorkflowPhaseStatus{Status: StatusUnknown}, + }, + { + name: "file's source missing from sourceMap is skipped", + files: []portainer.ArtifactFile{file(1, portainer.SourceStatusError, "unreachable", portainer.SourceStatusError, "not found")}, + sourceMap: map[portainer.SourceID]portainer.Source{ + 2: src(2, portainer.SourceStatusHealthy, ""), + }, + wantSource: WorkflowPhaseStatus{Status: StatusUnknown}, + wantArtifact: WorkflowPhaseStatus{Status: StatusUnknown}, + }, + { + name: "all healthy", + files: []portainer.ArtifactFile{file(1, portainer.SourceStatusHealthy, "", portainer.SourceStatusHealthy, "")}, + sourceMap: map[portainer.SourceID]portainer.Source{ + 1: src(1, portainer.SourceStatusHealthy, ""), + }, + wantSource: WorkflowPhaseStatus{Status: StatusHealthy}, + wantArtifact: WorkflowPhaseStatus{Status: StatusHealthy}, + }, + { + name: "source-level error dominates a healthy ref", + files: []portainer.ArtifactFile{file(1, portainer.SourceStatusHealthy, "", portainer.SourceStatusHealthy, "")}, + sourceMap: map[portainer.SourceID]portainer.Source{ + 1: src(1, portainer.SourceStatusError, "connection refused"), + }, + wantSource: WorkflowPhaseStatus{Status: StatusError, Error: "connection refused"}, + wantArtifact: WorkflowPhaseStatus{Status: StatusHealthy}, + }, + { + name: "file-level ref error dominates a healthy source", + files: []portainer.ArtifactFile{file(1, portainer.SourceStatusError, "ref not found", portainer.SourceStatusHealthy, "")}, + sourceMap: map[portainer.SourceID]portainer.Source{ + 1: src(1, portainer.SourceStatusHealthy, ""), + }, + wantSource: WorkflowPhaseStatus{Status: StatusError, Error: "ref not found"}, + wantArtifact: WorkflowPhaseStatus{Status: StatusHealthy}, + }, + { + name: "path error is independent of a broken source", + files: []portainer.ArtifactFile{file(1, portainer.SourceStatusHealthy, "", portainer.SourceStatusHealthy, "")}, + sourceMap: map[portainer.SourceID]portainer.Source{ + 1: src(1, portainer.SourceStatusError, "connection refused"), + }, + wantSource: WorkflowPhaseStatus{Status: StatusError, Error: "connection refused"}, + wantArtifact: WorkflowPhaseStatus{Status: StatusHealthy}, + }, + { + name: "path error surfaces on its own", + files: []portainer.ArtifactFile{file(1, portainer.SourceStatusHealthy, "", portainer.SourceStatusError, "path not found")}, + sourceMap: map[portainer.SourceID]portainer.Source{ + 1: src(1, portainer.SourceStatusHealthy, ""), + }, + wantSource: WorkflowPhaseStatus{Status: StatusHealthy}, + wantArtifact: WorkflowPhaseStatus{Status: StatusError, Error: "path not found"}, + }, + { + name: "tie between source and ref error keeps the source-level message", + files: []portainer.ArtifactFile{file(1, portainer.SourceStatusError, "ref not found", portainer.SourceStatusHealthy, "")}, + sourceMap: map[portainer.SourceID]portainer.Source{ + 1: src(1, portainer.SourceStatusError, "connection refused"), + }, + wantSource: WorkflowPhaseStatus{Status: StatusError, Error: "connection refused"}, + wantArtifact: WorkflowPhaseStatus{Status: StatusHealthy}, + }, + { + name: "worst artifact phase wins across multiple files", + files: []portainer.ArtifactFile{ + file(1, portainer.SourceStatusHealthy, "", portainer.SourceStatusHealthy, ""), + file(2, portainer.SourceStatusHealthy, "", portainer.SourceStatusError, "path not found"), + }, + sourceMap: map[portainer.SourceID]portainer.Source{ + 1: src(1, portainer.SourceStatusHealthy, ""), + 2: src(2, portainer.SourceStatusHealthy, ""), + }, + wantSource: WorkflowPhaseStatus{Status: StatusHealthy}, + wantArtifact: WorkflowPhaseStatus{Status: StatusError, Error: "path not found"}, + }, + { + name: "worst source phase wins across multiple files", + files: []portainer.ArtifactFile{ + file(1, portainer.SourceStatusHealthy, "", portainer.SourceStatusHealthy, ""), + file(2, portainer.SourceStatusError, "ref not found", portainer.SourceStatusHealthy, ""), + }, + sourceMap: map[portainer.SourceID]portainer.Source{ + 1: src(1, portainer.SourceStatusHealthy, ""), + 2: src(2, portainer.SourceStatusHealthy, ""), + }, + wantSource: WorkflowPhaseStatus{Status: StatusError, Error: "ref not found"}, + wantArtifact: WorkflowPhaseStatus{Status: StatusHealthy}, + }, + { + name: "mixed accessible and inaccessible files: only the accessible one drives the result", + files: []portainer.ArtifactFile{ + file(1, portainer.SourceStatusError, "should be ignored", portainer.SourceStatusError, "should be ignored"), + file(2, portainer.SourceStatusHealthy, "", portainer.SourceStatusHealthy, ""), + }, + sourceMap: map[portainer.SourceID]portainer.Source{ + 2: src(2, portainer.SourceStatusHealthy, ""), + }, + wantSource: WorkflowPhaseStatus{Status: StatusHealthy}, + wantArtifact: WorkflowPhaseStatus{Status: StatusHealthy}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + gotSource, gotArtifact := ArtifactPhases(tc.files, tc.sourceMap) + assert.Equal(t, tc.wantSource, gotSource) + assert.Equal(t, tc.wantArtifact, gotArtifact) + }) + } +} diff --git a/api/gitops/workflows/types.go b/api/gitops/workflows/types.go new file mode 100644 index 0000000..907d763 --- /dev/null +++ b/api/gitops/workflows/types.go @@ -0,0 +1,124 @@ +package workflows + +import ( + "fmt" + + portainer "github.com/portainer/portainer/api" + gittypes "github.com/portainer/portainer/api/git/types" + "github.com/portainer/portainer/api/gitops/sources" +) + +type Status string + +const ( + StatusHealthy Status = "healthy" + StatusSyncing Status = "syncing" + StatusError Status = "error" + StatusPaused Status = "paused" + StatusUnknown Status = "unknown" +) + +type Type string + +const ( + TypeStack Type = "stack" + TypeEdgeStack Type = "edgeStack" +) + +type DeploymentPlatform string + +const ( + DeploymentPlatformDockerStandalone DeploymentPlatform = "dockerStandalone" + DeploymentPlatformDockerSwarm DeploymentPlatform = "dockerSwarm" + DeploymentPlatformKubernetes DeploymentPlatform = "kubernetes" +) + +func ParseStatus(s string) (Status, error) { + switch Status(s) { + case StatusHealthy, StatusSyncing, StatusError, StatusPaused, StatusUnknown: + return Status(s), nil + } + return "", fmt.Errorf("unknown status %q", s) +} + +func ParseType(s string) (Type, error) { + switch Type(s) { + case TypeStack, TypeEdgeStack: + return Type(s), nil + } + return "", fmt.Errorf("unknown type %q", s) +} + +func ParsePlatform(s string) (DeploymentPlatform, error) { + switch DeploymentPlatform(s) { + case DeploymentPlatformDockerStandalone, DeploymentPlatformDockerSwarm, DeploymentPlatformKubernetes: + return DeploymentPlatform(s), nil + } + return "", fmt.Errorf("unknown platform %q", s) +} + +type Target struct { + EndpointID portainer.EndpointID `json:"endpointId,omitempty"` + Namespace string `json:"namespace,omitempty"` + EdgeGroupIDs []portainer.EdgeGroupID `json:"edgeGroupIds,omitempty"` + GroupStatus map[portainer.EdgeGroupID]Status `json:"groupStatus,omitempty"` + ResolvedEndpointIDs []portainer.EndpointID `json:"resolvedEndpointIds,omitempty"` +} + +// WorkflowPhaseStatus represents the status of one phase (source, artifact, or target) of a workflow. +// All three phases share the Status type; source and artifact only ever emit healthy, error, or unknown. +type WorkflowPhaseStatus struct { + Status Status `json:"status"` + Error string `json:"error,omitempty"` +} + +// WorkflowStatusObject is the structured status reported for a workflow. +type WorkflowStatusObject struct { + Source WorkflowPhaseStatus `json:"source"` + Artifact WorkflowPhaseStatus `json:"artifact"` + Target WorkflowPhaseStatus `json:"target"` +} + +type Workflow struct { + ID portainer.WorkflowID `json:"id" validate:"required"` + Name string `json:"name" validate:"required"` + Type Type `json:"type" validate:"required"` + Platform DeploymentPlatform `json:"platform" validate:"required"` + Status WorkflowStatusObject `json:"status" validate:"required"` + SourceID portainer.SourceID `json:"sourceId,omitempty"` + GitConfig *gittypes.RepoConfig `json:"gitConfig,omitempty"` + AutoUpdate *portainer.AutoUpdateSettings `json:"autoUpdate,omitempty"` + Target Target `json:"target" validate:"required"` + CreationDate int64 `json:"creationDate"` + LastSyncDate int64 `json:"lastSyncDate"` +} + +type StatusSummary struct { + Healthy int `json:"healthy"` + Syncing int `json:"syncing"` + Error int `json:"error"` + Paused int `json:"paused"` + Unknown int `json:"unknown"` +} + +// ArtifactDetail describes one Artifact's backing Stack or EdgeStack. +type ArtifactDetail struct { + ID int `json:"id" validate:"required"` + Type Type `json:"type" validate:"required"` + Name string `json:"name" validate:"required"` + Platform DeploymentPlatform `json:"platform"` + AutoUpdate *portainer.AutoUpdateSettings `json:"autoUpdate,omitempty"` + Target Target `json:"target"` + Status WorkflowStatusObject `json:"status"` + Files []ArtifactFileDetail `json:"files"` + CreationDate int64 `json:"creationDate"` + LastSyncDate int64 `json:"lastSyncDate"` +} + +// ArtifactFileDetail describe the representation of portainer.ArtifactFile used in API responses. +type ArtifactFileDetail struct { + portainer.ArtifactFile + + RefStatus sources.Status `json:"refStatus,omitempty"` + PathStatus sources.Status `json:"pathStatus,omitempty"` +} diff --git a/api/gitops/workflows/types_test.go b/api/gitops/workflows/types_test.go new file mode 100644 index 0000000..27fc3c3 --- /dev/null +++ b/api/gitops/workflows/types_test.go @@ -0,0 +1,65 @@ +package workflows + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseStatus(t *testing.T) { + t.Parallel() + + for _, valid := range []string{"healthy", "error", "syncing", "paused", "unknown"} { + t.Run(valid, func(t *testing.T) { + t.Parallel() + s, err := ParseStatus(valid) + require.NoError(t, err) + assert.Equal(t, Status(valid), s) + }) + } + + t.Run("invalid returns error", func(t *testing.T) { + t.Parallel() + _, err := ParseStatus("garbage") + assert.Error(t, err) + }) +} + +func TestParseType(t *testing.T) { + t.Parallel() + + for _, valid := range []string{"stack", "edgeStack"} { + t.Run(valid, func(t *testing.T) { + t.Parallel() + tp, err := ParseType(valid) + require.NoError(t, err) + assert.Equal(t, Type(valid), tp) + }) + } + + t.Run("invalid returns error", func(t *testing.T) { + t.Parallel() + _, err := ParseType("garbage") + assert.Error(t, err) + }) +} + +func TestParsePlatform(t *testing.T) { + t.Parallel() + + for _, valid := range []string{"dockerStandalone", "dockerSwarm", "kubernetes"} { + t.Run(valid, func(t *testing.T) { + t.Parallel() + p, err := ParsePlatform(valid) + require.NoError(t, err) + assert.Equal(t, DeploymentPlatform(valid), p) + }) + } + + t.Run("invalid returns error", func(t *testing.T) { + t.Parallel() + _, err := ParsePlatform("garbage") + assert.Error(t, err) + }) +} diff --git a/api/http/client/client.go b/api/http/client/client.go new file mode 100644 index 0000000..8b65777 --- /dev/null +++ b/api/http/client/client.go @@ -0,0 +1,160 @@ +package client + +import ( + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/crypto" + "github.com/portainer/portainer/pkg/libhttp/ssrf" + + "github.com/rs/zerolog/log" + "github.com/segmentio/encoding/json" +) + +var errInvalidResponseStatus = errors.New("invalid response status (expecting 200)") + +const defaultHTTPTimeout = 5 + +// HTTPClient represents a client to send HTTP requests. +type HTTPClient struct { + *http.Client +} + +// NewHTTPClient is used to build a new HTTPClient. +func NewHTTPClient() *HTTPClient { + return &HTTPClient{ + &http.Client{ + Timeout: time.Second * time.Duration(defaultHTTPTimeout), + }, + } +} + +// AzureAuthenticationResponse represents an Azure API authentication response. +type AzureAuthenticationResponse struct { + AccessToken string `json:"access_token"` + ExpiresOn string `json:"expires_on"` +} + +// ExecuteAzureAuthenticationRequest is used to execute an authentication request +// against the Azure API. It re-uses the same http.Client. +func (client *HTTPClient) ExecuteAzureAuthenticationRequest(credentials *portainer.AzureCredentials) (*AzureAuthenticationResponse, error) { + loginURL := fmt.Sprintf("https://login.microsoftonline.com/%s/oauth2/token", credentials.TenantID) + params := url.Values{ + "grant_type": {"client_credentials"}, + "client_id": {credentials.ApplicationID}, + "client_secret": {credentials.AuthenticationKey}, + "resource": {"https://management.azure.com/"}, + } + + resp, err := client.PostForm(loginURL, params) + if err != nil { + return nil, err + } + defer func() { + if err := resp.Body.Close(); err != nil { + log.Warn().Err(err).Msg("failed to close response body") + } + }() + + if resp.StatusCode != http.StatusOK { + return nil, errors.New("invalid Azure credentials") + } + + var token AzureAuthenticationResponse + err = json.NewDecoder(resp.Body).Decode(&token) + if err != nil { + return nil, err + } + + return &token, nil +} + +// Get executes a simple HTTP GET to the specified URL and returns +// the content of the response body. Timeout can be specified via the timeout parameter, +// will default to defaultHTTPTimeout if set to 0. +func Get(url string, timeout int) ([]byte, error) { + if timeout == 0 { + timeout = defaultHTTPTimeout + } + + client := &http.Client{ + Timeout: time.Second * time.Duration(timeout), + } + + response, err := client.Get(url) + if err != nil { + return nil, err + } + defer func() { + if err := response.Body.Close(); err != nil { + log.Warn().Err(err).Msg("failed to close response body") + } + }() + + if response.StatusCode != http.StatusOK { + log.Error().Int("status_code", response.StatusCode).Msg("unexpected status code") + + return nil, errInvalidResponseStatus + } + + body, err := io.ReadAll(response.Body) + if err != nil { + return nil, err + } + + return body, nil +} + +// ExecutePingOperation will send a SystemPing operation HTTP request to a Docker environment(endpoint) +// using the specified host and optional TLS configuration. +// It uses a new Http.Client for each operation. +func ExecutePingOperation(host string, tlsConfiguration portainer.TLSConfiguration) (bool, error) { + scheme := "http" + + var transport *http.Transport + if tlsConfiguration.TLS { + tlsConfig, err := crypto.CreateTLSConfigurationFromDisk(tlsConfiguration) + if err != nil { + return false, err + } + + scheme = "https" + transport = ssrf.NewTransport(tlsConfig) + } else { + transport = ssrf.NewTransport(nil) + } + + client := &http.Client{ + Timeout: 3 * time.Second, + Transport: transport, + } + + target := strings.Replace(host, "tcp://", scheme+"://", 1) + + return pingOperation(client, target) +} + +func pingOperation(client *http.Client, target string) (bool, error) { + pingOperationURL := target + "/_ping" + + resp, err := client.Get(pingOperationURL) + if err != nil { + return false, err + } + + _, _ = io.Copy(io.Discard, resp.Body) + + if err := resp.Body.Close(); err != nil { + log.Warn().Err(err).Msg("failed to close response body") + } + + agentOnDockerEnvironment := resp.Header.Get(portainer.PortainerAgentHeader) != "" + + return agentOnDockerEnvironment, nil +} diff --git a/api/http/client/client_test.go b/api/http/client/client_test.go new file mode 100644 index 0000000..be328e4 --- /dev/null +++ b/api/http/client/client_test.go @@ -0,0 +1,49 @@ +package client + +import ( + "net/http" + "net/http/httptest" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/pkg/fips" + + "github.com/stretchr/testify/require" +) + +func TestExecutePingOperationFailure(t *testing.T) { + t.Parallel() + fips.InitFIPS(false) + + host := "http://localhost:1" + config := portainer.TLSConfiguration{ + TLS: true, + TLSSkipVerify: true, + } + + // Invalid host + ok, err := ExecutePingOperation(host, config) + require.False(t, ok) + require.Error(t, err) + + // Invalid TLS configuration + config.TLSCertPath = "/invalid/path/to/cert" + config.TLSKeyPath = "/invalid/path/to/key" + + ok, err = ExecutePingOperation(host, config) + require.False(t, ok) + require.Error(t, err) + +} + +func TestPingOperation(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Add(portainer.PortainerAgentHeader, "1") + })) + defer srv.Close() + + agentOnDockerEnv, err := pingOperation(http.DefaultClient, srv.URL) + require.NoError(t, err) + require.True(t, agentOnDockerEnv) +} diff --git a/api/http/csrf/csrf.go b/api/http/csrf/csrf.go new file mode 100644 index 0000000..d71093b --- /dev/null +++ b/api/http/csrf/csrf.go @@ -0,0 +1,48 @@ +package csrf + +import ( + "fmt" + "net/http" + "os" + + "github.com/rs/zerolog/log" +) + +func WithProtect(handler http.Handler, trustedOrigins []string) (http.Handler, error) { + // DOCKER_EXTENSION=1 is set in build/docker-extension/docker-compose.yml + isDockerDesktopExtension := false + if val, ok := os.LookupEnv("DOCKER_EXTENSION"); ok && val == "1" { + isDockerDesktopExtension = true + } + + cop := http.NewCrossOriginProtection() + for _, origin := range trustedOrigins { + if err := cop.AddTrustedOrigin(origin); err != nil { + return nil, fmt.Errorf("failed to add trusted origin %q: %w", origin, err) + } + } + + cop.SetDenyHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + log.Error().Err(cop.Check(r)). + Str("request_url", r.URL.String()). + Str("host", r.Host). + Str("origin", r.Header.Get("Origin")). + Str("sec_fetch_site", r.Header.Get("Sec-Fetch-Site")). + Strs("trusted_origins", trustedOrigins). + Msg("CSRF check failed") + + http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden) + })) + + protected := cop.Handler(handler) + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if isDockerDesktopExtension { + handler.ServeHTTP(w, r) + + return + } + + protected.ServeHTTP(w, r) + }), nil +} diff --git a/api/http/csrf/csrf_test.go b/api/http/csrf/csrf_test.go new file mode 100644 index 0000000..34d1d9c --- /dev/null +++ b/api/http/csrf/csrf_test.go @@ -0,0 +1,170 @@ +package csrf + +import ( + "net/http" + "net/http/httptest" + "testing" + + portainer "github.com/portainer/portainer/api" + + "github.com/stretchr/testify/require" +) + +var okHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) +}) + +func TestWithProtect_invalidTrustedOriginReturnsError(t *testing.T) { + t.Parallel() + + _, err := WithProtect(okHandler, []string{"not-a-valid-origin"}) + require.Error(t, err) +} + +func TestWithProtect_safeMethodsAlwaysAllowed(t *testing.T) { + t.Parallel() + + handler, err := WithProtect(okHandler, nil) + require.NoError(t, err) + + for _, method := range []string{http.MethodGet, http.MethodHead, http.MethodOptions} { + req := httptest.NewRequest(method, "/", nil) + req.Header.Set("Sec-Fetch-Site", "cross-site") + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + require.Equal(t, http.StatusOK, rr.Code, "method %s should be allowed", method) + } +} + +func TestWithProtect_allowsPostWithNoOriginHeaders(t *testing.T) { + t.Parallel() + + handler, err := WithProtect(okHandler, nil) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodPost, "/", nil) + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + require.Equal(t, http.StatusOK, rr.Code) +} + +func TestWithProtect_allowsPostWithSameOriginSecFetchSite(t *testing.T) { + t.Parallel() + + handler, err := WithProtect(okHandler, nil) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodPost, "/", nil) + req.Header.Set("Sec-Fetch-Site", "same-origin") + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + require.Equal(t, http.StatusOK, rr.Code) +} + +func TestWithProtect_allowsPostWithNoneSecFetchSite(t *testing.T) { + t.Parallel() + + handler, err := WithProtect(okHandler, nil) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodPost, "/", nil) + req.Header.Set("Sec-Fetch-Site", "none") + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + require.Equal(t, http.StatusOK, rr.Code) +} + +func TestWithProtect_blocksCrossSiteSecFetchSite(t *testing.T) { + t.Parallel() + + handler, err := WithProtect(okHandler, nil) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodPost, "/", nil) + req.Header.Set("Sec-Fetch-Site", "cross-site") + req.AddCookie(&http.Cookie{Name: portainer.AuthCookieKey, Value: "some-token"}) + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + require.Equal(t, http.StatusForbidden, rr.Code) +} + +func TestWithProtect_blocksSameSiteSecFetchSite(t *testing.T) { + t.Parallel() + + handler, err := WithProtect(okHandler, nil) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodPost, "/", nil) + req.Header.Set("Sec-Fetch-Site", "same-site") + req.AddCookie(&http.Cookie{Name: portainer.AuthCookieKey, Value: "some-token"}) + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + require.Equal(t, http.StatusForbidden, rr.Code) +} + +func TestWithProtect_allowsPostWithMatchingOriginHeader(t *testing.T) { + t.Parallel() + + handler, err := WithProtect(okHandler, nil) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodPost, "/", nil) + req.Host = "portainer.example.com" + req.Header.Set("Origin", "https://portainer.example.com") + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + require.Equal(t, http.StatusOK, rr.Code) +} + +func TestWithProtect_blocksMismatchedOriginHeader(t *testing.T) { + t.Parallel() + + handler, err := WithProtect(okHandler, nil) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodPost, "/", nil) + req.Host = "portainer.example.com" + req.Header.Set("Origin", "https://evil.example.com") + req.AddCookie(&http.Cookie{Name: portainer.AuthCookieKey, Value: "some-token"}) + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + require.Equal(t, http.StatusForbidden, rr.Code) +} + +func TestWithProtect_allowsPostFromTrustedOrigin(t *testing.T) { + t.Parallel() + + handler, err := WithProtect(okHandler, []string{"https://trusted.example.com"}) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodPost, "/", nil) + req.Host = "portainer.example.com" + req.Header.Set("Origin", "https://trusted.example.com") + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + require.Equal(t, http.StatusOK, rr.Code) +} + +func TestWithProtect_enforcesCsrfForCookieAuth(t *testing.T) { + t.Parallel() + + handler, err := WithProtect(okHandler, nil) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodPost, "/", nil) + req.Header.Set("Sec-Fetch-Site", "cross-site") + req.AddCookie(&http.Cookie{Name: portainer.AuthCookieKey, Value: "some-token"}) + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + require.Equal(t, http.StatusForbidden, rr.Code) +} diff --git a/api/http/errors/conflict.go b/api/http/errors/conflict.go new file mode 100644 index 0000000..cc2d662 --- /dev/null +++ b/api/http/errors/conflict.go @@ -0,0 +1,20 @@ +package errors + +import "errors" + +type ConflictError struct { + msg string +} + +func (e *ConflictError) Error() string { + return e.msg +} + +func NewConflictError(msg string) *ConflictError { + return &ConflictError{msg: msg} +} + +func IsConflictError(err error) bool { + var conflictError *ConflictError + return errors.As(err, &conflictError) +} diff --git a/api/http/errors/errors.go b/api/http/errors/errors.go new file mode 100644 index 0000000..df896fd --- /dev/null +++ b/api/http/errors/errors.go @@ -0,0 +1,12 @@ +package errors + +import "errors" + +var ( + // ErrEndpointAccessDenied Access denied to environment(endpoint) error + ErrEndpointAccessDenied = errors.New("Access denied to environment") + // 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/errors/invalidpayload.go b/api/http/errors/invalidpayload.go new file mode 100644 index 0000000..29a6a75 --- /dev/null +++ b/api/http/errors/invalidpayload.go @@ -0,0 +1,20 @@ +package errors + +import "errors" + +type InvalidPayloadError struct { + msg string +} + +func (e *InvalidPayloadError) Error() string { + return e.msg +} + +func NewInvalidPayloadError(msg string) *InvalidPayloadError { + return &InvalidPayloadError{msg: msg} +} + +func IsInvalidPayloadError(err error) bool { + var payloadError *InvalidPayloadError + return errors.As(err, &payloadError) +} diff --git a/api/http/handler/auth/authenticate.go b/api/http/handler/auth/authenticate.go new file mode 100644 index 0000000..45b99c1 --- /dev/null +++ b/api/http/handler/auth/authenticate.go @@ -0,0 +1,242 @@ +package auth + +import ( + "net/http" + "strconv" + "strings" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + httperrors "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/api/http/middlewares" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/authorization" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" + + "github.com/pkg/errors" + "github.com/rs/zerolog/log" +) + +type authenticatePayload struct { + // Username + Username string `example:"admin" validate:"required"` + // Password + Password string `example:"mypassword" validate:"required"` +} + +type authenticateResponse struct { + // JWT token used to authenticate against the API + JWT string `json:"jwt" example:"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzAB"` +} + +func (payload *authenticatePayload) Validate(r *http.Request) error { + if len(payload.Username) == 0 { + return errors.New("Invalid username") + } + + if len(payload.Password) == 0 { + return errors.New("Invalid password") + } + + return nil +} + +// @id AuthenticateUser +// @summary Authenticate +// @description **Access policy**: public +// @description Use this environment(endpoint) to authenticate against Portainer using a username and password. +// @tags auth +// @accept json +// @produce json +// @param body body authenticatePayload true "Credentials used for authentication" +// @success 200 {object} authenticateResponse "Success" +// @failure 400 "Invalid request" +// @failure 422 "Invalid Credentials" +// @failure 500 "Server error" +// @router /auth [post] +func (handler *Handler) authenticate(rw http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload authenticatePayload + if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + var settings *portainer.Settings + if err := handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error { + var err error + settings, err = tx.Settings().Settings() + return err + }); err != nil { + return httperror.InternalServerError("Unable to retrieve settings from the database", err) + } + + user, err := handler.DataStore.User().UserByUsername(payload.Username) + if err != nil { + if !handler.DataStore.IsErrObjectNotFound(err) { + return httperror.InternalServerError("Unable to retrieve a user with the specified username from the database", err) + } + + if settings.AuthenticationMethod == portainer.AuthenticationInternal || + settings.AuthenticationMethod == portainer.AuthenticationOAuth || + (settings.AuthenticationMethod == portainer.AuthenticationLDAP && !settings.LDAPSettings.AutoCreateUsers) { + // avoid username enumeration timing attack by creating a fake user + // https://en.wikipedia.org/wiki/Timing_attack + user = &portainer.User{ + Username: "portainer-fake-username", + Password: "$2a$10$abcdefghijklmnopqrstuvwx..ABCDEFGHIJKLMNOPQRSTUVWXYZ12", // fake but valid format bcrypt hash + } + } + } + + // Clear any existing user caches + if user != nil { + handler.KubernetesClientFactory.ClearUserClientCache(strconv.Itoa(int(user.ID))) + } + + if user != nil && isUserInitialAdmin(user) || settings.AuthenticationMethod == portainer.AuthenticationInternal { + return handler.authenticateInternal(rw, r, user, payload.Password, settings.ForceSecureCookies) + } + + if settings.AuthenticationMethod == portainer.AuthenticationOAuth { + return httperror.NewError(http.StatusUnprocessableEntity, "Only initial admin is allowed to login without oauth", httperrors.ErrUnauthorized) + } + + if settings.AuthenticationMethod == portainer.AuthenticationLDAP { + return handler.authenticateLDAP(rw, r, user, payload.Username, payload.Password, &settings.LDAPSettings, settings.ForceSecureCookies) + } + + return httperror.NewError(http.StatusUnprocessableEntity, "Login method is not supported", httperrors.ErrUnauthorized) +} + +func isUserInitialAdmin(user *portainer.User) bool { + return int(user.ID) == 1 +} + +func (handler *Handler) authenticateInternal(w http.ResponseWriter, r *http.Request, user *portainer.User, password string, forceSecureCookies bool) *httperror.HandlerError { + if err := handler.CryptoService.CompareHashAndData(user.Password, password); err != nil { + return httperror.NewError(http.StatusUnprocessableEntity, "Invalid credentials", httperrors.ErrUnauthorized) + } + + forceChangePassword := !handler.passwordStrengthChecker.Check(password) + + return handler.writeToken(w, r, user, forceChangePassword, forceSecureCookies) +} + +func (handler *Handler) authenticateLDAP(w http.ResponseWriter, r *http.Request, user *portainer.User, username, password string, ldapSettings *portainer.LDAPSettings, forceSecureCookies bool) *httperror.HandlerError { + if err := handler.LDAPService.AuthenticateUser(username, password, ldapSettings); err != nil { + if errors.Is(err, httperrors.ErrUnauthorized) { + return httperror.NewError(http.StatusUnprocessableEntity, "Invalid credentials", httperrors.ErrUnauthorized) + } + + return httperror.InternalServerError("Unable to authenticate user against LDAP", err) + } + + if user == nil { + user = &portainer.User{ + Username: username, + Role: portainer.StandardUserRole, + PortainerAuthorizations: authorization.DefaultPortainerAuthorizations(), + } + + if err := handler.DataStore.User().Create(user); err != nil { + return httperror.InternalServerError("Unable to persist user inside the database", err) + } + } + + if err := handler.syncUserTeamsWithLDAPGroups(user, ldapSettings); err != nil { + log.Warn().Err(err).Msg("unable to automatically sync user teams with ldap") + } + + return handler.writeToken(w, r, user, false, forceSecureCookies) +} + +func (handler *Handler) writeToken(w http.ResponseWriter, r *http.Request, user *portainer.User, forceChangePassword bool, forceSecureCookies bool) *httperror.HandlerError { + tokenData := composeTokenData(user, forceChangePassword) + + return handler.persistAndWriteToken(w, r, tokenData, forceSecureCookies) +} + +func (handler *Handler) persistAndWriteToken(w http.ResponseWriter, r *http.Request, tokenData *portainer.TokenData, forceSecureCookies bool) *httperror.HandlerError { + token, expirationTime, err := handler.JWTService.GenerateToken(tokenData) + if err != nil { + return httperror.InternalServerError("Unable to generate JWT token", err) + } + + security.AddAuthCookie(w, token, expirationTime, handler.isSecureCookie(r, forceSecureCookies)) + + return response.JSON(w, &authenticateResponse{JWT: token}) +} + +func (handler *Handler) isSecureCookie(r *http.Request, forceSecureCookies bool) bool { + return r.TLS != nil || middlewares.IsHTTPSRequest(r) || forceSecureCookies +} + +func (handler *Handler) syncUserTeamsWithLDAPGroups(user *portainer.User, settings *portainer.LDAPSettings) error { + // only sync if there is a group base DN + if len(settings.GroupSearchSettings) == 0 || len(settings.GroupSearchSettings[0].GroupBaseDN) == 0 { + return nil + } + + teams, err := handler.DataStore.Team().ReadAll() + if err != nil { + return err + } + + userGroups, err := handler.LDAPService.GetUserGroups(user.Username, settings) + if err != nil { + return err + } + + userMemberships, err := handler.DataStore.TeamMembership().TeamMembershipsByUserID(user.ID) + if err != nil { + return err + } + + for _, team := range teams { + if !teamExists(team.Name, userGroups) || teamMembershipExists(team.ID, userMemberships) { + continue + } + + membership := &portainer.TeamMembership{ + UserID: user.ID, + TeamID: team.ID, + Role: portainer.TeamMember, + } + + if err := handler.DataStore.TeamMembership().Create(membership); err != nil { + return err + } + } + + return nil +} + +func teamExists(teamName string, ldapGroups []string) bool { + for _, group := range ldapGroups { + if strings.EqualFold(group, teamName) { + return true + } + } + + return false +} + +func teamMembershipExists(teamID portainer.TeamID, memberships []portainer.TeamMembership) bool { + for _, membership := range memberships { + if membership.TeamID == teamID { + return true + } + } + + return false +} + +func composeTokenData(user *portainer.User, forceChangePassword bool) *portainer.TokenData { + return &portainer.TokenData{ + ID: user.ID, + Username: user.Username, + Role: user.Role, + ForceChangePassword: forceChangePassword, + } +} diff --git a/api/http/handler/auth/authenticate_oauth.go b/api/http/handler/auth/authenticate_oauth.go new file mode 100644 index 0000000..6760fec --- /dev/null +++ b/api/http/handler/auth/authenticate_oauth.go @@ -0,0 +1,122 @@ +package auth + +import ( + "context" + "errors" + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + httperrors "github.com/portainer/portainer/api/http/errors" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + + "github.com/rs/zerolog/log" +) + +type oauthPayload struct { + // OAuth code returned from OAuth Provided + Code string +} + +func (payload *oauthPayload) Validate(r *http.Request) error { + if len(payload.Code) == 0 { + return errors.New("Invalid OAuth authorization code") + } + + return nil +} + +func (handler *Handler) authenticateOAuth(ctx context.Context, code string, settings *portainer.OAuthSettings) (string, error) { + if code == "" { + return "", errors.New("Invalid OAuth authorization code") + } + + if settings == nil { + return "", errors.New("Invalid OAuth configuration") + } + + username, err := handler.OAuthService.Authenticate(ctx, code, settings) + if err != nil { + return "", err + } + + return username, nil +} + +// @id ValidateOAuth +// @summary Authenticate with OAuth +// @description **Access policy**: public +// @tags auth +// @accept json +// @produce json +// @param body body oauthPayload true "OAuth Credentials used for authentication" +// @success 200 {object} authenticateResponse "Success" +// @failure 400 "Invalid request" +// @failure 422 "Invalid Credentials" +// @failure 500 "Server error" +// @router /auth/oauth/validate [post] +func (handler *Handler) validateOAuth(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload oauthPayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + var settings *portainer.Settings + if err := handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error { + var err error + settings, err = tx.Settings().Settings() + return err + }); err != nil { + return httperror.InternalServerError("Unable to retrieve settings from the database", err) + } + + if settings.AuthenticationMethod != portainer.AuthenticationOAuth { + return httperror.Forbidden("OAuth authentication is not enabled", errors.New("OAuth authentication is not enabled")) + } + + username, err := handler.authenticateOAuth(r.Context(), payload.Code, &settings.OAuthSettings) + if err != nil { + log.Debug().Err(err).Msg("OAuth authentication error") + + return httperror.InternalServerError("Unable to authenticate through OAuth", httperrors.ErrUnauthorized) + } + + user, err := handler.DataStore.User().UserByUsername(username) + if err != nil && !handler.DataStore.IsErrObjectNotFound(err) { + return httperror.InternalServerError("Unable to retrieve a user with the specified username from the database", err) + } + + if user == nil && !settings.OAuthSettings.OAuthAutoCreateUsers { + return httperror.Forbidden("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, + } + + err = handler.DataStore.User().Create(user) + if err != nil { + return httperror.InternalServerError("Unable to persist user inside the database", err) + } + + if settings.OAuthSettings.DefaultTeamID != 0 { + membership := &portainer.TeamMembership{ + UserID: user.ID, + TeamID: settings.OAuthSettings.DefaultTeamID, + Role: portainer.TeamMember, + } + + err = handler.DataStore.TeamMembership().Create(membership) + if err != nil { + return httperror.InternalServerError("Unable to persist team membership inside the database", err) + } + } + + } + + return handler.writeToken(w, r, user, false, settings.ForceSecureCookies) +} diff --git a/api/http/handler/auth/handler.go b/api/http/handler/auth/handler.go new file mode 100644 index 0000000..035ceab --- /dev/null +++ b/api/http/handler/auth/handler.go @@ -0,0 +1,49 @@ +package auth + +import ( + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/http/proxy" + "github.com/portainer/portainer/api/http/proxy/factory/kubernetes" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/kubernetes/cli" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + + "github.com/gorilla/mux" +) + +// Handler is the HTTP handler used to handle authentication operations. +type Handler struct { + *mux.Router + DataStore dataservices.DataStore + CryptoService portainer.CryptoService + JWTService portainer.JWTService + LDAPService portainer.LDAPService + OAuthService portainer.OAuthService + ProxyManager *proxy.Manager + KubernetesTokenCacheManager *kubernetes.TokenCacheManager + KubernetesClientFactory *cli.ClientFactory + passwordStrengthChecker security.PasswordStrengthChecker + bouncer security.BouncerService +} + +// NewHandler creates a handler to manage authentication operations. +func NewHandler(bouncer security.BouncerService, rateLimiter *security.RateLimiter, passwordStrengthChecker security.PasswordStrengthChecker, kubernetesClientFactory *cli.ClientFactory) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + passwordStrengthChecker: passwordStrengthChecker, + bouncer: bouncer, + KubernetesClientFactory: kubernetesClientFactory, + } + + 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.PublicAccess(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 0000000..82a5df0 --- /dev/null +++ b/api/http/handler/auth/logout.go @@ -0,0 +1,46 @@ +package auth + +import ( + "net/http" + "strconv" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/logoutcontext" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +// @id Logout +// @summary Logout +// @description **Access policy**: public +// @security ApiKeyAuth +// @security jwt +// @tags auth +// @success 204 "Success" +// @failure 500 "Server error" +// @router /auth/logout [post] +func (handler *Handler) logout(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + tokenData, _ := handler.bouncer.CookieAuthLookup(r) + + if tokenData != nil { + handler.KubernetesTokenCacheManager.RemoveUserFromCache(tokenData.ID) + handler.KubernetesClientFactory.ClearUserClientCache(strconv.Itoa(int(tokenData.ID))) + logoutcontext.Cancel(tokenData.Token) + handler.bouncer.RevokeJWT(tokenData.Token) + } + + var settings *portainer.Settings + if err := handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error { + var err error + settings, err = tx.Settings().Settings() + return err + }); err != nil { + return httperror.InternalServerError("Unable to retrieve settings from the database", err) + } + + security.RemoveAuthCookie(w, handler.isSecureCookie(r, settings.ForceSecureCookies)) + + return response.Empty(w) +} diff --git a/api/http/handler/auth/logout_test.go b/api/http/handler/auth/logout_test.go new file mode 100644 index 0000000..318370f --- /dev/null +++ b/api/http/handler/auth/logout_test.go @@ -0,0 +1,109 @@ +package auth + +import ( + "net/http" + "net/http/httptest" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/http/proxy/factory/kubernetes" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/testhelpers" + "github.com/portainer/portainer/api/kubernetes/cli" + + "github.com/stretchr/testify/require" +) + +type mockBouncer struct { + security.BouncerService +} + +func NewMockBouncer() *mockBouncer { + return &mockBouncer{BouncerService: testhelpers.NewTestRequestBouncer()} +} + +func (*mockBouncer) CookieAuthLookup(r *http.Request) (*portainer.TokenData, error) { + return &portainer.TokenData{ + ID: 1, + Username: "testuser", + Token: "valid-token", + }, nil +} + +func TestLogout(t *testing.T) { + t.Parallel() + h := NewHandler(NewMockBouncer(), nil, nil, nil) + h.KubernetesTokenCacheManager = kubernetes.NewTokenCacheManager() + _, h.DataStore = datastore.MustNewTestStore(t, true, false) + k, err := cli.NewClientFactory(nil, nil, nil, "", "", "") + require.NoError(t, err) + h.KubernetesClientFactory = k + + rr := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/auth/logout", nil) + + h.ServeHTTP(rr, req) + require.Equal(t, http.StatusNoContent, rr.Code) +} + +func TestLogoutNoPanic(t *testing.T) { + t.Parallel() + h := NewHandler(testhelpers.NewTestRequestBouncer(), nil, nil, nil) + _, h.DataStore = datastore.MustNewTestStore(t, true, false) + + rr := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/auth/logout", nil) + + h.ServeHTTP(rr, req) + require.Equal(t, http.StatusNoContent, rr.Code) +} + +func TestLogout_ClearsCookie(t *testing.T) { + tests := []struct { + name string + forceSecureCookies bool + wantSecure bool + }{ + {name: "clears cookie without secure flag", forceSecureCookies: false, wantSecure: false}, + {name: "clears cookie with secure flag when ForceSecureCookies is set", forceSecureCookies: true, wantSecure: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + h := NewHandler(NewMockBouncer(), nil, nil, nil) + h.KubernetesTokenCacheManager = kubernetes.NewTokenCacheManager() + _, h.DataStore = datastore.MustNewTestStore(t, true, false) + k, err := cli.NewClientFactory(nil, nil, nil, "", "", "") + require.NoError(t, err) + h.KubernetesClientFactory = k + + err = h.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + return tx.Settings().UpdateSettings(&portainer.Settings{ForceSecureCookies: tc.forceSecureCookies}) + }) + require.NoError(t, err) + + rr := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/auth/logout", nil) + + h.ServeHTTP(rr, req) + require.Equal(t, http.StatusNoContent, rr.Code) + + cookies := rr.Result().Cookies() + var authCookie *http.Cookie + for _, c := range cookies { + if c.Name == portainer.AuthCookieKey { + authCookie = c + break + } + } + require.NotNil(t, authCookie, "expected auth cookie to be present in response so the browser can clear it") + require.Empty(t, authCookie.Value) + require.Equal(t, -1, authCookie.MaxAge) + require.Equal(t, tc.wantSecure, authCookie.Secure) + }) + } +} diff --git a/api/http/handler/backup/backup.go b/api/http/handler/backup/backup.go new file mode 100644 index 0000000..b4a89a0 --- /dev/null +++ b/api/http/handler/backup/backup.go @@ -0,0 +1,58 @@ +package backup + +import ( + "net/http" + "os" + "path/filepath" + + operations "github.com/portainer/portainer/api/backup" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/rs/zerolog/log" +) + +type ( + backupPayload struct { + Password string + } +) + +func (p *backupPayload) Validate(r *http.Request) error { + return nil +} + +// @id Backup +// @summary Creates an archive with a system data snapshot that could be used to restore the system. +// @description Creates an archive with a system data snapshot that could be used to restore the system. +// @description **Access policy**: admin +// @tags backup +// @security ApiKeyAuth +// @security jwt +// @accept json +// @produce octet-stream +// @param body body backupPayload false "An object contains the password to encrypt the backup with" +// @success 200 "Success" +// @failure 400 "Invalid request" +// @failure 500 "Server error" +// @router /backup [post] +func (h *Handler) backup(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload backupPayload + if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + archivePath, err := operations.CreateBackupArchive(payload.Password, h.gate, h.dataStore, h.filestorePath) + if err != nil { + return httperror.InternalServerError("Failed to create backup", err) + } + defer func() { + if err := os.RemoveAll(filepath.Dir(archivePath)); err != nil { + log.Warn().Err(err).Msg("failed to remove backup temp folder") + } + }() + + w.Header().Set("Content-Disposition", "attachment; filename=portainer-backup_"+filepath.Base(archivePath)) + http.ServeFile(w, r, archivePath) + + return nil +} diff --git a/api/http/handler/backup/backup_test.go b/api/http/handler/backup/backup_test.go new file mode 100644 index 0000000..05b5d7e --- /dev/null +++ b/api/http/handler/backup/backup_test.go @@ -0,0 +1,166 @@ +package backup + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/portainer/portainer/api/adminmonitor" + "github.com/portainer/portainer/api/crypto" + "github.com/portainer/portainer/api/filesystem" + "github.com/portainer/portainer/api/http/offlinegate" + "github.com/portainer/portainer/api/internal/testhelpers" + "github.com/portainer/portainer/pkg/fips" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// prepareFilestorePath copies the test assets to an isolated temp dir so +// parallel tests don't share the same filestorePath and interfere with each other. +func prepareFilestorePath(t *testing.T) string { + t.Helper() + tmpDir := t.TempDir() + err := os.CopyFS(tmpDir, os.DirFS("./test_assets/handler_test")) + require.NoError(t, err) + + return tmpDir +} + +func init() { + fips.InitFIPS(false) +} + +func listFiles(t *testing.T, dir string) []string { + items := make([]string, 0) + + err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if path == dir { + return nil + } + items = append(items, path) + + return nil + }) + require.NoError(t, err) + + return items +} + +func contains(t *testing.T, list []string, path string) { + assert.Contains(t, list, path) + copyContent, err := os.ReadFile(path) + require.NoError(t, err) + + assert.Equal(t, "content\n", string(copyContent)) +} + +func Test_backupHandlerWithoutPassword_shouldCreateATarballArchive(t *testing.T) { + t.Parallel() + r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"password":""}`)) + w := httptest.NewRecorder() + + gate := offlinegate.NewOfflineGate() + adminMonitor := adminmonitor.New(time.Hour, nil) + + handlerErr := NewHandler( + testhelpers.NewTestRequestBouncer(), + testhelpers.NewDatastore(), + gate, + prepareFilestorePath(t), + func() {}, + adminMonitor).backup(w, r) + assert.Nil(t, handlerErr, "Handler should not fail") + + response := w.Result() + body, err := io.ReadAll(response.Body) + require.NoError(t, err) + + err = response.Body.Close() + require.NoError(t, err) + + tmpdir := t.TempDir() + + archivePath := filesystem.JoinPaths(tmpdir, "archive.tar.gz") + if err := os.WriteFile(archivePath, body, 0600); err != nil { + t.Fatal("Failed to save downloaded .tar.gz archive: ", err) + } + + cmd := exec.Command("tar", "-xzf", archivePath, "-C", tmpdir) + if err := cmd.Run(); err != nil { + t.Fatal("Failed to extract archive: ", err) + } + + createdFiles := listFiles(t, tmpdir) + + contains(t, createdFiles, filesystem.JoinPaths(tmpdir, "portainer.key")) + contains(t, createdFiles, filesystem.JoinPaths(tmpdir, "portainer.pub")) + contains(t, createdFiles, filesystem.JoinPaths(tmpdir, "tls", "file1")) + contains(t, createdFiles, filesystem.JoinPaths(tmpdir, "tls", "file2")) + assert.NotContains(t, createdFiles, filesystem.JoinPaths(tmpdir, "extra_file")) + assert.NotContains(t, createdFiles, filesystem.JoinPaths(tmpdir, "extra_folder", "file1")) +} + +func Test_backupHandlerWithPassword_shouldCreateEncryptedATarballArchive(t *testing.T) { + t.Parallel() + r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"password":"secret"}`)) + w := httptest.NewRecorder() + + gate := offlinegate.NewOfflineGate() + adminMonitor := adminmonitor.New(time.Hour, nil) + + handlerErr := NewHandler( + testhelpers.NewTestRequestBouncer(), + testhelpers.NewDatastore(), + gate, + prepareFilestorePath(t), + func() {}, + adminMonitor).backup(w, r) + assert.Nil(t, handlerErr, "Handler should not fail") + + response := w.Result() + body, _ := io.ReadAll(response.Body) + + err := response.Body.Close() + require.NoError(t, err) + + tmpdir := t.TempDir() + + dr, err := crypto.AesDecrypt(bytes.NewReader(body), []byte("secret")) + if err != nil { + t.Fatal("Failed to decrypt archive") + } + + archivePath := filesystem.JoinPaths(tmpdir, "archive.tag.gz") + archive, err := os.Create(archivePath) + require.NoError(t, err) + + defer func() { + err := archive.Close() + require.NoError(t, err) + }() + + _, err = io.Copy(archive, dr) + require.NoError(t, err) + + cmd := exec.Command("tar", "-xzf", archivePath, "-C", tmpdir) + if err := cmd.Run(); err != nil { + t.Fatal("Failed to extract archive: ", err) + } + + createdFiles := listFiles(t, tmpdir) + + contains(t, createdFiles, filesystem.JoinPaths(tmpdir, "portainer.key")) + contains(t, createdFiles, filesystem.JoinPaths(tmpdir, "portainer.pub")) + contains(t, createdFiles, filesystem.JoinPaths(tmpdir, "tls", "file1")) + contains(t, createdFiles, filesystem.JoinPaths(tmpdir, "tls", "file2")) + assert.NotContains(t, createdFiles, filesystem.JoinPaths(tmpdir, "extra_file")) + assert.NotContains(t, createdFiles, filesystem.JoinPaths(tmpdir, "extra_folder", "file1")) +} diff --git a/api/http/handler/backup/handler.go b/api/http/handler/backup/handler.go new file mode 100644 index 0000000..de0f35a --- /dev/null +++ b/api/http/handler/backup/handler.go @@ -0,0 +1,68 @@ +package backup + +import ( + "context" + "net/http" + + "github.com/portainer/portainer/api/adminmonitor" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/http/offlinegate" + "github.com/portainer/portainer/api/http/security" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + + "github.com/gorilla/mux" +) + +// Handler is an http handler responsible for backup and restore portainer state +type Handler struct { + *mux.Router + bouncer security.BouncerService + dataStore dataservices.DataStore + gate *offlinegate.OfflineGate + filestorePath string + shutdownTrigger context.CancelFunc + adminMonitor *adminmonitor.Monitor + SetupToken string +} + +// NewHandler creates an new instance of backup handler +func NewHandler( + bouncer security.BouncerService, + dataStore dataservices.DataStore, + gate *offlinegate.OfflineGate, + filestorePath string, + shutdownTrigger context.CancelFunc, + adminMonitor *adminmonitor.Monitor, +) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + bouncer: bouncer, + dataStore: dataStore, + gate: gate, + filestorePath: filestorePath, + shutdownTrigger: shutdownTrigger, + adminMonitor: adminMonitor, + } + + h.Handle("/backup", bouncer.RestrictedAccess(adminAccess(httperror.LoggerHandler(h.backup)))).Methods(http.MethodPost) + h.Handle("/restore", bouncer.PublicAccess(httperror.LoggerHandler(h.restore))).Methods(http.MethodPost) + + return h +} + +func adminAccess(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + httperror.WriteError(w, http.StatusInternalServerError, "Unable to retrieve user info from request context", err) + return + } + + if !securityContext.IsAdmin { + httperror.WriteError(w, http.StatusUnauthorized, "User is not authorized to perform the action", nil) + return + } + + next.ServeHTTP(w, r) + }) +} diff --git a/api/http/handler/backup/restore.go b/api/http/handler/backup/restore.go new file mode 100644 index 0000000..f8e5b7f --- /dev/null +++ b/api/http/handler/backup/restore.go @@ -0,0 +1,74 @@ +package backup + +import ( + "bytes" + "io" + "net/http" + + "github.com/pkg/errors" + operations "github.com/portainer/portainer/api/backup" + "github.com/portainer/portainer/api/http/security/setuptoken" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" +) + +type restorePayload struct { + FileContent []byte + FileName string + Password string +} + +// @id Restore +// @summary Triggers a system restore using provided backup file +// @description Triggers a system restore using provided backup file +// @description **Access policy**: public (requires the X-Setup-Token header on an uninitialized instance unless --no-setup-token is set) +// @tags backup +// @accept json +// @param X-Setup-Token header string false "Setup token (required when instance is uninitialized and --no-setup-token is not set)" +// @param restorePayload body restorePayload true "Restore request payload" +// @success 200 "Success" +// @failure 400 "Invalid request" +// @failure 403 "Access denied - invalid or missing setup token" +// @failure 500 "Server error" +// @router /restore [post] +func (h *Handler) restore(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + if err := setuptoken.Validate(r, h.SetupToken); err != nil { + return err + } + + initialized, err := h.adminMonitor.WasInitialized() + if err != nil { + return httperror.InternalServerError("Failed to check system initialization", err) + } + if initialized { + return httperror.BadRequest("Cannot restore already initialized instance", errors.New("system already initialized")) + } + h.adminMonitor.Stop() + + var payload restorePayload + err = decodeForm(r, &payload) + if err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + var archiveReader io.Reader = bytes.NewReader(payload.FileContent) + err = operations.RestoreArchive(archiveReader, payload.Password, h.filestorePath, h.gate, h.dataStore, h.shutdownTrigger) + if err != nil { + return httperror.InternalServerError("Failed to restore the backup", err) + } + + return nil +} + +func decodeForm(r *http.Request, p *restorePayload) error { + content, name, err := request.RetrieveMultiPartFormFile(r, "file") + if err != nil { + return err + } + p.FileContent = content + p.FileName = name + + password, _ := request.RetrieveMultiPartFormValue(r, "password", true) + p.Password = password + return nil +} diff --git a/api/http/handler/backup/restore_test.go b/api/http/handler/backup/restore_test.go new file mode 100644 index 0000000..1953a0c --- /dev/null +++ b/api/http/handler/backup/restore_test.go @@ -0,0 +1,189 @@ +package backup + +import ( + "bytes" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/adminmonitor" + "github.com/portainer/portainer/api/http/offlinegate" + "github.com/portainer/portainer/api/http/security/setuptoken" + "github.com/portainer/portainer/api/internal/testhelpers" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_restoreArchive_usingCombinationOfPasswords(t *testing.T) { + t.Parallel() + tests := []struct { + name string + backupPassword string + restorePassword string + fails bool + }{ + { + name: "empty password to both encrypt and decrypt", + backupPassword: "", + restorePassword: "", + fails: false, + }, + { + name: "same password to encrypt and decrypt", + backupPassword: "secret", + restorePassword: "secret", + fails: false, + }, + { + name: "different passwords to encrypt and decrypt", + backupPassword: "secret", + restorePassword: "terces", + fails: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + datastore := testhelpers.NewDatastore( + testhelpers.WithUsers([]portainer.User{}), + testhelpers.WithEdgeJobs([]portainer.EdgeJob{}), + ) + adminMonitor := adminmonitor.New(time.Hour, datastore) + + h := NewHandler( + testhelpers.NewTestRequestBouncer(), + datastore, + offlinegate.NewOfflineGate(), + prepareFilestorePath(t), + func() {}, + adminMonitor, + ) + + // backup + archive := backup(t, h, test.backupPassword) + + // restore + w := httptest.NewRecorder() + r := prepareMultipartRequest(t, test.restorePassword, archive) + + restoreErr := h.restore(w, r) + assert.Equal(t, test.fails, restoreErr != nil, "Didn't meet expectation of failing restore handler") + }) + } +} + +func Test_restoreArchive_shouldFailIfSystemWasAlreadyInitialized(t *testing.T) { + t.Parallel() + admin := portainer.User{ + Role: portainer.AdministratorRole, + } + datastore := testhelpers.NewDatastore( + testhelpers.WithUsers([]portainer.User{admin}), + testhelpers.WithEdgeJobs([]portainer.EdgeJob{}), + ) + adminMonitor := adminmonitor.New(time.Hour, datastore) + + h := NewHandler(testhelpers.NewTestRequestBouncer(), + datastore, + offlinegate.NewOfflineGate(), + prepareFilestorePath(t), + func() {}, + adminMonitor, + ) + + // backup + archive := backup(t, h, "password") + + // restore + w := httptest.NewRecorder() + r := prepareMultipartRequest(t, "password", archive) + + restoreErr := h.restore(w, r) + assert.NotNil(t, restoreErr, "Should fail, because system it already initialized") + assert.Equal(t, "Cannot restore already initialized instance", restoreErr.Message, "Should fail with certain error") +} + +func backup(t *testing.T, h *Handler, password string) []byte { + r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(fmt.Sprintf(`{"password":"%s"}`, password))) + w := httptest.NewRecorder() + + backupErr := h.backup(w, r) + assert.Nil(t, backupErr, "Backup should not fail") + + response := w.Result() + archive, err := io.ReadAll(response.Body) + require.NoError(t, err) + + err = response.Body.Close() + require.NoError(t, err) + + return archive +} + +func Test_restore_setupTokenGate(t *testing.T) { + t.Parallel() + datastore := testhelpers.NewDatastore( + testhelpers.WithUsers([]portainer.User{}), + testhelpers.WithEdgeJobs([]portainer.EdgeJob{}), + ) + adminMonitor := adminmonitor.New(time.Hour, datastore) + h := NewHandler( + testhelpers.NewTestRequestBouncer(), + datastore, + offlinegate.NewOfflineGate(), + prepareFilestorePath(t), + func() {}, + adminMonitor, + ) + h.SetupToken = "secret-token" + + t.Run("403 without token header", func(t *testing.T) { + err := h.restore(httptest.NewRecorder(), prepareMultipartRequest(t, "", []byte("x"))) + require.Error(t, err) + assert.Equal(t, http.StatusForbidden, err.StatusCode) + }) + + t.Run("403 with wrong token", func(t *testing.T) { + r := prepareMultipartRequest(t, "", []byte("x")) + r.Header.Set(setuptoken.HeaderName, "wrong") + err := h.restore(httptest.NewRecorder(), r) + require.Error(t, err) + assert.Equal(t, http.StatusForbidden, err.StatusCode) + }) + + t.Run("passes gate with correct token", func(t *testing.T) { + archive := backup(t, h, "") + r := prepareMultipartRequest(t, "", archive) + r.Header.Set(setuptoken.HeaderName, "secret-token") + require.Nil(t, h.restore(httptest.NewRecorder(), r)) + }) +} + +func prepareMultipartRequest(t *testing.T, password string, file []byte) *http.Request { + var body bytes.Buffer + + w := multipart.NewWriter(&body) + err := w.WriteField("password", password) + require.NoError(t, err) + + fw, err := w.CreateFormFile("file", "filename") + require.NoError(t, err) + + _, err = io.Copy(fw, bytes.NewReader(file)) + require.NoError(t, err) + + r := httptest.NewRequest(http.MethodPost, "http://localhost/", &body) + r.Header.Set("Content-Type", w.FormDataContentType()) + + err = w.Close() + require.NoError(t, err) + + return r +} diff --git a/api/http/handler/backup/test_assets/handler_test/extra_file b/api/http/handler/backup/test_assets/handler_test/extra_file new file mode 100644 index 0000000..d95f3ad --- /dev/null +++ b/api/http/handler/backup/test_assets/handler_test/extra_file @@ -0,0 +1 @@ +content diff --git a/api/http/handler/backup/test_assets/handler_test/extra_folder/file1 b/api/http/handler/backup/test_assets/handler_test/extra_folder/file1 new file mode 100644 index 0000000..d95f3ad --- /dev/null +++ b/api/http/handler/backup/test_assets/handler_test/extra_folder/file1 @@ -0,0 +1 @@ +content diff --git a/api/http/handler/backup/test_assets/handler_test/portainer.key b/api/http/handler/backup/test_assets/handler_test/portainer.key new file mode 100644 index 0000000..d95f3ad --- /dev/null +++ b/api/http/handler/backup/test_assets/handler_test/portainer.key @@ -0,0 +1 @@ +content diff --git a/api/http/handler/backup/test_assets/handler_test/portainer.pub b/api/http/handler/backup/test_assets/handler_test/portainer.pub new file mode 100644 index 0000000..d95f3ad --- /dev/null +++ b/api/http/handler/backup/test_assets/handler_test/portainer.pub @@ -0,0 +1 @@ +content diff --git a/api/http/handler/backup/test_assets/handler_test/tls/file1 b/api/http/handler/backup/test_assets/handler_test/tls/file1 new file mode 100644 index 0000000..d95f3ad --- /dev/null +++ b/api/http/handler/backup/test_assets/handler_test/tls/file1 @@ -0,0 +1 @@ +content diff --git a/api/http/handler/backup/test_assets/handler_test/tls/file2 b/api/http/handler/backup/test_assets/handler_test/tls/file2 new file mode 100644 index 0000000..d95f3ad --- /dev/null +++ b/api/http/handler/backup/test_assets/handler_test/tls/file2 @@ -0,0 +1 @@ +content diff --git a/api/http/handler/customtemplates/customtemplate_create.go b/api/http/handler/customtemplates/customtemplate_create.go new file mode 100644 index 0000000..7b2153d --- /dev/null +++ b/api/http/handler/customtemplates/customtemplate_create.go @@ -0,0 +1,530 @@ +package customtemplates + +import ( + "context" + "errors" + "net/http" + "os" + "strconv" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/dataservices/source" + "github.com/portainer/portainer/api/filesystem" + gittypes "github.com/portainer/portainer/api/git/types" + "github.com/portainer/portainer/api/gitops/sources" + "github.com/portainer/portainer/api/gitops/workflows" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/authorization" + "github.com/portainer/portainer/api/stacks/stackutils" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" + "github.com/portainer/portainer/pkg/libhttp/ssrf" + "github.com/portainer/portainer/pkg/validate" + + "github.com/rs/zerolog/log" + "github.com/segmentio/encoding/json" +) + +func (handler *Handler) customTemplateCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + method, err := request.RetrieveRouteVariableValue(r, "method") + if err != nil { + return httperror.BadRequest("Invalid query parameter: method", err) + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) + } + + customTemplate, err := handler.createCustomTemplate(method, r) + if err != nil { + return httperror.InternalServerError("Unable to create custom template", err) + } + + customTemplate.CreatedByUserID = securityContext.UserID + + err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + return createCustomTemplateTx(tx, customTemplate, securityContext) + }) + + return response.TxResponse(w, customTemplate, err) +} + +func createCustomTemplateTx(tx dataservices.DataStoreTx, customTemplate *portainer.CustomTemplate, sc *security.RestrictedRequestContext) error { + existingTemplates, err := tx.CustomTemplate().ReadAll() + if err != nil { + return httperror.InternalServerError("Unable to retrieve custom templates from the database", err) + } + + for _, existing := range existingTemplates { + if existing.Title == customTemplate.Title { + return httperror.InternalServerError("Template name must be unique", errors.New("Template name must be unique")) + } + } + + if err := tx.CustomTemplate().Create(customTemplate); err != nil { + return httperror.InternalServerError("Unable to create custom template", err) + } + + resourceControl := authorization.NewPrivateResourceControl(strconv.Itoa(int(customTemplate.ID)), portainer.CustomTemplateResourceControl, sc.UserID) + + if err := tx.ResourceControl().Create(resourceControl); err != nil { + return httperror.InternalServerError("Unable to persist resource control inside the database", err) + } + + customTemplate.ResourceControl = resourceControl + + userContext := source.NewUserContext(sc.User, sc.UserMemberships) + populateGitConfig(tx, userContext, customTemplate) + + return nil +} + +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 { + // URL of the template's logo + Logo string `example:"https://portainer.io/img/logo.svg"` + // Title of the template + Title string `example:"Nginx" validate:"required"` + // Description of the template + Description string `example:"High performance web server" validate:"required"` + // A note that will be displayed in the UI. Supports HTML content + Note string `example:"This is my custom template"` + // Platform associated to the template. + // Valid values are: 1 - 'linux', 2 - 'windows' + // Required for Docker stacks + Platform portainer.CustomTemplatePlatform `example:"1" enums:"1,2"` + // Type of created stack: + // * 1 - swarm + // * 2 - compose + // * 3 - kubernetes + Type portainer.StackType `example:"1" enums:"1,2,3" validate:"required"` + // Content of stack file + FileContent string `validate:"required"` + // Definitions of variables in the stack file + Variables []portainer.CustomTemplateVariableDefinition + // EdgeTemplate indicates if this template purpose for Edge Stack + EdgeTemplate bool `example:"false"` +} + +func (payload *customTemplateFromFileContentPayload) Validate(r *http.Request) error { + if len(payload.Title) == 0 { + return errors.New("Invalid custom template title") + } + if len(payload.Description) == 0 { + return errors.New("Invalid custom template description") + } + if len(payload.FileContent) == 0 { + return errors.New("Invalid file content") + } + if payload.Type != portainer.KubernetesStack && payload.Platform != portainer.CustomTemplatePlatformLinux && payload.Platform != portainer.CustomTemplatePlatformWindows { + return errors.New("Invalid custom template platform") + } + // Platform validation is only for docker related stack (docker standalone and docker swarm) + if payload.Type != portainer.KubernetesStack && payload.Type != portainer.DockerSwarmStack && payload.Type != portainer.DockerComposeStack { + return errors.New("Invalid custom template type") + } + if !IsValidNote(payload.Note) { + return errors.New("Invalid note. tag is not supported") + } + + return ValidateVariablesDefinitions(payload.Variables) +} + +// @id CustomTemplateCreateString +// @summary Create a custom template +// @description Create a custom template. +// @description **Access policy**: authenticated +// @tags custom_templates +// @security ApiKeyAuth +// @security jwt +// @accept json +// @produce json +// @param body body customTemplateFromFileContentPayload true "body" +// @success 200 {object} portainer.CustomTemplate +// @failure 400 "Invalid request" +// @failure 500 "Server error" +// @router /custom_templates/create/string [post] +func (handler *Handler) createCustomTemplateFromFileContent(r *http.Request) (*portainer.CustomTemplate, error) { + var payload customTemplateFromFileContentPayload + if err := request.DecodeAndValidateJSONPayload(r, &payload); 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, + Variables: payload.Variables, + EdgeTemplate: payload.EdgeTemplate, + } + + 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 { + // URL of the template's logo + Logo string `example:"https://portainer.io/img/logo.svg"` + // Title of the template + Title string `example:"Nginx" validate:"required"` + // Description of the template + Description string `example:"High performance web server" validate:"required"` + // A note that will be displayed in the UI. Supports HTML content + Note string `example:"This is my custom template"` + // Platform associated to the template. + // Valid values are: 1 - 'linux', 2 - 'windows' + // Required for Docker stacks + Platform portainer.CustomTemplatePlatform `example:"1" enums:"1,2"` + // Type of created stack: + // * 1 - swarm + // * 2 - compose + // * 3 - kubernetes + Type portainer.StackType `example:"1" enums:"1,2" validate:"required"` + + // SourceID references an existing Source for git credentials/URL. + // When set, the inline URL and authentication fields are ignored. + SourceID portainer.SourceID `example:"1" validate:"required"` + // Deprecated: use SourceID instead. URL of a Git repository hosting the Stack file. + RepositoryURL string `example:"https://github.com/openfaas/faas"` + // Reference name of a Git repository hosting the Stack file + RepositoryReferenceName string `example:"refs/heads/master"` + // Deprecated: use SourceID instead. Use basic authentication to clone the Git repository. + RepositoryAuthentication bool `example:"true"` + // Deprecated: use SourceID instead. Username used in basic authentication. Required when RepositoryAuthentication is true. + RepositoryUsername string `example:"myGitUsername"` + // Deprecated: use SourceID instead. Password used in basic authentication. Required when RepositoryAuthentication is true. + RepositoryPassword string `example:"myGitPassword"` + // Path to the Stack file inside the Git repository + ComposeFilePathInRepository string `example:"docker-compose.yml" default:"docker-compose.yml"` + // Definitions of variables in the stack file + Variables []portainer.CustomTemplateVariableDefinition + // Deprecated: use SourceID instead. TLSSkipVerify skips SSL verification when cloning the Git repository. + TLSSkipVerify bool `example:"false"` + // IsComposeFormat indicates if the Kubernetes template is created from a Docker Compose file + IsComposeFormat bool `example:"false"` + // EdgeTemplate indicates if this template purpose for Edge Stack + EdgeTemplate bool `example:"false"` +} + +func (payload *customTemplateFromGitRepositoryPayload) Validate(r *http.Request) error { + if len(payload.Title) == 0 { + return errors.New("Invalid custom template title") + } + if len(payload.Description) == 0 { + return errors.New("Invalid custom template description") + } + if payload.SourceID == 0 { + if len(payload.RepositoryURL) == 0 || !validate.IsURL(payload.RepositoryURL) { + return errors.New("Invalid repository URL. Must correspond to a valid URL format") + } + if payload.RepositoryAuthentication && (len(payload.RepositoryUsername) == 0 || len(payload.RepositoryPassword) == 0) { + return errors.New("Invalid repository credentials. Username and password must be specified when authentication is enabled") + } + } + if len(payload.ComposeFilePathInRepository) == 0 { + payload.ComposeFilePathInRepository = filesystem.ComposeFileDefaultName + } + + // Platform validation is only for docker related stack (docker standalone and docker swarm) + if payload.Type != portainer.KubernetesStack && payload.Platform != portainer.CustomTemplatePlatformLinux && payload.Platform != portainer.CustomTemplatePlatformWindows { + return errors.New("Invalid custom template platform") + } + if payload.Type != portainer.DockerSwarmStack && payload.Type != portainer.DockerComposeStack && payload.Type != portainer.KubernetesStack { + return errors.New("Invalid custom template type") + } + if !IsValidNote(payload.Note) { + return errors.New("Invalid note. tag is not supported") + } + + return ValidateVariablesDefinitions(payload.Variables) +} + +// @id CustomTemplateCreateRepository +// @summary Create a custom template +// @description Create a custom template. +// @description **Access policy**: authenticated +// @tags custom_templates +// @security ApiKeyAuth +// @security jwt +// @accept json +// @produce json +// @param body body customTemplateFromGitRepositoryPayload true "Required when using method=repository" +// @success 200 {object} portainer.CustomTemplate +// @failure 400 "Invalid request" +// @failure 500 "Server error" +// @router /custom_templates/create/repository [post] +func (handler *Handler) createCustomTemplateFromGitRepository(r *http.Request) (*portainer.CustomTemplate, error) { + var payload customTemplateFromGitRepositoryPayload + if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil { + return nil, err + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return nil, httperror.InternalServerError("Unable to retrieve info from request context", 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, + Variables: payload.Variables, + IsComposeFormat: payload.IsComposeFormat, + EdgeTemplate: payload.EdgeTemplate, + } + + getProjectPath := func() string { + return handler.FileService.GetCustomTemplateProjectPath(strconv.Itoa(customTemplateID)) + } + projectPath := getProjectPath() + customTemplate.ProjectPath = projectPath + + userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships) + + gitConfig, httpErr := sources.ResolveRepoConfig(handler.DataStore, userContext, sources.RepoConfigInput{ + SourceID: payload.SourceID, + ReferenceName: payload.RepositoryReferenceName, + ConfigFilePath: payload.ComposeFilePathInRepository, + RepositoryURL: payload.RepositoryURL, + TLSSkipVerify: payload.TLSSkipVerify, + RepositoryAuthentication: payload.RepositoryAuthentication, + Username: payload.RepositoryUsername, + Password: payload.RepositoryPassword, + }) + if httpErr != nil { + return nil, httpErr + } + + if err := ssrf.CheckURL(r.Context(), gitConfig.URL); err != nil { + return nil, err + } + + commitHash, err := stackutils.DownloadGitRepository(context.TODO(), gitConfig, handler.GitService, getProjectPath) + if err != nil { + return nil, err + } + + sourceID := payload.SourceID + if sourceID == 0 { + src, err := workflows.FindOrCreateGitSource(handler.DataStore, userContext, &portainer.Source{ + Name: gittypes.RepoName(gitConfig.URL), + Type: portainer.SourceTypeGit, + Git: &gittypes.GitSource{ + URL: gitConfig.URL, + Authentication: gitConfig.Authentication, + TLSSkipVerify: gitConfig.TLSSkipVerify, + }, + }) + if err != nil { + return nil, err + } + sourceID = src.ID + } + + customTemplate.Artifact = &portainer.Artifact{ + Files: []portainer.ArtifactFile{{ + SourceID: sourceID, + Path: gitConfig.ConfigFilePath, + Ref: gitConfig.ReferenceName, + Hash: commitHash, + }}, + } + isValidProject := true + defer func() { + if !isValidProject { + if err := handler.FileService.RemoveDirectory(projectPath); err != nil { + log.Warn().Err(err).Msg("unable to remove git repository directory") + } + } + }() + + entryPath := filesystem.JoinPaths(projectPath, gitConfig.ConfigFilePath) + + exists, err := handler.FileService.FileExists(entryPath) + if err != nil || !exists { + isValidProject = false + } + + if err != nil { + return nil, err + } + + if !exists { + if payload.Type == portainer.KubernetesStack { + return nil, errors.New("Invalid Manifest file, ensure that the Manifest file path is correct") + } + return nil, errors.New("Invalid Compose file, ensure that the Compose file path is correct") + } + + info, err := os.Lstat(entryPath) + if err != nil { + isValidProject = false + return nil, err + } + if info.Mode()&os.ModeSymlink != 0 { // entry is a symlink + isValidProject = false + return nil, errors.New("Invalid Compose file, ensure that the Compose file is not a symbolic link") + } + + return customTemplate, nil +} + +type customTemplateFromFileUploadPayload struct { + Logo string + Title string + Description string + Note string + Platform portainer.CustomTemplatePlatform + // Type of created stack: + // * 1 - swarm + // * 2 - compose + // * 3 - kubernetes + Type portainer.StackType + FileContent []byte + // Definitions of variables in the stack file + Variables []portainer.CustomTemplateVariableDefinition + // EdgeTemplate indicates if this template purpose for Edge Stack + EdgeTemplate bool `example:"false"` +} + +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 + + logo, _ := request.RetrieveMultiPartFormValue(r, "Logo", true) + payload.Logo = logo + + note, _ := request.RetrieveMultiPartFormValue(r, "Note", true) + if !IsValidNote(note) { + return errors.New("Invalid note. tag is not supported") + } + payload.Note = note + + typeNumeral, _ := request.RetrieveNumericMultiPartFormValue(r, "Type", true) + templateType := portainer.StackType(typeNumeral) + if templateType != portainer.KubernetesStack && templateType != portainer.DockerSwarmStack && templateType != portainer.DockerComposeStack { + return errors.New("Invalid custom template type") + } + payload.Type = templateType + + platform, _ := request.RetrieveNumericMultiPartFormValue(r, "Platform", true) + templatePlatform := portainer.CustomTemplatePlatform(platform) + // Platform validation is only for docker related stack (docker standalone and docker swarm) + if templateType != portainer.KubernetesStack && templatePlatform != portainer.CustomTemplatePlatformLinux && templatePlatform != portainer.CustomTemplatePlatformWindows { + return errors.New("Invalid custom template platform") + } + + payload.Platform = templatePlatform + + 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 + + varsString, _ := request.RetrieveMultiPartFormValue(r, "Variables", true) + if varsString != "" { + if err := json.Unmarshal([]byte(varsString), &payload.Variables); err != nil { + return errors.New("Invalid variables. Ensure that the variables are valid JSON") + } + if err := ValidateVariablesDefinitions(payload.Variables); err != nil { + return err + } + } + + edgeTemplate, _ := request.RetrieveBooleanMultiPartFormValue(r, "EdgeTemplate", true) + payload.EdgeTemplate = edgeTemplate + + return nil +} + +// @id CustomTemplateCreateFile +// @summary Create a custom template +// @description Create a custom template. +// @description **Access policy**: authenticated +// @tags custom_templates +// @security ApiKeyAuth +// @security jwt +// @accept multipart/form-data +// @produce json +// @param Title formData string true "Title of the template" +// @param Description formData string true "Description of the template" +// @param Note formData string true "A note that will be displayed in the UI. Supports HTML content" +// @param Platform formData int true "Platform associated to the template (1 - 'linux', 2 - 'windows')" Enums(1,2) +// @param Type formData int true "Type of created stack (1 - swarm, 2 - compose, 3 - kubernetes)" Enums(1,2,3) +// @param File formData file true "File" +// @param Logo formData string false "URL of the template's logo" example:"https://portainer.io/img/logo.svg" +// @param Variables formData string false "A json array of variables definitions" example:"[{\"label\":\"image\",\"description\":\"Image name\",\"defaultValue\":\"nginx:latest\",\"name\":\"image\"}]" +// @success 200 {object} portainer.CustomTemplate +// @failure 400 "Invalid request" +// @failure 500 "Server error" +// @router /custom_templates/create/file [post] +func (handler *Handler) createCustomTemplateFromFileUpload(r *http.Request) (*portainer.CustomTemplate, error) { + payload := &customTemplateFromFileUploadPayload{} + if err := payload.Validate(r); 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, + Variables: payload.Variables, + EdgeTemplate: payload.EdgeTemplate, + } + + templateFolder := strconv.Itoa(customTemplateID) + projectPath, err := handler.FileService.StoreCustomTemplateFileFromBytes(templateFolder, customTemplate.EntryPoint, payload.FileContent) + if err != nil { + return nil, err + } + customTemplate.ProjectPath = projectPath + + return customTemplate, nil +} diff --git a/api/http/handler/customtemplates/customtemplate_create_test.go b/api/http/handler/customtemplates/customtemplate_create_test.go new file mode 100644 index 0000000..0985385 --- /dev/null +++ b/api/http/handler/customtemplates/customtemplate_create_test.go @@ -0,0 +1,1171 @@ +package customtemplates + +import ( + "bytes" + "context" + "mime/multipart" + "net/http" + "net/http/httptest" + "os" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/filesystem" + gittypes "github.com/portainer/portainer/api/git/types" + "github.com/portainer/portainer/api/http/security" + + "github.com/gorilla/mux" + "github.com/segmentio/encoding/json" + "github.com/stretchr/testify/require" +) + +func createTemplateRequest(t *testing.T, method string, payload any, userID portainer.UserID, role portainer.UserRole) *http.Request { + t.Helper() + + body, err := json.Marshal(payload) + require.NoError(t, err) + + r := httptest.NewRequest(http.MethodPost, "/custom_templates/create/"+method, bytes.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + r = mux.SetURLVars(r, map[string]string{"method": method}) + + ctx := security.StoreTokenData(r, &portainer.TokenData{ID: userID, Role: role}) + r = r.WithContext(ctx) + ctx = security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{ + UserID: userID, + IsAdmin: role == portainer.AdministratorRole, + User: &portainer.User{ID: userID, Role: role}, + }) + return r.WithContext(ctx) +} + +func TestCustomTemplateCreate_FromFileContent_Success(t *testing.T) { + t.Parallel() + + handler, ds, _ := newTestHandler(t) + + payload := customTemplateFromFileContentPayload{ + Title: "My Template", + Description: "A test template", + FileContent: "version: '3'\nservices:\n web:\n image: nginx", + Type: portainer.DockerComposeStack, + Platform: portainer.CustomTemplatePlatformLinux, + } + + r := createTemplateRequest(t, "string", payload, 1, portainer.AdministratorRole) + rr := httptest.NewRecorder() + + herr := handler.customTemplateCreate(rr, r) + require.Nil(t, herr) + require.Equal(t, http.StatusOK, rr.Code) + + var tmpl portainer.CustomTemplate + require.NoError(t, json.NewDecoder(rr.Body).Decode(&tmpl)) + require.Equal(t, "My Template", tmpl.Title) + require.Equal(t, "A test template", tmpl.Description) + require.Equal(t, portainer.UserID(1), tmpl.CreatedByUserID) + require.NotNil(t, tmpl.ResourceControl) + + err := ds.ViewTx(func(tx dataservices.DataStoreTx) error { + templates, err := tx.CustomTemplate().ReadAll() + require.NoError(t, err) + require.Len(t, templates, 1) + require.Equal(t, "My Template", templates[0].Title) + + rcs, err := tx.ResourceControl().ReadAll() + require.NoError(t, err) + require.Len(t, rcs, 1) + + return nil + }) + require.NoError(t, err) +} + +func TestCustomTemplateCreate_FromFileContent_DuplicateTitle(t *testing.T) { + t.Parallel() + + handler, ds, _ := newTestHandler(t) + + require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error { + return tx.CustomTemplate().Create(&portainer.CustomTemplate{ + ID: 1, + Title: "Existing Template", + }) + })) + + payload := customTemplateFromFileContentPayload{ + Title: "Existing Template", + Description: "Another template", + FileContent: "version: '3'", + Type: portainer.DockerComposeStack, + Platform: portainer.CustomTemplatePlatformLinux, + } + + r := createTemplateRequest(t, "string", payload, 1, portainer.AdministratorRole) + rr := httptest.NewRecorder() + + herr := handler.customTemplateCreate(rr, r) + require.NotNil(t, herr) + require.Equal(t, http.StatusInternalServerError, herr.StatusCode) +} + +func TestCustomTemplateCreate_FromFileContent_MissingTitle(t *testing.T) { + t.Parallel() + + handler, _, _ := newTestHandler(t) + + payload := customTemplateFromFileContentPayload{ + Description: "A test template", + FileContent: "version: '3'", + Type: portainer.DockerComposeStack, + Platform: portainer.CustomTemplatePlatformLinux, + } + + r := createTemplateRequest(t, "string", payload, 1, portainer.AdministratorRole) + rr := httptest.NewRecorder() + + herr := handler.customTemplateCreate(rr, r) + require.NotNil(t, herr) + require.Equal(t, http.StatusInternalServerError, herr.StatusCode) +} + +func TestCustomTemplateCreate_FromFileContent_MissingDescription(t *testing.T) { + t.Parallel() + + handler, _, _ := newTestHandler(t) + + payload := customTemplateFromFileContentPayload{ + Title: "My Template", + FileContent: "version: '3'", + Type: portainer.DockerComposeStack, + Platform: portainer.CustomTemplatePlatformLinux, + } + + r := createTemplateRequest(t, "string", payload, 1, portainer.AdministratorRole) + rr := httptest.NewRecorder() + + herr := handler.customTemplateCreate(rr, r) + require.NotNil(t, herr) + require.Equal(t, http.StatusInternalServerError, herr.StatusCode) +} + +func TestCustomTemplateCreate_FromFileContent_MissingFileContent(t *testing.T) { + t.Parallel() + + handler, _, _ := newTestHandler(t) + + payload := customTemplateFromFileContentPayload{ + Title: "My Template", + Description: "A test template", + Type: portainer.DockerComposeStack, + Platform: portainer.CustomTemplatePlatformLinux, + } + + r := createTemplateRequest(t, "string", payload, 1, portainer.AdministratorRole) + rr := httptest.NewRecorder() + + herr := handler.customTemplateCreate(rr, r) + require.NotNil(t, herr) + require.Equal(t, http.StatusInternalServerError, herr.StatusCode) +} + +func TestCustomTemplateCreate_FromFileContent_InvalidPlatform(t *testing.T) { + t.Parallel() + + handler, _, _ := newTestHandler(t) + + payload := customTemplateFromFileContentPayload{ + Title: "My Template", + Description: "A test template", + FileContent: "version: '3'", + Type: portainer.DockerComposeStack, + Platform: 0, + } + + r := createTemplateRequest(t, "string", payload, 1, portainer.AdministratorRole) + rr := httptest.NewRecorder() + + herr := handler.customTemplateCreate(rr, r) + require.NotNil(t, herr) + require.Equal(t, http.StatusInternalServerError, herr.StatusCode) +} + +func TestCustomTemplateCreate_FromFileContent_NoteWithImage(t *testing.T) { + t.Parallel() + + handler, _, _ := newTestHandler(t) + + payload := customTemplateFromFileContentPayload{ + Title: "My Template", + Description: "A test template", + FileContent: "version: '3'", + Note: `Some note with `, + Type: portainer.DockerComposeStack, + Platform: portainer.CustomTemplatePlatformLinux, + } + + r := createTemplateRequest(t, "string", payload, 1, portainer.AdministratorRole) + rr := httptest.NewRecorder() + + herr := handler.customTemplateCreate(rr, r) + require.NotNil(t, herr) + require.Equal(t, http.StatusInternalServerError, herr.StatusCode) +} + +func TestCustomTemplateCreate_FromFileContent_KubernetesTypeIgnoresPlatform(t *testing.T) { + t.Parallel() + + handler, ds, _ := newTestHandler(t) + + payload := customTemplateFromFileContentPayload{ + Title: "K8s Template", + Description: "A kubernetes template", + FileContent: "apiVersion: v1\nkind: Pod", + Type: portainer.KubernetesStack, + Platform: 0, + } + + r := createTemplateRequest(t, "string", payload, 1, portainer.AdministratorRole) + rr := httptest.NewRecorder() + + herr := handler.customTemplateCreate(rr, r) + require.Nil(t, herr) + require.Equal(t, http.StatusOK, rr.Code) + + var tmpl portainer.CustomTemplate + require.NoError(t, json.NewDecoder(rr.Body).Decode(&tmpl)) + require.Equal(t, "K8s Template", tmpl.Title) + require.Equal(t, portainer.KubernetesStack, tmpl.Type) + + err := ds.ViewTx(func(tx dataservices.DataStoreTx) error { + templates, err := tx.CustomTemplate().ReadAll() + require.NoError(t, err) + require.Len(t, templates, 1) + + return nil + }) + require.NoError(t, err) +} + +func TestCustomTemplateCreate_FromFileUpload_Success(t *testing.T) { + t.Parallel() + + handler, ds, _ := newTestHandler(t) + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + + err := writer.WriteField("Title", "Uploaded Template") + require.NoError(t, err) + + err = writer.WriteField("Description", "Uploaded from file") + require.NoError(t, err) + + err = writer.WriteField("Type", "2") + require.NoError(t, err) + + err = writer.WriteField("Platform", "1") + require.NoError(t, err) + + part, err := writer.CreateFormFile("File", "docker-compose.yml") + require.NoError(t, err) + + _, err = part.Write([]byte("version: '3'\nservices:\n web:\n image: nginx")) + require.NoError(t, err) + + require.NoError(t, writer.Close()) + + r := httptest.NewRequest(http.MethodPost, "/custom_templates/create/file", &body) + r.Header.Set("Content-Type", writer.FormDataContentType()) + r = mux.SetURLVars(r, map[string]string{"method": "file"}) + ctx := security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole}) + r = r.WithContext(ctx) + r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{ + UserID: 1, + IsAdmin: true, + User: &portainer.User{ID: 1, Role: portainer.AdministratorRole}, + })) + + rr := httptest.NewRecorder() + herr := handler.customTemplateCreate(rr, r) + require.Nil(t, herr) + require.Equal(t, http.StatusOK, rr.Code) + + var tmpl portainer.CustomTemplate + require.NoError(t, json.NewDecoder(rr.Body).Decode(&tmpl)) + require.Equal(t, "Uploaded Template", tmpl.Title) + require.Equal(t, filesystem.ComposeFileDefaultName, tmpl.EntryPoint) + + err = ds.ViewTx(func(tx dataservices.DataStoreTx) error { + templates, err := tx.CustomTemplate().ReadAll() + require.NoError(t, err) + require.Len(t, templates, 1) + + return nil + }) + require.NoError(t, err) +} + +func TestCustomTemplateCreate_InvalidMethod(t *testing.T) { + t.Parallel() + + handler, _, _ := newTestHandler(t) + + payload := customTemplateFromFileContentPayload{ + Title: "My Template", + Description: "A test template", + FileContent: "version: '3'", + Type: portainer.DockerComposeStack, + Platform: portainer.CustomTemplatePlatformLinux, + } + + r := createTemplateRequest(t, "invalid", payload, 1, portainer.AdministratorRole) + rr := httptest.NewRecorder() + + herr := handler.customTemplateCreate(rr, r) + require.NotNil(t, herr) + require.Equal(t, http.StatusInternalServerError, herr.StatusCode) +} + +func TestCustomTemplateCreate_FromFileContent_InvalidType(t *testing.T) { + t.Parallel() + + handler, _, _ := newTestHandler(t) + + payload := customTemplateFromFileContentPayload{ + Title: "My Template", + Description: "A test template", + FileContent: "version: '3'", + Type: 0, + Platform: portainer.CustomTemplatePlatformLinux, + } + + r := createTemplateRequest(t, "string", payload, 1, portainer.AdministratorRole) + rr := httptest.NewRecorder() + + herr := handler.customTemplateCreate(rr, r) + require.NotNil(t, herr) + require.Equal(t, http.StatusInternalServerError, herr.StatusCode) +} + +func TestCustomTemplateCreate_FromFileContent_Variables(t *testing.T) { + t.Parallel() + + handler, ds, _ := newTestHandler(t) + + payload := customTemplateFromFileContentPayload{ + Title: "Template With Variables", + Description: "A test template", + FileContent: "version: '3'", + Type: portainer.DockerComposeStack, + Platform: portainer.CustomTemplatePlatformLinux, + Variables: []portainer.CustomTemplateVariableDefinition{ + {Name: "IMAGE", Label: "Docker image"}, + }, + } + + r := createTemplateRequest(t, "string", payload, 1, portainer.AdministratorRole) + rr := httptest.NewRecorder() + + herr := handler.customTemplateCreate(rr, r) + require.Nil(t, herr) + require.Equal(t, http.StatusOK, rr.Code) + + var tmpl portainer.CustomTemplate + require.NoError(t, json.NewDecoder(rr.Body).Decode(&tmpl)) + require.Len(t, tmpl.Variables, 1) + require.Equal(t, "IMAGE", tmpl.Variables[0].Name) + + err := ds.ViewTx(func(tx dataservices.DataStoreTx) error { + stored, err := tx.CustomTemplate().Read(tmpl.ID) + require.NoError(t, err) + require.Len(t, stored.Variables, 1) + + return nil + }) + require.NoError(t, err) +} + +func TestCustomTemplateCreate_FromFileContent_InvalidVariables(t *testing.T) { + t.Parallel() + + handler, _, _ := newTestHandler(t) + + payload := customTemplateFromFileContentPayload{ + Title: "My Template", + Description: "A test template", + FileContent: "version: '3'", + Type: portainer.DockerComposeStack, + Platform: portainer.CustomTemplatePlatformLinux, + Variables: []portainer.CustomTemplateVariableDefinition{ + {Label: "Missing name"}, + }, + } + + r := createTemplateRequest(t, "string", payload, 1, portainer.AdministratorRole) + rr := httptest.NewRecorder() + + herr := handler.customTemplateCreate(rr, r) + require.NotNil(t, herr) + require.Equal(t, http.StatusInternalServerError, herr.StatusCode) +} + +func TestCustomTemplateCreate_FromFileContent_EdgeTemplate(t *testing.T) { + t.Parallel() + + handler, ds, _ := newTestHandler(t) + + payload := customTemplateFromFileContentPayload{ + Title: "Edge Template", + Description: "For edge stacks", + FileContent: "version: '3'\nservices:\n web:\n image: nginx", + Type: portainer.DockerComposeStack, + Platform: portainer.CustomTemplatePlatformLinux, + EdgeTemplate: true, + } + + r := createTemplateRequest(t, "string", payload, 1, portainer.AdministratorRole) + rr := httptest.NewRecorder() + + herr := handler.customTemplateCreate(rr, r) + require.Nil(t, herr) + require.Equal(t, http.StatusOK, rr.Code) + + var tmpl portainer.CustomTemplate + require.NoError(t, json.NewDecoder(rr.Body).Decode(&tmpl)) + require.True(t, tmpl.EdgeTemplate) + + err := ds.ViewTx(func(tx dataservices.DataStoreTx) error { + stored, err := tx.CustomTemplate().Read(tmpl.ID) + require.NoError(t, err) + require.True(t, stored.EdgeTemplate) + + return nil + }) + require.NoError(t, err) +} + +func TestCustomTemplateCreate_FromFileUpload_MissingTitle(t *testing.T) { + t.Parallel() + + handler, _, _ := newTestHandler(t) + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + + err := writer.WriteField("Description", "A description") + require.NoError(t, err) + + err = writer.WriteField("Type", "2") + require.NoError(t, err) + + err = writer.WriteField("Platform", "1") + require.NoError(t, err) + + part, err := writer.CreateFormFile("File", "docker-compose.yml") + require.NoError(t, err) + + _, err = part.Write([]byte("version: '3'")) + require.NoError(t, err) + + require.NoError(t, writer.Close()) + + r := httptest.NewRequest(http.MethodPost, "/custom_templates/create/file", &body) + r.Header.Set("Content-Type", writer.FormDataContentType()) + r = mux.SetURLVars(r, map[string]string{"method": "file"}) + ctx := security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole}) + r = r.WithContext(ctx) + r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{ + UserID: 1, + IsAdmin: true, + User: &portainer.User{ID: 1, Role: portainer.AdministratorRole}, + })) + + rr := httptest.NewRecorder() + herr := handler.customTemplateCreate(rr, r) + require.NotNil(t, herr) + require.Equal(t, http.StatusInternalServerError, herr.StatusCode) +} + +func TestCustomTemplateCreate_FromFileUpload_MissingDescription(t *testing.T) { + t.Parallel() + + handler, _, _ := newTestHandler(t) + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + + err := writer.WriteField("Title", "My Template") + require.NoError(t, err) + + err = writer.WriteField("Type", "2") + require.NoError(t, err) + + err = writer.WriteField("Platform", "1") + require.NoError(t, err) + + part, err := writer.CreateFormFile("File", "docker-compose.yml") + require.NoError(t, err) + + _, err = part.Write([]byte("version: '3'")) + require.NoError(t, err) + + require.NoError(t, writer.Close()) + + r := httptest.NewRequest(http.MethodPost, "/custom_templates/create/file", &body) + r.Header.Set("Content-Type", writer.FormDataContentType()) + r = mux.SetURLVars(r, map[string]string{"method": "file"}) + ctx := security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole}) + r = r.WithContext(ctx) + r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{ + UserID: 1, + IsAdmin: true, + User: &portainer.User{ID: 1, Role: portainer.AdministratorRole}, + })) + + rr := httptest.NewRecorder() + herr := handler.customTemplateCreate(rr, r) + require.NotNil(t, herr) + require.Equal(t, http.StatusInternalServerError, herr.StatusCode) +} + +func TestCustomTemplateCreate_FromFileUpload_MissingFile(t *testing.T) { + t.Parallel() + + handler, _, _ := newTestHandler(t) + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + + err := writer.WriteField("Title", "My Template") + require.NoError(t, err) + + err = writer.WriteField("Description", "A description") + require.NoError(t, err) + + err = writer.WriteField("Type", "2") + require.NoError(t, err) + + err = writer.WriteField("Platform", "1") + require.NoError(t, err) + + require.NoError(t, writer.Close()) + + r := httptest.NewRequest(http.MethodPost, "/custom_templates/create/file", &body) + r.Header.Set("Content-Type", writer.FormDataContentType()) + r = mux.SetURLVars(r, map[string]string{"method": "file"}) + ctx := security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole}) + r = r.WithContext(ctx) + r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{ + UserID: 1, + IsAdmin: true, + User: &portainer.User{ID: 1, Role: portainer.AdministratorRole}, + })) + + rr := httptest.NewRecorder() + herr := handler.customTemplateCreate(rr, r) + require.NotNil(t, herr) + require.Equal(t, http.StatusInternalServerError, herr.StatusCode) +} + +func TestCustomTemplateCreate_FromFileUpload_InvalidType(t *testing.T) { + t.Parallel() + + handler, _, _ := newTestHandler(t) + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + + err := writer.WriteField("Title", "My Template") + require.NoError(t, err) + + err = writer.WriteField("Description", "A description") + require.NoError(t, err) + + err = writer.WriteField("Type", "0") + require.NoError(t, err) + + err = writer.WriteField("Platform", "1") + require.NoError(t, err) + + part, err := writer.CreateFormFile("File", "docker-compose.yml") + require.NoError(t, err) + + _, err = part.Write([]byte("version: '3'")) + require.NoError(t, err) + + require.NoError(t, writer.Close()) + + r := httptest.NewRequest(http.MethodPost, "/custom_templates/create/file", &body) + r.Header.Set("Content-Type", writer.FormDataContentType()) + r = mux.SetURLVars(r, map[string]string{"method": "file"}) + ctx := security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole}) + r = r.WithContext(ctx) + r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{ + UserID: 1, + IsAdmin: true, + User: &portainer.User{ID: 1, Role: portainer.AdministratorRole}, + })) + + rr := httptest.NewRecorder() + herr := handler.customTemplateCreate(rr, r) + require.NotNil(t, herr) + require.Equal(t, http.StatusInternalServerError, herr.StatusCode) +} + +func TestCustomTemplateCreate_FromFileUpload_InvalidPlatform(t *testing.T) { + t.Parallel() + + handler, _, _ := newTestHandler(t) + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + + err := writer.WriteField("Title", "My Template") + require.NoError(t, err) + + err = writer.WriteField("Description", "A description") + require.NoError(t, err) + + err = writer.WriteField("Type", "2") + require.NoError(t, err) + + err = writer.WriteField("Platform", "0") + require.NoError(t, err) + + part, err := writer.CreateFormFile("File", "docker-compose.yml") + require.NoError(t, err) + + _, err = part.Write([]byte("version: '3'")) + require.NoError(t, err) + + require.NoError(t, writer.Close()) + + r := httptest.NewRequest(http.MethodPost, "/custom_templates/create/file", &body) + r.Header.Set("Content-Type", writer.FormDataContentType()) + r = mux.SetURLVars(r, map[string]string{"method": "file"}) + ctx := security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole}) + r = r.WithContext(ctx) + r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{ + UserID: 1, + IsAdmin: true, + User: &portainer.User{ID: 1, Role: portainer.AdministratorRole}, + })) + + rr := httptest.NewRecorder() + herr := handler.customTemplateCreate(rr, r) + require.NotNil(t, herr) + require.Equal(t, http.StatusInternalServerError, herr.StatusCode) +} + +func TestCustomTemplateCreate_FromFileUpload_NoteWithImage(t *testing.T) { + t.Parallel() + + handler, _, _ := newTestHandler(t) + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + + err := writer.WriteField("Title", "My Template") + require.NoError(t, err) + + err = writer.WriteField("Description", "A description") + require.NoError(t, err) + + err = writer.WriteField("Note", `Some note `) + require.NoError(t, err) + + err = writer.WriteField("Type", "2") + require.NoError(t, err) + + err = writer.WriteField("Platform", "1") + require.NoError(t, err) + + part, err := writer.CreateFormFile("File", "docker-compose.yml") + require.NoError(t, err) + + _, err = part.Write([]byte("version: '3'")) + require.NoError(t, err) + + require.NoError(t, writer.Close()) + + r := httptest.NewRequest(http.MethodPost, "/custom_templates/create/file", &body) + r.Header.Set("Content-Type", writer.FormDataContentType()) + r = mux.SetURLVars(r, map[string]string{"method": "file"}) + ctx := security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole}) + r = r.WithContext(ctx) + r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{ + UserID: 1, + IsAdmin: true, + User: &portainer.User{ID: 1, Role: portainer.AdministratorRole}, + })) + + rr := httptest.NewRecorder() + herr := handler.customTemplateCreate(rr, r) + require.NotNil(t, herr) + require.Equal(t, http.StatusInternalServerError, herr.StatusCode) +} + +func TestCustomTemplateCreate_FromFileUpload_KubernetesIgnoresPlatform(t *testing.T) { + t.Parallel() + + handler, ds, _ := newTestHandler(t) + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + + err := writer.WriteField("Title", "K8s Upload Template") + require.NoError(t, err) + + err = writer.WriteField("Description", "A kubernetes template") + require.NoError(t, err) + + err = writer.WriteField("Type", "3") + require.NoError(t, err) + + err = writer.WriteField("Platform", "0") + require.NoError(t, err) + + part, err := writer.CreateFormFile("File", "deployment.yml") + require.NoError(t, err) + + _, err = part.Write([]byte("apiVersion: v1\nkind: Pod")) + require.NoError(t, err) + + require.NoError(t, writer.Close()) + + r := httptest.NewRequest(http.MethodPost, "/custom_templates/create/file", &body) + r.Header.Set("Content-Type", writer.FormDataContentType()) + r = mux.SetURLVars(r, map[string]string{"method": "file"}) + ctx := security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole}) + r = r.WithContext(ctx) + r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{ + UserID: 1, + IsAdmin: true, + User: &portainer.User{ID: 1, Role: portainer.AdministratorRole}, + })) + + rr := httptest.NewRecorder() + herr := handler.customTemplateCreate(rr, r) + require.Nil(t, herr) + require.Equal(t, http.StatusOK, rr.Code) + + var tmpl portainer.CustomTemplate + require.NoError(t, json.NewDecoder(rr.Body).Decode(&tmpl)) + require.Equal(t, "K8s Upload Template", tmpl.Title) + require.Equal(t, filesystem.ComposeFileDefaultName, tmpl.EntryPoint) + + err = ds.ViewTx(func(tx dataservices.DataStoreTx) error { + templates, err := tx.CustomTemplate().ReadAll() + require.NoError(t, err) + require.Len(t, templates, 1) + + return nil + }) + require.NoError(t, err) +} + +func TestCustomTemplateCreate_FromFileUpload_Variables(t *testing.T) { + t.Parallel() + + handler, ds, _ := newTestHandler(t) + + vars, err := json.Marshal([]portainer.CustomTemplateVariableDefinition{{Name: "IMAGE", Label: "Docker image"}}) + require.NoError(t, err) + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + + err = writer.WriteField("Title", "Template With Variables") + require.NoError(t, err) + + err = writer.WriteField("Description", "A description") + require.NoError(t, err) + + err = writer.WriteField("Type", "2") + require.NoError(t, err) + + err = writer.WriteField("Platform", "1") + require.NoError(t, err) + + err = writer.WriteField("Variables", string(vars)) + require.NoError(t, err) + + part, err := writer.CreateFormFile("File", "docker-compose.yml") + require.NoError(t, err) + + _, err = part.Write([]byte("version: '3'")) + require.NoError(t, err) + + require.NoError(t, writer.Close()) + + r := httptest.NewRequest(http.MethodPost, "/custom_templates/create/file", &body) + r.Header.Set("Content-Type", writer.FormDataContentType()) + r = mux.SetURLVars(r, map[string]string{"method": "file"}) + ctx := security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole}) + r = r.WithContext(ctx) + r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{ + UserID: 1, + IsAdmin: true, + User: &portainer.User{ID: 1, Role: portainer.AdministratorRole}, + })) + + rr := httptest.NewRecorder() + herr := handler.customTemplateCreate(rr, r) + require.Nil(t, herr) + require.Equal(t, http.StatusOK, rr.Code) + + var tmpl portainer.CustomTemplate + require.NoError(t, json.NewDecoder(rr.Body).Decode(&tmpl)) + require.Len(t, tmpl.Variables, 1) + require.Equal(t, "IMAGE", tmpl.Variables[0].Name) + + err = ds.ViewTx(func(tx dataservices.DataStoreTx) error { + stored, err := tx.CustomTemplate().Read(tmpl.ID) + require.NoError(t, err) + require.Len(t, stored.Variables, 1) + + return nil + }) + require.NoError(t, err) +} + +func TestCustomTemplateCreate_FromFileUpload_InvalidVariables(t *testing.T) { + t.Parallel() + + handler, _, _ := newTestHandler(t) + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + + err := writer.WriteField("Title", "My Template") + require.NoError(t, err) + + err = writer.WriteField("Description", "A description") + require.NoError(t, err) + + err = writer.WriteField("Type", "2") + require.NoError(t, err) + + err = writer.WriteField("Platform", "1") + require.NoError(t, err) + + err = writer.WriteField("Variables", "not-valid-json") + require.NoError(t, err) + + part, err := writer.CreateFormFile("File", "docker-compose.yml") + require.NoError(t, err) + + _, err = part.Write([]byte("version: '3'")) + require.NoError(t, err) + + require.NoError(t, writer.Close()) + + r := httptest.NewRequest(http.MethodPost, "/custom_templates/create/file", &body) + r.Header.Set("Content-Type", writer.FormDataContentType()) + r = mux.SetURLVars(r, map[string]string{"method": "file"}) + ctx := security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole}) + r = r.WithContext(ctx) + r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{ + UserID: 1, + IsAdmin: true, + User: &portainer.User{ID: 1, Role: portainer.AdministratorRole}, + })) + + rr := httptest.NewRecorder() + herr := handler.customTemplateCreate(rr, r) + require.NotNil(t, herr) + require.Equal(t, http.StatusInternalServerError, herr.StatusCode) +} + +type gitServiceCreatingFile struct { + portainer.GitService +} + +func (g *gitServiceCreatingFile) CloneRepository(_ context.Context, destination, _, _, _, _ string, _ bool) error { + if err := os.MkdirAll(destination, 0700); err != nil { + return err + } + + f, err := os.Create(filesystem.JoinPaths(destination, filesystem.ComposeFileDefaultName)) + if err != nil { + return err + } + + return f.Close() +} + +func (g *gitServiceCreatingFile) LatestCommitID(_ context.Context, _, _, _, _ string, _ bool) (string, error) { + return "deadbeef123", nil +} + +func TestCustomTemplateCreate_FromRepository_Success(t *testing.T) { + t.Parallel() + + handler, ds, _ := newTestHandler(t) + handler.GitService = &gitServiceCreatingFile{} + + payload := customTemplateFromGitRepositoryPayload{ + Title: "Git Template", + Description: "Created from git", + RepositoryURL: "https://github.com/example/repo", + Type: portainer.DockerComposeStack, + Platform: portainer.CustomTemplatePlatformLinux, + } + + r := createTemplateRequest(t, "repository", payload, 1, portainer.AdministratorRole) + rr := httptest.NewRecorder() + + herr := handler.customTemplateCreate(rr, r) + require.Nil(t, herr) + require.Equal(t, http.StatusOK, rr.Code) + + var tmpl portainer.CustomTemplate + require.NoError(t, json.NewDecoder(rr.Body).Decode(&tmpl)) + require.Equal(t, "Git Template", tmpl.Title) + require.NotNil(t, tmpl.Artifact) + require.Len(t, tmpl.Artifact.Files, 1) + require.Equal(t, "deadbeef123", tmpl.Artifact.Files[0].Hash) + + err := ds.ViewTx(func(tx dataservices.DataStoreTx) error { + stored, err := tx.CustomTemplate().Read(tmpl.ID) + require.NoError(t, err) + require.NotNil(t, stored.Artifact) + + src, err := tx.Source().Read(adminUserContext, stored.Artifact.Files[0].SourceID) + require.NoError(t, err) + require.Equal(t, portainer.SourceTypeGit, src.Type) + require.Equal(t, "https://github.com/example/repo", src.Git.URL) + + return nil + }) + require.NoError(t, err) +} + +func TestCustomTemplateCreate_FromRepository_DeduplicatesSource(t *testing.T) { + t.Parallel() + + handler, ds, _ := newTestHandler(t) + handler.GitService = &gitServiceCreatingFile{} + + makePayload := func(title string) customTemplateFromGitRepositoryPayload { + return customTemplateFromGitRepositoryPayload{ + Title: title, + Description: "Created from git", + RepositoryURL: "https://github.com/example/repo", + Type: portainer.DockerComposeStack, + Platform: portainer.CustomTemplatePlatformLinux, + } + } + + r1 := createTemplateRequest(t, "repository", makePayload("Template One"), 1, portainer.AdministratorRole) + rr1 := httptest.NewRecorder() + herr := handler.customTemplateCreate(rr1, r1) + require.Nil(t, herr) + + r2 := createTemplateRequest(t, "repository", makePayload("Template Two"), 1, portainer.AdministratorRole) + rr2 := httptest.NewRecorder() + herr = handler.customTemplateCreate(rr2, r2) + require.Nil(t, herr) + + err := ds.ViewTx(func(tx dataservices.DataStoreTx) error { + sources, err := tx.Source().ReadAll(adminUserContext) + require.NoError(t, err) + require.Len(t, sources, 1, "two templates with the same URL must share one Source") + + return nil + }) + require.NoError(t, err) +} + +func TestCustomTemplateCreate_FromRepository_Validation_MissingTitle(t *testing.T) { + t.Parallel() + + handler, _, _ := newTestHandler(t) + + payload := customTemplateFromGitRepositoryPayload{ + Description: "A description", + RepositoryURL: "https://github.com/example/repo", + Type: portainer.DockerComposeStack, + Platform: portainer.CustomTemplatePlatformLinux, + } + + r := createTemplateRequest(t, "repository", payload, 1, portainer.AdministratorRole) + rr := httptest.NewRecorder() + + herr := handler.customTemplateCreate(rr, r) + require.NotNil(t, herr) + require.Equal(t, http.StatusInternalServerError, herr.StatusCode) +} + +func TestCustomTemplateCreate_FromRepository_Validation_MissingDescription(t *testing.T) { + t.Parallel() + + handler, _, _ := newTestHandler(t) + + payload := customTemplateFromGitRepositoryPayload{ + Title: "My Template", + RepositoryURL: "https://github.com/example/repo", + Type: portainer.DockerComposeStack, + Platform: portainer.CustomTemplatePlatformLinux, + } + + r := createTemplateRequest(t, "repository", payload, 1, portainer.AdministratorRole) + rr := httptest.NewRecorder() + + herr := handler.customTemplateCreate(rr, r) + require.NotNil(t, herr) + require.Equal(t, http.StatusInternalServerError, herr.StatusCode) +} + +func TestCustomTemplateCreate_FromRepository_Validation_InvalidURL(t *testing.T) { + t.Parallel() + + handler, _, _ := newTestHandler(t) + + payload := customTemplateFromGitRepositoryPayload{ + Title: "My Template", + Description: "A description", + RepositoryURL: "http://", + Type: portainer.DockerComposeStack, + Platform: portainer.CustomTemplatePlatformLinux, + } + + r := createTemplateRequest(t, "repository", payload, 1, portainer.AdministratorRole) + rr := httptest.NewRecorder() + + herr := handler.customTemplateCreate(rr, r) + require.NotNil(t, herr) + require.Equal(t, http.StatusInternalServerError, herr.StatusCode) +} + +func TestCustomTemplateCreate_FromRepository_Validation_AuthWithoutCredentials(t *testing.T) { + t.Parallel() + + handler, _, _ := newTestHandler(t) + + payload := customTemplateFromGitRepositoryPayload{ + Title: "My Template", + Description: "A description", + RepositoryURL: "https://github.com/example/repo", + RepositoryAuthentication: true, + Type: portainer.DockerComposeStack, + Platform: portainer.CustomTemplatePlatformLinux, + } + + r := createTemplateRequest(t, "repository", payload, 1, portainer.AdministratorRole) + rr := httptest.NewRecorder() + + herr := handler.customTemplateCreate(rr, r) + require.NotNil(t, herr) + require.Equal(t, http.StatusInternalServerError, herr.StatusCode) +} + +func TestCustomTemplateCreate_FromRepository_Validation_InvalidPlatform(t *testing.T) { + t.Parallel() + + handler, _, _ := newTestHandler(t) + + payload := customTemplateFromGitRepositoryPayload{ + Title: "My Template", + Description: "A description", + RepositoryURL: "https://github.com/example/repo", + Type: portainer.DockerComposeStack, + Platform: 0, + } + + r := createTemplateRequest(t, "repository", payload, 1, portainer.AdministratorRole) + rr := httptest.NewRecorder() + + herr := handler.customTemplateCreate(rr, r) + require.NotNil(t, herr) + require.Equal(t, http.StatusInternalServerError, herr.StatusCode) +} + +func TestCustomTemplateCreate_FromRepository_Validation_InvalidType(t *testing.T) { + t.Parallel() + + handler, _, _ := newTestHandler(t) + + payload := customTemplateFromGitRepositoryPayload{ + Title: "My Template", + Description: "A description", + RepositoryURL: "https://github.com/example/repo", + Type: 0, + Platform: portainer.CustomTemplatePlatformLinux, + } + + r := createTemplateRequest(t, "repository", payload, 1, portainer.AdministratorRole) + rr := httptest.NewRecorder() + + herr := handler.customTemplateCreate(rr, r) + require.NotNil(t, herr) + require.Equal(t, http.StatusInternalServerError, herr.StatusCode) +} + +func TestCustomTemplateCreate_FromRepository_WithSourceID_Success(t *testing.T) { + t.Parallel() + + handler, ds, _ := newTestHandler(t) + handler.GitService = &gitServiceCreatingFile{} + + var srcID portainer.SourceID + require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error { + src := &portainer.Source{ + Name: "example/repo", + Type: portainer.SourceTypeGit, + Git: &gittypes.GitSource{ + URL: "https://github.com/example/repo", + }, + } + err := tx.Source().Create(adminUserContext, src) + require.NoError(t, err) + srcID = src.ID + return nil + })) + + payload := customTemplateFromGitRepositoryPayload{ + Title: "Source Template", + Description: "Created from source ID", + SourceID: srcID, + Type: portainer.DockerComposeStack, + Platform: portainer.CustomTemplatePlatformLinux, + } + + r := createTemplateRequest(t, "repository", payload, 1, portainer.AdministratorRole) + rr := httptest.NewRecorder() + + herr := handler.customTemplateCreate(rr, r) + require.Nil(t, herr) + require.Equal(t, http.StatusOK, rr.Code) + + var tmpl portainer.CustomTemplate + require.NoError(t, json.NewDecoder(rr.Body).Decode(&tmpl)) + require.NotNil(t, tmpl.Artifact) + require.Len(t, tmpl.Artifact.Files, 1) + require.Equal(t, srcID, tmpl.Artifact.Files[0].SourceID) + require.Equal(t, "deadbeef123", tmpl.Artifact.Files[0].Hash) +} + +func TestCustomTemplateCreate_FromRepository_WithSourceID_NonExistentSource(t *testing.T) { + t.Parallel() + + handler, _, _ := newTestHandler(t) + handler.GitService = &gitServiceCreatingFile{} + + payload := customTemplateFromGitRepositoryPayload{ + Title: "Source Template", + Description: "Created from non-existent source ID", + SourceID: 999, + Type: portainer.DockerComposeStack, + Platform: portainer.CustomTemplatePlatformLinux, + } + + r := createTemplateRequest(t, "repository", payload, 1, portainer.AdministratorRole) + rr := httptest.NewRecorder() + + herr := handler.customTemplateCreate(rr, r) + require.NotNil(t, herr) + require.Equal(t, http.StatusInternalServerError, herr.StatusCode) +} diff --git a/api/http/handler/customtemplates/customtemplate_delete.go b/api/http/handler/customtemplates/customtemplate_delete.go new file mode 100644 index 0000000..bb8e6d5 --- /dev/null +++ b/api/http/handler/customtemplates/customtemplate_delete.go @@ -0,0 +1,79 @@ +package customtemplates + +import ( + "net/http" + "strconv" + + portainer "github.com/portainer/portainer/api" + httperrors "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/api/http/security" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" + + "github.com/rs/zerolog/log" +) + +// @id CustomTemplateDelete +// @summary Remove a template +// @description Remove a template. +// @description **Access policy**: authenticated +// @tags custom_templates +// @security ApiKeyAuth +// @security jwt +// @param id path int true "Template identifier" +// @success 204 "Success" +// @failure 400 "Invalid request" +// @failure 403 "Access denied to resource" +// @failure 404 "Template not found" +// @failure 500 "Server error" +// @router /custom_templates/{id} [delete] +func (handler *Handler) customTemplateDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + customTemplateID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid Custom template identifier route variable", err) + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) + } + + customTemplate, err := handler.DataStore.CustomTemplate().Read(portainer.CustomTemplateID(customTemplateID)) + if handler.DataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find a custom template with the specified identifier inside the database", err) + } else if err != nil { + return httperror.InternalServerError("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.InternalServerError("Unable to retrieve a resource control associated to the custom template", err) + } + + customTemplate.ResourceControl = resourceControl + access := userCanEditTemplate(customTemplate, securityContext) + if !access { + return httperror.Forbidden("Access denied to resource", httperrors.ErrResourceAccessDenied) + } + + err = handler.DataStore.CustomTemplate().Delete(portainer.CustomTemplateID(customTemplateID)) + if err != nil { + return httperror.InternalServerError("Unable to remove the custom template from the database", err) + } + + err = handler.FileService.RemoveDirectory(customTemplate.ProjectPath) + if err != nil { + log.Warn().Err(err).Msg("Unable to remove custom template files from disk") + } + + if resourceControl != nil { + err = handler.DataStore.ResourceControl().Delete(resourceControl.ID) + if err != nil { + return httperror.InternalServerError("Unable to remove the associated resource control from the database", err) + } + } + + return response.Empty(w) + +} diff --git a/api/http/handler/customtemplates/customtemplate_delete_test.go b/api/http/handler/customtemplates/customtemplate_delete_test.go new file mode 100644 index 0000000..accd437 --- /dev/null +++ b/api/http/handler/customtemplates/customtemplate_delete_test.go @@ -0,0 +1,296 @@ +package customtemplates + +import ( + "net/http" + "net/http/httptest" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/http/security" + + "github.com/gorilla/mux" + "github.com/stretchr/testify/require" +) + +func TestCustomTemplateDelete_NotFound(t *testing.T) { + t.Parallel() + + handler, _, _ := newTestHandler(t) + + r := httptest.NewRequest(http.MethodDelete, "/custom_templates/99", nil) + r = mux.SetURLVars(r, map[string]string{"id": "99"}) + r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{UserID: 1, IsAdmin: true})) + rr := httptest.NewRecorder() + + herr := handler.customTemplateDelete(rr, r) + require.NotNil(t, herr) + require.Equal(t, http.StatusNotFound, herr.StatusCode) +} + +func TestCustomTemplateDelete_Forbidden(t *testing.T) { + t.Parallel() + + handler, store, _ := newTestHandler(t) + + err := store.UpdateTx(func(tx dataservices.DataStoreTx) error { + err := tx.CustomTemplate().Create(&portainer.CustomTemplate{ + ID: 1, + CreatedByUserID: 1, + }) + require.NoError(t, err) + + return nil + }) + require.NoError(t, err) + + // User 2 did not create this template and is not an admin + r := httptest.NewRequest(http.MethodDelete, "/custom_templates/1", nil) + r = mux.SetURLVars(r, map[string]string{"id": "1"}) + r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{UserID: 2})) + rr := httptest.NewRecorder() + + herr := handler.customTemplateDelete(rr, r) + require.NotNil(t, herr) + require.Equal(t, http.StatusForbidden, herr.StatusCode) +} + +func TestCustomTemplateDelete_CreatorDeniedWhenAdminOnly(t *testing.T) { + t.Parallel() + + handler, store, _ := newTestHandler(t) + + err := store.UpdateTx(func(tx dataservices.DataStoreTx) error { + err := tx.CustomTemplate().Create(&portainer.CustomTemplate{ + ID: 1, + CreatedByUserID: 2, + }) + require.NoError(t, err) + + err = tx.ResourceControl().Create(&portainer.ResourceControl{ + ID: 1, + ResourceID: "1", + Type: portainer.CustomTemplateResourceControl, + AdministratorsOnly: true, + }) + require.NoError(t, err) + + return nil + }) + require.NoError(t, err) + + // User 2 created the template but an admin later changed it to admins-only + r := httptest.NewRequest(http.MethodDelete, "/custom_templates/1", nil) + r = mux.SetURLVars(r, map[string]string{"id": "1"}) + r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{UserID: 2})) + rr := httptest.NewRecorder() + + herr := handler.customTemplateDelete(rr, r) + require.NotNil(t, herr) + require.Equal(t, http.StatusForbidden, herr.StatusCode) +} + +func TestCustomTemplateDelete_CreatorDeniedWithoutResourceControl(t *testing.T) { + t.Parallel() + + handler, store, _ := newTestHandler(t) + + err := store.UpdateTx(func(tx dataservices.DataStoreTx) error { + return tx.CustomTemplate().Create(&portainer.CustomTemplate{ + ID: 1, + CreatedByUserID: 2, + }) + }) + require.NoError(t, err) + + // User 2 created this template but there is no resource control + r := httptest.NewRequest(http.MethodDelete, "/custom_templates/1", nil) + r = mux.SetURLVars(r, map[string]string{"id": "1"}) + r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{UserID: 2})) + rr := httptest.NewRecorder() + + herr := handler.customTemplateDelete(rr, r) + require.NotNil(t, herr) + require.Equal(t, http.StatusForbidden, herr.StatusCode) +} + +func TestCustomTemplateDelete_Success(t *testing.T) { + t.Parallel() + + handler, store, _ := newTestHandler(t) + + err := store.UpdateTx(func(tx dataservices.DataStoreTx) error { + err := tx.CustomTemplate().Create(&portainer.CustomTemplate{ + ID: 1, + CreatedByUserID: 2, + }) + require.NoError(t, err) + + err = tx.ResourceControl().Create(&portainer.ResourceControl{ + ID: 1, + ResourceID: "1", + Type: portainer.CustomTemplateResourceControl, + UserAccesses: []portainer.UserResourceAccess{{UserID: 2}}, + }) + require.NoError(t, err) + + return nil + }) + require.NoError(t, err) + + r := httptest.NewRequest(http.MethodDelete, "/custom_templates/1", nil) + r = mux.SetURLVars(r, map[string]string{"id": "1"}) + r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{UserID: 2})) + rr := httptest.NewRecorder() + + herr := handler.customTemplateDelete(rr, r) + require.Nil(t, herr) + require.Equal(t, http.StatusNoContent, rr.Code) + + err = store.ViewTx(func(tx dataservices.DataStoreTx) error { + _, err := tx.CustomTemplate().Read(1) + require.True(t, tx.IsErrObjectNotFound(err)) + + return nil + }) + require.NoError(t, err) +} + +func TestCustomTemplateDelete_AdminCanDeleteAdminOnly(t *testing.T) { + t.Parallel() + + handler, store, _ := newTestHandler(t) + + err := store.UpdateTx(func(tx dataservices.DataStoreTx) error { + err := tx.CustomTemplate().Create(&portainer.CustomTemplate{ + ID: 1, + CreatedByUserID: 2, + }) + require.NoError(t, err) + + err = tx.ResourceControl().Create(&portainer.ResourceControl{ + ID: 1, + ResourceID: "1", + Type: portainer.CustomTemplateResourceControl, + AdministratorsOnly: true, + }) + require.NoError(t, err) + + return nil + }) + require.NoError(t, err) + + r := httptest.NewRequest(http.MethodDelete, "/custom_templates/1", nil) + r = mux.SetURLVars(r, map[string]string{"id": "1"}) + r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{UserID: 1, IsAdmin: true})) + rr := httptest.NewRecorder() + + herr := handler.customTemplateDelete(rr, r) + require.Nil(t, herr) + require.Equal(t, http.StatusNoContent, rr.Code) +} + +func TestCustomTemplateDelete_PublicTemplateAllowsAnyUser(t *testing.T) { + t.Parallel() + + handler, store, _ := newTestHandler(t) + + err := store.UpdateTx(func(tx dataservices.DataStoreTx) error { + err := tx.CustomTemplate().Create(&portainer.CustomTemplate{ + ID: 1, + CreatedByUserID: 1, + }) + require.NoError(t, err) + + err = tx.ResourceControl().Create(&portainer.ResourceControl{ + ID: 1, + ResourceID: "1", + Type: portainer.CustomTemplateResourceControl, + Public: true, + }) + require.NoError(t, err) + + return nil + }) + require.NoError(t, err) + + // User 2 is not the creator but the template is public + r := httptest.NewRequest(http.MethodDelete, "/custom_templates/1", nil) + r = mux.SetURLVars(r, map[string]string{"id": "1"}) + r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{UserID: 2})) + rr := httptest.NewRecorder() + + herr := handler.customTemplateDelete(rr, r) + require.Nil(t, herr) + require.Equal(t, http.StatusNoContent, rr.Code) +} + +func TestCustomTemplateDelete_NonCreatorForbiddenWithPrivateRC(t *testing.T) { + t.Parallel() + + handler, store, _ := newTestHandler(t) + + err := store.UpdateTx(func(tx dataservices.DataStoreTx) error { + err := tx.CustomTemplate().Create(&portainer.CustomTemplate{ + ID: 1, + CreatedByUserID: 1, + }) + require.NoError(t, err) + + err = tx.ResourceControl().Create(&portainer.ResourceControl{ + ID: 1, + ResourceID: "1", + Type: portainer.CustomTemplateResourceControl, + UserAccesses: []portainer.UserResourceAccess{{UserID: 1}}, + }) + require.NoError(t, err) + + return nil + }) + require.NoError(t, err) + + // User 2 is not the creator and the template has a private resource control + r := httptest.NewRequest(http.MethodDelete, "/custom_templates/1", nil) + r = mux.SetURLVars(r, map[string]string{"id": "1"}) + r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{UserID: 2})) + rr := httptest.NewRecorder() + + herr := handler.customTemplateDelete(rr, r) + require.NotNil(t, herr) + require.Equal(t, http.StatusForbidden, herr.StatusCode) +} + +func TestCustomTemplateDelete_CreatorDeniedWithoutAccess(t *testing.T) { + t.Parallel() + + handler, store, _ := newTestHandler(t) + + err := store.UpdateTx(func(tx dataservices.DataStoreTx) error { + err := tx.CustomTemplate().Create(&portainer.CustomTemplate{ + ID: 1, + CreatedByUserID: 2, + }) + require.NoError(t, err) + + // RC exists but only grants access to user 3, not the creator (user 2) + err = tx.ResourceControl().Create(&portainer.ResourceControl{ + ID: 1, + ResourceID: "1", + Type: portainer.CustomTemplateResourceControl, + UserAccesses: []portainer.UserResourceAccess{{UserID: 3}}, + }) + require.NoError(t, err) + + return nil + }) + require.NoError(t, err) + + r := httptest.NewRequest(http.MethodDelete, "/custom_templates/1", nil) + r = mux.SetURLVars(r, map[string]string{"id": "1"}) + r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{UserID: 2})) + rr := httptest.NewRecorder() + + herr := handler.customTemplateDelete(rr, r) + require.NotNil(t, herr) + require.Equal(t, http.StatusForbidden, herr.StatusCode) +} diff --git a/api/http/handler/customtemplates/customtemplate_file.go b/api/http/handler/customtemplates/customtemplate_file.go new file mode 100644 index 0000000..934b6e6 --- /dev/null +++ b/api/http/handler/customtemplates/customtemplate_file.go @@ -0,0 +1,93 @@ +package customtemplates + +import ( + "net/http" + "strconv" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + httperrors "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/authorization" + "github.com/portainer/portainer/api/slicesx" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +type fileResponse struct { + FileContent string +} + +// @id CustomTemplateFile +// @summary Get Template stack file content. +// @description Retrieve the content of the Stack file for the specified custom template +// @description **Access policy**: authenticated +// @tags custom_templates +// @security ApiKeyAuth +// @security jwt +// @produce json +// @param id path int true "Template identifier" +// @success 200 {object} fileResponse "Success" +// @failure 400 "Invalid request" +// @failure 404 "Custom template not found" +// @failure 500 "Server error" +// @router /custom_templates/{id}/file [get] +func (handler *Handler) customTemplateFile(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + customTemplateID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid custom template identifier route variable", err) + } + + var customTemplate *portainer.CustomTemplate + if err := handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error { + var err error + customTemplate, err = tx.CustomTemplate().Read(portainer.CustomTemplateID(customTemplateID)) + if tx.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find a custom template with the specified identifier inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find a custom template with the specified identifier inside the database", err) + } + + resourceControl, err := tx.ResourceControl().ResourceControlByResourceIDAndType(strconv.Itoa(customTemplateID), portainer.CustomTemplateResourceControl) + if err != nil { + return httperror.InternalServerError("Unable to retrieve a resource control associated to the custom template", err) + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve user info from request context", err) + } + + customTemplate.ResourceControl = resourceControl + canEdit := userCanEditTemplate(customTemplate, securityContext) + hasAccess := false + + if resourceControl != nil { + teamIDs := slicesx.Map(securityContext.UserMemberships, func(m portainer.TeamMembership) portainer.TeamID { + return m.TeamID + }) + + hasAccess = authorization.UserCanAccessResource(securityContext.UserID, teamIDs, resourceControl) + } + + if canEdit || hasAccess { + return nil + } + + return httperror.Forbidden("Access denied to resource", httperrors.ErrResourceAccessDenied) + }); err != nil { + return response.TxErrorResponse(err) + } + + entryPath := customTemplate.EntryPoint + if customTemplate.Artifact != nil && len(customTemplate.Artifact.Files) > 0 { + entryPath = customTemplate.Artifact.Files[0].Path + } + fileContent, err := handler.FileService.GetFileContent(customTemplate.ProjectPath, entryPath) + if err != nil { + return httperror.InternalServerError("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_file_test.go b/api/http/handler/customtemplates/customtemplate_file_test.go new file mode 100644 index 0000000..977df0f --- /dev/null +++ b/api/http/handler/customtemplates/customtemplate_file_test.go @@ -0,0 +1,184 @@ +package customtemplates + +import ( + "net/http" + "net/http/httptest" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + gittypes "github.com/portainer/portainer/api/git/types" + "github.com/portainer/portainer/api/http/security" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + + "github.com/gorilla/mux" + "github.com/segmentio/encoding/json" + "github.com/stretchr/testify/require" +) + +func TestCustomTemplateFile(t *testing.T) { + t.Parallel() + + handler, ds, fs := newTestHandler(t) + + templateContent := "some template content" + templateEntrypoint := "entrypoint" + + require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error { + // template 1 + path, err := fs.StoreCustomTemplateFileFromBytes("1", templateEntrypoint, []byte(templateContent)) + require.NoError(t, err) + require.NoError(t, tx.CustomTemplate().Create(&portainer.CustomTemplate{ID: 1, EntryPoint: templateEntrypoint, ProjectPath: path})) + + // template 2 + path, err = fs.StoreCustomTemplateFileFromBytes("2", templateEntrypoint, []byte(templateContent)) + require.NoError(t, err) + require.NoError(t, tx.CustomTemplate().Create(&portainer.CustomTemplate{ID: 2, EntryPoint: templateEntrypoint, ProjectPath: path})) + + require.NoError(t, tx.ResourceControl().Create(&portainer.ResourceControl{ + ID: 1, ResourceID: "2", Type: portainer.CustomTemplateResourceControl, + UserAccesses: []portainer.UserResourceAccess{{UserID: 2}}, + TeamAccesses: []portainer.TeamResourceAccess{{TeamID: 1}}, + })) + return nil + })) + + test := func(templateID string, restrictedContext *security.RestrictedRequestContext) (*httptest.ResponseRecorder, *httperror.HandlerError) { + r := httptest.NewRequest(http.MethodGet, "/custom_templates/"+templateID+"/file", nil) + r = mux.SetURLVars(r, map[string]string{"id": templateID}) + ctx := security.StoreRestrictedRequestContext(r, restrictedContext) + r = r.WithContext(ctx) + rr := httptest.NewRecorder() + return rr, handler.customTemplateFile(rr, r) + } + + t.Run("unknown id should get not found error", func(t *testing.T) { + _, r := test("0", &security.RestrictedRequestContext{UserID: 1}) + require.NotNil(t, r) + require.Equal(t, http.StatusNotFound, r.StatusCode) + }) + + t.Run("admin should access adminonly template", func(t *testing.T) { + rr, r := test("1", &security.RestrictedRequestContext{UserID: 1, IsAdmin: true}) + require.Nil(t, r) + require.Equal(t, http.StatusOK, rr.Result().StatusCode) + + var res struct{ FileContent string } + require.NoError(t, json.NewDecoder(rr.Body).Decode(&res)) + require.Equal(t, templateContent, res.FileContent) + }) + + t.Run("std should not access adminonly template", func(t *testing.T) { + _, r := test("1", &security.RestrictedRequestContext{UserID: 2}) + require.NotNil(t, r) + require.Equal(t, http.StatusForbidden, r.StatusCode) + }) + + t.Run("std should access template via direct user access", func(t *testing.T) { + rr, r := test("2", &security.RestrictedRequestContext{UserID: 2}) + require.Nil(t, r) + require.Equal(t, http.StatusOK, rr.Result().StatusCode) + + var res struct{ FileContent string } + require.NoError(t, json.NewDecoder(rr.Body).Decode(&res)) + require.Equal(t, templateContent, res.FileContent) + }) + + t.Run("std should access template via team access", func(t *testing.T) { + rr, r := test("2", &security.RestrictedRequestContext{UserID: 3, UserMemberships: []portainer.TeamMembership{{ID: 1, UserID: 3, TeamID: 1}}}) + require.Nil(t, r) + require.Equal(t, http.StatusOK, rr.Result().StatusCode) + + var res struct{ FileContent string } + require.NoError(t, json.NewDecoder(rr.Body).Decode(&res)) + require.Equal(t, templateContent, res.FileContent) + }) + + t.Run("std should not access template without access", func(t *testing.T) { + _, r := test("2", &security.RestrictedRequestContext{UserID: 4}) + require.NotNil(t, r) + require.Equal(t, http.StatusForbidden, r.StatusCode) + }) +} + +func TestCustomTemplateFile_CreatorDeniedWhenAdminOnly(t *testing.T) { + t.Parallel() + + handler, store, fs := newTestHandler(t) + + err := store.UpdateTx(func(tx dataservices.DataStoreTx) error { + path, err := fs.StoreCustomTemplateFileFromBytes("5", "entrypoint", []byte("content")) + require.NoError(t, err) + + err = tx.CustomTemplate().Create(&portainer.CustomTemplate{ + ID: 5, + EntryPoint: "entrypoint", + ProjectPath: path, + CreatedByUserID: 2, + }) + require.NoError(t, err) + + err = tx.ResourceControl().Create(&portainer.ResourceControl{ + ID: 5, + ResourceID: "5", + Type: portainer.CustomTemplateResourceControl, + AdministratorsOnly: true, + }) + require.NoError(t, err) + + return nil + }) + require.NoError(t, err) + + r := httptest.NewRequest(http.MethodGet, "/custom_templates/5/file", nil) + r = mux.SetURLVars(r, map[string]string{"id": "5"}) + r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{UserID: 2})) + rr := httptest.NewRecorder() + + herr := handler.customTemplateFile(rr, r) + require.NotNil(t, herr) + require.Equal(t, http.StatusForbidden, herr.StatusCode) +} + +func TestCustomTemplateFile_GitTemplate(t *testing.T) { + t.Parallel() + + handler, ds, fs := newTestHandler(t) + + templateContent := "git template content" + configFilePath := "docker-compose.yml" + + require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error { + src := &portainer.Source{ + Type: portainer.SourceTypeGit, + Git: &gittypes.GitSource{URL: "https://github.com/example/repo"}, + } + err := tx.Source().Create(adminUserContext, src) + require.NoError(t, err) + + path, err := fs.StoreCustomTemplateFileFromBytes("10", configFilePath, []byte(templateContent)) + require.NoError(t, err) + + return tx.CustomTemplate().Create(&portainer.CustomTemplate{ + ID: 10, + EntryPoint: "should-not-be-used.yml", + ProjectPath: path, + Artifact: &portainer.Artifact{ + Files: []portainer.ArtifactFile{{Path: configFilePath, SourceID: src.ID}}, + }, + }) + })) + + r := httptest.NewRequest(http.MethodGet, "/custom_templates/10/file", nil) + r = mux.SetURLVars(r, map[string]string{"id": "10"}) + ctx := security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{UserID: 1, IsAdmin: true}) + r = r.WithContext(ctx) + rr := httptest.NewRecorder() + herr := handler.customTemplateFile(rr, r) + require.Nil(t, herr) + require.Equal(t, http.StatusOK, rr.Result().StatusCode) + + var res struct{ FileContent string } + require.NoError(t, json.NewDecoder(rr.Body).Decode(&res)) + require.Equal(t, templateContent, res.FileContent) +} diff --git a/api/http/handler/customtemplates/customtemplate_git_fetch.go b/api/http/handler/customtemplates/customtemplate_git_fetch.go new file mode 100644 index 0000000..b9c4e80 --- /dev/null +++ b/api/http/handler/customtemplates/customtemplate_git_fetch.go @@ -0,0 +1,162 @@ +package customtemplates + +import ( + "context" + "net/http" + "os" + "sync" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/dataservices/source" + gittypes "github.com/portainer/portainer/api/git/types" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/stacks/stackutils" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" + + "github.com/rs/zerolog/log" +) + +// @id CustomTemplateGitFetch +// @summary Fetch the latest config file content based on custom template's git repository configuration +// @description Retrieve details about a template created from git repository method. +// @description **Access policy**: authenticated +// @tags custom_templates +// @security ApiKeyAuth +// @security jwt +// @produce json +// @param id path int true "Template identifier" +// @success 200 {object} fileResponse "Success" +// @failure 400 "Invalid request" +// @failure 404 "Custom template not found" +// @failure 500 "Server error" +// @router /custom_templates/{id}/git_fetch [put] +func (handler *Handler) customTemplateGitFetch(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + customTemplateID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid Custom template identifier route variable", err) + } + + customTemplate, err := handler.DataStore.CustomTemplate().Read(portainer.CustomTemplateID(customTemplateID)) + if handler.DataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find a custom template with the specified identifier inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find a custom template with the specified identifier inside the database", err) + } + + if customTemplate.Artifact == nil || len(customTemplate.Artifact.Files) == 0 { + return httperror.BadRequest("Git configuration does not exist in this custom template", nil) + } + + file := customTemplate.Artifact.Files[0] + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) + } + + var src *portainer.Source + if err := handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error { + var err error + + userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships) + src, err = tx.Source().Read(userContext, file.SourceID) + if err != nil { + return httperror.InternalServerError("Unable to retrieve git source for custom template", err) + } + return nil + }); err != nil { + return response.TxErrorResponse(err) + } + + if src.Git == nil { + return httperror.InternalServerError("Source has no git configuration", nil) + } + + gitConfig := &gittypes.RepoConfig{ + URL: src.Git.URL, + Authentication: src.Git.Authentication, + TLSSkipVerify: src.Git.TLSSkipVerify, + ReferenceName: file.Ref, + ConfigFilePath: file.Path, + ConfigHash: file.Hash, + } + + // If multiple users are trying to fetch the same custom template simultaneously, a lock needs to be added + mu, ok := handler.gitFetchMutexs[portainer.TemplateID(customTemplateID)] + if !ok { + mu = &sync.Mutex{} + handler.gitFetchMutexs[portainer.TemplateID(customTemplateID)] = mu + } + mu.Lock() + defer mu.Unlock() + + // back up the current custom template folder + backupPath, err := backupCustomTemplate(customTemplate.ProjectPath) + if err != nil { + return httperror.InternalServerError("Failed to backup the custom template folder", err) + } + + // remove backup custom template folder + defer func() { + if err := cleanUpBackupCustomTemplate(backupPath); err != nil { + log.Warn().Err(err).Msg("failed to remove backup custom template folder") + } + }() + + commitHash, err := stackutils.DownloadGitRepository(context.TODO(), *gitConfig, handler.GitService, func() string { + return customTemplate.ProjectPath + }) + if err != nil { + log.Warn().Err(err).Msg("failed to download git repository") + + if rbErr := rollbackCustomTemplate(backupPath, customTemplate.ProjectPath); rbErr != nil { + return httperror.InternalServerError("Failed to rollback the custom template folder", rbErr) + } + + return httperror.InternalServerError("Failed to download git repository", err) + } + + if customTemplate.Artifact.Files[0].Hash != commitHash { + customTemplate.Artifact.Files[0].Hash = commitHash + + if err := handler.DataStore.CustomTemplate().Update(customTemplate.ID, customTemplate); err != nil { + return httperror.InternalServerError("Unable to persist custom template changes inside the database", err) + } + } + + fileContent, err := handler.FileService.GetFileContent(customTemplate.ProjectPath, gitConfig.ConfigFilePath) + if err != nil { + return httperror.InternalServerError("Unable to retrieve custom template file from disk", err) + } + + return response.JSON(w, &fileResponse{FileContent: string(fileContent)}) +} + +func backupCustomTemplate(projectPath string) (string, error) { + stat, err := os.Stat(projectPath) + if err != nil { + return "", err + } + + backupPath := projectPath + "-backup" + if err := os.Rename(projectPath, backupPath); err != nil { + return "", err + } + + return backupPath, os.Mkdir(projectPath, stat.Mode()) +} + +func rollbackCustomTemplate(backupPath, projectPath string) error { + if err := os.RemoveAll(projectPath); err != nil { + return err + } + + return os.Rename(backupPath, projectPath) +} + +func cleanUpBackupCustomTemplate(backupPath string) error { + return os.RemoveAll(backupPath) +} diff --git a/api/http/handler/customtemplates/customtemplate_git_fetch_test.go b/api/http/handler/customtemplates/customtemplate_git_fetch_test.go new file mode 100644 index 0000000..c4d2696 --- /dev/null +++ b/api/http/handler/customtemplates/customtemplate_git_fetch_test.go @@ -0,0 +1,339 @@ +package customtemplates + +import ( + "bytes" + "context" + "errors" + "io" + "io/fs" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/filesystem" + gittypes "github.com/portainer/portainer/api/git/types" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/authorization" + "github.com/portainer/portainer/api/internal/testhelpers" + "github.com/portainer/portainer/api/jwt" + "github.com/portainer/portainer/api/logs" + "github.com/portainer/portainer/pkg/fips" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + + "github.com/segmentio/encoding/json" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/sync/errgroup" +) + +func init() { + fips.InitFIPS(false) +} + +var testFileContent = "abcdefg" + +type TestGitService struct { + portainer.GitService + targetFilePath string +} + +func (g *TestGitService) CloneRepository( + _ context.Context, + destination string, + repositoryURL, + referenceName string, + username, + password string, + tlsSkipVerify bool, +) error { + time.Sleep(100 * time.Millisecond) + + return createTestFile(g.targetFilePath) +} + +func (g *TestGitService) LatestCommitID( + _ context.Context, + repositoryURL, + referenceName, + username, + password string, + tlsSkipVerify bool, +) (string, error) { + return "", nil +} + +type TestFileService struct { + portainer.FileService +} + +func (f *TestFileService) GetFileContent(projectPath, configFilePath string) ([]byte, error) { + return os.ReadFile(filesystem.JoinPaths(projectPath, configFilePath)) +} + +type InvalidTestGitService struct { + portainer.GitService + targetFilePath string +} + +func (g *InvalidTestGitService) CloneRepository( + _ context.Context, + dest, + repoUrl, + refName, + username, + password string, + tlsSkipVerify bool, +) error { + return errors.New("simulate network error") +} + +func (g *InvalidTestGitService) LatestCommitID( + _ context.Context, + repositoryURL, + referenceName, + username, + password string, + tlsSkipVerify bool, +) (string, error) { + return "", nil +} + +func createTestFile(targetPath string) error { + f, err := os.Create(targetPath) + if err != nil { + return err + } + defer logs.CloseAndLogErr(f) + + _, err = f.WriteString(testFileContent) + + return err +} + +func prepareTestFolder(projectPath, filename string) error { + if err := os.MkdirAll(projectPath, fs.ModePerm); err != nil { + return err + } + + return createTestFile(filesystem.JoinPaths(projectPath, filename)) +} + +func singleAPIRequest(h *Handler, jwt string, expect string) error { + type response struct { + FileContent string + } + + req := httptest.NewRequest(http.MethodPut, "/custom_templates/1/git_fetch", bytes.NewBufferString("{}")) + testhelpers.AddTestSecurityCookie(req, jwt) + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + return errors.New("unexpected status code: " + http.StatusText(rr.Code)) + } + + body, err := io.ReadAll(rr.Body) + if err != nil { + return err + } + + var resp response + if err := json.Unmarshal(body, &resp); err != nil { + return err + } + + if resp.FileContent != expect { + return errors.New("unexpected file content: " + resp.FileContent + ", expected: " + expect) + } + + return nil +} + +func Test_customTemplateGitFetch(t *testing.T) { + t.Parallel() + + is := assert.New(t) + + _, store := datastore.MustNewTestStore(t, true, true) + + // create user(s) + user1 := &portainer.User{ID: 1, Username: "user-1", Role: portainer.StandardUserRole, PortainerAuthorizations: authorization.DefaultPortainerAuthorizations()} + err := store.User().Create(user1) + require.NoError(t, err, "error creating user 1") + + user2 := &portainer.User{ID: 2, Username: "user-2", Role: portainer.StandardUserRole, PortainerAuthorizations: authorization.DefaultPortainerAuthorizations()} + err = store.User().Create(user2) + require.NoError(t, err, "error creating user 2") + + dir, err := os.Getwd() + require.NoError(t, err, "error to get working directory") + + src := &portainer.Source{ + ID: 1, + Type: portainer.SourceTypeGit, + Public: true, + Git: &gittypes.GitSource{ + URL: "https://github.com/example/repo", + }, + } + err = store.Source().Create(adminUserContext, src) + require.NoError(t, err, "error creating source") + + const configFilePath = "test-config-path.txt" + + template1 := &portainer.CustomTemplate{ + ID: 1, + Title: "custom-template-1", + ProjectPath: filesystem.JoinPaths(dir, "fixtures/custom_template_1"), + Artifact: &portainer.Artifact{ + Files: []portainer.ArtifactFile{{Path: configFilePath, SourceID: src.ID}}, + }, + } + err = store.CustomTemplateService.Create(template1) + require.NoError(t, err, "error creating custom template 1") + + // prepare testing folder + err = prepareTestFolder(template1.ProjectPath, configFilePath) + require.NoError(t, err, "error creating testing folder") + + defer func() { + err := os.RemoveAll(filesystem.JoinPaths(dir, "fixtures")) + require.NoError(t, err) + }() + + // setup services + jwtService, err := jwt.NewService("1h", store) + require.NoError(t, err, "Error initiating jwt service") + + requestBouncer := security.NewRequestBouncer(t.Context(), store, jwtService, nil) + + gitService := &TestGitService{ + targetFilePath: filesystem.JoinPaths(template1.ProjectPath, configFilePath), + } + fileService := &TestFileService{} + + h := NewHandler(requestBouncer, store, fileService, gitService) + + // generate two standard users' tokens + jwt1, _, err := jwtService.GenerateToken(&portainer.TokenData{ID: user1.ID, Username: user1.Username, Role: user1.Role}) + require.NoError(t, err) + + jwt2, _, err := jwtService.GenerateToken(&portainer.TokenData{ID: user2.ID, Username: user2.Username, Role: user2.Role}) + require.NoError(t, err) + + t.Run("can return the expected file content by a single call from one user", func(t *testing.T) { + err := singleAPIRequest(h, jwt1, "abcdefg") + require.NoError(t, err) + }) + + t.Run("can return the expected file content by multiple calls from one user", func(t *testing.T) { + var g errgroup.Group + + for range 5 { + g.Go(func() error { + return singleAPIRequest(h, jwt1, "abcdefg") + }) + } + + err := g.Wait() + require.NoError(t, err) + }) + + t.Run("can return the expected file content by multiple calls from different users", func(t *testing.T) { + var g errgroup.Group + + for i := range 10 { + g.Go(func() error { + if i%2 == 0 { + return singleAPIRequest(h, jwt1, "abcdefg") + } + + return singleAPIRequest(h, jwt2, "abcdefg") + }) + } + + err := g.Wait() + require.NoError(t, err) + }) + + t.Run("can return the expected file content after a new commit is made", func(t *testing.T) { + err := singleAPIRequest(h, jwt1, "abcdefg") + require.NoError(t, err) + + testFileContent = "gfedcba" + + err = singleAPIRequest(h, jwt2, "gfedcba") + require.NoError(t, err) + }) + + t.Run("restore git repository if it is failed to download the new git repository", func(t *testing.T) { + invalidGitService := &InvalidTestGitService{ + targetFilePath: filesystem.JoinPaths(template1.ProjectPath, configFilePath), + } + h := NewHandler(requestBouncer, store, fileService, invalidGitService) + + req := httptest.NewRequest(http.MethodPut, "/custom_templates/1/git_fetch", bytes.NewBufferString("{}")) + testhelpers.AddTestSecurityCookie(req, jwt1) + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + is.Equal(http.StatusInternalServerError, rr.Code) + + var errResp httperror.HandlerError + err = json.NewDecoder(rr.Body).Decode(&errResp) + require.NoError(t, err, "failed to parse error body") + + assert.FileExists(t, gitService.targetFilePath, "previous git repository is not restored") + fileContent, err := os.ReadFile(gitService.targetFilePath) + require.NoError(t, err, "failed to read target file") + assert.Equal(t, "gfedcba", string(fileContent)) + }) +} + +func TestCustomTemplateGitFetch_NilArtifactReturnsBadRequest(t *testing.T) { + t.Parallel() + + _, store := datastore.MustNewTestStore(t, false, true) + + template := &portainer.CustomTemplate{ID: 1, Title: "no-git-template"} + err := store.CustomTemplateService.Create(template) + require.NoError(t, err) + + h := NewHandler(testhelpers.NewTestRequestBouncer(), store, &TestFileService{}, &TestGitService{}) + + req := httptest.NewRequest(http.MethodPut, "/custom_templates/1/git_fetch", bytes.NewBufferString("{}")) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + require.Equal(t, http.StatusBadRequest, rr.Code) +} + +func TestCustomTemplateGitFetch_EmptySourceIDsReturnsBadRequest(t *testing.T) { + t.Parallel() + + _, store := datastore.MustNewTestStore(t, false, true) + + template := &portainer.CustomTemplate{ + ID: 1, + Title: "empty-source-ids", + Artifact: &portainer.Artifact{ + Files: []portainer.ArtifactFile{}, + }, + } + err := store.CustomTemplateService.Create(template) + require.NoError(t, err) + + h := NewHandler(testhelpers.NewTestRequestBouncer(), store, &TestFileService{}, &TestGitService{}) + + req := httptest.NewRequest(http.MethodPut, "/custom_templates/1/git_fetch", bytes.NewBufferString("{}")) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + require.Equal(t, http.StatusBadRequest, rr.Code) +} diff --git a/api/http/handler/customtemplates/customtemplate_inspect.go b/api/http/handler/customtemplates/customtemplate_inspect.go new file mode 100644 index 0000000..47863a4 --- /dev/null +++ b/api/http/handler/customtemplates/customtemplate_inspect.go @@ -0,0 +1,81 @@ +package customtemplates + +import ( + "net/http" + "strconv" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/dataservices/source" + httperrors "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/authorization" + "github.com/portainer/portainer/api/slicesx" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +// @id CustomTemplateInspect +// @summary Inspect a custom template +// @description Retrieve details about a template. +// @description **Access policy**: authenticated +// @tags custom_templates +// @security ApiKeyAuth +// @security jwt +// @produce json +// @param id path int true "Template identifier" +// @success 200 {object} portainer.CustomTemplate "Success" +// @failure 400 "Invalid request" +// @failure 404 "Template not found" +// @failure 500 "Server error" +// @router /custom_templates/{id} [get] +func (handler *Handler) customTemplateInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + customTemplateID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid Custom template identifier route variable", err) + } + + var customTemplate *portainer.CustomTemplate + err = handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error { + customTemplate, err = tx.CustomTemplate().Read(portainer.CustomTemplateID(customTemplateID)) + if tx.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find a custom template with the specified identifier inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find a custom template with the specified identifier inside the database", err) + } + + resourceControl, err := tx.ResourceControl().ResourceControlByResourceIDAndType(strconv.Itoa(customTemplateID), portainer.CustomTemplateResourceControl) + if err != nil { + return httperror.InternalServerError("Unable to retrieve a resource control associated to the custom template", err) + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve user info from request context", err) + } + + customTemplate.ResourceControl = resourceControl + canEdit := userCanEditTemplate(customTemplate, securityContext) + hasAccess := false + + if resourceControl != nil { + teamIDs := slicesx.Map(securityContext.UserMemberships, func(m portainer.TeamMembership) portainer.TeamID { + return m.TeamID + }) + + hasAccess = authorization.UserCanAccessResource(securityContext.UserID, teamIDs, resourceControl) + } + + if !canEdit && !hasAccess { + return httperror.Forbidden("Access denied to resource", httperrors.ErrResourceAccessDenied) + } + + userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships) + populateGitConfig(tx, userContext, customTemplate) + + return nil + }) + + return response.TxResponse(w, customTemplate, err) +} diff --git a/api/http/handler/customtemplates/customtemplate_inspect_test.go b/api/http/handler/customtemplates/customtemplate_inspect_test.go new file mode 100644 index 0000000..47dd9cb --- /dev/null +++ b/api/http/handler/customtemplates/customtemplate_inspect_test.go @@ -0,0 +1,212 @@ +package customtemplates + +import ( + "net/http" + "net/http/httptest" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/filesystem" + gittypes "github.com/portainer/portainer/api/git/types" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/testhelpers" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + + "github.com/gorilla/mux" + "github.com/segmentio/encoding/json" + "github.com/stretchr/testify/require" +) + +// newTestHandler creates a handler with a test datastore, filesystem service, and common fixtures: +// admin user (ID=1), standard users (ID=2,3,4), endpoint (ID=1) with access for users 2 and 3, +// team (ID=1), and team membership for user 3. +func newTestHandler(t *testing.T) (*Handler, dataservices.DataStore, portainer.FileService) { + t.Helper() + + _, ds := datastore.MustNewTestStore(t, true, false) + + fs, err := filesystem.NewService(t.TempDir(), t.TempDir()) + require.NoError(t, err) + + require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error { + require.NoError(t, tx.User().Create(&portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole})) + require.NoError(t, tx.User().Create(&portainer.User{ID: 2, Username: "std2", Role: portainer.StandardUserRole})) + require.NoError(t, tx.User().Create(&portainer.User{ID: 3, Username: "std3", Role: portainer.StandardUserRole})) + require.NoError(t, tx.User().Create(&portainer.User{ID: 4, Username: "std4", Role: portainer.StandardUserRole})) + require.NoError(t, tx.Endpoint().Create(&portainer.Endpoint{ + ID: 1, + UserAccessPolicies: portainer.UserAccessPolicies{ + 2: portainer.AccessPolicy{RoleID: 0}, + 3: portainer.AccessPolicy{RoleID: 0}, + }, + })) + require.NoError(t, tx.Team().Create(&portainer.Team{ID: 1})) + require.NoError(t, tx.TeamMembership().Create(&portainer.TeamMembership{ID: 1, UserID: 3, TeamID: 1, Role: portainer.TeamMember})) + return nil + })) + + handler := NewHandler(testhelpers.NewTestRequestBouncer(), ds, fs, nil) + + return handler, ds, fs +} + +func TestInspectHandler(t *testing.T) { + t.Parallel() + + handler, ds, _ := newTestHandler(t) + + require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error { + require.NoError(t, tx.CustomTemplate().Create(&portainer.CustomTemplate{ID: 1})) + require.NoError(t, tx.CustomTemplate().Create(&portainer.CustomTemplate{ID: 2})) + require.NoError(t, tx.ResourceControl().Create(&portainer.ResourceControl{ + ID: 1, ResourceID: "2", Type: portainer.CustomTemplateResourceControl, + UserAccesses: []portainer.UserResourceAccess{{UserID: 2}}, + TeamAccesses: []portainer.TeamResourceAccess{{TeamID: 1}}, + })) + return nil + })) + + test := func(templateID string, restrictedContext *security.RestrictedRequestContext) (*httptest.ResponseRecorder, *httperror.HandlerError) { + r := httptest.NewRequest(http.MethodGet, "/custom_templates/"+templateID, nil) + r = mux.SetURLVars(r, map[string]string{"id": templateID}) + ctx := security.StoreRestrictedRequestContext(r, restrictedContext) + r = r.WithContext(ctx) + rr := httptest.NewRecorder() + return rr, handler.customTemplateInspect(rr, r) + } + + t.Run("unknown id should get not found error", func(t *testing.T) { + _, r := test("0", &security.RestrictedRequestContext{UserID: 1, User: &portainer.User{ID: 1, Role: portainer.AdministratorRole}}) + require.NotNil(t, r) + require.Equal(t, http.StatusNotFound, r.StatusCode) + }) + + t.Run("admin should access adminonly template", func(t *testing.T) { + rr, r := test("1", &security.RestrictedRequestContext{UserID: 1, IsAdmin: true, User: &portainer.User{ID: 1, Role: portainer.AdministratorRole}}) + require.Nil(t, r) + require.Equal(t, http.StatusOK, rr.Result().StatusCode) + + var template portainer.CustomTemplate + require.NoError(t, json.NewDecoder(rr.Body).Decode(&template)) + require.Equal(t, portainer.CustomTemplateID(1), template.ID) + }) + + t.Run("std should not access adminonly template", func(t *testing.T) { + _, r := test("1", &security.RestrictedRequestContext{UserID: 2, User: &portainer.User{ID: 2, Role: portainer.StandardUserRole}}) + require.NotNil(t, r) + require.Equal(t, http.StatusForbidden, r.StatusCode) + }) + + t.Run("std should access template via direct user access", func(t *testing.T) { + rr, r := test("2", &security.RestrictedRequestContext{UserID: 2, User: &portainer.User{ID: 2, Role: portainer.StandardUserRole}}) + require.Nil(t, r) + require.Equal(t, http.StatusOK, rr.Result().StatusCode) + + var template portainer.CustomTemplate + require.NoError(t, json.NewDecoder(rr.Body).Decode(&template)) + require.Equal(t, portainer.CustomTemplateID(2), template.ID) + }) + + t.Run("std should access template via team access", func(t *testing.T) { + rr, r := test("2", &security.RestrictedRequestContext{UserID: 3, User: &portainer.User{ID: 3, Role: portainer.StandardUserRole}, UserMemberships: []portainer.TeamMembership{{ID: 1, UserID: 3, TeamID: 1}}}) + require.Nil(t, r) + require.Equal(t, http.StatusOK, rr.Result().StatusCode) + + var template portainer.CustomTemplate + require.NoError(t, json.NewDecoder(rr.Body).Decode(&template)) + require.Equal(t, portainer.CustomTemplateID(2), template.ID) + }) + + t.Run("std should not access template without access", func(t *testing.T) { + _, r := test("2", &security.RestrictedRequestContext{UserID: 4, User: &portainer.User{ID: 4, Role: portainer.StandardUserRole}}) + require.NotNil(t, r) + require.Equal(t, http.StatusForbidden, r.StatusCode) + }) +} + +func TestInspectHandler_CreatorDeniedWhenAdminOnly(t *testing.T) { + t.Parallel() + + handler, store, _ := newTestHandler(t) + + err := store.UpdateTx(func(tx dataservices.DataStoreTx) error { + err := tx.CustomTemplate().Create(&portainer.CustomTemplate{ + ID: 5, + CreatedByUserID: 2, + }) + require.NoError(t, err) + + err = tx.ResourceControl().Create(&portainer.ResourceControl{ + ID: 5, + ResourceID: "5", + Type: portainer.CustomTemplateResourceControl, + AdministratorsOnly: true, + }) + require.NoError(t, err) + + return nil + }) + require.NoError(t, err) + + r := httptest.NewRequest(http.MethodGet, "/custom_templates/5", nil) + r = mux.SetURLVars(r, map[string]string{"id": "5"}) + r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{UserID: 2, User: &portainer.User{ID: 2, Role: portainer.StandardUserRole}})) + rr := httptest.NewRecorder() + + herr := handler.customTemplateInspect(rr, r) + require.NotNil(t, herr) + require.Equal(t, http.StatusForbidden, herr.StatusCode) +} + +func TestInspectHandler_GitConfigPopulatedFromSource(t *testing.T) { + t.Parallel() + + handler, ds, _ := newTestHandler(t) + + var srcID portainer.SourceID + require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error { + src := &portainer.Source{ + Type: portainer.SourceTypeGit, + Git: &gittypes.GitSource{ + URL: "https://github.com/example/repo", + TLSSkipVerify: true, + }, + } + err := tx.Source().Create(adminUserContext, src) + require.NoError(t, err) + + srcID = src.ID + + return tx.CustomTemplate().Create(&portainer.CustomTemplate{ + ID: 10, + Artifact: &portainer.Artifact{ + Files: []portainer.ArtifactFile{{ + Ref: "refs/heads/main", + Path: "docker-compose.yml", + Hash: "abc123", + SourceID: srcID, + }}, + }, + }) + })) + + r := httptest.NewRequest(http.MethodGet, "/custom_templates/10", nil) + r = mux.SetURLVars(r, map[string]string{"id": "10"}) + ctx := security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{UserID: 1, IsAdmin: true, User: &portainer.User{ID: 1, Role: portainer.AdministratorRole}}) + r = r.WithContext(ctx) + rr := httptest.NewRecorder() + herr := handler.customTemplateInspect(rr, r) + require.Nil(t, herr) + require.Equal(t, http.StatusOK, rr.Result().StatusCode) + + var template portainer.CustomTemplate + require.NoError(t, json.NewDecoder(rr.Body).Decode(&template)) + require.NotNil(t, template.GitConfig) + require.Equal(t, "https://github.com/example/repo", template.GitConfig.URL) + require.True(t, template.GitConfig.TLSSkipVerify) + require.Equal(t, "refs/heads/main", template.GitConfig.ReferenceName) + require.Equal(t, "docker-compose.yml", template.GitConfig.ConfigFilePath) + require.Equal(t, "abc123", template.GitConfig.ConfigHash) +} diff --git a/api/http/handler/customtemplates/customtemplate_list.go b/api/http/handler/customtemplates/customtemplate_list.go new file mode 100644 index 0000000..5c50203 --- /dev/null +++ b/api/http/handler/customtemplates/customtemplate_list.go @@ -0,0 +1,150 @@ +package customtemplates + +import ( + "net/http" + "strconv" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/dataservices/source" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/authorization" + "github.com/portainer/portainer/api/slicesx" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" + + "github.com/pkg/errors" + "github.com/rs/zerolog/log" +) + +// @id CustomTemplateList +// @summary List available custom templates +// @description List available custom templates. +// @description **Access policy**: authenticated +// @tags custom_templates +// @security ApiKeyAuth +// @security jwt +// @produce json +// @param type query []int true "Template types" Enums(1,2,3) +// @param edge query boolean false "Filter by edge templates" +// @success 200 {array} portainer.CustomTemplate "Success" +// @failure 500 "Server error" +// @router /custom_templates [get] +func (handler *Handler) customTemplateList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + templateTypes, err := parseTemplateTypes(r) + if err != nil { + return httperror.BadRequest("Invalid Custom template type", err) + } + + edge := retrieveEdgeParam(r) + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) + } + + var customTemplates []portainer.CustomTemplate + err = handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error { + var err error + customTemplates, err = tx.CustomTemplate().ReadAll() + if err != nil { + return httperror.InternalServerError("Unable to retrieve custom templates from the database", err) + } + + resourceControls, err := tx.ResourceControl().ReadAll() + if err != nil { + return httperror.InternalServerError("Unable to retrieve resource controls from the database", err) + } + + customTemplates = authorization.DecorateCustomTemplates(customTemplates, resourceControls) + + if !securityContext.IsAdmin { + user, err := tx.User().Read(securityContext.UserID) + if err != nil { + return httperror.InternalServerError("Unable to retrieve user information from the database", err) + } + + userTeamIDs := authorization.TeamIDs(securityContext.UserMemberships) + + customTemplates = authorization.FilterAuthorizedCustomTemplates(customTemplates, user, userTeamIDs) + } + + customTemplates = filterByType(customTemplates, templateTypes) + + if edge != nil { + customTemplates = slicesx.FilterInPlace(customTemplates, func(customTemplate portainer.CustomTemplate) bool { + return customTemplate.EdgeTemplate == *edge + }) + } + + userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships) + for i := range customTemplates { + populateGitConfig(tx, userContext, &customTemplates[i]) + } + + return nil + }) + + return response.TxResponse(w, customTemplates, err) +} + +func retrieveEdgeParam(r *http.Request) *bool { + var edge *bool + edgeParam, _ := request.RetrieveQueryParameter(r, "edge", true) + if edgeParam != "" { + edgeVal, err := strconv.ParseBool(edgeParam) + if err != nil { + log.Warn().Err(err).Msg("failed parsing edge param") + return nil + } + + edge = &edgeVal + } + return edge +} + +func parseTemplateTypes(r *http.Request) ([]portainer.StackType, error) { + err := r.ParseForm() + if err != nil { + return nil, errors.WithMessage(err, "failed to parse request params") + } + + types, exist := r.Form["type"] + if !exist { + return []portainer.StackType{}, nil + } + + res := []portainer.StackType{} + for _, templateTypeStr := range types { + templateType, err := strconv.Atoi(templateTypeStr) + if err != nil { + return nil, errors.WithMessage(err, "failed parsing template type") + } + + res = append(res, portainer.StackType(templateType)) + } + + return res, nil +} + +func filterByType(customTemplates []portainer.CustomTemplate, templateTypes []portainer.StackType) []portainer.CustomTemplate { + if len(templateTypes) == 0 { + return customTemplates + } + + typeSet := map[portainer.StackType]bool{} + for _, templateType := range templateTypes { + typeSet[templateType] = true + } + + filtered := []portainer.CustomTemplate{} + + for _, template := range customTemplates { + if typeSet[template.Type] { + filtered = append(filtered, template) + } + } + + return filtered +} diff --git a/api/http/handler/customtemplates/customtemplate_list_test.go b/api/http/handler/customtemplates/customtemplate_list_test.go new file mode 100644 index 0000000..55d4773 --- /dev/null +++ b/api/http/handler/customtemplates/customtemplate_list_test.go @@ -0,0 +1,127 @@ +package customtemplates + +import ( + "net/http" + "net/http/httptest" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + gittypes "github.com/portainer/portainer/api/git/types" + "github.com/portainer/portainer/api/http/security" + + "github.com/segmentio/encoding/json" + "github.com/stretchr/testify/require" +) + +func TestCustomTemplateList_PopulatesGitConfigFromSource(t *testing.T) { + t.Parallel() + + handler, ds, _ := newTestHandler(t) + + var srcID portainer.SourceID + require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error { + src := &portainer.Source{ + Type: portainer.SourceTypeGit, + Git: &gittypes.GitSource{ + URL: "https://github.com/example/repo", + TLSSkipVerify: true, + }, + } + err := tx.Source().Create(adminUserContext, src) + require.NoError(t, err) + srcID = src.ID + require.NoError(t, tx.CustomTemplate().Create(&portainer.CustomTemplate{ + ID: 1, + Artifact: &portainer.Artifact{ + Files: []portainer.ArtifactFile{{ + Ref: "refs/heads/main", + Path: "docker-compose.yml", + Hash: "abc123", + SourceID: srcID, + }}, + }, + })) + require.NoError(t, tx.CustomTemplate().Create(&portainer.CustomTemplate{ID: 2, EntryPoint: "docker-compose.yml"})) + + return nil + })) + + r := httptest.NewRequest(http.MethodGet, "/custom_templates", nil) + r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{UserID: 1, IsAdmin: true, User: &portainer.User{ID: 1, Role: portainer.AdministratorRole}})) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, r) + + require.Equal(t, http.StatusOK, rr.Code) + + var templates []portainer.CustomTemplate + require.NoError(t, json.NewDecoder(rr.Body).Decode(&templates)) + + var gitTemplate portainer.CustomTemplate + for _, tpl := range templates { + if tpl.ID == 1 { + gitTemplate = tpl + } + } + + require.NotNil(t, gitTemplate.GitConfig) + require.Equal(t, "https://github.com/example/repo", gitTemplate.GitConfig.URL) + require.True(t, gitTemplate.GitConfig.TLSSkipVerify) + require.Equal(t, "refs/heads/main", gitTemplate.GitConfig.ReferenceName) + require.Equal(t, "docker-compose.yml", gitTemplate.GitConfig.ConfigFilePath) + require.Equal(t, "abc123", gitTemplate.GitConfig.ConfigHash) + + var plainTemplate portainer.CustomTemplate + for _, tpl := range templates { + if tpl.ID == 2 { + plainTemplate = tpl + } + } + require.Nil(t, plainTemplate.GitConfig) +} + +func TestCustomTemplateList_StripsPasswordFromGitConfig(t *testing.T) { + t.Parallel() + + handler, ds, _ := newTestHandler(t) + + var srcID portainer.SourceID + require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error { + src := &portainer.Source{ + Type: portainer.SourceTypeGit, + Git: &gittypes.GitSource{ + URL: "https://github.com/example/repo", + Authentication: &gittypes.GitAuthentication{ + Username: "user", + Password: "topsecret", + }, + }, + } + err := tx.Source().Create(adminUserContext, src) + require.NoError(t, err) + srcID = src.ID + require.NoError(t, tx.CustomTemplate().Create(&portainer.CustomTemplate{ + ID: 1, + Artifact: &portainer.Artifact{ + Files: []portainer.ArtifactFile{{SourceID: srcID}}, + }, + })) + + return nil + })) + + r := httptest.NewRequest(http.MethodGet, "/custom_templates", nil) + r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{UserID: 1, IsAdmin: true, User: &portainer.User{ID: 1, Role: portainer.AdministratorRole}})) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, r) + + require.Equal(t, http.StatusOK, rr.Code) + + var templates []portainer.CustomTemplate + require.NoError(t, json.NewDecoder(rr.Body).Decode(&templates)) + require.Len(t, templates, 1) + require.NotNil(t, templates[0].GitConfig) + require.NotNil(t, templates[0].GitConfig.Authentication) + require.Equal(t, "user", templates[0].GitConfig.Authentication.Username) + require.Empty(t, templates[0].GitConfig.Authentication.Password) +} diff --git a/api/http/handler/customtemplates/customtemplate_update.go b/api/http/handler/customtemplates/customtemplate_update.go new file mode 100644 index 0000000..6ea3887 --- /dev/null +++ b/api/http/handler/customtemplates/customtemplate_update.go @@ -0,0 +1,284 @@ +package customtemplates + +import ( + "context" + "errors" + "fmt" + "net/http" + "strconv" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/dataservices/source" + "github.com/portainer/portainer/api/filesystem" + "github.com/portainer/portainer/api/git" + gittypes "github.com/portainer/portainer/api/git/types" + "github.com/portainer/portainer/api/gitops/sources" + "github.com/portainer/portainer/api/gitops/workflows" + httperrors "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/api/http/security" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" + "github.com/portainer/portainer/pkg/validate" +) + +type customTemplateUpdatePayload struct { + // URL of the template's logo + Logo string `example:"https://portainer.io/img/logo.svg"` + // Title of the template + Title string `example:"Nginx" validate:"required"` + // Description of the template + Description string `example:"High performance web server" validate:"required"` + // A note that will be displayed in the UI. Supports HTML content + Note string `example:"This is my custom template"` + // Platform associated to the template. + // Valid values are: 1 - 'linux', 2 - 'windows' + // Required for Docker stacks + Platform portainer.CustomTemplatePlatform `example:"1" enums:"1,2"` + // Type of created stack (1 - swarm, 2 - compose, 3 - kubernetes) + Type portainer.StackType `example:"1" enums:"1,2,3" validate:"required"` + // SourceID references an existing Source for git credentials/URL. + // When set, the inline URL and authentication fields are ignored. + SourceID portainer.SourceID `example:"1"` + // Deprecated: use SourceID instead. URL of a Git repository hosting the Stack file. + RepositoryURL string `example:"https://github.com/openfaas/faas"` + // Reference name of a Git repository hosting the Stack file + RepositoryReferenceName string `example:"refs/heads/master"` + // Deprecated: use SourceID instead. Use authentication to clone the Git repository. + RepositoryAuthentication bool `example:"true"` + // Deprecated: use SourceID instead. Username used in basic authentication. Required when RepositoryAuthentication is true. + RepositoryUsername string `example:"myGitUsername"` + // Deprecated: use SourceID instead. Password used in basic authentication or token used in token authentication. Required when RepositoryAuthentication is true. + RepositoryPassword string `example:"myGitPassword"` + // Path to the Stack file inside the Git repository + ComposeFilePathInRepository string `example:"docker-compose.yml" default:"docker-compose.yml"` + // Content of stack file + FileContent string `validate:"required"` + // Definitions of variables in the stack file + Variables []portainer.CustomTemplateVariableDefinition + // Deprecated: use SourceID instead. TLSSkipVerify skips SSL verification when cloning the Git repository. + TLSSkipVerify bool `example:"false"` + // IsComposeFormat indicates if the Kubernetes template is created from a Docker Compose file + IsComposeFormat bool `example:"false"` + // EdgeTemplate indicates if this template purpose for Edge Stack + EdgeTemplate bool `example:"false"` +} + +func (payload *customTemplateUpdatePayload) Validate(r *http.Request) error { + if len(payload.Title) == 0 { + return errors.New("Invalid custom template title") + } + + if payload.Type != portainer.KubernetesStack && payload.Platform != portainer.CustomTemplatePlatformLinux && payload.Platform != portainer.CustomTemplatePlatformWindows { + return errors.New("Invalid custom template platform") + } + + if payload.Type != portainer.KubernetesStack && payload.Type != portainer.DockerSwarmStack && payload.Type != portainer.DockerComposeStack { + return errors.New("Invalid custom template type") + } + + if len(payload.Description) == 0 { + return errors.New("Invalid custom template description") + } + + if !IsValidNote(payload.Note) { + return errors.New("Invalid note. tag is not supported") + } + + if len(payload.FileContent) == 0 && payload.SourceID == 0 { + if len(payload.RepositoryURL) == 0 { + return errors.New("Either file content, git repository url, or source ID need to be provided") + } + + if !validate.IsURL(payload.RepositoryURL) { + return errors.New("Invalid repository URL. Must correspond to a valid URL format") + } + + if payload.RepositoryAuthentication && (len(payload.RepositoryUsername) == 0 || len(payload.RepositoryPassword) == 0) { + return errors.New("Invalid repository credentials. Username and password must be specified when authentication is enabled") + } + + } + + if len(payload.ComposeFilePathInRepository) == 0 { + payload.ComposeFilePathInRepository = filesystem.ComposeFileDefaultName + } + + if err := ValidateVariablesDefinitions(payload.Variables); err != nil { + return err + } + + return nil +} + +// @id CustomTemplateUpdate +// @summary Update a template +// @description Update a template. +// @description **Access policy**: authenticated +// @tags custom_templates +// @security ApiKeyAuth +// @security jwt +// @accept json +// @produce json +// @param id path int true "Template identifier" +// @param body body customTemplateUpdatePayload true "Template details" +// @success 200 {object} portainer.CustomTemplate "Success" +// @failure 400 "Invalid request" +// @failure 403 "Permission denied to access template" +// @failure 404 "Template not found" +// @failure 500 "Server error" +// @router /custom_templates/{id} [put] +func (handler *Handler) customTemplateUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + customTemplateID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid Custom template identifier route variable", err) + } + + var payload customTemplateUpdatePayload + if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + duplicates, err := handler.DataStore.CustomTemplate().ReadAll(func(t portainer.CustomTemplate) bool { + return t.ID != portainer.CustomTemplateID(customTemplateID) && t.Title == payload.Title + }) + if err != nil { + return httperror.InternalServerError("Unable to retrieve custom templates from the database", err) + } + + if len(duplicates) > 0 { + return httperror.InternalServerError("Template name must be unique", errors.New("Template name must be unique")) + } + + customTemplate, err := handler.DataStore.CustomTemplate().Read(portainer.CustomTemplateID(customTemplateID)) + if handler.DataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find a custom template with the specified identifier inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find a custom template with the specified identifier inside the database", err) + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) + } + + resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(strconv.Itoa(customTemplateID), portainer.CustomTemplateResourceControl) + if err != nil { + return httperror.InternalServerError("Unable to retrieve a resource control associated to the custom template", err) + } + + customTemplate.ResourceControl = resourceControl + if !userCanEditTemplate(customTemplate, securityContext) { + return httperror.Forbidden("Access denied to resource", httperrors.ErrResourceAccessDenied) + } + + customTemplate.Title = payload.Title + customTemplate.Logo = payload.Logo + customTemplate.Description = payload.Description + customTemplate.Note = payload.Note + customTemplate.Platform = payload.Platform + customTemplate.Type = payload.Type + customTemplate.Variables = payload.Variables + customTemplate.IsComposeFormat = payload.IsComposeFormat + customTemplate.EdgeTemplate = payload.EdgeTemplate + + userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships) + + if payload.SourceID != 0 || payload.RepositoryURL != "" { + gitConfig, httpErr := sources.ResolveRepoConfig(handler.DataStore, userContext, sources.RepoConfigInput{ + SourceID: payload.SourceID, + ReferenceName: payload.RepositoryReferenceName, + ConfigFilePath: payload.ComposeFilePathInRepository, + RepositoryURL: payload.RepositoryURL, + TLSSkipVerify: payload.TLSSkipVerify, + RepositoryAuthentication: payload.RepositoryAuthentication, + Username: payload.RepositoryUsername, + Password: payload.RepositoryPassword, + }) + if httpErr != nil { + return httpErr + } + + var username, password string + if gitConfig.Authentication != nil { + username = gitConfig.Authentication.Username + password = gitConfig.Authentication.Password + } + + cleanBackup, err := git.CloneWithBackup(context.TODO(), handler.GitService, handler.FileService, git.CloneOptions{ + ProjectPath: customTemplate.ProjectPath, + URL: gitConfig.URL, + ReferenceName: gitConfig.ReferenceName, + Username: username, + Password: password, + TLSSkipVerify: gitConfig.TLSSkipVerify, + }) + if err != nil { + return httperror.InternalServerError("Unable to clone git repository directory", err) + } + + defer cleanBackup() + + commitHash, err := handler.GitService.LatestCommitID( + context.TODO(), + gitConfig.URL, + gitConfig.ReferenceName, + username, + password, + gitConfig.TLSSkipVerify, + ) + if err != nil { + return httperror.InternalServerError("Unable get latest commit id", fmt.Errorf("failed to fetch latest commit id of the template %v: %w", customTemplate.ID, err)) + } + + sourceID := payload.SourceID + if sourceID == 0 { + src, err := workflows.FindOrCreateGitSource(handler.DataStore, userContext, &portainer.Source{ + Name: gittypes.RepoName(gitConfig.URL), + Type: portainer.SourceTypeGit, + Git: &gittypes.GitSource{ + URL: gitConfig.URL, + Authentication: gitConfig.Authentication, + TLSSkipVerify: gitConfig.TLSSkipVerify, + }, + }) + if err != nil { + return httperror.InternalServerError("Unable to find or create git source", err) + } + sourceID = src.ID + } + + customTemplate.Artifact = &portainer.Artifact{ + Files: []portainer.ArtifactFile{{ + SourceID: sourceID, + Path: gitConfig.ConfigFilePath, + Ref: gitConfig.ReferenceName, + Hash: commitHash, + }}, + } + + } else { + templateFolder := strconv.Itoa(customTemplateID) + projectPath, err := handler.FileService.StoreCustomTemplateFileFromBytes(templateFolder, customTemplate.EntryPoint, []byte(payload.FileContent)) + if err != nil { + return httperror.InternalServerError("Unable to persist updated custom template file on disk", err) + } + + customTemplate.ProjectPath = projectPath + customTemplate.Artifact = nil + } + + err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + if err := tx.CustomTemplate().Update(customTemplate.ID, customTemplate); err != nil { + return httperror.InternalServerError("Unable to persist custom template changes inside the database", err) + } + + userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships) + populateGitConfig(tx, userContext, customTemplate) + + return nil + }) + + return response.TxResponse(w, customTemplate, err) +} diff --git a/api/http/handler/customtemplates/customtemplate_update_test.go b/api/http/handler/customtemplates/customtemplate_update_test.go new file mode 100644 index 0000000..115337b --- /dev/null +++ b/api/http/handler/customtemplates/customtemplate_update_test.go @@ -0,0 +1,649 @@ +package customtemplates + +import ( + "bytes" + "net/http" + "net/http/httptest" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/filesystem" + gittypes "github.com/portainer/portainer/api/git/types" + "github.com/portainer/portainer/api/http/security" + + "github.com/gorilla/mux" + "github.com/segmentio/encoding/json" + "github.com/stretchr/testify/require" +) + +func updateTemplateRequest(t *testing.T, templateID string, payload any, ctx *security.RestrictedRequestContext) *http.Request { + t.Helper() + + body, err := json.Marshal(payload) + require.NoError(t, err) + + r := httptest.NewRequest(http.MethodPut, "/custom_templates/"+templateID, bytes.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + r = mux.SetURLVars(r, map[string]string{"id": templateID}) + + if ctx.User == nil { + role := portainer.StandardUserRole + if ctx.IsAdmin { + role = portainer.AdministratorRole + } + ctx.User = &portainer.User{ID: ctx.UserID, Role: role} + } + + return r.WithContext(security.StoreRestrictedRequestContext(r, ctx)) +} + +func TestCustomTemplateUpdate_NotFound(t *testing.T) { + t.Parallel() + + handler, _, _ := newTestHandler(t) + + payload := customTemplateUpdatePayload{ + Title: "New Title", + Description: "New Description", + FileContent: "version: '3'", + Type: portainer.DockerComposeStack, + Platform: portainer.CustomTemplatePlatformLinux, + } + + r := updateTemplateRequest(t, "99", payload, &security.RestrictedRequestContext{UserID: 1, IsAdmin: true}) + rr := httptest.NewRecorder() + + herr := handler.customTemplateUpdate(rr, r) + require.NotNil(t, herr) + require.Equal(t, http.StatusNotFound, herr.StatusCode) +} + +func TestCustomTemplateUpdate_Forbidden(t *testing.T) { + t.Parallel() + + handler, ds, _ := newTestHandler(t) + + require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error { + return tx.CustomTemplate().Create(&portainer.CustomTemplate{ + ID: 1, + Title: "Original Title", + EntryPoint: filesystem.ComposeFileDefaultName, + CreatedByUserID: 1, + }) + })) + + payload := customTemplateUpdatePayload{ + Title: "New Title", + Description: "New Description", + FileContent: "version: '3'", + Type: portainer.DockerComposeStack, + Platform: portainer.CustomTemplatePlatformLinux, + } + + // User 2 did not create this template and is not an admin + r := updateTemplateRequest(t, "1", payload, &security.RestrictedRequestContext{UserID: 2}) + rr := httptest.NewRecorder() + + herr := handler.customTemplateUpdate(rr, r) + require.NotNil(t, herr) + require.Equal(t, http.StatusForbidden, herr.StatusCode) +} + +func TestCustomTemplateUpdate_DuplicateTitle(t *testing.T) { + t.Parallel() + + handler, ds, _ := newTestHandler(t) + + require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error { + require.NoError(t, tx.CustomTemplate().Create(&portainer.CustomTemplate{ + ID: 1, + Title: "Template One", + })) + + return tx.CustomTemplate().Create(&portainer.CustomTemplate{ + ID: 2, + Title: "Template Two", + }) + })) + + payload := customTemplateUpdatePayload{ + Title: "Template One", + Description: "Renamed", + FileContent: "version: '3'", + Type: portainer.DockerComposeStack, + Platform: portainer.CustomTemplatePlatformLinux, + } + + r := updateTemplateRequest(t, "2", payload, &security.RestrictedRequestContext{UserID: 1, IsAdmin: true}) + rr := httptest.NewRecorder() + + herr := handler.customTemplateUpdate(rr, r) + require.NotNil(t, herr) + require.Equal(t, http.StatusInternalServerError, herr.StatusCode) +} + +func TestCustomTemplateUpdate_Success_FileContent(t *testing.T) { + t.Parallel() + + handler, ds, _ := newTestHandler(t) + + require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error { + return tx.CustomTemplate().Create(&portainer.CustomTemplate{ + ID: 1, + Title: "Original Title", + Description: "Original Description", + EntryPoint: filesystem.ComposeFileDefaultName, + Type: portainer.DockerComposeStack, + Platform: portainer.CustomTemplatePlatformLinux, + CreatedByUserID: 1, + }) + })) + + payload := customTemplateUpdatePayload{ + Title: "Updated Title", + Description: "Updated Description", + FileContent: "version: '3'\nservices:\n app:\n image: alpine", + Type: portainer.DockerComposeStack, + Platform: portainer.CustomTemplatePlatformLinux, + } + + r := updateTemplateRequest(t, "1", payload, &security.RestrictedRequestContext{UserID: 1, IsAdmin: true}) + rr := httptest.NewRecorder() + + herr := handler.customTemplateUpdate(rr, r) + require.Nil(t, herr) + require.Equal(t, http.StatusOK, rr.Code) + + var tmpl portainer.CustomTemplate + require.NoError(t, json.NewDecoder(rr.Body).Decode(&tmpl)) + require.Equal(t, "Updated Title", tmpl.Title) + require.Equal(t, "Updated Description", tmpl.Description) + + err := ds.ViewTx(func(tx dataservices.DataStoreTx) error { + stored, err := tx.CustomTemplate().Read(1) + require.NoError(t, err) + require.Equal(t, "Updated Title", stored.Title) + require.Equal(t, "Updated Description", stored.Description) + + return nil + }) + require.NoError(t, err) +} + +func TestCustomTemplateUpdate_SameTitleAllowed(t *testing.T) { + t.Parallel() + + handler, ds, _ := newTestHandler(t) + + require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error { + return tx.CustomTemplate().Create(&portainer.CustomTemplate{ + ID: 1, + Title: "My Template", + EntryPoint: filesystem.ComposeFileDefaultName, + }) + })) + + payload := customTemplateUpdatePayload{ + Title: "My Template", + Description: "Updated description", + FileContent: "version: '3'", + Type: portainer.DockerComposeStack, + Platform: portainer.CustomTemplatePlatformLinux, + } + + r := updateTemplateRequest(t, "1", payload, &security.RestrictedRequestContext{UserID: 1, IsAdmin: true}) + rr := httptest.NewRecorder() + + herr := handler.customTemplateUpdate(rr, r) + require.Nil(t, herr) + require.Equal(t, http.StatusOK, rr.Code) + + err := ds.ViewTx(func(tx dataservices.DataStoreTx) error { + stored, err := tx.CustomTemplate().Read(1) + require.NoError(t, err) + require.Equal(t, "My Template", stored.Title) + require.Equal(t, "Updated description", stored.Description) + + return nil + }) + require.NoError(t, err) +} + +func TestCustomTemplateUpdate_InvalidPayload(t *testing.T) { + t.Parallel() + + handler, ds, _ := newTestHandler(t) + + require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error { + return tx.CustomTemplate().Create(&portainer.CustomTemplate{ + ID: 1, + Title: "My Template", + EntryPoint: filesystem.ComposeFileDefaultName, + }) + })) + + payload := customTemplateUpdatePayload{ + // Title is empty - invalid + Description: "A description", + FileContent: "version: '3'", + Type: portainer.DockerComposeStack, + Platform: portainer.CustomTemplatePlatformLinux, + } + + r := updateTemplateRequest(t, "1", payload, &security.RestrictedRequestContext{UserID: 1, IsAdmin: true}) + rr := httptest.NewRecorder() + + herr := handler.customTemplateUpdate(rr, r) + require.NotNil(t, herr) + require.Equal(t, http.StatusBadRequest, herr.StatusCode) +} + +func TestCustomTemplateUpdate_Validation_MissingDescription(t *testing.T) { + t.Parallel() + + handler, _, _ := newTestHandler(t) + + payload := customTemplateUpdatePayload{ + Title: "My Template", + FileContent: "version: '3'", + Type: portainer.DockerComposeStack, + Platform: portainer.CustomTemplatePlatformLinux, + } + + r := updateTemplateRequest(t, "99", payload, &security.RestrictedRequestContext{UserID: 1, IsAdmin: true}) + rr := httptest.NewRecorder() + + herr := handler.customTemplateUpdate(rr, r) + require.NotNil(t, herr) + require.Equal(t, http.StatusBadRequest, herr.StatusCode) +} + +func TestCustomTemplateUpdate_Validation_BothContentAndRepoMissing(t *testing.T) { + t.Parallel() + + handler, _, _ := newTestHandler(t) + + payload := customTemplateUpdatePayload{ + Title: "My Template", + Description: "A description", + Type: portainer.DockerComposeStack, + Platform: portainer.CustomTemplatePlatformLinux, + } + + r := updateTemplateRequest(t, "99", payload, &security.RestrictedRequestContext{UserID: 1, IsAdmin: true}) + rr := httptest.NewRecorder() + + herr := handler.customTemplateUpdate(rr, r) + require.NotNil(t, herr) + require.Equal(t, http.StatusBadRequest, herr.StatusCode) +} + +func TestCustomTemplateUpdate_Validation_InvalidPlatform(t *testing.T) { + t.Parallel() + + handler, _, _ := newTestHandler(t) + + payload := customTemplateUpdatePayload{ + Title: "My Template", + Description: "A description", + FileContent: "version: '3'", + Type: portainer.DockerComposeStack, + Platform: 0, + } + + r := updateTemplateRequest(t, "99", payload, &security.RestrictedRequestContext{UserID: 1, IsAdmin: true}) + rr := httptest.NewRecorder() + + herr := handler.customTemplateUpdate(rr, r) + require.NotNil(t, herr) + require.Equal(t, http.StatusBadRequest, herr.StatusCode) +} + +func TestCustomTemplateUpdate_Validation_InvalidType(t *testing.T) { + t.Parallel() + + handler, _, _ := newTestHandler(t) + + payload := customTemplateUpdatePayload{ + Title: "My Template", + Description: "A description", + FileContent: "version: '3'", + Type: 0, + Platform: portainer.CustomTemplatePlatformLinux, + } + + r := updateTemplateRequest(t, "99", payload, &security.RestrictedRequestContext{UserID: 1, IsAdmin: true}) + rr := httptest.NewRecorder() + + herr := handler.customTemplateUpdate(rr, r) + require.NotNil(t, herr) + require.Equal(t, http.StatusBadRequest, herr.StatusCode) +} + +func TestCustomTemplateUpdate_Validation_NoteWithImage(t *testing.T) { + t.Parallel() + + handler, _, _ := newTestHandler(t) + + payload := customTemplateUpdatePayload{ + Title: "My Template", + Description: "A description", + FileContent: "version: '3'", + Note: `Some note `, + Type: portainer.DockerComposeStack, + Platform: portainer.CustomTemplatePlatformLinux, + } + + r := updateTemplateRequest(t, "99", payload, &security.RestrictedRequestContext{UserID: 1, IsAdmin: true}) + rr := httptest.NewRecorder() + + herr := handler.customTemplateUpdate(rr, r) + require.NotNil(t, herr) + require.Equal(t, http.StatusBadRequest, herr.StatusCode) +} + +func TestCustomTemplateUpdate_Validation_AuthWithoutCredentials(t *testing.T) { + t.Parallel() + + handler, _, _ := newTestHandler(t) + + payload := customTemplateUpdatePayload{ + Title: "My Template", + Description: "A description", + RepositoryURL: "https://github.com/example/repo", + Type: portainer.DockerComposeStack, + Platform: portainer.CustomTemplatePlatformLinux, + RepositoryAuthentication: true, + } + + r := updateTemplateRequest(t, "99", payload, &security.RestrictedRequestContext{UserID: 1, IsAdmin: true}) + rr := httptest.NewRecorder() + + herr := handler.customTemplateUpdate(rr, r) + require.NotNil(t, herr) + require.Equal(t, http.StatusBadRequest, herr.StatusCode) +} + +func TestCustomTemplateUpdate_ClearsArtifact(t *testing.T) { + t.Parallel() + + handler, ds, _ := newTestHandler(t) + + require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error { + return tx.CustomTemplate().Create(&portainer.CustomTemplate{ + ID: 1, + Title: "Git Template", + EntryPoint: filesystem.ComposeFileDefaultName, + Type: portainer.DockerComposeStack, + Platform: portainer.CustomTemplatePlatformLinux, + CreatedByUserID: 1, + Artifact: &portainer.Artifact{ + Files: []portainer.ArtifactFile{}, + }, + }) + })) + + payload := customTemplateUpdatePayload{ + Title: "Git Template", + Description: "Updated with file content", + FileContent: "version: '3'\nservices:\n app:\n image: alpine", + Type: portainer.DockerComposeStack, + Platform: portainer.CustomTemplatePlatformLinux, + } + + r := updateTemplateRequest(t, "1", payload, &security.RestrictedRequestContext{UserID: 1, IsAdmin: true}) + rr := httptest.NewRecorder() + + herr := handler.customTemplateUpdate(rr, r) + require.Nil(t, herr) + require.Equal(t, http.StatusOK, rr.Code) + + var tmpl portainer.CustomTemplate + require.NoError(t, json.NewDecoder(rr.Body).Decode(&tmpl)) + require.Nil(t, tmpl.Artifact) + + err := ds.ViewTx(func(tx dataservices.DataStoreTx) error { + stored, err := tx.CustomTemplate().Read(1) + require.NoError(t, err) + require.Nil(t, stored.Artifact) + + return nil + }) + require.NoError(t, err) +} + +func TestCustomTemplateUpdate_CreatorDeniedWhenAdminOnly(t *testing.T) { + t.Parallel() + + handler, store, _ := newTestHandler(t) + + err := store.UpdateTx(func(tx dataservices.DataStoreTx) error { + err := tx.CustomTemplate().Create(&portainer.CustomTemplate{ + ID: 1, + Title: "User Template", + EntryPoint: filesystem.ComposeFileDefaultName, + Type: portainer.DockerComposeStack, + Platform: portainer.CustomTemplatePlatformLinux, + CreatedByUserID: 2, + }) + require.NoError(t, err) + + err = tx.ResourceControl().Create(&portainer.ResourceControl{ + ID: 1, + ResourceID: "1", + Type: portainer.CustomTemplateResourceControl, + AdministratorsOnly: true, + }) + require.NoError(t, err) + + return nil + }) + require.NoError(t, err) + + payload := customTemplateUpdatePayload{ + Title: "User Template Updated", + Description: "Attempted update by creator after adminonly change", + FileContent: "version: '3'", + Type: portainer.DockerComposeStack, + Platform: portainer.CustomTemplatePlatformLinux, + } + + r := updateTemplateRequest(t, "1", payload, &security.RestrictedRequestContext{UserID: 2}) + rr := httptest.NewRecorder() + + herr := handler.customTemplateUpdate(rr, r) + require.NotNil(t, herr) + require.Equal(t, http.StatusForbidden, herr.StatusCode) +} + +func TestCustomTemplateUpdate_WithSourceID_Success(t *testing.T) { + t.Parallel() + + handler, ds, _ := newTestHandler(t) + handler.GitService = &gitServiceCreatingFile{} + + projectDir := t.TempDir() + + var srcID portainer.SourceID + require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error { + require.NoError(t, tx.CustomTemplate().Create(&portainer.CustomTemplate{ + ID: 1, + Title: "Source Template", + EntryPoint: filesystem.ComposeFileDefaultName, + Type: portainer.DockerComposeStack, + Platform: portainer.CustomTemplatePlatformLinux, + CreatedByUserID: 1, + ProjectPath: projectDir, + })) + + src := &portainer.Source{ + Name: "example/repo", + Type: portainer.SourceTypeGit, + Git: &gittypes.GitSource{ + URL: "https://github.com/example/repo", + }, + } + err := tx.Source().Create(adminUserContext, src) + require.NoError(t, err) + srcID = src.ID + return nil + })) + + payload := customTemplateUpdatePayload{ + Title: "Source Template", + Description: "Updated via source ID", + SourceID: srcID, + Type: portainer.DockerComposeStack, + Platform: portainer.CustomTemplatePlatformLinux, + } + + r := updateTemplateRequest(t, "1", payload, &security.RestrictedRequestContext{UserID: 1, IsAdmin: true}) + rr := httptest.NewRecorder() + + herr := handler.customTemplateUpdate(rr, r) + require.Nil(t, herr) + require.Equal(t, http.StatusOK, rr.Code) + + var tmpl portainer.CustomTemplate + require.NoError(t, json.NewDecoder(rr.Body).Decode(&tmpl)) + require.NotNil(t, tmpl.Artifact) + require.Len(t, tmpl.Artifact.Files, 1) + require.Equal(t, srcID, tmpl.Artifact.Files[0].SourceID) + require.Equal(t, "deadbeef123", tmpl.Artifact.Files[0].Hash) +} + +func TestCustomTemplateUpdate_WithSourceID_NonExistentSource(t *testing.T) { + t.Parallel() + + handler, ds, _ := newTestHandler(t) + handler.GitService = &gitServiceCreatingFile{} + + require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error { + return tx.CustomTemplate().Create(&portainer.CustomTemplate{ + ID: 1, + Title: "Source Template", + EntryPoint: filesystem.ComposeFileDefaultName, + Type: portainer.DockerComposeStack, + Platform: portainer.CustomTemplatePlatformLinux, + CreatedByUserID: 1, + }) + })) + + payload := customTemplateUpdatePayload{ + Title: "Source Template", + Description: "Updated via non-existent source ID", + SourceID: 999, + Type: portainer.DockerComposeStack, + Platform: portainer.CustomTemplatePlatformLinux, + } + + r := updateTemplateRequest(t, "1", payload, &security.RestrictedRequestContext{UserID: 1, IsAdmin: true}) + rr := httptest.NewRecorder() + + herr := handler.customTemplateUpdate(rr, r) + require.NotNil(t, herr) + require.Equal(t, http.StatusNotFound, herr.StatusCode) +} + +func TestCustomTemplateUpdate_AdminCanUpdateAdminOnly(t *testing.T) { + t.Parallel() + + handler, store, _ := newTestHandler(t) + + err := store.UpdateTx(func(tx dataservices.DataStoreTx) error { + err := tx.CustomTemplate().Create(&portainer.CustomTemplate{ + ID: 1, + Title: "User Template", + EntryPoint: filesystem.ComposeFileDefaultName, + Type: portainer.DockerComposeStack, + Platform: portainer.CustomTemplatePlatformLinux, + CreatedByUserID: 2, + }) + require.NoError(t, err) + + err = tx.ResourceControl().Create(&portainer.ResourceControl{ + ID: 1, + ResourceID: "1", + Type: portainer.CustomTemplateResourceControl, + AdministratorsOnly: true, + }) + require.NoError(t, err) + + return nil + }) + require.NoError(t, err) + + payload := customTemplateUpdatePayload{ + Title: "Updated by Admin", + Description: "Admin update of adminonly template", + FileContent: "version: '3'", + Type: portainer.DockerComposeStack, + Platform: portainer.CustomTemplatePlatformLinux, + } + + r := updateTemplateRequest(t, "1", payload, &security.RestrictedRequestContext{UserID: 1, IsAdmin: true}) + rr := httptest.NewRecorder() + + herr := handler.customTemplateUpdate(rr, r) + require.Nil(t, herr) + require.Equal(t, http.StatusOK, rr.Code) +} + +func TestCustomTemplateUpdate_GitRepository_Success(t *testing.T) { + t.Parallel() + + handler, ds, _ := newTestHandler(t) + handler.GitService = &gitServiceCreatingFile{} + + projectDir := t.TempDir() + + require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error { + return tx.CustomTemplate().Create(&portainer.CustomTemplate{ + ID: 1, + Title: "Git Template", + EntryPoint: filesystem.ComposeFileDefaultName, + Type: portainer.DockerComposeStack, + Platform: portainer.CustomTemplatePlatformLinux, + CreatedByUserID: 1, + ProjectPath: projectDir, + }) + })) + + payload := customTemplateUpdatePayload{ + Title: "Git Template", + Description: "Updated via git", + RepositoryURL: "https://github.com/example/repo", + RepositoryReferenceName: "refs/heads/main", + ComposeFilePathInRepository: filesystem.ComposeFileDefaultName, + Type: portainer.DockerComposeStack, + Platform: portainer.CustomTemplatePlatformLinux, + } + + r := updateTemplateRequest(t, "1", payload, &security.RestrictedRequestContext{UserID: 1, IsAdmin: true}) + rr := httptest.NewRecorder() + + herr := handler.customTemplateUpdate(rr, r) + require.Nil(t, herr) + require.Equal(t, http.StatusOK, rr.Code) + + var tmpl portainer.CustomTemplate + require.NoError(t, json.NewDecoder(rr.Body).Decode(&tmpl)) + require.NotNil(t, tmpl.Artifact) + require.Len(t, tmpl.Artifact.Files, 1) + require.Equal(t, "deadbeef123", tmpl.Artifact.Files[0].Hash) + + err := ds.ViewTx(func(tx dataservices.DataStoreTx) error { + stored, err := tx.CustomTemplate().Read(1) + require.NoError(t, err) + require.NotNil(t, stored.Artifact) + + src, err := tx.Source().Read(adminUserContext, stored.Artifact.Files[0].SourceID) + require.NoError(t, err) + require.Equal(t, portainer.SourceTypeGit, src.Type) + require.Equal(t, "https://github.com/example/repo", src.Git.URL) + + return nil + }) + require.NoError(t, err) +} diff --git a/api/http/handler/customtemplates/handler.go b/api/http/handler/customtemplates/handler.go new file mode 100644 index 0000000..352816c --- /dev/null +++ b/api/http/handler/customtemplates/handler.go @@ -0,0 +1,77 @@ +package customtemplates + +import ( + "net/http" + "sync" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/authorization" + "github.com/portainer/portainer/api/slicesx" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + + "github.com/gorilla/mux" +) + +// Handler is the HTTP handler used to handle environment(endpoint) group operations. +type Handler struct { + *mux.Router + DataStore dataservices.DataStore + FileService portainer.FileService + GitService portainer.GitService + gitFetchMutexs map[portainer.TemplateID]*sync.Mutex +} + +// NewHandler creates a handler to manage environment(endpoint) group operations. +func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStore, fileService portainer.FileService, gitService portainer.GitService) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + DataStore: dataStore, + FileService: fileService, + GitService: gitService, + gitFetchMutexs: make(map[portainer.TemplateID]*sync.Mutex), + } + + h.Handle("/custom_templates/create/{method}", + 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) + h.Handle("/custom_templates/{id}/git_fetch", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.customTemplateGitFetch))).Methods(http.MethodPut) + return h +} + +func userCanEditTemplate(customTemplate *portainer.CustomTemplate, securityContext *security.RestrictedRequestContext) bool { + resourceControl := customTemplate.ResourceControl + + if securityContext.IsAdmin { + return true + } + + if resourceControl == nil || resourceControl.AdministratorsOnly { + return false + } + + if resourceControl.Public { + return true + } + + if customTemplate.CreatedByUserID != securityContext.UserID { + return false + } + + teamIDs := slicesx.Map(securityContext.UserMemberships, func(m portainer.TeamMembership) portainer.TeamID { + return m.TeamID + }) + + return authorization.UserCanAccessResource(securityContext.UserID, teamIDs, resourceControl) +} diff --git a/api/http/handler/customtemplates/helpers_test.go b/api/http/handler/customtemplates/helpers_test.go new file mode 100644 index 0000000..e938644 --- /dev/null +++ b/api/http/handler/customtemplates/helpers_test.go @@ -0,0 +1,5 @@ +package customtemplates + +import "github.com/portainer/portainer/api/dataservices/source" + +var adminUserContext = source.InsecureNewAdminContext() diff --git a/api/http/handler/customtemplates/utils.go b/api/http/handler/customtemplates/utils.go new file mode 100644 index 0000000..d5123fa --- /dev/null +++ b/api/http/handler/customtemplates/utils.go @@ -0,0 +1,63 @@ +package customtemplates + +import ( + "errors" + "regexp" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/dataservices/source" + gittypes "github.com/portainer/portainer/api/git/types" +) + +func populateGitConfig(tx dataservices.DataStoreTx, userContext source.UserContext, template *portainer.CustomTemplate) { + if template.Artifact == nil || len(template.Artifact.Files) == 0 { + return + } + + file := template.Artifact.Files[0] + + src, err := tx.Source().Read(userContext, file.SourceID) + if err != nil || src.Git == nil { + return + } + + cfg := &gittypes.RepoConfig{ + URL: src.Git.URL, + Authentication: src.Git.Authentication, + TLSSkipVerify: src.Git.TLSSkipVerify, + ReferenceName: file.Ref, + ConfigFilePath: file.Path, + ConfigHash: file.Hash, + } + + if cfg.Authentication != nil { + sanitized := *cfg.Authentication + sanitized.Password = "" + cfg.Authentication = &sanitized + } + + template.GitConfig = cfg +} + +// IsValidNote reports whether note is safe to display. Notes containing tags are rejected. +func IsValidNote(note string) bool { + if len(note) == 0 { + return true + } + match, _ := regexp.MatchString(" 0 { + for _, repoDigest := range image.RepoDigests { + image.RepoTags = append(image.RepoTags, repoDigest[0:strings.Index(repoDigest, "@")]+":") + } + } + + imagesList[i] = ImageResponse{ + Created: image.Created, + // Only works if the order of `images` is not changed between unmarshaling the agent's response + // in NodeNameTransport.RoundTrip() (api/docker/client/client.go) + // and docker's cli.ImageList() + // As both functions unmarshal the same response body, the resulting array will be ordered the same way. + NodeName: nodeNames[fmt.Sprintf("%s-%d", image.ID, i)], + ID: image.ID, + Size: image.Size, + Tags: image.RepoTags, + Used: imageUsageSet.Contains(image.ID), + } + } + + return response.JSON(w, imagesList) +} diff --git a/api/http/handler/docker/utils/get_client.go b/api/http/handler/docker/utils/get_client.go new file mode 100644 index 0000000..90bb186 --- /dev/null +++ b/api/http/handler/docker/utils/get_client.go @@ -0,0 +1,28 @@ +package utils + +import ( + "net/http" + + dockerclient "github.com/docker/docker/client" + portainer "github.com/portainer/portainer/api" + prclient "github.com/portainer/portainer/api/docker/client" + "github.com/portainer/portainer/api/http/middlewares" + httperror "github.com/portainer/portainer/pkg/libhttp/error" +) + +// GetClient returns a Docker client based on the request context +func GetClient(r *http.Request, dockerClientFactory *prclient.ClientFactory) (*dockerclient.Client, *httperror.HandlerError) { + endpoint, err := middlewares.FetchEndpoint(r) + if err != nil { + return nil, httperror.NotFound("Unable to find an environment on request context", err) + } + + agentTargetHeader := r.Header.Get(portainer.PortainerAgentTargetHeader) + + cli, err := dockerClientFactory.CreateClient(endpoint, agentTargetHeader, nil) + if err != nil { + return nil, httperror.InternalServerError("Unable to connect to the Docker daemon", err) + } + + return cli, nil +} diff --git a/api/http/handler/docker/utils/get_stacks.go b/api/http/handler/docker/utils/get_stacks.go new file mode 100644 index 0000000..e44a8ff --- /dev/null +++ b/api/http/handler/docker/utils/get_stacks.go @@ -0,0 +1,95 @@ +package utils + +import ( + "fmt" + + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/swarm" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + dockerconsts "github.com/portainer/portainer/api/docker/consts" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/uac" +) + +type StackViewModel struct { + InternalStack *portainer.Stack + + ID portainer.StackID + Name string + IsExternal bool + Type portainer.StackType + Labels map[string]string +} + +// GetDockerStacks retrieves all the stacks associated to a specific environment filtered by the user's access. +func GetDockerStacks(tx dataservices.DataStoreTx, securityContext *security.RestrictedRequestContext, environmentID portainer.EndpointID, containers []types.Container, services []swarm.Service) ([]StackViewModel, error) { + stacks, err := tx.Stack().ReadAll() + if err != nil { + return nil, fmt.Errorf("Unable to retrieve stacks: %w", err) + } + + user, err := tx.User().Read(securityContext.UserID) + if err != nil { + return nil, fmt.Errorf("Unable to retrieve user: %w", err) + } + + stacksNameSet := map[string]*StackViewModel{} + + for i := range stacks { + stack := stacks[i] + if stack.EndpointID == environmentID { + stacksNameSet[stack.Name] = &StackViewModel{ + InternalStack: &stack, + ID: stack.ID, + Name: stack.Name, + IsExternal: false, + Type: stack.Type, + } + } + } + + for _, container := range containers { + name := container.Labels[dockerconsts.ComposeStackNameLabel] + + if name != "" && stacksNameSet[name] == nil && !isHiddenStack(container.Labels) { + stacksNameSet[name] = &StackViewModel{ + Name: name, + IsExternal: true, + Type: portainer.DockerComposeStack, + Labels: container.Labels, + } + } + } + + for _, service := range services { + name := service.Spec.Labels[dockerconsts.SwarmStackNameLabel] + + if name != "" && stacksNameSet[name] == nil && !isHiddenStack(service.Spec.Labels) { + stacksNameSet[name] = &StackViewModel{ + Name: name, + IsExternal: true, + Type: portainer.DockerSwarmStack, + Labels: service.Spec.Labels, + } + } + } + + stacksList := make([]StackViewModel, 0) + for _, stack := range stacksNameSet { + stacksList = append(stacksList, *stack) + } + + return uac.FilterByResourceControl(stacksList, user, securityContext.UserMemberships, + func(item StackViewModel) (*portainer.ResourceControl, error) { + if item.InternalStack != nil { + return uac.StackResourceControlGetter(tx, environmentID)(*item.InternalStack) + } + return uac.ExternalStackResourceControlGetter(tx, environmentID)(uac.ExternalStack{Labels: item.Labels}) + }, + ) +} + +func isHiddenStack(labels map[string]string) bool { + return labels[dockerconsts.HideStackLabel] != "" +} diff --git a/api/http/handler/docker/utils/get_stacks_test.go b/api/http/handler/docker/utils/get_stacks_test.go new file mode 100644 index 0000000..8a01ee9 --- /dev/null +++ b/api/http/handler/docker/utils/get_stacks_test.go @@ -0,0 +1,144 @@ +package utils + +import ( + "testing" + + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/swarm" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/docker/consts" + "github.com/portainer/portainer/api/http/security" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHandler_getDockerStacks(t *testing.T) { + t.Parallel() + is := require.New(t) + + environment := &portainer.Endpoint{ + ID: 1, + SecuritySettings: portainer.EndpointSecuritySettings{ + AllowStackManagementForRegularUsers: true, + }, + } + + containers := []types.Container{ + { + Labels: map[string]string{ + consts.ComposeStackNameLabel: "stack1", + }, + }, + { + Labels: map[string]string{ + consts.ComposeStackNameLabel: "stack2", + "io.portainer.accesscontrol.public": "true", + }, + }, + } + + services := []swarm.Service{ + { + Spec: swarm.ServiceSpec{ + Annotations: swarm.Annotations{ + Labels: map[string]string{ + consts.SwarmStackNameLabel: "stack3", + }, + }, + }, + }, + } + + stack1 := portainer.Stack{ + ID: 1, + Name: "stack1", + EndpointID: 1, + Type: portainer.DockerComposeStack, + } + + ok, store := datastore.MustNewTestStore(t, false, false) + is.True(ok) + + is.NoError(store.UpdateTx(func(tx dataservices.DataStoreTx) error { + is.NoError(tx.Endpoint().Create(environment)) + is.NoError(tx.Stack().Create(&stack1)) + is.NoError(tx.Stack().Create(&portainer.Stack{ + ID: 2, + Name: "stack2", // stack 2 on env 2 + EndpointID: 2, + Type: portainer.DockerSwarmStack, + })) + is.NoError(tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})) + is.NoError(tx.User().Create(&portainer.User{ID: 2, Role: portainer.StandardUserRole})) + return nil + })) + + // testing admin user + is.NoError(store.ViewTx(func(tx dataservices.DataStoreTx) error { + stacksList, err := GetDockerStacks(tx, &security.RestrictedRequestContext{ + IsAdmin: true, + UserID: 1, + }, environment.ID, containers, services) + require.NoError(t, err) + assert.Len(t, stacksList, 3) + + expectedStacks := []StackViewModel{ + { + InternalStack: &stack1, + ID: 1, + Name: "stack1", + IsExternal: false, + Type: portainer.DockerComposeStack, + }, + { + Name: "stack2", + IsExternal: true, + Type: portainer.DockerComposeStack, + Labels: map[string]string{ + consts.ComposeStackNameLabel: "stack2", + "io.portainer.accesscontrol.public": "true", + }, + }, + { + Name: "stack3", + IsExternal: true, + Type: portainer.DockerSwarmStack, + Labels: map[string]string{ + consts.SwarmStackNameLabel: "stack3", + }, + }, + } + + assert.ElementsMatch(t, expectedStacks, stacksList) + return nil + })) + + // testing standard user + is.NoError(store.ViewTx(func(tx dataservices.DataStoreTx) error { + stacksList, err := GetDockerStacks(tx, &security.RestrictedRequestContext{ + IsAdmin: false, + UserID: 2, + }, environment.ID, containers, services) + require.NoError(t, err) + assert.Len(t, stacksList, 1) + + expectedStacks := []StackViewModel{ + { + Name: "stack2", + IsExternal: true, + Type: portainer.DockerComposeStack, + Labels: map[string]string{ + consts.ComposeStackNameLabel: "stack2", + "io.portainer.accesscontrol.public": "true", + }, + }, + } + + assert.ElementsMatch(t, expectedStacks, stacksList) + return nil + })) + +} diff --git a/api/http/handler/edgegroups/associated_endpoints.go b/api/http/handler/edgegroups/associated_endpoints.go new file mode 100644 index 0000000..7310be7 --- /dev/null +++ b/api/http/handler/edgegroups/associated_endpoints.go @@ -0,0 +1,32 @@ +package edgegroups + +import ( + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/roar" +) + +func getTrustedEndpoints(tx dataservices.DataStoreTx, endpointIDs roar.Roar[portainer.EndpointID]) ([]portainer.EndpointID, error) { + var innerErr error + + results := []portainer.EndpointID{} + + endpointIDs.Iterate(func(endpointID portainer.EndpointID) bool { + endpoint, err := tx.Endpoint().Endpoint(endpointID) + if err != nil { + innerErr = err + + return false + } + + if !endpoint.UserTrusted { + return true + } + + results = append(results, endpoint.ID) + + return true + }) + + return results, innerErr +} diff --git a/api/http/handler/edgegroups/edgegroup_create.go b/api/http/handler/edgegroups/edgegroup_create.go new file mode 100644 index 0000000..16e4e6b --- /dev/null +++ b/api/http/handler/edgegroups/edgegroup_create.go @@ -0,0 +1,117 @@ +package edgegroups + +import ( + "errors" + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/internal/endpointutils" + "github.com/portainer/portainer/api/roar" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +type edgeGroupCreatePayload struct { + Name string + Dynamic bool + TagIDs []portainer.TagID + Endpoints []portainer.EndpointID + PartialMatch bool +} + +func (payload *edgeGroupCreatePayload) Validate(r *http.Request) error { + if len(payload.Name) == 0 { + return errors.New("invalid Edge group name") + } + + if payload.Dynamic && len(payload.TagIDs) == 0 { + return errors.New("tagIDs is mandatory for a dynamic Edge group") + } + + return nil +} + +func calculateEndpointsOrTags(tx dataservices.DataStoreTx, edgeGroup *portainer.EdgeGroup, endpoints []portainer.EndpointID, tagIDs []portainer.TagID) error { + if edgeGroup.Dynamic { + edgeGroup.TagIDs = tagIDs + + return nil + } + + endpointIDs := []portainer.EndpointID{} + + for _, endpointID := range endpoints { + endpoint, err := tx.Endpoint().Endpoint(endpointID) + if err != nil { + return httperror.InternalServerError("Unable to retrieve environment from the database", err) + } + + if endpointutils.IsEdgeEndpoint(endpoint) { + endpointIDs = append(endpointIDs, endpoint.ID) + } + } + + edgeGroup.Endpoints = endpointIDs + edgeGroup.EndpointIDs = roar.FromSlice(endpointIDs) + + return nil +} + +// @id EdgeGroupCreate +// @summary Create an EdgeGroup +// @description **Access policy**: administrator +// @tags edge_groups +// @security ApiKeyAuth +// @security jwt +// @accept json +// @produce json +// @param body body edgeGroupCreatePayload true "EdgeGroup data" +// @success 200 {object} portainer.EdgeGroup +// @failure 503 "Edge compute features are disabled" +// @failure 500 +// @router /edge_groups [post] +func (handler *Handler) edgeGroupCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload edgeGroupCreatePayload + if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + var shadowEdgeGroup shadowedEdgeGroup + err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + edgeGroups, err := tx.EdgeGroup().ReadAll() + if err != nil { + return httperror.InternalServerError("Unable to retrieve Edge groups from the database", err) + } + + for _, edgeGroup := range edgeGroups { + if edgeGroup.Name == payload.Name { + return httperror.BadRequest("Edge group name must be unique", errors.New("edge group name must be unique")) + } + } + + edgeGroup := &portainer.EdgeGroup{ + Name: payload.Name, + Dynamic: payload.Dynamic, + TagIDs: []portainer.TagID{}, + Endpoints: []portainer.EndpointID{}, + EndpointIDs: roar.Roar[portainer.EndpointID]{}, + PartialMatch: payload.PartialMatch, + } + + if err := calculateEndpointsOrTags(tx, edgeGroup, payload.Endpoints, payload.TagIDs); err != nil { + return err + } + + if err := tx.EdgeGroup().Create(edgeGroup); err != nil { + return httperror.InternalServerError("Unable to persist the Edge group inside the database", err) + } + + shadowEdgeGroup = shadowedEdgeGroup{EdgeGroup: *edgeGroup} + + return nil + }) + + return response.TxResponse(w, shadowEdgeGroup, err) +} diff --git a/api/http/handler/edgegroups/edgegroup_create_test.go b/api/http/handler/edgegroups/edgegroup_create_test.go new file mode 100644 index 0000000..374bf61 --- /dev/null +++ b/api/http/handler/edgegroups/edgegroup_create_test.go @@ -0,0 +1,57 @@ +package edgegroups + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/internal/testhelpers" + + "github.com/segmentio/encoding/json" + "github.com/stretchr/testify/require" +) + +func TestEdgeGroupCreateHandler(t *testing.T) { + t.Parallel() + handler, _ := newHandlerWithEdgeEndpoints(t) + + rr := httptest.NewRecorder() + + req := httptest.NewRequest( + http.MethodPost, + "/edge_groups", + strings.NewReader(`{"Name": "New Edge Group", "Endpoints": [1, 2, 3]}`), + ) + + handler.ServeHTTP(rr, req) + require.Equal(t, http.StatusOK, rr.Result().StatusCode) + + var responseGroup portainer.EdgeGroup + err := json.NewDecoder(rr.Body).Decode(&responseGroup) + require.NoError(t, err) + + require.ElementsMatch(t, []portainer.EndpointID{1, 2, 3}, responseGroup.Endpoints) +} + +func TestEdgeGroupCreatePanic(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + handler := NewHandler(testhelpers.NewTestRequestBouncer()) + handler.DataStore = store + + err := store.EdgeGroup().Create(&portainer.EdgeGroup{ID: 1, Name: "New Edge Group"}) + require.NoError(t, err) + + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, + "/edge_groups", + strings.NewReader(`{"Name": "New Edge Group", "Endpoints": [1, 2, 3]}`), + ) + + handler.ServeHTTP(rr, req) + require.Equal(t, http.StatusBadRequest, rr.Result().StatusCode) +} diff --git a/api/http/handler/edgegroups/edgegroup_delete.go b/api/http/handler/edgegroups/edgegroup_delete.go new file mode 100644 index 0000000..698059b --- /dev/null +++ b/api/http/handler/edgegroups/edgegroup_delete.go @@ -0,0 +1,77 @@ +package edgegroups + +import ( + "errors" + "net/http" + "slices" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + dserrors "github.com/portainer/portainer/api/dataservices/errors" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +// @id EdgeGroupDelete +// @summary Deletes an EdgeGroup +// @description **Access policy**: administrator +// @tags edge_groups +// @security ApiKeyAuth +// @security jwt +// @param id path int true "EdgeGroup Id" +// @success 204 +// @failure 409 "Edge group is in use by an Edge stack or Edge job" +// @failure 503 "Edge compute features are disabled" +// @failure 500 "Server error" +// @router /edge_groups/{id} [delete] +func (handler *Handler) edgeGroupDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + edgeGroupID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid Edge group identifier route variable", err) + } + + err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + return deleteEdgeGroup(tx, portainer.EdgeGroupID(edgeGroupID)) + }) + + return response.TxEmptyResponse(w, err) +} + +func deleteEdgeGroup(tx dataservices.DataStoreTx, ID portainer.EdgeGroupID) error { + ok, err := tx.EdgeGroup().Exists(ID) + if !ok { + return httperror.NotFound("Unable to find an Edge group with the specified identifier inside the database", dserrors.ErrObjectNotFound) + } else if err != nil { + return httperror.InternalServerError("Unable to find an Edge group with the specified identifier inside the database", err) + } + + edgeStacks, err := tx.EdgeStack().EdgeStacks() + if err != nil { + return httperror.InternalServerError("Unable to retrieve Edge stacks from the database", err) + } + + for _, edgeStack := range edgeStacks { + if slices.Contains(edgeStack.EdgeGroups, ID) { + return httperror.Conflict("Edge group is used by an Edge stack", errors.New("edge group is used by an Edge stack")) + } + } + + edgeJobs, err := tx.EdgeJob().ReadAll() + if err != nil { + return httperror.InternalServerError("Unable to retrieve Edge jobs from the database", err) + } + + for _, edgeJob := range edgeJobs { + if slices.Contains(edgeJob.EdgeGroups, ID) { + return httperror.Conflict("Edge group is used by an Edge job", errors.New("edge group is used by an Edge job")) + } + } + + err = tx.EdgeGroup().Delete(ID) + if err != nil { + return httperror.InternalServerError("Unable to remove the Edge group from the database", err) + } + + return nil +} diff --git a/api/http/handler/edgegroups/edgegroup_inspect.go b/api/http/handler/edgegroups/edgegroup_inspect.go new file mode 100644 index 0000000..2742801 --- /dev/null +++ b/api/http/handler/edgegroups/edgegroup_inspect.go @@ -0,0 +1,68 @@ +package edgegroups + +import ( + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/internal/endpointutils" + "github.com/portainer/portainer/api/roar" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +// @id EdgeGroupInspect +// @summary Inspects an EdgeGroup +// @description **Access policy**: administrator +// @tags edge_groups +// @security ApiKeyAuth +// @security jwt +// @produce json +// @param id path int true "EdgeGroup Id" +// @success 200 {object} portainer.EdgeGroup +// @failure 503 "Edge compute features are disabled" +// @failure 500 +// @router /edge_groups/{id} [get] +func (handler *Handler) edgeGroupInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + edgeGroupID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid Edge group identifier route variable", err) + } + + var shadowEdgeGroup shadowedEdgeGroup + err = handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error { + edgeGroup, err := getEdgeGroup(tx, portainer.EdgeGroupID(edgeGroupID)) + if err != nil { + return err + } + + edgeGroup.Endpoints = edgeGroup.EndpointIDs.ToSlice() + + shadowEdgeGroup = shadowedEdgeGroup{EdgeGroup: *edgeGroup} + + return nil + }) + + return response.TxResponse(w, shadowEdgeGroup, err) +} + +func getEdgeGroup(tx dataservices.DataStoreTx, ID portainer.EdgeGroupID) (*portainer.EdgeGroup, error) { + edgeGroup, err := tx.EdgeGroup().Read(ID) + if tx.IsErrObjectNotFound(err) { + return nil, httperror.NotFound("Unable to find an Edge group with the specified identifier inside the database", err) + } else if err != nil { + return nil, httperror.InternalServerError("Unable to find an Edge group with the specified identifier inside the database", err) + } + + if edgeGroup.Dynamic { + endpoints, err := endpointutils.GetEndpointsByTags(tx, edgeGroup.TagIDs, edgeGroup.PartialMatch) + if err != nil { + return nil, httperror.InternalServerError("Unable to retrieve environments and environment groups for Edge group", err) + } + + edgeGroup.EndpointIDs = roar.FromSlice(endpoints) + } + + return edgeGroup, err +} diff --git a/api/http/handler/edgegroups/edgegroup_inspect_test.go b/api/http/handler/edgegroups/edgegroup_inspect_test.go new file mode 100644 index 0000000..8bd0aef --- /dev/null +++ b/api/http/handler/edgegroups/edgegroup_inspect_test.go @@ -0,0 +1,201 @@ +package edgegroups + +import ( + "net/http" + "net/http/httptest" + "strconv" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/internal/testhelpers" + "github.com/portainer/portainer/api/roar" + + "github.com/segmentio/encoding/json" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newHandlerWithEdgeEndpoints(t *testing.T) (*Handler, *datastore.Store) { + t.Helper() + + _, store := datastore.MustNewTestStore(t, false, true) + + handler := NewHandler(testhelpers.NewTestRequestBouncer()) + handler.DataStore = store + + err := store.EndpointGroup().Create(&portainer.EndpointGroup{ + ID: 1, + Name: "Test Group", + }) + require.NoError(t, err) + + for i := range 3 { + err = store.Endpoint().Create(&portainer.Endpoint{ + ID: portainer.EndpointID(i + 1), + Name: "Test Endpoint " + strconv.Itoa(i+1), + Type: portainer.EdgeAgentOnDockerEnvironment, + GroupID: 1, + }) + require.NoError(t, err) + + err = store.EndpointRelation().Create(&portainer.EndpointRelation{ + EndpointID: portainer.EndpointID(i + 1), + EdgeStacks: map[portainer.EdgeStackID]bool{}, + }) + require.NoError(t, err) + } + + return handler, store +} + +func TestEdgeGroupInspectHandler(t *testing.T) { + t.Parallel() + handler, store := newHandlerWithEdgeEndpoints(t) + + err := store.EdgeGroup().Create(&portainer.EdgeGroup{ + ID: 1, + Name: "Test Edge Group", + EndpointIDs: roar.FromSlice([]portainer.EndpointID{1, 2, 3}), + }) + require.NoError(t, err) + + rr := httptest.NewRecorder() + + req := httptest.NewRequest( + http.MethodGet, + "/edge_groups/1", + nil, + ) + + handler.ServeHTTP(rr, req) + require.Equal(t, http.StatusOK, rr.Result().StatusCode) + + var responseGroup portainer.EdgeGroup + err = json.NewDecoder(rr.Body).Decode(&responseGroup) + require.NoError(t, err) + + assert.ElementsMatch(t, []portainer.EndpointID{1, 2, 3}, responseGroup.Endpoints) +} + +func TestEmptyEdgeGroupInspectHandler(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + handler := NewHandler(testhelpers.NewTestRequestBouncer()) + handler.DataStore = store + + err := store.EndpointGroup().Create(&portainer.EndpointGroup{ + ID: 1, + Name: "Test Group", + }) + require.NoError(t, err) + + err = store.EdgeGroup().Create(&portainer.EdgeGroup{ + ID: 1, + Name: "Test Edge Group", + EndpointIDs: roar.Roar[portainer.EndpointID]{}, + }) + require.NoError(t, err) + + rr := httptest.NewRecorder() + + req := httptest.NewRequest( + http.MethodGet, + "/edge_groups/1", + nil, + ) + + handler.ServeHTTP(rr, req) + require.Equal(t, http.StatusOK, rr.Result().StatusCode) + + var responseGroup portainer.EdgeGroup + err = json.NewDecoder(rr.Body).Decode(&responseGroup) + require.NoError(t, err) + + // Make sure the frontend does not get a null value but a [] instead + require.NotNil(t, responseGroup.Endpoints) + require.Empty(t, responseGroup.Endpoints) +} + +func TestDynamicEdgeGroupInspectHandler(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + handler := NewHandler(testhelpers.NewTestRequestBouncer()) + handler.DataStore = store + + err := store.EndpointGroup().Create(&portainer.EndpointGroup{ + ID: 1, + Name: "Test Group", + }) + require.NoError(t, err) + + err = store.Tag().Create(&portainer.Tag{ + ID: 1, + Name: "Test Tag", + Endpoints: map[portainer.EndpointID]bool{ + 1: true, + 2: true, + 3: true, + }, + }) + require.NoError(t, err) + + for i := range 3 { + err = store.Endpoint().Create(&portainer.Endpoint{ + ID: portainer.EndpointID(i + 1), + Name: "Test Endpoint " + strconv.Itoa(i+1), + Type: portainer.EdgeAgentOnDockerEnvironment, + GroupID: 1, + TagIDs: []portainer.TagID{1}, + UserTrusted: true, + }) + require.NoError(t, err) + + err = store.EndpointRelation().Create(&portainer.EndpointRelation{ + EndpointID: portainer.EndpointID(i + 1), + EdgeStacks: map[portainer.EdgeStackID]bool{}, + }) + require.NoError(t, err) + } + + err = store.EdgeGroup().Create(&portainer.EdgeGroup{ + ID: 1, + Name: "Test Edge Group", + Dynamic: true, + TagIDs: []portainer.TagID{1}, + }) + require.NoError(t, err) + + rr := httptest.NewRecorder() + + req := httptest.NewRequest( + http.MethodGet, + "/edge_groups/1", + nil, + ) + + handler.ServeHTTP(rr, req) + require.Equal(t, http.StatusOK, rr.Result().StatusCode) + + var responseGroup portainer.EdgeGroup + err = json.NewDecoder(rr.Body).Decode(&responseGroup) + require.NoError(t, err) + + require.ElementsMatch(t, []portainer.EndpointID{1, 2, 3}, responseGroup.Endpoints) +} + +func TestEdgeGroupInspectPanic(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + handler := NewHandler(testhelpers.NewTestRequestBouncer()) + handler.DataStore = store + + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/edge_groups/1", nil) + + handler.ServeHTTP(rr, req) + require.Equal(t, http.StatusNotFound, rr.Result().StatusCode) +} diff --git a/api/http/handler/edgegroups/edgegroup_list.go b/api/http/handler/edgegroups/edgegroup_list.go new file mode 100644 index 0000000..24c5818 --- /dev/null +++ b/api/http/handler/edgegroups/edgegroup_list.go @@ -0,0 +1,151 @@ +package edgegroups + +import ( + "fmt" + "net/http" + "slices" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/internal/endpointutils" + "github.com/portainer/portainer/api/roar" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +type shadowedEdgeGroup struct { + portainer.EdgeGroup + EndpointIds int `json:"EndpointIds,omitempty"` // Shadow to avoid exposing in the API +} + +type decoratedEdgeGroup struct { + shadowedEdgeGroup + HasEdgeStack bool `json:"HasEdgeStack"` + HasEdgeJob bool `json:"HasEdgeJob"` + EndpointTypes []portainer.EndpointType + TrustedEndpoints []portainer.EndpointID `json:"TrustedEndpoints"` +} + +// @id EdgeGroupList +// @summary list EdgeGroups +// @description **Access policy**: administrator +// @tags edge_groups +// @security ApiKeyAuth +// @security jwt +// @produce json +// @success 200 {array} decoratedEdgeGroup "EdgeGroups" +// @failure 500 +// @failure 503 "Edge compute features are disabled" +// @router /edge_groups [get] +func (handler *Handler) edgeGroupList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var decoratedEdgeGroups []decoratedEdgeGroup + var err error + + err = handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error { + decoratedEdgeGroups, err = getEdgeGroupList(tx) + return err + }) + + return response.TxResponse(w, decoratedEdgeGroups, err) +} + +func getEdgeGroupList(tx dataservices.DataStoreTx) ([]decoratedEdgeGroup, error) { + edgeGroups, err := tx.EdgeGroup().ReadAll() + if err != nil { + return nil, httperror.InternalServerError("Unable to retrieve Edge groups from the database", err) + } + + edgeStacks, err := tx.EdgeStack().EdgeStacks() + if err != nil { + return nil, httperror.InternalServerError("Unable to retrieve Edge stacks from the database", err) + } + + usedEdgeGroups := make(map[portainer.EdgeGroupID]bool) + + for _, stack := range edgeStacks { + for _, groupID := range stack.EdgeGroups { + usedEdgeGroups[groupID] = true + } + } + + edgeJobs, err := tx.EdgeJob().ReadAll() + if err != nil { + return nil, httperror.InternalServerError("Unable to retrieve Edge jobs from the database", err) + } + + decoratedEdgeGroups := []decoratedEdgeGroup{} + for _, orgEdgeGroup := range edgeGroups { + usedByEdgeJob := false + for _, edgeJob := range edgeJobs { + if slices.Contains(edgeJob.EdgeGroups, orgEdgeGroup.ID) { + usedByEdgeJob = true + break + } + } + + edgeGroup := decoratedEdgeGroup{ + shadowedEdgeGroup: shadowedEdgeGroup{EdgeGroup: orgEdgeGroup}, + EndpointTypes: []portainer.EndpointType{}, + } + if edgeGroup.Dynamic { + endpointIDs, err := endpointutils.GetEndpointsByTags(tx, edgeGroup.TagIDs, edgeGroup.PartialMatch) + if err != nil { + return nil, httperror.InternalServerError("Unable to retrieve environments and environment groups for Edge group", err) + } + + edgeGroup.Endpoints = endpointIDs + edgeGroup.TrustedEndpoints = endpointIDs + } else { + trustedEndpoints, err := getTrustedEndpoints(tx, edgeGroup.EndpointIDs) + if err != nil { + return nil, httperror.InternalServerError("Unable to retrieve environments for Edge group", err) + } + + edgeGroup.Endpoints = edgeGroup.EndpointIDs.ToSlice() + edgeGroup.TrustedEndpoints = trustedEndpoints + } + + endpointTypes, err := getEndpointTypes(tx, edgeGroup.EndpointIDs) + if err != nil { + return nil, httperror.InternalServerError("Unable to retrieve environment types for Edge group", err) + } + + edgeGroup.EndpointTypes = endpointTypes + edgeGroup.HasEdgeStack = usedEdgeGroups[edgeGroup.ID] + edgeGroup.HasEdgeJob = usedByEdgeJob + + decoratedEdgeGroups = append(decoratedEdgeGroups, edgeGroup) + } + + return decoratedEdgeGroups, nil +} + +func getEndpointTypes(tx dataservices.DataStoreTx, endpointIds roar.Roar[portainer.EndpointID]) ([]portainer.EndpointType, error) { + var innerErr error + + typeSet := map[portainer.EndpointType]bool{} + + endpointIds.Iterate(func(endpointID portainer.EndpointID) bool { + endpoint, err := tx.Endpoint().Endpoint(endpointID) + if err != nil { + innerErr = fmt.Errorf("failed fetching environment: %w", err) + + return false + } + + typeSet[endpoint.Type] = true + + return true + }) + + if innerErr != nil { + return nil, innerErr + } + + endpointTypes := make([]portainer.EndpointType, 0, len(typeSet)) + for endpointType := range typeSet { + endpointTypes = append(endpointTypes, endpointType) + } + + return endpointTypes, nil +} diff --git a/api/http/handler/edgegroups/edgegroup_list_test.go b/api/http/handler/edgegroups/edgegroup_list_test.go new file mode 100644 index 0000000..aeaac29 --- /dev/null +++ b/api/http/handler/edgegroups/edgegroup_list_test.go @@ -0,0 +1,92 @@ +package edgegroups + +import ( + "net/http" + "net/http/httptest" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/internal/testhelpers" + "github.com/portainer/portainer/api/roar" + + "github.com/segmentio/encoding/json" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_getEndpointTypes(t *testing.T) { + t.Parallel() + endpoints := []portainer.Endpoint{ + {ID: 1, Type: portainer.DockerEnvironment}, + {ID: 2, Type: portainer.AgentOnDockerEnvironment}, + {ID: 3, Type: portainer.AzureEnvironment}, + {ID: 4, Type: portainer.EdgeAgentOnDockerEnvironment}, + {ID: 5, Type: portainer.KubernetesLocalEnvironment}, + {ID: 6, Type: portainer.AgentOnKubernetesEnvironment}, + {ID: 7, Type: portainer.EdgeAgentOnKubernetesEnvironment}, + } + + datastore := testhelpers.NewDatastore(testhelpers.WithEndpoints(endpoints)) + + tests := []struct { + endpointIds []portainer.EndpointID + expected []portainer.EndpointType + }{ + {endpointIds: []portainer.EndpointID{1}, expected: []portainer.EndpointType{portainer.DockerEnvironment}}, + {endpointIds: []portainer.EndpointID{2}, expected: []portainer.EndpointType{portainer.AgentOnDockerEnvironment}}, + {endpointIds: []portainer.EndpointID{3}, expected: []portainer.EndpointType{portainer.AzureEnvironment}}, + {endpointIds: []portainer.EndpointID{4}, expected: []portainer.EndpointType{portainer.EdgeAgentOnDockerEnvironment}}, + {endpointIds: []portainer.EndpointID{5}, expected: []portainer.EndpointType{portainer.KubernetesLocalEnvironment}}, + {endpointIds: []portainer.EndpointID{6}, expected: []portainer.EndpointType{portainer.AgentOnKubernetesEnvironment}}, + {endpointIds: []portainer.EndpointID{7}, expected: []portainer.EndpointType{portainer.EdgeAgentOnKubernetesEnvironment}}, + {endpointIds: []portainer.EndpointID{7, 2}, expected: []portainer.EndpointType{portainer.EdgeAgentOnKubernetesEnvironment, portainer.AgentOnDockerEnvironment}}, + {endpointIds: []portainer.EndpointID{6, 4, 1}, expected: []portainer.EndpointType{portainer.AgentOnKubernetesEnvironment, portainer.EdgeAgentOnDockerEnvironment, portainer.DockerEnvironment}}, + {endpointIds: []portainer.EndpointID{1, 2, 3}, expected: []portainer.EndpointType{portainer.DockerEnvironment, portainer.AgentOnDockerEnvironment, portainer.AzureEnvironment}}, + } + + for _, test := range tests { + ans, err := getEndpointTypes(datastore, roar.FromSlice(test.endpointIds)) + require.NoError(t, err, "getEndpointTypes shouldn't fail") + + assert.ElementsMatch(t, test.expected, ans, "getEndpointTypes expected to return %b for %v, but returned %b", test.expected, test.endpointIds, ans) + } +} + +func Test_getEndpointTypes_failWhenEndpointDontExist(t *testing.T) { + t.Parallel() + datastore := testhelpers.NewDatastore(testhelpers.WithEndpoints([]portainer.Endpoint{})) + + _, err := getEndpointTypes(datastore, roar.FromSlice([]portainer.EndpointID{1})) + require.Error(t, err, "getEndpointTypes should fail") +} + +func TestEdgeGroupListHandler(t *testing.T) { + t.Parallel() + handler, store := newHandlerWithEdgeEndpoints(t) + + err := store.EdgeGroup().Create(&portainer.EdgeGroup{ + ID: 1, + Name: "Test Edge Group", + EndpointIDs: roar.FromSlice([]portainer.EndpointID{1, 2, 3}), + }) + require.NoError(t, err) + + rr := httptest.NewRecorder() + + req := httptest.NewRequest( + http.MethodGet, + "/edge_groups", + nil, + ) + + handler.ServeHTTP(rr, req) + require.Equal(t, http.StatusOK, rr.Result().StatusCode) + + var responseGroups []decoratedEdgeGroup + err = json.NewDecoder(rr.Body).Decode(&responseGroups) + require.NoError(t, err) + + require.Len(t, responseGroups, 1) + require.ElementsMatch(t, []portainer.EndpointID{1, 2, 3}, responseGroups[0].Endpoints) + require.Empty(t, responseGroups[0].TrustedEndpoints) +} diff --git a/api/http/handler/edgegroups/edgegroup_update.go b/api/http/handler/edgegroups/edgegroup_update.go new file mode 100644 index 0000000..c2d8497 --- /dev/null +++ b/api/http/handler/edgegroups/edgegroup_update.go @@ -0,0 +1,203 @@ +package edgegroups + +import ( + "errors" + "net/http" + "slices" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/internal/edge" + "github.com/portainer/portainer/api/internal/edge/cache" + "github.com/portainer/portainer/api/internal/endpointutils" + "github.com/portainer/portainer/api/slicesx" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +type edgeGroupUpdatePayload struct { + Name string + Dynamic bool + TagIDs []portainer.TagID + Endpoints []portainer.EndpointID + PartialMatch *bool +} + +func (payload *edgeGroupUpdatePayload) Validate(r *http.Request) error { + if payload.Dynamic && len(payload.TagIDs) == 0 { + return errors.New("tagIDs is mandatory for a dynamic Edge group") + } + + return nil +} + +// @id EdgeGroupUpdate +// @summary Updates an EdgeGroup +// @description **Access policy**: administrator +// @tags edge_groups +// @security ApiKeyAuth +// @security jwt +// @accept json +// @produce json +// @param id path int true "EdgeGroup Id" +// @param body body edgeGroupUpdatePayload true "EdgeGroup data" +// @success 200 {object} portainer.EdgeGroup +// @failure 503 "Edge compute features are disabled" +// @failure 500 +// @router /edge_groups/{id} [put] +func (handler *Handler) edgeGroupUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + edgeGroupID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid Edge group identifier route variable", err) + } + + var payload edgeGroupUpdatePayload + if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + var shadowEdgeGroup shadowedEdgeGroup + err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + edgeGroup, err := tx.EdgeGroup().Read(portainer.EdgeGroupID(edgeGroupID)) + if handler.DataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find an Edge group with the specified identifier inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find an Edge group with the specified identifier inside the database", err) + } + + edgeGroups, err := tx.EdgeGroup().ReadAll() + if err != nil { + return httperror.InternalServerError("Unable to retrieve Edge groups from the database", err) + } + + if payload.Name != "" { + for _, edgeGroup := range edgeGroups { + if edgeGroup.Name == payload.Name && edgeGroup.ID != portainer.EdgeGroupID(edgeGroupID) { + return httperror.BadRequest("Edge group name must be unique", errors.New("edge group name must be unique")) + } + } + + edgeGroup.Name = payload.Name + } + + endpoints, err := tx.Endpoint().Endpoints() + if err != nil { + return httperror.InternalServerError("Unable to retrieve environments from database", err) + } + + endpointGroups, err := tx.EndpointGroup().ReadAll() + if err != nil { + return httperror.InternalServerError("Unable to retrieve environment groups from database", err) + } + + oldRelatedEndpoints := edge.EdgeGroupRelatedEndpoints(edgeGroup, endpoints, endpointGroups) + + edgeGroup.Dynamic = payload.Dynamic + if err := calculateEndpointsOrTags(tx, edgeGroup, payload.Endpoints, payload.TagIDs); err != nil { + return err + } + + if payload.PartialMatch != nil { + edgeGroup.PartialMatch = *payload.PartialMatch + } + + if err := tx.EdgeGroup().Update(edgeGroup.ID, edgeGroup); err != nil { + return httperror.InternalServerError("Unable to persist Edge group changes inside the database", err) + } + + newRelatedEndpoints := edge.EdgeGroupRelatedEndpoints(edgeGroup, endpoints, endpointGroups) + endpointsToUpdate := slicesx.Unique(append(newRelatedEndpoints, oldRelatedEndpoints...)) + + edgeJobs, err := tx.EdgeJob().ReadAll() + if err != nil { + return httperror.InternalServerError("Unable to fetch Edge jobs", err) + } + + edgeStacks, err := tx.EdgeStack().EdgeStacks() + if err != nil { + return err + } + + // Update the edgeGroups with the modified edgeGroup for updateEndpointStacks() + for i := range edgeGroups { + if edgeGroups[i].ID == edgeGroup.ID { + edgeGroups[i] = *edgeGroup + } + } + + for _, endpointID := range endpointsToUpdate { + endpoint, err := tx.Endpoint().Endpoint(endpointID) + if err != nil { + return httperror.InternalServerError("Unable to get Environment from database", err) + } + + if err := handler.updateEndpointStacks(tx, endpoint, edgeGroups, edgeStacks); err != nil { + return httperror.InternalServerError("Unable to persist Environment relation changes inside the database", err) + } + + if !endpointutils.IsEdgeEndpoint(endpoint) { + continue + } + + var operation string + if slices.Contains(newRelatedEndpoints, endpointID) && slices.Contains(oldRelatedEndpoints, endpointID) { + continue + } else if slices.Contains(newRelatedEndpoints, endpointID) { + operation = "add" + } else if slices.Contains(oldRelatedEndpoints, endpointID) { + operation = "remove" + } else { + continue + } + + if err := handler.updateEndpointEdgeJobs(edgeGroup.ID, endpoint, edgeJobs, operation); err != nil { + return httperror.InternalServerError("Unable to persist Environment Edge Jobs changes inside the database", err) + } + } + + shadowEdgeGroup = shadowedEdgeGroup{EdgeGroup: *edgeGroup} + + return nil + }) + + return response.TxResponse(w, shadowEdgeGroup, err) +} + +func (handler *Handler) updateEndpointStacks(tx dataservices.DataStoreTx, endpoint *portainer.Endpoint, edgeGroups []portainer.EdgeGroup, edgeStacks []portainer.EdgeStack) error { + relation, err := tx.EndpointRelation().EndpointRelation(endpoint.ID) + if err != nil { + return err + } + + endpointGroup, err := tx.EndpointGroup().Read(endpoint.GroupID) + if err != nil { + return err + } + + edgeStackSet := map[portainer.EdgeStackID]bool{} + + endpointEdgeStacks := edge.EndpointRelatedEdgeStacks(endpoint, endpointGroup, edgeGroups, edgeStacks) + for _, edgeStackID := range endpointEdgeStacks { + edgeStackSet[edgeStackID] = true + } + + relation.EdgeStacks = edgeStackSet + + return tx.EndpointRelation().UpdateEndpointRelation(endpoint.ID, relation) +} + +func (handler *Handler) updateEndpointEdgeJobs(edgeGroupID portainer.EdgeGroupID, endpoint *portainer.Endpoint, edgeJobs []portainer.EdgeJob, operation string) error { + for _, edgeJob := range edgeJobs { + if !slices.Contains(edgeJob.EdgeGroups, edgeGroupID) { + continue + } + + switch operation { + case "add", "remove": + cache.Del(endpoint.ID) + } + } + + return nil +} diff --git a/api/http/handler/edgegroups/edgegroup_update_test.go b/api/http/handler/edgegroups/edgegroup_update_test.go new file mode 100644 index 0000000..b14ed31 --- /dev/null +++ b/api/http/handler/edgegroups/edgegroup_update_test.go @@ -0,0 +1,59 @@ +package edgegroups + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/internal/testhelpers" + "github.com/portainer/portainer/api/roar" + + "github.com/segmentio/encoding/json" + "github.com/stretchr/testify/require" +) + +func TestEdgeGroupUpdateHandler(t *testing.T) { + t.Parallel() + handler, store := newHandlerWithEdgeEndpoints(t) + + err := store.EdgeGroup().Create(&portainer.EdgeGroup{ + ID: 1, + Name: "Test Edge Group", + EndpointIDs: roar.FromSlice([]portainer.EndpointID{1}), + }) + require.NoError(t, err) + + rr := httptest.NewRecorder() + + req := httptest.NewRequest( + http.MethodPut, + "/edge_groups/1", + strings.NewReader(`{"Endpoints": [1, 2, 3]}`), + ) + + handler.ServeHTTP(rr, req) + require.Equal(t, http.StatusOK, rr.Result().StatusCode) + + var responseGroup portainer.EdgeGroup + err = json.NewDecoder(rr.Body).Decode(&responseGroup) + require.NoError(t, err) + + require.ElementsMatch(t, []portainer.EndpointID{1, 2, 3}, responseGroup.Endpoints) +} + +func TestEdgeGroupUpdatePanic(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + handler := NewHandler(testhelpers.NewTestRequestBouncer()) + handler.DataStore = store + + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPut, "/edge_groups/1", strings.NewReader("{}")) + + handler.ServeHTTP(rr, req) + require.Equal(t, http.StatusNotFound, rr.Result().StatusCode) +} diff --git a/api/http/handler/edgegroups/handler.go b/api/http/handler/edgegroups/handler.go new file mode 100644 index 0000000..b813872 --- /dev/null +++ b/api/http/handler/edgegroups/handler.go @@ -0,0 +1,38 @@ +package edgegroups + +import ( + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/http/security" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + + "github.com/gorilla/mux" +) + +// Handler is the HTTP handler used to handle environment(endpoint) group operations. +type Handler struct { + *mux.Router + DataStore dataservices.DataStore + ReverseTunnelService portainer.ReverseTunnelService +} + +// NewHandler creates a handler to manage environment(endpoint) group operations. +func NewHandler(bouncer security.BouncerService) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + } + h.Handle("/edge_groups", + bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeGroupCreate)))).Methods(http.MethodPost) + h.Handle("/edge_groups", + bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeGroupList)))).Methods(http.MethodGet) + h.Handle("/edge_groups/{id}", + bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeGroupInspect)))).Methods(http.MethodGet) + h.Handle("/edge_groups/{id}", + bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeGroupUpdate)))).Methods(http.MethodPut) + h.Handle("/edge_groups/{id}", + 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 0000000..dab6b11 --- /dev/null +++ b/api/http/handler/edgejobs/edgejob_create.go @@ -0,0 +1,271 @@ +package edgejobs + +import ( + "errors" + "maps" + "net/http" + "strconv" + "strings" + "time" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/internal/edge" + "github.com/portainer/portainer/api/internal/edge/cache" + "github.com/portainer/portainer/api/internal/endpointutils" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" + "github.com/portainer/portainer/pkg/validate" +) + +type edgeJobBasePayload struct { + Name string + CronExpression string + Recurring bool + Endpoints []portainer.EndpointID + EdgeGroups []portainer.EdgeGroupID +} + +func (handler *Handler) edgeJobCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + method, err := request.RetrieveRouteVariableValue(r, "method") + if err != nil { + return httperror.BadRequest("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.BadRequest("Invalid query parameter: method. Valid values are: file or string", errors.New(strings.ToLower(request.ErrInvalidQueryParameter))) + } +} + +type edgeJobCreateFromFileContentPayload struct { + edgeJobBasePayload + FileContent string +} + +func (payload *edgeJobCreateFromFileContentPayload) Validate(r *http.Request) error { + if len(payload.Name) == 0 { + return errors.New("invalid Edge job name") + } + + if !validate.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 len(payload.CronExpression) == 0 { + return errors.New("invalid cron expression") + } + + if len(payload.Endpoints) == 0 && len(payload.EdgeGroups) == 0 { + return errors.New("no environments or groups have been provided") + } + + if len(payload.FileContent) == 0 { + return errors.New("invalid script file content") + } + + return nil +} + +// @id EdgeJobCreateString +// @summary Create an EdgeJob from a text +// @description **Access policy**: administrator +// @tags edge_jobs +// @security ApiKeyAuth +// @security jwt +// @produce json +// @param body body edgeJobCreateFromFileContentPayload true "EdgeGroup data when method is string" +// @success 200 {object} portainer.EdgeGroup +// @failure 503 "Edge compute features are disabled" +// @failure 500 +// @router /edge_jobs/create/string [post] +func (handler *Handler) createEdgeJobFromFileContent(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload edgeJobCreateFromFileContentPayload + if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + var edgeJob *portainer.EdgeJob + var err error + err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + edgeJob, err = handler.createEdgeJob(tx, &payload.edgeJobBasePayload, []byte(payload.FileContent)) + return err + }) + + return response.TxResponse(w, edgeJob, err) +} + +func (handler *Handler) createEdgeJob(tx dataservices.DataStoreTx, payload *edgeJobBasePayload, fileContent []byte) (*portainer.EdgeJob, error) { + var err error + + edgeJob := handler.createEdgeJobObjectFromPayload(tx, payload) + + var endpoints []portainer.EndpointID + if len(edgeJob.EdgeGroups) > 0 { + endpoints, err = edge.GetEndpointsFromEdgeGroups(payload.EdgeGroups, tx) + if err != nil { + return nil, httperror.InternalServerError("Unable to get Endpoints from EdgeGroups", err) + } + } + + if err := handler.addAndPersistEdgeJob(tx, edgeJob, fileContent, endpoints); err != nil { + return nil, httperror.InternalServerError("Unable to schedule Edge job", err) + } + + for _, endpointID := range endpoints { + cache.Del(endpointID) + } + + return edgeJob, nil +} + +type edgeJobCreateFromFilePayload struct { + edgeJobBasePayload + 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 !validate.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 + if err := request.RetrieveMultiPartFormJSONValue(r, "Endpoints", &endpoints, true); err != nil { + return errors.New("invalid environments") + } + payload.Endpoints = endpoints + + var edgeGroups []portainer.EdgeGroupID + if err := request.RetrieveMultiPartFormJSONValue(r, "EdgeGroups", &edgeGroups, true); err != nil { + return errors.New("invalid edge groups") + } + payload.EdgeGroups = edgeGroups + + if len(payload.Endpoints) == 0 && len(payload.EdgeGroups) == 0 { + return errors.New("no environments or groups have been provided") + } + + 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 +} + +// @id EdgeJobCreateFile +// @summary Create an EdgeJob from a file +// @description **Access policy**: administrator +// @tags edge_jobs +// @accept multipart/form-data +// @security ApiKeyAuth +// @security jwt +// @produce json +// @param file formData file true "Content of the Stack file" +// @param Name formData string true "Name of the stack" +// @param CronExpression formData string true "A cron expression to schedule this job" +// @param EdgeGroups formData string true "JSON stringified array of Edge Groups ids" +// @param Endpoints formData string true "JSON stringified array of Environment ids" +// @param Recurring formData bool false "If recurring" +// @success 200 {object} portainer.EdgeGroup +// @failure 503 "Edge compute features are disabled" +// @failure 500 +// @router /edge_jobs/create/file [post] +func (handler *Handler) createEdgeJobFromFile(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + payload := &edgeJobCreateFromFilePayload{} + if err := payload.Validate(r); err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + var edgeJob *portainer.EdgeJob + var err error + err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + edgeJob, err = handler.createEdgeJob(tx, &payload.edgeJobBasePayload, payload.File) + return err + }) + + return response.TxResponse(w, edgeJob, err) +} + +func (handler *Handler) createEdgeJobObjectFromPayload(tx dataservices.DataStoreTx, payload *edgeJobBasePayload) *portainer.EdgeJob { + return &portainer.EdgeJob{ + ID: portainer.EdgeJobID(tx.EdgeJob().GetNextIdentifier()), + Name: payload.Name, + CronExpression: payload.CronExpression, + Recurring: payload.Recurring, + Created: time.Now().Unix(), + Endpoints: convertEndpointsToMetaObject(payload.Endpoints), + EdgeGroups: payload.EdgeGroups, + Version: 1, + GroupLogsCollection: map[portainer.EndpointID]portainer.EdgeJobEndpointMeta{}, + } +} + +func (handler *Handler) addAndPersistEdgeJob(tx dataservices.DataStoreTx, edgeJob *portainer.EdgeJob, file []byte, endpointsFromGroups []portainer.EndpointID) 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 := tx.Endpoint().Endpoint(ID) + if err != nil { + return err + } + + if !endpointutils.IsEdgeEndpoint(endpoint) { + delete(edgeJob.Endpoints, ID) + } + } + + scriptPath, err := handler.FileService.StoreEdgeJobFileFromBytes(strconv.Itoa(int(edgeJob.ID)), file) + if err != nil { + return err + } + edgeJob.ScriptPath = scriptPath + + var endpointsMap map[portainer.EndpointID]portainer.EdgeJobEndpointMeta + if len(endpointsFromGroups) > 0 { + endpointsMap = convertEndpointsToMetaObject(endpointsFromGroups) + + for ID := range endpointsMap { + endpoint, err := tx.Endpoint().Endpoint(ID) + if err != nil { + return err + } + + if !endpointutils.IsEdgeEndpoint(endpoint) { + delete(endpointsMap, ID) + } + } + + maps.Copy(endpointsMap, edgeJob.Endpoints) + } else { + endpointsMap = edgeJob.Endpoints + } + + if len(endpointsMap) == 0 { + return errors.New("environments or edge groups are mandatory for an Edge job") + } + + return tx.EdgeJob().CreateWithID(edgeJob.ID, edgeJob) +} diff --git a/api/http/handler/edgejobs/edgejob_create_test.go b/api/http/handler/edgejobs/edgejob_create_test.go new file mode 100644 index 0000000..e0aee3a --- /dev/null +++ b/api/http/handler/edgejobs/edgejob_create_test.go @@ -0,0 +1,160 @@ +package edgejobs + +import ( + "bytes" + "encoding/json" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gorilla/mux" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/datastore" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +type mockFileService struct { + mock.Mock + portainer.FileService +} + +func (m *mockFileService) StoreEdgeJobFileFromBytes(id string, file []byte) (string, error) { + args := m.Called(id, file) + return args.String(0), args.Error(1) +} + +func (m *mockFileService) GetEdgeJobFolder(id string) string { + args := m.Called(id) + + return args.String(0) +} + +func (m *mockFileService) RemoveDirectory(path string) error { + args := m.Called(path) + + return args.Error(0) +} + +func initStore(t *testing.T) *datastore.Store { + _, store := datastore.MustNewTestStore(t, true, true) + require.NotNil(t, store) + + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + require.NoError(t, tx.Endpoint().Create(&portainer.Endpoint{ + ID: 1, + Name: "endpoint-1", + EdgeID: "edge-id-1", + GroupID: 1, + Type: portainer.EdgeAgentOnDockerEnvironment, + UserTrusted: true, + })) + + require.NoError(t, tx.Endpoint().Create(&portainer.Endpoint{ + ID: 2, + Name: "endpoint-2", + EdgeID: "edge-id-2", + GroupID: 1, + Type: portainer.EdgeAgentOnDockerEnvironment, + UserTrusted: false, + })) + return nil + })) + + return store +} + +func Test_edgeJobCreate_StringMethod_Success(t *testing.T) { + t.Parallel() + store := initStore(t) + + fileService := &mockFileService{} + fileService.On("StoreEdgeJobFileFromBytes", mock.Anything, mock.Anything).Return("testfile.txt", nil) + + handler := &Handler{ + DataStore: store, + FileService: fileService, + } + + payload := edgeJobCreateFromFileContentPayload{ + edgeJobBasePayload: edgeJobBasePayload{ + Name: "testjob", + CronExpression: "* * * * *", + Endpoints: []portainer.EndpointID{1, 2}, + }, + FileContent: "echo hello", + } + + body, _ := json.Marshal(payload) + req := httptest.NewRequest(http.MethodPost, "/edge_jobs/create/string", bytes.NewReader(body)) + req = mux.SetURLVars(req, map[string]string{"method": "string"}) + w := httptest.NewRecorder() + + // Call handler + errh := handler.edgeJobCreate(w, req) + require.Nil(t, errh) + require.Equal(t, http.StatusOK, w.Result().StatusCode) + + // Get edge job ID from response + var resp struct { + ID int `json:"Id"` + } + require.NoError(t, json.NewDecoder(w.Body).Decode(&resp)) + + edgeJob, err := store.EdgeJob().Read(portainer.EdgeJobID(resp.ID)) + require.NoError(t, err) + + require.Len(t, edgeJob.Endpoints, 2) + require.Contains(t, edgeJob.Endpoints, portainer.EndpointID(1)) +} + +func Test_edgeJobCreate_FileMethod_Success(t *testing.T) { + t.Parallel() + store := initStore(t) + + fileService := &mockFileService{} + fileService.On("StoreEdgeJobFileFromBytes", mock.Anything, mock.Anything).Return("testfile.txt", nil) + + handler := &Handler{ + DataStore: store, + FileService: fileService, + } + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + require.NoError(t, writer.WriteField("Name", "testjob")) + require.NoError(t, writer.WriteField("CronExpression", "* * * * *")) + require.NoError(t, writer.WriteField("Endpoints", "[1,2]")) + + fileWriter, err := writer.CreateFormFile("file", "test.txt") + require.NoError(t, err) + + _, err = io.Copy(fileWriter, strings.NewReader("echo hello")) + require.NoError(t, err) + require.NoError(t, writer.Close()) + + req := httptest.NewRequest(http.MethodPost, "/edge_jobs/create/file", &body) + req = mux.SetURLVars(req, map[string]string{"method": "file"}) + req.Header.Set("Content-Type", writer.FormDataContentType()) + + w := httptest.NewRecorder() + handlerErr := handler.edgeJobCreate(w, req) + require.Nil(t, handlerErr) + require.Equal(t, http.StatusOK, w.Result().StatusCode) + + var resp struct { + ID int `json:"Id"` + } + require.NoError(t, json.NewDecoder(w.Body).Decode(&resp)) + + edgeJob, err := store.EdgeJob().Read(portainer.EdgeJobID(resp.ID)) + require.NoError(t, err) + + require.Len(t, edgeJob.Endpoints, 2) + require.Contains(t, edgeJob.Endpoints, portainer.EndpointID(1)) +} diff --git a/api/http/handler/edgejobs/edgejob_delete.go b/api/http/handler/edgejobs/edgejob_delete.go new file mode 100644 index 0000000..6b26507 --- /dev/null +++ b/api/http/handler/edgejobs/edgejob_delete.go @@ -0,0 +1,79 @@ +package edgejobs + +import ( + "maps" + "net/http" + "strconv" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/internal/edge" + "github.com/portainer/portainer/api/internal/edge/cache" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" + + "github.com/rs/zerolog/log" +) + +// @id EdgeJobDelete +// @summary Delete an EdgeJob +// @description **Access policy**: administrator +// @tags edge_jobs +// @security ApiKeyAuth +// @security jwt +// @param id path int true "EdgeJob Id" +// @success 204 +// @failure 500 +// @failure 400 +// @failure 503 "Edge compute features are disabled" +// @router /edge_jobs/{id} [delete] +func (handler *Handler) edgeJobDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + edgeJobID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid Edge job identifier route variable", err) + } + + err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + return handler.deleteEdgeJob(tx, portainer.EdgeJobID(edgeJobID)) + }) + + return response.TxEmptyResponse(w, err) +} + +func (handler *Handler) deleteEdgeJob(tx dataservices.DataStoreTx, edgeJobID portainer.EdgeJobID) error { + edgeJob, err := tx.EdgeJob().Read(edgeJobID) + if tx.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find an Edge job with the specified identifier inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find an Edge job with the specified identifier inside the database", err) + } + + edgeJobFolder := handler.FileService.GetEdgeJobFolder(strconv.Itoa(int(edgeJobID))) + if err := handler.FileService.RemoveDirectory(edgeJobFolder); err != nil { + log.Warn().Err(err).Msg("Unable to remove the files associated to the Edge job on the filesystem") + } + + var endpointsMap map[portainer.EndpointID]portainer.EdgeJobEndpointMeta + if len(edgeJob.EdgeGroups) > 0 { + endpoints, err := edge.GetEndpointsFromEdgeGroups(edgeJob.EdgeGroups, tx) + if err != nil { + return httperror.InternalServerError("Unable to get Endpoints from EdgeGroups", err) + } + + endpointsMap = convertEndpointsToMetaObject(endpoints) + maps.Copy(endpointsMap, edgeJob.Endpoints) + } else { + endpointsMap = edgeJob.Endpoints + } + + for endpointID := range endpointsMap { + cache.Del(endpointID) + } + + if err := tx.EdgeJob().Delete(edgeJob.ID); err != nil { + return httperror.InternalServerError("Unable to remove the Edge job from the database", err) + } + + return nil +} diff --git a/api/http/handler/edgejobs/edgejob_file.go b/api/http/handler/edgejobs/edgejob_file.go new file mode 100644 index 0000000..f892420 --- /dev/null +++ b/api/http/handler/edgejobs/edgejob_file.go @@ -0,0 +1,48 @@ +package edgejobs + +import ( + "net/http" + + portainer "github.com/portainer/portainer/api" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +type edgeJobFileResponse struct { + FileContent string `json:"FileContent"` +} + +// @id EdgeJobFile +// @summary Fetch a file of an EdgeJob +// @description **Access policy**: administrator +// @tags edge_jobs +// @security ApiKeyAuth +// @security jwt +// @produce json +// @param id path int true "EdgeJob Id" +// @success 200 {object} edgeJobFileResponse +// @failure 500 +// @failure 400 +// @failure 503 "Edge compute features are disabled" +// @router /edge_jobs/{id}/file [get] +func (handler *Handler) edgeJobFile(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + edgeJobID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid Edge job identifier route variable", err) + } + + edgeJob, err := handler.DataStore.EdgeJob().Read(portainer.EdgeJobID(edgeJobID)) + if handler.DataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find an Edge job with the specified identifier inside the database", err) + } else if err != nil { + return httperror.InternalServerError("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.InternalServerError("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 0000000..2407b2b --- /dev/null +++ b/api/http/handler/edgejobs/edgejob_inspect.go @@ -0,0 +1,52 @@ +package edgejobs + +import ( + "net/http" + + portainer "github.com/portainer/portainer/api" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +type edgeJobInspectResponse struct { + *portainer.EdgeJob + Endpoints []portainer.EndpointID +} + +// @id EdgeJobInspect +// @summary Inspect an EdgeJob +// @description **Access policy**: administrator +// @tags edge_jobs +// @security ApiKeyAuth +// @security jwt +// @produce json +// @param id path int true "EdgeJob Id" +// @success 200 {object} portainer.EdgeJob +// @failure 500 +// @failure 400 +// @failure 503 "Edge compute features are disabled" +// @router /edge_jobs/{id} [get] +func (handler *Handler) edgeJobInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + edgeJobID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid Edge job identifier route variable", err) + } + + edgeJob, err := handler.DataStore.EdgeJob().Read(portainer.EdgeJobID(edgeJobID)) + if handler.DataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find an Edge job with the specified identifier inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find an Edge job with the specified identifier inside the database", err) + } + + responseObj := edgeJobInspectResponse{ + EdgeJob: edgeJob, + } + + for endpointID := range edgeJob.Endpoints { + responseObj.Endpoints = append(responseObj.Endpoints, endpointID) + } + + 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 0000000..f28c58f --- /dev/null +++ b/api/http/handler/edgejobs/edgejob_list.go @@ -0,0 +1,30 @@ +package edgejobs + +import ( + "net/http" + + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +// @id EdgeJobList +// @summary Fetch EdgeJobs list +// @description **Access policy**: administrator +// @tags edge_jobs +// @security ApiKeyAuth +// @security jwt +// @produce json +// @success 200 {array} portainer.EdgeJob +// @failure 500 +// @failure 400 +// @failure 503 "Edge compute features are disabled" +// @router /edge_jobs [get] +// GET request on /api/edge_jobs +func (handler *Handler) edgeJobList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + edgeJobs, err := handler.DataStore.EdgeJob().ReadAll() + if err != nil { + return httperror.InternalServerError("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 0000000..61b422a --- /dev/null +++ b/api/http/handler/edgejobs/edgejob_tasklogs_clear.go @@ -0,0 +1,93 @@ +package edgejobs + +import ( + "net/http" + "slices" + "strconv" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/internal/edge" + "github.com/portainer/portainer/api/internal/edge/cache" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +// @id EdgeJobTasksClear +// @summary Clear the log for a specifc task on an EdgeJob +// @description **Access policy**: administrator +// @tags edge_jobs +// @security ApiKeyAuth +// @security jwt +// @produce json +// @param id path int true "EdgeJob Id" +// @param taskID path int true "Task Id" +// @success 204 +// @failure 500 +// @failure 400 +// @failure 503 "Edge compute features are disabled" +// @router /edge_jobs/{id}/tasks/{taskID}/logs [delete] +func (handler *Handler) edgeJobTasksClear(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + edgeJobID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid Edge job identifier route variable", err) + } + + taskID, err := request.RetrieveNumericRouteVariableValue(r, "taskID") + if err != nil { + return httperror.BadRequest("Invalid Task identifier route variable", err) + } + + mutationFn := func(edgeJob *portainer.EdgeJob, endpointID portainer.EndpointID, endpointsFromGroups []portainer.EndpointID) { + if slices.Contains(endpointsFromGroups, endpointID) { + edgeJob.GroupLogsCollection[endpointID] = portainer.EdgeJobEndpointMeta{ + CollectLogs: false, + LogsStatus: portainer.EdgeJobLogsStatusIdle, + } + } else { + meta := edgeJob.Endpoints[endpointID] + meta.CollectLogs = false + meta.LogsStatus = portainer.EdgeJobLogsStatusIdle + edgeJob.Endpoints[endpointID] = meta + } + } + + err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + updateEdgeJobFn := func(edgeJob *portainer.EdgeJob, endpointID portainer.EndpointID, endpointsFromGroups []portainer.EndpointID) error { + mutationFn(edgeJob, endpointID, endpointsFromGroups) + + return tx.EdgeJob().Update(edgeJob.ID, edgeJob) + } + + return handler.clearEdgeJobTaskLogs(tx, portainer.EdgeJobID(edgeJobID), portainer.EndpointID(taskID), updateEdgeJobFn) + }) + + return response.TxEmptyResponse(w, err) +} + +func (handler *Handler) clearEdgeJobTaskLogs(tx dataservices.DataStoreTx, edgeJobID portainer.EdgeJobID, endpointID portainer.EndpointID, updateEdgeJob func(*portainer.EdgeJob, portainer.EndpointID, []portainer.EndpointID) error) error { + edgeJob, err := tx.EdgeJob().Read(edgeJobID) + if tx.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find an Edge job with the specified identifier inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find an Edge job with the specified identifier inside the database", err) + } + + if err := handler.FileService.ClearEdgeJobTaskLogs(strconv.Itoa(int(edgeJobID)), strconv.Itoa(int(endpointID))); err != nil { + return httperror.InternalServerError("Unable to clear log file from disk", err) + } + + endpointsFromGroups, err := edge.GetEndpointsFromEdgeGroups(edgeJob.EdgeGroups, tx) + if err != nil { + return httperror.InternalServerError("Unable to get Endpoints from EdgeGroups", err) + } + + if err := updateEdgeJob(edgeJob, endpointID, endpointsFromGroups); err != nil { + return httperror.InternalServerError("Unable to persist Edge job changes in the database", err) + } + + cache.Del(endpointID) + + return nil +} 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 0000000..6f1e802 --- /dev/null +++ b/api/http/handler/edgejobs/edgejob_tasklogs_collect.go @@ -0,0 +1,86 @@ +package edgejobs + +import ( + "net/http" + "slices" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/internal/edge" + "github.com/portainer/portainer/api/internal/edge/cache" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +// @id EdgeJobTasksCollect +// @summary Collect the log for a specifc task on an EdgeJob +// @description **Access policy**: administrator +// @tags edge_jobs +// @security ApiKeyAuth +// @security jwt +// @produce json +// @param id path int true "EdgeJob Id" +// @param taskID path int true "Task Id" +// @success 204 +// @failure 500 +// @failure 400 +// @failure 503 "Edge compute features are disabled" +// @router /edge_jobs/{id}/tasks/{taskID}/logs [post] +func (handler *Handler) edgeJobTasksCollect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + edgeJobID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid Edge job identifier route variable", err) + } + + taskID, err := request.RetrieveNumericRouteVariableValue(r, "taskID") + if err != nil { + return httperror.BadRequest("Invalid Task identifier route variable", err) + } + + err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + edgeJob, err := tx.EdgeJob().Read(portainer.EdgeJobID(edgeJobID)) + if tx.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find an Edge job with the specified identifier inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find an Edge job with the specified identifier inside the database", err) + } + + endpointID := portainer.EndpointID(taskID) + endpointsFromGroups, err := edge.GetEndpointsFromEdgeGroups(edgeJob.EdgeGroups, tx) + if err != nil { + return httperror.InternalServerError("Unable to get Endpoints from EdgeGroups", err) + } + + if slices.Contains(endpointsFromGroups, endpointID) { + edgeJob.GroupLogsCollection[endpointID] = portainer.EdgeJobEndpointMeta{ + CollectLogs: true, + LogsStatus: portainer.EdgeJobLogsStatusPending, + } + } else { + meta := edgeJob.Endpoints[endpointID] + meta.CollectLogs = true + meta.LogsStatus = portainer.EdgeJobLogsStatusPending + edgeJob.Endpoints[endpointID] = meta + } + + if err := tx.EdgeJob().Update(edgeJob.ID, edgeJob); err != nil { + return httperror.InternalServerError("Unable to persist Edge job changes in the database", err) + } + + endpoint, err := tx.Endpoint().Endpoint(endpointID) + if err != nil { + return httperror.InternalServerError("Unable to retrieve environment from the database", err) + } + + cache.Del(endpointID) + + if endpoint.Edge.AsyncMode { + return httperror.BadRequest("Async Edge Endpoints are not supported in Portainer CE", nil) + } + + return nil + }) + + return response.TxEmptyResponse(w, err) +} 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 0000000..92aad8c --- /dev/null +++ b/api/http/handler/edgejobs/edgejob_tasklogs_inspect.go @@ -0,0 +1,47 @@ +package edgejobs + +import ( + "net/http" + "strconv" + + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +type fileResponse struct { + FileContent string `json:"FileContent"` +} + +// @id EdgeJobTaskLogsInspect +// @summary Fetch the log for a specifc task on an EdgeJob +// @description **Access policy**: administrator +// @tags edge_jobs +// @security ApiKeyAuth +// @security jwt +// @produce json +// @param id path int true "EdgeJob Id" +// @param taskID path int true "Task Id" +// @success 200 {object} fileResponse +// @failure 500 +// @failure 400 +// @failure 503 "Edge compute features are disabled" +// @router /edge_jobs/{id}/tasks/{taskID}/logs [get] +func (handler *Handler) edgeJobTaskLogsInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + edgeJobID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid Edge job identifier route variable", err) + } + + taskID, err := request.RetrieveNumericRouteVariableValue(r, "taskID") + if err != nil { + return httperror.BadRequest("Invalid Task identifier route variable", err) + } + + logFileContent, err := handler.FileService.GetEdgeJobTaskLogFileContent(strconv.Itoa(edgeJobID), strconv.Itoa(taskID)) + if err != nil { + return httperror.InternalServerError("Unable to retrieve log file from disk", err) + } + + return response.JSON(w, &fileResponse{FileContent: logFileContent}) +} 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 0000000..afde439 --- /dev/null +++ b/api/http/handler/edgejobs/edgejob_tasks_list.go @@ -0,0 +1,128 @@ +package edgejobs + +import ( + "errors" + "fmt" + "maps" + "net/http" + "strings" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/http/utils/filters" + "github.com/portainer/portainer/api/internal/edge" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +type taskContainer struct { + ID string `json:"Id"` + EndpointID portainer.EndpointID `json:"EndpointId"` + EndpointName string `json:"EndpointName"` + LogsStatus portainer.EdgeJobLogsStatus `json:"LogsStatus"` +} + +// @id EdgeJobTasksList +// @summary Fetch the list of tasks on an EdgeJob +// @description **Access policy**: administrator +// @tags edge_jobs +// @security ApiKeyAuth +// @security jwt +// @produce json +// @param id path int true "EdgeJob Id" +// @success 200 {array} taskContainer +// @failure 500 +// @failure 400 +// @failure 503 "Edge compute features are disabled" +// @router /edge_jobs/{id}/tasks [get] +func (handler *Handler) edgeJobTasksList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + edgeJobID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid Edge job identifier route variable", err) + } + + params := filters.ExtractListModifiersQueryParams(r) + + var tasks []*taskContainer + err = handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error { + tasks, err = listEdgeJobTasks(tx, portainer.EdgeJobID(edgeJobID)) + return err + }) + + return response.TxFuncResponse(err, func() *httperror.HandlerError { + results := filters.SearchOrderAndPaginate(tasks, params, filters.Config[*taskContainer]{ + SearchAccessors: []filters.SearchAccessor[*taskContainer]{ + func(tc *taskContainer) (string, error) { + switch tc.LogsStatus { + case portainer.EdgeJobLogsStatusPending: + return "pending", nil + case 0, portainer.EdgeJobLogsStatusIdle: + return "idle", nil + case portainer.EdgeJobLogsStatusCollected: + return "collected", nil + } + return "", errors.New("unknown state") + }, + func(tc *taskContainer) (string, error) { + return tc.EndpointName, nil + }, + }, + SortBindings: []filters.SortBinding[*taskContainer]{ + {Key: "EndpointName", Fn: func(a, b *taskContainer) int { return strings.Compare(a.EndpointName, b.EndpointName) }}, + }, + }) + + filters.ApplyFilterResultsHeaders(&w, results) + + return response.JSON(w, results.Items) + }) +} + +func listEdgeJobTasks(tx dataservices.DataStoreTx, edgeJobID portainer.EdgeJobID) ([]*taskContainer, error) { + edgeJob, err := tx.EdgeJob().Read(edgeJobID) + if tx.IsErrObjectNotFound(err) { + return nil, httperror.NotFound("Unable to find an Edge job with the specified identifier inside the database", err) + } else if err != nil { + return nil, httperror.InternalServerError("Unable to find an Edge job with the specified identifier inside the database", err) + } + + endpoints, err := tx.Endpoint().Endpoints() + if err != nil { + return nil, err + } + + tasks := make([]*taskContainer, 0) + + endpointsMap := map[portainer.EndpointID]portainer.EdgeJobEndpointMeta{} + if len(edgeJob.EdgeGroups) > 0 { + endpoints, err := edge.GetEndpointsFromEdgeGroups(edgeJob.EdgeGroups, tx) + if err != nil { + return nil, httperror.InternalServerError("Unable to get Endpoints from EdgeGroups", err) + } + + endpointsMap = convertEndpointsToMetaObject(endpoints) + maps.Copy(endpointsMap, edgeJob.GroupLogsCollection) + } + + maps.Copy(endpointsMap, edgeJob.Endpoints) + + for endpointID, meta := range endpointsMap { + + endpointName := "" + for idx := range endpoints { + if endpoints[idx].ID == endpointID { + endpointName = endpoints[idx].Name + } + } + + tasks = append(tasks, &taskContainer{ + ID: fmt.Sprintf("edgejob_task_%d_%d", edgeJob.ID, endpointID), + EndpointID: endpointID, + EndpointName: endpointName, + LogsStatus: meta.LogsStatus, + }) + } + + return tasks, nil +} diff --git a/api/http/handler/edgejobs/edgejob_tasks_list_test.go b/api/http/handler/edgejobs/edgejob_tasks_list_test.go new file mode 100644 index 0000000..3c729f0 --- /dev/null +++ b/api/http/handler/edgejobs/edgejob_tasks_list_test.go @@ -0,0 +1,135 @@ +package edgejobs + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strconv" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/internal/testhelpers" + "github.com/portainer/portainer/api/roar" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_EdgeJobTasksListHandler(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, true, false) + + handler := NewHandler(testhelpers.NewTestRequestBouncer()) + handler.DataStore = store + + addEnv := func(env *portainer.Endpoint) { + err := store.EndpointService.Create(env) + require.NoError(t, err) + } + + addEdgeGroup := func(group *portainer.EdgeGroup) { + err := store.EdgeGroupService.Create(group) + require.NoError(t, err) + } + + addJob := func(job *portainer.EdgeJob) { + err := store.EdgeJobService.Create(job) + require.NoError(t, err) + } + + envCount := 6 + + for i := range envCount { + addEnv(&portainer.Endpoint{ID: portainer.EndpointID(i + 1), Name: "env_" + strconv.Itoa(i+1)}) + } + + addEdgeGroup(&portainer.EdgeGroup{ID: 1, Name: "edge_group_1", EndpointIDs: roar.FromSlice([]portainer.EndpointID{5, 6})}) + + addJob(&portainer.EdgeJob{ + ID: 1, + Endpoints: map[portainer.EndpointID]portainer.EdgeJobEndpointMeta{ + 1: {}, + 2: {LogsStatus: portainer.EdgeJobLogsStatusIdle}, + 3: {LogsStatus: portainer.EdgeJobLogsStatusPending}, + 4: {LogsStatus: portainer.EdgeJobLogsStatusCollected}}, + EdgeGroups: []portainer.EdgeGroupID{1}, + }) + + test := func(params string, expect []taskContainer, expectedCount int) { + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/edge_jobs/1/tasks"+params, nil) + handler.ServeHTTP(rr, req) + require.Equal(t, http.StatusOK, rr.Result().StatusCode) + + var response []taskContainer + err := json.NewDecoder(rr.Body).Decode(&response) + require.NoError(t, err) + + assert.ElementsMatch(t, expect, response) + + tcStr := rr.Header().Get("x-total-count") + assert.NotEmpty(t, tcStr) + + totalCount, err := strconv.Atoi(tcStr) + require.NoError(t, err) + assert.Equal(t, expectedCount, totalCount) + + taStr := rr.Header().Get("x-total-available") + assert.NotEmpty(t, taStr) + + totalAvailable, err := strconv.Atoi(taStr) + require.NoError(t, err) + assert.Equal(t, envCount, totalAvailable) + + } + + tasks := []taskContainer{ + {}, + {"edgejob_task_1_1", 1, "env_1", 0}, + {"edgejob_task_1_2", 2, "env_2", portainer.EdgeJobLogsStatusIdle}, + {"edgejob_task_1_3", 3, "env_3", portainer.EdgeJobLogsStatusPending}, + {"edgejob_task_1_4", 4, "env_4", portainer.EdgeJobLogsStatusCollected}, + {"edgejob_task_1_5", 5, "env_5", 0}, + {"edgejob_task_1_6", 6, "env_6", 0}, + } + + t.Run("should return no results", func(t *testing.T) { + test("?search=foo", []taskContainer{}, 0) // unknown search + test("?start=100&limit=1", []taskContainer{}, 6) // overflowing start. Still return the correct count header + }) + + t.Run("should return one element", func(t *testing.T) { + // limit the *returned* results but not the total count + test("?start=0&limit=1&sort=EndpointName&order=asc", []taskContainer{tasks[1]}, envCount) // limit + test("?start=5&limit=10&sort=EndpointName&order=asc", []taskContainer{tasks[6]}, envCount) // start = last element + overflowing limit + // limit the number of results + test("?search=env_1", []taskContainer{tasks[1]}, 1) // only 1 result + }) + + t.Run("should filter by status", func(t *testing.T) { + test("?search=idle", []taskContainer{tasks[1], tasks[2], tasks[5], tasks[6]}, 4) // 0 (default value) is IDLE + test("?search=pending", []taskContainer{tasks[3]}, 1) + test("?search=collected", []taskContainer{tasks[4]}, 1) + }) + + t.Run("should return all elements", func(t *testing.T) { + test("", tasks[1:], envCount) // default + test("?some=invalid_param", tasks[1:], envCount) // unknown query params + test("?limit=-1", tasks[1:], envCount) // underflowing limit + test("?start=100", tasks[1:], envCount) // overflowing start without limit + test("?search=env", tasks[1:], envCount) // search in a match-all keyword + }) + + testError := func(params string, status int) { + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/edge_jobs/2/tasks"+params, nil) + handler.ServeHTTP(rr, req) + require.Equal(t, status, rr.Result().StatusCode) + } + + t.Run("errors", func(t *testing.T) { + testError("", http.StatusNotFound) // unknown job id + }) + +} diff --git a/api/http/handler/edgejobs/edgejob_update.go b/api/http/handler/edgejobs/edgejob_update.go new file mode 100644 index 0000000..b522bec --- /dev/null +++ b/api/http/handler/edgejobs/edgejob_update.go @@ -0,0 +1,233 @@ +package edgejobs + +import ( + "errors" + "maps" + "net/http" + "slices" + "strconv" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + dserrors "github.com/portainer/portainer/api/dataservices/errors" + "github.com/portainer/portainer/api/internal/edge" + "github.com/portainer/portainer/api/internal/edge/cache" + "github.com/portainer/portainer/api/internal/endpointutils" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" + "github.com/portainer/portainer/pkg/validate" +) + +type edgeJobUpdatePayload struct { + Name *string + CronExpression *string + Recurring *bool + Endpoints []portainer.EndpointID + EdgeGroups []portainer.EdgeGroupID + FileContent *string +} + +func (payload *edgeJobUpdatePayload) Validate(r *http.Request) error { + if payload.Name != nil && !validate.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 +} + +// @id EdgeJobUpdate +// @summary Update an EdgeJob +// @description **Access policy**: administrator +// @tags edge_jobs +// @security ApiKeyAuth +// @security jwt +// @accept json +// @produce json +// @param id path int true "EdgeJob Id" +// @param body body edgeJobUpdatePayload true "EdgeGroup data" +// @success 200 {object} portainer.EdgeJob +// @failure 500 +// @failure 400 +// @failure 503 "Edge compute features are disabled" +// @router /edge_jobs/{id} [put] +func (handler *Handler) edgeJobUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + edgeJobID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid Edge job identifier route variable", err) + } + + var payload edgeJobUpdatePayload + if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + var edgeJob *portainer.EdgeJob + err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + edgeJob, err = handler.updateEdgeJob(tx, portainer.EdgeJobID(edgeJobID), payload) + return err + }) + + return response.TxResponse(w, edgeJob, err) +} + +func (handler *Handler) updateEdgeJob(tx dataservices.DataStoreTx, edgeJobID portainer.EdgeJobID, payload edgeJobUpdatePayload) (*portainer.EdgeJob, error) { + edgeJob, err := tx.EdgeJob().Read(edgeJobID) + if tx.IsErrObjectNotFound(err) { + return nil, httperror.NotFound("Unable to find an Edge job with the specified identifier inside the database", err) + } else if err != nil { + return nil, httperror.InternalServerError("Unable to find an Edge job with the specified identifier inside the database", err) + } + + if err := handler.updateEdgeSchedule(tx, edgeJob, &payload); err != nil { + return nil, httperror.InternalServerError("Unable to update Edge job", err) + } + + if err := tx.EdgeJob().Update(edgeJob.ID, edgeJob); err != nil { + return nil, httperror.InternalServerError("Unable to persist Edge job changes inside the database", err) + } + + return edgeJob, nil +} + +func (handler *Handler) updateEdgeSchedule(tx dataservices.DataStoreTx, edgeJob *portainer.EdgeJob, payload *edgeJobUpdatePayload) error { + if payload.Name != nil { + edgeJob.Name = *payload.Name + } + + endpointsToAdd := map[portainer.EndpointID]bool{} + endpointsToRemove := map[portainer.EndpointID]bool{} + + if payload.Endpoints != nil { + endpointsMap := map[portainer.EndpointID]portainer.EdgeJobEndpointMeta{} + + newEndpoints := endpointutils.EndpointSet(payload.Endpoints) + for endpointID := range edgeJob.Endpoints { + if !newEndpoints[endpointID] { + endpointsToRemove[endpointID] = true + } + } + + for _, endpointID := range payload.Endpoints { + endpoint, err := tx.Endpoint().Endpoint(endpointID) + if err != nil { + return err + } + + if !endpointutils.IsEdgeEndpoint(endpoint) { + continue + } + + if meta, exists := edgeJob.Endpoints[endpointID]; exists { + endpointsMap[endpointID] = meta + } else { + endpointsMap[endpointID] = portainer.EdgeJobEndpointMeta{} + endpointsToAdd[endpointID] = true + } + } + + edgeJob.Endpoints = endpointsMap + } + + if len(payload.EdgeGroups) == 0 && len(edgeJob.EdgeGroups) > 0 { + endpoints, err := edge.GetEndpointsFromEdgeGroups(edgeJob.EdgeGroups, tx) + if err != nil { + return errors.New("unable to get endpoints from edge groups") + } + + for _, endpointID := range endpoints { + endpointsToRemove[endpointID] = true + } + + edgeJob.EdgeGroups = nil + } + + edgeGroupsToAdd := []portainer.EdgeGroupID{} + edgeGroupsToRemove := []portainer.EdgeGroupID{} + endpointsFromGroupsToAddMap := map[portainer.EndpointID]portainer.EdgeJobEndpointMeta{} + + if len(payload.EdgeGroups) > 0 { + for _, edgeGroupID := range payload.EdgeGroups { + if ok, err := tx.EdgeGroup().Exists(edgeGroupID); !ok { + return dserrors.ErrObjectNotFound + } else if err != nil { + return err + } + + if !slices.Contains(edgeJob.EdgeGroups, edgeGroupID) { + edgeGroupsToAdd = append(edgeGroupsToAdd, edgeGroupID) + } + } + + endpointsFromGroupsToAdd, err := edge.GetEndpointsFromEdgeGroups(edgeGroupsToAdd, tx) + if err != nil { + return errors.New("unable to get endpoints from edge groups") + } + endpointsFromGroupsToAddMap = convertEndpointsToMetaObject(endpointsFromGroupsToAdd) + + for endpointID := range endpointsFromGroupsToAddMap { + endpointsToAdd[endpointID] = true + } + + newEdgeGroups := edge.EdgeGroupSet(payload.EdgeGroups) + for _, edgeGroupID := range edgeJob.EdgeGroups { + if !newEdgeGroups[edgeGroupID] { + edgeGroupsToRemove = append(edgeGroupsToRemove, edgeGroupID) + } + } + + endpointsFromGroupsToRemove, err := edge.GetEndpointsFromEdgeGroups(edgeGroupsToRemove, tx) + if err != nil { + return errors.New("unable to get endpoints from edge groups") + } + + endpointsToRemoveMap := convertEndpointsToMetaObject(endpointsFromGroupsToRemove) + + for endpointID := range endpointsToRemoveMap { + endpointsToRemove[endpointID] = true + } + + edgeJob.EdgeGroups = payload.EdgeGroups + } + + updateVersion := false + if payload.CronExpression != nil && *payload.CronExpression != edgeJob.CronExpression { + edgeJob.CronExpression = *payload.CronExpression + updateVersion = true + } + + fileContent, err := handler.FileService.GetFileContent(edgeJob.ScriptPath, "") + if err != nil { + return err + } + + if payload.FileContent != nil && *payload.FileContent != string(fileContent) { + fileContent = []byte(*payload.FileContent) + if _, err := handler.FileService.StoreEdgeJobFileFromBytes(strconv.Itoa(int(edgeJob.ID)), fileContent); err != nil { + return err + } + + updateVersion = true + } + + if payload.Recurring != nil && *payload.Recurring != edgeJob.Recurring { + edgeJob.Recurring = *payload.Recurring + updateVersion = true + } + + if updateVersion { + edgeJob.Version++ + } + + maps.Copy(endpointsFromGroupsToAddMap, edgeJob.Endpoints) + + for endpointID := range endpointsFromGroupsToAddMap { + cache.Del(endpointID) + } + + for endpointID := range endpointsToRemove { + cache.Del(endpointID) + } + + return nil +} diff --git a/api/http/handler/edgejobs/handler.go b/api/http/handler/edgejobs/handler.go new file mode 100644 index 0000000..c91e3dd --- /dev/null +++ b/api/http/handler/edgejobs/handler.go @@ -0,0 +1,60 @@ +package edgejobs + +import ( + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/http/security" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + + "github.com/gorilla/mux" +) + +// Handler is the HTTP handler used to handle Edge job operations. +type Handler struct { + *mux.Router + DataStore dataservices.DataStore + FileService portainer.FileService + ReverseTunnelService portainer.ReverseTunnelService +} + +// NewHandler creates a handler to manage Edge job operations. +func NewHandler(bouncer security.BouncerService) *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/create/{method}", + 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 +} + +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/edgestacks/edgestack_create.go b/api/http/handler/edgestacks/edgestack_create.go new file mode 100644 index 0000000..05fbb97 --- /dev/null +++ b/api/http/handler/edgestacks/edgestack_create.go @@ -0,0 +1,57 @@ +package edgestacks + +import ( + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + httperrors "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/api/http/security" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +func (handler *Handler) edgeStackCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + method, err := request.RetrieveRouteVariableValue(r, "method") + if err != nil { + return httperror.BadRequest("Invalid query parameter: method", err) + } + + dryrun, _ := request.RetrieveBooleanQueryParameter(r, "dryrun", true) + + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve user details from authentication token", err) + } + + var edgeStack *portainer.EdgeStack + if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + edgeStack, err = handler.createSwarmStack(tx, method, dryrun, tokenData, r) + return err + }); err != nil { + switch { + case httperrors.IsInvalidPayloadError(err): + return httperror.BadRequest("Invalid payload", err) + case httperrors.IsConflictError(err): + return httperror.Conflict(err.Error(), err) + default: + return httperror.InternalServerError("Unable to create Edge stack", err) + } + } + + return response.JSON(w, edgeStack) +} + +func (handler *Handler) createSwarmStack(tx dataservices.DataStoreTx, method string, dryrun bool, tokenData *portainer.TokenData, r *http.Request) (*portainer.EdgeStack, error) { + switch method { + case "string": + return handler.createEdgeStackFromFileContent(r, tx, tokenData, dryrun) + case "repository": + return handler.createEdgeStackFromGitRepository(r, tx, tokenData, dryrun) + case "file": + return handler.createEdgeStackFromFileUpload(r, tx, tokenData, dryrun) + } + + return nil, httperrors.NewInvalidPayloadError("Invalid value for query parameter: method. Value must be one of: string, repository or file") +} diff --git a/api/http/handler/edgestacks/edgestack_create_file.go b/api/http/handler/edgestacks/edgestack_create_file.go new file mode 100644 index 0000000..0129ccd --- /dev/null +++ b/api/http/handler/edgestacks/edgestack_create_file.go @@ -0,0 +1,129 @@ +package edgestacks + +import ( + "fmt" + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + httperrors "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/api/stacks/stackutils" + "github.com/portainer/portainer/pkg/edge" + "github.com/portainer/portainer/pkg/libhttp/request" + + "github.com/pkg/errors" +) + +type edgeStackFromFileUploadPayload struct { + // Name of the stack + // Max length: 255 + // Name must only contains lowercase characters, numbers, hyphens, or underscores + // Name must start with a lowercase character or number + // Example: stack-name or stack_123 or stackName + Name string + StackFileContent []byte + EdgeGroups []portainer.EdgeGroupID + // Deployment type to deploy this stack + // Valid values are: 0 - 'compose', 1 - 'kubernetes' + // compose is enabled only for docker environments + // kubernetes is enabled only for kubernetes environments + DeploymentType portainer.EdgeStackDeploymentType `example:"0" enums:"0,1"` + Registries []portainer.RegistryID + // Uses the manifest's namespaces instead of the default one + UseManifestNamespaces bool +} + +func (payload *edgeStackFromFileUploadPayload) Validate(r *http.Request) error { + name, err := request.RetrieveMultiPartFormValue(r, "Name", false) + if err != nil { + return httperrors.NewInvalidPayloadError("Invalid stack name") + } + payload.Name = name + + if !edge.IsValidEdgeStackName(payload.Name) { + return httperrors.NewInvalidPayloadError("Invalid stack name. Stack name must only consist of lowercase alpha characters, numbers, hyphens, or underscores as well as start with a lowercase character or number") + } + + composeFileContent, _, err := request.RetrieveMultiPartFormFile(r, "file") + if err != nil { + return httperrors.NewInvalidPayloadError("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 httperrors.NewInvalidPayloadError("Edge Groups are mandatory for an Edge stack") + } + payload.EdgeGroups = edgeGroups + + deploymentType, err := request.RetrieveNumericMultiPartFormValue(r, "DeploymentType", false) + if err != nil { + return httperrors.NewInvalidPayloadError("Invalid deployment type") + } + payload.DeploymentType = portainer.EdgeStackDeploymentType(deploymentType) + if payload.DeploymentType != portainer.EdgeStackDeploymentCompose && payload.DeploymentType != portainer.EdgeStackDeploymentKubernetes { + return httperrors.NewInvalidPayloadError("Invalid deployment type") + } + + var registries []portainer.RegistryID + err = request.RetrieveMultiPartFormJSONValue(r, "Registries", ®istries, true) + if err != nil { + return httperrors.NewInvalidPayloadError("Invalid registry type") + } + payload.Registries = registries + + useManifestNamespaces, _ := request.RetrieveBooleanMultiPartFormValue(r, "UseManifestNamespaces", true) + payload.UseManifestNamespaces = useManifestNamespaces + + return nil +} + +// @id EdgeStackCreateFile +// @summary Create an EdgeStack from file +// @description **Access policy**: administrator +// @tags edge_stacks +// @security ApiKeyAuth +// @security jwt +// @accept multipart/form-data +// @produce json +// @param Name formData string true "Name of the stack. it must only consist of lowercase alphanumeric characters, hyphens, or underscores as well as start with a letter or number" +// @param file formData file true "Content of the Stack file" +// @param EdgeGroups formData string true "JSON stringified array of Edge Groups ids" +// @param DeploymentType formData int true "deploy type 0 - 'compose', 1 - 'kubernetes'" +// @param Registries formData string false "JSON stringified array of Registry ids to use for this stack" +// @param UseManifestNamespaces formData bool false "Uses the manifest's namespaces instead of the default one, relevant only for kube environments" +// @param PrePullImage formData bool false "Pre Pull image" +// @param RetryDeploy formData bool false "Retry deploy" +// @param dryrun query string false "if true, will not create an edge stack, but just will check the settings and return a non-persisted edge stack object" +// @success 200 {object} portainer.EdgeStack +// @failure 400 "Bad request" +// @failure 500 "Internal server error" +// @failure 503 "Edge compute features are disabled" +// @router /edge_stacks/create/file [post] +func (handler *Handler) createEdgeStackFromFileUpload(r *http.Request, tx dataservices.DataStoreTx, tokenData *portainer.TokenData, dryrun bool) (*portainer.EdgeStack, error) { + payload := &edgeStackFromFileUploadPayload{} + if err := payload.Validate(r); err != nil { + return nil, err + } + + stack, err := handler.edgeStacksService.BuildEdgeStack(tx, payload.Name, payload.DeploymentType, payload.EdgeGroups, payload.Registries, payload.UseManifestNamespaces) + if err != nil { + return nil, errors.Wrap(err, "failed to create edge stack object") + } + + if dryrun { + return stack, nil + } + + if err := stackutils.ValidateEdgeStackComposeContent(r.Context(), payload.DeploymentType, payload.StackFileContent); err != nil { + return nil, httperrors.NewInvalidPayloadError(err.Error()) + } + + stack.CreatedByUserId = fmt.Sprintf("%d", tokenData.ID) + stack.CreatedBy = stackutils.SanitizeLabel(tokenData.Username) + + return handler.edgeStacksService.PersistEdgeStack(tx, stack, func(stackFolder string, relatedEndpointIds []portainer.EndpointID) (composePath string, manifestPath string, projectPath string, err error) { + return handler.storeFileContent(tx, stackFolder, payload.DeploymentType, relatedEndpointIds, payload.StackFileContent) + }) +} diff --git a/api/http/handler/edgestacks/edgestack_create_git.go b/api/http/handler/edgestacks/edgestack_create_git.go new file mode 100644 index 0000000..3541460 --- /dev/null +++ b/api/http/handler/edgestacks/edgestack_create_git.go @@ -0,0 +1,208 @@ +package edgestacks + +import ( + "context" + "fmt" + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/dataservices/source" + "github.com/portainer/portainer/api/filesystem" + gittypes "github.com/portainer/portainer/api/git/types" + "github.com/portainer/portainer/api/gitops/sources" + "github.com/portainer/portainer/api/gitops/workflows" + httperrors "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/stacks/stackutils" + "github.com/portainer/portainer/pkg/edge" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/ssrf" + "github.com/portainer/portainer/pkg/validate" + + "github.com/pkg/errors" +) + +type edgeStackFromGitRepositoryPayload struct { + // Name of the stack + // Max length: 255 + // Name must only contains lowercase characters, numbers, hyphens, or underscores + // Name must start with a lowercase character or number + // Example: stack-name or stack_123 or stackName + Name string `example:"stack-name" validate:"required"` + // SourceID references an existing Source for git credentials/URL. + // When set, the inline URL and authentication fields are ignored. + SourceID portainer.SourceID `example:"1"` + // Deprecated: Use SourceID instead. URL of a Git repository hosting the Stack file. + RepositoryURL string `example:"https://github.com/openfaas/faas"` + // Reference name of a Git repository hosting the Stack file + RepositoryReferenceName string `example:"refs/heads/master"` + // Deprecated: Use SourceID instead. Use basic authentication to clone the Git repository. + RepositoryAuthentication bool `example:"true"` + // Deprecated: Use SourceID instead. Username used in basic authentication. + RepositoryUsername string `example:"myGitUsername"` + // Deprecated: Use SourceID instead. Password used in basic authentication. + RepositoryPassword string `example:"myGitPassword"` + // Path to the Stack file inside the Git repository + FilePathInRepository string `example:"docker-compose.yml" default:"docker-compose.yml"` + // List of identifiers of EdgeGroups + EdgeGroups []portainer.EdgeGroupID `example:"1" validate:"required"` + // Deployment type to deploy this stack + // Valid values are: 0 - 'compose', 1 - 'kubernetes' + // compose is enabled only for docker environments + // kubernetes is enabled only for kubernetes environments + DeploymentType portainer.EdgeStackDeploymentType `example:"0" enums:"0,1,2"` + // List of Registries to use for this stack + Registries []portainer.RegistryID + // Uses the manifest's namespaces instead of the default one + UseManifestNamespaces bool + // Deprecated: Use SourceID instead. TLSSkipVerify skips SSL verification when cloning the Git repository. + TLSSkipVerify bool `example:"false"` +} + +func (payload *edgeStackFromGitRepositoryPayload) Validate(r *http.Request) error { + if len(payload.Name) == 0 { + return httperrors.NewInvalidPayloadError("Invalid stack name") + } + + if !edge.IsValidEdgeStackName(payload.Name) { + return httperrors.NewInvalidPayloadError("Invalid stack name. Stack name must only consist of lowercase alpha characters, numbers, hyphens, or underscores as well as start with a lowercase character or number") + } + + if payload.SourceID == 0 { + if len(payload.RepositoryURL) == 0 || !validate.IsURL(payload.RepositoryURL) { + return httperrors.NewInvalidPayloadError("Invalid repository URL. Must correspond to a valid URL format") + } + if payload.RepositoryAuthentication && len(payload.RepositoryPassword) == 0 { + return httperrors.NewInvalidPayloadError("Invalid repository credentials. Password must be specified when authentication is enabled") + } + } + + if payload.DeploymentType != portainer.EdgeStackDeploymentCompose && payload.DeploymentType != portainer.EdgeStackDeploymentKubernetes { + return httperrors.NewInvalidPayloadError("Invalid deployment type") + } + + if len(payload.FilePathInRepository) == 0 { + switch payload.DeploymentType { + case portainer.EdgeStackDeploymentCompose: + payload.FilePathInRepository = filesystem.ComposeFileDefaultName + case portainer.EdgeStackDeploymentKubernetes: + payload.FilePathInRepository = filesystem.ManifestFileDefaultName + } + } + + if len(payload.EdgeGroups) == 0 { + return httperrors.NewInvalidPayloadError("Invalid edge groups. At least one edge group must be specified") + } + + return nil +} + +// @id EdgeStackCreateRepository +// @summary Create an EdgeStack from a git repository +// @description **Access policy**: administrator +// @tags edge_stacks +// @security ApiKeyAuth +// @security jwt +// @produce json +// @param body body edgeStackFromGitRepositoryPayload true "stack config" +// @param dryrun query string false "if true, will not create an edge stack, but just will check the settings and return a non-persisted edge stack object" +// @success 200 {object} portainer.EdgeStack +// @failure 400 "Bad request" +// @failure 500 "Internal server error" +// @failure 503 "Edge compute features are disabled" +// @router /edge_stacks/create/repository [post] +func (handler *Handler) createEdgeStackFromGitRepository(r *http.Request, tx dataservices.DataStoreTx, tokenData *portainer.TokenData, dryrun bool) (*portainer.EdgeStack, error) { + var payload edgeStackFromGitRepositoryPayload + if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil { + return nil, err + } + + stack, err := handler.edgeStacksService.BuildEdgeStack(tx, payload.Name, payload.DeploymentType, payload.EdgeGroups, payload.Registries, payload.UseManifestNamespaces) + if err != nil { + return nil, errors.Wrap(err, "failed to create edge stack object") + } + + if dryrun { + return stack, nil + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return nil, httperror.InternalServerError("Unable to retrieve user info from request context", err) + } + userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships) + + repoConfig, httpErr := sources.ResolveRepoConfig(tx, userContext, sources.RepoConfigInput{ + SourceID: payload.SourceID, + ReferenceName: payload.RepositoryReferenceName, + ConfigFilePath: payload.FilePathInRepository, + RepositoryURL: payload.RepositoryURL, + TLSSkipVerify: payload.TLSSkipVerify, + RepositoryAuthentication: payload.RepositoryAuthentication, + Username: payload.RepositoryUsername, + Password: payload.RepositoryPassword, + }) + if httpErr != nil { + return nil, httpErr + } + + if err := ssrf.CheckURL(r.Context(), repoConfig.URL); err != nil { + return nil, errors.Wrap(err, "repository URL blocked by SSRF policy") + } + + stack.CreatedByUserId = fmt.Sprintf("%d", tokenData.ID) + stack.CreatedBy = stackutils.SanitizeLabel(tokenData.Username) + + return handler.edgeStacksService.PersistEdgeStack(tx, stack, func(stackFolder string, relatedEndpointIds []portainer.EndpointID) (composePath string, manifestPath string, projectPath string, err error) { + return handler.storeManifestFromGitRepository(context.TODO(), tx, userContext, payload.SourceID, stackFolder, relatedEndpointIds, payload.DeploymentType, tokenData.ID, repoConfig) + }) +} + +func (handler *Handler) storeManifestFromGitRepository(ctx context.Context, tx dataservices.DataStoreTx, userContext source.UserContext, sourceID portainer.SourceID, stackFolder string, relatedEndpointIds []portainer.EndpointID, deploymentType portainer.EdgeStackDeploymentType, currentUserID portainer.UserID, repositoryConfig gittypes.RepoConfig) (composePath, manifestPath, projectPath string, err error) { + if hasWrongType, err := hasWrongEnvironmentType(tx.Endpoint(), relatedEndpointIds, deploymentType); err != nil { + return "", "", "", fmt.Errorf("unable to check for existence of non fitting environments: %w", err) + } else if hasWrongType { + return "", "", "", errors.New("edge stack with config do not match the environment type") + } + + projectPath = handler.FileService.GetEdgeStackProjectPath(stackFolder) + repositoryUsername := "" + repositoryPassword := "" + if repositoryConfig.Authentication != nil && repositoryConfig.Authentication.Password != "" { + repositoryUsername = repositoryConfig.Authentication.Username + repositoryPassword = repositoryConfig.Authentication.Password + } + + if err := handler.GitService.CloneRepository( + ctx, + projectPath, + repositoryConfig.URL, + repositoryConfig.ReferenceName, + repositoryUsername, + repositoryPassword, + repositoryConfig.TLSSkipVerify, + ); err != nil { + if statusErr := workflows.SaveSourceStatus(tx, userContext, sourceID, err); statusErr != nil { + return "", "", "", fmt.Errorf("%w (and failed to persist status: %w)", err, statusErr) + } + + return "", "", "", err + } + + if err := workflows.SaveSourceStatus(tx, userContext, sourceID, nil); err != nil { + return "", "", "", fmt.Errorf("failed to persist source sync status: %w", err) + } + + if deploymentType == portainer.EdgeStackDeploymentCompose { + return repositoryConfig.ConfigFilePath, "", projectPath, nil + } + + if deploymentType == portainer.EdgeStackDeploymentKubernetes { + return "", repositoryConfig.ConfigFilePath, projectPath, nil + } + + errMessage := fmt.Sprintf("unknown deployment type: %d", deploymentType) + return "", "", "", httperrors.NewInvalidPayloadError(errMessage) +} diff --git a/api/http/handler/edgestacks/edgestack_create_string.go b/api/http/handler/edgestacks/edgestack_create_string.go new file mode 100644 index 0000000..3a573ea --- /dev/null +++ b/api/http/handler/edgestacks/edgestack_create_string.go @@ -0,0 +1,136 @@ +package edgestacks + +import ( + "fmt" + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/filesystem" + httperrors "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/api/stacks/stackutils" + "github.com/portainer/portainer/pkg/edge" + "github.com/portainer/portainer/pkg/libhttp/request" + + "github.com/pkg/errors" +) + +type edgeStackFromStringPayload struct { + // Name of the stack + // Max length: 255 + // Name must only contains lowercase characters, numbers, hyphens, or underscores + // Name must start with a lowercase character or number + // Example: stack-name or stack_123 or stackName + Name string `example:"stack-name" validate:"required"` + // Content of the Stack file + StackFileContent string `example:"version: 3\n services:\n web:\n image:nginx" validate:"required"` + // List of identifiers of EdgeGroups + EdgeGroups []portainer.EdgeGroupID `example:"1"` + // Deployment type to deploy this stack + // Valid values are: 0 - 'compose', 1 - 'kubernetes' + // compose is enabled only for docker environments + // kubernetes is enabled only for kubernetes environments + DeploymentType portainer.EdgeStackDeploymentType `example:"0" enums:"0,1,2"` + // List of Registries to use for this stack + Registries []portainer.RegistryID + // Uses the manifest's namespaces instead of the default one + UseManifestNamespaces bool +} + +func (payload *edgeStackFromStringPayload) Validate(r *http.Request) error { + if len(payload.Name) == 0 { + return httperrors.NewInvalidPayloadError("Invalid stack name") + } + + if !edge.IsValidEdgeStackName(payload.Name) { + return httperrors.NewInvalidPayloadError("Invalid stack name. Stack name must only consist of lowercase alpha characters, numbers, hyphens, or underscores as well as start with a lowercase character or number") + } + + if len(payload.StackFileContent) == 0 { + return httperrors.NewInvalidPayloadError("Invalid stack file content") + } + + if len(payload.EdgeGroups) == 0 { + return httperrors.NewInvalidPayloadError("Edge Groups are mandatory for an Edge stack") + } + + if payload.DeploymentType != portainer.EdgeStackDeploymentCompose && payload.DeploymentType != portainer.EdgeStackDeploymentKubernetes { + return httperrors.NewInvalidPayloadError("Invalid deployment type") + } + + return nil +} + +// @id EdgeStackCreateString +// @summary Create an EdgeStack from a text +// @description **Access policy**: administrator +// @tags edge_stacks +// @security ApiKeyAuth +// @security jwt +// @produce json +// @param body body edgeStackFromStringPayload true "stack config" +// @param dryrun query string false "if true, will not create an edge stack, but just will check the settings and return a non-persisted edge stack object" +// @success 200 {object} portainer.EdgeStack +// @failure 400 "Bad request" +// @failure 500 "Internal server error" +// @failure 503 "Edge compute features are disabled" +// @router /edge_stacks/create/string [post] +func (handler *Handler) createEdgeStackFromFileContent(r *http.Request, tx dataservices.DataStoreTx, tokenData *portainer.TokenData, dryrun bool) (*portainer.EdgeStack, error) { + var payload edgeStackFromStringPayload + if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil { + return nil, err + } + + stack, err := handler.edgeStacksService.BuildEdgeStack(tx, payload.Name, payload.DeploymentType, payload.EdgeGroups, payload.Registries, payload.UseManifestNamespaces) + if err != nil { + return nil, errors.Wrap(err, "failed to create Edge stack object") + } + + stack.CreatedByUserId = fmt.Sprintf("%d", tokenData.ID) + stack.CreatedBy = stackutils.SanitizeLabel(tokenData.Username) + + if dryrun { + return stack, nil + } + + if err := stackutils.ValidateEdgeStackComposeContent(r.Context(), payload.DeploymentType, []byte(payload.StackFileContent)); err != nil { + return nil, httperrors.NewInvalidPayloadError(err.Error()) + } + + return handler.edgeStacksService.PersistEdgeStack(tx, stack, func(stackFolder string, relatedEndpointIds []portainer.EndpointID) (composePath string, manifestPath string, projectPath string, err error) { + return handler.storeFileContent(tx, stackFolder, payload.DeploymentType, relatedEndpointIds, []byte(payload.StackFileContent)) + }) +} + +func (handler *Handler) storeFileContent(tx dataservices.DataStoreTx, stackFolder string, deploymentType portainer.EdgeStackDeploymentType, relatedEndpointIds []portainer.EndpointID, fileContent []byte) (composePath, manifestPath, projectPath string, err error) { + if hasWrongType, err := hasWrongEnvironmentType(tx.Endpoint(), relatedEndpointIds, deploymentType); err != nil { + return "", "", "", fmt.Errorf("unable to check for existence of non fitting environments: %w", err) + } else if hasWrongType { + return "", "", "", errors.New("edge stack with config do not match the environment type") + } + + if deploymentType == portainer.EdgeStackDeploymentCompose { + composePath = filesystem.ComposeFileDefaultName + + projectPath, err := handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, composePath, fileContent) + if err != nil { + return "", "", "", err + } + + return composePath, "", projectPath, nil + } + + if deploymentType == portainer.EdgeStackDeploymentKubernetes { + manifestPath = filesystem.ManifestFileDefaultName + + projectPath, err := handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, manifestPath, fileContent) + if err != nil { + return "", "", "", err + } + + return "", manifestPath, projectPath, nil + } + + errMessage := fmt.Sprintf("invalid deployment type: %d", deploymentType) + return "", "", "", httperrors.NewInvalidPayloadError(errMessage) +} diff --git a/api/http/handler/edgestacks/edgestack_create_test.go b/api/http/handler/edgestacks/edgestack_create_test.go new file mode 100644 index 0000000..b9c46f9 --- /dev/null +++ b/api/http/handler/edgestacks/edgestack_create_test.go @@ -0,0 +1,216 @@ +package edgestacks + +import ( + "bytes" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/roar" + + "github.com/segmentio/encoding/json" + "github.com/stretchr/testify/require" +) + +// Create +func TestCreateAndInspect(t *testing.T) { + t.Parallel() + handler, rawAPIKey := setupHandler(t) + + // Create Endpoint, EdgeGroup and EndpointRelation + endpoint := createEndpoint(t, handler.DataStore) + edgeGroup := portainer.EdgeGroup{ + ID: 1, + Name: "EdgeGroup 1", + Dynamic: false, + TagIDs: nil, + EndpointIDs: roar.FromSlice([]portainer.EndpointID{endpoint.ID}), + PartialMatch: false, + } + + err := handler.DataStore.EdgeGroup().Create(&edgeGroup) + require.NoError(t, err) + + endpointRelation := portainer.EndpointRelation{ + EndpointID: endpoint.ID, + EdgeStacks: map[portainer.EdgeStackID]bool{}, + } + + err = handler.DataStore.EndpointRelation().Create(&endpointRelation) + require.NoError(t, err) + + payload := edgeStackFromStringPayload{ + Name: "test-stack", + StackFileContent: "stack content", + EdgeGroups: []portainer.EdgeGroupID{1}, + DeploymentType: portainer.EdgeStackDeploymentCompose, + } + + jsonPayload, err := json.Marshal(payload) + require.NoError(t, err) + + r := bytes.NewBuffer(jsonPayload) + + // Create EdgeStack + req, err := http.NewRequest(http.MethodPost, "/edge_stacks/create/string", r) + require.NoError(t, err) + + req.Header.Add("x-api-key", rawAPIKey) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected a %d response, found: %d", http.StatusOK, rec.Code) + } + + data := portainer.EdgeStack{} + err = json.NewDecoder(rec.Body).Decode(&data) + require.NoError(t, err) + + // Inspect + req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/edge_stacks/%d", data.ID), nil) + require.NoError(t, err) + + req.Header.Add("x-api-key", rawAPIKey) + rec = httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected a %d response, found: %d", http.StatusOK, rec.Code) + } + + data = portainer.EdgeStack{} + err = json.NewDecoder(rec.Body).Decode(&data) + require.NoError(t, err) + + if payload.Name != data.Name { + t.Fatalf("expected EdgeStack Name %s, found %s", payload.Name, data.Name) + } +} + +func TestCreateWithInvalidPayload(t *testing.T) { + t.Parallel() + handler, rawAPIKey := setupHandler(t) + + endpoint := createEndpoint(t, handler.DataStore) + edgeStack := createEdgeStack(t, handler.DataStore, endpoint.ID) + + cases := []struct { + Name string + Payload any + ExpectedStatusCode int + Method string + }{ + { + Name: "Invalid method parameter", + Payload: edgeStackFromStringPayload{}, + Method: "invalid", + ExpectedStatusCode: 400, + }, + + { + Name: "Empty edgeStackFromStringPayload with string method", + Payload: edgeStackFromStringPayload{}, + Method: "string", + ExpectedStatusCode: 400, + }, + { + Name: "Empty edgeStackFromStringPayload with repository method", + Payload: edgeStackFromStringPayload{}, + Method: "repository", + ExpectedStatusCode: 400, + }, + { + Name: "Empty edgeStackFromStringPayload with file method", + Payload: edgeStackFromStringPayload{}, + Method: "file", + ExpectedStatusCode: 400, + }, + { + Name: "Duplicated EdgeStack Name", + Payload: edgeStackFromStringPayload{ + Name: edgeStack.Name, + StackFileContent: "content", + EdgeGroups: edgeStack.EdgeGroups, + DeploymentType: edgeStack.DeploymentType, + }, + Method: "string", + ExpectedStatusCode: http.StatusConflict, + }, + { + Name: "Empty EdgeStack Groups", + Payload: edgeStackFromStringPayload{ + Name: edgeStack.Name, + StackFileContent: "content", + EdgeGroups: []portainer.EdgeGroupID{}, + DeploymentType: edgeStack.DeploymentType, + }, + Method: "string", + ExpectedStatusCode: 400, + }, + { + Name: "EdgeStackDeploymentKubernetes with Docker endpoint", + Payload: edgeStackFromStringPayload{ + Name: "stack-name", + StackFileContent: "content", + EdgeGroups: []portainer.EdgeGroupID{1}, + DeploymentType: portainer.EdgeStackDeploymentKubernetes, + }, + Method: "string", + ExpectedStatusCode: 500, + }, + { + Name: "Empty Stack File Content", + Payload: edgeStackFromStringPayload{ + Name: "stack-name", + StackFileContent: "", + EdgeGroups: []portainer.EdgeGroupID{1}, + DeploymentType: portainer.EdgeStackDeploymentCompose, + }, + Method: "string", + ExpectedStatusCode: 400, + }, + { + Name: "Clone Git repository error", + Payload: edgeStackFromGitRepositoryPayload{ + Name: "stack-name", + RepositoryURL: "github.com/portainer/portainer", + RepositoryReferenceName: "ref name", + RepositoryAuthentication: false, + RepositoryUsername: "", + RepositoryPassword: "", + FilePathInRepository: "/file/path", + EdgeGroups: []portainer.EdgeGroupID{1}, + DeploymentType: portainer.EdgeStackDeploymentCompose, + }, + Method: "repository", + ExpectedStatusCode: 500, + }, + } + + for _, tc := range cases { + t.Run(tc.Name, func(t *testing.T) { + jsonPayload, err := json.Marshal(tc.Payload) + if err != nil { + t.Fatal("JSON marshal error:", err) + } + r := bytes.NewBuffer(jsonPayload) + + // Create EdgeStack + req, err := http.NewRequest(http.MethodPost, "/edge_stacks/create/"+tc.Method, r) + if err != nil { + t.Fatal("request error:", err) + } + + req.Header.Add("x-api-key", rawAPIKey) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != tc.ExpectedStatusCode { + t.Fatalf("expected a %d response, found: %d", tc.ExpectedStatusCode, rec.Code) + } + }) + } +} diff --git a/api/http/handler/edgestacks/edgestack_delete.go b/api/http/handler/edgestacks/edgestack_delete.go new file mode 100644 index 0000000..9371a68 --- /dev/null +++ b/api/http/handler/edgestacks/edgestack_delete.go @@ -0,0 +1,57 @@ +package edgestacks + +import ( + "net/http" + "strconv" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +// @id EdgeStackDelete +// @summary Delete an EdgeStack +// @description **Access policy**: administrator +// @tags edge_stacks +// @security ApiKeyAuth +// @security jwt +// @param id path int true "EdgeStack Id" +// @success 204 +// @failure 500 +// @failure 400 +// @failure 503 "Edge compute features are disabled" +// @router /edge_stacks/{id} [delete] +func (handler *Handler) edgeStackDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + edgeStackID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid edge stack identifier route variable", err) + } + + err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + return handler.deleteEdgeStack(tx, portainer.EdgeStackID(edgeStackID)) + }) + + return response.TxEmptyResponse(w, err) +} + +func (handler *Handler) deleteEdgeStack(tx dataservices.DataStoreTx, edgeStackID portainer.EdgeStackID) error { + edgeStack, err := tx.EdgeStack().EdgeStack(edgeStackID) + if handler.DataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find an edge stack with the specified identifier inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find an edge stack with the specified identifier inside the database", err) + } + + if err := handler.edgeStacksService.DeleteEdgeStack(tx, edgeStack.ID, edgeStack.EdgeGroups); err != nil { + return httperror.InternalServerError("Unable to delete edge stack", err) + } + + stackFolder := handler.FileService.GetEdgeStackProjectPath(strconv.Itoa(int(edgeStack.ID))) + if err := handler.FileService.RemoveDirectory(stackFolder); err != nil { + return httperror.InternalServerError("Unable to remove edge stack project folder", err) + } + + return nil +} diff --git a/api/http/handler/edgestacks/edgestack_delete_test.go b/api/http/handler/edgestacks/edgestack_delete_test.go new file mode 100644 index 0000000..b2ffe11 --- /dev/null +++ b/api/http/handler/edgestacks/edgestack_delete_test.go @@ -0,0 +1,146 @@ +package edgestacks + +import ( + "bytes" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + portainer "github.com/portainer/portainer/api" + + "github.com/segmentio/encoding/json" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Delete +func TestDeleteAndInspect(t *testing.T) { + t.Parallel() + handler, rawAPIKey := setupHandler(t) + + // Create + endpoint := createEndpoint(t, handler.DataStore) + edgeStack := createEdgeStack(t, handler.DataStore, endpoint.ID) + + // Inspect + req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), nil) + require.NoError(t, err) + + req.Header.Add("x-api-key", rawAPIKey) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected a %d response, found: %d", http.StatusOK, rec.Code) + } + + data := portainer.EdgeStack{} + err = json.NewDecoder(rec.Body).Decode(&data) + require.NoError(t, err) + + if data.ID != edgeStack.ID { + t.Fatalf("expected EdgeStackID %d, found %d", int(edgeStack.ID), data.ID) + } + + // Delete + req, err = http.NewRequest(http.MethodDelete, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), nil) + require.NoError(t, err) + + req.Header.Add("x-api-key", rawAPIKey) + rec = httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusNoContent { + t.Fatalf("expected a %d response, found: %d", http.StatusNoContent, rec.Code) + } + + // Inspect + req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), nil) + require.NoError(t, err) + + req.Header.Add("x-api-key", rawAPIKey) + rec = httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Fatalf("expected a %d response, found: %d", http.StatusNotFound, rec.Code) + } +} + +func TestDeleteInvalidEdgeStack(t *testing.T) { + t.Parallel() + handler, rawAPIKey := setupHandler(t) + + cases := []struct { + Name string + URL string + ExpectedStatusCode int + }{ + {Name: "Non-existing EdgeStackID", URL: "/edge_stacks/-1", ExpectedStatusCode: http.StatusNotFound}, + {Name: "Invalid EdgeStackID", URL: "/edge_stacks/aaaaaaa", ExpectedStatusCode: http.StatusBadRequest}, + } + + for _, tc := range cases { + t.Run(tc.Name, func(t *testing.T) { + req, err := http.NewRequest(http.MethodDelete, tc.URL, nil) + if err != nil { + t.Fatal("request error:", err) + } + + req.Header.Add("x-api-key", rawAPIKey) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != tc.ExpectedStatusCode { + t.Fatalf("expected a %d response, found: %d", tc.ExpectedStatusCode, rec.Code) + } + }) + } +} + +func TestDeleteEdgeStack_RemoveProjectFolder(t *testing.T) { + t.Parallel() + handler, rawAPIKey := setupHandler(t) + + edgeGroup := createEdgeGroup(t, handler.DataStore) + + payload := edgeStackFromStringPayload{ + Name: "test-stack", + DeploymentType: portainer.EdgeStackDeploymentCompose, + EdgeGroups: []portainer.EdgeGroupID{edgeGroup.ID}, + StackFileContent: "version: '3.7'\nservices:\n test:\n image: test", + } + + var buf bytes.Buffer + err := json.NewEncoder(&buf).Encode(payload) + require.NoError(t, err) + + // Create + req, err := http.NewRequest(http.MethodPost, "/edge_stacks/create/string", &buf) + require.NoError(t, err) + + req.Header.Add("x-api-key", rawAPIKey) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected a %d response, found: %d", http.StatusNoContent, rec.Code) + } + + assert.DirExists(t, handler.FileService.GetEdgeStackProjectPath("1")) + + // Delete + req, err = http.NewRequest(http.MethodDelete, "/edge_stacks/1", nil) + require.NoError(t, err) + + req.Header.Add("x-api-key", rawAPIKey) + rec = httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusNoContent { + t.Fatalf("expected a %d response, found: %d", http.StatusNoContent, rec.Code) + } + + assert.NoDirExists(t, handler.FileService.GetEdgeStackProjectPath("1")) +} diff --git a/api/http/handler/edgestacks/edgestack_file.go b/api/http/handler/edgestacks/edgestack_file.go new file mode 100644 index 0000000..70551b8 --- /dev/null +++ b/api/http/handler/edgestacks/edgestack_file.go @@ -0,0 +1,51 @@ +package edgestacks + +import ( + "net/http" + + portainer "github.com/portainer/portainer/api" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +type stackFileResponse struct { + StackFileContent string `json:"StackFileContent"` +} + +// @id EdgeStackFile +// @summary Fetches the stack file for an EdgeStack +// @description **Access policy**: administrator +// @tags edge_stacks +// @security ApiKeyAuth +// @security jwt +// @produce json +// @param id path int true "EdgeStack Id" +// @success 200 {object} stackFileResponse +// @failure 500 +// @failure 400 +// @failure 503 "Edge compute features are disabled" +// @router /edge_stacks/{id}/file [get] +func (handler *Handler) edgeStackFile(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + stackID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid edge stack identifier route variable", err) + } + + stack, err := handler.DataStore.EdgeStack().EdgeStack(portainer.EdgeStackID(stackID)) + if err != nil { + return handlerDBErr(err, "Unable to find an edge stack with the specified identifier inside the database") + } + + fileName := stack.EntryPoint + if stack.DeploymentType == portainer.EdgeStackDeploymentKubernetes { + fileName = stack.ManifestPath + } + + stackFileContent, err := handler.FileService.GetFileContent(stack.ProjectPath, fileName) + if err != nil { + return httperror.InternalServerError("Unable to retrieve stack file from disk", err) + } + + return response.JSON(w, &stackFileResponse{StackFileContent: string(stackFileContent)}) +} diff --git a/api/http/handler/edgestacks/edgestack_inspect.go b/api/http/handler/edgestacks/edgestack_inspect.go new file mode 100644 index 0000000..2936f32 --- /dev/null +++ b/api/http/handler/edgestacks/edgestack_inspect.go @@ -0,0 +1,68 @@ +package edgestacks + +import ( + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +// @id EdgeStackInspect +// @summary Inspect an EdgeStack +// @description **Access policy**: administrator +// @tags edge_stacks +// @security ApiKeyAuth +// @security jwt +// @produce json +// @param id path int true "EdgeStack Id" +// @success 200 {object} portainer.EdgeStack +// @failure 500 +// @failure 400 +// @failure 503 "Edge compute features are disabled" +// @router /edge_stacks/{id} [get] +func (handler *Handler) edgeStackInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + edgeStackID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid edge stack identifier route variable", err) + } + + edgeStack, err := handler.DataStore.EdgeStack().EdgeStack(portainer.EdgeStackID(edgeStackID)) + if err != nil { + return handlerDBErr(err, "Unable to find an edge stack with the specified identifier inside the database") + } + + if err := fillEdgeStackStatus(handler.DataStore, edgeStack); err != nil { + return handlerDBErr(err, "Unable to retrieve edge stack status from the database") + } + + return response.JSON(w, edgeStack) +} + +func fillEdgeStackStatus(tx dataservices.DataStoreTx, edgeStack *portainer.EdgeStack) error { + status, err := tx.EdgeStackStatus().ReadAll(edgeStack.ID) + if err != nil { + return err + } + + edgeStack.Status = make(map[portainer.EndpointID]portainer.EdgeStackStatus, len(status)) + + emptyStatus := make([]portainer.EdgeStackDeploymentStatus, 0) + + for _, s := range status { + if s.Status == nil { + s.Status = emptyStatus + } + + edgeStack.Status[s.EndpointID] = portainer.EdgeStackStatus{ + Status: s.Status, + EndpointID: s.EndpointID, + DeploymentInfo: s.DeploymentInfo, + ReadyRePullImage: s.ReadyRePullImage, + } + } + + return nil +} diff --git a/api/http/handler/edgestacks/edgestack_inspect_test.go b/api/http/handler/edgestacks/edgestack_inspect_test.go new file mode 100644 index 0000000..6253de6 --- /dev/null +++ b/api/http/handler/edgestacks/edgestack_inspect_test.go @@ -0,0 +1,39 @@ +package edgestacks + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +// Inspect +func TestInspectInvalidEdgeID(t *testing.T) { + t.Parallel() + handler, rawAPIKey := setupHandler(t) + + cases := []struct { + Name string + EdgeStackID string + ExpectedStatusCode int + }{ + {"Invalid EdgeStackID", "x", 400}, + {"Non-existing EdgeStackID", "5", 404}, + } + + for _, tc := range cases { + t.Run(tc.Name, func(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, "/edge_stacks/"+tc.EdgeStackID, nil) + if err != nil { + t.Fatal("request error:", err) + } + + req.Header.Add("x-api-key", rawAPIKey) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != tc.ExpectedStatusCode { + t.Fatalf("expected a %d response, found: %d", tc.ExpectedStatusCode, rec.Code) + } + }) + } +} diff --git a/api/http/handler/edgestacks/edgestack_list.go b/api/http/handler/edgestacks/edgestack_list.go new file mode 100644 index 0000000..1ea991c --- /dev/null +++ b/api/http/handler/edgestacks/edgestack_list.go @@ -0,0 +1,164 @@ +package edgestacks + +import ( + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/slicesx" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +type aggregatedStatusesMap map[portainer.EdgeStackStatusType]int + +type SummarizedStatus string + +const ( + sumStatusUnavailable SummarizedStatus = "Unavailable" + sumStatusDeploying SummarizedStatus = "Deploying" + sumStatusFailed SummarizedStatus = "Failed" + sumStatusPaused SummarizedStatus = "Paused" + sumStatusPartiallyRunning SummarizedStatus = "PartiallyRunning" + sumStatusCompleted SummarizedStatus = "Completed" + sumStatusRunning SummarizedStatus = "Running" +) + +type edgeStackStatusSummary struct { + AggregatedStatus aggregatedStatusesMap + Status SummarizedStatus + Reason string +} + +type edgeStackListResponseItem struct { + portainer.EdgeStack + StatusSummary edgeStackStatusSummary +} + +// @id EdgeStackList +// @summary Fetches the list of EdgeStacks +// @description **Access policy**: administrator +// @tags edge_stacks +// @security ApiKeyAuth +// @security jwt +// @produce json +// @param summarizeStatuses query boolean false "will summarize the statuses" +// @success 200 {array} portainer.EdgeStack +// @failure 500 +// @failure 400 +// @failure 503 "Edge compute features are disabled" +// @router /edge_stacks [get] +func (handler *Handler) edgeStackList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + summarizeStatuses, _ := request.RetrieveBooleanQueryParameter(r, "summarizeStatuses", true) + + edgeStacks, err := handler.DataStore.EdgeStack().EdgeStacks() + if err != nil { + return httperror.InternalServerError("Unable to retrieve edge stacks from the database", err) + } + + res := make([]edgeStackListResponseItem, len(edgeStacks)) + + for i := range edgeStacks { + res[i].EdgeStack = edgeStacks[i] + + if summarizeStatuses { + if err := fillStatusSummary(handler.DataStore, &res[i]); err != nil { + return handlerDBErr(err, "Unable to retrieve edge stack status from the database") + } + } else if err := fillEdgeStackStatus(handler.DataStore, &res[i].EdgeStack); err != nil { + return handlerDBErr(err, "Unable to retrieve edge stack status from the database") + } + } + + return response.JSON(w, res) +} + +func fillStatusSummary(tx dataservices.DataStoreTx, edgeStack *edgeStackListResponseItem) error { + statuses, err := tx.EdgeStackStatus().ReadAll(edgeStack.ID) + if err != nil { + return err + } + + aggregated := make(aggregatedStatusesMap) + + for _, envStatus := range statuses { + for _, status := range envStatus.Status { + aggregated[status.Type]++ + } + } + + status, reason := SummarizeStatuses(statuses, edgeStack.NumDeployments) + + edgeStack.StatusSummary = edgeStackStatusSummary{ + AggregatedStatus: aggregated, + Status: status, + Reason: reason, + } + + edgeStack.Status = map[portainer.EndpointID]portainer.EdgeStackStatus{} + + return nil +} + +func SummarizeStatuses(statuses []portainer.EdgeStackStatusForEnv, numDeployments int) (SummarizedStatus, string) { + if numDeployments == 0 { + return sumStatusUnavailable, "Your edge stack is currently unavailable due to the absence of an available environment in your edge group" + } + + allStatuses := slicesx.FlatMap(statuses, func(x portainer.EdgeStackStatusForEnv) []portainer.EdgeStackDeploymentStatus { + return x.Status + }) + + lastStatuses := slicesx.Map( + slicesx.Filter( + statuses, + func(s portainer.EdgeStackStatusForEnv) bool { + return len(s.Status) > 0 + }, + ), + func(x portainer.EdgeStackStatusForEnv) portainer.EdgeStackDeploymentStatus { + return x.Status[len(x.Status)-1] + }, + ) + + if len(lastStatuses) == 0 { + return sumStatusDeploying, "" + } + + if allFailed := slicesx.Every(lastStatuses, func(s portainer.EdgeStackDeploymentStatus) bool { + return s.Type == portainer.EdgeStackStatusError + }); allFailed { + return sumStatusFailed, "" + } + + if hasPaused := slicesx.Some(allStatuses, func(s portainer.EdgeStackDeploymentStatus) bool { + return s.Type == portainer.EdgeStackStatusPausedDeploying + }); hasPaused { + return sumStatusPaused, "" + } + + if len(lastStatuses) < numDeployments { + return sumStatusDeploying, "" + } + + hasDeploying := slicesx.Some(lastStatuses, func(s portainer.EdgeStackDeploymentStatus) bool { return s.Type == portainer.EdgeStackStatusDeploying }) + hasRunning := slicesx.Some(lastStatuses, func(s portainer.EdgeStackDeploymentStatus) bool { return s.Type == portainer.EdgeStackStatusRunning }) + hasFailed := slicesx.Some(lastStatuses, func(s portainer.EdgeStackDeploymentStatus) bool { return s.Type == portainer.EdgeStackStatusError }) + + if hasRunning && hasFailed && !hasDeploying { + return sumStatusPartiallyRunning, "" + } + + if allCompleted := slicesx.Every(lastStatuses, func(s portainer.EdgeStackDeploymentStatus) bool { return s.Type == portainer.EdgeStackStatusCompleted }); allCompleted { + return sumStatusCompleted, "" + } + + if allRunning := slicesx.Every(lastStatuses, func(s portainer.EdgeStackDeploymentStatus) bool { + return s.Type == portainer.EdgeStackStatusRunning + }); allRunning { + return sumStatusRunning, "" + } + + return sumStatusDeploying, "" +} diff --git a/api/http/handler/edgestacks/edgestack_status_update.go b/api/http/handler/edgestacks/edgestack_status_update.go new file mode 100644 index 0000000..2deb5a4 --- /dev/null +++ b/api/http/handler/edgestacks/edgestack_status_update.go @@ -0,0 +1,147 @@ +package edgestacks + +import ( + "errors" + "fmt" + "net/http" + "slices" + "strconv" + "time" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +type updateStatusPayload struct { + Error string + Status *portainer.EdgeStackStatusType + EndpointID portainer.EndpointID + Time int64 + Version int +} + +func (payload *updateStatusPayload) Validate(r *http.Request) error { + if payload.Status == nil { + return errors.New("invalid status") + } + + if payload.EndpointID == 0 { + return errors.New("invalid EnvironmentID") + } + + if *payload.Status == portainer.EdgeStackStatusError && len(payload.Error) == 0 { + return errors.New("error message is mandatory when status is error") + } + + if payload.Time == 0 { + payload.Time = time.Now().Unix() + } + + return nil +} + +// @id EdgeStackStatusUpdate +// @summary Update an EdgeStack status +// @description Authorized only if the request is done by an Edge Environment(Endpoint) +// @tags edge_stacks +// @accept json +// @produce json +// @param id path int true "EdgeStack Id" +// @param body body updateStatusPayload true "EdgeStack status payload" +// @success 200 {object} portainer.EdgeStack +// @failure 500 +// @failure 400 +// @failure 404 +// @failure 403 +// @router /edge_stacks/{id}/status [put] +func (handler *Handler) edgeStackStatusUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + stackID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid stack identifier route variable", err) + } + + var payload updateStatusPayload + if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil { + return httperror.BadRequest("Invalid request payload", fmt.Errorf("edge polling error: %w. Environment ID: %d", err, payload.EndpointID)) + } + + endpoint, err := handler.DataStore.Endpoint().Endpoint(payload.EndpointID) + if err != nil { + return handlerDBErr(fmt.Errorf("unable to find the environment from the database: %w. Environment ID: %d", err, payload.EndpointID), "unable to find the environment") + } + + if err := handler.requestBouncer.AuthorizedEdgeEndpointOperation(r, endpoint); err != nil { + return httperror.Forbidden("Permission denied to access environment", fmt.Errorf("unauthorized edge endpoint operation: %w. Environment ID: %d", err, payload.EndpointID)) + } + + var stack *portainer.EdgeStack + + if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + var err error + stack, err = tx.EdgeStack().EdgeStack(portainer.EdgeStackID(stackID)) + if err != nil { + if dataservices.IsErrObjectNotFound(err) { + return nil + } + + return httperror.InternalServerError("Unable to retrieve Edge stack from the database", err) + } + + if err := handler.updateEdgeStackStatus(tx, stack, stack.ID, payload); err != nil { + return httperror.InternalServerError("Unable to update Edge stack status", err) + } + + return nil + }); err != nil { + return response.TxErrorResponse(err) + } + + if ok, _ := strconv.ParseBool(r.Header.Get("X-Portainer-No-Body")); ok { + return nil + } + + if err := fillEdgeStackStatus(handler.DataStore, stack); err != nil { + return handlerDBErr(err, "Unable to retrieve edge stack status from the database") + } + + return response.JSON(w, stack) +} + +func (handler *Handler) updateEdgeStackStatus(tx dataservices.DataStoreTx, stack *portainer.EdgeStack, stackID portainer.EdgeStackID, payload updateStatusPayload) error { + if payload.Version > 0 && payload.Version < stack.Version { + return nil + } + + status := *payload.Status + + deploymentStatus := portainer.EdgeStackDeploymentStatus{ + Type: status, + Error: payload.Error, + Time: payload.Time, + } + + if deploymentStatus.Type == portainer.EdgeStackStatusRemoved { + return tx.EdgeStackStatus().Delete(stackID, payload.EndpointID) + } + + environmentStatus, err := tx.EdgeStackStatus().Read(stackID, payload.EndpointID) + if err != nil && !tx.IsErrObjectNotFound(err) { + return err + } else if tx.IsErrObjectNotFound(err) { + environmentStatus = &portainer.EdgeStackStatusForEnv{ + EndpointID: payload.EndpointID, + Status: []portainer.EdgeStackDeploymentStatus{}, + } + } + + if containsStatus := slices.ContainsFunc(environmentStatus.Status, func(e portainer.EdgeStackDeploymentStatus) bool { + return e.Type == deploymentStatus.Type + }); !containsStatus { + environmentStatus.Status = append(environmentStatus.Status, deploymentStatus) + } + + return tx.EdgeStackStatus().Update(stackID, payload.EndpointID, environmentStatus) +} diff --git a/api/http/handler/edgestacks/edgestack_status_update_test.go b/api/http/handler/edgestacks/edgestack_status_update_test.go new file mode 100644 index 0000000..2017363 --- /dev/null +++ b/api/http/handler/edgestacks/edgestack_status_update_test.go @@ -0,0 +1,147 @@ +package edgestacks + +import ( + "bytes" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + portainer "github.com/portainer/portainer/api" + + "github.com/segmentio/encoding/json" + "github.com/stretchr/testify/require" +) + +// Update Status +func TestUpdateStatusAndInspect(t *testing.T) { + t.Parallel() + handler, rawAPIKey := setupHandler(t) + + endpoint := createEndpoint(t, handler.DataStore) + edgeStack := createEdgeStack(t, handler.DataStore, endpoint.ID) + + // Update edge stack status + newStatus := portainer.EdgeStackStatusError + payload := updateStatusPayload{ + Error: "test-error", + Status: &newStatus, + EndpointID: endpoint.ID, + } + + jsonPayload, err := json.Marshal(payload) + require.NoError(t, err) + + r := bytes.NewBuffer(jsonPayload) + req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("/edge_stacks/%d/status", edgeStack.ID), r) + require.NoError(t, err) + + req.Header.Set(portainer.PortainerAgentEdgeIDHeader, endpoint.EdgeID) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected a %d response, found: %d", http.StatusOK, rec.Code) + } + + // Get updated edge stack + req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), nil) + require.NoError(t, err) + + req.Header.Add("x-api-key", rawAPIKey) + rec = httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected a %d response, found: %d", http.StatusOK, rec.Code) + } + + updatedStack := portainer.EdgeStack{} + err = json.NewDecoder(rec.Body).Decode(&updatedStack) + require.NoError(t, err) + + endpointStatus, ok := updatedStack.Status[payload.EndpointID] + require.True(t, ok) + + lastStatus := endpointStatus.Status[len(endpointStatus.Status)-1] + + if len(endpointStatus.Status) == len(edgeStack.Status[payload.EndpointID].Status) { + t.Fatal("expected status array to be updated") + } + + if lastStatus.Type != *payload.Status { + t.Fatalf("expected EdgeStackStatusType %d, found %d", *payload.Status, lastStatus.Type) + } + + if endpointStatus.EndpointID != payload.EndpointID { + t.Fatalf("expected EndpointID %d, found %d", payload.EndpointID, endpointStatus.EndpointID) + } +} + +func TestUpdateStatusWithInvalidPayload(t *testing.T) { + t.Parallel() + handler, _ := setupHandler(t) + + endpoint := createEndpoint(t, handler.DataStore) + edgeStack := createEdgeStack(t, handler.DataStore, endpoint.ID) + + // Update edge stack status + statusError := portainer.EdgeStackStatusError + statusOk := portainer.EdgeStackStatusDeploymentReceived + cases := []struct { + Name string + Payload updateStatusPayload + ExpectedErrorMessage string + ExpectedStatusCode int + }{ + { + "Update with nil Status", + updateStatusPayload{ + Error: "test-error", + Status: nil, + EndpointID: endpoint.ID, + }, + "Invalid status", + 400, + }, + { + "Update with error status and empty error message", + updateStatusPayload{ + Error: "", + Status: &statusError, + EndpointID: endpoint.ID, + }, + "Error message is mandatory when status is error", + 400, + }, + { + "Update with missing EndpointID", + updateStatusPayload{ + Error: "", + Status: &statusOk, + EndpointID: 0, + }, + "Invalid EnvironmentID", + 400, + }, + } + + for _, tc := range cases { + t.Run(tc.Name, func(t *testing.T) { + jsonPayload, err := json.Marshal(tc.Payload) + require.NoError(t, err) + + r := bytes.NewBuffer(jsonPayload) + req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("/edge_stacks/%d/status", edgeStack.ID), r) + require.NoError(t, err) + + req.Header.Set(portainer.PortainerAgentEdgeIDHeader, endpoint.EdgeID) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != tc.ExpectedStatusCode { + t.Fatalf("expected a %d response, found: %d", tc.ExpectedStatusCode, rec.Code) + } + }) + } +} diff --git a/api/http/handler/edgestacks/edgestack_test.go b/api/http/handler/edgestacks/edgestack_test.go new file mode 100644 index 0000000..caae7c9 --- /dev/null +++ b/api/http/handler/edgestacks/edgestack_test.go @@ -0,0 +1,147 @@ +package edgestacks + +import ( + "strconv" + "testing" + "time" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/apikey" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/filesystem" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/edge/edgestacks" + "github.com/portainer/portainer/api/internal/testhelpers" + "github.com/portainer/portainer/api/jwt" + "github.com/portainer/portainer/api/roar" + + "github.com/pkg/errors" + "github.com/stretchr/testify/require" +) + +// Helpers +func setupHandler(t *testing.T) (*Handler, string) { + t.Helper() + + _, store := datastore.MustNewTestStore(t, true, true) + + jwtService, err := jwt.NewService("1h", store) + if err != nil { + t.Fatal(err) + } + + user := &portainer.User{ID: 2, Username: "admin", Role: portainer.AdministratorRole} + if err := store.User().Create(user); err != nil { + t.Fatal(err) + } + + apiKeyService := apikey.NewAPIKeyService(store.APIKeyRepository(), store.User()) + rawAPIKey, _, err := apiKeyService.GenerateApiKey(*user, "test") + if err != nil { + t.Fatal(err) + } + + fs, err := filesystem.NewService(t.TempDir(), "") + if err != nil { + t.Fatal(err) + } + + handler := NewHandler( + security.NewRequestBouncer(t.Context(), store, jwtService, apiKeyService), + store, + edgestacks.NewService(store), + ) + + handler.FileService = fs + + settings, err := handler.DataStore.Settings().Settings() + require.NoError(t, err) + + settings.EnableEdgeComputeFeatures = true + + err = handler.DataStore.Settings().UpdateSettings(settings) + require.NoError(t, err) + + handler.GitService = testhelpers.NewGitService(errors.New("Clone error"), "git-service-id") + + return handler, rawAPIKey +} + +func createEndpointWithId(t *testing.T, store dataservices.DataStore, endpointID portainer.EndpointID) portainer.Endpoint { + t.Helper() + + endpoint := portainer.Endpoint{ + ID: endpointID, + Name: "test-endpoint-" + strconv.Itoa(int(endpointID)), + Type: portainer.EdgeAgentOnDockerEnvironment, + URL: "https://portainer.io:9443", + EdgeID: "edge-id", + LastCheckInDate: time.Now().Unix(), + } + + err := store.Endpoint().Create(&endpoint) + require.NoError(t, err) + + return endpoint +} + +func createEndpoint(t *testing.T, store dataservices.DataStore) portainer.Endpoint { + return createEndpointWithId(t, store, 5) +} + +func createEdgeStack(t *testing.T, store dataservices.DataStore, endpointID portainer.EndpointID) portainer.EdgeStack { + t.Helper() + + edgeGroup := portainer.EdgeGroup{ + ID: 1, + Name: "EdgeGroup 1", + Dynamic: false, + TagIDs: nil, + EndpointIDs: roar.FromSlice([]portainer.EndpointID{endpointID}), + PartialMatch: false, + } + + err := store.EdgeGroup().Create(&edgeGroup) + require.NoError(t, err) + + edgeStackID := portainer.EdgeStackID(14) + edgeStack := portainer.EdgeStack{ + ID: edgeStackID, + Name: "test-edge-stack-" + strconv.Itoa(int(edgeStackID)), + CreationDate: time.Now().Unix(), + EdgeGroups: []portainer.EdgeGroupID{edgeGroup.ID}, + ProjectPath: "/project/path", + EntryPoint: "entrypoint", + Version: 237, + ManifestPath: "/manifest/path", + DeploymentType: portainer.EdgeStackDeploymentKubernetes, + } + + endpointRelation := portainer.EndpointRelation{ + EndpointID: endpointID, + EdgeStacks: map[portainer.EdgeStackID]bool{ + edgeStack.ID: true, + }, + } + + err = store.EdgeStack().Create(edgeStack.ID, &edgeStack) + require.NoError(t, err) + + err = store.EndpointRelation().Create(&endpointRelation) + require.NoError(t, err) + + return edgeStack +} + +func createEdgeGroup(t *testing.T, store dataservices.DataStore) portainer.EdgeGroup { + edgeGroup := portainer.EdgeGroup{ + ID: 1, + Name: "EdgeGroup 1", + } + + err := store.EdgeGroup().Create(&edgeGroup) + require.NoError(t, err) + + return edgeGroup +} diff --git a/api/http/handler/edgestacks/edgestack_update.go b/api/http/handler/edgestacks/edgestack_update.go new file mode 100644 index 0000000..efb4d6d --- /dev/null +++ b/api/http/handler/edgestacks/edgestack_update.go @@ -0,0 +1,164 @@ +package edgestacks + +import ( + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/internal/edge" + "github.com/portainer/portainer/api/set" + "github.com/portainer/portainer/api/stacks/stackutils" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" + + "github.com/pkg/errors" +) + +type updateEdgeStackPayload struct { + StackFileContent string + UpdateVersion bool + EdgeGroups []portainer.EdgeGroupID + DeploymentType portainer.EdgeStackDeploymentType + // Uses the manifest's namespaces instead of the default one + UseManifestNamespaces bool +} + +func (payload *updateEdgeStackPayload) Validate(r *http.Request) error { + if payload.StackFileContent == "" { + return errors.New("invalid stack file content") + } + + if len(payload.EdgeGroups) == 0 { + return errors.New("edge Groups are mandatory for an Edge stack") + } + + return nil +} + +// @id EdgeStackUpdate +// @summary Update an EdgeStack +// @description **Access policy**: administrator +// @tags edge_stacks +// @security ApiKeyAuth +// @security jwt +// @accept json +// @produce json +// @param id path int true "EdgeStack Id" +// @param body body updateEdgeStackPayload true "EdgeStack data" +// @success 200 {object} portainer.EdgeStack +// @failure 500 +// @failure 400 +// @failure 503 "Edge compute features are disabled" +// @router /edge_stacks/{id} [put] +func (handler *Handler) edgeStackUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + stackID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid stack identifier route variable", err) + } + + var payload updateEdgeStackPayload + if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + if err := stackutils.ValidateEdgeStackComposeContent(r.Context(), payload.DeploymentType, []byte(payload.StackFileContent)); err != nil { + return httperror.BadRequest("Stack file contains a URL blocked by the SSRF policy", err) + } + + var stack *portainer.EdgeStack + if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + stack, err = handler.updateEdgeStack(tx, portainer.EdgeStackID(stackID), payload) + return err + }); err != nil { + return response.TxErrorResponse(err) + } + + if err := fillEdgeStackStatus(handler.DataStore, stack); err != nil { + return handlerDBErr(err, "Unable to retrieve edge stack status from the database") + } + + return response.JSON(w, stack) +} + +func (handler *Handler) updateEdgeStack(tx dataservices.DataStoreTx, stackID portainer.EdgeStackID, payload updateEdgeStackPayload) (*portainer.EdgeStack, error) { + stack, err := tx.EdgeStack().EdgeStack(stackID) + if err != nil { + return nil, handlerDBErr(err, "Unable to find a stack with the specified identifier inside the database") + } + + relationConfig, err := edge.FetchEndpointRelationsConfig(tx) + if err != nil { + return nil, httperror.InternalServerError("Unable to retrieve environments relations config from database", err) + } + + relatedEndpointIds, err := edge.EdgeStackRelatedEndpoints(stack.EdgeGroups, relationConfig.Endpoints, relationConfig.EndpointGroups, relationConfig.EdgeGroups) + if err != nil { + return nil, httperror.InternalServerError("Unable to retrieve edge stack related environments from database", err) + } + + groupsIds := stack.EdgeGroups + if payload.EdgeGroups != nil { + newRelated, _, err := handler.handleChangeEdgeGroups(tx, stack, payload.EdgeGroups, relatedEndpointIds, relationConfig) + if err != nil { + return nil, httperror.InternalServerError("Unable to handle edge groups change", err) + } + + groupsIds = payload.EdgeGroups + relatedEndpointIds = newRelated + + } + + hasWrongType, err := hasWrongEnvironmentType(tx.Endpoint(), relatedEndpointIds, payload.DeploymentType) + if err != nil { + return nil, httperror.InternalServerError("unable to check for existence of non fitting environments: %w", err) + } + if hasWrongType { + return nil, httperror.BadRequest("edge stack with config do not match the environment type", nil) + } + + stack.NumDeployments = len(relatedEndpointIds) + + stack.UseManifestNamespaces = payload.UseManifestNamespaces + + stack.EdgeGroups = groupsIds + + if payload.UpdateVersion { + if err := handler.updateStackVersion(tx, stack, payload.DeploymentType, []byte(payload.StackFileContent), "", relatedEndpointIds); err != nil { + return nil, httperror.InternalServerError("Unable to update stack version", err) + } + } + + if err := tx.EdgeStack().UpdateEdgeStack(stack.ID, stack); err != nil { + return nil, httperror.InternalServerError("Unable to persist the stack changes inside the database", err) + } + + return stack, nil +} + +func (handler *Handler) handleChangeEdgeGroups(tx dataservices.DataStoreTx, edgeStack *portainer.EdgeStack, newEdgeGroupsIDs []portainer.EdgeGroupID, oldRelatedEnvironmentIDs []portainer.EndpointID, relationConfig *edge.EndpointRelationsConfig) ([]portainer.EndpointID, set.Set[portainer.EndpointID], error) { + newRelatedEnvironmentIDs, err := edge.EdgeStackRelatedEndpoints(newEdgeGroupsIDs, relationConfig.Endpoints, relationConfig.EndpointGroups, relationConfig.EdgeGroups) + if err != nil { + return nil, nil, errors.WithMessage(err, "Unable to retrieve edge stack related environments from database") + } + + oldRelatedEnvironmentsSet := set.ToSet(oldRelatedEnvironmentIDs) + newRelatedEnvironmentsSet := set.ToSet(newRelatedEnvironmentIDs) + + relatedEnvironmentsToAdd := newRelatedEnvironmentsSet.Difference(oldRelatedEnvironmentsSet) + relatedEnvironmentsToRemove := oldRelatedEnvironmentsSet.Difference(newRelatedEnvironmentsSet) + + if len(relatedEnvironmentsToRemove) > 0 { + if err := tx.EndpointRelation().RemoveEndpointRelationsForEdgeStack(relatedEnvironmentsToRemove.Keys(), edgeStack.ID); err != nil { + return nil, nil, errors.WithMessage(err, "Unable to remove edge stack relations from the database") + } + } + + if len(relatedEnvironmentsToAdd) > 0 { + if err := tx.EndpointRelation().AddEndpointRelationsForEdgeStack(relatedEnvironmentsToAdd.Keys(), edgeStack); err != nil { + return nil, nil, errors.WithMessage(err, "Unable to add edge stack relations to the database") + } + } + + return newRelatedEnvironmentIDs, relatedEnvironmentsToAdd, nil +} diff --git a/api/http/handler/edgestacks/edgestack_update_test.go b/api/http/handler/edgestacks/edgestack_update_test.go new file mode 100644 index 0000000..1665f39 --- /dev/null +++ b/api/http/handler/edgestacks/edgestack_update_test.go @@ -0,0 +1,238 @@ +package edgestacks + +import ( + "bytes" + "fmt" + "net/http" + "net/http/httptest" + "reflect" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/roar" + + "github.com/segmentio/encoding/json" + "github.com/stretchr/testify/require" +) + +// Update +func TestUpdateAndInspect(t *testing.T) { + t.Parallel() + handler, rawAPIKey := setupHandler(t) + + endpoint := createEndpoint(t, handler.DataStore) + edgeStack := createEdgeStack(t, handler.DataStore, endpoint.ID) + + // Update edge stack: create new Endpoint, EndpointRelation and EdgeGroup + endpointID := portainer.EndpointID(6) + newEndpoint := createEndpointWithId(t, handler.DataStore, endpointID) + + err := handler.DataStore.Endpoint().Create(&newEndpoint) + require.NoError(t, err) + + endpointRelation := portainer.EndpointRelation{ + EndpointID: endpointID, + EdgeStacks: map[portainer.EdgeStackID]bool{ + edgeStack.ID: true, + }, + } + + err = handler.DataStore.EndpointRelation().Create(&endpointRelation) + require.NoError(t, err) + + newEdgeGroup := portainer.EdgeGroup{ + ID: 2, + Name: "EdgeGroup 2", + Dynamic: false, + TagIDs: nil, + EndpointIDs: roar.FromSlice([]portainer.EndpointID{newEndpoint.ID}), + PartialMatch: false, + } + + err = handler.DataStore.EdgeGroup().Create(&newEdgeGroup) + require.NoError(t, err) + + payload := updateEdgeStackPayload{ + StackFileContent: "update-test", + UpdateVersion: true, + EdgeGroups: append(edgeStack.EdgeGroups, newEdgeGroup.ID), + DeploymentType: portainer.EdgeStackDeploymentCompose, + } + + jsonPayload, err := json.Marshal(payload) + require.NoError(t, err) + + r := bytes.NewBuffer(jsonPayload) + req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), r) + require.NoError(t, err) + + req.Header.Add("x-api-key", rawAPIKey) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected a %d response, found: %d", http.StatusOK, rec.Code) + } + + // Get updated edge stack + req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), nil) + require.NoError(t, err) + + req.Header.Add("x-api-key", rawAPIKey) + rec = httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected a %d response, found: %d", http.StatusOK, rec.Code) + } + + updatedStack := portainer.EdgeStack{} + err = json.NewDecoder(rec.Body).Decode(&updatedStack) + require.NoError(t, err) + + if payload.UpdateVersion && updatedStack.Version != edgeStack.Version+1 { + t.Fatalf("expected EdgeStack version %d, found %d", edgeStack.Version+1, updatedStack.Version+1) + } + + if updatedStack.DeploymentType != payload.DeploymentType { + t.Fatalf("expected DeploymentType %d, found %d", edgeStack.DeploymentType, updatedStack.DeploymentType) + } + + if !reflect.DeepEqual(updatedStack.EdgeGroups, payload.EdgeGroups) { + t.Fatalf("expected EdgeGroups to be equal") + } +} + +func TestUpdateWithInvalidEdgeGroups(t *testing.T) { + t.Parallel() + handler, rawAPIKey := setupHandler(t) + + endpoint := createEndpoint(t, handler.DataStore) + edgeStack := createEdgeStack(t, handler.DataStore, endpoint.ID) + + newEdgeGroup := portainer.EdgeGroup{ + ID: 2, + Name: "EdgeGroup 2", + Dynamic: false, + TagIDs: nil, + EndpointIDs: roar.FromSlice([]portainer.EndpointID{8889}), + PartialMatch: false, + } + + err := handler.DataStore.EdgeGroup().Create(&newEdgeGroup) + require.NoError(t, err) + + cases := []struct { + Name string + Payload updateEdgeStackPayload + ExpectedStatusCode int + }{ + { + "Update with non-existing EdgeGroupID", + updateEdgeStackPayload{ + StackFileContent: "error-test", + UpdateVersion: true, + EdgeGroups: []portainer.EdgeGroupID{9999}, + DeploymentType: edgeStack.DeploymentType, + }, + http.StatusInternalServerError, + }, + { + "Update with invalid EdgeGroup (non-existing Endpoint)", + updateEdgeStackPayload{ + StackFileContent: "error-test", + UpdateVersion: true, + EdgeGroups: []portainer.EdgeGroupID{2}, + DeploymentType: edgeStack.DeploymentType, + }, + http.StatusInternalServerError, + }, + { + "Update DeploymentType from Docker to Kubernetes", + updateEdgeStackPayload{ + StackFileContent: "error-test", + UpdateVersion: true, + EdgeGroups: []portainer.EdgeGroupID{1}, + DeploymentType: portainer.EdgeStackDeploymentKubernetes, + }, + http.StatusBadRequest, + }, + } + + for _, tc := range cases { + t.Run(tc.Name, func(t *testing.T) { + jsonPayload, err := json.Marshal(tc.Payload) + if err != nil { + t.Fatal("JSON marshal error:", err) + } + + r := bytes.NewBuffer(jsonPayload) + req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), r) + if err != nil { + t.Fatal("request error:", err) + } + + req.Header.Add("x-api-key", rawAPIKey) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != tc.ExpectedStatusCode { + t.Fatalf("expected a %d response, found: %d", tc.ExpectedStatusCode, rec.Code) + } + }) + } +} + +func TestUpdateWithInvalidPayload(t *testing.T) { + t.Parallel() + handler, rawAPIKey := setupHandler(t) + + endpoint := createEndpoint(t, handler.DataStore) + edgeStack := createEdgeStack(t, handler.DataStore, endpoint.ID) + + cases := []struct { + Name string + Payload updateEdgeStackPayload + ExpectedStatusCode int + }{ + { + "Update with empty StackFileContent", + updateEdgeStackPayload{ + StackFileContent: "", + UpdateVersion: true, + EdgeGroups: edgeStack.EdgeGroups, + DeploymentType: edgeStack.DeploymentType, + }, + http.StatusBadRequest, + }, + { + "Update with empty EdgeGroups", + updateEdgeStackPayload{ + StackFileContent: "error-test", + UpdateVersion: true, + EdgeGroups: []portainer.EdgeGroupID{}, + DeploymentType: edgeStack.DeploymentType, + }, + http.StatusBadRequest, + }, + } + + for _, tc := range cases { + t.Run(tc.Name, func(t *testing.T) { + jsonPayload, err := json.Marshal(tc.Payload) + require.NoError(t, err) + + r := bytes.NewBuffer(jsonPayload) + req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), r) + require.NoError(t, err) + + req.Header.Add("x-api-key", rawAPIKey) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != tc.ExpectedStatusCode { + t.Fatalf("expected a %d response, found: %d", tc.ExpectedStatusCode, rec.Code) + } + }) + } +} diff --git a/api/http/handler/edgestacks/endpoints.go b/api/http/handler/edgestacks/endpoints.go new file mode 100644 index 0000000..0349200 --- /dev/null +++ b/api/http/handler/edgestacks/endpoints.go @@ -0,0 +1,45 @@ +package edgestacks + +import ( + "fmt" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/internal/endpointutils" +) + +func hasKubeEndpoint(endpointService dataservices.EndpointService, endpointIDs []portainer.EndpointID) (bool, error) { + return hasEndpointPredicate(endpointService, endpointIDs, endpointutils.IsKubernetesEndpoint) +} + +func hasDockerEndpoint(endpointService dataservices.EndpointService, endpointIDs []portainer.EndpointID) (bool, error) { + return hasEndpointPredicate(endpointService, endpointIDs, endpointutils.IsDockerEndpoint) +} + +func hasEndpointPredicate(endpointService dataservices.EndpointService, endpointIDs []portainer.EndpointID, predicate func(*portainer.Endpoint) bool) (bool, error) { + for _, endpointID := range endpointIDs { + endpoint, err := endpointService.Endpoint(endpointID) + if err != nil { + return false, fmt.Errorf("failed to retrieve environment from database: %w", err) + } + + if predicate(endpoint) { + return true, nil + } + } + + return false, nil +} + +func hasWrongEnvironmentType(endpointService dataservices.EndpointService, endpointIDs []portainer.EndpointID, deploymentType portainer.EdgeStackDeploymentType) (bool, error) { + return hasEndpointPredicate(endpointService, endpointIDs, func(e *portainer.Endpoint) bool { + switch deploymentType { + case portainer.EdgeStackDeploymentKubernetes: + return !endpointutils.IsKubernetesEndpoint(e) + case portainer.EdgeStackDeploymentCompose: + return !endpointutils.IsDockerEndpoint(e) + default: + return true + } + }) +} diff --git a/api/http/handler/edgestacks/endpoints_test.go b/api/http/handler/edgestacks/endpoints_test.go new file mode 100644 index 0000000..07f623b --- /dev/null +++ b/api/http/handler/edgestacks/endpoints_test.go @@ -0,0 +1,105 @@ +package edgestacks + +import ( + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/internal/testhelpers" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_hasKubeEndpoint(t *testing.T) { + t.Parallel() + endpoints := []portainer.Endpoint{ + {ID: 1, Type: portainer.DockerEnvironment}, + {ID: 2, Type: portainer.AgentOnDockerEnvironment}, + {ID: 3, Type: portainer.AzureEnvironment}, + {ID: 4, Type: portainer.EdgeAgentOnDockerEnvironment}, + {ID: 5, Type: portainer.KubernetesLocalEnvironment}, + {ID: 6, Type: portainer.AgentOnKubernetesEnvironment}, + {ID: 7, Type: portainer.EdgeAgentOnKubernetesEnvironment}, + } + + datastore := testhelpers.NewDatastore(testhelpers.WithEndpoints(endpoints)) + + tests := []struct { + endpointIds []portainer.EndpointID + expected bool + }{ + {endpointIds: []portainer.EndpointID{1}, expected: false}, + {endpointIds: []portainer.EndpointID{2}, expected: false}, + {endpointIds: []portainer.EndpointID{3}, expected: false}, + {endpointIds: []portainer.EndpointID{4}, expected: false}, + {endpointIds: []portainer.EndpointID{5}, expected: true}, + {endpointIds: []portainer.EndpointID{6}, expected: true}, + {endpointIds: []portainer.EndpointID{7}, expected: true}, + {endpointIds: []portainer.EndpointID{7, 2}, expected: true}, + {endpointIds: []portainer.EndpointID{6, 4, 1}, expected: true}, + {endpointIds: []portainer.EndpointID{1, 2, 3}, expected: false}, + } + + for _, test := range tests { + + ans, err := hasKubeEndpoint(datastore.Endpoint(), test.endpointIds) + require.NoError(t, err, "hasKubeEndpoint shouldn't fail") + + assert.Equal(t, test.expected, ans, "hasKubeEndpoint expected to return %b for %v, but returned %b", test.expected, test.endpointIds, ans) + } +} + +func Test_hasKubeEndpoint_failWhenEndpointDontExist(t *testing.T) { + t.Parallel() + datastore := testhelpers.NewDatastore(testhelpers.WithEndpoints([]portainer.Endpoint{})) + + _, err := hasKubeEndpoint(datastore.Endpoint(), []portainer.EndpointID{1}) + require.Error(t, err, "hasKubeEndpoint should fail") +} + +func Test_hasDockerEndpoint(t *testing.T) { + t.Parallel() + endpoints := []portainer.Endpoint{ + {ID: 1, Type: portainer.DockerEnvironment}, + {ID: 2, Type: portainer.AgentOnDockerEnvironment}, + {ID: 3, Type: portainer.AzureEnvironment}, + {ID: 4, Type: portainer.EdgeAgentOnDockerEnvironment}, + {ID: 5, Type: portainer.KubernetesLocalEnvironment}, + {ID: 6, Type: portainer.AgentOnKubernetesEnvironment}, + {ID: 7, Type: portainer.EdgeAgentOnKubernetesEnvironment}, + } + + datastore := testhelpers.NewDatastore(testhelpers.WithEndpoints(endpoints)) + + tests := []struct { + endpointIds []portainer.EndpointID + expected bool + }{ + {endpointIds: []portainer.EndpointID{1}, expected: true}, + {endpointIds: []portainer.EndpointID{2}, expected: true}, + {endpointIds: []portainer.EndpointID{3}, expected: false}, + {endpointIds: []portainer.EndpointID{4}, expected: true}, + {endpointIds: []portainer.EndpointID{5}, expected: false}, + {endpointIds: []portainer.EndpointID{6}, expected: false}, + {endpointIds: []portainer.EndpointID{7}, expected: false}, + {endpointIds: []portainer.EndpointID{7, 2}, expected: true}, + {endpointIds: []portainer.EndpointID{6, 4, 1}, expected: true}, + {endpointIds: []portainer.EndpointID{1, 2, 3}, expected: true}, + } + + for _, test := range tests { + + ans, err := hasDockerEndpoint(datastore.Endpoint(), test.endpointIds) + require.NoError(t, err, "hasDockerEndpoint shouldn't fail") + + assert.Equal(t, test.expected, ans, "hasDockerEndpoint expected to return %b for %v, but returned %b", test.expected, test.endpointIds, ans) + } +} + +func Test_hasDockerEndpoint_failWhenEndpointDontExist(t *testing.T) { + t.Parallel() + datastore := testhelpers.NewDatastore(testhelpers.WithEndpoints([]portainer.Endpoint{})) + + _, err := hasDockerEndpoint(datastore.Endpoint(), []portainer.EndpointID{1}) + require.Error(t, err, "hasDockerEndpoint should fail") +} diff --git a/api/http/handler/edgestacks/handler.go b/api/http/handler/edgestacks/handler.go new file mode 100644 index 0000000..78df853 --- /dev/null +++ b/api/http/handler/edgestacks/handler.go @@ -0,0 +1,65 @@ +package edgestacks + +import ( + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/http/middlewares" + "github.com/portainer/portainer/api/http/security" + edgestackservice "github.com/portainer/portainer/api/internal/edge/edgestacks" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + + "github.com/gorilla/mux" +) + +// Handler is the HTTP handler used to handle environment(endpoint) group operations. +type Handler struct { + *mux.Router + requestBouncer security.BouncerService + DataStore dataservices.DataStore + FileService portainer.FileService + GitService portainer.GitService + edgeStacksService *edgestackservice.Service + KubernetesDeployer portainer.KubernetesDeployer +} + +// NewHandler creates a handler to manage environment(endpoint) group operations. +func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStore, edgeStacksService *edgestackservice.Service) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + requestBouncer: bouncer, + DataStore: dataStore, + edgeStacksService: edgeStacksService, + } + + h.Handle("/edge_stacks/create/{method}", + bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeStackCreate)))).Methods(http.MethodPost) + h.Handle("/edge_stacks", + bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeStackList)))).Methods(http.MethodGet) + h.Handle("/edge_stacks/{id}", + bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeStackInspect)))).Methods(http.MethodGet) + h.Handle("/edge_stacks/{id}", + bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeStackUpdate)))).Methods(http.MethodPut) + h.Handle("/edge_stacks/{id}", + bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeStackDelete)))).Methods(http.MethodDelete) + h.Handle("/edge_stacks/{id}/file", + 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) + + edgeStackStatusRouter := h.NewRoute().Subrouter() + edgeStackStatusRouter.Use(middlewares.WithEndpoint(h.DataStore.Endpoint(), "endpoint_id")) + + return h +} + +func handlerDBErr(err error, msg string) *httperror.HandlerError { + httpErr := httperror.InternalServerError(msg, err) + + if dataservices.IsErrObjectNotFound(err) { + httpErr.StatusCode = http.StatusNotFound + } + + return httpErr +} diff --git a/api/http/handler/edgestacks/utils_update_stack_version.go b/api/http/handler/edgestacks/utils_update_stack_version.go new file mode 100644 index 0000000..78ac500 --- /dev/null +++ b/api/http/handler/edgestacks/utils_update_stack_version.go @@ -0,0 +1,59 @@ +package edgestacks + +import ( + "fmt" + "strconv" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/filesystem" + + "github.com/rs/zerolog/log" +) + +func (handler *Handler) updateStackVersion(tx dataservices.DataStoreTx, stack *portainer.EdgeStack, deploymentType portainer.EdgeStackDeploymentType, config []byte, oldGitHash string, relatedEnvironmentsIDs []portainer.EndpointID) error { + stack.Version++ + + if err := tx.EdgeStackStatus().Clear(stack.ID, relatedEnvironmentsIDs); err != nil { + return err + } + + return handler.storeStackFile(stack, deploymentType, config) +} + +func (handler *Handler) storeStackFile(stack *portainer.EdgeStack, deploymentType portainer.EdgeStackDeploymentType, config []byte) error { + if deploymentType != stack.DeploymentType { + // deployment type was changed - need to delete all old files + if err := handler.FileService.RemoveDirectory(stack.ProjectPath); err != nil { + log.Warn().Err(err).Msg("Unable to clear old files") + } + + stack.EntryPoint = "" + stack.ManifestPath = "" + stack.DeploymentType = deploymentType + } + + stackFolder := strconv.Itoa(int(stack.ID)) + entryPoint := "" + if deploymentType == portainer.EdgeStackDeploymentCompose { + if stack.EntryPoint == "" { + stack.EntryPoint = filesystem.ComposeFileDefaultName + } + + entryPoint = stack.EntryPoint + } + + if deploymentType == portainer.EdgeStackDeploymentKubernetes { + if stack.ManifestPath == "" { + stack.ManifestPath = filesystem.ManifestFileDefaultName + } + + entryPoint = stack.ManifestPath + } + + if _, err := handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, entryPoint, config); err != nil { + return fmt.Errorf("unable to persist updated Compose file with version on disk: %w", err) + } + + return nil +} diff --git a/api/http/handler/endpointedge/endpointedge_job_logs.go b/api/http/handler/endpointedge/endpointedge_job_logs.go new file mode 100644 index 0000000..61b99dd --- /dev/null +++ b/api/http/handler/endpointedge/endpointedge_job_logs.go @@ -0,0 +1,112 @@ +package endpointedge + +import ( + "errors" + "fmt" + "net/http" + "strconv" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/http/middlewares" + "github.com/portainer/portainer/api/internal/edge/cache" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +type logsPayload struct { + FileContent string +} + +func (payload *logsPayload) Validate(r *http.Request) error { + return nil +} + +// endpointEdgeJobsLogs +// @summary Update the logs collected from an Edge Job +// @description **Access policy**: Edge agent only — requires X-PortainerAgent-EdgeID header +// @tags edge_agent +// @accept json +// @produce json +// @param id path int true "environment Id" +// @param jobID path int true "Job Id" +// @success 200 +// @failure 500 +// @failure 400 +// @failure 403 +// @router /endpoints/{id}/edge/jobs/{jobID}/logs [post] +func (handler *Handler) endpointEdgeJobsLogs(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpoint, err := middlewares.FetchEndpoint(r) + if err != nil { + return httperror.BadRequest("Unable to find an environment on request context", err) + } + + if err := handler.requestBouncer.AuthorizedEdgeEndpointOperation(r, endpoint); err != nil { + return httperror.Forbidden("Permission denied to access environment", fmt.Errorf("unauthorized edge endpoint operation: %w. Environment ID: %d", err, endpoint.ID)) + } + + edgeJobID, err := request.RetrieveNumericRouteVariableValue(r, "jobID") + if err != nil { + return httperror.BadRequest("Invalid edge job identifier route variable", fmt.Errorf("invalid Edge job route variable: %w. Environment ID: %d", err, endpoint.ID)) + } + + var payload logsPayload + if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil { + return httperror.BadRequest("Invalid request payload", fmt.Errorf("invalid Edge job request payload: %w. Environment ID: %d", err, endpoint.ID)) + } + + if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + return handler.updateEdgeJobLogs(tx, endpoint.ID, portainer.EdgeJobID(edgeJobID), payload) + }); err != nil { + var httpErr *httperror.HandlerError + if errors.As(err, &httpErr) { + httpErr.Err = fmt.Errorf("edge polling error: %w. Environment ID: %d", httpErr.Err, endpoint.ID) + return httpErr + } + + return httperror.InternalServerError("Unexpected error", fmt.Errorf("edge polling error: %w. Environment ID: %d", err, endpoint.ID)) + } + + return response.JSON(w, nil) +} + +func (handler *Handler) updateEdgeJobLogs(tx dataservices.DataStoreTx, endpointID portainer.EndpointID, edgeJobID portainer.EdgeJobID, payload logsPayload) error { + endpoint, err := tx.Endpoint().Endpoint(endpointID) + if tx.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find an environment with the specified identifier inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find an environment with the specified identifier inside the database", err) + } + + edgeJob, err := tx.EdgeJob().Read(edgeJobID) + if tx.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find an edge job with the specified identifier inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find an edge job with the specified identifier inside the database", err) + } + + if resp, err := handler.buildSchedules(tx, endpoint, []portainer.EdgeJob{*edgeJob}); err != nil || len(resp) == 0 { + return httperror.InternalServerError("Unable to verify if the edge job is assigned to the environment", + fmt.Errorf("unable to verify if the edge job is assigned to the environment: %w. Environment name: %s", err, endpoint.Name)) + } + + if err := handler.FileService.StoreEdgeJobTaskLogFileFromBytes(strconv.Itoa(int(edgeJobID)), strconv.Itoa(int(endpoint.ID)), []byte(payload.FileContent)); err != nil { + return httperror.InternalServerError("Unable to save task log to the filesystem", err) + } + + meta := portainer.EdgeJobEndpointMeta{CollectLogs: false, LogsStatus: portainer.EdgeJobLogsStatusCollected} + if _, ok := edgeJob.GroupLogsCollection[endpoint.ID]; ok { + edgeJob.GroupLogsCollection[endpoint.ID] = meta + } else { + edgeJob.Endpoints[endpoint.ID] = meta + } + + if err := tx.EdgeJob().Update(edgeJob.ID, edgeJob); err != nil { + return httperror.InternalServerError("Unable to persist edge job changes to the database", err) + } + + cache.Del(endpointID) + + return nil +} diff --git a/api/http/handler/endpointedge/endpointedge_job_logs_test.go b/api/http/handler/endpointedge/endpointedge_job_logs_test.go new file mode 100644 index 0000000..740dfb5 --- /dev/null +++ b/api/http/handler/endpointedge/endpointedge_job_logs_test.go @@ -0,0 +1,41 @@ +package endpointedge + +import ( + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/datastore" + + "github.com/stretchr/testify/require" +) + +func TestUpdateUnrelatedEdgeJobLogs(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, false) + + h := &Handler{DataStore: store} + + endpointID := portainer.EndpointID(2) + edgeJobID := portainer.EdgeJobID(3) + payload := logsPayload{FileContent: "log content"} + + err := store.Endpoint().Create(&portainer.Endpoint{ + ID: endpointID, + Name: "test-endpoint", + }) + require.NoError(t, err) + + err = store.EdgeJob().CreateWithID(edgeJobID, &portainer.EdgeJob{ + ID: edgeJobID, + Name: "test-edge-job", + }) + require.NoError(t, err) + + // There is no relation between the edge job and the endpoint, so the + // update must fail + err = store.UpdateTx(func(tx dataservices.DataStoreTx) error { + return h.updateEdgeJobLogs(tx, endpointID, edgeJobID, payload) + }) + require.Error(t, err) +} diff --git a/api/http/handler/endpointedge/endpointedge_stack_inspect.go b/api/http/handler/endpointedge/endpointedge_stack_inspect.go new file mode 100644 index 0000000..70e5f5a --- /dev/null +++ b/api/http/handler/endpointedge/endpointedge_stack_inspect.go @@ -0,0 +1,112 @@ +package endpointedge + +import ( + "errors" + "fmt" + "net/http" + "strconv" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/edge" + "github.com/portainer/portainer/api/filesystem" + "github.com/portainer/portainer/api/http/middlewares" + "github.com/portainer/portainer/api/internal/endpointutils" + "github.com/portainer/portainer/api/kubernetes" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" + + "golang.org/x/sync/singleflight" +) + +var edgeStackSingleFlightGroup = singleflight.Group{} + +// @summary Inspect an Edge Stack for an Environment +// @description **Access policy**: Edge agent only — requires X-PortainerAgent-EdgeID header +// @tags edge_agent, edge_stacks +// @accept json +// @produce json +// @param id path int true "environment Id" +// @param stackId path int true "EdgeStack Id" +// @success 200 {object} edge.StackPayload +// @failure 500 +// @failure 400 +// @failure 404 +// @router /endpoints/{id}/edge/stacks/{stackId} [get] +func (handler *Handler) endpointEdgeStackInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpoint, err := middlewares.FetchEndpoint(r) + if err != nil { + return httperror.BadRequest("Unable to find an environment on request context", err) + } + + if err := handler.requestBouncer.AuthorizedEdgeEndpointOperation(r, endpoint); err != nil { + return httperror.Forbidden("Permission denied to access environment", fmt.Errorf("unauthorized edge endpoint operation: %w. Environment ID: %d", err, endpoint.ID)) + } + + edgeStackID, err := request.RetrieveNumericRouteVariableValue(r, "stackId") + if err != nil { + return httperror.BadRequest("Invalid edge stack identifier route variable", fmt.Errorf("invalid Edge stack route variable: %w. Environment ID: %d", err, endpoint.ID)) + } + + s, err, _ := edgeStackSingleFlightGroup.Do(strconv.Itoa(edgeStackID), func() (any, error) { + edgeStack, err := handler.DataStore.EdgeStack().EdgeStack(portainer.EdgeStackID(edgeStackID)) + if handler.DataStore.IsErrObjectNotFound(err) { + return nil, httperror.NotFound("Unable to find an edge stack with the specified identifier inside the database", fmt.Errorf("unable to find the Edge stack from database: %w. Environment ID: %d", err, endpoint.ID)) + } + + return edgeStack, err + }) + if err != nil { + var httpErr *httperror.HandlerError + if errors.As(err, &httpErr) { + return httpErr + } + + return httperror.InternalServerError("Unable to find an edge stack with the specified identifier inside the database", fmt.Errorf("failed to find Edge stack from the database: %w. Environment ID: %d", err, endpoint.ID)) + } + + // WARNING: this variable must not be mutated + edgeStack := s.(*portainer.EdgeStack) + + fileName := edgeStack.EntryPoint + if endpointutils.IsDockerEndpoint(endpoint) { + if fileName == "" { + return httperror.BadRequest("Docker is not supported by this stack", fmt.Errorf("no filename is provided for the Docker endpoint. Environment ID: %d", endpoint.ID)) + } + } + + namespace := "" + if !edgeStack.UseManifestNamespaces { + namespace = kubernetes.DefaultNamespace + } + + if endpointutils.IsKubernetesEndpoint(endpoint) { + fileName = edgeStack.ManifestPath + + if fileName == "" { + return httperror.BadRequest("Kubernetes is not supported by this stack", fmt.Errorf("no filename is provided for the Kubernetes endpoint. Environment ID: %d", endpoint.ID)) + } + } + + dirEntries, err := filesystem.LoadDir(edgeStack.ProjectPath) + if err != nil { + return httperror.InternalServerError("Unable to load repository", fmt.Errorf("failed to load project directory: %w. Environment ID: %d", err, endpoint.ID)) + } + + fileContent, err := filesystem.FilterDirForCompatibility(dirEntries, fileName, endpoint.Agent.Version) + if err != nil { + return httperror.InternalServerError("File not found", fmt.Errorf("unable to find file: %w. Environment ID: %d", err, endpoint.ID)) + } + + dirEntries = filesystem.FilterDirForEntryFile(dirEntries, fileName) + + return response.JSON(w, edge.StackPayload{ + DirEntries: dirEntries, + EntryFileName: fileName, + StackFileContent: fileContent, + Name: edgeStack.Name, + Namespace: namespace, + CreatedBy: edgeStack.CreatedBy, + CreatedByUserId: edgeStack.CreatedByUserId, + }) +} diff --git a/api/http/handler/endpointedge/endpointedge_status_inspect.go b/api/http/handler/endpointedge/endpointedge_status_inspect.go new file mode 100644 index 0000000..23d5434 --- /dev/null +++ b/api/http/handler/endpointedge/endpointedge_status_inspect.go @@ -0,0 +1,351 @@ +package endpointedge + +import ( + "bytes" + "cmp" + "encoding/base64" + "errors" + "fmt" + "hash/fnv" + "io" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "time" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/internal/edge" + "github.com/portainer/portainer/api/internal/edge/cache" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" + "github.com/rs/zerolog/log" +) + +type stackStatusResponse struct { + // EdgeStack Identifier + ID portainer.EdgeStackID `example:"1"` + // Version of this stack + Version int `example:"3"` +} + +type edgeJobResponse struct { + // EdgeJob Identifier + ID portainer.EdgeJobID `json:"Id" example:"2"` + // Whether to collect logs + CollectLogs bool `json:"CollectLogs" example:"true"` + // A cron expression to schedule this job + CronExpression string `json:"CronExpression" example:"* * * * *"` + // Script to run + Script string `json:"Script" example:"echo hello"` + // Version of this EdgeJob + Version int `json:"Version" example:"2"` +} + +type endpointEdgeStatusInspectResponse struct { + // Status represents the environment(endpoint) status + Status string `json:"status" example:"REQUIRED"` + // The tunnel port + Port int `json:"port" example:"8732"` + // List of requests for jobs to run on the environment(endpoint) + Schedules []edgeJobResponse `json:"schedules"` + // The current value of CheckinInterval + CheckinInterval int `json:"checkin" example:"5"` + // + Credentials string `json:"credentials"` + // List of stacks to be deployed on the environments(endpoints) + Stacks []stackStatusResponse `json:"stacks"` +} + +// @id EndpointEdgeStatusInspect +// @summary Get environment status +// @description Endpoint for edge agent to check status of environment +// @description **Access policy**: Edge agent only — requires X-PortainerAgent-EdgeID header +// @tags edge_agent +// @security ApiKeyAuth +// @security jwt +// @param id path int true "Environment identifier" +// @success 200 {object} endpointEdgeStatusInspectResponse "Success" +// @failure 400 "Invalid request" +// @failure 403 "Permission denied to access environment" +// @failure 404 "Environment not found" +// @failure 500 "Server error" +// @router /endpoints/{id}/edge/status [get] +func (handler *Handler) endpointEdgeStatusInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid environment identifier route variable", err) + } + + if cachedResp := handler.respondFromCache(w, r, portainer.EndpointID(endpointID)); cachedResp { + return nil + } + + if _, ok := handler.DataStore.Endpoint().Heartbeat(portainer.EndpointID(endpointID)); !ok { + // EE-5190 + return httperror.Forbidden("Permission denied to access environment. The device has not been trusted yet", fmt.Errorf("unable to retrieve endpoint heartbeat. Environment ID: %d", endpointID)) + } + + endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) + if err != nil { + // EE-5190 + return httperror.Forbidden("Permission denied to access environment. The device has not been trusted yet", fmt.Errorf("unable to retrieve endpoint from database: %w. Environment ID: %d", err, endpointID)) + } + + firstConn := endpoint.LastCheckInDate == 0 + + if err := handler.requestBouncer.AuthorizedEdgeEndpointOperation(r, endpoint); err != nil { + return httperror.Forbidden("Permission denied to access environment. The device has not been trusted yet", fmt.Errorf("unauthorized Edge endpoint operation: %w. Environment ID: %d", err, endpoint.ID)) + } + + handler.DataStore.Endpoint().UpdateHeartbeat(endpoint.ID) + + if err := handler.requestBouncer.TrustedEdgeEnvironmentAccess(handler.DataStore, endpoint); err != nil { + return httperror.Forbidden("Permission denied to access environment. The device has not been trusted yet", fmt.Errorf("untrusted Edge environment access: %w. Environment ID: %d", err, endpoint.ID)) + } + + var statusResponse *endpointEdgeStatusInspectResponse + if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + statusResponse, err = handler.inspectStatus(tx, r, portainer.EndpointID(endpointID), firstConn) + return err + }); err != nil { + var httpErr *httperror.HandlerError + if errors.As(err, &httpErr) { + httpErr.Err = fmt.Errorf("edge polling error: %w. Environment ID: %d", httpErr.Err, endpoint.ID) + return httpErr + } + + return httperror.InternalServerError("Unexpected error", fmt.Errorf("edge polling error: %w. Environment ID: %d", err, endpoint.ID)) + } + + return cacheResponse(w, endpoint.ID, *statusResponse) +} + +func (handler *Handler) parseHeaders(r *http.Request, endpoint *portainer.Endpoint) error { + endpoint.EdgeID = cmp.Or(endpoint.EdgeID, r.Header.Get(portainer.PortainerAgentEdgeIDHeader)) + + agentPlatform, agentPlatformErr := parseAgentPlatform(r) + if agentPlatformErr != nil { + return httperror.BadRequest("agent platform header is not valid", agentPlatformErr) + } + endpoint.Type = agentPlatform + + version := r.Header.Get(portainer.PortainerAgentHeader) + endpoint.Agent.Version = version + + if gpuOperatorHeader := r.Header.Get(portainer.HTTPResponseAgentGPUOperator); gpuOperatorHeader != "" { + endpoint.Kubernetes.Flags.GPUOperator = gpuOperatorHeader == "true" + } + + return nil +} + +func (handler *Handler) inspectStatus(tx dataservices.DataStoreTx, r *http.Request, endpointID portainer.EndpointID, firstConn bool) (*endpointEdgeStatusInspectResponse, error) { + endpoint, err := tx.Endpoint().Endpoint(endpointID) + if err != nil { + return nil, err + } + + if err := handler.parseHeaders(r, endpoint); err != nil { + return nil, err + } + + // Take an initial snapshot + if firstConn { + if err := handler.ReverseTunnelService.Open(endpoint); err != nil { + log.Error().Err(err).Msg("could not open the tunnel") + } + } + + endpoint.LastCheckInDate = time.Now().Unix() + + if err := tx.Endpoint().UpdateEndpoint(endpoint.ID, endpoint); err != nil { + return nil, httperror.InternalServerError("Unable to persist environment changes inside the database", err) + } + + tunnel := handler.ReverseTunnelService.Config(endpoint.ID) + + statusResponse := endpointEdgeStatusInspectResponse{ + Status: tunnel.Status, + Port: tunnel.Port, + CheckinInterval: edge.EffectiveCheckinInterval(tx, endpoint), + Credentials: tunnel.Credentials, + } + + schedules, handlerErr := handler.buildAllSchedules(tx, endpoint) + if handlerErr != nil { + return nil, handlerErr + } + statusResponse.Schedules = schedules + + edgeStacksStatus, handlerErr := handler.buildEdgeStacks(tx, endpoint.ID) + if handlerErr != nil { + return nil, handlerErr + } + statusResponse.Stacks = edgeStacksStatus + + return &statusResponse, nil +} + +func parseAgentPlatform(r *http.Request) (portainer.EndpointType, error) { + agentPlatformHeader := r.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 + } + + agentPlatform := portainer.AgentPlatform(agentPlatformNumber) + + switch agentPlatform { + case portainer.AgentPlatformDocker: + return portainer.EdgeAgentOnDockerEnvironment, nil + case portainer.AgentPlatformKubernetes: + return portainer.EdgeAgentOnKubernetesEnvironment, nil + default: + return 0, fmt.Errorf("agent platform %v is not valid", agentPlatform) + } +} + +func (handler *Handler) buildAllSchedules(tx dataservices.DataStoreTx, endpoint *portainer.Endpoint) ([]edgeJobResponse, *httperror.HandlerError) { + edgeJobs, err := tx.EdgeJob().ReadAll() + if err != nil { + return nil, httperror.InternalServerError("Unable to retrieve Edge Jobs", err) + } + + return handler.buildSchedules(tx, endpoint, edgeJobs) +} + +func (handler *Handler) buildSchedules(tx dataservices.DataStoreTx, endpoint *portainer.Endpoint, edgeJobs []portainer.EdgeJob) ([]edgeJobResponse, *httperror.HandlerError) { + schedules := []edgeJobResponse{} + + endpointGroups, err := tx.EndpointGroup().ReadAll() + if err != nil { + return nil, httperror.InternalServerError("Unable to retrieve endpoint groups", err) + } + + for _, job := range edgeJobs { + _, endpointHasJob := job.Endpoints[endpoint.ID] + if !endpointHasJob { + for _, edgeGroupID := range job.EdgeGroups { + member, _, err := edge.EndpointInEdgeGroup(tx, endpoint, edgeGroupID, endpointGroups) + if err != nil { + return nil, httperror.InternalServerError("Unable to retrieve relations", err) + } else if member { + endpointHasJob = true + + break + } + } + } + + if !endpointHasJob { + continue + } + + schedule := edgeJobResponse{ + ID: job.ID, + CronExpression: job.CronExpression, + CollectLogs: job.GroupLogsCollection[endpoint.ID].CollectLogs || job.Endpoints[endpoint.ID].CollectLogs, + Version: job.Version, + } + + file, err := handler.FileService.GetFileContent(job.ScriptPath, "") + if err != nil { + return nil, httperror.InternalServerError("Unable to retrieve Edge job script file", err) + } + schedule.Script = base64.RawStdEncoding.EncodeToString(file) + + schedules = append(schedules, schedule) + } + + return schedules, nil +} + +func (handler *Handler) buildEdgeStacks(tx dataservices.DataStoreTx, endpointID portainer.EndpointID) ([]stackStatusResponse, *httperror.HandlerError) { + relation, err := tx.EndpointRelation().EndpointRelation(endpointID) + if err != nil { + return nil, httperror.InternalServerError("Unable to retrieve relation object from the database", err) + } + + edgeStacksStatus := []stackStatusResponse{} + for stackID := range relation.EdgeStacks { + version, ok := tx.EdgeStack().EdgeStackVersion(stackID) + if !ok { + return nil, httperror.InternalServerError("Unable to retrieve edge stack from the database", err) + } + + stackStatus := stackStatusResponse{ + ID: stackID, + Version: version, + } + + edgeStacksStatus = append(edgeStacksStatus, stackStatus) + } + + return edgeStacksStatus, nil +} + +func cacheResponse(w http.ResponseWriter, endpointID portainer.EndpointID, statusResponse endpointEdgeStatusInspectResponse) *httperror.HandlerError { + rr := httptest.NewRecorder() + + if err := response.JSON(rr, statusResponse); err != nil { + return err + } + + h := fnv.New32a() + h.Write(rr.Body.Bytes()) + etag := strconv.FormatUint(uint64(h.Sum32()), 16) + + cache.Set(endpointID, []byte(etag)) + + resp := rr.Result() + + for k, vs := range resp.Header { + for _, v := range vs { + w.Header().Add(k, v) + } + } + + w.Header().Set("ETag", etag) + + if _, err := io.Copy(w, resp.Body); err != nil { + log.Warn().Err(err).Msg("failed to copy response body") + } + + return nil +} + +func (handler *Handler) respondFromCache(w http.ResponseWriter, r *http.Request, endpointID portainer.EndpointID) bool { + inmHeader := r.Header.Get("If-None-Match") + etags := strings.Split(inmHeader, ",") + + if len(inmHeader) == 0 || etags[0] == "" { + return false + } + + cachedETag, ok := cache.Get(endpointID) + if !ok { + return false + } + + for _, etag := range etags { + if !bytes.Equal([]byte(etag), cachedETag) { + continue + } + + handler.DataStore.Endpoint().UpdateHeartbeat(endpointID) + + w.Header().Set("ETag", etag) + w.WriteHeader(http.StatusNotModified) + + return true + } + + return false +} diff --git a/api/http/handler/endpointedge/endpointedge_status_inspect_test.go b/api/http/handler/endpointedge/endpointedge_status_inspect_test.go new file mode 100644 index 0000000..8b07f92 --- /dev/null +++ b/api/http/handler/endpointedge/endpointedge_status_inspect_test.go @@ -0,0 +1,440 @@ +package endpointedge + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strconv" + "testing" + "testing/synctest" + "time" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/apikey" + "github.com/portainer/portainer/api/chisel" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/filesystem" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/jwt" + "github.com/portainer/portainer/api/roar" + + "github.com/segmentio/encoding/json" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type endpointTestCase struct { + endpoint portainer.Endpoint + endpointRelation portainer.EndpointRelation + expectedStatusCode int +} + +var endpointTestCases = []endpointTestCase{ + { + portainer.Endpoint{}, + portainer.EndpointRelation{}, + http.StatusForbidden, + }, + { + portainer.Endpoint{ + ID: -1, + Name: "endpoint-id-1", + Type: portainer.EdgeAgentOnDockerEnvironment, + URL: "https://portainer.io:9443", + EdgeID: "edge-id", + }, + portainer.EndpointRelation{ + EndpointID: -1, + }, + http.StatusForbidden, + }, + { + portainer.Endpoint{ + ID: 2, + Name: "endpoint-id-2", + Type: portainer.EdgeAgentOnDockerEnvironment, + URL: "https://portainer.io:9443", + EdgeID: "", + }, + portainer.EndpointRelation{ + EndpointID: 2, + }, + http.StatusForbidden, + }, + { + portainer.Endpoint{ + ID: 4, + Name: "endpoint-id-4", + Type: portainer.EdgeAgentOnDockerEnvironment, + URL: "https://portainer.io:9443", + EdgeID: "edge-id", + }, + portainer.EndpointRelation{ + EndpointID: 4, + }, + http.StatusOK, + }, +} + +func mustSetupHandler(t *testing.T) *Handler { + tmpDir := t.TempDir() + fs, err := filesystem.NewService(tmpDir, "") + if err != nil { + t.Fatalf("could not start a new filesystem service: %s", err) + } + + _, store := datastore.MustNewTestStore(t, true, true) + + jwtService, err := jwt.NewService("1h", store) + if err != nil { + t.Fatalf("could not start a new JWT service: %s", err) + } + + apiKeyService := apikey.NewAPIKeyService(nil, nil) + + settings, err := store.Settings().Settings() + if err != nil { + t.Fatalf("could not create new settings: %s", err) + } + settings.TrustOnFirstConnect = true + + if err = store.Settings().UpdateSettings(settings); err != nil { + t.Fatalf("could not update settings: %s", err) + } + + handler := NewHandler( + security.NewRequestBouncer(t.Context(), store, jwtService, apiKeyService), + store, + fs, + chisel.NewService(store, t.Context(), nil), + ) + + handler.ReverseTunnelService = chisel.NewService(store, t.Context(), nil) + + return handler +} + +func createEndpoint(handler *Handler, endpoint portainer.Endpoint, endpointRelation portainer.EndpointRelation) (err error) { + // Avoid setting ID below 0 to generate invalid test cases + if endpoint.ID <= 0 { + return nil + } + + if err := handler.DataStore.Endpoint().Create(&endpoint); err != nil { + return err + } + + return handler.DataStore.EndpointRelation().Create(&endpointRelation) +} + +func TestMissingEdgeIdentifier(t *testing.T) { + t.Parallel() + handler := mustSetupHandler(t) + endpointID := portainer.EndpointID(45) + + if err := createEndpoint(handler, portainer.Endpoint{ + ID: endpointID, + Name: "endpoint-id-45", + Type: portainer.EdgeAgentOnDockerEnvironment, + URL: "https://portainer.io:9443", + EdgeID: "edge-id", + }, portainer.EndpointRelation{EndpointID: endpointID}); err != nil { + t.Fatal(err) + } + + req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("/api/endpoints/%d/edge/status", endpointID), nil) + if err != nil { + t.Fatal("request error:", err) + } + + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusForbidden { + t.Fatalf("expected a %d response, found: %d without Edge identifier", http.StatusForbidden, rec.Code) + } +} + +func TestWithEndpoints(t *testing.T) { + t.Parallel() + handler := mustSetupHandler(t) + + for _, test := range endpointTestCases { + err := createEndpoint(handler, test.endpoint, test.endpointRelation) + if err != nil { + t.Fatal(err) + } + + req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("/api/endpoints/%d/edge/status", test.endpoint.ID), nil) + if err != nil { + t.Fatal("request error:", err) + } + + req.Header.Set(portainer.PortainerAgentEdgeIDHeader, test.endpoint.EdgeID) + req.Header.Set(portainer.HTTPResponseAgentPlatform, "1") + + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != test.expectedStatusCode { + t.Fatalf("expected a %d response, found: %d for endpoint ID: %d", test.expectedStatusCode, rec.Code, test.endpoint.ID) + } + } +} + +func TestLastCheckInDateIncreases(t *testing.T) { + t.Parallel() + synctest.Test(t, testLastCheckInDateIncreases) +} + +func testLastCheckInDateIncreases(t *testing.T) { + handler := mustSetupHandler(t) + + endpointID := portainer.EndpointID(56) + endpoint := portainer.Endpoint{ + ID: endpointID, + Name: "test-endpoint-56", + Type: portainer.EdgeAgentOnDockerEnvironment, + URL: "https://portainer.io:9443", + EdgeID: "edge-id", + LastCheckInDate: time.Now().Unix(), + } + + endpointRelation := portainer.EndpointRelation{ + EndpointID: endpoint.ID, + } + + if err := createEndpoint(handler, endpoint, endpointRelation); err != nil { + t.Fatal(err) + } + + time.Sleep(1 * time.Second) + + req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("/api/endpoints/%d/edge/status", endpoint.ID), nil) + if err != nil { + t.Fatal("request error:", err) + } + + req.Header.Set(portainer.PortainerAgentEdgeIDHeader, "edge-id") + req.Header.Set(portainer.HTTPResponseAgentPlatform, "1") + + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected a %d response, found: %d", http.StatusOK, rec.Code) + } + + updatedEndpoint, err := handler.DataStore.Endpoint().Endpoint(endpoint.ID) + if err != nil { + t.Fatal(err) + } + + assert.Greater(t, updatedEndpoint.LastCheckInDate, endpoint.LastCheckInDate) +} + +func TestEmptyEdgeIdWithAgentPlatformHeader(t *testing.T) { + t.Parallel() + handler := mustSetupHandler(t) + + endpointID := portainer.EndpointID(44) + edgeId := "edge-id" + endpoint := portainer.Endpoint{ + ID: endpointID, + Name: "test-endpoint-44", + Type: portainer.EdgeAgentOnDockerEnvironment, + URL: "https://portainer.io:9443", + EdgeID: "", + } + endpointRelation := portainer.EndpointRelation{ + EndpointID: endpoint.ID, + } + + if err := createEndpoint(handler, endpoint, endpointRelation); err != nil { + t.Fatal(err) + } + + req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("/api/endpoints/%d/edge/status", endpoint.ID), nil) + if err != nil { + t.Fatal("request error:", err) + } + + req.Header.Set(portainer.PortainerAgentEdgeIDHeader, edgeId) + req.Header.Set(portainer.HTTPResponseAgentPlatform, "1") + + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected a %d response, found: %d with empty edge ID", http.StatusOK, rec.Code) + } + + updatedEndpoint, err := handler.DataStore.Endpoint().Endpoint(endpoint.ID) + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, updatedEndpoint.EdgeID, edgeId) +} + +func TestEdgeStackStatus(t *testing.T) { + t.Parallel() + handler := mustSetupHandler(t) + + endpointID := portainer.EndpointID(7) + endpoint := portainer.Endpoint{ + ID: endpointID, + Name: "test-endpoint-7", + Type: portainer.EdgeAgentOnDockerEnvironment, + URL: "https://portainer.io:9443", + EdgeID: "edge-id", + LastCheckInDate: time.Now().Unix(), + } + + edgeStackID := portainer.EdgeStackID(17) + edgeStack := portainer.EdgeStack{ + ID: edgeStackID, + Name: "test-edge-stack-17", + CreationDate: time.Now().Unix(), + EdgeGroups: []portainer.EdgeGroupID{1, 2}, + ProjectPath: "/project/path", + EntryPoint: "entrypoint", + Version: 237, + ManifestPath: "/manifest/path", + DeploymentType: 1, + } + + endpointRelation := portainer.EndpointRelation{ + EndpointID: endpoint.ID, + EdgeStacks: map[portainer.EdgeStackID]bool{ + edgeStack.ID: true, + }, + } + + err := handler.DataStore.EdgeStack().Create(edgeStack.ID, &edgeStack) + require.NoError(t, err) + + if err := createEndpoint(handler, endpoint, endpointRelation); err != nil { + t.Fatal(err) + } + + req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("/api/endpoints/%d/edge/status", endpoint.ID), nil) + if err != nil { + t.Fatal("request error:", err) + } + + req.Header.Set(portainer.PortainerAgentEdgeIDHeader, "edge-id") + req.Header.Set(portainer.HTTPResponseAgentPlatform, "1") + + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected a %d response, found: %d", http.StatusOK, rec.Code) + } + + var data endpointEdgeStatusInspectResponse + if err := json.NewDecoder(rec.Body).Decode(&data); err != nil { + t.Fatal("error decoding response:", err) + } + + assert.Len(t, data.Stacks, 1) + assert.Equal(t, edgeStack.ID, data.Stacks[0].ID) + assert.Equal(t, edgeStack.Version, data.Stacks[0].Version) +} + +func TestEdgeJobsResponse(t *testing.T) { + t.Parallel() + handler := mustSetupHandler(t) + + localCreateEndpoint := func(endpointID portainer.EndpointID, tagIDs []portainer.TagID) *portainer.Endpoint { + endpoint := portainer.Endpoint{ + ID: endpointID, + Name: "test-endpoint-" + strconv.Itoa(int(endpointID)), + Type: portainer.EdgeAgentOnDockerEnvironment, + URL: "https://portainer.io:9443", + EdgeID: "edge-id-" + strconv.Itoa(int(endpointID)), + TagIDs: tagIDs, + LastCheckInDate: time.Now().Unix(), + UserTrusted: true, + } + err := createEndpoint(handler, endpoint, + portainer.EndpointRelation{EndpointID: endpointID}) + require.NoError(t, err) + + return &endpoint + } + + dynamicGroupTags := []portainer.TagID{1, 2, 3} + + endpoint := localCreateEndpoint(77, nil) + endpointFromStaticEdgeGroup := localCreateEndpoint(78, nil) + endpointFromDynamicEdgeGroup := localCreateEndpoint(79, dynamicGroupTags) + unrelatedEndpoint := localCreateEndpoint(80, nil) + + staticEdgeGroup := portainer.EdgeGroup{ + ID: 1, + EndpointIDs: roar.FromSlice([]portainer.EndpointID{endpointFromStaticEdgeGroup.ID}), + } + err := handler.DataStore.EdgeGroup().Create(&staticEdgeGroup) + require.NoError(t, err) + + dynamicEdgeGroup := portainer.EdgeGroup{ + ID: 2, + Dynamic: true, + TagIDs: dynamicGroupTags, + } + err = handler.DataStore.EdgeGroup().Create(&dynamicEdgeGroup) + require.NoError(t, err) + + path, err := handler.FileService.StoreEdgeJobFileFromBytes("test-script", []byte("pwd")) + require.NoError(t, err) + + edgeJobID := portainer.EdgeJobID(35) + edgeJob := portainer.EdgeJob{ + ID: edgeJobID, + Created: time.Now().Unix(), + CronExpression: "* * * * *", + Name: "test-edge-job", + ScriptPath: path, + Recurring: true, + Version: 57, + Endpoints: map[portainer.EndpointID]portainer.EdgeJobEndpointMeta{ + endpoint.ID: {}, + }, + EdgeGroups: []portainer.EdgeGroupID{staticEdgeGroup.ID, dynamicEdgeGroup.ID}, + } + + err = handler.DataStore.EdgeJob().Create(&edgeJob) + require.NoError(t, err) + + f := func(endpoint *portainer.Endpoint, scheduleLen int) { + req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("/api/endpoints/%d/edge/status", endpoint.ID), nil) + require.NoError(t, err) + + req.Header.Set(portainer.PortainerAgentEdgeIDHeader, endpoint.EdgeID) + req.Header.Set(portainer.HTTPResponseAgentPlatform, "1") + + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + + var data endpointEdgeStatusInspectResponse + err = json.NewDecoder(rec.Body).Decode(&data) + require.NoError(t, err) + + require.Len(t, data.Schedules, scheduleLen) + + if scheduleLen > 0 { + require.Equal(t, edgeJob.ID, data.Schedules[0].ID) + require.Equal(t, edgeJob.CronExpression, data.Schedules[0].CronExpression) + require.Equal(t, edgeJob.Version, data.Schedules[0].Version) + } + } + + f(endpoint, 1) + f(endpointFromStaticEdgeGroup, 1) + f(endpointFromDynamicEdgeGroup, 1) + f(unrelatedEndpoint, 0) +} diff --git a/api/http/handler/endpointedge/handler.go b/api/http/handler/endpointedge/handler.go new file mode 100644 index 0000000..626217c --- /dev/null +++ b/api/http/handler/endpointedge/handler.go @@ -0,0 +1,46 @@ +package endpointedge + +import ( + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/http/middlewares" + "github.com/portainer/portainer/api/http/security" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + + "github.com/gorilla/mux" +) + +// Handler is the HTTP handler used to handle edge environment(endpoint) operations. +type Handler struct { + *mux.Router + requestBouncer security.BouncerService + DataStore dataservices.DataStore + FileService portainer.FileService + ReverseTunnelService portainer.ReverseTunnelService +} + +// NewHandler creates a handler to manage environment(endpoint) operations. +func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStore, fileService portainer.FileService, reverseTunnelService portainer.ReverseTunnelService) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + requestBouncer: bouncer, + DataStore: dataStore, + FileService: fileService, + ReverseTunnelService: reverseTunnelService, + } + + h.Handle("/api/endpoints/{id}/edge/status", bouncer.PublicAccess(httperror.LoggerHandler(h.endpointEdgeStatusInspect))).Methods(http.MethodGet) + + endpointRouter := h.PathPrefix("/api/endpoints/{id}").Subrouter() + endpointRouter.Use(middlewares.WithEndpoint(dataStore.Endpoint(), "id")) + + endpointRouter.PathPrefix("/edge/stacks/{stackId}").Handler( + bouncer.PublicAccess(httperror.LoggerHandler(h.endpointEdgeStackInspect))).Methods(http.MethodGet) + + endpointRouter.PathPrefix("/edge/jobs/{jobID}/logs").Handler( + 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 new file mode 100644 index 0000000..59f00ab --- /dev/null +++ b/api/http/handler/endpointgroups/endpointgroup_create.go @@ -0,0 +1,120 @@ +package endpointgroups + +import ( + "errors" + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +type endpointGroupCreatePayload struct { + // Environment(Endpoint) group name + Name string `validate:"required" example:"my-environment-group"` + // Environment(Endpoint) group description + Description string `example:"description"` + // List of environment(endpoint) identifiers that will be part of this group + AssociatedEndpoints []portainer.EndpointID `example:"1,3"` + // List of tag identifiers to which this environment(endpoint) group is associated + TagIDs []portainer.TagID `example:"1,2"` +} + +func (payload *endpointGroupCreatePayload) Validate(r *http.Request) error { + if len(payload.Name) == 0 { + return errors.New("invalid environment group name") + } + + if payload.TagIDs == nil { + payload.TagIDs = []portainer.TagID{} + } + + return nil +} + +// @summary Create an Environment(Endpoint) Group +// @description Create a new environment(endpoint) group. +// @description **Access policy**: administrator +// @tags endpoint_groups +// @security ApiKeyAuth +// @security jwt +// @accept json +// @produce json +// @param body body endpointGroupCreatePayload true "Environment(Endpoint) Group details" +// @success 200 {object} portainer.EndpointGroup "Success" +// @failure 400 "Invalid request" +// @failure 500 "Server error" +// @router /endpoint_groups [post] +func (handler *Handler) endpointGroupCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload endpointGroupCreatePayload + if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + var endpointGroup *portainer.EndpointGroup + var err error + err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + endpointGroup, err = handler.createEndpointGroup(tx, payload) + return err + }) + + return response.TxResponse(w, endpointGroup, err) +} + +func (handler *Handler) createEndpointGroup(tx dataservices.DataStoreTx, payload endpointGroupCreatePayload) (*portainer.EndpointGroup, error) { + endpointGroup := &portainer.EndpointGroup{ + Name: payload.Name, + Description: payload.Description, + UserAccessPolicies: portainer.UserAccessPolicies{}, + TeamAccessPolicies: portainer.TeamAccessPolicies{}, + TagIDs: payload.TagIDs, + } + + err := tx.EndpointGroup().Create(endpointGroup) + if err != nil { + return nil, httperror.InternalServerError("Unable to persist the environment group inside the database", err) + } + + endpoints, err := tx.Endpoint().Endpoints() + if err != nil { + return nil, httperror.InternalServerError("Unable to retrieve environments from the database", err) + } + + for _, id := range payload.AssociatedEndpoints { + for _, endpoint := range endpoints { + if endpoint.ID == id { + endpoint.GroupID = endpointGroup.ID + + err := tx.Endpoint().UpdateEndpoint(endpoint.ID, &endpoint) + if err != nil { + return nil, httperror.InternalServerError("Unable to update environment", err) + } + + err = handler.updateEndpointRelations(tx, &endpoint, endpointGroup) + if err != nil { + return nil, httperror.InternalServerError("Unable to persist environment relations changes inside the database", err) + } + + break + } + } + } + + for _, tagID := range endpointGroup.TagIDs { + tag, err := tx.Tag().Read(tagID) + if err != nil { + return nil, httperror.InternalServerError("Unable to find a tag inside the database", err) + } + + tag.EndpointGroups[endpointGroup.ID] = true + + err = tx.Tag().Update(tagID, tag) + if err != nil { + return nil, httperror.InternalServerError("Unable to persist tag changes inside the database", err) + } + } + + return endpointGroup, nil +} diff --git a/api/http/handler/endpointgroups/endpointgroup_delete.go b/api/http/handler/endpointgroups/endpointgroup_delete.go new file mode 100644 index 0000000..9d6cddf --- /dev/null +++ b/api/http/handler/endpointgroups/endpointgroup_delete.go @@ -0,0 +1,90 @@ +package endpointgroups + +import ( + "errors" + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +// @id EndpointGroupDelete +// @summary Remove an environment(endpoint) group +// @description Remove an environment(endpoint) group. +// @description **Access policy**: administrator +// @tags endpoint_groups +// @security ApiKeyAuth +// @security jwt +// @param id path int true "EndpointGroup identifier" +// @success 204 "Success" +// @failure 400 "Invalid request" +// @failure 404 "EndpointGroup not found" +// @failure 500 "Server error" +// @router /endpoint_groups/{id} [delete] +func (handler *Handler) endpointGroupDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpointGroupID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid environment group identifier route variable", err) + } + + if endpointGroupID == 1 { + return httperror.Forbidden("Unable to remove the default 'Unassigned' group", errors.New("Cannot remove the default environment group")) + } + + err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + return handler.deleteEndpointGroup(tx, portainer.EndpointGroupID(endpointGroupID)) + }) + + return response.TxEmptyResponse(w, err) +} + +func (handler *Handler) deleteEndpointGroup(tx dataservices.DataStoreTx, endpointGroupID portainer.EndpointGroupID) error { + endpointGroup, err := tx.EndpointGroup().Read(endpointGroupID) + if tx.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find an environment group with the specified identifier inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find an environment group with the specified identifier inside the database", err) + } + + if err := tx.EndpointGroup().Delete(endpointGroupID); err != nil { + return httperror.InternalServerError("Unable to remove the environment group from the database", err) + } + + endpoints, err := tx.Endpoint().Endpoints() + if err != nil { + return httperror.InternalServerError("Unable to retrieve environment from the database", err) + } + + for _, endpoint := range endpoints { + if endpoint.GroupID != endpointGroupID { + continue + } + + endpoint.GroupID = portainer.EndpointGroupID(1) + if err := tx.Endpoint().UpdateEndpoint(endpoint.ID, &endpoint); err != nil { + return httperror.InternalServerError("Unable to update environment", err) + } + + if err := handler.updateEndpointRelations(tx, &endpoint, nil); err != nil { + return httperror.InternalServerError("Unable to persist environment relations changes inside the database", err) + } + } + + for _, tagID := range endpointGroup.TagIDs { + tag, err := tx.Tag().Read(tagID) + if err != nil { + return httperror.InternalServerError("Unable to find a tag inside the database", err) + } + + delete(tag.EndpointGroups, endpointGroup.ID) + + if err := tx.Tag().Update(tagID, tag); err != nil { + return httperror.InternalServerError("Unable to persist tag changes inside the database", err) + } + } + + return nil +} diff --git a/api/http/handler/endpointgroups/endpointgroup_endpoint_add.go b/api/http/handler/endpointgroups/endpointgroup_endpoint_add.go new file mode 100644 index 0000000..ba66564 --- /dev/null +++ b/api/http/handler/endpointgroups/endpointgroup_endpoint_add.go @@ -0,0 +1,73 @@ +package endpointgroups + +import ( + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +// @id EndpointGroupAddEndpoint +// @summary Add an environment(endpoint) to an environment(endpoint) group +// @description Add an environment(endpoint) to an environment(endpoint) group +// @description **Access policy**: administrator +// @tags endpoint_groups +// @security ApiKeyAuth +// @security jwt +// @param id path int true "EndpointGroup identifier" +// @param endpointId path int true "Environment(Endpoint) identifier" +// @success 204 "Success" +// @failure 400 "Invalid request" +// @failure 404 "EndpointGroup not found" +// @failure 500 "Server error" +// @router /endpoint_groups/{id}/endpoints/{endpointId} [put] +func (handler *Handler) endpointGroupAddEndpoint(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpointGroupID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid environment group identifier route variable", err) + } + + endpointID, err := request.RetrieveNumericRouteVariableValue(r, "endpointId") + if err != nil { + return httperror.BadRequest("Invalid environment identifier route variable", err) + } + + err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + return handler.addEndpoint(tx, portainer.EndpointGroupID(endpointGroupID), portainer.EndpointID(endpointID)) + }) + + return response.TxEmptyResponse(w, err) +} + +func (handler *Handler) addEndpoint(tx dataservices.DataStoreTx, endpointGroupID portainer.EndpointGroupID, endpointID portainer.EndpointID) error { + endpointGroup, err := tx.EndpointGroup().Read(endpointGroupID) + if tx.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find an environment group with the specified identifier inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find an environment group with the specified identifier inside the database", err) + } + + endpoint, err := tx.Endpoint().Endpoint(endpointID) + if tx.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find an environment with the specified identifier inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find an environment with the specified identifier inside the database", err) + } + + endpoint.GroupID = endpointGroup.ID + + err = tx.Endpoint().UpdateEndpoint(endpoint.ID, endpoint) + if err != nil { + return httperror.InternalServerError("Unable to persist environment changes inside the database", err) + } + + err = handler.updateEndpointRelations(tx, endpoint, endpointGroup) + if err != nil { + return httperror.InternalServerError("Unable to persist environment relations changes inside the database", err) + } + + return nil +} diff --git a/api/http/handler/endpointgroups/endpointgroup_endpoint_delete.go b/api/http/handler/endpointgroups/endpointgroup_endpoint_delete.go new file mode 100644 index 0000000..3ec4e65 --- /dev/null +++ b/api/http/handler/endpointgroups/endpointgroup_endpoint_delete.go @@ -0,0 +1,73 @@ +package endpointgroups + +import ( + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + dserrors "github.com/portainer/portainer/api/dataservices/errors" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +// @id EndpointGroupDeleteEndpoint +// @summary Removes environment(endpoint) from an environment(endpoint) group +// @description **Access policy**: administrator +// @tags endpoint_groups +// @security ApiKeyAuth +// @security jwt +// @param id path int true "EndpointGroup identifier" +// @param endpointId path int true "Environment(Endpoint) identifier" +// @success 204 "Success" +// @failure 400 "Invalid request" +// @failure 404 "EndpointGroup not found" +// @failure 500 "Server error" +// @router /endpoint_groups/{id}/endpoints/{endpointId} [delete] +func (handler *Handler) endpointGroupDeleteEndpoint(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpointGroupID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid environment group identifier route variable", err) + } + + endpointID, err := request.RetrieveNumericRouteVariableValue(r, "endpointId") + if err != nil { + return httperror.BadRequest("Invalid environment identifier route variable", err) + } + + err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + return handler.removeEndpoint(tx, portainer.EndpointGroupID(endpointGroupID), portainer.EndpointID(endpointID)) + }) + + return response.TxEmptyResponse(w, err) +} + +func (handler *Handler) removeEndpoint(tx dataservices.DataStoreTx, endpointGroupID portainer.EndpointGroupID, endpointID portainer.EndpointID) error { + ok, err := tx.EndpointGroup().Exists(endpointGroupID) + if !ok { + return httperror.NotFound("Unable to find an environment group with the specified identifier inside the database", dserrors.ErrObjectNotFound) + } else if err != nil { + return httperror.InternalServerError("Unable to find an environment group with the specified identifier inside the database", err) + } + + endpoint, err := tx.Endpoint().Endpoint(endpointID) + if tx.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find an environment with the specified identifier inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find an environment with the specified identifier inside the database", err) + } + + endpoint.GroupID = portainer.EndpointGroupID(1) + + err = tx.Endpoint().UpdateEndpoint(endpoint.ID, endpoint) + if err != nil { + return httperror.InternalServerError("Unable to persist environment changes inside the database", err) + } + + err = handler.updateEndpointRelations(tx, endpoint, nil) + if err != nil { + return httperror.InternalServerError("Unable to persist environment relations changes inside the database", err) + } + + return nil +} diff --git a/api/http/handler/endpointgroups/endpointgroup_inspect.go b/api/http/handler/endpointgroups/endpointgroup_inspect.go new file mode 100644 index 0000000..a9483a4 --- /dev/null +++ b/api/http/handler/endpointgroups/endpointgroup_inspect.go @@ -0,0 +1,72 @@ +package endpointgroups + +import ( + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +// @summary Inspect an Environment(Endpoint) group +// @description Retrieve details abont an environment(endpoint) group. +// @description **Access policy**: administrator +// @tags endpoint_groups +// @security ApiKeyAuth +// @security jwt +// @accept json +// @produce json +// @param id path int true "Environment(Endpoint) group identifier" +// @param size query boolean false "If true, include the number of environments and breakdown by type" +// @success 200 {object} EndpointGroupResponse "Success" +// @failure 400 "Invalid request" +// @failure 404 "EndpointGroup not found" +// @failure 500 "Server error" +// @router /endpoint_groups/{id} [get] +func (handler *Handler) endpointGroupInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpointGroupID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid environment group identifier route variable", err) + } + + includeSize, err := request.RetrieveBooleanQueryParameter(r, "size", true) + if err != nil { + return httperror.BadRequest("Invalid query parameter: size", err) + } + + groupID := portainer.EndpointGroupID(endpointGroupID) + + var endpointGroup *portainer.EndpointGroup + var endpoints []portainer.Endpoint + + if err := handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error { + var err error + endpointGroup, err = tx.EndpointGroup().Read(groupID) + if err != nil { + return err + } + if includeSize { + endpoints, err = tx.Endpoint().Endpoints() + } + return err + }); err != nil { + if handler.DataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find an environment group with the specified identifier inside the database", err) + } + return httperror.InternalServerError("Unable to retrieve environment group details", err) + } + + resp := EndpointGroupResponse{ + EndpointGroup: *endpointGroup, + } + + if includeSize { + countMap, typeInfoMap := computeGroupSizeInfo([]portainer.EndpointGroup{*endpointGroup}, endpoints) + resp.Total = countMap[groupID] + resp.TypeInfo = typeInfoMap[groupID] + } + + return response.JSON(w, resp) +} diff --git a/api/http/handler/endpointgroups/endpointgroup_list.go b/api/http/handler/endpointgroups/endpointgroup_list.go new file mode 100644 index 0000000..a574905 --- /dev/null +++ b/api/http/handler/endpointgroups/endpointgroup_list.go @@ -0,0 +1,152 @@ +package endpointgroups + +import ( + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/set" + endpointutils "github.com/portainer/portainer/pkg/endpoints" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +func computeGroupSizeInfo(endpointGroups []portainer.EndpointGroup, endpoints []portainer.Endpoint) (map[portainer.EndpointGroupID]int, map[portainer.EndpointGroupID]EndpointGroupTypeInfo) { + groupSet := set.Set[portainer.EndpointGroupID]{} + for i := range endpointGroups { + groupSet[endpointGroups[i].ID] = true + } + + countMap := make(map[portainer.EndpointGroupID]int) + typeInfoMap := make(map[portainer.EndpointGroupID]EndpointGroupTypeInfo) + + for _, endpoint := range endpoints { + if _, ok := groupSet[endpoint.GroupID]; !ok { + continue + } + countMap[endpoint.GroupID]++ + + typeInfo := typeInfoMap[endpoint.GroupID] + if endpointutils.IsKubernetesEndpoint(&endpoint) { + typeInfo.Kubernetes++ + } else if endpoint.ContainerEngine == portainer.ContainerEnginePodman { + typeInfo.Podman++ + } else { + typeInfo.Docker++ + } + typeInfoMap[endpoint.GroupID] = typeInfo + } + + for groupID, typeInfo := range typeInfoMap { + var bits int + if typeInfo.Docker > 0 { + bits |= 1 + } + if typeInfo.Kubernetes > 0 { + bits |= 2 + } + if typeInfo.Podman > 0 { + bits |= 4 + } + typeInfo.Mixed = bits&(bits-1) != 0 + typeInfoMap[groupID] = typeInfo + } + + return countMap, typeInfoMap +} + +type EndpointGroupTypeInfo struct { + Docker int `json:"Docker" validate:"required"` + Kubernetes int `json:"Kubernetes" validate:"required"` + Podman int `json:"Podman" validate:"required"` + Mixed bool `json:"Mixed" validate:"required"` +} + +type EndpointGroupResponse struct { + portainer.EndpointGroup + Total int `json:"Total,omitzero"` + TypeInfo EndpointGroupTypeInfo `json:"TypeInfo,omitzero"` +} + +// @id EndpointGroupList +// @summary List Environment(Endpoint) groups +// @description List all environment(endpoint) groups based on the current user authorizations. Will +// @description return all environment(endpoint) groups if using an administrator account otherwise it will +// @description only return authorized environment(endpoint) groups. +// @description **Access policy**: restricted +// @tags endpoint_groups +// @security ApiKeyAuth +// @security jwt +// @produce json +// @param size query boolean false "If true, each environment(endpoint) group will include the number of environments(endpoints) associated to it and breakdown by type" +// @success 200 {array} EndpointGroupResponse "Environment(Endpoint) group" +// @failure 500 "Server error" +// @router /endpoint_groups [get] +func (handler *Handler) endpointGroupList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + includeSize, err := request.RetrieveBooleanQueryParameter(r, "size", true) + if err != nil { + return httperror.BadRequest("Invalid query parameter: size", err) + } + + var endpoints []portainer.Endpoint + var endpointGroups []portainer.EndpointGroup + var handlerErr *httperror.HandlerError + if err := handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error { + var txErr error + endpointGroups, txErr = handler.DataStore.EndpointGroup().ReadAll() + if txErr != nil { + handlerErr = httperror.InternalServerError("Unable to retrieve environment groups from the database", txErr) + return handlerErr + } + + if includeSize { + endpoints, txErr = tx.Endpoint().Endpoints() + if txErr != nil { + handlerErr = httperror.InternalServerError("Unable to retrieve endpoints from the database", txErr) + return handlerErr + } + } + + return nil + }); err != nil { + if handlerErr != nil { + return handlerErr + } + return httperror.InternalServerError("Unable to retrieve data from the database", err) + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) + } + + endpointGroups = security.FilterEndpointGroups(endpointGroups, securityContext) + + if len(endpointGroups) == 0 { + return response.JSON(w, []portainer.EndpointGroup{}) + } + + var endpointGroupCountMap map[portainer.EndpointGroupID]int + var endpointGroupTypeInfoMap map[portainer.EndpointGroupID]EndpointGroupTypeInfo + if includeSize { + endpointGroupCountMap, endpointGroupTypeInfoMap = computeGroupSizeInfo(endpointGroups, endpoints) + } + + endpointGroupsResponse := make([]EndpointGroupResponse, len(endpointGroups)) + for i := range endpointGroups { + groupID := endpointGroups[i].ID + + endpointGroupsResponse[i] = EndpointGroupResponse{ + EndpointGroup: endpointGroups[i], + } + + if includeSize { + endpointGroupsResponse[i].Total = endpointGroupCountMap[groupID] + endpointGroupsResponse[i].TypeInfo = endpointGroupTypeInfoMap[groupID] + } + } + + return response.JSON(w, endpointGroupsResponse) +} diff --git a/api/http/handler/endpointgroups/endpointgroup_list_test.go b/api/http/handler/endpointgroups/endpointgroup_list_test.go new file mode 100644 index 0000000..46d2e46 --- /dev/null +++ b/api/http/handler/endpointgroups/endpointgroup_list_test.go @@ -0,0 +1,205 @@ +package endpointgroups + +import ( + "net/http" + "net/http/httptest" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/http/security" + "github.com/segmentio/encoding/json" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHandler_endpointGroupList(t *testing.T) { + _, store := datastore.MustNewTestStore(t, true, false) + handler := setUpHandler(t, store) + + groups := setUpGroups(t, store) + + t.Run("with groups, no size flag", func(t *testing.T) { + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/endpoint_groups", nil) + rrc := &security.RestrictedRequestContext{ + IsAdmin: true, + } + req = req.WithContext(security.StoreRestrictedRequestContext(req, rrc)) + + handler.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + res := make([]EndpointGroupResponse, 0) + require.NoError(t, json.NewDecoder(w.Body).Decode(&res)) + require.Len(t, res, len(groups)+1, "should contain an additional default group") + for _, group := range res { + assert.Zero(t, group.Total) + assert.Zero(t, group.TypeInfo.Docker) + assert.Zero(t, group.TypeInfo.Kubernetes) + assert.Zero(t, group.TypeInfo.Podman) + assert.False(t, group.TypeInfo.Mixed) + } + }) + + t.Run("with size flag, no endpoints", func(t *testing.T) { + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/endpoint_groups?size=true", nil) + rrc := &security.RestrictedRequestContext{ + IsAdmin: true, + } + req = req.WithContext(security.StoreRestrictedRequestContext(req, rrc)) + + handler.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + res := make([]EndpointGroupResponse, 0) + require.NoError(t, json.NewDecoder(w.Body).Decode(&res)) + for _, group := range res { + assert.Zero(t, group.Total) + } + }) + + t.Run("with size flag and single docker endpoint", func(t *testing.T) { + endpoint := &portainer.Endpoint{ + ID: 1, + GroupID: groups[0].ID, + Type: portainer.DockerEnvironment, + } + require.NoError(t, store.Endpoint().Create(endpoint)) + t.Cleanup(func() { _ = store.Endpoint().DeleteEndpoint(endpoint.ID) }) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/endpoint_groups?size=true", nil) + rrc := &security.RestrictedRequestContext{ + IsAdmin: true, + } + req = req.WithContext(security.StoreRestrictedRequestContext(req, rrc)) + + handler.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + res := make([]EndpointGroupResponse, 0) + require.NoError(t, json.NewDecoder(w.Body).Decode(&res)) + + var group1 *EndpointGroupResponse + for i := range res { + if res[i].ID == groups[0].ID { + group1 = &res[i] + break + } + } + require.NotNil(t, group1) + assert.Equal(t, 1, group1.Total) + assert.Equal(t, 1, group1.TypeInfo.Docker) + assert.Equal(t, 0, group1.TypeInfo.Kubernetes) + assert.Equal(t, 0, group1.TypeInfo.Podman) + assert.False(t, group1.TypeInfo.Mixed) + }) + + t.Run("with mixed endpoint types", func(t *testing.T) { + dockerEndpoint := &portainer.Endpoint{ + ID: 2, + GroupID: groups[1].ID, + Type: portainer.DockerEnvironment, + } + require.NoError(t, store.Endpoint().Create(dockerEndpoint)) + t.Cleanup(func() { _ = store.Endpoint().DeleteEndpoint(dockerEndpoint.ID) }) + + k8sEndpoint := &portainer.Endpoint{ + ID: 3, + GroupID: groups[1].ID, + Type: portainer.KubernetesLocalEnvironment, + } + require.NoError(t, store.Endpoint().Create(k8sEndpoint)) + t.Cleanup(func() { _ = store.Endpoint().DeleteEndpoint(k8sEndpoint.ID) }) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/endpoint_groups?size=true", nil) + rrc := &security.RestrictedRequestContext{ + IsAdmin: true, + } + req = req.WithContext(security.StoreRestrictedRequestContext(req, rrc)) + + handler.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + res := make([]EndpointGroupResponse, 0) + require.NoError(t, json.NewDecoder(w.Body).Decode(&res)) + + var group2 *EndpointGroupResponse + for i := range res { + if res[i].ID == groups[1].ID { + group2 = &res[i] + break + } + } + require.NotNil(t, group2) + assert.Equal(t, 2, group2.Total) + assert.Equal(t, 1, group2.TypeInfo.Docker) + assert.Equal(t, 1, group2.TypeInfo.Kubernetes) + assert.Equal(t, 0, group2.TypeInfo.Podman) + assert.True(t, group2.TypeInfo.Mixed, "should be marked as mixed when multiple types exist") + }) + + t.Run("with podman endpoint", func(t *testing.T) { + dockerEndpoint := &portainer.Endpoint{ + ID: 4, + GroupID: groups[0].ID, + Type: portainer.DockerEnvironment, + } + require.NoError(t, store.Endpoint().Create(dockerEndpoint)) + t.Cleanup(func() { _ = store.Endpoint().DeleteEndpoint(dockerEndpoint.ID) }) + + podmanEndpoint := &portainer.Endpoint{ + ID: 5, + GroupID: groups[0].ID, + Type: portainer.DockerEnvironment, + ContainerEngine: portainer.ContainerEnginePodman, + } + require.NoError(t, store.Endpoint().Create(podmanEndpoint)) + t.Cleanup(func() { _ = store.Endpoint().DeleteEndpoint(podmanEndpoint.ID) }) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/endpoint_groups?size=true", nil) + rrc := &security.RestrictedRequestContext{ + IsAdmin: true, + } + req = req.WithContext(security.StoreRestrictedRequestContext(req, rrc)) + + handler.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + res := make([]EndpointGroupResponse, 0) + require.NoError(t, json.NewDecoder(w.Body).Decode(&res)) + + var group1 *EndpointGroupResponse + for i := range res { + if res[i].ID == groups[0].ID { + group1 = &res[i] + break + } + } + require.NotNil(t, group1) + assert.Equal(t, 2, group1.Total) + assert.Equal(t, 1, group1.TypeInfo.Docker) + assert.Equal(t, 0, group1.TypeInfo.Kubernetes) + assert.Equal(t, 1, group1.TypeInfo.Podman) + assert.True(t, group1.TypeInfo.Mixed) + }) +} + +func setUpGroups(t *testing.T, store *datastore.Store) []portainer.EndpointGroup { + group1 := &portainer.EndpointGroup{ + ID: 1, + Name: "Group 1", + } + group2 := &portainer.EndpointGroup{ + ID: 2, + Name: "Group 2", + } + require.NoError(t, store.EndpointGroup().Create(group1)) + require.NoError(t, store.EndpointGroup().Create(group2)) + + return []portainer.EndpointGroup{*group1, *group2} +} diff --git a/api/http/handler/endpointgroups/endpointgroup_update.go b/api/http/handler/endpointgroups/endpointgroup_update.go new file mode 100644 index 0000000..344980e --- /dev/null +++ b/api/http/handler/endpointgroups/endpointgroup_update.go @@ -0,0 +1,224 @@ +package endpointgroups + +import ( + "net/http" + "reflect" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/internal/endpointutils" + "github.com/portainer/portainer/api/pendingactions/handlers" + "github.com/portainer/portainer/api/tag" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" + + "github.com/rs/zerolog/log" +) + +type endpointGroupUpdatePayload struct { + // Environment(Endpoint) group name + Name string `example:"my-environment-group"` + // Environment(Endpoint) group description + Description *string `example:"description"` + // List of environment(endpoint) identifiers that will be part of this group + AssociatedEndpoints []portainer.EndpointID `example:"1,3"` + // List of tag identifiers associated to the environment(endpoint) group + TagIDs []portainer.TagID `example:"3,4"` + UserAccessPolicies portainer.UserAccessPolicies + TeamAccessPolicies portainer.TeamAccessPolicies +} + +func (payload *endpointGroupUpdatePayload) Validate(r *http.Request) error { + return nil +} + +// @id EndpointGroupUpdate +// @summary Update an environment(endpoint) group +// @description Update an environment(endpoint) group. +// @description **Access policy**: administrator +// @tags endpoint_groups +// @security ApiKeyAuth +// @security jwt +// @accept json +// @produce json +// @param id path int true "EndpointGroup identifier" +// @param body body endpointGroupUpdatePayload true "EndpointGroup details" +// @success 200 {object} portainer.EndpointGroup "Success" +// @failure 400 "Invalid request" +// @failure 404 "EndpointGroup not found" +// @failure 500 "Server error" +// @router /endpoint_groups/{id} [put] +func (handler *Handler) endpointGroupUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpointGroupID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid environment group identifier route variable", err) + } + + var payload endpointGroupUpdatePayload + if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + var endpointGroup *portainer.EndpointGroup + + err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + endpointGroup, err = handler.updateEndpointGroup(tx, portainer.EndpointGroupID(endpointGroupID), payload) + return err + }) + + return response.TxResponse(w, endpointGroup, err) +} + +func (handler *Handler) updateEndpointGroup(tx dataservices.DataStoreTx, endpointGroupID portainer.EndpointGroupID, payload endpointGroupUpdatePayload) (*portainer.EndpointGroup, error) { + endpointGroup, err := tx.EndpointGroup().Read(endpointGroupID) + if tx.IsErrObjectNotFound(err) { + return nil, httperror.NotFound("Unable to find an environment group with the specified identifier inside the database", err) + } else if err != nil { + return nil, httperror.InternalServerError("Unable to find an environment group with the specified identifier inside the database", err) + } + + if payload.Name != "" { + endpointGroup.Name = payload.Name + } + + if payload.Description != nil { + endpointGroup.Description = *payload.Description + } + + tagsChanged := false + if payload.TagIDs != nil { + payloadTagSet := tag.Set(payload.TagIDs) + endpointGroupTagSet := tag.Set((endpointGroup.TagIDs)) + union := tag.Union(payloadTagSet, endpointGroupTagSet) + intersection := tag.IntersectionCount(payloadTagSet, endpointGroupTagSet) + tagsChanged = len(union) > intersection + + if tagsChanged { + removeTags := tag.Difference(endpointGroupTagSet, payloadTagSet) + + for tagID := range removeTags { + tag, err := tx.Tag().Read(tagID) + if err != nil { + return nil, httperror.InternalServerError("Unable to find a tag inside the database", err) + } + + delete(tag.EndpointGroups, endpointGroup.ID) + + err = tx.Tag().Update(tagID, tag) + if err != nil { + return nil, httperror.InternalServerError("Unable to persist tag changes inside the database", err) + } + } + + endpointGroup.TagIDs = payload.TagIDs + for _, tagID := range payload.TagIDs { + tag, err := tx.Tag().Read(tagID) + if err != nil { + return nil, httperror.InternalServerError("Unable to find a tag inside the database", err) + } + + tag.EndpointGroups[endpointGroup.ID] = true + + err = tx.Tag().Update(tagID, tag) + if err != nil { + return nil, httperror.InternalServerError("Unable to persist tag changes inside the database", err) + } + } + } + } + + 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 + } + + if updateAuthorizations { + endpoints, err := tx.Endpoint().Endpoints() + if err != nil { + return nil, httperror.InternalServerError("Unable to retrieve environments from the database", err) + } + + for _, endpoint := range endpoints { + if endpoint.GroupID == endpointGroup.ID && endpointutils.IsKubernetesEndpoint(&endpoint) { + if err := handler.AuthorizationService.CleanNAPWithOverridePolicies(tx, &endpoint, endpointGroup); err != nil { + // Update flag with endpoint and continue + if err := handler.PendingActionsService.Create(tx, handlers.NewCleanNAPWithOverridePolicies(endpoint.ID, &endpointGroup.ID)); err != nil { + log.Error().Err(err).Msgf("Unable to create pending action to clean NAP with override policies for endpoint (%d) and endpoint group (%d).", endpoint.ID, endpointGroup.ID) + } + } + } + } + } + + if err := tx.EndpointGroup().Update(endpointGroup.ID, endpointGroup); err != nil { + return nil, httperror.InternalServerError("Unable to persist environment group changes inside the database", err) + } + + // Handle associated endpoints updates + endpointsChanged := false + if payload.AssociatedEndpoints != nil { + endpoints, err := tx.Endpoint().Endpoints() + if err != nil { + return nil, httperror.InternalServerError("Unable to retrieve environments from the database", err) + } + + // Build a set of the new endpoint IDs for quick lookup + newEndpointSet := make(map[portainer.EndpointID]bool) + for _, id := range payload.AssociatedEndpoints { + newEndpointSet[id] = true + } + + for i := range endpoints { + endpoint := &endpoints[i] + wasInGroup := endpoint.GroupID == endpointGroup.ID + shouldBeInGroup := newEndpointSet[endpoint.ID] + + if wasInGroup && !shouldBeInGroup { + // Remove from group (move to Unassigned) + endpoint.GroupID = portainer.EndpointGroupID(1) + if err := tx.Endpoint().UpdateEndpoint(endpoint.ID, endpoint); err != nil { + return nil, httperror.InternalServerError("Unable to update environment", err) + } + if err := handler.updateEndpointRelations(tx, endpoint, nil); err != nil { + return nil, httperror.InternalServerError("Unable to persist environment relations changes inside the database", err) + } + endpointsChanged = true + } else if !wasInGroup && shouldBeInGroup { + // Add to group + endpoint.GroupID = endpointGroup.ID + if err := tx.Endpoint().UpdateEndpoint(endpoint.ID, endpoint); err != nil { + return nil, httperror.InternalServerError("Unable to update environment", err) + } + if err := handler.updateEndpointRelations(tx, endpoint, endpointGroup); err != nil { + return nil, httperror.InternalServerError("Unable to persist environment relations changes inside the database", err) + } + endpointsChanged = true + } + } + } + + // Reconcile endpoints in the group if tags changed (but endpoints weren't already reconciled) + if tagsChanged && !endpointsChanged { + endpoints, err := tx.Endpoint().Endpoints() + if err != nil { + return nil, httperror.InternalServerError("Unable to retrieve environments from the database", err) + } + + for _, endpoint := range endpoints { + if endpoint.GroupID == endpointGroup.ID { + if err := handler.updateEndpointRelations(tx, &endpoint, endpointGroup); err != nil { + return nil, httperror.InternalServerError("Unable to persist environment relations changes inside the database", err) + } + } + } + } + + return endpointGroup, nil +} diff --git a/api/http/handler/endpointgroups/endpoints.go b/api/http/handler/endpointgroups/endpoints.go new file mode 100644 index 0000000..8b420f2 --- /dev/null +++ b/api/http/handler/endpointgroups/endpoints.go @@ -0,0 +1,49 @@ +package endpointgroups + +import ( + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/internal/edge" +) + +func (handler *Handler) updateEndpointRelations(tx dataservices.DataStoreTx, endpoint *portainer.Endpoint, endpointGroup *portainer.EndpointGroup) error { + if endpoint.Type != portainer.EdgeAgentOnKubernetesEnvironment && endpoint.Type != portainer.EdgeAgentOnDockerEnvironment { + return nil + } + + if endpointGroup == nil { + unassignedGroup, err := tx.EndpointGroup().Read(portainer.EndpointGroupID(1)) + if err != nil { + return err + } + + endpointGroup = unassignedGroup + } + + endpointRelation, err := tx.EndpointRelation().EndpointRelation(endpoint.ID) + if err != nil { + return err + } + + edgeGroups, err := tx.EdgeGroup().ReadAll() + if err != nil { + return err + } + + edgeStacks, err := tx.EdgeStack().EdgeStacks() + if err != nil { + if tx.IsErrObjectNotFound(err) { + return nil + } + return err + } + + endpointStacks := edge.EndpointRelatedEdgeStacks(endpoint, endpointGroup, edgeGroups, edgeStacks) + stacksSet := map[portainer.EdgeStackID]bool{} + for _, edgeStackID := range endpointStacks { + stacksSet[edgeStackID] = true + } + endpointRelation.EdgeStacks = stacksSet + + return tx.EndpointRelation().UpdateEndpointRelation(endpoint.ID, endpointRelation) +} diff --git a/api/http/handler/endpointgroups/handler.go b/api/http/handler/endpointgroups/handler.go new file mode 100644 index 0000000..6fe912d --- /dev/null +++ b/api/http/handler/endpointgroups/handler.go @@ -0,0 +1,43 @@ +package endpointgroups + +import ( + "net/http" + + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/authorization" + "github.com/portainer/portainer/api/pendingactions" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + + "github.com/gorilla/mux" +) + +// Handler is the HTTP handler used to handle environment(endpoint) group operations. +type Handler struct { + *mux.Router + AuthorizationService *authorization.Service + DataStore dataservices.DataStore + PendingActionsService *pendingactions.PendingActionsService +} + +// NewHandler creates a handler to manage environment(endpoint) group operations. +func NewHandler(bouncer security.BouncerService) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + } + h.Handle("/endpoint_groups", + bouncer.AdminAccess(httperror.LoggerHandler(h.endpointGroupCreate))).Methods(http.MethodPost) + h.Handle("/endpoint_groups", + bouncer.RestrictedAccess(httperror.LoggerHandler(h.endpointGroupList))).Methods(http.MethodGet) + h.Handle("/endpoint_groups/{id}", + bouncer.AdminAccess(httperror.LoggerHandler(h.endpointGroupInspect))).Methods(http.MethodGet) + h.Handle("/endpoint_groups/{id}", + bouncer.AdminAccess(httperror.LoggerHandler(h.endpointGroupUpdate))).Methods(http.MethodPut) + h.Handle("/endpoint_groups/{id}", + bouncer.AdminAccess(httperror.LoggerHandler(h.endpointGroupDelete))).Methods(http.MethodDelete) + h.Handle("/endpoint_groups/{id}/endpoints/{endpointId}", + bouncer.AdminAccess(httperror.LoggerHandler(h.endpointGroupAddEndpoint))).Methods(http.MethodPut) + h.Handle("/endpoint_groups/{id}/endpoints/{endpointId}", + bouncer.AdminAccess(httperror.LoggerHandler(h.endpointGroupDeleteEndpoint))).Methods(http.MethodDelete) + return h +} diff --git a/api/http/handler/endpointgroups/handler_test.go b/api/http/handler/endpointgroups/handler_test.go new file mode 100644 index 0000000..e24873b --- /dev/null +++ b/api/http/handler/endpointgroups/handler_test.go @@ -0,0 +1,15 @@ +package endpointgroups + +import ( + "testing" + + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/internal/testhelpers" +) + +func setUpHandler(t *testing.T, store *datastore.Store) *Handler { + t.Helper() + handler := NewHandler(testhelpers.NewTestRequestBouncer()) + handler.DataStore = store + return handler +} diff --git a/api/http/handler/endpointproxy/handler.go b/api/http/handler/endpointproxy/handler.go new file mode 100644 index 0000000..64f8654 --- /dev/null +++ b/api/http/handler/endpointproxy/handler.go @@ -0,0 +1,39 @@ +package endpointproxy + +import ( + "github.com/gorilla/mux" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/http/proxy" + "github.com/portainer/portainer/api/http/security" + httperror "github.com/portainer/portainer/pkg/libhttp/error" +) + +// Handler is the HTTP handler used to proxy requests to external APIs. +type Handler struct { + *mux.Router + DataStore dataservices.DataStore + requestBouncer security.BouncerService + ProxyManager *proxy.Manager + ReverseTunnelService portainer.ReverseTunnelService +} + +// NewHandler creates a handler to proxy requests to external APIs. +func NewHandler(bouncer security.BouncerService) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + requestBouncer: bouncer, + } + h.PathPrefix("/{id}/azure").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}/agent/docker").Handler( + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.proxyRequestsToDockerAPI))) + h.PathPrefix("/{id}/agent/kubernetes").Handler( + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.proxyRequestsToKubernetesAPI))) + h.PathPrefix("/{id}/agent/host").Handler(httperror.LoggerHandler(h.proxyRequestsToAgentHostAPI)) + return h +} diff --git a/api/http/handler/endpointproxy/proxy_agent_host.go b/api/http/handler/endpointproxy/proxy_agent_host.go new file mode 100644 index 0000000..e686dd5 --- /dev/null +++ b/api/http/handler/endpointproxy/proxy_agent_host.go @@ -0,0 +1,59 @@ +package endpointproxy + +import ( + "errors" + "net/http" + "strconv" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" +) + +func (handler *Handler) proxyRequestsToAgentHostAPI(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid environment identifier route variable", err) + } + + var endpoint *portainer.Endpoint + if err := handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error { + var err error + endpoint, err = tx.Endpoint().Endpoint(portainer.EndpointID(endpointID)) + return err + }); handler.DataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find an environment with the specified identifier inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find an environment with the specified identifier inside the database", err) + } + + err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) + if err != nil { + return httperror.Forbidden("Permission denied to access environment", err) + } + + if endpoint.Type == portainer.EdgeAgentOnDockerEnvironment { + if endpoint.EdgeID == "" { + return httperror.InternalServerError("No Edge agent registered with the environment", errors.New("No agent available")) + } + + _, err := handler.ReverseTunnelService.TunnelAddr(endpoint) + if err != nil { + return httperror.InternalServerError("Unable to get the active tunnel", err) + } + } + + var proxy http.Handler + proxy = handler.ProxyManager.GetEndpointProxy(endpoint) + if proxy == nil { + proxy, err = handler.ProxyManager.CreateAndRegisterEndpointProxy(endpoint) + if err != nil { + return httperror.InternalServerError("Unable to create proxy", err) + } + } + + id := strconv.Itoa(endpointID) + http.StripPrefix("/"+id+"/agent", proxy).ServeHTTP(w, r) + return nil +} diff --git a/api/http/handler/endpointproxy/proxy_agent_host_test.go b/api/http/handler/endpointproxy/proxy_agent_host_test.go new file mode 100644 index 0000000..6bf9dbb --- /dev/null +++ b/api/http/handler/endpointproxy/proxy_agent_host_test.go @@ -0,0 +1,199 @@ +package endpointproxy + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/http/proxy" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/testhelpers" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// stubTunnelService is a minimal ReverseTunnelService that controls TunnelAddr behavior. +type stubTunnelService struct { + tunnelAddr string + tunnelAddrErr error +} + +func (s *stubTunnelService) StartTunnelServer(_, _ string, _ portainer.SnapshotService) error { + return nil +} +func (s *stubTunnelService) StopTunnelServer() error { return nil } +func (s *stubTunnelService) GenerateEdgeKey(_, _ string, _ int) string { + return "" +} +func (s *stubTunnelService) Open(_ *portainer.Endpoint) error { return nil } +func (s *stubTunnelService) Config(_ portainer.EndpointID) portainer.TunnelDetails { + return portainer.TunnelDetails{} +} +func (s *stubTunnelService) TunnelAddr(_ *portainer.Endpoint) (string, error) { + return s.tunnelAddr, s.tunnelAddrErr +} +func (s *stubTunnelService) UpdateLastActivity(_ portainer.EndpointID) {} +func (s *stubTunnelService) KeepTunnelAlive(_ portainer.EndpointID, _ context.Context, _ time.Duration) { +} + +// denyBouncer wraps the test bouncer but rejects AuthorizedEndpointOperation. +// Used to test the 403 path without setting up a full JWT stack. +type denyBouncer struct { + security.BouncerService +} + +func (denyBouncer) AuthorizedEndpointOperation(_ *http.Request, _ *portainer.Endpoint) error { + return errors.New("access denied to environment") +} + +// setupProxyHandler builds a Handler backed by a real (empty) test datastore. +// The real datastore is required because proxyRequestsToAgentHostAPI uses ViewTx, +// which must execute its callback to populate the endpoint variable. +func setupProxyHandler(t *testing.T, bouncer security.BouncerService) (*Handler, *datastore.Store) { + t.Helper() + + _, store := datastore.MustNewTestStore(t, false, false) + + h := NewHandler(bouncer) + h.DataStore = store + h.ProxyManager = proxy.NewManager(nil) + h.ReverseTunnelService = &stubTunnelService{} + + return h, store +} + +func TestProxyAgentHostAPI_InvalidEndpointID(t *testing.T) { + t.Parallel() + + // A non-numeric environment ID in the URL (e.g. caused by a typo or path-traversal attempt) + // must be rejected immediately with 400 Bad Request. + h, _ := setupProxyHandler(t, testhelpers.NewTestRequestBouncer()) + + rw := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/abc/agent/host/docker-storage", nil) + + h.ServeHTTP(rw, req) + + assert.Equal(t, http.StatusBadRequest, rw.Code) +} + +func TestProxyAgentHostAPI_EndpointNotFound(t *testing.T) { + t.Parallel() + + // The environment was deleted from the database while the user still has a + // browser tab open. The server should return 404, not a 500 or panic. + h, _ := setupProxyHandler(t, testhelpers.NewTestRequestBouncer()) + + rw := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/99/agent/host/docker-storage", nil) + + h.ServeHTTP(rw, req) + + assert.Equal(t, http.StatusNotFound, rw.Code) +} + +func TestProxyAgentHostAPI_PermissionDenied(t *testing.T) { + t.Parallel() + + // A standard user without access to this environment must receive 403 Forbidden. + bouncer := denyBouncer{BouncerService: testhelpers.NewTestRequestBouncer()} + h, store := setupProxyHandler(t, bouncer) + + require.NoError(t, store.Endpoint().Create(&portainer.Endpoint{ + ID: 1, + Name: "env-1", + Type: portainer.AgentOnDockerEnvironment, + })) + + rw := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/1/agent/host/docker-storage", nil) + + h.ServeHTTP(rw, req) + + assert.Equal(t, http.StatusForbidden, rw.Code) +} + +func TestProxyAgentHostAPI_EdgeNoEdgeID(t *testing.T) { + t.Parallel() + + // An Edge environment that was registered in Portainer but whose agent has never + // connected (EdgeID is empty) cannot be contacted — the server returns 500. + h, store := setupProxyHandler(t, testhelpers.NewTestRequestBouncer()) + + require.NoError(t, store.Endpoint().Create(&portainer.Endpoint{ + ID: 2, + Name: "edge-env-no-id", + Type: portainer.EdgeAgentOnDockerEnvironment, + EdgeID: "", + })) + + rw := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/2/agent/host/docker-storage", nil) + + h.ServeHTTP(rw, req) + + assert.Equal(t, http.StatusInternalServerError, rw.Code) +} + +func TestProxyAgentHostAPI_EdgeTunnelUnavailable(t *testing.T) { + t.Parallel() + + // The Edge agent was registered and has an EdgeID but is currently offline + // (tunnel establishment fails). The user receives 500 rather than a hang. + _, store := datastore.MustNewTestStore(t, false, false) + + h := NewHandler(testhelpers.NewTestRequestBouncer()) + h.DataStore = store + h.ProxyManager = proxy.NewManager(nil) + h.ReverseTunnelService = &stubTunnelService{ + tunnelAddrErr: errors.New("no active tunnel for edge agent"), + } + + require.NoError(t, store.Endpoint().Create(&portainer.Endpoint{ + ID: 3, + Name: "edge-env-offline", + Type: portainer.EdgeAgentOnDockerEnvironment, + EdgeID: "registered-edge-id", + })) + + rw := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/3/agent/host/docker-storage", nil) + + h.ServeHTTP(rw, req) + + assert.Equal(t, http.StatusInternalServerError, rw.Code) +} + +func TestProxyAgentHostAPI_ProxyCreationFails(t *testing.T) { + t.Parallel() + + // When a proxy for the environment has not been cached yet and the proxy factory is + // uninitialised (e.g. a misconfigured server), the handler returns 500 rather than panicking. + h, store := setupProxyHandler(t, testhelpers.NewTestRequestBouncer()) + + require.NoError(t, store.Endpoint().Create(&portainer.Endpoint{ + ID: 4, + Name: "env-4", + Type: portainer.AgentOnDockerEnvironment, + URL: "tcp://agent:9001", + })) + + rw := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/4/agent/host/docker-storage", nil) + + h.ServeHTTP(rw, req) + + // proxy.NewManager(nil) without NewProxyFactory → ErrProxyFactoryNotInitialized → 500 + assert.Equal(t, http.StatusInternalServerError, rw.Code) +} + +// Verify the stubTunnelService satisfies the interface at compile time. +var _ portainer.ReverseTunnelService = (*stubTunnelService)(nil) + +// Verify denyBouncer satisfies the interface at compile time. +var _ security.BouncerService = denyBouncer{} diff --git a/api/http/handler/endpointproxy/proxy_azure.go b/api/http/handler/endpointproxy/proxy_azure.go new file mode 100644 index 0000000..b468c50 --- /dev/null +++ b/api/http/handler/endpointproxy/proxy_azure.go @@ -0,0 +1,42 @@ +package endpointproxy + +import ( + "net/http" + "strconv" + + portainer "github.com/portainer/portainer/api" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" +) + +func (handler *Handler) proxyRequestsToAzureAPI(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid environment identifier route variable", err) + } + + endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) + if handler.DataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find an environment with the specified identifier inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find an environment with the specified identifier inside the database", err) + } + + err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) + if err != nil { + return httperror.Forbidden("Permission denied to access environment", err) + } + + var proxy http.Handler + proxy = handler.ProxyManager.GetEndpointProxy(endpoint) + if proxy == nil { + proxy, err = handler.ProxyManager.CreateAndRegisterEndpointProxy(endpoint) + if err != nil { + return httperror.InternalServerError("Unable to create proxy", err) + } + } + + id := strconv.Itoa(endpointID) + http.StripPrefix("/"+id+"/azure", proxy).ServeHTTP(w, r) + return nil +} diff --git a/api/http/handler/endpointproxy/proxy_docker.go b/api/http/handler/endpointproxy/proxy_docker.go new file mode 100644 index 0000000..30782a9 --- /dev/null +++ b/api/http/handler/endpointproxy/proxy_docker.go @@ -0,0 +1,61 @@ +package endpointproxy + +import ( + "errors" + "net/http" + "strconv" + "strings" + + portainer "github.com/portainer/portainer/api" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" +) + +func (handler *Handler) proxyRequestsToDockerAPI(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid environment identifier route variable", err) + } + + endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) + if handler.DataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find an environment with the specified identifier inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find an environment with the specified identifier inside the database", err) + } + + err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) + if err != nil { + return httperror.Forbidden("Permission denied to access environment", err) + } + + if endpoint.Type == portainer.EdgeAgentOnDockerEnvironment { + if endpoint.EdgeID == "" { + return httperror.InternalServerError("No Edge agent registered with the environment", errors.New("No agent available")) + } + + _, err := handler.ReverseTunnelService.TunnelAddr(endpoint) + if err != nil { + return httperror.InternalServerError("Unable to get the active tunnel", err) + } + } + + var proxy http.Handler + proxy = handler.ProxyManager.GetEndpointProxy(endpoint) + if proxy == nil { + proxy, err = handler.ProxyManager.CreateAndRegisterEndpointProxy(endpoint) + if err != nil { + return httperror.InternalServerError("Unable to create proxy", err) + } + } + + id := strconv.Itoa(endpointID) + + prefix := "/" + id + "/agent/docker" + if !strings.HasPrefix(r.URL.Path, prefix) { + prefix = "/" + id + "/docker" + } + + http.StripPrefix(prefix, proxy).ServeHTTP(w, r) + return nil +} diff --git a/api/http/handler/endpointproxy/proxy_kubernetes.go b/api/http/handler/endpointproxy/proxy_kubernetes.go new file mode 100644 index 0000000..ddf54d6 --- /dev/null +++ b/api/http/handler/endpointproxy/proxy_kubernetes.go @@ -0,0 +1,66 @@ +package endpointproxy + +import ( + "errors" + "fmt" + "net/http" + "strings" + + portainer "github.com/portainer/portainer/api" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" +) + +func (handler *Handler) proxyRequestsToKubernetesAPI(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid environment identifier route variable", err) + } + + endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) + if handler.DataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find an environment with the specified identifier inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find an environment with the specified identifier inside the database", err) + } + + err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) + if err != nil { + return httperror.Forbidden("Permission denied to access environment", err) + } + + if endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment { + if endpoint.EdgeID == "" { + return httperror.InternalServerError("No Edge agent registered with the environment", errors.New("No agent available")) + } + + _, err := handler.ReverseTunnelService.TunnelAddr(endpoint) + if err != nil { + return httperror.InternalServerError("Unable to get the active tunnel", err) + } + } + + var proxy http.Handler + proxy = handler.ProxyManager.GetEndpointProxy(endpoint) + if proxy == nil { + proxy, err = handler.ProxyManager.CreateAndRegisterEndpointProxy(endpoint) + if err != nil { + return httperror.InternalServerError("Unable to create proxy", err) + } + } + + // For KubernetesLocalEnvironment + requestPrefix := fmt.Sprintf("/%d/kubernetes", endpointID) + + if endpoint.Type == portainer.AgentOnKubernetesEnvironment || endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment { + requestPrefix = fmt.Sprintf("/%d", endpointID) + + agentPrefix := fmt.Sprintf("/%d/agent/kubernetes", endpointID) + if strings.HasPrefix(r.URL.Path, agentPrefix) { + requestPrefix = agentPrefix + } + } + + http.StripPrefix(requestPrefix, proxy).ServeHTTP(w, r) + return nil +} diff --git a/api/http/handler/endpoints/endpoint_agent_browse_docs.go b/api/http/handler/endpoints/endpoint_agent_browse_docs.go new file mode 100644 index 0000000..cb22f71 --- /dev/null +++ b/api/http/handler/endpoints/endpoint_agent_browse_docs.go @@ -0,0 +1,27 @@ +package endpoints + +/// This feature is implemented in the agent API and not directly here. +/// However, it's proxied. So we document it here. + +// @summary Upload a file under a specific path on the file system of an environment (endpoint) +// @description Use this environment(endpoint) to upload TLS files. +// @description **Access policy**: administrator +// @tags endpoints +// @security ApiKeyAuth +// @security jwt +// @accept multipart/form-data +// @produce json +// @param id path int true "Environment(Endpoint) identifier" +// @param volumeID query string false "Optional volume identifier to upload the file" +// @param Path formData string true "The destination path to upload the file to" +// @param file formData file true "The file to upload" +// @success 204 "Success" +// @failure 400 "Invalid request" +// @failure 500 "Server error" +// @router /endpoints/{id}/docker/v2/browse/put [post] +// +//lint:ignore U1000 Ignore unused code, for documentation purposes +func _fileBrowseFileUploadV2() { + // dummy function to make swag pick up the above docs for the following REST call + // POST request on /browse/put?volumeID=:id +} diff --git a/api/http/handler/endpoints/endpoint_agent_versions.go b/api/http/handler/endpoints/endpoint_agent_versions.go new file mode 100644 index 0000000..ced7d0b --- /dev/null +++ b/api/http/handler/endpoints/endpoint_agent_versions.go @@ -0,0 +1,50 @@ +package endpoints + +import ( + "net/http" + + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/set" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +// @id AgentVersions +// @summary List agent versions +// @description List all agent versions based on the current user authorizations and query parameters. +// @description **Access policy**: restricted +// @tags endpoints +// @security ApiKeyAuth +// @security jwt +// @produce json +// @success 200 {array} string "List of available agent versions" +// @failure 500 "Server error" +// @router /endpoints/agent_versions [get] + +func (handler *Handler) agentVersions(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpointGroups, err := handler.DataStore.EndpointGroup().ReadAll() + if err != nil { + return httperror.InternalServerError("Unable to retrieve environment groups from the database", err) + } + + endpoints, err := handler.DataStore.Endpoint().Endpoints() + if err != nil { + return httperror.InternalServerError("Unable to retrieve environments from the database", err) + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) + } + + filteredEndpoints := security.FilterEndpoints(endpoints, endpointGroups, securityContext) + + agentVersions := set.Set[string]{} + for _, endpoint := range filteredEndpoints { + if endpoint.Agent.Version != "" { + agentVersions[endpoint.Agent.Version] = true + } + } + + return response.JSON(w, agentVersions.Keys()) +} diff --git a/api/http/handler/endpoints/endpoint_association_delete.go b/api/http/handler/endpoints/endpoint_association_delete.go new file mode 100644 index 0000000..55cad59 --- /dev/null +++ b/api/http/handler/endpoints/endpoint_association_delete.go @@ -0,0 +1,87 @@ +package endpoints + +import ( + "encoding/base64" + "errors" + "fmt" + "net/http" + "regexp" + "strings" + + portainer "github.com/portainer/portainer/api" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +// @id EndpointAssociationDelete +// @summary De-association an edge environment(endpoint) +// @description De-association an edge environment(endpoint). +// @description **Access policy**: administrator +// @security ApiKeyAuth +// @security jwt +// @tags endpoints +// @produce json +// @param id path int true "Environment(Endpoint) identifier" +// @success 204 "Success" +// @failure 400 "Invalid request" +// @failure 404 "Environment(Endpoint) not found" +// @failure 500 "Server error" +// @router /endpoints/{id}/association [put] +func (handler *Handler) endpointAssociationDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid environment identifier route variable", err) + } + + endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) + if handler.DataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find an environment with the specified identifier inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find an environment with the specified identifier inside the database", err) + } + + if endpoint.Type != portainer.EdgeAgentOnKubernetesEnvironment && endpoint.Type != portainer.EdgeAgentOnDockerEnvironment { + return httperror.BadRequest("Invalid environment type", errors.New("Invalid environment type")) + } + + endpoint.EdgeID = "" + endpoint.Snapshots = []portainer.DockerSnapshot{} + endpoint.Kubernetes.Snapshots = []portainer.KubernetesSnapshot{} + + endpoint.EdgeKey, err = handler.updateEdgeKey(endpoint.EdgeKey) + if err != nil { + return httperror.InternalServerError("Invalid EdgeKey", err) + } + + err = handler.DataStore.Endpoint().UpdateEndpoint(portainer.EndpointID(endpointID), endpoint) + if err != nil { + return httperror.InternalServerError("Failed persisting environment in database", err) + } + + return response.Empty(w) +} + +func (handler *Handler) updateEdgeKey(edgeKey string) (string, error) { + oldEdgeKeyByte, err := base64.RawStdEncoding.DecodeString(edgeKey) + if err != nil { + return "", err + } + + oldEdgeKeyStr := string(oldEdgeKeyByte) + + httpPort := getPort(handler.BindAddress) + httpsPort := getPort(handler.BindAddressHTTPS) + + // replace "http://" with "https://" and replace ":9000" with ":9443", in the case of default values + // oldEdgeKeyStr example: http://10.116.1.178:9000|10.116.1.178:8000|46:99:4a:8d:a6:de:6a:bd:d8:e2:1c:99:81:60:54:55|52 + r := regexp.MustCompile(fmt.Sprintf("^(http://)([^|]+)(:%s)(|.*)", httpPort)) + newEdgeKeyStr := r.ReplaceAllString(oldEdgeKeyStr, fmt.Sprintf("https://$2:%s$4", httpsPort)) + + return base64.RawStdEncoding.EncodeToString([]byte(newEdgeKeyStr)), nil +} + +func getPort(url string) string { + items := strings.Split(url, ":") + return items[len(items)-1] +} diff --git a/api/http/handler/endpoints/endpoint_create.go b/api/http/handler/endpoints/endpoint_create.go new file mode 100644 index 0000000..7d95a31 --- /dev/null +++ b/api/http/handler/endpoints/endpoint_create.go @@ -0,0 +1,598 @@ +package endpoints + +import ( + "errors" + "net/http" + "runtime" + "strconv" + "strings" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/agent" + "github.com/portainer/portainer/api/crypto" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/http/client" + "github.com/portainer/portainer/api/internal/edge" + "github.com/portainer/portainer/api/internal/endpointutils" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" + + "github.com/google/uuid" + "github.com/rs/zerolog/log" +) + +type endpointCreatePayload struct { + Name string + URL string + EndpointCreationType endpointCreationEnum + PublicURL string + Gpus []portainer.Pair + GroupID int + TLS bool + TLSSkipVerify bool + TLSSkipClientVerify bool + TLSCACertFile []byte + TLSCertFile []byte + TLSKeyFile []byte + AzureApplicationID string + AzureTenantID string + AzureAuthenticationKey string + TagIDs []portainer.TagID + EdgeCheckinInterval int + ContainerEngine string +} + +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 errors.New("invalid environment name") + } + payload.Name = name + + endpointCreationType, err := request.RetrieveNumericMultiPartFormValue(r, "EndpointCreationType", false) + if err != nil || endpointCreationType == 0 { + return errors.New("invalid environment 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.EndpointCreationType = endpointCreationEnum(endpointCreationType) + + payload.ContainerEngine, err = request.RetrieveMultiPartFormValue(r, "ContainerEngine", true) + if err != nil || (payload.ContainerEngine != "" && payload.ContainerEngine != portainer.ContainerEngineDocker && payload.ContainerEngine != portainer.ContainerEnginePodman) { + return errors.New("invalid container engine value. Value must be one of: 'docker' or 'podman'") + } + + groupID, _ := request.RetrieveNumericMultiPartFormValue(r, "GroupID", true) + if groupID == 0 { + groupID = 1 + } + payload.GroupID = groupID + + var tagIDs []portainer.TagID + if err := request.RetrieveMultiPartFormJSONValue(r, "TagIds", &tagIDs, true); err != nil { + return errors.New("invalid TagIds parameter") + } + payload.TagIDs = tagIDs + if payload.TagIDs == nil { + payload.TagIDs = make([]portainer.TagID, 0) + } + + useTLS, _ := request.RetrieveBooleanMultiPartFormValue(r, "TLS", true) + payload.TLS = useTLS + + if payload.TLS && payload.EndpointCreationType == edgeAgentEnvironment { + return errors.New("TLS is not supported for Edge Agent environments") + } + + if payload.TLS { + skipTLSServerVerification, _ := request.RetrieveBooleanMultiPartFormValue(r, "TLSSkipVerify", true) + payload.TLSSkipVerify = skipTLSServerVerification + skipTLSClientVerification, _ := request.RetrieveBooleanMultiPartFormValue(r, "TLSSkipClientVerify", true) + payload.TLSSkipClientVerify = skipTLSClientVerification + + if !payload.TLSSkipVerify { + caCert, _, err := request.RetrieveMultiPartFormFile(r, "TLSCACertFile") + if err != nil { + return errors.New("invalid CA certificate file. Ensure that the file is uploaded correctly") + } + + payload.TLSCACertFile = caCert + } + + if !payload.TLSSkipClientVerify { + cert, _, err := request.RetrieveMultiPartFormFile(r, "TLSCertFile") + if err != nil { + 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 errors.New("invalid key file. Ensure that the file is uploaded correctly") + } + + payload.TLSKeyFile = key + } + } + + switch payload.EndpointCreationType { + case azureEnvironment: + azureApplicationID, err := request.RetrieveMultiPartFormValue(r, "AzureApplicationID", false) + if err != nil { + return errors.New("invalid Azure application ID") + } + + payload.AzureApplicationID = azureApplicationID + + azureTenantID, err := request.RetrieveMultiPartFormValue(r, "AzureTenantID", false) + if err != nil { + return errors.New("invalid Azure tenant ID") + } + payload.AzureTenantID = azureTenantID + + azureAuthenticationKey, err := request.RetrieveMultiPartFormValue(r, "AzureAuthenticationKey", false) + if err != nil { + return errors.New("invalid Azure authentication key") + } + payload.AzureAuthenticationKey = azureAuthenticationKey + + case edgeAgentEnvironment: + endpointURL, err := request.RetrieveMultiPartFormValue(r, "URL", false) + if err != nil || strings.EqualFold("", strings.Trim(endpointURL, " ")) { + return errors.New("URL cannot be empty") + } + + payload.URL = endpointURL + + publicURL, _ := request.RetrieveMultiPartFormValue(r, "PublicURL", true) + payload.PublicURL = publicURL + + default: + endpointURL, err := request.RetrieveMultiPartFormValue(r, "URL", true) + if err != nil { + return errors.New("invalid environment URL") + } + payload.URL = endpointURL + + publicURL, _ := request.RetrieveMultiPartFormValue(r, "PublicURL", true) + payload.PublicURL = publicURL + } + + gpus := make([]portainer.Pair, 0) + if err := request.RetrieveMultiPartFormJSONValue(r, "Gpus", &gpus, true); err != nil { + return errors.New("invalid Gpus parameter") + } + + payload.Gpus = gpus + + edgeCheckinInterval, _ := request.RetrieveNumericMultiPartFormValue(r, "EdgeCheckinInterval", true) + if edgeCheckinInterval == 0 { + // deprecated CheckinInterval + edgeCheckinInterval, _ = request.RetrieveNumericMultiPartFormValue(r, "CheckinInterval", true) + } + payload.EdgeCheckinInterval = edgeCheckinInterval + + return nil +} + +// @id EndpointCreate +// @summary Create a new environment(endpoint) +// @description Create a new environment(endpoint) that will be used to manage an environment(endpoint). +// @description **Access policy**: administrator +// @tags endpoints +// @security ApiKeyAuth +// @security jwt +// @accept multipart/form-data +// @produce json +// @param Name formData string true "Name that will be used to identify this environment(endpoint) (example: my-environment)" +// @param EndpointCreationType formData endpointCreationEnum true "Environment(Endpoint) type. Value must be one of: 1 (Local Docker environment), 2 (Agent environment), 3 (Azure environment), 4 (Edge agent environment) or 5 (Local Kubernetes Environment)" Enum(1,2,3,4,5) +// @param ContainerEngine formData string false "Container engine used by the environment(endpoint). Value must be one of: 'docker' or 'podman'" +// @param URL formData string false "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). Cannot be empty if EndpointCreationType is set to 4 (Edge agent environment)" +// @param PublicURL formData string false "URL or IP address where exposed containers will be reachable. Defaults to URL if not specified (example: docker.mydomain.tld:2375)" +// @param GroupID formData int false "Environment(Endpoint) group identifier. If not specified will default to 1 (unassigned)." +// @param TLS formData bool false "Require TLS to connect against this environment(endpoint). Must be true if EndpointCreationType is set to 2 (Agent environment)" +// @param TLSSkipVerify formData bool false "Skip server verification when using TLS. Must be true if EndpointCreationType is set to 2 (Agent environment)" +// @param TLSSkipClientVerify formData bool false "Skip client verification when using TLS. Must be true if EndpointCreationType is set to 2 (Agent environment)" +// @param TLSCACertFile formData file false "TLS CA certificate file" +// @param TLSCertFile formData file false "TLS client certificate file" +// @param TLSKeyFile formData file false "TLS client key file" +// @param AzureApplicationID formData string false "Azure application ID. Required if environment(endpoint) type is set to 3" +// @param AzureTenantID formData string false "Azure tenant ID. Required if environment(endpoint) type is set to 3" +// @param AzureAuthenticationKey formData string false "Azure authentication key. Required if environment(endpoint) type is set to 3" +// @param TagIds formData string false "JSON-parsable array of tag identifiers to which this environment(endpoint) is associated" +// @param EdgeCheckinInterval formData int false "The check in interval for edge agent (in seconds)" +// @param EdgeTunnelServerAddress formData string false "URL or IP address that will be used to establish a reverse tunnel" +// @param Gpus formData string false "List of GPUs - json stringified array of {name, value} structs" +// @success 200 {object} portainer.Endpoint "Success" +// @failure 400 "Invalid request" +// @failure 409 "Name is not unique" +// @failure 500 "Server error" +// @router /endpoints [post] +func (handler *Handler) endpointCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + payload := &endpointCreatePayload{} + if err := payload.Validate(r); err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + isUnique, err := handler.isNameUnique(payload.Name, 0) + if err != nil { + return httperror.InternalServerError("Unable to check if name is unique", err) + } + + if !isUnique { + return httperror.Conflict("Name is not unique", nil) + } + + endpoint, endpointCreationError := handler.createEndpoint(handler.DataStore, payload) + if endpointCreationError != nil { + return endpointCreationError + } + + endpointGroup, err := handler.DataStore.EndpointGroup().Read(endpoint.GroupID) + if err != nil { + return httperror.InternalServerError("Unable to find an environment group inside the database", err) + } + + edgeGroups, err := handler.DataStore.EdgeGroup().ReadAll() + if err != nil { + return httperror.InternalServerError("Unable to retrieve edge groups from the database", err) + } + + edgeStacks, err := handler.DataStore.EdgeStack().EdgeStacks() + if err != nil { + return httperror.InternalServerError("Unable to retrieve edge stacks from the database", err) + } + + relationObject := &portainer.EndpointRelation{ + EndpointID: endpoint.ID, + EdgeStacks: map[portainer.EdgeStackID]bool{}, + } + + if endpoint.Type == portainer.EdgeAgentOnDockerEnvironment || endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment { + relatedEdgeStacks := edge.EndpointRelatedEdgeStacks(endpoint, endpointGroup, edgeGroups, edgeStacks) + for _, stackID := range relatedEdgeStacks { + relationObject.EdgeStacks[stackID] = true + } + } else if endpointutils.IsKubernetesEndpoint(endpoint) { + if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + endpointutils.InitialIngressClassDetection(tx, endpoint, handler.K8sClientFactory) + endpointutils.InitialMetricsDetection(tx, endpoint, handler.K8sClientFactory) + endpointutils.InitialStorageDetection(tx, handler.DataStore, endpoint, handler.K8sClientFactory) + return nil + }); err != nil { + log.Err(err).Msg("failed to persist initial kube detection") + } + } + + if err := handler.DataStore.EndpointRelation().Create(relationObject); err != nil { + return httperror.InternalServerError("Unable to persist the relation object inside the database", err) + } + + return response.JSON(w, endpoint) +} + +func (handler *Handler) createEndpoint(tx dataservices.DataStoreTx, payload *endpointCreatePayload) (*portainer.Endpoint, *httperror.HandlerError) { + switch payload.EndpointCreationType { + case azureEnvironment: + return handler.createAzureEndpoint(tx, payload) + + case edgeAgentEnvironment: + return handler.createEdgeAgentEndpoint(tx, payload) + + case localKubernetesEnvironment: + return handler.createKubernetesEndpoint(tx, payload) + } + + endpointType := portainer.DockerEnvironment + var agentVersion string + if payload.EndpointCreationType == agentEnvironment { + tlsConfig, err := crypto.CreateTLSConfigurationFromBytes(payload.TLS, payload.TLSCACertFile, payload.TLSCertFile, payload.TLSKeyFile, payload.TLSSkipVerify, payload.TLSSkipClientVerify) + if err != nil { + return nil, httperror.InternalServerError("Unable to create TLS configuration", err) + } + + agentPlatform, version, err := agent.GetAgentVersionAndPlatform(payload.URL, tlsConfig) + if err != nil { + return nil, httperror.InternalServerError("Unable to get environment type", err) + } + + agentVersion = version + 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(tx, payload, endpointType, agentVersion) + } + + return handler.createUnsecuredEndpoint(tx, payload) +} + +func (handler *Handler) createAzureEndpoint(tx dataservices.DataStoreTx, payload *endpointCreatePayload) (*portainer.Endpoint, *httperror.HandlerError) { + credentials := portainer.AzureCredentials{ + ApplicationID: payload.AzureApplicationID, + TenantID: payload.AzureTenantID, + AuthenticationKey: payload.AzureAuthenticationKey, + } + + httpClient := client.NewHTTPClient() + if _, err := httpClient.ExecuteAzureAuthenticationRequest(&credentials); err != nil { + return nil, httperror.InternalServerError("Unable to authenticate against Azure", err) + } + + endpointID := tx.Endpoint().GetNextIdentifier() + endpoint := &portainer.Endpoint{ + ID: portainer.EndpointID(endpointID), + Name: payload.Name, + URL: "https://management.azure.com", + Type: portainer.AzureEnvironment, + GroupID: portainer.EndpointGroupID(payload.GroupID), + PublicURL: payload.PublicURL, + Gpus: payload.Gpus, + UserAccessPolicies: portainer.UserAccessPolicies{}, + TeamAccessPolicies: portainer.TeamAccessPolicies{}, + AzureCredentials: credentials, + TagIDs: payload.TagIDs, + Status: portainer.EndpointStatusUp, + Snapshots: []portainer.DockerSnapshot{}, + Kubernetes: portainer.KubernetesDefault(), + } + + if err := handler.saveEndpointAndUpdateAuthorizations(tx, endpoint); err != nil { + return nil, httperror.InternalServerError("An error occurred while trying to create the environment", err) + } + + return endpoint, nil +} + +func (handler *Handler) createEdgeAgentEndpoint(tx dataservices.DataStoreTx, payload *endpointCreatePayload) (*portainer.Endpoint, *httperror.HandlerError) { + endpointID := handler.DataStore.Endpoint().GetNextIdentifier() + + portainerHost, err := edge.ParseHostForEdge(payload.URL) + if err != nil { + return nil, httperror.BadRequest("Unable to parse host", err) + } + + edgeKey := handler.ReverseTunnelService.GenerateEdgeKey(payload.URL, portainerHost, endpointID) + + endpoint := &portainer.Endpoint{ + ID: portainer.EndpointID(endpointID), + Name: payload.Name, + URL: portainerHost, + Type: func() portainer.EndpointType { + // an empty container engine means that the endpoint is a Kubernetes endpoint + if payload.ContainerEngine == "" { + return portainer.EdgeAgentOnKubernetesEnvironment + } + return portainer.EdgeAgentOnDockerEnvironment + }(), + ContainerEngine: payload.ContainerEngine, + GroupID: portainer.EndpointGroupID(payload.GroupID), + Gpus: payload.Gpus, + TLSConfig: portainer.TLSConfiguration{ + TLS: false, + }, + UserAccessPolicies: portainer.UserAccessPolicies{}, + TeamAccessPolicies: portainer.TeamAccessPolicies{}, + TagIDs: payload.TagIDs, + Status: portainer.EndpointStatusUp, + Snapshots: []portainer.DockerSnapshot{}, + EdgeKey: edgeKey, + EdgeCheckinInterval: payload.EdgeCheckinInterval, + Kubernetes: portainer.KubernetesDefault(), + UserTrusted: true, + } + + settings, err := handler.DataStore.Settings().Settings() + if err != nil { + return nil, httperror.InternalServerError("Unable to retrieve the settings from the database", err) + } + + if settings.EnforceEdgeID { + edgeID, err := uuid.NewRandom() + if err != nil { + return nil, httperror.InternalServerError("Cannot generate the Edge ID", err) + } + + endpoint.EdgeID = edgeID.String() + } + + if err := handler.saveEndpointAndUpdateAuthorizations(tx, endpoint); err != nil { + return nil, httperror.InternalServerError("An error occurred while trying to create the environment", err) + } + + return endpoint, nil +} + +func (handler *Handler) createUnsecuredEndpoint(tx dataservices.DataStoreTx, payload *endpointCreatePayload) (*portainer.Endpoint, *httperror.HandlerError) { + endpointType := portainer.DockerEnvironment + + if payload.URL == "" { + payload.URL = "unix:///var/run/docker.sock" + if runtime.GOOS == "windows" { + payload.URL = "npipe:////./pipe/docker_engine" + } + } + + endpointID := tx.Endpoint().GetNextIdentifier() + endpoint := &portainer.Endpoint{ + ID: portainer.EndpointID(endpointID), + Name: payload.Name, + URL: payload.URL, + Type: endpointType, + ContainerEngine: payload.ContainerEngine, + GroupID: portainer.EndpointGroupID(payload.GroupID), + PublicURL: payload.PublicURL, + Gpus: payload.Gpus, + TLSConfig: portainer.TLSConfiguration{ + TLS: false, + }, + UserAccessPolicies: portainer.UserAccessPolicies{}, + TeamAccessPolicies: portainer.TeamAccessPolicies{}, + TagIDs: payload.TagIDs, + Status: portainer.EndpointStatusUp, + Snapshots: []portainer.DockerSnapshot{}, + Kubernetes: portainer.KubernetesDefault(), + } + + if err := handler.snapshotAndPersistEndpoint(tx, endpoint); err != nil { + return nil, err + } + + return endpoint, nil +} + +func (handler *Handler) createKubernetesEndpoint(tx dataservices.DataStoreTx, payload *endpointCreatePayload) (*portainer.Endpoint, *httperror.HandlerError) { + if payload.URL == "" { + payload.URL = "https://kubernetes.default.svc" + } + + endpointID := tx.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, + Gpus: payload.Gpus, + TLSConfig: portainer.TLSConfiguration{ + TLS: payload.TLS, + TLSSkipVerify: payload.TLSSkipVerify, + }, + UserAccessPolicies: portainer.UserAccessPolicies{}, + TeamAccessPolicies: portainer.TeamAccessPolicies{}, + TagIDs: payload.TagIDs, + Status: portainer.EndpointStatusUp, + Snapshots: []portainer.DockerSnapshot{}, + Kubernetes: portainer.KubernetesDefault(), + } + + if err := handler.snapshotAndPersistEndpoint(tx, endpoint); err != nil { + return nil, err + } + + return endpoint, nil +} + +func (handler *Handler) createTLSSecuredEndpoint(tx dataservices.DataStoreTx, payload *endpointCreatePayload, endpointType portainer.EndpointType, agentVersion string) (*portainer.Endpoint, *httperror.HandlerError) { + endpointID := tx.Endpoint().GetNextIdentifier() + endpoint := &portainer.Endpoint{ + ID: portainer.EndpointID(endpointID), + Name: payload.Name, + URL: payload.URL, + Type: endpointType, + ContainerEngine: payload.ContainerEngine, + GroupID: portainer.EndpointGroupID(payload.GroupID), + PublicURL: payload.PublicURL, + Gpus: payload.Gpus, + TLSConfig: portainer.TLSConfiguration{ + TLS: payload.TLS, + TLSSkipVerify: payload.TLSSkipVerify, + }, + UserAccessPolicies: portainer.UserAccessPolicies{}, + TeamAccessPolicies: portainer.TeamAccessPolicies{}, + TagIDs: payload.TagIDs, + Status: portainer.EndpointStatusUp, + Snapshots: []portainer.DockerSnapshot{}, + Kubernetes: portainer.KubernetesDefault(), + } + + endpoint.Agent.Version = agentVersion + + if err := handler.storeTLSFiles(endpoint, payload); err != nil { + return nil, err + } + + if err := handler.snapshotAndPersistEndpoint(tx, endpoint); err != nil { + return nil, err + } + + return endpoint, nil +} + +func (handler *Handler) snapshotAndPersistEndpoint(tx dataservices.DataStoreTx, endpoint *portainer.Endpoint) *httperror.HandlerError { + if err := handler.SnapshotService.SnapshotEndpoint(endpoint); err != nil { + if (endpoint.Type == portainer.AgentOnDockerEnvironment && strings.Contains(err.Error(), "Invalid request signature")) || + (endpoint.Type == portainer.AgentOnKubernetesEnvironment && strings.Contains(err.Error(), "unknown")) { + err = errors.New("agent already paired with another Portainer instance") + } + + return httperror.InternalServerError("Unable to initiate communications with environment", err) + } + + if err := handler.saveEndpointAndUpdateAuthorizations(tx, endpoint); err != nil { + return httperror.InternalServerError("An error occurred while trying to create the environment", err) + } + + return nil +} + +func (handler *Handler) saveEndpointAndUpdateAuthorizations(tx dataservices.DataStoreTx, endpoint *portainer.Endpoint) error { + endpoint.SecuritySettings = portainer.DefaultEndpointSecuritySettings() + + if err := tx.Endpoint().Create(endpoint); err != nil { + return err + } + + if err := endpointutils.InitializeEdgeEndpointRelation(endpoint, tx); err != nil { + return err + } + + for _, tagID := range endpoint.TagIDs { + if err := tx.Tag().UpdateTagFunc(tagID, func(tag *portainer.Tag) { + tag.Endpoints[endpoint.ID] = true + }); err != nil { + return err + } + } + + return nil +} + +func (handler *Handler) storeTLSFiles(endpoint *portainer.Endpoint, payload *endpointCreatePayload) *httperror.HandlerError { + folder := strconv.Itoa(int(endpoint.ID)) + + if !payload.TLSSkipVerify { + caCertPath, err := handler.FileService.StoreTLSFileFromBytes(folder, portainer.TLSFileCA, payload.TLSCACertFile) + if err != nil { + return httperror.InternalServerError("Unable to persist TLS CA certificate file on disk", err) + } + + endpoint.TLSConfig.TLSCACertPath = caCertPath + } + + if payload.TLSSkipClientVerify { + return nil + } + + certPath, err := handler.FileService.StoreTLSFileFromBytes(folder, portainer.TLSFileCert, payload.TLSCertFile) + if err != nil { + return httperror.InternalServerError("Unable to persist TLS certificate file on disk", err) + } + + endpoint.TLSConfig.TLSCertPath = certPath + + keyPath, err := handler.FileService.StoreTLSFileFromBytes(folder, portainer.TLSFileKey, payload.TLSKeyFile) + if err != nil { + return httperror.InternalServerError("Unable to persist TLS key file on disk", err) + } + endpoint.TLSConfig.TLSKeyPath = keyPath + + return nil +} diff --git a/api/http/handler/endpoints/endpoint_create_global_key.go b/api/http/handler/endpoints/endpoint_create_global_key.go new file mode 100644 index 0000000..5593493 --- /dev/null +++ b/api/http/handler/endpoints/endpoint_create_global_key.go @@ -0,0 +1,37 @@ +package endpoints + +import ( + "errors" + "net/http" + + portainer "github.com/portainer/portainer/api" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +type endpointCreateGlobalKeyResponse struct { + EndpointID portainer.EndpointID `json:"endpointID"` +} + +// @id EndpointCreateGlobalKey +// @summary Create or retrieve the endpoint for an EdgeID +// @tags endpoints +// @success 200 {object} endpointCreateGlobalKeyResponse "Success" +// @failure 400 "Invalid request" +// @failure 500 "Server error" +// @router /endpoints/global-key [post] +func (handler *Handler) endpointCreateGlobalKey(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + edgeID := r.Header.Get(portainer.PortainerAgentEdgeIDHeader) + if edgeID == "" { + return httperror.BadRequest("Invalid Edge ID", errors.New("the Edge ID cannot be empty")) + } + + // Search for existing endpoints for the given edgeID + + endpointID, ok := handler.DataStore.Endpoint().EndpointIDByEdgeID(edgeID) + if ok { + return response.JSON(w, endpointCreateGlobalKeyResponse{endpointID}) + } + + return httperror.NotFound("Unable to find the endpoint in the database", nil) +} diff --git a/api/http/handler/endpoints/endpoint_create_global_key_test.go b/api/http/handler/endpoints/endpoint_create_global_key_test.go new file mode 100644 index 0000000..bdde9d8 --- /dev/null +++ b/api/http/handler/endpoints/endpoint_create_global_key_test.go @@ -0,0 +1,29 @@ +package endpoints + +import ( + "net/http" + "net/http/httptest" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/internal/testhelpers" +) + +func TestEmptyGlobalKey(t *testing.T) { + t.Parallel() + handler := NewHandler(testhelpers.NewTestRequestBouncer()) + + req, err := http.NewRequest(http.MethodPost, "https://portainer.io:9443/endpoints/global-key", nil) + if err != nil { + t.Fatal("request error:", err) + } + req.Header.Set(portainer.PortainerAgentEdgeIDHeader, "") + + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatal("expected a 400 response, found:", rec.Code) + } +} diff --git a/api/http/handler/endpoints/endpoint_create_test.go b/api/http/handler/endpoints/endpoint_create_test.go new file mode 100644 index 0000000..d0cd5d1 --- /dev/null +++ b/api/http/handler/endpoints/endpoint_create_test.go @@ -0,0 +1,230 @@ +package endpoints + +import ( + "net/http" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/chisel" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/internal/testhelpers" + "github.com/portainer/portainer/pkg/fips" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// EE-only kubeconfig validation tests removed for CE + +func TestSaveEndpointAndUpdateAuthorizations(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, true, false) + + endpointGroup := &portainer.EndpointGroup{ + ID: 1, + Name: "test-endpoint-group", + } + + err := store.EndpointGroup().Create(endpointGroup) + require.NoError(t, err) + + h := &Handler{ + DataStore: store, + } + + testCases := []struct { + name string + endpointType portainer.EndpointType + expectRelation bool + }{ + { + name: "create azure environment, expect no relation to be created", + endpointType: portainer.AzureEnvironment, + expectRelation: false, + }, + { + name: "create edge agent environment, expect relation to be created", + endpointType: portainer.EdgeAgentOnDockerEnvironment, + expectRelation: true, + }, + { + name: "create kubernetes environment, expect no relation to be created", + endpointType: portainer.KubernetesLocalEnvironment, + expectRelation: false, + }, + { + name: "create kubeconfig environment, expect no relation to be created", + endpointType: portainer.AgentOnKubernetesEnvironment, + expectRelation: false, + }, + { + name: "create agent docker environment, expect no relation to be created", + endpointType: portainer.AgentOnDockerEnvironment, + expectRelation: false, + }, + { + name: "create unsecured environment, expect no relation to be created", + endpointType: portainer.DockerEnvironment, + expectRelation: false, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + endpoint := &portainer.Endpoint{ + ID: portainer.EndpointID(store.Endpoint().GetNextIdentifier()), + Type: testCase.endpointType, + GroupID: endpointGroup.ID, + } + + err := h.saveEndpointAndUpdateAuthorizations(store, endpoint) + require.NoError(t, err) + + relation, relationErr := store.EndpointRelation().EndpointRelation(endpoint.ID) + if testCase.expectRelation { + require.NoError(t, relationErr) + require.NotNil(t, relation) + } else { + require.Error(t, relationErr) + require.True(t, store.IsErrObjectNotFound(relationErr)) + require.Nil(t, relation) + } + }) + } +} + +func TestCreateEndpointFailure(t *testing.T) { + t.Parallel() + fips.InitFIPS(false) + + _, store := datastore.MustNewTestStore(t, true, false) + + h := NewHandler(testhelpers.NewTestRequestBouncer()) + h.DataStore = store + + payload := &endpointCreatePayload{ + Name: "Test Endpoint", + EndpointCreationType: agentEnvironment, + TLS: true, + TLSCertFile: []byte("invalid data"), + TLSKeyFile: []byte("invalid data"), + } + + endpoint, httpErr := h.createEndpoint(store, payload) + require.NotNil(t, httpErr) + require.Equal(t, http.StatusInternalServerError, httpErr.StatusCode) + require.Nil(t, endpoint) +} + +func TestValidateEndpointCreatePayload_TLS(t *testing.T) { + t.Parallel() + fips.InitFIPS(false) + + testCases := []struct { + name string + formValues map[string][]string + expectedError string + }{ + { + name: "edge agent env with TLS rejected", + formValues: map[string][]string{ + "Name": {"Test Endpoint"}, + "EndpointCreationType": {"4"}, + "TLS": {"true"}, + }, + expectedError: "TLS is not supported for Edge Agent environments", + }, + { + name: "edge agent env without TLS succeeds", + formValues: map[string][]string{ + "Name": {"Test Endpoint"}, + "EndpointCreationType": {"4"}, + "URL": {"https://portainer.example:9443"}, + }, + expectedError: "", + }, + { + name: "non-edge agent env with TLS allowed", + formValues: map[string][]string{ + "Name": {"Test Endpoint"}, + "EndpointCreationType": {"2"}, + "TLS": {"true"}, + "TLSSkipVerify": {"true"}, + "TLSSkipClientVerify": {"true"}, + }, + expectedError: "", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + r := http.Request{Form: tc.formValues} + p := &endpointCreatePayload{} + err := p.Validate(&r) + + if tc.expectedError == "" { + require.NoError(t, err) + } else { + require.EqualError(t, err, tc.expectedError) + } + }) + } +} + +func TestCreateEdgeAgentEndpoint_ContainerEngineMapping(t *testing.T) { + t.Parallel() + fips.InitFIPS(false) + + _, store := datastore.MustNewTestStore(t, true, false) + + // required group for save flow + endpointGroup := &portainer.EndpointGroup{ID: 1, Name: "test-group"} + err := store.EndpointGroup().Create(endpointGroup) + require.NoError(t, err) + + h := &Handler{ + DataStore: store, + ReverseTunnelService: chisel.NewService(store, nil, nil), + } + + tests := []struct { + name string + engine string + wantType portainer.EndpointType + }{ + { + name: "empty engine -> EdgeAgentOnKubernetesEnvironment", + engine: "", + wantType: portainer.EdgeAgentOnKubernetesEnvironment, + }, + { + name: "docker engine -> EdgeAgentOnDockerEnvironment", + engine: portainer.ContainerEngineDocker, + wantType: portainer.EdgeAgentOnDockerEnvironment, + }, + { + name: "podman engine -> EdgeAgentOnDockerEnvironment", + engine: portainer.ContainerEnginePodman, + wantType: portainer.EdgeAgentOnDockerEnvironment, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + payload := &endpointCreatePayload{ + Name: "edge-endpoint", + EndpointCreationType: edgeAgentEnvironment, + ContainerEngine: tc.engine, + GroupID: 1, + URL: "https://portainer.example:9443", + } + + ep, httpErr := h.createEdgeAgentEndpoint(store, payload) + require.Nil(t, httpErr) + require.NotNil(t, ep) + + assert.Equal(t, tc.wantType, ep.Type) + assert.Equal(t, tc.engine, ep.ContainerEngine) + }) + } +} diff --git a/api/http/handler/endpoints/endpoint_delete.go b/api/http/handler/endpoints/endpoint_delete.go new file mode 100644 index 0000000..a468f12 --- /dev/null +++ b/api/http/handler/endpoints/endpoint_delete.go @@ -0,0 +1,251 @@ +package endpoints + +import ( + "errors" + "net/http" + "strconv" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/internal/endpointutils" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" + + "github.com/rs/zerolog/log" +) + +type endpointDeleteRequest struct { + ID int `json:"id"` + DeleteCluster bool `json:"deleteCluster"` +} + +type endpointDeleteBatchPayload struct { + Endpoints []endpointDeleteRequest `json:"endpoints"` +} + +type endpointDeleteBatchPartialResponse struct { + Deleted []int `json:"deleted"` + Errors []int `json:"errors"` +} + +func (payload *endpointDeleteBatchPayload) Validate(r *http.Request) error { + if payload == nil || len(payload.Endpoints) == 0 { + return errors.New("invalid request payload. You must provide a list of environments to delete") + } + + return nil +} + +// @id EndpointDelete +// @summary Remove an environment +// @description Remove the environment associated to the specified identifier and optionally clean-up associated resources. +// @description **Access policy**: Administrator only. +// @tags endpoints +// @security ApiKeyAuth || jwt +// @param id path int true "Environment(Endpoint) identifier" +// @success 204 "Environment successfully deleted." +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 403 "Unauthorized access or operation not allowed." +// @failure 404 "Unable to find the environment with the specified identifier inside the database." +// @failure 500 "Server error occurred while attempting to delete the environment." +// @router /endpoints/{id} [delete] +func (handler *Handler) endpointDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid environment identifier route variable", err) + } + + // This is a Portainer provisioned cloud environment + deleteCluster, err := request.RetrieveBooleanQueryParameter(r, "deleteCluster", true) + if err != nil { + return httperror.BadRequest("Invalid boolean query parameter", err) + } + + err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + return handler.deleteEndpoint(tx, portainer.EndpointID(endpointID), deleteCluster) + }) + + return response.TxEmptyResponse(w, err) +} + +// @id EndpointDeleteBatch +// @summary Remove multiple environments +// @description Remove multiple environments and optionally clean-up associated resources. +// @description **Access policy**: Administrator only. +// @tags endpoints +// @security ApiKeyAuth || jwt +// @accept json +// @produce json +// @param body body endpointDeleteBatchPayload true "List of environments to delete, with optional deleteCluster flag to clean-up associated resources (cloud environments only)" +// @success 204 "Environment(s) successfully deleted." +// @failure 207 {object} endpointDeleteBatchPartialResponse "Partial success. Some environments were deleted successfully, while others failed." +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 403 "Unauthorized access or operation not allowed." +// @failure 500 "Server error occurred while attempting to delete the specified environments." +// @router /endpoints/delete [post] +func (handler *Handler) endpointDeleteBatch(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var p endpointDeleteBatchPayload + if err := request.DecodeAndValidateJSONPayload(r, &p); err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + resp := endpointDeleteBatchPartialResponse{ + Deleted: []int{}, + Errors: []int{}, + } + + if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + for _, e := range p.Endpoints { + if err := handler.deleteEndpoint(tx, portainer.EndpointID(e.ID), e.DeleteCluster); err != nil { + resp.Errors = append(resp.Errors, e.ID) + log.Warn().Err(err).Int("environment_id", e.ID).Msg("Unable to remove environment") + + continue + } + + resp.Deleted = append(resp.Deleted, e.ID) + } + + return nil + }); err != nil { + return httperror.InternalServerError("Unable to delete environments", err) + } + + if len(resp.Errors) > 0 { + return response.JSONWithStatus(w, resp, http.StatusPartialContent) + } + + return response.Empty(w) +} + +// @id EndpointDeleteBatchDeprecated +// @summary Remove multiple environments +// @deprecated +// @description Deprecated: use the `POST` endpoint instead. +// @description Remove multiple environments and optionally clean-up associated resources. +// @description **Access policy**: Administrator only. +// @tags endpoints +// @security ApiKeyAuth || jwt +// @accept json +// @produce json +// @param body body endpointDeleteBatchPayload true "List of environments to delete, with optional deleteCluster flag to clean-up associated resources (cloud environments only)" +// @success 204 "Environment(s) successfully deleted." +// @failure 207 {object} endpointDeleteBatchPartialResponse "Partial success. Some environments were deleted successfully, while others failed." +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 403 "Unauthorized access or operation not allowed." +// @failure 500 "Server error occurred while attempting to delete the specified environments." +// @router /endpoints [delete] +func (handler *Handler) endpointDeleteBatchDeprecated(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + return handler.endpointDeleteBatch(w, r) +} + +func (handler *Handler) deleteEndpoint(tx dataservices.DataStoreTx, endpointID portainer.EndpointID, deleteCluster bool) error { + endpoint, err := tx.Endpoint().Endpoint(endpointID) + if tx.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find an environment with the specified identifier inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to read the environment record from the database", err) + } + + if endpoint.TLSConfig.TLS { + folder := strconv.Itoa(int(endpointID)) + if err := handler.FileService.DeleteTLSFiles(folder); err != nil { + log.Error().Err(err).Msgf("Unable to remove TLS files from disk when deleting endpoint %d", endpointID) + } + } + + if err := tx.Snapshot().Delete(endpointID); err != nil { + log.Warn().Err(err).Msg("Unable to remove the snapshot from the database") + } + + handler.ProxyManager.DeleteEndpointProxy(endpoint.ID) + + if err := tx.EndpointRelation().DeleteEndpointRelation(endpoint.ID); err != nil { + log.Warn().Err(err).Msg("Unable to remove environment relation from the database") + } + + for _, tagID := range endpoint.TagIDs { + var tag *portainer.Tag + tag, err = tx.Tag().Read(tagID) + if err == nil { + delete(tag.Endpoints, endpoint.ID) + err = tx.Tag().Update(tagID, tag) + } + + if tx.IsErrObjectNotFound(err) { + log.Warn().Err(err).Msg("Unable to find tag inside the database") + } else if err != nil { + log.Warn().Err(err).Msg("Unable to delete tag relation from the database") + } + } + + edgeGroups, err := tx.EdgeGroup().ReadAll() + if err != nil { + log.Warn().Err(err).Msgf("Unable to retrieve edge groups from the database") + } + + for _, edgeGroup := range edgeGroups { + edgeGroup.EndpointIDs.Remove(endpoint.ID) + + if err := tx.EdgeGroup().Update(edgeGroup.ID, &edgeGroup); err != nil { + log.Warn().Err(err).Msg("Unable to update edge group") + } + } + + edgeStacks, err := tx.EdgeStack().EdgeStacks() + if err != nil { + log.Warn().Err(err).Msg("Unable to retrieve edge stacks from the database") + } + + for _, edgeStack := range edgeStacks { + if err := tx.EdgeStackStatus().Delete(edgeStack.ID, endpoint.ID); err != nil { + log.Warn().Err(err).Msg("Unable to delete edge stack status") + } + } + + registries, err := tx.Registry().ReadAll() + if err != nil { + log.Warn().Err(err).Msg("Unable to retrieve registries from the database") + } + + for idx := range registries { + registry := ®istries[idx] + if _, ok := registry.RegistryAccesses[endpoint.ID]; ok { + delete(registry.RegistryAccesses, endpoint.ID) + + if err := tx.Registry().Update(registry.ID, registry); err != nil { + log.Warn().Err(err).Msg("Unable to update registry accesses") + } + } + } + + if endpointutils.IsEdgeEndpoint(endpoint) { + edgeJobs, err := tx.EdgeJob().ReadAll() + if err != nil { + log.Warn().Err(err).Msg("Unable to retrieve edge jobs from the database") + } + + for idx := range edgeJobs { + edgeJob := &edgeJobs[idx] + if _, ok := edgeJob.Endpoints[endpoint.ID]; ok { + delete(edgeJob.Endpoints, endpoint.ID) + + if err := tx.EdgeJob().Update(edgeJob.ID, edgeJob); err != nil { + log.Warn().Err(err).Msg("Unable to update edge job") + } + } + } + } + + // delete the pending actions + if err := tx.PendingActions().DeleteByEndpointID(endpoint.ID); err != nil { + log.Warn().Err(err).Int("endpointId", int(endpoint.ID)).Msg("Unable to delete pending actions") + } + + if err := tx.Endpoint().DeleteEndpoint(endpointID); err != nil { + return httperror.InternalServerError("Unable to delete the environment from the database", err) + } + + return nil +} diff --git a/api/http/handler/endpoints/endpoint_delete_test.go b/api/http/handler/endpoints/endpoint_delete_test.go new file mode 100644 index 0000000..8537d3b --- /dev/null +++ b/api/http/handler/endpoints/endpoint_delete_test.go @@ -0,0 +1,86 @@ +package endpoints + +import ( + "net/http" + "net/http/httptest" + "strconv" + "sync" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/http/proxy" + "github.com/portainer/portainer/api/internal/testhelpers" + "github.com/portainer/portainer/api/roar" +) + +func TestEndpointDeleteEdgeGroupsConcurrently(t *testing.T) { + t.Parallel() + const endpointsCount = 100 + + _, store := datastore.MustNewTestStore(t, true, false) + + handler := NewHandler(testhelpers.NewTestRequestBouncer()) + handler.DataStore = store + handler.ProxyManager = proxy.NewManager(nil) + handler.ProxyManager.NewProxyFactory(nil, nil, nil, nil, nil, nil, nil, nil, nil) + + // Create all the environments and add them to the same edge group + + var endpointIDs []portainer.EndpointID + + for i := range endpointsCount { + endpointID := portainer.EndpointID(i) + 1 + + if err := store.Endpoint().Create(&portainer.Endpoint{ + ID: endpointID, + Name: "env-" + strconv.Itoa(int(endpointID)), + Type: portainer.EdgeAgentOnDockerEnvironment, + }); err != nil { + t.Fatal("could not create endpoint:", err) + } + + endpointIDs = append(endpointIDs, endpointID) + } + + if err := store.EdgeGroup().Create(&portainer.EdgeGroup{ + ID: 1, + Name: "edgegroup-1", + EndpointIDs: roar.FromSlice(endpointIDs), + }); err != nil { + t.Fatal("could not create edge group:", err) + } + + // Remove the environments concurrently + + var wg sync.WaitGroup + wg.Add(len(endpointIDs)) + + for _, endpointID := range endpointIDs { + go func(ID portainer.EndpointID) { + defer wg.Done() + + req, err := http.NewRequest(http.MethodDelete, "/endpoints/"+strconv.Itoa(int(ID)), nil) + if err != nil { + t.Fail() + return + } + + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + }(endpointID) + } + + wg.Wait() + + // Check that the edge group is consistent + + edgeGroup, err := handler.DataStore.EdgeGroup().Read(1) + if err != nil { + t.Fatal("could not retrieve the edge group:", err) + } + + if edgeGroup.EndpointIDs.Len() > 0 { + t.Fatal("the edge group is not consistent") + } +} diff --git a/api/http/handler/endpoints/endpoint_dockerhub_status.go b/api/http/handler/endpoints/endpoint_dockerhub_status.go new file mode 100644 index 0000000..e028d21 --- /dev/null +++ b/api/http/handler/endpoints/endpoint_dockerhub_status.go @@ -0,0 +1,200 @@ +package endpoints + +import ( + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/client" + "github.com/portainer/portainer/api/internal/endpointutils" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" + "github.com/rs/zerolog/log" + + "github.com/segmentio/encoding/json" +) + +type dockerhubStatusResponse struct { + // Remaiming images to pull + Remaining int `json:"remaining"` + // Daily limit + Limit int `json:"limit"` +} + +// @id endpointDockerhubStatus +// @summary fetch docker pull limits +// @description get docker pull limits for a docker hub registry in the environment +// @description **Access policy**: +// @tags endpoints +// @security ApiKeyAuth +// @security jwt +// @produce json +// @param id path int true "endpoint ID" +// @param registryId path int true "registry ID" +// @success 200 {object} dockerhubStatusResponse "Success" +// @failure 400 "Invalid request" +// @failure 403 "Permission denied" +// @failure 404 "registry or endpoint not found" +// @failure 500 "Server error" +// @router /endpoints/{id}/dockerhub/{registryId} [get] +func (handler *Handler) endpointDockerhubStatus(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid environment identifier route variable", err) + } + + endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) + if handler.DataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find an environment with the specified identifier inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find an environment with the specified identifier inside the database", err) + } + + if !endpointutils.IsLocalEndpoint(endpoint) { + return httperror.BadRequest("Invalid environment type", errors.New("Invalid environment type")) + } + + registryID, err := request.RetrieveNumericRouteVariableValue(r, "registryId") + if err != nil { + return httperror.BadRequest("Invalid registry identifier route variable", err) + } + + var registry *portainer.Registry + + if registryID == 0 { + registry = &portainer.Registry{} + } else { + registry, err = handler.DataStore.Registry().Read(portainer.RegistryID(registryID)) + if handler.DataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find a registry with the specified identifier inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find a registry with the specified identifier inside the database", err) + } + + if registry.Type != portainer.DockerHubRegistry { + return httperror.BadRequest("Invalid registry type", errors.New("Invalid registry type")) + } + } + + if handler.PullLimitCheckDisabled { + return response.JSON(w, &dockerhubStatusResponse{ + Limit: 10, + Remaining: 10, + }) + } + + httpClient := client.NewHTTPClient() + token, err := getDockerHubToken(httpClient, registry) + if err != nil { + return httperror.InternalServerError("Unable to retrieve DockerHub token from DockerHub", err) + } + + resp, err := getDockerHubLimits(httpClient, token) + if err != nil { + return httperror.InternalServerError("Unable to retrieve DockerHub rate limits from DockerHub", err) + } + + return response.JSON(w, resp) +} + +func getDockerHubToken(httpClient *client.HTTPClient, registry *portainer.Registry) (string, error) { + type dockerhubTokenResponse struct { + Token string `json:"token"` + } + + requestURL := "https://auth.docker.io/token?service=registry.docker.io&scope=repository:ratelimitpreview/test:pull" + + req, err := http.NewRequest(http.MethodGet, requestURL, nil) + if err != nil { + return "", err + } + + if registry.Authentication { + req.SetBasicAuth(registry.Username, registry.Password) + } + + resp, err := httpClient.Do(req) + if err != nil { + return "", err + } + defer func() { + if err := resp.Body.Close(); err != nil { + log.Warn().Err(err).Msg("failed to close response body") + } + }() + + if resp.StatusCode != http.StatusOK { + return "", errors.New("failed fetching dockerhub token") + } + + var data dockerhubTokenResponse + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + return "", err + } + + return data.Token, nil +} + +func getDockerHubLimits(httpClient *client.HTTPClient, token string) (*dockerhubStatusResponse, error) { + requestURL := "https://registry-1.docker.io/v2/ratelimitpreview/test/manifests/latest" + + req, err := http.NewRequest(http.MethodHead, requestURL, nil) + if err != nil { + return nil, err + } + + req.Header.Add("Authorization", "Bearer "+token) + + resp, err := httpClient.Do(req) + if err != nil { + return nil, err + } + + _, _ = io.Copy(io.Discard, resp.Body) + + if err := resp.Body.Close(); err != nil { + log.Warn().Err(err).Msg("failed to close response body") + } + + if resp.StatusCode != http.StatusOK { + return nil, errors.New("failed fetching dockerhub limits") + } + + // An error with rateLimit-Limit or RateLimit-Remaining is likely for dockerhub pro accounts where there is no rate limit. + // In that specific case the headers will not be present. Don't bubble up the error as its normal + // See: https://docs.docker.com/docker-hub/download-rate-limit/ + rateLimit, err := parseRateLimitHeader(resp.Header, "RateLimit-Limit") + if err != nil { + return nil, nil + } + + rateLimitRemaining, err := parseRateLimitHeader(resp.Header, "RateLimit-Remaining") + if err != nil { + return nil, nil + } + + return &dockerhubStatusResponse{ + Limit: rateLimit, + Remaining: rateLimitRemaining, + }, nil +} + +func parseRateLimitHeader(headers http.Header, headerKey string) (int, error) { + headerValue := headers.Get(headerKey) + if headerValue == "" { + return 0, fmt.Errorf("Missing %s header", headerKey) + } + + matches := strings.Split(headerValue, ";") + value, err := strconv.Atoi(matches[0]) + if err != nil { + return 0, err + } + + return value, nil +} diff --git a/api/http/handler/endpoints/endpoint_force_update_service.go b/api/http/handler/endpoints/endpoint_force_update_service.go new file mode 100644 index 0000000..8543f4c --- /dev/null +++ b/api/http/handler/endpoints/endpoint_force_update_service.go @@ -0,0 +1,125 @@ +package endpoints + +import ( + "context" + "net/http" + "strings" + + portaineree "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/docker/consts" + "github.com/portainer/portainer/api/docker/images" + "github.com/portainer/portainer/api/logs" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" + "github.com/rs/zerolog/log" + + dockertypes "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/filters" +) + +type forceUpdateServicePayload struct { + // ServiceId to update + ServiceID string + // PullImage if true will pull the image + PullImage bool +} + +func (payload *forceUpdateServicePayload) Validate(r *http.Request) error { + return nil +} + +// @id endpointForceUpdateService +// @summary force update a docker service +// @description force update a docker service +// @description **Access policy**: authenticated +// @tags endpoints +// @security ApiKeyAuth +// @security jwt +// @accept json +// @produce json +// @param id path int true "endpoint identifier" +// @param body body forceUpdateServicePayload true "details" +// @success 200 {object} swarm.ServiceUpdateResponse "Success" +// @failure 400 "Invalid request" +// @failure 403 "Permission denied" +// @failure 404 "endpoint not found" +// @failure 500 "Server error" +// @router /endpoints/{id}/forceupdateservice [put] +func (handler *Handler) endpointForceUpdateService(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid environment identifier route variable", err) + } + + var payload forceUpdateServicePayload + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + endpoint, err := handler.DataStore.Endpoint().Endpoint(portaineree.EndpointID(endpointID)) + if handler.DataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find an environment with the specified identifier inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find an environment with the specified identifier inside the database", err) + } + + err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) + if err != nil { + return httperror.Forbidden("Permission denied to force update service", err) + } + + dockerClient, err := handler.DockerClientFactory.CreateClient(endpoint, "", nil) + if err != nil { + return httperror.InternalServerError("Error creating docker client", err) + } + closeClient := true + defer func() { + if closeClient { + logs.CloseAndLogErr(dockerClient) + } + }() + + service, _, err := dockerClient.ServiceInspectWithRaw(context.Background(), payload.ServiceID, dockertypes.ServiceInspectOptions{InsertDefaults: true}) + if err != nil { + return httperror.InternalServerError("Error looking up service", err) + } + + service.Spec.TaskTemplate.ForceUpdate++ + + if payload.PullImage { + service.Spec.TaskTemplate.ContainerSpec.Image = strings.Split(service.Spec.TaskTemplate.ContainerSpec.Image, "@sha")[0] + } + + newService, err := dockerClient.ServiceUpdate(context.Background(), payload.ServiceID, service.Version, service.Spec, dockertypes.ServiceUpdateOptions{QueryRegistry: true}) + if err != nil { + return httperror.InternalServerError("Error force update service", err) + } + + // The goroutine will close the client + closeClient = false + + go func() { + defer logs.CloseAndLogErr(dockerClient) + + images.EvictImageStatus(payload.ServiceID) + images.EvictImageStatus(service.Spec.Labels[consts.SwarmStackNameLabel]) + + // ignore errors from this cleanup function, log them instead + containers, err := dockerClient.ContainerList(context.TODO(), container.ListOptions{ + All: true, + Filters: filters.NewArgs(filters.Arg("label", consts.SwarmServiceIDLabel+"="+payload.ServiceID)), + }) + if err != nil { + log.Warn().Err(err).Str("Environment", endpoint.Name).Msg("Error listing containers") + } + + for _, container := range containers { + images.EvictImageStatus(container.ID) + } + }() + + return response.JSON(w, newService) +} diff --git a/api/http/handler/endpoints/endpoint_inspect.go b/api/http/handler/endpoints/endpoint_inspect.go new file mode 100644 index 0000000..81d2cb2 --- /dev/null +++ b/api/http/handler/endpoints/endpoint_inspect.go @@ -0,0 +1,88 @@ +package endpoints + +import ( + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/internal/endpointutils" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" + "github.com/rs/zerolog/log" +) + +// @id EndpointInspect +// @summary Inspect an environment(endpoint) +// @description Retrieve details about an environment(endpoint). +// @description **Access policy**: restricted +// @tags endpoints +// @security ApiKeyAuth +// @security jwt +// @produce json +// @param id path int true "Environment(Endpoint) identifier" +// @param excludeSnapshot query bool false "if true, the snapshot data won't be retrieved" +// @success 200 {object} portainer.Endpoint "Success" +// @failure 400 "Invalid request" +// @failure 404 "Environment(Endpoint) not found" +// @failure 500 "Server error" +// @router /endpoints/{id} [get] +func (handler *Handler) endpointInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid environment identifier route variable", err) + } + + endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) + if handler.DataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find an environment with the specified identifier inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find an environment with the specified identifier inside the database", err) + } + + if err := handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint); err != nil { + return httperror.Forbidden("Permission denied to access environment", err) + } + + settings, err := handler.DataStore.Settings().Settings() + if err != nil { + return httperror.InternalServerError("Unable to retrieve settings from the database", err) + } + + hideFields(endpoint) + endpointutils.UpdateEdgeEndpointHeartbeat(endpoint, settings) + endpoint.ComposeSyntaxMaxVersion = handler.ComposeStackManager.ComposeSyntaxMaxVersion() + + excludeSnapshot, _ := request.RetrieveBooleanQueryParameter(r, "excludeSnapshot", true) + + if !excludeSnapshot { + if err := handler.SnapshotService.FillSnapshotData(endpoint, false); err != nil { + return httperror.InternalServerError("Unable to add snapshot data", err) + } + } + + if endpointutils.IsKubernetesEndpoint(endpoint) { + isServerMetricsDetected := endpoint.Kubernetes.Flags.IsServerMetricsDetected + isServerStorageDetected := endpoint.Kubernetes.Flags.IsServerStorageDetected + if (!isServerMetricsDetected || !isServerStorageDetected) && handler.K8sClientFactory != nil { + if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + if !isServerMetricsDetected { + endpointutils.InitialMetricsDetection(tx, endpoint, handler.K8sClientFactory) + } + + if !isServerStorageDetected { + endpointutils.InitialStorageDetection(tx, handler.DataStore, endpoint, handler.K8sClientFactory) + } + + return nil + }); err != nil { + log.Err(err).Msg("failed to persist initial kube detection") + } + } + } + + // Execute endpoint pending actions + handler.PendingActionsService.Execute(endpoint.ID) + + return response.JSON(w, endpoint) +} diff --git a/api/http/handler/endpoints/endpoint_list.go b/api/http/handler/endpoints/endpoint_list.go new file mode 100644 index 0000000..2996df8 --- /dev/null +++ b/api/http/handler/endpoints/endpoint_list.go @@ -0,0 +1,157 @@ +package endpoints + +import ( + "net/http" + "strconv" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/endpointutils" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +const ( + EdgeDeviceIntervalMultiplier = 2 + EdgeDeviceIntervalAdd = 20 +) + +// @id EndpointList +// @summary List environments(endpoints) +// @description List all environments(endpoints) based on the current user authorizations. Will +// @description return all environments(endpoints) if using an administrator or team leader account otherwise it will +// @description only return authorized environments(endpoints). +// @description **Access policy**: restricted +// @tags endpoints +// @security ApiKeyAuth +// @security jwt +// @produce json +// @param start query int false "Start searching from" +// @param limit query int false "Limit results to this value" +// @param sort query sortKey false "Sort results by this value" Enum("Name", "Group", "Status", "LastCheckIn", "EdgeID") +// @param order query string false "Order sorted results by desc/asc" Enum("asc", "desc") +// @param search query string false "Search query" +// @param groupIds query []int false "List environments(endpoints) of these groups" +// @param status query []int false "List environments(endpoints) by this status" +// @param types query []portainer.EndpointType false "List environments(endpoints) of this type" +// @param platformTypes query []portainer.PlatformType false "Filter environments by platform type" +// @param outdated query bool false "If true, return only environments with an outdated agent" +// @param excludeGroupIds query []int false "Exclude environments of these groups" +// @param tagIds query []int false "search environments(endpoints) with these tags (depends on tagsPartialMatch)" +// @param tagsPartialMatch query bool false "If true, will return environment(endpoint) which has one of tagIds, if false (or missing) will return only environments(endpoints) that has all the tags" +// @param endpointIds query []int false "will return only these environments(endpoints)" +// @param excludeIds query []int false "will exclude these environments(endpoints)" +// @param agentVersions query []string false "will return only environments with on of these agent versions" +// @param edgeAsync query bool false "if exists true show only edge async agents, false show only standard edge agents. if missing, will show both types (relevant only for edge agents)" +// @param edgeDeviceUntrusted query bool false "if true, show only untrusted edge agents, if false show only trusted edge agents (relevant only for edge agents)" +// @param edgeCheckInPassedSeconds query number false "if bigger then zero, show only edge agents that checked-in in the last provided seconds (relevant only for edge agents)" +// @param excludeSnapshots query bool false "if true, the snapshot data won't be retrieved" +// @param name query string false "will return only environments(endpoints) with this name" +// @param edgeStackId query int false "will return the environments of the specified edge stack" +// @param edgeStackStatus query portainer.EdgeStackStatusType false "only applied when edgeStackId exists. Filter the returned environments based on their deployment status in the stack (not the environment status!)" Enum("Pending", "Ok", "Error", "Acknowledged", "Remove", "RemoteUpdateSuccess", "ImagesPulled") +// @param edgeGroupIds query []int false "List environments(endpoints) of these edge groups" +// @param excludeEdgeGroupIds query []int false "Exclude environments(endpoints) of these edge groups" +// @success 200 {array} portainer.Endpoint "Endpoints" +// @failure 500 "Server error" +// @router /endpoints [get] +func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + start, _ := request.RetrieveNumericQueryParameter(r, "start", true) + if start != 0 { + start-- + } + + limit, _ := request.RetrieveNumericQueryParameter(r, "limit", true) + sortField, _ := request.RetrieveQueryParameter(r, "sort", true) + sortOrder, _ := request.RetrieveQueryParameter(r, "order", true) + + endpointGroups, err := handler.DataStore.EndpointGroup().ReadAll() + if err != nil { + return httperror.InternalServerError("Unable to retrieve environment groups from the database", err) + } + + edgeGroups, err := handler.DataStore.EdgeGroup().ReadAll() + if err != nil { + return httperror.InternalServerError("Unable to retrieve edge groups from the database", err) + } + + endpoints, err := handler.DataStore.Endpoint().Endpoints() + if err != nil { + return httperror.InternalServerError("Unable to retrieve environments from the database", err) + } + + settings, err := handler.DataStore.Settings().Settings() + if err != nil { + return httperror.InternalServerError("Unable to retrieve settings from the database", err) + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) + } + + query, err := parseQuery(r) + if err != nil { + return httperror.BadRequest("Invalid query parameters", err) + } + + filteredEndpoints, totalAvailableEndpoints, err := handler.filterEndpointsByQuery(endpoints, query, endpointGroups, edgeGroups, settings, securityContext) + if err != nil { + return httperror.InternalServerError("Unable to filter endpoints", err) + } + filteredEndpoints = security.FilterEndpoints(filteredEndpoints, endpointGroups, securityContext) + + sortEnvironmentsByField(filteredEndpoints, endpointGroups, getSortKey(sortField), sortOrder == "desc", settings) + + filteredEndpointCount := len(filteredEndpoints) + + paginatedEndpoints := paginateEndpoints(filteredEndpoints, start, limit) + + for idx := range paginatedEndpoints { + hideFields(&paginatedEndpoints[idx]) + + paginatedEndpoints[idx].ComposeSyntaxMaxVersion = handler.ComposeStackManager.ComposeSyntaxMaxVersion() + if paginatedEndpoints[idx].EdgeCheckinInterval == 0 { + paginatedEndpoints[idx].EdgeCheckinInterval = settings.EdgeAgentCheckinInterval + } + + endpointutils.UpdateEdgeEndpointHeartbeat(&paginatedEndpoints[idx], settings) + + if !query.excludeSnapshots { + if err := handler.SnapshotService.FillSnapshotData(&paginatedEndpoints[idx], false); err != nil { + return httperror.InternalServerError("Unable to add snapshot data", err) + } + } + } + + w.Header().Set("X-Total-Count", strconv.Itoa(filteredEndpointCount)) + w.Header().Set("X-Total-Available", strconv.Itoa(totalAvailableEndpoints)) + + return response.JSON(w, paginatedEndpoints) +} + +func paginateEndpoints(endpoints []portainer.Endpoint, start, limit int) []portainer.Endpoint { + if limit == 0 { + return endpoints + } + + endpointCount := len(endpoints) + + start = min(max(start, 0), endpointCount) + end := min(start+limit, endpointCount) + + return endpoints[start:end] +} + +func getEndpointGroup(groupID portainer.EndpointGroupID, groups []portainer.EndpointGroup) portainer.EndpointGroup { + var endpointGroup portainer.EndpointGroup + for _, group := range groups { + if group.ID == groupID { + endpointGroup = group + + break + } + } + + return endpointGroup +} diff --git a/api/http/handler/endpoints/endpoint_list_test.go b/api/http/handler/endpoints/endpoint_list_test.go new file mode 100644 index 0000000..28e4d97 --- /dev/null +++ b/api/http/handler/endpoints/endpoint_list_test.go @@ -0,0 +1,239 @@ +package endpoints + +import ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/snapshot" + "github.com/portainer/portainer/api/internal/testhelpers" + + "github.com/segmentio/encoding/json" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type endpointListTest struct { + title string + expected []portainer.EndpointID +} + +func Test_EndpointList_AgentVersion(t *testing.T) { + t.Parallel() + version1Endpoint := portainer.Endpoint{ + ID: 1, + GroupID: 1, + Type: portainer.AgentOnDockerEnvironment, + Agent: portainer.EnvironmentAgentData{Version: "1.0.0"}, + } + version2Endpoint := portainer.Endpoint{ + ID: 2, + GroupID: 1, + Type: portainer.AgentOnDockerEnvironment, + Agent: portainer.EnvironmentAgentData{Version: "2.0.0"}, + } + noVersionEndpoint := portainer.Endpoint{ID: 3, Type: portainer.AgentOnDockerEnvironment, GroupID: 1} + notAgentEnvironments := portainer.Endpoint{ID: 4, Type: portainer.DockerEnvironment, GroupID: 1} + + handler := setupEndpointListHandler(t, []portainer.Endpoint{ + notAgentEnvironments, + version1Endpoint, + version2Endpoint, + noVersionEndpoint, + }) + + type endpointListAgentVersionTest struct { + endpointListTest + filter []string + } + + tests := []endpointListAgentVersionTest{ + { + endpointListTest{ + "should show version 1 agent endpoints and non-agent endpoints", + []portainer.EndpointID{version1Endpoint.ID, notAgentEnvironments.ID}, + }, + []string{version1Endpoint.Agent.Version}, + }, + { + endpointListTest{ + "should show version 2 endpoints and non-agent endpoints", + []portainer.EndpointID{version2Endpoint.ID, notAgentEnvironments.ID}, + }, + []string{version2Endpoint.Agent.Version}, + }, + { + endpointListTest{ + "should show version 1 and 2 endpoints and non-agent endpoints", + []portainer.EndpointID{version2Endpoint.ID, notAgentEnvironments.ID, version1Endpoint.ID}, + }, + []string{version2Endpoint.Agent.Version, version1Endpoint.Agent.Version}, + }, + } + + for _, test := range tests { + t.Run(test.title, func(t *testing.T) { + is := assert.New(t) + + var query strings.Builder + for _, filter := range test.filter { + _, _ = query.WriteString("agentVersions[]=") + _, _ = query.WriteString(filter) + _, _ = query.WriteRune('&') + } + + req := buildEndpointListRequest(query.String()) + + resp, err := doEndpointListRequest(req, handler, is) + require.NoError(t, err) + + is.Len(resp, len(test.expected)) + + respIds := []portainer.EndpointID{} + + for _, endpoint := range resp { + respIds = append(respIds, endpoint.ID) + } + + is.ElementsMatch(test.expected, respIds) + }) + } +} + +func Test_endpointList_edgeFilter(t *testing.T) { + t.Parallel() + trustedEdgeAsync := portainer.Endpoint{ID: 1, UserTrusted: true, Edge: portainer.EnvironmentEdgeSettings{AsyncMode: true}, GroupID: 1, Type: portainer.EdgeAgentOnDockerEnvironment} + untrustedEdgeAsync := portainer.Endpoint{ID: 2, UserTrusted: false, Edge: portainer.EnvironmentEdgeSettings{AsyncMode: true}, GroupID: 1, Type: portainer.EdgeAgentOnDockerEnvironment} + regularUntrustedEdgeStandard := portainer.Endpoint{ID: 3, UserTrusted: false, Edge: portainer.EnvironmentEdgeSettings{AsyncMode: false}, GroupID: 1, Type: portainer.EdgeAgentOnDockerEnvironment} + regularTrustedEdgeStandard := portainer.Endpoint{ID: 4, UserTrusted: true, Edge: portainer.EnvironmentEdgeSettings{AsyncMode: false}, GroupID: 1, Type: portainer.EdgeAgentOnDockerEnvironment} + regularEndpoint := portainer.Endpoint{ID: 5, GroupID: 1, Type: portainer.DockerEnvironment} + + handler := setupEndpointListHandler(t, []portainer.Endpoint{ + trustedEdgeAsync, + untrustedEdgeAsync, + regularUntrustedEdgeStandard, + regularTrustedEdgeStandard, + regularEndpoint, + }) + + type endpointListEdgeTest struct { + endpointListTest + edgeAsync *bool + edgeDeviceUntrusted bool + } + + tests := []endpointListEdgeTest{ + { + endpointListTest: endpointListTest{ + "should show all endpoints expect of the untrusted devices", + []portainer.EndpointID{trustedEdgeAsync.ID, regularTrustedEdgeStandard.ID, regularEndpoint.ID}, + }, + }, + { + endpointListTest: endpointListTest{ + "should show only trusted edge async agents and regular endpoints", + []portainer.EndpointID{trustedEdgeAsync.ID, regularEndpoint.ID}, + }, + edgeAsync: new(true), + }, + { + endpointListTest: endpointListTest{ + "should show only untrusted edge devices and regular endpoints", + []portainer.EndpointID{untrustedEdgeAsync.ID, regularEndpoint.ID}, + }, + edgeAsync: new(true), + edgeDeviceUntrusted: true, + }, + { + endpointListTest: endpointListTest{ + "should show no edge devices", + []portainer.EndpointID{regularEndpoint.ID, regularTrustedEdgeStandard.ID}, + }, + edgeAsync: new(false), + }, + } + + for _, test := range tests { + t.Run(test.title, func(t *testing.T) { + is := assert.New(t) + + query := fmt.Sprintf("edgeDeviceUntrusted=%v&", test.edgeDeviceUntrusted) + if test.edgeAsync != nil { + query += fmt.Sprintf("edgeAsync=%v&", *test.edgeAsync) + } + + req := buildEndpointListRequest(query) + resp, err := doEndpointListRequest(req, handler, is) + require.NoError(t, err) + + is.Len(resp, len(test.expected)) + + respIds := []portainer.EndpointID{} + + for _, endpoint := range resp { + respIds = append(respIds, endpoint.ID) + } + + is.ElementsMatch(test.expected, respIds) + }) + } +} + +func setupEndpointListHandler(t *testing.T, endpoints []portainer.Endpoint) *Handler { + _, store := datastore.MustNewTestStore(t, true, true) + + for _, endpoint := range endpoints { + err := store.Endpoint().Create(&endpoint) + require.NoError(t, err, "error creating environment") + } + + err := store.User().Create(&portainer.User{Username: "admin", Role: portainer.AdministratorRole}) + require.NoError(t, err, "error creating a user") + + bouncer := testhelpers.NewTestRequestBouncer() + + handler := NewHandler(bouncer) + handler.DataStore = store + handler.ComposeStackManager = testhelpers.NewComposeStackManager() + handler.SnapshotService, _ = snapshot.NewService("1s", store, nil, nil, nil) + + return handler +} + +func buildEndpointListRequest(query string) *http.Request { + req := httptest.NewRequest(http.MethodGet, "/endpoints?"+query, nil) + + ctx := security.StoreTokenData(req, &portainer.TokenData{ID: 1, Username: "admin", Role: 1}) + req = req.WithContext(ctx) + + restrictedCtx := security.StoreRestrictedRequestContext(req, &security.RestrictedRequestContext{UserID: 1, IsAdmin: true}) + req = req.WithContext(restrictedCtx) + + testhelpers.AddTestSecurityCookie(req, "Bearer dummytoken") + + return req +} + +func doEndpointListRequest(req *http.Request, h *Handler, is *assert.Assertions) ([]portainer.Endpoint, error) { + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + is.Equal(http.StatusOK, rr.Code, "Status should be 200") + body, err := io.ReadAll(rr.Body) + if err != nil { + return nil, err + } + + resp := []portainer.Endpoint{} + if err := json.Unmarshal(body, &resp); err != nil { + return nil, err + } + + return resp, nil +} diff --git a/api/http/handler/endpoints/endpoint_registries_list.go b/api/http/handler/endpoints/endpoint_registries_list.go new file mode 100644 index 0000000..6940576 --- /dev/null +++ b/api/http/handler/endpoints/endpoint_registries_list.go @@ -0,0 +1,219 @@ +package endpoints + +import ( + "net/http" + "slices" + + "github.com/pkg/errors" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/endpointutils" + "github.com/portainer/portainer/api/kubernetes" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +// @id endpointRegistriesList +// @summary List Registries on environment +// @description List all registries based on the current user authorizations in current environment. +// @description **Access policy**: authenticated +// @tags endpoints +// @param namespace query string false "required if kubernetes environment, will show registries by namespace" +// @security ApiKeyAuth +// @security jwt +// @produce json +// @param id path int true "Environment(Endpoint) identifier" +// @success 200 {array} portainer.Registry "Success" +// @failure 500 "Server error" +// @router /endpoints/{id}/registries [get] +func (handler *Handler) endpointRegistriesList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid environment identifier route variable", err) + } + + var registries []portainer.Registry + err = handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error { + registries, err = handler.listRegistries(tx, r, portainer.EndpointID(endpointID)) + return err + }) + + return response.TxResponse(w, registries, err) +} + +func (handler *Handler) listRegistries(tx dataservices.DataStoreTx, r *http.Request, endpointID portainer.EndpointID) ([]portainer.Registry, error) { + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return nil, httperror.InternalServerError("Unable to retrieve info from request context", err) + } + + user, err := tx.User().Read(securityContext.UserID) + if err != nil { + return nil, httperror.InternalServerError("Unable to retrieve user from the database", err) + } + + endpoint, err := tx.Endpoint().Endpoint(endpointID) + if tx.IsErrObjectNotFound(err) { + return nil, httperror.NotFound("Unable to find an environment with the specified identifier inside the database", err) + } else if err != nil { + return nil, httperror.InternalServerError("Unable to find an environment with the specified identifier inside the database", err) + } + + isAdmin := securityContext.IsAdmin + + registries, err := tx.Registry().ReadAll() + if err != nil { + return nil, httperror.InternalServerError("Unable to retrieve registries from the database", err) + } + + registries, handleError := handler.filterRegistriesByAccess(tx, r, registries, endpoint, user, securityContext.UserMemberships) + if handleError != nil { + return nil, handleError + } + + for idx := range registries { + hideRegistryFields(®istries[idx], !isAdmin) + } + + return registries, err +} + +func (handler *Handler) filterRegistriesByAccess(tx dataservices.DataStoreTx, r *http.Request, registries []portainer.Registry, endpoint *portainer.Endpoint, user *portainer.User, memberships []portainer.TeamMembership) ([]portainer.Registry, *httperror.HandlerError) { + if !endpointutils.IsKubernetesEndpoint(endpoint) { + return security.FilterRegistries(registries, user, memberships, endpoint.ID), nil + } + + return handler.filterKubernetesEndpointRegistries(tx, r, registries, endpoint, user, memberships) +} + +func (handler *Handler) filterKubernetesEndpointRegistries(tx dataservices.DataStoreTx, r *http.Request, registries []portainer.Registry, endpoint *portainer.Endpoint, user *portainer.User, memberships []portainer.TeamMembership) ([]portainer.Registry, *httperror.HandlerError) { + namespaceParam, _ := request.RetrieveQueryParameter(r, "namespace", true) + isAdmin, err := security.IsAdmin(r) + if err != nil { + return nil, httperror.InternalServerError("Unable to check user role", err) + } + + if namespaceParam != "" { + if authorized, err := handler.isNamespaceAuthorized(endpoint, namespaceParam, user.ID, memberships, isAdmin); err != nil { + return nil, httperror.NotFound("Unable to check for namespace authorization", err) + } else if !authorized { + return nil, httperror.Forbidden("User is not authorized to use namespace", errors.New("user is not authorized to use namespace")) + } + + return filterRegistriesByNamespaces(registries, endpoint.ID, []string{namespaceParam}), nil + } + + if isAdmin { + return registries, nil + } + + return handler.filterKubernetesRegistriesByUserRole(tx, r, registries, endpoint, user) +} + +func (handler *Handler) isNamespaceAuthorized(endpoint *portainer.Endpoint, namespace string, userId portainer.UserID, memberships []portainer.TeamMembership, isAdmin bool) (bool, error) { + if isAdmin || namespace == "" { + return true, nil + } + + if !endpoint.Kubernetes.Configuration.RestrictDefaultNamespace && namespace == kubernetes.DefaultNamespace { + return true, nil + } + + kcl, err := handler.K8sClientFactory.GetPrivilegedKubeClient(endpoint) + if err != nil { + return false, errors.Wrap(err, "unable to retrieve kubernetes client") + } + + accessPolicies, err := kcl.GetNamespaceAccessPolicies() + if err != nil { + return false, errors.Wrap(err, "unable to retrieve environment's namespaces policies") + } + + namespacePolicy, ok := accessPolicies[namespace] + if !ok { + return false, nil + } + + return security.AuthorizedAccess(userId, memberships, namespacePolicy.UserAccessPolicies, namespacePolicy.TeamAccessPolicies), nil +} + +func filterRegistriesByNamespaces(registries []portainer.Registry, endpointId portainer.EndpointID, namespaces []string) []portainer.Registry { + filteredRegistries := []portainer.Registry{} + + for _, registry := range registries { + if registryAccessPoliciesContainsNamespace(registry.RegistryAccesses[endpointId], namespaces) { + filteredRegistries = append(filteredRegistries, registry) + } + } + + return filteredRegistries +} + +func registryAccessPoliciesContainsNamespace(registryAccess portainer.RegistryAccessPolicies, namespaces []string) bool { + for _, authorizedNamespace := range registryAccess.Namespaces { + if slices.Contains(namespaces, authorizedNamespace) { + return true + } + } + return false +} + +func (handler *Handler) filterKubernetesRegistriesByUserRole(tx dataservices.DataStoreTx, r *http.Request, registries []portainer.Registry, endpoint *portainer.Endpoint, user *portainer.User) ([]portainer.Registry, *httperror.HandlerError) { + err := handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) + if errors.Is(err, security.ErrAuthorizationRequired) { + return nil, httperror.Forbidden("User is not authorized", err) + } + if err != nil { + return nil, httperror.InternalServerError("Unable to retrieve info from request context", err) + } + + userNamespaces, err := handler.userNamespaces(tx, endpoint, user) + if err != nil { + return nil, httperror.InternalServerError("unable to retrieve user namespaces", err) + } + + return filterRegistriesByNamespaces(registries, endpoint.ID, userNamespaces), nil +} + +func (handler *Handler) userNamespaces(tx dataservices.DataStoreTx, endpoint *portainer.Endpoint, user *portainer.User) ([]string, error) { + kcl, err := handler.K8sClientFactory.GetPrivilegedKubeClient(endpoint) + if err != nil { + return nil, err + } + + namespaceAuthorizations, err := kcl.GetNamespaceAccessPolicies() + if err != nil { + return nil, err + } + + userMemberships, err := tx.TeamMembership().TeamMembershipsByUserID(user.ID) + if err != nil { + return nil, err + } + + var userNamespaces []string + for namespace, namespaceAuthorization := range namespaceAuthorizations { + if _, ok := namespaceAuthorization.UserAccessPolicies[user.ID]; ok { + userNamespaces = append(userNamespaces, namespace) + continue + } + for _, userTeam := range userMemberships { + if _, ok := namespaceAuthorization.TeamAccessPolicies[userTeam.TeamID]; ok { + userNamespaces = append(userNamespaces, namespace) + continue + } + } + } + return userNamespaces, nil +} + +func hideRegistryFields(registry *portainer.Registry, hideAccesses bool) { + registry.Password = "" + registry.ManagementConfiguration = nil + if hideAccesses { + registry.RegistryAccesses = nil + } +} diff --git a/api/http/handler/endpoints/endpoint_registry_access.go b/api/http/handler/endpoints/endpoint_registry_access.go new file mode 100644 index 0000000..3389853 --- /dev/null +++ b/api/http/handler/endpoints/endpoint_registry_access.go @@ -0,0 +1,186 @@ +package endpoints + +import ( + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/registryutils" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +type registryAccessPayload struct { + UserAccessPolicies portainer.UserAccessPolicies + TeamAccessPolicies portainer.TeamAccessPolicies + Namespaces []string +} + +func (payload *registryAccessPayload) Validate(r *http.Request) error { + return nil +} + +// @id endpointRegistryAccess +// @summary update registry access for environment +// @description **Access policy**: authenticated +// @tags endpoints +// @security ApiKeyAuth +// @security jwt +// @accept json +// @produce json +// @param id path int true "Environment(Endpoint) identifier" +// @param registryId path int true "Registry identifier" +// @param body body registryAccessPayload true "details" +// @success 204 "Success" +// @failure 400 "Invalid request" +// @failure 403 "Permission denied" +// @failure 404 "Endpoint not found" +// @failure 500 "Server error" +// @router /endpoints/{id}/registries/{registryId} [put] +func (handler *Handler) endpointRegistryAccess(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid environment identifier route variable", err) + } + + registryID, err := request.RetrieveNumericRouteVariableValue(r, "registryId") + if err != nil { + return httperror.BadRequest("Invalid registry identifier route variable", err) + } + + err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + return handler.updateRegistryAccess(tx, r, portainer.EndpointID(endpointID), portainer.RegistryID(registryID)) + }) + + return response.TxEmptyResponse(w, err) +} + +func (handler *Handler) updateRegistryAccess(tx dataservices.DataStoreTx, r *http.Request, endpointID portainer.EndpointID, registryID portainer.RegistryID) error { + endpoint, err := tx.Endpoint().Endpoint(endpointID) + if tx.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find an environment with the specified identifier inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find an environment with the specified identifier inside the database", err) + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) + } + + err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) + if err != nil { + return httperror.Forbidden("Permission denied to access environment", err) + } + + if !securityContext.IsAdmin { + return httperror.Forbidden("User is not authorized", err) + } + + registry, err := tx.Registry().Read(registryID) + if tx.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find an environment with the specified identifier inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find an environment with the specified identifier inside the database", err) + } + + var payload registryAccessPayload + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + if registry.RegistryAccesses == nil { + registry.RegistryAccesses = portainer.RegistryAccesses{} + } + + if _, ok := registry.RegistryAccesses[endpoint.ID]; !ok { + registry.RegistryAccesses[endpoint.ID] = portainer.RegistryAccessPolicies{} + } + + registryAccess := registry.RegistryAccesses[endpoint.ID] + + if endpoint.Type == portainer.KubernetesLocalEnvironment || endpoint.Type == portainer.AgentOnKubernetesEnvironment || endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment { + err := handler.updateKubeAccess(endpoint, registry, registryAccess.Namespaces, payload.Namespaces) + if err != nil { + return httperror.InternalServerError("Unable to update kube access policies", err) + } + + registryAccess.Namespaces = payload.Namespaces + } else { + registryAccess.UserAccessPolicies = payload.UserAccessPolicies + registryAccess.TeamAccessPolicies = payload.TeamAccessPolicies + } + + registry.RegistryAccesses[endpointID] = registryAccess + + return tx.Registry().Update(registry.ID, registry) +} + +func (handler *Handler) updateKubeAccess(endpoint *portainer.Endpoint, registry *portainer.Registry, oldNamespaces, newNamespaces []string) error { + cli, err := handler.K8sClientFactory.GetPrivilegedKubeClient(endpoint) + if err != nil { + return err + } + + return applyKubeRegistryAccess(cli, registry, oldNamespaces, newNamespaces) +} + +func applyKubeRegistryAccess(cli portainer.KubeClient, registry *portainer.Registry, oldNamespaces, newNamespaces []string) error { + oldNamespacesSet := toSet(oldNamespaces) + newNamespacesSet := toSet(newNamespaces) + + namespacesToRemove := setDifference(oldNamespacesSet, newNamespacesSet) + namespacesToAdd := setDifference(newNamespacesSet, oldNamespacesSet) + + for namespace := range namespacesToRemove { + secretName := registryutils.RegistrySecretName(registry.ID) + + if err := cli.RemoveImagePullSecretFromServiceAccount(namespace, "default", secretName); err != nil { + return err + } + + if err := cli.DeleteRegistrySecret(registry.ID, namespace); err != nil { + return err + } + } + + for namespace := range namespacesToAdd { + secretName := registryutils.RegistrySecretName(registry.ID) + + if err := cli.CreateRegistrySecret(registry, namespace); err != nil { + return err + } + + if err := cli.AddImagePullSecretToServiceAccount(namespace, "default", secretName); err != nil { + return err + } + } + + return nil +} + +type stringSet map[string]bool + +func toSet(list []string) stringSet { + set := stringSet{} + for _, el := range list { + set[el] = true + } + return set +} + +// setDifference returns the set difference tagsA - tagsB +func setDifference(setA stringSet, setB stringSet) stringSet { + set := stringSet{} + + for el := range setA { + if !setB[el] { + set[el] = true + } + } + + return set +} diff --git a/api/http/handler/endpoints/endpoint_registry_access_test.go b/api/http/handler/endpoints/endpoint_registry_access_test.go new file mode 100644 index 0000000..f2a3058 --- /dev/null +++ b/api/http/handler/endpoints/endpoint_registry_access_test.go @@ -0,0 +1,169 @@ +package endpoints + +import ( + "errors" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// spyKubeClient implements portainer.KubeClient for testing applyKubeRegistryAccess. +// It embeds the interface so unimplemented methods panic, and overrides only the +// four methods exercised by applyKubeRegistryAccess. +type spyKubeClient struct { + portainer.KubeClient + + createSecretErrors map[string]error + deleteSecretErrors map[string]error + addPullSecretErrors map[string]error + removePullSecretErrors map[string]error + + createdSecrets []string + deletedSecrets []string + addedPullSecrets []string + removedPullSecrets []string +} + +func newSpyKubeClient() *spyKubeClient { + return &spyKubeClient{ + createSecretErrors: make(map[string]error), + deleteSecretErrors: make(map[string]error), + addPullSecretErrors: make(map[string]error), + removePullSecretErrors: make(map[string]error), + } +} + +func (s *spyKubeClient) CreateRegistrySecret(_ *portainer.Registry, namespace string) error { + s.createdSecrets = append(s.createdSecrets, namespace) + return s.createSecretErrors[namespace] +} + +func (s *spyKubeClient) DeleteRegistrySecret(_ portainer.RegistryID, namespace string) error { + s.deletedSecrets = append(s.deletedSecrets, namespace) + return s.deleteSecretErrors[namespace] +} + +func (s *spyKubeClient) AddImagePullSecretToServiceAccount(namespace, _, _ string) error { + s.addedPullSecrets = append(s.addedPullSecrets, namespace) + return s.addPullSecretErrors[namespace] +} + +func (s *spyKubeClient) RemoveImagePullSecretFromServiceAccount(namespace, _, _ string) error { + s.removedPullSecrets = append(s.removedPullSecrets, namespace) + return s.removePullSecretErrors[namespace] +} + +var testRegistry = &portainer.Registry{ID: 3, URL: "registry.example.com"} + +func TestApplyKubeRegistryAccess_Grant(t *testing.T) { + t.Parallel() + t.Run("single namespace granted creates secret then patches SA", func(t *testing.T) { + spy := newSpyKubeClient() + err := applyKubeRegistryAccess(spy, testRegistry, nil, []string{"ns-a"}) + require.NoError(t, err) + assert.Equal(t, []string{"ns-a"}, spy.createdSecrets) + assert.Equal(t, []string{"ns-a"}, spy.addedPullSecrets) + assert.Empty(t, spy.deletedSecrets) + assert.Empty(t, spy.removedPullSecrets) + }) + + t.Run("multiple namespaces granted applies to all", func(t *testing.T) { + spy := newSpyKubeClient() + err := applyKubeRegistryAccess(spy, testRegistry, nil, []string{"ns-a", "ns-b"}) + require.NoError(t, err) + assert.ElementsMatch(t, []string{"ns-a", "ns-b"}, spy.createdSecrets) + assert.ElementsMatch(t, []string{"ns-a", "ns-b"}, spy.addedPullSecrets) + }) + + t.Run("CreateRegistrySecret fails - AddImagePullSecret not called", func(t *testing.T) { + spy := newSpyKubeClient() + spy.createSecretErrors["ns-a"] = errors.New("secret create failed") + err := applyKubeRegistryAccess(spy, testRegistry, nil, []string{"ns-a"}) + require.Error(t, err) + assert.Equal(t, []string{"ns-a"}, spy.createdSecrets) + assert.Empty(t, spy.addedPullSecrets) + }) + + t.Run("AddImagePullSecret fails after secret created - returns error", func(t *testing.T) { + spy := newSpyKubeClient() + spy.addPullSecretErrors["ns-a"] = errors.New("sa patch failed") + err := applyKubeRegistryAccess(spy, testRegistry, nil, []string{"ns-a"}) + require.Error(t, err) + assert.Equal(t, []string{"ns-a"}, spy.createdSecrets) + assert.Equal(t, []string{"ns-a"}, spy.addedPullSecrets) + }) +} + +func TestApplyKubeRegistryAccess_Revoke(t *testing.T) { + t.Parallel() + t.Run("single namespace revoked removes from SA then deletes secret", func(t *testing.T) { + spy := newSpyKubeClient() + err := applyKubeRegistryAccess(spy, testRegistry, []string{"ns-a"}, nil) + require.NoError(t, err) + assert.Equal(t, []string{"ns-a"}, spy.removedPullSecrets) + assert.Equal(t, []string{"ns-a"}, spy.deletedSecrets) + assert.Empty(t, spy.createdSecrets) + assert.Empty(t, spy.addedPullSecrets) + }) + + t.Run("multiple namespaces revoked applies to all", func(t *testing.T) { + spy := newSpyKubeClient() + err := applyKubeRegistryAccess(spy, testRegistry, []string{"ns-a", "ns-b"}, nil) + require.NoError(t, err) + assert.ElementsMatch(t, []string{"ns-a", "ns-b"}, spy.removedPullSecrets) + assert.ElementsMatch(t, []string{"ns-a", "ns-b"}, spy.deletedSecrets) + }) + + t.Run("RemoveImagePullSecret fails - DeleteRegistrySecret not called", func(t *testing.T) { + spy := newSpyKubeClient() + spy.removePullSecretErrors["ns-a"] = errors.New("sa remove failed") + err := applyKubeRegistryAccess(spy, testRegistry, []string{"ns-a"}, nil) + require.Error(t, err) + assert.Equal(t, []string{"ns-a"}, spy.removedPullSecrets) + assert.Empty(t, spy.deletedSecrets) + }) + + t.Run("DeleteRegistrySecret fails after SA patched - returns error", func(t *testing.T) { + spy := newSpyKubeClient() + spy.deleteSecretErrors["ns-a"] = errors.New("secret delete failed") + err := applyKubeRegistryAccess(spy, testRegistry, []string{"ns-a"}, nil) + require.Error(t, err) + assert.Equal(t, []string{"ns-a"}, spy.removedPullSecrets) + assert.Equal(t, []string{"ns-a"}, spy.deletedSecrets) + }) +} + +func TestApplyKubeRegistryAccess_Mixed(t *testing.T) { + t.Parallel() + t.Run("one namespace added and one removed in same call", func(t *testing.T) { + spy := newSpyKubeClient() + err := applyKubeRegistryAccess(spy, testRegistry, []string{"ns-old"}, []string{"ns-new"}) + require.NoError(t, err) + assert.Equal(t, []string{"ns-old"}, spy.removedPullSecrets) + assert.Equal(t, []string{"ns-old"}, spy.deletedSecrets) + assert.Equal(t, []string{"ns-new"}, spy.createdSecrets) + assert.Equal(t, []string{"ns-new"}, spy.addedPullSecrets) + }) + + t.Run("empty old and new namespaces - no operations performed", func(t *testing.T) { + spy := newSpyKubeClient() + err := applyKubeRegistryAccess(spy, testRegistry, nil, nil) + require.NoError(t, err) + assert.Empty(t, spy.createdSecrets) + assert.Empty(t, spy.deletedSecrets) + assert.Empty(t, spy.addedPullSecrets) + assert.Empty(t, spy.removedPullSecrets) + }) + + t.Run("namespace present in both old and new - no operations performed for it", func(t *testing.T) { + spy := newSpyKubeClient() + err := applyKubeRegistryAccess(spy, testRegistry, []string{"ns-keep"}, []string{"ns-keep"}) + require.NoError(t, err) + assert.Empty(t, spy.createdSecrets) + assert.Empty(t, spy.deletedSecrets) + assert.Empty(t, spy.addedPullSecrets) + assert.Empty(t, spy.removedPullSecrets) + }) +} diff --git a/api/http/handler/endpoints/endpoint_settings_update.go b/api/http/handler/endpoints/endpoint_settings_update.go new file mode 100644 index 0000000..e960b1f --- /dev/null +++ b/api/http/handler/endpoints/endpoint_settings_update.go @@ -0,0 +1,136 @@ +package endpoints + +import ( + "net/http" + + portainer "github.com/portainer/portainer/api" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +type endpointSettingsUpdatePayload struct { + // Whether non-administrator should be able to use bind mounts when creating containers + AllowBindMountsForRegularUsers *bool `json:"allowBindMountsForRegularUsers" example:"false"` + // Whether non-administrator should be able to use privileged mode when creating containers + AllowPrivilegedModeForRegularUsers *bool `json:"allowPrivilegedModeForRegularUsers" example:"false"` + // Whether non-administrator should be able to browse volumes + AllowVolumeBrowserForRegularUsers *bool `json:"allowVolumeBrowserForRegularUsers" example:"true"` + // Whether non-administrator should be able to use the host pid + AllowHostNamespaceForRegularUsers *bool `json:"allowHostNamespaceForRegularUsers" example:"true"` + // Whether non-administrator should be able to use device mapping + AllowDeviceMappingForRegularUsers *bool `json:"allowDeviceMappingForRegularUsers" example:"true"` + // Whether non-administrator should be able to manage stacks + AllowStackManagementForRegularUsers *bool `json:"allowStackManagementForRegularUsers" example:"true"` + // Whether non-administrator should be able to use container capabilities + AllowContainerCapabilitiesForRegularUsers *bool `json:"allowContainerCapabilitiesForRegularUsers" example:"true"` + // Whether non-administrator should be able to use sysctl settings + AllowSysctlSettingForRegularUsers *bool `json:"allowSysctlSettingForRegularUsers" example:"true"` + // Whether non-administrator should be able to use security-opt settings + AllowSecurityOptForRegularUsers *bool `json:"allowSecurityOptForRegularUsers" example:"true"` + // Whether host management features are enabled + EnableHostManagementFeatures *bool `json:"enableHostManagementFeatures" example:"true"` + + EnableGPUManagement *bool `json:"enableGPUManagement" example:"false"` + + Gpus []portainer.Pair `json:"gpus"` +} + +func (payload *endpointSettingsUpdatePayload) Validate(r *http.Request) error { + return nil +} + +// @id EndpointSettingsUpdate +// @summary Update settings for an environment(endpoint) +// @description Update settings for an environment(endpoint). +// @description **Access policy**: authenticated +// @security ApiKeyAuth +// @security jwt +// @tags endpoints +// @accept json +// @produce json +// @param id path int true "Environment(Endpoint) identifier" +// @param body body endpointSettingsUpdatePayload true "Environment(Endpoint) details" +// @success 200 {object} portainer.Endpoint "Success" +// @failure 400 "Invalid request" +// @failure 404 "Environment(Endpoint) not found" +// @failure 500 "Server error" +// @router /endpoints/{id}/settings [put] +func (handler *Handler) endpointSettingsUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid environment identifier route variable", err) + } + + var payload endpointSettingsUpdatePayload + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) + if handler.DataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find an environment with the specified identifier inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find an environment with the specified identifier inside the database", err) + } + + securitySettings := endpoint.SecuritySettings + + if payload.AllowBindMountsForRegularUsers != nil { + securitySettings.AllowBindMountsForRegularUsers = *payload.AllowBindMountsForRegularUsers + } + + if payload.AllowContainerCapabilitiesForRegularUsers != nil { + securitySettings.AllowContainerCapabilitiesForRegularUsers = *payload.AllowContainerCapabilitiesForRegularUsers + } + + if payload.AllowDeviceMappingForRegularUsers != nil { + securitySettings.AllowDeviceMappingForRegularUsers = *payload.AllowDeviceMappingForRegularUsers + } + + if payload.AllowHostNamespaceForRegularUsers != nil { + securitySettings.AllowHostNamespaceForRegularUsers = *payload.AllowHostNamespaceForRegularUsers + } + + if payload.AllowPrivilegedModeForRegularUsers != nil { + securitySettings.AllowPrivilegedModeForRegularUsers = *payload.AllowPrivilegedModeForRegularUsers + } + + if payload.AllowStackManagementForRegularUsers != nil { + securitySettings.AllowStackManagementForRegularUsers = *payload.AllowStackManagementForRegularUsers + } + + if payload.AllowVolumeBrowserForRegularUsers != nil { + securitySettings.AllowVolumeBrowserForRegularUsers = *payload.AllowVolumeBrowserForRegularUsers + } + + if payload.AllowSysctlSettingForRegularUsers != nil { + securitySettings.AllowSysctlSettingForRegularUsers = *payload.AllowSysctlSettingForRegularUsers + } + + if payload.EnableHostManagementFeatures != nil { + securitySettings.EnableHostManagementFeatures = *payload.EnableHostManagementFeatures + } + + if payload.AllowSecurityOptForRegularUsers != nil { + securitySettings.AllowSecurityOptForRegularUsers = *payload.AllowSecurityOptForRegularUsers + } + + endpoint.SecuritySettings = securitySettings + + if payload.EnableGPUManagement != nil { + endpoint.EnableGPUManagement = *payload.EnableGPUManagement + } + + if payload.Gpus != nil { + endpoint.Gpus = payload.Gpus + } + + err = handler.DataStore.Endpoint().UpdateEndpoint(portainer.EndpointID(endpointID), endpoint) + if err != nil { + return httperror.InternalServerError("Failed persisting environment in database", err) + } + + return response.JSON(w, endpoint) +} diff --git a/api/http/handler/endpoints/endpoint_snapshot.go b/api/http/handler/endpoints/endpoint_snapshot.go new file mode 100644 index 0000000..bf641f7 --- /dev/null +++ b/api/http/handler/endpoints/endpoint_snapshot.go @@ -0,0 +1,64 @@ +package endpoints + +import ( + "errors" + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/internal/snapshot" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +// @id EndpointSnapshot +// @summary Snapshots an environment(endpoint) +// @description Snapshots an environment(endpoint) +// @description **Access policy**: administrator +// @tags endpoints +// @security ApiKeyAuth +// @security jwt +// @param id path int true "Environment(Endpoint) identifier" +// @success 204 "Success" +// @failure 400 "Invalid request" +// @failure 404 "Environment(Endpoint) not found" +// @failure 500 "Server error" +// @router /endpoints/{id}/snapshot [post] +func (handler *Handler) endpointSnapshot(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid environment identifier route variable", err) + } + + endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) + if handler.DataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find an environment with the specified identifier inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find an environment with the specified identifier inside the database", err) + } + + if !snapshot.SupportDirectSnapshot(endpoint) { + return httperror.BadRequest("Snapshots not supported for this environment", errors.New("Snapshots not supported for this environment")) + } + + snapshotError := handler.SnapshotService.SnapshotEndpoint(endpoint) + + latestEndpointReference, err := handler.DataStore.Endpoint().Endpoint(endpoint.ID) + if latestEndpointReference == nil { + return httperror.NotFound("Unable to find an environment with the specified identifier inside the database", err) + } + + latestEndpointReference.Status = portainer.EndpointStatusUp + if snapshotError != nil { + latestEndpointReference.Status = portainer.EndpointStatusDown + } + + latestEndpointReference.Agent.Version = endpoint.Agent.Version + + err = handler.DataStore.Endpoint().UpdateEndpoint(latestEndpointReference.ID, latestEndpointReference) + if err != nil { + return httperror.InternalServerError("Unable to persist environment changes inside the database", err) + } + + return response.Empty(w) +} diff --git a/api/http/handler/endpoints/endpoint_snapshots.go b/api/http/handler/endpoints/endpoint_snapshots.go new file mode 100644 index 0000000..90c232c --- /dev/null +++ b/api/http/handler/endpoints/endpoint_snapshots.go @@ -0,0 +1,72 @@ +package endpoints + +import ( + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/internal/snapshot" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/response" + + "github.com/rs/zerolog/log" +) + +// @id EndpointSnapshots +// @summary Snapshot all environments(endpoints) +// @description Snapshot all environments(endpoints) +// @description **Access policy**: administrator +// @tags endpoints +// @security ApiKeyAuth +// @security jwt +// @success 204 "Success" +// @failure 500 "Server Error" +// @router /endpoints/snapshot [post] +func (handler *Handler) endpointSnapshots(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpoints, err := handler.DataStore.Endpoint().Endpoints() + if err != nil { + return httperror.InternalServerError("Unable to retrieve environments from the database", err) + } + + for _, endpoint := range endpoints { + if !snapshot.SupportDirectSnapshot(&endpoint) { + continue + } + + if endpoint.URL == "" { + continue + } + + snapshotError := handler.SnapshotService.SnapshotEndpoint(&endpoint) + + latestEndpointReference, err := handler.DataStore.Endpoint().Endpoint(endpoint.ID) + if latestEndpointReference == nil { + log.Debug(). + Str("endpoint", endpoint.Name). + Str("URL", endpoint.URL). + Err(err). + Msg("background schedule error (environment snapshot), environment not found inside the database anymore") + + continue + } + + latestEndpointReference.Status = portainer.EndpointStatusUp + if snapshotError != nil { + log.Debug(). + Str("endpoint", endpoint.Name). + Str("URL", endpoint.URL). + Err(snapshotError). + Msg("background schedule error (environment snapshot), unable to create snapshot") + + latestEndpointReference.Status = portainer.EndpointStatusDown + } + + latestEndpointReference.Agent.Version = endpoint.Agent.Version + + err = handler.DataStore.Endpoint().UpdateEndpoint(latestEndpointReference.ID, latestEndpointReference) + if err != nil { + return httperror.InternalServerError("Unable to persist environment changes inside the database", err) + } + } + + return response.Empty(w) +} diff --git a/api/http/handler/endpoints/endpoint_snapshots_test.go b/api/http/handler/endpoints/endpoint_snapshots_test.go new file mode 100644 index 0000000..332393a --- /dev/null +++ b/api/http/handler/endpoints/endpoint_snapshots_test.go @@ -0,0 +1,109 @@ +package endpoints + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/testhelpers" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_endpointSnapshots(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + endpointID := portainer.EndpointID(123) + endpoint := &portainer.Endpoint{ + ID: endpointID, + Name: "mock", + URL: "http://mock.example/", + Status: portainer.EndpointStatusDown, // starts in down state + } + err := store.Endpoint().Create(endpoint) + + require.NoError(t, err, "error creating environment") + + err = store.User().Create( + &portainer.User{ + Username: "admin", + Role: portainer.AdministratorRole, + }, + ) + require.NoError(t, err, "error creating a user") + + bouncer := testhelpers.NewTestRequestBouncer() + + snapshotService := &mockSnapshotService{ + snapshotEndpointShouldSucceed: atomic.Bool{}, + } + snapshotService.snapshotEndpointShouldSucceed.Store(true) + + h := NewHandler(bouncer) + h.DataStore = store + h.SnapshotService = snapshotService + + doPostRequest := func() { + req := httptest.NewRequest(http.MethodPost, "/endpoints/snapshot", nil) + ctx := security.StoreTokenData(req, &portainer.TokenData{ID: 1, Username: "admin", Role: 1}) + req = req.WithContext(ctx) + testhelpers.AddTestSecurityCookie(req, "Bearer dummytoken") + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + require.Equal(t, http.StatusNoContent, rr.Code, "Status should be 204") + + _, err := io.ReadAll(rr.Body) + require.NoError(t, err, "ReadAll should not return error") + } + + doPostRequest() + + // check that the endpoint has been immediately set to up + endpoint, err = store.Endpoint().Endpoint(endpointID) + require.NoError(t, err, "error getting endpoint") + assert.Equal(t, portainer.EndpointStatusUp, endpoint.Status, "endpoint should be up (1) since mock snapshot returned ok") + + // set the mock to return an error + snapshotService.snapshotEndpointShouldSucceed.Store(false) + doPostRequest() + + // check that the endpoint has been immediately set to down + endpoint, err = store.Endpoint().Endpoint(endpointID) + require.NoError(t, err, "error getting endpoint") + assert.Equal(t, portainer.EndpointStatusDown, endpoint.Status, "endpoint should be down (2) since mock snapshot returned error") +} + +var _ portainer.SnapshotService = &mockSnapshotService{} + +type mockSnapshotService struct { + snapshotEndpointShouldSucceed atomic.Bool +} + +func (s *mockSnapshotService) Start(_ context.Context) { +} + +func (s *mockSnapshotService) SetSnapshotInterval(snapshotInterval string) error { + return nil +} + +func (s *mockSnapshotService) SnapshotEndpoint(endpoint *portainer.Endpoint) error { + if s.snapshotEndpointShouldSucceed.Load() { + return nil + } + + return errors.New("snapshot failed") +} + +func (s *mockSnapshotService) FillSnapshotData(endpoint *portainer.Endpoint, includeRaw bool) error { + return nil +} diff --git a/api/http/handler/endpoints/endpoint_status_inspect.go b/api/http/handler/endpoints/endpoint_status_inspect.go new file mode 100644 index 0000000..283556a --- /dev/null +++ b/api/http/handler/endpoints/endpoint_status_inspect.go @@ -0,0 +1,21 @@ +package endpoints + +import ( + "fmt" + "net/http" + + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" +) + +// DEPRECATED +func (handler *Handler) endpointStatusInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid environment identifier route variable", err) + } + + url := fmt.Sprintf("/api/endpoints/%d/edge/status", endpointID) + http.Redirect(w, r, url, http.StatusPermanentRedirect) + return nil +} diff --git a/api/http/handler/endpoints/endpoint_summary_counts.go b/api/http/handler/endpoints/endpoint_summary_counts.go new file mode 100644 index 0000000..271b1bc --- /dev/null +++ b/api/http/handler/endpoints/endpoint_summary_counts.go @@ -0,0 +1,246 @@ +package endpoints + +import ( + "net/http" + "sort" + "strings" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/endpointutils" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/response" + "github.com/rs/zerolog/log" + + "golang.org/x/mod/semver" +) + +type groupCount struct { + GroupID int `json:"groupID"` + GroupName string `json:"groupName"` + Count int `json:"count"` +} + +type platformCounts struct { + Docker int `json:"docker"` + Kubernetes int `json:"kubernetes"` + Azure int `json:"azure"` + Podman int `json:"podman"` +} + +type healthCounts struct { + Down int `json:"down"` + Outdated int `json:"outdated"` + Up int `json:"up"` + Heartbeat int `json:"heartbeat"` +} + +type EnvironmentSummaryCountsResponse struct { + Total int `json:"total"` + Up int `json:"up"` + Down int `json:"down"` + Outdated int `json:"outdated"` + Unassigned int `json:"unassigned"` + ByGroup []groupCount `json:"byGroup"` + ByPlatformType platformCounts `json:"byPlatformType"` + ByHealth healthCounts `json:"byHealth"` +} + +const UnassignedGroupID = portainer.EndpointGroupID(1) + +// @id EndpointSummaryCounts +// @summary Get environment summary counts +// @description Returns counts of environments by status (up, down) and ungrouped environments (unassigned), plus breakdowns by group, type, and health. +// @description **Access policy**: restricted +// @tags endpoints +// @security ApiKeyAuth +// @security jwt +// @produce json +// @success 200 {object} EnvironmentSummaryCountsResponse "Environment summary counts" +// @failure 500 "Server error" +// @router /endpoints/summary [get] +func (handler *Handler) endpointSummaryCounts(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var endpointGroups []portainer.EndpointGroup + var endpoints []portainer.Endpoint + var settings *portainer.Settings + + if err := handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error { + var err error + + endpointGroups, err = tx.EndpointGroup().ReadAll() + if err != nil { + return httperror.InternalServerError("Unable to retrieve environment groups from the database", err) + } + + endpoints, err = tx.Endpoint().Endpoints() + if err != nil { + return httperror.InternalServerError("Unable to retrieve environments from the database", err) + } + + settings, err = tx.Settings().Settings() + if err != nil { + return httperror.InternalServerError("Unable to retrieve settings from the database", err) + } + + return nil + }); err != nil { + return response.TxErrorResponse(err) + } + + // Refresh LastCheckInDate from the in-memory heartbeats map. Edge agents + // use ETag-based response caching: cache hits update the map but do not + // write LastCheckInDate back to the database, so the tx value grows stale. + // The tx path cannot access the in-memory map; this non-tx access is + // intentional. + endpointSvc := handler.DataStore.Endpoint() + for i := range endpoints { + if t, ok := endpointSvc.Heartbeat(endpoints[i].ID); ok { + endpoints[i].LastCheckInDate = t + } + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) + } + + filteredEndpoints := security.FilterEndpoints(endpoints, endpointGroups, securityContext) + + trustedEndpoints := make([]portainer.Endpoint, 0, len(filteredEndpoints)) + for i := range filteredEndpoints { + ep := &filteredEndpoints[i] + if endpointutils.IsEdgeEndpoint(ep) && !ep.UserTrusted { + continue + } + trustedEndpoints = append(trustedEndpoints, filteredEndpoints[i]) + } + + counts := EnvironmentSummaryCountsResponse{ + Total: len(trustedEndpoints), + } + + groupCounts := make(map[portainer.EndpointGroupID]int) + platformCounts := platformCounts{} + healthCounts := healthCounts{} + + for i := range trustedEndpoints { + endpoint := &trustedEndpoints[i] + + switch endpointutils.EndpointPlatformType(endpoint) { + case portainer.DockerPlatformType: + platformCounts.Docker++ + case portainer.KubernetesPlatformType: + platformCounts.Kubernetes++ + case portainer.AzurePlatformType: + platformCounts.Azure++ + case portainer.PodmanPlatformType: + platformCounts.Podman++ + case portainer.UnknownPlatformType: + log.Error().Int("endpoint_id", int(endpoint.ID)).Msg("Unknown platform type") + } + + groupCounts[endpoint.GroupID]++ + + if endpoint.GroupID == UnassignedGroupID { + counts.Unassigned++ + } + + outdated := isOutdated(endpoint) + status := resolveEndpointStatus(endpoint, settings) + + if outdated { + counts.Outdated++ + healthCounts.Outdated++ + } + + switch status { + case statusHeartbeat: + healthCounts.Heartbeat++ + healthCounts.Up++ + counts.Up++ + case statusUp: + healthCounts.Up++ + counts.Up++ + case statusDown: + healthCounts.Down++ + counts.Down++ + } + } + + counts.ByGroup = parseGroupCounts(groupCounts, endpointGroups) + counts.ByPlatformType = platformCounts + counts.ByHealth = healthCounts + + return response.JSON(w, counts) +} + +// iota order overlaps with portainer.EndpointStatus (Up=1, Down=2) so non-edge +// endpoints can pass their Status straight through. statusHeartbeat (0) is +// edge-only. +const ( + statusHeartbeat = iota + statusUp + statusDown +) + +func resolveEndpointStatus(endpoint *portainer.Endpoint, settings *portainer.Settings) int { + if endpointutils.IsEdgeEndpoint(endpoint) { + if endpointutils.GetHeartbeatStatus(endpoint, settings) { + return statusHeartbeat + } + return statusDown + } + return int(endpoint.Status) +} + +func parseGroupCounts(counts map[portainer.EndpointGroupID]int, endpointGroups []portainer.EndpointGroup) []groupCount { + parsedGroupCounts := []groupCount{} + + // Build group name lookup + groupNameByID := make(map[portainer.EndpointGroupID]string, len(endpointGroups)) + for _, g := range endpointGroups { + groupNameByID[g.ID] = g.Name + } + + for groupID, count := range counts { + + parsedGroupCounts = append(parsedGroupCounts, + groupCount{ + GroupID: int(groupID), + GroupName: groupNameByID[groupID], + Count: count, + }) + } + + sort.Slice(parsedGroupCounts, func(i, j int) bool { + return parsedGroupCounts[i].GroupID < parsedGroupCounts[j].GroupID + }) + + return parsedGroupCounts +} + +// canonicalizeSemver ensures v has a "v" prefix as required by golang.org/x/mod/semver. +func canonicalizeSemver(v string) string { + v = strings.TrimSpace(v) + if v == "" || strings.HasPrefix(v, "v") { + return v + } + return "v" + v +} + +func isOutdated(endpoint *portainer.Endpoint) bool { + if !endpointutils.IsAgentEndpoint(endpoint) { + return false + } + + if endpoint.Agent.Version == "" { + edgeHasCheckedInWithoutVersion := endpointutils.IsEdgeEndpoint(endpoint) && endpoint.LastCheckInDate > 0 + return edgeHasCheckedInWithoutVersion + } + + latestVersion := canonicalizeSemver(portainer.APIVersion) + agentVersion := canonicalizeSemver(endpoint.Agent.Version) + + return semver.Compare(agentVersion, latestVersion) < 0 +} diff --git a/api/http/handler/endpoints/endpoint_summary_counts_test.go b/api/http/handler/endpoints/endpoint_summary_counts_test.go new file mode 100644 index 0000000..d3ee39a --- /dev/null +++ b/api/http/handler/endpoints/endpoint_summary_counts_test.go @@ -0,0 +1,497 @@ +package endpoints + +import ( + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/testhelpers" + "github.com/segmentio/encoding/json" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSummaryCounts(t *testing.T) { + type testEndpoint struct { + endpointType portainer.EndpointType + status portainer.EndpointStatus + groupID portainer.EndpointGroupID + agentVersion string + containerEngine string + userTrusted bool + lastCheckInDate int64 + } + + currentVersion := portainer.APIVersion + + tests := []struct { + name string + endpoints []testEndpoint + expectedCounts EnvironmentSummaryCountsResponse + }{ + { + name: "all docker endpoints up", + endpoints: []testEndpoint{ + {endpointType: portainer.DockerEnvironment, status: portainer.EndpointStatusUp, groupID: 2, agentVersion: currentVersion}, + {endpointType: portainer.DockerEnvironment, status: portainer.EndpointStatusUp, groupID: 2, agentVersion: currentVersion}, + }, + expectedCounts: EnvironmentSummaryCountsResponse{ + Total: 2, Up: 2, Down: 0, Outdated: 0, Unassigned: 0, + // GroupID 2 has no matching EndpointGroup in the test store. + ByGroup: []groupCount{{GroupID: 2, GroupName: "", Count: 2}}, + ByPlatformType: platformCounts{Docker: 2}, + ByHealth: healthCounts{Up: 2}, + }, + }, + { + name: "mix of up and down docker endpoints", + endpoints: []testEndpoint{ + {endpointType: portainer.DockerEnvironment, status: portainer.EndpointStatusUp, groupID: 2, agentVersion: currentVersion}, + {endpointType: portainer.DockerEnvironment, status: portainer.EndpointStatusDown, groupID: 2, agentVersion: currentVersion}, + {endpointType: portainer.DockerEnvironment, status: portainer.EndpointStatusDown, groupID: 2, agentVersion: currentVersion}, + }, + expectedCounts: EnvironmentSummaryCountsResponse{ + Total: 3, Up: 1, Down: 2, Outdated: 0, Unassigned: 0, + ByGroup: []groupCount{{GroupID: 2, GroupName: "", Count: 3}}, + ByPlatformType: platformCounts{Docker: 3}, + ByHealth: healthCounts{Down: 2, Up: 1}, + }, + }, + { + name: "unassigned endpoints have groupID 1", + endpoints: []testEndpoint{ + {endpointType: portainer.DockerEnvironment, status: portainer.EndpointStatusUp, groupID: 1, agentVersion: currentVersion}, + {endpointType: portainer.DockerEnvironment, status: portainer.EndpointStatusUp, groupID: 2, agentVersion: currentVersion}, + }, + expectedCounts: EnvironmentSummaryCountsResponse{ + Total: 2, Up: 2, Down: 0, Outdated: 0, Unassigned: 1, + // GroupID 1 is the default "Unassigned" group; GroupID 2 has no match. + ByGroup: []groupCount{{GroupID: 1, GroupName: "Unassigned", Count: 1}, {GroupID: 2, GroupName: "", Count: 1}}, + ByPlatformType: platformCounts{Docker: 2}, + ByHealth: healthCounts{Up: 2}, + }, + }, + { + name: "mixed scenario with docker and kubernetes types", + endpoints: []testEndpoint{ + {endpointType: portainer.DockerEnvironment, status: portainer.EndpointStatusUp, groupID: 2, agentVersion: currentVersion}, + {endpointType: portainer.DockerEnvironment, status: portainer.EndpointStatusDown, groupID: 1, agentVersion: currentVersion}, + {endpointType: portainer.KubernetesLocalEnvironment, status: portainer.EndpointStatusUp, groupID: 1, agentVersion: currentVersion}, + {endpointType: portainer.AgentOnKubernetesEnvironment, status: portainer.EndpointStatusDown, groupID: 2, agentVersion: currentVersion}, + }, + expectedCounts: EnvironmentSummaryCountsResponse{ + Total: 4, Up: 2, Down: 2, Outdated: 0, Unassigned: 2, + ByGroup: []groupCount{{GroupID: 1, GroupName: "Unassigned", Count: 2}, {GroupID: 2, GroupName: "", Count: 2}}, + ByPlatformType: platformCounts{Docker: 2, Kubernetes: 2}, + ByHealth: healthCounts{Down: 2, Up: 2}, + }, + }, + { + name: "outdated endpoints count in both their connection bucket and Outdated", + endpoints: []testEndpoint{ + {endpointType: portainer.AgentOnDockerEnvironment, status: portainer.EndpointStatusUp, groupID: 2, agentVersion: "2.0.0"}, + {endpointType: portainer.AgentOnDockerEnvironment, status: portainer.EndpointStatusDown, groupID: 2, agentVersion: "2.0.0"}, + {endpointType: portainer.AgentOnDockerEnvironment, status: portainer.EndpointStatusUp, groupID: 2, agentVersion: currentVersion}, + }, + expectedCounts: EnvironmentSummaryCountsResponse{ + Total: 3, Up: 2, Down: 1, Outdated: 2, Unassigned: 0, + ByGroup: []groupCount{{GroupID: 2, GroupName: "", Count: 3}}, + ByPlatformType: platformCounts{Docker: 3}, + ByHealth: healthCounts{Outdated: 2, Up: 2, Down: 1}, + }, + }, + { + name: "azure and podman endpoints counted in platform breakdown", + endpoints: []testEndpoint{ + {endpointType: portainer.AzureEnvironment, status: portainer.EndpointStatusUp, groupID: 2, agentVersion: currentVersion}, + {endpointType: portainer.DockerEnvironment, status: portainer.EndpointStatusUp, groupID: 2, agentVersion: currentVersion, containerEngine: portainer.ContainerEnginePodman}, + }, + expectedCounts: EnvironmentSummaryCountsResponse{ + Total: 2, Up: 2, Down: 0, Outdated: 0, Unassigned: 0, + ByGroup: []groupCount{{GroupID: 2, GroupName: "", Count: 2}}, + ByPlatformType: platformCounts{Azure: 1, Podman: 1}, + ByHealth: healthCounts{Up: 2}, + }, + }, + { + name: "untrusted edge endpoints are excluded from counts", + endpoints: []testEndpoint{ + {endpointType: portainer.DockerEnvironment, status: portainer.EndpointStatusUp, groupID: 2, agentVersion: currentVersion}, + {endpointType: portainer.EdgeAgentOnDockerEnvironment, status: portainer.EndpointStatusUp, groupID: 2, agentVersion: currentVersion, userTrusted: false}, + }, + expectedCounts: EnvironmentSummaryCountsResponse{ + Total: 1, Up: 1, Down: 0, Outdated: 0, Unassigned: 0, + ByGroup: []groupCount{{GroupID: 2, GroupName: "", Count: 1}}, + ByPlatformType: platformCounts{Docker: 1}, + ByHealth: healthCounts{Up: 1}, + }, + }, + { + name: "trusted edge endpoints classified by heartbeat, not stored status", + endpoints: []testEndpoint{ + // Recent check-in: heartbeat alive → counted as Up + Heartbeat. + {endpointType: portainer.EdgeAgentOnDockerEnvironment, status: portainer.EndpointStatusUp, groupID: 2, agentVersion: currentVersion, userTrusted: true, lastCheckInDate: time.Now().Unix()}, + // Stored Up but never checked in: counted as Down. + {endpointType: portainer.EdgeAgentOnDockerEnvironment, status: portainer.EndpointStatusUp, groupID: 2, agentVersion: currentVersion, userTrusted: true, lastCheckInDate: 0}, + }, + expectedCounts: EnvironmentSummaryCountsResponse{ + Total: 2, Up: 1, Down: 1, Outdated: 0, Unassigned: 0, + ByGroup: []groupCount{{GroupID: 2, GroupName: "", Count: 2}}, + ByPlatformType: platformCounts{Docker: 2}, + ByHealth: healthCounts{Up: 1, Down: 1, Heartbeat: 1}, + }, + }, + { + name: "edge endpoint with unknown version and no check-in is not outdated", + endpoints: []testEndpoint{ + {endpointType: portainer.EdgeAgentOnDockerEnvironment, status: portainer.EndpointStatusDown, groupID: 2, agentVersion: "", userTrusted: true, lastCheckInDate: 0}, + }, + expectedCounts: EnvironmentSummaryCountsResponse{ + Total: 1, Up: 0, Down: 1, Outdated: 0, Unassigned: 0, + ByGroup: []groupCount{{GroupID: 2, GroupName: "", Count: 1}}, + ByPlatformType: platformCounts{Docker: 1}, + ByHealth: healthCounts{Down: 1}, + }, + }, + { + name: "edge endpoint with unknown version but prior check-in is outdated", + endpoints: []testEndpoint{ + {endpointType: portainer.EdgeAgentOnDockerEnvironment, status: portainer.EndpointStatusDown, groupID: 2, agentVersion: "", userTrusted: true, lastCheckInDate: time.Now().Add(-1 * time.Hour).Unix()}, + }, + expectedCounts: EnvironmentSummaryCountsResponse{ + Total: 1, Up: 0, Down: 1, Outdated: 1, Unassigned: 0, + ByGroup: []groupCount{{GroupID: 2, GroupName: "", Count: 1}}, + ByPlatformType: platformCounts{Docker: 1}, + ByHealth: healthCounts{Down: 1, Outdated: 1}, + }, + }, + { + name: "no endpoints returns all zeros", + endpoints: []testEndpoint{}, + expectedCounts: EnvironmentSummaryCountsResponse{ + Total: 0, Up: 0, Down: 0, Outdated: 0, Unassigned: 0, + ByGroup: []groupCount{}, + ByPlatformType: platformCounts{}, + ByHealth: healthCounts{}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, store := datastore.MustNewTestStore(t, true, true) + + for i, ep := range tt.endpoints { + endpoint := &portainer.Endpoint{ + ID: portainer.EndpointID(i + 1), + Name: "env", + Type: ep.endpointType, + Status: ep.status, + GroupID: ep.groupID, + ContainerEngine: ep.containerEngine, + UserTrusted: ep.userTrusted, + LastCheckInDate: ep.lastCheckInDate, + } + endpoint.Agent.Version = ep.agentVersion + + err := store.Endpoint().Create(endpoint) + require.NoError(t, err) + } + + err := store.User().Create(&portainer.User{Username: "admin", Role: portainer.AdministratorRole}) + require.NoError(t, err) + + bouncer := testhelpers.NewTestRequestBouncer() + handler := NewHandler(bouncer) + handler.DataStore = store + + req := httptest.NewRequest(http.MethodGet, "/endpoints/summary", nil) + ctx := security.StoreTokenData(req, &portainer.TokenData{ID: 1, Username: "admin", Role: 1}) + req = req.WithContext(ctx) + restrictedCtx := security.StoreRestrictedRequestContext(req, &security.RestrictedRequestContext{UserID: 1, IsAdmin: true}) + req = req.WithContext(restrictedCtx) + testhelpers.AddTestSecurityCookie(req, "Bearer dummytoken") + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + require.Equal(t, http.StatusOK, rr.Code, "expected 200 OK") + + body, err := io.ReadAll(rr.Body) + require.NoError(t, err) + + var counts EnvironmentSummaryCountsResponse + err = json.Unmarshal(body, &counts) + require.NoError(t, err) + + assert.Equal(t, tt.expectedCounts.Total, counts.Total, "Total") + assert.Equal(t, tt.expectedCounts.Up, counts.Up, "Up") + assert.Equal(t, tt.expectedCounts.Down, counts.Down, "Down") + assert.Equal(t, tt.expectedCounts.Outdated, counts.Outdated, "Outdated") + assert.Equal(t, tt.expectedCounts.Unassigned, counts.Unassigned, "Unassigned") + assert.Equal(t, tt.expectedCounts.ByPlatformType, counts.ByPlatformType, "ByPlatformType") + assert.Equal(t, tt.expectedCounts.ByHealth, counts.ByHealth, "ByHealth") + // ByGroup is derived from map iteration so order is non-deterministic. + assert.ElementsMatch(t, tt.expectedCounts.ByGroup, counts.ByGroup, "ByGroup") + }) + } +} + +func TestSummaryCountsHealthBreakdown(t *testing.T) { + _, store := datastore.MustNewTestStore(t, true, true) + + currentVersion := portainer.APIVersion + + type ep struct { + id portainer.EndpointID + name string + epType portainer.EndpointType + status portainer.EndpointStatus + lastCheckIn int64 + trusted bool + agentVer string + } + + for _, e := range []ep{ + {1, "outdated-up", portainer.EdgeAgentOnDockerEnvironment, 0, time.Now().Unix(), true, "2.0.0"}, + {2, "outdated-down", portainer.EdgeAgentOnDockerEnvironment, 0, time.Now().Add(-1 * time.Hour).Unix(), true, "2.0.0"}, + {3, "up-current", portainer.DockerEnvironment, portainer.EndpointStatusUp, 0, false, currentVersion}, + {4, "down-current", portainer.DockerEnvironment, portainer.EndpointStatusDown, 0, false, currentVersion}, + } { + endpoint := &portainer.Endpoint{ + ID: e.id, + Name: e.name, + Type: e.epType, + Status: e.status, + LastCheckInDate: e.lastCheckIn, + GroupID: 2, + UserTrusted: e.trusted, + } + endpoint.Agent.Version = e.agentVer + require.NoError(t, store.Endpoint().Create(endpoint)) + } + + err := store.User().Create(&portainer.User{Username: "admin", Role: portainer.AdministratorRole}) + require.NoError(t, err) + + bouncer := testhelpers.NewTestRequestBouncer() + handler := NewHandler(bouncer) + handler.DataStore = store + + req := httptest.NewRequest(http.MethodGet, "/endpoints/summary", nil) + ctx := security.StoreTokenData(req, &portainer.TokenData{ID: 1, Username: "admin", Role: 1}) + req = req.WithContext(ctx) + restrictedCtx := security.StoreRestrictedRequestContext(req, &security.RestrictedRequestContext{UserID: 1, IsAdmin: true}) + req = req.WithContext(restrictedCtx) + testhelpers.AddTestSecurityCookie(req, "Bearer dummytoken") + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + require.Equal(t, http.StatusOK, rr.Code) + + body, err := io.ReadAll(rr.Body) + require.NoError(t, err) + + var counts EnvironmentSummaryCountsResponse + require.NoError(t, json.Unmarshal(body, &counts)) + + assert.Equal(t, 4, counts.Total) + assert.Equal(t, 2, counts.Up, "outdated-up counts in both Up and Outdated") + assert.Equal(t, 2, counts.Down, "outdated-down counts in both Down and Outdated") + assert.Equal(t, 2, counts.Outdated) + + assert.Equal(t, healthCounts{ + Down: 2, + Outdated: 2, + Up: 2, + Heartbeat: 1, + }, counts.ByHealth) +} + +func TestSummaryCountsHeartbeatRefreshedFromInMemoryMap(t *testing.T) { + _, store := datastore.MustNewTestStore(t, true, true) + + endpoint := &portainer.Endpoint{ + ID: 1, + Name: "stale-edge", + Type: portainer.EdgeAgentOnDockerEnvironment, + GroupID: 2, + UserTrusted: true, + LastCheckInDate: time.Now().Add(-1 * time.Hour).Unix(), + } + endpoint.Agent.Version = portainer.APIVersion + require.NoError(t, store.Endpoint().Create(endpoint)) + + store.Endpoint().UpdateHeartbeat(1) + + err := store.User().Create(&portainer.User{Username: "admin", Role: portainer.AdministratorRole}) + require.NoError(t, err) + + bouncer := testhelpers.NewTestRequestBouncer() + handler := NewHandler(bouncer) + handler.DataStore = store + + req := httptest.NewRequest(http.MethodGet, "/endpoints/summary", nil) + ctx := security.StoreTokenData(req, &portainer.TokenData{ID: 1, Username: "admin", Role: 1}) + req = req.WithContext(ctx) + restrictedCtx := security.StoreRestrictedRequestContext(req, &security.RestrictedRequestContext{UserID: 1, IsAdmin: true}) + req = req.WithContext(restrictedCtx) + testhelpers.AddTestSecurityCookie(req, "Bearer dummytoken") + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + require.Equal(t, http.StatusOK, rr.Code) + + body, err := io.ReadAll(rr.Body) + require.NoError(t, err) + + var counts EnvironmentSummaryCountsResponse + require.NoError(t, json.Unmarshal(body, &counts)) + + assert.Equal(t, 1, counts.Total) + assert.Equal(t, 1, counts.Up, "in-memory heartbeat should override stale DB LastCheckInDate") + assert.Equal(t, 0, counts.Down) + assert.Equal(t, healthCounts{Up: 1, Heartbeat: 1}, counts.ByHealth) +} + +func TestResolveEndpointStatus(t *testing.T) { + settings := &portainer.Settings{EdgeAgentCheckinInterval: 60} + + tests := []struct { + name string + endpoint *portainer.Endpoint + expectedStatus int + }{ + { + name: "non-edge endpoint returns stored up status", + endpoint: &portainer.Endpoint{ + Type: portainer.DockerEnvironment, + Status: portainer.EndpointStatusUp, + }, + expectedStatus: statusUp, + }, + { + name: "non-edge endpoint returns stored down status", + endpoint: &portainer.Endpoint{ + Type: portainer.DockerEnvironment, + Status: portainer.EndpointStatusDown, + }, + expectedStatus: statusDown, + }, + { + name: "edge endpoint with recent check-in returns heartbeat", + endpoint: &portainer.Endpoint{ + Type: portainer.EdgeAgentOnDockerEnvironment, + Status: portainer.EndpointStatusUp, + LastCheckInDate: time.Now().Unix(), + }, + expectedStatus: statusHeartbeat, + }, + { + name: "edge endpoint with stale check-in returns down regardless of stored status", + endpoint: &portainer.Endpoint{ + Type: portainer.EdgeAgentOnDockerEnvironment, + Status: portainer.EndpointStatusUp, + LastCheckInDate: 0, + }, + expectedStatus: statusDown, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expectedStatus, resolveEndpointStatus(tt.endpoint, settings)) + }) + } +} + +func TestIsOutdated(t *testing.T) { + currentVersion := portainer.APIVersion + tests := []struct { + name string + version string + lastCheckInDate int64 + expected bool + }{ + {name: "empty version with prior check-in is outdated (old agent style)", version: "", lastCheckInDate: time.Now().Unix(), expected: true}, + {name: "empty version with no check-in is not outdated (never connected)", version: "", lastCheckInDate: 0, expected: false}, + {name: "old version is outdated", version: "2.0.0", expected: true}, + {name: "v-prefixed old version is outdated", version: "v2.0.0", expected: true}, + {name: "current version is not outdated", version: currentVersion, expected: false}, + {name: "v-prefixed current version is not outdated", version: "v" + currentVersion, expected: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Empty-version logic only applies to edge endpoints; use EdgeAgentOnDocker for those cases. + epType := portainer.AgentOnDockerEnvironment + if tt.version == "" { + epType = portainer.EdgeAgentOnDockerEnvironment + } + ep := &portainer.Endpoint{Type: epType, LastCheckInDate: tt.lastCheckInDate} + ep.Agent.Version = tt.version + assert.Equal(t, tt.expected, isOutdated(ep)) + }) + } +} + +func TestParseGroupCounts(t *testing.T) { + groups := []portainer.EndpointGroup{ + {ID: 1, Name: "Unassigned"}, + {ID: 3, Name: "Production"}, + {ID: 2, Name: "Staging"}, + } + + tests := []struct { + name string + counts map[portainer.EndpointGroupID]int + expected []groupCount + }{ + { + name: "empty counts returns empty slice", + counts: map[portainer.EndpointGroupID]int{}, + expected: []groupCount{}, + }, + { + name: "results are sorted by GroupID ascending", + counts: map[portainer.EndpointGroupID]int{ + 3: 5, + 1: 2, + 2: 8, + }, + expected: []groupCount{ + {GroupID: 1, GroupName: "Unassigned", Count: 2}, + {GroupID: 2, GroupName: "Staging", Count: 8}, + {GroupID: 3, GroupName: "Production", Count: 5}, + }, + }, + { + name: "group with no matching name gets empty string", + counts: map[portainer.EndpointGroupID]int{ + 99: 1, + }, + expected: []groupCount{ + {GroupID: 99, GroupName: "", Count: 1}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseGroupCounts(tt.counts, groups) + assert.Equal(t, tt.expected, got) + }) + } +} + +func TestCanonicalizeSemver(t *testing.T) { + assert.Equal(t, "v2.0.0", canonicalizeSemver("2.0.0")) + assert.Equal(t, "v2.0.0", canonicalizeSemver("v2.0.0")) + assert.Empty(t, canonicalizeSemver("")) + assert.Empty(t, canonicalizeSemver(" ")) +} diff --git a/api/http/handler/endpoints/endpoint_update.go b/api/http/handler/endpoints/endpoint_update.go new file mode 100644 index 0000000..a1cbb44 --- /dev/null +++ b/api/http/handler/endpoints/endpoint_update.go @@ -0,0 +1,317 @@ +package endpoints + +import ( + "cmp" + "net/http" + "reflect" + "strconv" + "strings" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/http/client" + "github.com/portainer/portainer/api/internal/endpointutils" + "github.com/portainer/portainer/api/pendingactions/handlers" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" + + "github.com/rs/zerolog/log" +) + +type endpointUpdatePayload struct { + // Name that will be used to identify this environment(endpoint) + Name *string `example:"my-environment"` + // URL or IP address of a Docker host + URL *string `example:"docker.mydomain.tld:2375"` + // URL or IP address where exposed containers will be reachable.\ + // Defaults to URL if not specified + PublicURL *string `example:"docker.mydomain.tld:2375"` + // GPUs information + Gpus []portainer.Pair + // Group identifier + GroupID *int `example:"1"` + // Require TLS to connect against this environment(endpoint) + TLS *bool `example:"true"` + // Skip server verification when using TLS + TLSSkipVerify *bool `example:"false"` + // Skip client verification when using TLS + TLSSkipClientVerify *bool `example:"false"` + // The status of the environment(endpoint) (1 - up, 2 - down) + Status *int `example:"1"` + // Azure application ID + AzureApplicationID *string `example:"eag7cdo9-o09l-9i83-9dO9-f0b23oe78db4"` + // Azure tenant ID + AzureTenantID *string `example:"34ddc78d-4fel-2358-8cc1-df84c8o839f5"` + // Azure authentication key + AzureAuthenticationKey *string `example:"cOrXoK/1D35w8YQ8nH1/8ZGwzz45JIYD5jxHKXEQknk="` + // List of tag identifiers to which this environment(endpoint) is associated + TagIDs []portainer.TagID `example:"1,2"` + UserAccessPolicies portainer.UserAccessPolicies + TeamAccessPolicies portainer.TeamAccessPolicies + // The check in interval for edge agent (in seconds) + EdgeCheckinInterval *int `example:"5"` + // Associated Kubernetes data + Kubernetes *portainer.KubernetesData +} + +func (payload *endpointUpdatePayload) Validate(r *http.Request) error { + return nil +} + +// @id EndpointUpdate +// @summary Update an environment(endpoint) +// @description Update an environment(endpoint). +// @description **Access policy**: authenticated +// @security ApiKeyAuth +// @security jwt +// @tags endpoints +// @accept json +// @produce json +// @param id path int true "Environment(Endpoint) identifier" +// @param body body endpointUpdatePayload true "Environment(Endpoint) details" +// @success 200 {object} portainer.Endpoint "Success" +// @failure 400 "Invalid request" +// @failure 404 "Environment(Endpoint) not found" +// @failure 409 "Name is not unique" +// @failure 500 "Server error" +// @router /endpoints/{id} [put] +func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid environment identifier route variable", err) + } + + var payload endpointUpdatePayload + if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) + if handler.DataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find an environment with the specified identifier inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find an environment with the specified identifier inside the database", err) + } + + if payload.TLS != nil && *payload.TLS && endpointutils.IsEdgeEndpoint(endpoint) { + return httperror.BadRequest("TLS is not supported for Edge Agent environments", nil) + } + + updateEndpointProxy := shouldReloadTLSConfiguration(endpoint, &payload) + + if payload.Name != nil { + name := *payload.Name + if isUnique, err := handler.isNameUnique(name, endpoint.ID); err != nil { + return httperror.InternalServerError("Unable to check if name is unique", err) + } else if !isUnique { + return httperror.Conflict("Name is not unique", nil) + } + + endpoint.Name = name + } + + if payload.URL != nil && *payload.URL != endpoint.URL { + endpoint.URL = *payload.URL + updateEndpointProxy = true + } + + if payload.Gpus != nil { + endpoint.Gpus = payload.Gpus + } + + endpoint.PublicURL = *cmp.Or(payload.PublicURL, &endpoint.PublicURL) + endpoint.EdgeCheckinInterval = *cmp.Or(payload.EdgeCheckinInterval, &endpoint.EdgeCheckinInterval) + + updateRelations := false + + if payload.GroupID != nil { + groupID := portainer.EndpointGroupID(*payload.GroupID) + + updateRelations = updateRelations || groupID != endpoint.GroupID + endpoint.GroupID = groupID + } + + if payload.TagIDs != nil { + if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + tagsChanged, err := updateEnvironmentTags(tx, payload.TagIDs, endpoint.TagIDs, endpoint.ID) + if err != nil { + return err + } + + endpoint.TagIDs = payload.TagIDs + updateRelations = updateRelations || tagsChanged + + return nil + }); err != nil { + return httperror.InternalServerError("Unable to update environment tags", err) + } + } + + updateAuthorizations := false + + if payload.Kubernetes != nil { + if payload.Kubernetes.Configuration.RestrictDefaultNamespace != + endpoint.Kubernetes.Configuration.RestrictDefaultNamespace { + updateAuthorizations = true + } + + endpoint.Kubernetes = *payload.Kubernetes + } + + if payload.UserAccessPolicies != nil && !reflect.DeepEqual(payload.UserAccessPolicies, endpoint.UserAccessPolicies) { + updateAuthorizations = true + endpoint.UserAccessPolicies = payload.UserAccessPolicies + } + + if payload.TeamAccessPolicies != nil && !reflect.DeepEqual(payload.TeamAccessPolicies, endpoint.TeamAccessPolicies) { + updateAuthorizations = true + endpoint.TeamAccessPolicies = payload.TeamAccessPolicies + } + + if payload.Status != nil { + switch *payload.Status { + case 1: + endpoint.Status = portainer.EndpointStatusUp + case 2: + endpoint.Status = portainer.EndpointStatusDown + } + } + + if endpoint.Type == portainer.AzureEnvironment { + updateEndpointProxy = true + + credentials := endpoint.AzureCredentials + if payload.AzureApplicationID != nil { + credentials.ApplicationID = *payload.AzureApplicationID + } + + if payload.AzureTenantID != nil { + credentials.TenantID = *payload.AzureTenantID + } + + if payload.AzureAuthenticationKey != nil { + credentials.AuthenticationKey = *payload.AzureAuthenticationKey + } + + httpClient := client.NewHTTPClient() + if _, err := httpClient.ExecuteAzureAuthenticationRequest(&credentials); err != nil { + return httperror.InternalServerError("Unable to authenticate against Azure", err) + } + endpoint.AzureCredentials = credentials + } + + if payload.TLS != nil { + folder := strconv.Itoa(endpointID) + + if *payload.TLS { + endpoint.TLSConfig.TLS = true + if payload.TLSSkipVerify != nil { + endpoint.TLSConfig.TLSSkipVerify = *payload.TLSSkipVerify + + if !*payload.TLSSkipVerify { + caCertPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCA) + endpoint.TLSConfig.TLSCACertPath = caCertPath + } else { + endpoint.TLSConfig.TLSCACertPath = "" + + if err := handler.FileService.DeleteTLSFile(folder, portainer.TLSFileCA); err != nil { + log.Warn().Err(err).Msg("Unable to remove CA cert from disk") + } + } + } + + if payload.TLSSkipClientVerify != nil { + if !*payload.TLSSkipClientVerify { + certPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCert) + endpoint.TLSConfig.TLSCertPath = certPath + keyPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileKey) + endpoint.TLSConfig.TLSKeyPath = keyPath + } else { + endpoint.TLSConfig.TLSCertPath = "" + if err := handler.FileService.DeleteTLSFile(folder, portainer.TLSFileCert); err != nil { + log.Warn().Err(err).Msg("Unable to remove TLS cert from disk") + } + + endpoint.TLSConfig.TLSKeyPath = "" + if err := handler.FileService.DeleteTLSFile(folder, portainer.TLSFileKey); err != nil { + log.Warn().Err(err).Msg("Unable to remove TLS key from disk") + } + } + } + } else { + endpoint.TLSConfig.TLS = false + endpoint.TLSConfig.TLSSkipVerify = false + endpoint.TLSConfig.TLSCACertPath = "" + endpoint.TLSConfig.TLSCertPath = "" + endpoint.TLSConfig.TLSKeyPath = "" + + if err := handler.FileService.DeleteTLSFiles(folder); err != nil { + return httperror.InternalServerError("Unable to remove TLS files from disk", err) + } + } + + isStandardKubeAgent := !endpointutils.IsLocalEndpoint(endpoint) && endpointutils.IsKubernetesEndpoint(endpoint) && !endpointutils.IsEdgeEndpoint(endpoint) + if isStandardKubeAgent { + endpoint.TLSConfig.TLS = true + endpoint.TLSConfig.TLSSkipVerify = true + } + } + + if updateEndpointProxy { + handler.ProxyManager.DeleteEndpointProxy(endpoint.ID) + + if _, err := handler.ProxyManager.CreateAndRegisterEndpointProxy(endpoint); err != nil { + return httperror.InternalServerError("Unable to register HTTP proxy for the environment", err) + } + } + + if updateAuthorizations && endpointutils.IsKubernetesEndpoint(endpoint) { + if err := handler.AuthorizationService.CleanNAPWithOverridePolicies(handler.DataStore, endpoint, nil); err != nil { + log.Warn().Err(err).Msgf("Unable to clean NAP with override policies for endpoint (%d). Will try to update when endpoint is online.", endpoint.ID) + + if err := handler.PendingActionsService.Create(handler.DataStore, handlers.NewCleanNAPWithOverridePolicies(endpoint.ID, nil)); err != nil { + log.Warn().Err(err).Msg("unable to schedule pending action to clean NAP with override policies") + } + } + } + + if err := handler.DataStore.Endpoint().UpdateEndpoint(endpoint.ID, endpoint); err != nil { + return httperror.InternalServerError("Unable to persist environment changes inside the database", err) + } + + if updateRelations { + if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + return handler.updateEdgeRelations(tx, endpoint) + }); err != nil { + return httperror.InternalServerError("Unable to update environment relations", err) + } + } + + if err := handler.SnapshotService.FillSnapshotData(endpoint, true); err != nil { + return httperror.InternalServerError("Unable to add snapshot data", err) + } + + return response.JSON(w, endpoint) +} + +func shouldReloadTLSConfiguration(endpoint *portainer.Endpoint, payload *endpointUpdatePayload) bool { + // If we change anything in the tls config then we need to reload the proxy + if payload.TLS != nil && endpoint.TLSConfig.TLS != *payload.TLS { + return true + } + + // When updating Docker API environment, as long as TLS is true and TLSSkipVerify is false, + // we assume that new TLS files have been uploaded and we need to reload the TLS configuration. + if endpoint.Type != portainer.DockerEnvironment || + (payload.URL != nil && !strings.HasPrefix(*payload.URL, "tcp://")) || + payload.TLS == nil || !*payload.TLS { + return false + } + + if payload.TLSSkipVerify != nil && !*payload.TLSSkipVerify { + return true + } + + return payload.TLSSkipClientVerify != nil && !*payload.TLSSkipClientVerify +} diff --git a/api/http/handler/endpoints/endpoint_update_relations.go b/api/http/handler/endpoints/endpoint_update_relations.go new file mode 100644 index 0000000..fb2787f --- /dev/null +++ b/api/http/handler/endpoints/endpoint_update_relations.go @@ -0,0 +1,109 @@ +package endpoints + +import ( + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" + + "github.com/pkg/errors" +) + +type endpointUpdateRelationsPayload struct { + Relations map[portainer.EndpointID]struct { + EdgeGroups []portainer.EdgeGroupID + Tags []portainer.TagID + Group portainer.EndpointGroupID + } +} + +func (payload *endpointUpdateRelationsPayload) Validate(r *http.Request) error { + for eID := range payload.Relations { + if eID == 0 { + return errors.New("Missing environment identifier") + } + } + + return nil +} + +// @id EndpointUpdateRelations +// @summary Update relations for a list of environments +// @description Update relations for a list of environments +// @description Edge groups, tags and environment group can be updated. +// @description +// @description **Access policy**: administrator +// @tags endpoints +// @security jwt +// @accept json +// @param body body endpointUpdateRelationsPayload true "Environment relations data" +// @success 204 "Success" +// @failure 400 "Invalid request" +// @failure 401 "Unauthorized" +// @failure 404 "Not found" +// @failure 500 "Server error" +// @router /endpoints/relations [put] +func (handler *Handler) updateRelations(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + + payload, err := request.GetPayload[endpointUpdateRelationsPayload](r) + if err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + for environmentID, relationPayload := range payload.Relations { + endpoint, err := tx.Endpoint().Endpoint(environmentID) + if err != nil { + return errors.WithMessage(err, "Unable to find an environment with the specified identifier inside the database") + } + + updateRelations := false + + if relationPayload.Group != 0 { + groupIDChanged := endpoint.GroupID != relationPayload.Group + endpoint.GroupID = relationPayload.Group + updateRelations = updateRelations || groupIDChanged + } + + if relationPayload.Tags != nil { + tagsChanged, err := updateEnvironmentTags(tx, relationPayload.Tags, endpoint.TagIDs, endpoint.ID) + if err != nil { + return errors.WithMessage(err, "Unable to update environment tags") + } + + endpoint.TagIDs = relationPayload.Tags + updateRelations = updateRelations || tagsChanged + } + + if relationPayload.EdgeGroups != nil { + edgeGroupsChanged, err := updateEnvironmentEdgeGroups(tx, relationPayload.EdgeGroups, endpoint.ID) + if err != nil { + return errors.WithMessage(err, "Unable to update environment edge groups") + } + + updateRelations = updateRelations || edgeGroupsChanged + } + + if updateRelations { + if err := tx.Endpoint().UpdateEndpoint(endpoint.ID, endpoint); err != nil { + return errors.WithMessage(err, "Unable to update environment") + } + + if err := handler.updateEdgeRelations(tx, endpoint); err != nil { + return errors.WithMessage(err, "Unable to update environment relations") + } + } + } + + return nil + }) + + if err != nil { + return httperror.InternalServerError("Unable to update environment relations", err) + } + + return response.Empty(w) +} diff --git a/api/http/handler/endpoints/endpoint_update_test.go b/api/http/handler/endpoints/endpoint_update_test.go new file mode 100644 index 0000000..e233faa --- /dev/null +++ b/api/http/handler/endpoints/endpoint_update_test.go @@ -0,0 +1,67 @@ +package endpoints + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/testhelpers" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_endpointPut_TLSRejectedForEdgeEndpoint(t *testing.T) { + t.Parallel() + + _, store := datastore.MustNewTestStore(t, true, true) + + h := NewHandler(testhelpers.NewTestRequestBouncer()) + h.DataStore = store + + testCases := []struct { + name string + endpointType portainer.EndpointType + }{ + { + name: "edge agent on docker rejects TLS", + endpointType: portainer.EdgeAgentOnDockerEnvironment, + }, + { + name: "edge agent on kubernetes rejects TLS", + endpointType: portainer.EdgeAgentOnKubernetesEnvironment, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + endpointID := portainer.EndpointID(store.Endpoint().GetNextIdentifier()) + err := store.Endpoint().Create(&portainer.Endpoint{ + ID: endpointID, + Type: tc.endpointType, + }) + require.NoError(t, err) + + payload := &endpointUpdatePayload{TLS: new(true)} + bodyJSON, err := json.Marshal(payload) + require.NoError(t, err) + + url := fmt.Sprintf("/endpoints/%d", endpointID) + req := httptest.NewRequest(http.MethodPut, url, bytes.NewBuffer(bodyJSON)) + rctx := security.StoreTokenData(req, &portainer.TokenData{ID: 1, Username: "admin", Role: portainer.AdministratorRole}) + req = req.WithContext(rctx) + req.Header.Set("Content-Type", "application/json") + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusBadRequest, rr.Code) + }) + } +} diff --git a/api/http/handler/endpoints/filter.go b/api/http/handler/endpoints/filter.go new file mode 100644 index 0000000..2bacfe1 --- /dev/null +++ b/api/http/handler/endpoints/filter.go @@ -0,0 +1,748 @@ +package endpoints + +import ( + "fmt" + "net/http" + "slices" + "strconv" + "strings" + "time" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/edge" + "github.com/portainer/portainer/api/internal/endpointutils" + "github.com/portainer/portainer/api/roar" + "github.com/portainer/portainer/api/set" + "github.com/portainer/portainer/api/slicesx" + "github.com/portainer/portainer/pkg/libhttp/request" + + "github.com/pkg/errors" +) + +type EnvironmentsQuery struct { + search string + groupIds []portainer.EndpointGroupID + status []portainer.EndpointStatus + types []portainer.EndpointType + platformTypes []portainer.PlatformType + outdated bool + excludeGroupIds []portainer.EndpointGroupID + tagIds []portainer.TagID + tagsPartialMatch bool + endpointIds []portainer.EndpointID + excludeIds []portainer.EndpointID + agentVersions []string + // if edgeAsync not nil, will filter edge endpoints based on this value + edgeAsync *bool + edgeDeviceUntrusted bool + edgeCheckInPassedSeconds int + excludeSnapshots bool + name string + edgeStackId portainer.EdgeStackID + edgeStackStatus *portainer.EdgeStackStatusType + edgeGroupIds []portainer.EdgeGroupID + excludeEdgeGroupIds []portainer.EdgeGroupID +} + +func parseQuery(r *http.Request) (EnvironmentsQuery, error) { + search, _ := request.RetrieveQueryParameter(r, "search", true) + if search != "" { + search = strings.ToLower(search) + } + + status, err := request.RetrieveNumberArrayQueryParameter[portainer.EndpointStatus](r, "status") + if err != nil { + return EnvironmentsQuery{}, err + } + + groupIDs, err := request.RetrieveNumberArrayQueryParameter[portainer.EndpointGroupID](r, "groupIds") + if err != nil { + return EnvironmentsQuery{}, err + } + + endpointTypes, err := request.RetrieveNumberArrayQueryParameter[portainer.EndpointType](r, "types") + if err != nil { + return EnvironmentsQuery{}, err + } + + platformTypes, err := request.RetrieveNumberArrayQueryParameter[portainer.PlatformType](r, "platformTypes") + if err != nil { + return EnvironmentsQuery{}, err + } + + tagIDs, err := request.RetrieveNumberArrayQueryParameter[portainer.TagID](r, "tagIds") + if err != nil { + return EnvironmentsQuery{}, err + } + + tagsPartialMatch, _ := request.RetrieveBooleanQueryParameter(r, "tagsPartialMatch", true) + + endpointIDs, err := request.RetrieveNumberArrayQueryParameter[portainer.EndpointID](r, "endpointIds") + if err != nil { + return EnvironmentsQuery{}, err + } + + excludeIDs, err := request.RetrieveNumberArrayQueryParameter[portainer.EndpointID](r, "excludeIds") + if err != nil { + return EnvironmentsQuery{}, err + } + + excludeGroupIDs, err := request.RetrieveNumberArrayQueryParameter[portainer.EndpointGroupID](r, "excludeGroupIds") + if err != nil { + return EnvironmentsQuery{}, err + } + + edgeGroupIDs, err := request.RetrieveNumberArrayQueryParameter[portainer.EdgeGroupID](r, "edgeGroupIds") + if err != nil { + return EnvironmentsQuery{}, err + } + + excludeEdgeGroupIds, err := request.RetrieveNumberArrayQueryParameter[portainer.EdgeGroupID](r, "excludeEdgeGroupIds") + if err != nil { + return EnvironmentsQuery{}, err + } + + agentVersions := request.RetrieveArrayQueryParameter(r, "agentVersions") + + outdated, _ := request.RetrieveBooleanQueryParameter(r, "outdated", true) + + name, _ := request.RetrieveQueryParameter(r, "name", true) + + var edgeAsync *bool + edgeAsyncParam, _ := request.RetrieveQueryParameter(r, "edgeAsync", true) + if edgeAsyncParam != "" { + edgeAsync = new(edgeAsyncParam == "true") + } + + edgeDeviceUntrusted, _ := request.RetrieveBooleanQueryParameter(r, "edgeDeviceUntrusted", true) + + excludeSnapshots, _ := request.RetrieveBooleanQueryParameter(r, "excludeSnapshots", true) + + edgeCheckInPassedSeconds, _ := request.RetrieveNumericQueryParameter(r, "edgeCheckInPassedSeconds", true) + + edgeStackId, _ := request.RetrieveNumericQueryParameter(r, "edgeStackId", true) + + edgeStackStatus, err := getEdgeStackStatusParam(r) + if err != nil { + return EnvironmentsQuery{}, err + } + + return EnvironmentsQuery{ + search: search, + types: endpointTypes, + platformTypes: platformTypes, + tagIds: tagIDs, + endpointIds: endpointIDs, + excludeIds: excludeIDs, + excludeGroupIds: excludeGroupIDs, + tagsPartialMatch: tagsPartialMatch, + groupIds: groupIDs, + status: status, + edgeAsync: edgeAsync, + edgeDeviceUntrusted: edgeDeviceUntrusted, + excludeSnapshots: excludeSnapshots, + name: name, + agentVersions: agentVersions, + outdated: outdated, + edgeCheckInPassedSeconds: edgeCheckInPassedSeconds, + edgeStackId: portainer.EdgeStackID(edgeStackId), + edgeStackStatus: edgeStackStatus, + edgeGroupIds: edgeGroupIDs, + excludeEdgeGroupIds: excludeEdgeGroupIds, + }, nil +} + +func (handler *Handler) filterEndpointsByQuery( + filteredEndpoints []portainer.Endpoint, + query EnvironmentsQuery, + groups []portainer.EndpointGroup, + edgeGroups []portainer.EdgeGroup, + settings *portainer.Settings, + context *security.RestrictedRequestContext, +) ([]portainer.Endpoint, int, error) { + totalAvailableEndpoints := len(filteredEndpoints) + + if len(query.endpointIds) > 0 { + endpointIDs := roar.FromSlice(query.endpointIds) + + filteredEndpoints = filteredEndpointsByIds(filteredEndpoints, endpointIDs) + } + + if len(query.excludeIds) > 0 { + filteredEndpoints = filter(filteredEndpoints, func(endpoint portainer.Endpoint) bool { + return !slices.Contains(query.excludeIds, endpoint.ID) + }) + } + + if len(query.excludeGroupIds) > 0 { + filteredEndpoints = filter(filteredEndpoints, func(endpoint portainer.Endpoint) bool { + return !slices.Contains(query.excludeGroupIds, endpoint.GroupID) + }) + } + + if len(query.groupIds) > 0 { + filteredEndpoints = filterEndpointsByGroupIDs(filteredEndpoints, query.groupIds) + } + + if len(query.edgeGroupIds) > 0 { + filteredEndpoints, edgeGroups = filterEndpointsByEdgeGroupIDs(filteredEndpoints, edgeGroups, query.edgeGroupIds) + } + + if len(query.excludeEdgeGroupIds) > 0 { + filteredEndpoints, edgeGroups = filterEndpointsByExcludeEdgeGroupIDs(filteredEndpoints, edgeGroups, query.excludeEdgeGroupIds) + } + + if query.name != "" { + filteredEndpoints = filterEndpointsByName(filteredEndpoints, query.name) + } + + // filter async edge environments + if query.edgeAsync != nil { + filteredEndpoints = filter(filteredEndpoints, func(endpoint portainer.Endpoint) bool { + if !endpointutils.IsEdgeEndpoint(&endpoint) { + return true + } + + return endpoint.Edge.AsyncMode == *query.edgeAsync + }) + } + + // filter edge environments by trusted/untrusted + // only portainer admins are allowed to see untrusted environments + filteredEndpoints = filter(filteredEndpoints, func(endpoint portainer.Endpoint) bool { + if !endpointutils.IsEdgeEndpoint(&endpoint) { + return true + } + + if query.edgeDeviceUntrusted { + return !endpoint.UserTrusted && context.IsAdmin + } + + return endpoint.UserTrusted == !query.edgeDeviceUntrusted + }) + + if query.edgeCheckInPassedSeconds > 0 { + filteredEndpoints = filter(filteredEndpoints, func(endpoint portainer.Endpoint) bool { + // ignore non-edge endpoints + if !endpointutils.IsEdgeEndpoint(&endpoint) { + return true + } + + // filter out endpoints that have never checked in + if endpoint.LastCheckInDate == 0 { + return false + } + + return time.Now().Unix()-endpoint.LastCheckInDate < int64(query.edgeCheckInPassedSeconds) + }) + } + + if len(query.status) > 0 { + filteredEndpoints = filterEndpointsByStatuses(filteredEndpoints, query.status, settings) + } + + if query.search != "" { + tags, err := handler.DataStore.Tag().ReadAll() + if err != nil { + return nil, 0, errors.WithMessage(err, "Unable to retrieve tags from the database") + } + + tagsMap := make(map[portainer.TagID]string, len(tags)) + for _, tag := range tags { + tagsMap[tag.ID] = tag.Name + } + + filteredEndpoints = filterEndpointsBySearchCriteria(filteredEndpoints, groups, edgeGroups, tagsMap, query.search) + } + + if len(query.types) > 0 { + filteredEndpoints = filterEndpointsByTypes(filteredEndpoints, query.types) + } + + if len(query.platformTypes) > 0 { + filteredEndpoints = filterEndpointsByPlatform(filteredEndpoints, query.platformTypes) + } + + if len(query.tagIds) > 0 { + filteredEndpoints = filteredEndpointsByTags(filteredEndpoints, query.tagIds, groups, query.tagsPartialMatch) + } + + if len(query.agentVersions) > 0 { + filteredEndpoints = filter(filteredEndpoints, func(endpoint portainer.Endpoint) bool { + return !endpointutils.IsAgentEndpoint(&endpoint) || slices.Contains(query.agentVersions, endpoint.Agent.Version) + }) + } + + if query.outdated { + filteredEndpoints = filter(filteredEndpoints, func(endpoint portainer.Endpoint) bool { + return isOutdated(&endpoint) + }) + } + + if query.edgeStackId != 0 { + f, err := filterEndpointsByEdgeStack(filteredEndpoints, query.edgeStackId, query.edgeStackStatus, handler.DataStore) + if err != nil { + return nil, 0, err + } + filteredEndpoints = f + } + + return filteredEndpoints, totalAvailableEndpoints, nil +} + +func endpointStatusInStackMatchesFilter(stackStatus *portainer.EdgeStackStatusForEnv, statusFilter portainer.EdgeStackStatusType) bool { + // consider that if the env has no status in the stack it is in Pending state + if statusFilter == portainer.EdgeStackStatusPending { + return stackStatus == nil || len(stackStatus.Status) == 0 + } + + if stackStatus == nil { + return false + } + + return slices.ContainsFunc(stackStatus.Status, func(s portainer.EdgeStackDeploymentStatus) bool { + return s.Type == statusFilter + }) +} + +func filterEndpointsByEdgeStack(endpoints []portainer.Endpoint, edgeStackId portainer.EdgeStackID, statusFilter *portainer.EdgeStackStatusType, datastore dataservices.DataStore) ([]portainer.Endpoint, error) { + var filteredEndpoints []portainer.Endpoint + if err := datastore.ViewTx(func(tx dataservices.DataStoreTx) error { + stack, err := tx.EdgeStack().EdgeStack(edgeStackId) + if err != nil { + return errors.WithMessage(err, "Unable to retrieve edge stack from the database") + } + + envIds := roar.Roar[portainer.EndpointID]{} + for _, edgeGroupId := range stack.EdgeGroups { + edgeGroup, err := tx.EdgeGroup().Read(edgeGroupId) + if err != nil { + return errors.WithMessage(err, "Unable to retrieve edge group from the database") + } + + if edgeGroup.Dynamic { + endpointIDs, err := endpointutils.GetEndpointsByTags(tx, edgeGroup.TagIDs, edgeGroup.PartialMatch) + if err != nil { + return errors.WithMessage(err, "Unable to retrieve environments and environment groups for Edge group") + } + edgeGroup.EndpointIDs = roar.FromSlice(endpointIDs) + } + + envIds.Union(edgeGroup.EndpointIDs) + } + + filteredEnvIds := roar.Roar[portainer.EndpointID]{} + filteredEnvIds.Union(envIds) + + if statusFilter != nil { + var innerErr error + + envIds.Iterate(func(envId portainer.EndpointID) bool { + edgeStackStatus, err := tx.EdgeStackStatus().Read(edgeStackId, envId) + if err != nil && !dataservices.IsErrObjectNotFound(err) { + innerErr = errors.WithMessagef(err, "Unable to retrieve edge stack status for environment %d", envId) + return false + } + + if !endpointStatusInStackMatchesFilter(edgeStackStatus, *statusFilter) { + filteredEnvIds.Remove(envId) + } + + return true + }) + + if innerErr != nil { + return innerErr + } + } + + filteredEndpoints = filteredEndpointsByIds(endpoints, filteredEnvIds) + + return nil + }); err != nil { + return nil, err + } + return filteredEndpoints, nil +} + +func filterEndpointsByGroupIDs(endpoints []portainer.Endpoint, endpointGroupIDs []portainer.EndpointGroupID) []portainer.Endpoint { + n := 0 + for _, endpoint := range endpoints { + if slices.Contains(endpointGroupIDs, endpoint.GroupID) { + endpoints[n] = endpoint + n++ + } + } + + return endpoints[:n] +} + +func filterEndpointsByEdgeGroupIDs(endpoints []portainer.Endpoint, edgeGroups []portainer.EdgeGroup, edgeGroupIDs []portainer.EdgeGroupID) ([]portainer.Endpoint, []portainer.EdgeGroup) { + edgeGroupIDFilterSet := make(map[portainer.EdgeGroupID]struct{}, len(edgeGroupIDs)) + for _, id := range edgeGroupIDs { + edgeGroupIDFilterSet[id] = struct{}{} + } + + n := 0 + for _, edgeGroup := range edgeGroups { + if _, exists := edgeGroupIDFilterSet[edgeGroup.ID]; exists { + edgeGroups[n] = edgeGroup + n++ + } + } + edgeGroups = edgeGroups[:n] + + endpointIDSet := roar.Roar[portainer.EndpointID]{} + for _, edgeGroup := range edgeGroups { + endpointIDSet.Union(edgeGroup.EndpointIDs) + } + + n = 0 + for _, endpoint := range endpoints { + if endpointIDSet.Contains(endpoint.ID) { + endpoints[n] = endpoint + n++ + } + } + + return endpoints[:n], edgeGroups +} + +func filterEndpointsByExcludeEdgeGroupIDs(endpoints []portainer.Endpoint, edgeGroups []portainer.EdgeGroup, excludeEdgeGroupIds []portainer.EdgeGroupID) ([]portainer.Endpoint, []portainer.EdgeGroup) { + excludeEdgeGroupIDSet := make(map[portainer.EdgeGroupID]struct{}, len(excludeEdgeGroupIds)) + for _, id := range excludeEdgeGroupIds { + excludeEdgeGroupIDSet[id] = struct{}{} + } + + n := 0 + excludeEndpointIDSet := roar.Roar[portainer.EndpointID]{} + + for _, edgeGroup := range edgeGroups { + if _, ok := excludeEdgeGroupIDSet[edgeGroup.ID]; ok { + excludeEndpointIDSet.Union(edgeGroup.EndpointIDs) + } else { + edgeGroups[n] = edgeGroup + n++ + } + } + edgeGroups = edgeGroups[:n] + + n = 0 + for _, endpoint := range endpoints { + if !excludeEndpointIDSet.Contains(endpoint.ID) { + endpoints[n] = endpoint + n++ + } + } + + return endpoints[:n], edgeGroups +} + +func filterEndpointsBySearchCriteria( + endpoints []portainer.Endpoint, + endpointGroups []portainer.EndpointGroup, + edgeGroups []portainer.EdgeGroup, + tagsMap map[portainer.TagID]string, + searchCriteria string, +) []portainer.Endpoint { + n := 0 + for _, endpoint := range endpoints { + if endpointMatchSearchCriteria(&endpoint, tagsMap, searchCriteria) { + endpoints[n] = endpoint + n++ + + continue + } + + if endpointGroupMatchSearchCriteria(&endpoint, endpointGroups, tagsMap, searchCriteria) { + endpoints[n] = endpoint + n++ + + continue + } + + if edgeGroupMatchSearchCriteria(&endpoint, edgeGroups, searchCriteria, endpointGroups) { + endpoints[n] = endpoint + n++ + + continue + } + } + + return endpoints[:n] +} + +func filterEndpointsByStatuses(endpoints []portainer.Endpoint, statuses []portainer.EndpointStatus, settings *portainer.Settings) []portainer.Endpoint { + n := 0 + for _, endpoint := range endpoints { + status := endpoint.Status + if endpointutils.IsEdgeEndpoint(&endpoint) { + isCheckValid := false + + edgeCheckinInterval := endpoint.EdgeCheckinInterval + if edgeCheckinInterval == 0 { + edgeCheckinInterval = settings.EdgeAgentCheckinInterval + } + + if endpoint.Edge.AsyncMode { + edgeCheckinInterval = getShortestAsyncInterval(&endpoint, settings) + } + + if edgeCheckinInterval != 0 && endpoint.LastCheckInDate != 0 { + isCheckValid = time.Now().Unix()-endpoint.LastCheckInDate <= int64(edgeCheckinInterval*EdgeDeviceIntervalMultiplier+EdgeDeviceIntervalAdd) + } + + status = portainer.EndpointStatusDown // Offline + if isCheckValid { + status = portainer.EndpointStatusUp // Online + } + } + + if slices.Contains(statuses, status) { + endpoints[n] = endpoint + n++ + } + } + + return endpoints[:n] +} + +func endpointMatchSearchCriteria(endpoint *portainer.Endpoint, tagsMap map[portainer.TagID]string, searchCriteria string) bool { + if strings.Contains(strings.ToLower(endpoint.Name), searchCriteria) { + return true + } + + if strings.Contains(strings.ToLower(endpoint.URL), searchCriteria) { + return true + } + + if endpoint.Status == portainer.EndpointStatusUp && searchCriteria == "up" { + return true + } else if endpoint.Status == portainer.EndpointStatusDown && searchCriteria == "down" { + return true + } + + for _, tagID := range endpoint.TagIDs { + if strings.Contains(strings.ToLower(tagsMap[tagID]), searchCriteria) { + return true + } + } + + return false +} + +func endpointGroupMatchSearchCriteria(endpoint *portainer.Endpoint, endpointGroups []portainer.EndpointGroup, tagsMap map[portainer.TagID]string, searchCriteria string) bool { + for _, group := range endpointGroups { + if group.ID != endpoint.GroupID { + continue + } + + if strings.Contains(strings.ToLower(group.Name), searchCriteria) { + return true + } + + for _, tagID := range group.TagIDs { + if strings.Contains(strings.ToLower(tagsMap[tagID]), searchCriteria) { + return true + } + } + } + + return false +} + +// search endpoint's related edgegroups +func edgeGroupMatchSearchCriteria( + endpoint *portainer.Endpoint, + edgeGroups []portainer.EdgeGroup, + searchCriteria string, + endpointGroups []portainer.EndpointGroup, +) bool { + for _, edgeGroup := range edgeGroups { + relatedEndpointIDs := edge.EdgeGroupRelatedEndpoints(&edgeGroup, []portainer.Endpoint{*endpoint}, endpointGroups) + + for _, endpointID := range relatedEndpointIDs { + if endpointID == endpoint.ID { + if strings.Contains(strings.ToLower(edgeGroup.Name), searchCriteria) { + return true + } + } + } + } + + return false +} + +func filterEndpointsByTypes(endpoints []portainer.Endpoint, endpointTypes []portainer.EndpointType) []portainer.Endpoint { + typeSet := set.ToSet(endpointTypes) + + return slicesx.Filter(endpoints, func(e portainer.Endpoint) bool { + return typeSet[e.Type] + }) +} + +func filterEndpointsByPlatform(endpoints []portainer.Endpoint, platformTypes []portainer.PlatformType) []portainer.Endpoint { + typeSet := set.ToSet(platformTypes) + + return slicesx.Filter(endpoints, func(e portainer.Endpoint) bool { + return typeSet[endpointutils.EndpointPlatformType(&e)] + }) +} + +func filteredEndpointsByTags(endpoints []portainer.Endpoint, tagIDs []portainer.TagID, endpointGroups []portainer.EndpointGroup, partialMatch bool) []portainer.Endpoint { + n := 0 + for _, endpoint := range endpoints { + endpointGroup := getEndpointGroup(endpoint.GroupID, endpointGroups) + endpointMatched := false + + if partialMatch { + endpointMatched = endpointPartialMatchTags(endpoint, endpointGroup, tagIDs) + } else { + endpointMatched = endpointFullMatchTags(endpoint, endpointGroup, tagIDs) + } + + if endpointMatched { + endpoints[n] = endpoint + n++ + } + } + + return endpoints[:n] +} + +func endpointPartialMatchTags(endpoint portainer.Endpoint, endpointGroup portainer.EndpointGroup, tagIDs []portainer.TagID) bool { + tagSet := make(map[portainer.TagID]bool, len(tagIDs)) + + for _, tagID := range tagIDs { + tagSet[tagID] = true + } + + for _, tagID := range endpoint.TagIDs { + if tagSet[tagID] { + return true + } + } + + for _, tagID := range endpointGroup.TagIDs { + if tagSet[tagID] { + return true + } + } + + return false +} + +func endpointFullMatchTags(endpoint portainer.Endpoint, endpointGroup portainer.EndpointGroup, tagIDs []portainer.TagID) bool { + missingTags := make(map[portainer.TagID]bool) + for _, tagID := range tagIDs { + missingTags[tagID] = true + } + + for _, tagID := range endpoint.TagIDs { + if missingTags[tagID] { + delete(missingTags, tagID) + } + } + + for _, tagID := range endpointGroup.TagIDs { + if missingTags[tagID] { + delete(missingTags, tagID) + } + } + + return len(missingTags) == 0 +} + +func filteredEndpointsByIds(endpoints []portainer.Endpoint, ids roar.Roar[portainer.EndpointID]) []portainer.Endpoint { + n := 0 + for _, endpoint := range endpoints { + if ids.Contains(endpoint.ID) { + endpoints[n] = endpoint + n++ + } + } + + return endpoints[:n] +} + +func filterEndpointsByName(endpoints []portainer.Endpoint, name string) []portainer.Endpoint { + if name == "" { + return endpoints + } + + n := 0 + for _, endpoint := range endpoints { + if endpoint.Name == name { + endpoints[n] = endpoint + n++ + } + } + + return endpoints[:n] +} + +func filter(endpoints []portainer.Endpoint, predicate func(endpoint portainer.Endpoint) bool) []portainer.Endpoint { + n := 0 + for _, endpoint := range endpoints { + if predicate(endpoint) { + endpoints[n] = endpoint + n++ + } + } + + return endpoints[:n] +} + +func getEdgeStackStatusParam(r *http.Request) (*portainer.EdgeStackStatusType, error) { + edgeStackStatusQuery, _ := request.RetrieveQueryParameter(r, "edgeStackStatus", true) + if edgeStackStatusQuery == "" { + return nil, nil + } + + edgeStackStatusNumber, err := strconv.Atoi(edgeStackStatusQuery) + edgeStackStatus := portainer.EdgeStackStatusType(edgeStackStatusNumber) + if err != nil { + return nil, fmt.Errorf("failed parsing edgeStackStatus: %w", err) + } + + if !slices.Contains([]portainer.EdgeStackStatusType{ + portainer.EdgeStackStatusPending, + portainer.EdgeStackStatusDeploymentReceived, + portainer.EdgeStackStatusError, + portainer.EdgeStackStatusAcknowledged, + portainer.EdgeStackStatusRemoved, + portainer.EdgeStackStatusRemoteUpdateSuccess, + portainer.EdgeStackStatusImagesPulled, + portainer.EdgeStackStatusRunning, + portainer.EdgeStackStatusDeploying, + portainer.EdgeStackStatusRemoving, + portainer.EdgeStackStatusCompleted, + }, edgeStackStatus) { + return nil, errors.New("invalid edgeStackStatus parameter") + } + + return &edgeStackStatus, nil +} + +func getShortestAsyncInterval(endpoint *portainer.Endpoint, settings *portainer.Settings) int { + const edgeIntervalUseDefault = -1 + + pingInterval := endpoint.Edge.PingInterval + if pingInterval == edgeIntervalUseDefault { + pingInterval = settings.Edge.PingInterval + } + + snapshotInterval := endpoint.Edge.SnapshotInterval + if snapshotInterval == edgeIntervalUseDefault { + snapshotInterval = settings.Edge.SnapshotInterval + } + + commandInterval := endpoint.Edge.CommandInterval + if commandInterval == edgeIntervalUseDefault { + commandInterval = settings.Edge.CommandInterval + } + + return min(pingInterval, snapshotInterval, commandInterval) +} diff --git a/api/http/handler/endpoints/filter_test.go b/api/http/handler/endpoints/filter_test.go new file mode 100644 index 0000000..7505c26 --- /dev/null +++ b/api/http/handler/endpoints/filter_test.go @@ -0,0 +1,754 @@ +package endpoints + +import ( + "net/http" + "net/url" + "strconv" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/testhelpers" + "github.com/portainer/portainer/api/roar" + "github.com/portainer/portainer/api/slicesx" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type filterTest struct { + title string + expected []portainer.EndpointID + query EnvironmentsQuery +} + +func Test_Filter_AgentVersion(t *testing.T) { + t.Parallel() + version1Endpoint := portainer.Endpoint{ID: 1, GroupID: 1, + Type: portainer.AgentOnDockerEnvironment, + Agent: portainer.EnvironmentAgentData{Version: "1.0.0"}} + version2Endpoint := portainer.Endpoint{ID: 2, GroupID: 1, + Type: portainer.AgentOnDockerEnvironment, + Agent: portainer.EnvironmentAgentData{Version: "2.0.0"}} + noVersionEndpoint := portainer.Endpoint{ID: 3, GroupID: 1, + Type: portainer.AgentOnDockerEnvironment, + } + notAgentEnvironments := portainer.Endpoint{ID: 4, Type: portainer.DockerEnvironment, GroupID: 1} + + endpoints := []portainer.Endpoint{ + version1Endpoint, + version2Endpoint, + noVersionEndpoint, + notAgentEnvironments, + } + + handler := setupFilterTest(t, endpoints) + + tests := []filterTest{ + { + "should show version 1 endpoints", + []portainer.EndpointID{version1Endpoint.ID}, + EnvironmentsQuery{ + agentVersions: []string{version1Endpoint.Agent.Version}, + types: []portainer.EndpointType{portainer.AgentOnDockerEnvironment}, + }, + }, + { + "should show version 2 endpoints", + []portainer.EndpointID{version2Endpoint.ID}, + EnvironmentsQuery{ + agentVersions: []string{version2Endpoint.Agent.Version}, + types: []portainer.EndpointType{portainer.AgentOnDockerEnvironment}, + }, + }, + { + "should show version 1 and 2 endpoints", + []portainer.EndpointID{version2Endpoint.ID, version1Endpoint.ID}, + EnvironmentsQuery{ + agentVersions: []string{version2Endpoint.Agent.Version, version1Endpoint.Agent.Version}, + types: []portainer.EndpointType{portainer.AgentOnDockerEnvironment}, + }, + }, + } + + runTests(tests, t, handler, endpoints) +} + +func Test_Filter_edgeFilter(t *testing.T) { + t.Parallel() + trustedEdgeAsync := portainer.Endpoint{ID: 1, UserTrusted: true, Edge: portainer.EnvironmentEdgeSettings{AsyncMode: true}, GroupID: 1, Type: portainer.EdgeAgentOnDockerEnvironment} + untrustedEdgeAsync := portainer.Endpoint{ID: 2, UserTrusted: false, Edge: portainer.EnvironmentEdgeSettings{AsyncMode: true}, GroupID: 1, Type: portainer.EdgeAgentOnDockerEnvironment} + regularUntrustedEdgeStandard := portainer.Endpoint{ID: 3, UserTrusted: false, Edge: portainer.EnvironmentEdgeSettings{AsyncMode: false}, GroupID: 1, Type: portainer.EdgeAgentOnDockerEnvironment} + regularTrustedEdgeStandard := portainer.Endpoint{ID: 4, UserTrusted: true, Edge: portainer.EnvironmentEdgeSettings{AsyncMode: false}, GroupID: 1, Type: portainer.EdgeAgentOnDockerEnvironment} + regularEndpoint := portainer.Endpoint{ID: 5, GroupID: 1, Type: portainer.DockerEnvironment} + + endpoints := []portainer.Endpoint{ + trustedEdgeAsync, + untrustedEdgeAsync, + regularUntrustedEdgeStandard, + regularTrustedEdgeStandard, + regularEndpoint, + } + + handler := setupFilterTest(t, endpoints) + + tests := []filterTest{ + { + "should show all edge endpoints except of the untrusted edge", + []portainer.EndpointID{trustedEdgeAsync.ID, regularTrustedEdgeStandard.ID}, + EnvironmentsQuery{ + types: []portainer.EndpointType{portainer.EdgeAgentOnDockerEnvironment, portainer.EdgeAgentOnKubernetesEnvironment}, + }, + }, + { + "should show only trusted edge devices and other regular endpoints", + []portainer.EndpointID{trustedEdgeAsync.ID, regularEndpoint.ID}, + EnvironmentsQuery{ + edgeAsync: new(true), + }, + }, + { + "should show only untrusted edge devices and other regular endpoints", + []portainer.EndpointID{untrustedEdgeAsync.ID, regularEndpoint.ID}, + EnvironmentsQuery{ + edgeAsync: new(true), + edgeDeviceUntrusted: true, + }, + }, + { + "should show no edge devices", + []portainer.EndpointID{regularEndpoint.ID, regularTrustedEdgeStandard.ID}, + EnvironmentsQuery{ + edgeAsync: new(false), + }, + }, + } + + runTests(tests, t, handler, endpoints) +} + +func Test_Filter_excludeIDs(t *testing.T) { + t.Parallel() + ids := []portainer.EndpointID{1, 2, 3, 4, 5, 6, 7, 8, 9} + + environments := slicesx.Map(ids, func(id portainer.EndpointID) portainer.Endpoint { + return portainer.Endpoint{ID: id, GroupID: 1, Type: portainer.DockerEnvironment} + }) + + handler := setupFilterTest(t, environments) + + tests := []filterTest{ + { + title: "should exclude IDs 2,5,8", + expected: []portainer.EndpointID{1, 3, 4, 6, 7, 9}, + query: EnvironmentsQuery{ + excludeIds: []portainer.EndpointID{2, 5, 8}, + }, + }, + } + + runTests(tests, t, handler, environments) +} + +func Test_Filter_excludeGroupIDs(t *testing.T) { + t.Parallel() + groupA := portainer.EndpointGroupID(10) + groupB := portainer.EndpointGroupID(20) + groupC := portainer.EndpointGroupID(30) + + endpoints := []portainer.Endpoint{ + {ID: 1, GroupID: groupA, Type: portainer.DockerEnvironment}, + {ID: 2, GroupID: groupA, Type: portainer.DockerEnvironment}, + {ID: 3, GroupID: groupB, Type: portainer.DockerEnvironment}, + {ID: 4, GroupID: groupB, Type: portainer.DockerEnvironment}, + {ID: 5, GroupID: groupC, Type: portainer.DockerEnvironment}, + } + + handler := setupFilterTest(t, endpoints) + + tests := []filterTest{ + { + title: "should exclude endpoints in groupA", + expected: []portainer.EndpointID{3, 4, 5}, + query: EnvironmentsQuery{ + excludeGroupIds: []portainer.EndpointGroupID{groupA}, + }, + }, + { + title: "should exclude endpoints in groupA and groupB", + expected: []portainer.EndpointID{5}, + query: EnvironmentsQuery{ + excludeGroupIds: []portainer.EndpointGroupID{groupA, groupB}, + }, + }, + { + title: "should return all endpoints when excludeGroupIds is empty", + expected: []portainer.EndpointID{1, 2, 3, 4, 5}, + query: EnvironmentsQuery{}, + }, + } + + runTests(tests, t, handler, endpoints) +} + +func BenchmarkFilterEndpointsBySearchCriteria_PartialMatch(b *testing.B) { + n := 10000 + + endpointIDs := []portainer.EndpointID{} + + endpoints := []portainer.Endpoint{} + for i := range n { + endpoints = append(endpoints, portainer.Endpoint{ + ID: portainer.EndpointID(i + 1), + Name: "endpoint-" + strconv.Itoa(i+1), + GroupID: 1, + TagIDs: []portainer.TagID{1}, + Type: portainer.EdgeAgentOnDockerEnvironment, + }) + + endpointIDs = append(endpointIDs, portainer.EndpointID(i+1)) + } + + endpointGroups := []portainer.EndpointGroup{} + + edgeGroups := []portainer.EdgeGroup{} + for i := range 1000 { + edgeGroups = append(edgeGroups, portainer.EdgeGroup{ + ID: portainer.EdgeGroupID(i + 1), + Name: "edge-group-" + strconv.Itoa(i+1), + EndpointIDs: roar.FromSlice(endpointIDs), + Dynamic: true, + TagIDs: []portainer.TagID{1, 2, 3}, + PartialMatch: true, + }) + } + + tagsMap := map[portainer.TagID]string{} + for i := range 10 { + tagsMap[portainer.TagID(i+1)] = "tag-" + strconv.Itoa(i+1) + } + + searchString := "edge-group" + + for b.Loop() { + e := filterEndpointsBySearchCriteria(endpoints, endpointGroups, edgeGroups, tagsMap, searchString) + if len(e) != n { + b.FailNow() + } + } +} + +func BenchmarkFilterEndpointsBySearchCriteria_FullMatch(b *testing.B) { + n := 10000 + + endpointIDs := []portainer.EndpointID{} + + endpoints := []portainer.Endpoint{} + for i := range n { + endpoints = append(endpoints, portainer.Endpoint{ + ID: portainer.EndpointID(i + 1), + Name: "endpoint-" + strconv.Itoa(i+1), + GroupID: 1, + TagIDs: []portainer.TagID{1, 2, 3}, + Type: portainer.EdgeAgentOnDockerEnvironment, + }) + + endpointIDs = append(endpointIDs, portainer.EndpointID(i+1)) + } + + endpointGroups := []portainer.EndpointGroup{} + + edgeGroups := []portainer.EdgeGroup{} + for i := range 1000 { + edgeGroups = append(edgeGroups, portainer.EdgeGroup{ + ID: portainer.EdgeGroupID(i + 1), + Name: "edge-group-" + strconv.Itoa(i+1), + EndpointIDs: roar.FromSlice(endpointIDs), + Dynamic: true, + TagIDs: []portainer.TagID{1}, + }) + } + + tagsMap := map[portainer.TagID]string{} + for i := range 10 { + tagsMap[portainer.TagID(i+1)] = "tag-" + strconv.Itoa(i+1) + } + + searchString := "edge-group" + + for b.Loop() { + e := filterEndpointsBySearchCriteria(endpoints, endpointGroups, edgeGroups, tagsMap, searchString) + require.Len(b, e, n) + } +} + +func runTests(tests []filterTest, t *testing.T, handler *Handler, endpoints []portainer.Endpoint) { + for _, test := range tests { + t.Run(test.title, func(t *testing.T) { + runTest(t, test, handler, append([]portainer.Endpoint{}, endpoints...)) + }) + } +} + +func runTest(t *testing.T, test filterTest, handler *Handler, endpoints []portainer.Endpoint) { + is := assert.New(t) + + filteredEndpoints, _, err := handler.filterEndpointsByQuery( + endpoints, + test.query, + []portainer.EndpointGroup{}, + []portainer.EdgeGroup{}, + &portainer.Settings{}, + &security.RestrictedRequestContext{IsAdmin: true}, + ) + + require.NoError(t, err) + + is.Len(filteredEndpoints, len(test.expected)) + + respIds := []portainer.EndpointID{} + + for _, endpoint := range filteredEndpoints { + respIds = append(respIds, endpoint.ID) + } + + is.ElementsMatch(test.expected, respIds) +} + +func setupFilterTest(t *testing.T, endpoints []portainer.Endpoint) *Handler { + _, store := datastore.MustNewTestStore(t, true, true) + + for _, endpoint := range endpoints { + err := store.Endpoint().Create(&endpoint) + require.NoError(t, err, "error creating environment") + } + + err := store.User().Create(&portainer.User{Username: "admin", Role: portainer.AdministratorRole}) + require.NoError(t, err, "error creating a user") + + bouncer := testhelpers.NewTestRequestBouncer() + handler := NewHandler(bouncer) + handler.DataStore = store + handler.ComposeStackManager = testhelpers.NewComposeStackManager() + + return handler +} + +func TestFilterEndpointsByEdgeStack(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, false) + + endpoints := []portainer.Endpoint{ + {ID: 1, Name: "Endpoint 1", Type: portainer.EdgeAgentOnDockerEnvironment, UserTrusted: true}, + {ID: 2, Name: "Endpoint 2", TagIDs: []portainer.TagID{1}, Type: portainer.EdgeAgentOnDockerEnvironment, UserTrusted: true}, + {ID: 3, Name: "Endpoint 3", TagIDs: []portainer.TagID{1}, Type: portainer.EdgeAgentOnDockerEnvironment, UserTrusted: true}, + {ID: 4, Name: "Endpoint 4"}, + } + + edgeStackId := portainer.EdgeStackID(1) + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + require.NoError(t, tx.Tag().Create(&portainer.Tag{ID: 1, Name: "tag", Endpoints: map[portainer.EndpointID]bool{2: true, 3: true}})) + + for i := range endpoints { + require.NoError(t, tx.Endpoint().Create(&endpoints[i])) + } + + require.NoError(t, tx.EdgeStack().Create(edgeStackId, &portainer.EdgeStack{ + ID: edgeStackId, + Name: "Test Edge Stack", + EdgeGroups: []portainer.EdgeGroupID{1, 2}, + })) + + require.NoError(t, tx.EdgeGroup().Create(&portainer.EdgeGroup{ + ID: 1, + Name: "Edge Group 1", + EndpointIDs: roar.FromSlice([]portainer.EndpointID{1}), + })) + + require.NoError(t, tx.EdgeGroup().Create(&portainer.EdgeGroup{ + ID: 2, + Name: "Edge Group 2", + Dynamic: true, + TagIDs: []portainer.TagID{1}, + })) + + require.NoError(t, tx.EdgeStackStatus().Create(edgeStackId, endpoints[0].ID, &portainer.EdgeStackStatusForEnv{ + Status: []portainer.EdgeStackDeploymentStatus{{Type: portainer.EdgeStackStatusAcknowledged}}})) + + return nil + })) + + test := func(status *portainer.EdgeStackStatusType, expected []portainer.Endpoint) { + tmp := make([]portainer.Endpoint, len(endpoints)) + require.Equal(t, 4, copy(tmp, endpoints)) + es, err := filterEndpointsByEdgeStack(tmp, edgeStackId, status, store) + require.NoError(t, err) + // validate that the len is the same + require.Len(t, es, len(expected)) + // and that all items are the expected ones + for i := range expected { + require.Contains(t, es, expected[i]) + } + } + + test(nil, []portainer.Endpoint{endpoints[0], endpoints[1], endpoints[2]}) + + status := portainer.EdgeStackStatusPending + test(&status, []portainer.Endpoint{endpoints[1], endpoints[2]}) + + status = portainer.EdgeStackStatusCompleted + test(&status, []portainer.Endpoint{}) + + status = portainer.EdgeStackStatusAcknowledged + test(&status, []portainer.Endpoint{endpoints[0]}) // that's the only one with an edge stack status in DB +} + +func TestErrorsFilterEndpointsByEdgeStack(t *testing.T) { + t.Parallel() + t.Run("must error by edge stack not found", func(t *testing.T) { + _, store := datastore.MustNewTestStore(t, false, false) + require.NotNil(t, store) + + _, err := filterEndpointsByEdgeStack([]portainer.Endpoint{}, 1, nil, store) + require.Error(t, err) + }) + + t.Run("must error by edge group not found", func(t *testing.T) { + _, store := datastore.MustNewTestStore(t, false, false) + require.NotNil(t, store) + + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + require.NoError(t, tx.EdgeStack().Create(1, &portainer.EdgeStack{ID: 1, Name: "1", EdgeGroups: []portainer.EdgeGroupID{1}})) + return nil + })) + _, err := filterEndpointsByEdgeStack([]portainer.Endpoint{}, 1, nil, store) + require.Error(t, err) + }) + + t.Run("must error by env tag not found", func(t *testing.T) { + _, store := datastore.MustNewTestStore(t, false, false) + require.NotNil(t, store) + + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + require.NoError(t, tx.EdgeStack().Create(1, &portainer.EdgeStack{ID: 1, Name: "1", EdgeGroups: []portainer.EdgeGroupID{1}})) + require.NoError(t, tx.EdgeGroup().Create(&portainer.EdgeGroup{ID: 1, Name: "edge group", Dynamic: true, TagIDs: []portainer.TagID{1}})) + return nil + })) + _, err := filterEndpointsByEdgeStack([]portainer.Endpoint{}, 1, nil, store) + require.Error(t, err) + }) +} + +func TestFilterEndpointsByEdgeGroup(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, false) + + endpoints := []portainer.Endpoint{ + {ID: 1, Name: "Endpoint 1"}, + {ID: 2, Name: "Endpoint 2"}, + {ID: 3, Name: "Endpoint 3"}, + {ID: 4, Name: "Endpoint 4"}, + } + + err := store.EdgeGroup().Create(&portainer.EdgeGroup{ + ID: 1, + Name: "Edge Group 1", + EndpointIDs: roar.FromSlice([]portainer.EndpointID{1}), + }) + require.NoError(t, err) + + err = store.EdgeGroup().Create(&portainer.EdgeGroup{ + ID: 2, + Name: "Edge Group 2", + EndpointIDs: roar.FromSlice([]portainer.EndpointID{2, 3}), + }) + require.NoError(t, err) + + edgeGroups, err := store.EdgeGroup().ReadAll() + require.NoError(t, err) + + es, egs := filterEndpointsByEdgeGroupIDs(endpoints, edgeGroups, []portainer.EdgeGroupID{1, 2}) + require.NoError(t, err) + + require.Len(t, es, 3) + require.Contains(t, es, endpoints[0]) // Endpoint 1 + require.Contains(t, es, endpoints[1]) // Endpoint 2 + require.Contains(t, es, endpoints[2]) // Endpoint 3 + require.NotContains(t, es, endpoints[3]) // Endpoint 4 + + require.Len(t, egs, 2) + require.Equal(t, egs[0].ID, portainer.EdgeGroupID(1)) + require.Equal(t, egs[1].ID, portainer.EdgeGroupID(2)) +} + +func TestFilterEndpointsByExcludeEdgeGroupIDs(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, false) + + endpoints := []portainer.Endpoint{ + {ID: 1, Name: "Endpoint 1"}, + {ID: 2, Name: "Endpoint 2"}, + {ID: 3, Name: "Endpoint 3"}, + {ID: 4, Name: "Endpoint 4"}, + } + + err := store.EdgeGroup().Create(&portainer.EdgeGroup{ + ID: 1, + Name: "Edge Group 1", + EndpointIDs: roar.FromSlice([]portainer.EndpointID{1}), + }) + require.NoError(t, err) + + err = store.EdgeGroup().Create(&portainer.EdgeGroup{ + ID: 2, + Name: "Edge Group 2", + EndpointIDs: roar.FromSlice([]portainer.EndpointID{2, 3}), + }) + require.NoError(t, err) + + edgeGroups, err := store.EdgeGroup().ReadAll() + require.NoError(t, err) + + es, egs := filterEndpointsByExcludeEdgeGroupIDs(endpoints, edgeGroups, []portainer.EdgeGroupID{1}) + require.NoError(t, err) + + require.Len(t, es, 3) + require.Equal(t, []portainer.Endpoint{ + {ID: 2, Name: "Endpoint 2"}, + {ID: 3, Name: "Endpoint 3"}, + {ID: 4, Name: "Endpoint 4"}, + }, es) + + require.Len(t, egs, 1) + require.Equal(t, egs[0].ID, portainer.EdgeGroupID(2)) +} + +func TestGetShortestAsyncInterval(t *testing.T) { + t.Parallel() + endpoint := &portainer.Endpoint{ + ID: 1, + Name: "Test Endpoint", + Edge: portainer.EnvironmentEdgeSettings{ + PingInterval: -1, + SnapshotInterval: -1, + CommandInterval: -1, + }, + } + + settings := &portainer.Settings{ + Edge: portainer.Edge{ + PingInterval: 10, + SnapshotInterval: 20, + CommandInterval: 30, + }, + } + + require.Equal(t, 10, getShortestAsyncInterval(endpoint, settings)) +} + +func Test_filterEndpointsByPlatform(t *testing.T) { + ep := func(id portainer.EndpointID, epType portainer.EndpointType, containerEngine string) portainer.Endpoint { + return portainer.Endpoint{ + ID: id, + Type: epType, + ContainerEngine: containerEngine, + } + } + + docker := ep(1, portainer.DockerEnvironment, portainer.ContainerEngineDocker) + agentDocker := ep(2, portainer.AgentOnDockerEnvironment, portainer.ContainerEngineDocker) + edgeAgentDocker := ep(3, portainer.EdgeAgentOnDockerEnvironment, portainer.ContainerEngineDocker) + podman := ep(4, portainer.DockerEnvironment, portainer.ContainerEnginePodman) + agentPodman := ep(5, portainer.AgentOnDockerEnvironment, portainer.ContainerEnginePodman) + edgeAgentPodman := ep(6, portainer.EdgeAgentOnDockerEnvironment, portainer.ContainerEnginePodman) + k8sLocal := ep(7, portainer.KubernetesLocalEnvironment, "") + agentK8s := ep(8, portainer.AgentOnKubernetesEnvironment, "") + edgeAgentK8s := ep(9, portainer.EdgeAgentOnKubernetesEnvironment, "") + azure := ep(10, portainer.AzureEnvironment, "") + + type args struct { + endpoints []portainer.Endpoint + platformTypes []portainer.PlatformType + } + tests := []struct { + name string + args args + want []portainer.Endpoint + }{ + // Docker platform types + { + name: "DockerEnvironment is Docker platform", + args: args{endpoints: []portainer.Endpoint{docker}, platformTypes: []portainer.PlatformType{portainer.DockerPlatformType}}, + want: []portainer.Endpoint{docker}, + }, + { + name: "AgentOnDockerEnvironment is Docker platform", + args: args{endpoints: []portainer.Endpoint{agentDocker}, platformTypes: []portainer.PlatformType{portainer.DockerPlatformType}}, + want: []portainer.Endpoint{agentDocker}, + }, + { + name: "EdgeAgentOnDockerEnvironment is Docker platform", + args: args{endpoints: []portainer.Endpoint{edgeAgentDocker}, platformTypes: []portainer.PlatformType{portainer.DockerPlatformType}}, + want: []portainer.Endpoint{edgeAgentDocker}, + }, + // Podman platform types + { + name: "DockerEnvironment with Podman engine is Podman platform", + args: args{endpoints: []portainer.Endpoint{podman}, platformTypes: []portainer.PlatformType{portainer.PodmanPlatformType}}, + want: []portainer.Endpoint{podman}, + }, + { + name: "AgentOnDockerEnvironment with Podman engine is Podman platform", + args: args{endpoints: []portainer.Endpoint{agentPodman}, platformTypes: []portainer.PlatformType{portainer.PodmanPlatformType}}, + want: []portainer.Endpoint{agentPodman}, + }, + { + name: "EdgeAgentOnDockerEnvironment with Podman engine is Podman platform", + args: args{endpoints: []portainer.Endpoint{edgeAgentPodman}, platformTypes: []portainer.PlatformType{portainer.PodmanPlatformType}}, + want: []portainer.Endpoint{edgeAgentPodman}, + }, + // Kubernetes platform types + { + name: "KubernetesLocalEnvironment is Kubernetes platform", + args: args{endpoints: []portainer.Endpoint{k8sLocal}, platformTypes: []portainer.PlatformType{portainer.KubernetesPlatformType}}, + want: []portainer.Endpoint{k8sLocal}, + }, + { + name: "AgentOnKubernetesEnvironment is Kubernetes platform", + args: args{endpoints: []portainer.Endpoint{agentK8s}, platformTypes: []portainer.PlatformType{portainer.KubernetesPlatformType}}, + want: []portainer.Endpoint{agentK8s}, + }, + { + name: "EdgeAgentOnKubernetesEnvironment is Kubernetes platform", + args: args{endpoints: []portainer.Endpoint{edgeAgentK8s}, platformTypes: []portainer.PlatformType{portainer.KubernetesPlatformType}}, + want: []portainer.Endpoint{edgeAgentK8s}, + }, + // Azure platform type + { + name: "AzureEnvironment is Azure platform", + args: args{endpoints: []portainer.Endpoint{azure}, platformTypes: []portainer.PlatformType{portainer.AzurePlatformType}}, + want: []portainer.Endpoint{azure}, + }, + // Filter behaviour + { + name: "filters out non-matching platform types", + args: args{ + endpoints: []portainer.Endpoint{docker, k8sLocal, azure}, + platformTypes: []portainer.PlatformType{portainer.DockerPlatformType}, + }, + want: []portainer.Endpoint{docker}, + }, + { + name: "multiple platform types returns all matches", + args: args{ + endpoints: []portainer.Endpoint{docker, agentDocker, edgeAgentDocker, podman, k8sLocal, agentK8s, edgeAgentK8s, azure}, + platformTypes: []portainer.PlatformType{portainer.DockerPlatformType, portainer.KubernetesPlatformType}, + }, + want: []portainer.Endpoint{docker, agentDocker, edgeAgentDocker, k8sLocal, agentK8s, edgeAgentK8s}, + }, + { + name: "Podman endpoints not returned when filtering for Docker", + args: args{ + endpoints: []portainer.Endpoint{docker, podman, agentPodman}, + platformTypes: []portainer.PlatformType{portainer.DockerPlatformType}, + }, + want: []portainer.Endpoint{docker}, + }, + { + name: "returns empty when no endpoints match filter", + args: args{ + endpoints: []portainer.Endpoint{k8sLocal, azure}, + platformTypes: []portainer.PlatformType{portainer.DockerPlatformType}, + }, + want: []portainer.Endpoint{}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equalf(t, tt.want, filterEndpointsByPlatform(tt.args.endpoints, tt.args.platformTypes), "filterEndpointsByPlatform(%v, %v)", tt.args.endpoints, tt.args.platformTypes) + }) + } +} + +func Test_FilterQuery_PlatformTypes(t *testing.T) { + t.Parallel() + dockerEndpoint := portainer.Endpoint{ID: 1, GroupID: 1, Type: portainer.DockerEnvironment} + kubernetesEndpoint := portainer.Endpoint{ID: 2, GroupID: 1, Type: portainer.KubernetesLocalEnvironment} + azureEndpoint := portainer.Endpoint{ID: 3, GroupID: 1, Type: portainer.AzureEnvironment} + + endpoints := []portainer.Endpoint{dockerEndpoint, kubernetesEndpoint, azureEndpoint} + handler := setupFilterTest(t, endpoints) + + tests := []filterTest{ + { + title: "platformTypes filter returns only matching platform", + expected: []portainer.EndpointID{dockerEndpoint.ID}, + query: EnvironmentsQuery{platformTypes: []portainer.PlatformType{portainer.DockerPlatformType}}, + }, + { + title: "multiple platformTypes returns all matching platforms", + expected: []portainer.EndpointID{dockerEndpoint.ID, kubernetesEndpoint.ID}, + query: EnvironmentsQuery{platformTypes: []portainer.PlatformType{portainer.DockerPlatformType, portainer.KubernetesPlatformType}}, + }, + } + + runTests(tests, t, handler, endpoints) +} + +func Test_FilterQuery_Outdated(t *testing.T) { + t.Parallel() + currentVersion := portainer.APIVersion + upToDateEndpoint := portainer.Endpoint{ID: 1, GroupID: 1, Type: portainer.AgentOnDockerEnvironment} + upToDateEndpoint.Agent.Version = currentVersion + + outdatedEndpoint := portainer.Endpoint{ID: 2, GroupID: 1, Type: portainer.AgentOnDockerEnvironment} + outdatedEndpoint.Agent.Version = "2.0.0" + + endpoints := []portainer.Endpoint{upToDateEndpoint, outdatedEndpoint} + handler := setupFilterTest(t, endpoints) + + tests := []filterTest{ + { + title: "outdated filter returns only outdated endpoints", + expected: []portainer.EndpointID{outdatedEndpoint.ID}, + query: EnvironmentsQuery{outdated: true}, + }, + { + title: "outdated=false returns all endpoints", + expected: []portainer.EndpointID{upToDateEndpoint.ID, outdatedEndpoint.ID}, + query: EnvironmentsQuery{outdated: false}, + }, + } + + runTests(tests, t, handler, endpoints) +} + +func Test_parseQuery_emptyArrayParams(t *testing.T) { + t.Parallel() + + makeRequest := func(rawQuery string) *http.Request { + r := &http.Request{URL: &url.URL{RawQuery: rawQuery}} + require.NoError(t, r.ParseForm()) + return r + } + + tests := []struct { + name string + query string + }{ + {name: "empty status", query: "status="}, + {name: "empty endpointIds", query: "endpointIds="}, + {name: "empty groupIds", query: "groupIds="}, + {name: "empty tagIds", query: "tagIds="}, + {name: "multiple empty params", query: "status=&groupIds=&tagIds="}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := parseQuery(makeRequest(tt.query)) + require.NoError(t, err) + }) + } +} diff --git a/api/http/handler/endpoints/handler.go b/api/http/handler/endpoints/handler.go new file mode 100644 index 0000000..8f61bfb --- /dev/null +++ b/api/http/handler/endpoints/handler.go @@ -0,0 +1,94 @@ +package endpoints + +import ( + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + dockerclient "github.com/portainer/portainer/api/docker/client" + "github.com/portainer/portainer/api/http/proxy" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/authorization" + "github.com/portainer/portainer/api/kubernetes/cli" + "github.com/portainer/portainer/api/pendingactions" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + + "github.com/gorilla/mux" +) + +func hideFields(endpoint *portainer.Endpoint) { + endpoint.AzureCredentials = portainer.AzureCredentials{} + if len(endpoint.Snapshots) > 0 { + endpoint.Snapshots[0].SnapshotRaw = portainer.DockerSnapshotRaw{} + } +} + +// Handler is the HTTP handler used to handle environment(endpoint) operations. +type Handler struct { + *mux.Router + requestBouncer security.BouncerService + DataStore dataservices.DataStore + FileService portainer.FileService + ProxyManager *proxy.Manager + ReverseTunnelService portainer.ReverseTunnelService + SnapshotService portainer.SnapshotService + K8sClientFactory *cli.ClientFactory + ComposeStackManager portainer.ComposeStackManager + AuthorizationService *authorization.Service + DockerClientFactory *dockerclient.ClientFactory + BindAddress string + BindAddressHTTPS string + PendingActionsService *pendingactions.PendingActionsService + PullLimitCheckDisabled bool +} + +// NewHandler creates a handler to manage environment(endpoint) operations. +func NewHandler(bouncer security.BouncerService) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + requestBouncer: bouncer, + } + + h.Handle("/endpoints", + bouncer.AdminAccess(httperror.LoggerHandler(h.endpointCreate))).Methods(http.MethodPost) + h.Handle("/endpoints/{id}/settings", + bouncer.AdminAccess(httperror.LoggerHandler(h.endpointSettingsUpdate))).Methods(http.MethodPut) + h.Handle("/endpoints/{id}/association", + bouncer.AdminAccess(httperror.LoggerHandler(h.endpointAssociationDelete))).Methods(http.MethodDelete) + h.Handle("/endpoints/snapshot", + bouncer.AdminAccess(httperror.LoggerHandler(h.endpointSnapshots))).Methods(http.MethodPost) + h.Handle("/endpoints", + bouncer.RestrictedAccess(httperror.LoggerHandler(h.endpointList))).Methods(http.MethodGet) + h.Handle("/endpoints/agent_versions", + bouncer.RestrictedAccess(httperror.LoggerHandler(h.agentVersions))).Methods(http.MethodGet) + h.Handle("/endpoints/summary", + bouncer.RestrictedAccess(httperror.LoggerHandler(h.endpointSummaryCounts))).Methods(http.MethodGet) + h.Handle("/endpoints/relations", bouncer.AdminAccess(httperror.LoggerHandler(h.updateRelations))).Methods(http.MethodPut) + + h.Handle("/endpoints/{id}", + bouncer.RestrictedAccess(httperror.LoggerHandler(h.endpointInspect))).Methods(http.MethodGet) + h.Handle("/endpoints/{id}", + bouncer.AdminAccess(httperror.LoggerHandler(h.endpointUpdate))).Methods(http.MethodPut) + h.Handle("/endpoints/{id}", + bouncer.AdminAccess(httperror.LoggerHandler(h.endpointDelete))).Methods(http.MethodDelete) + h.Handle("/endpoints/delete", + bouncer.AdminAccess(httperror.LoggerHandler(h.endpointDeleteBatch))).Methods(http.MethodPost) + h.Handle("/endpoints/{id}/dockerhub/{registryId}", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.endpointDockerhubStatus))).Methods(http.MethodGet) + h.Handle("/endpoints/{id}/snapshot", + bouncer.AdminAccess(httperror.LoggerHandler(h.endpointSnapshot))).Methods(http.MethodPost) + h.Handle("/endpoints/{id}/registries", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.endpointRegistriesList))).Methods(http.MethodGet) + h.Handle("/endpoints/{id}/registries/{registryId}", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.endpointRegistryAccess))).Methods(http.MethodPut) + + h.Handle("/endpoints/global-key", bouncer.PublicAccess(httperror.LoggerHandler(h.endpointCreateGlobalKey))).Methods(http.MethodPost) + h.Handle("/endpoints/{id}/forceupdateservice", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.endpointForceUpdateService))).Methods(http.MethodPut) + + // DEPRECATED + h.Handle("/endpoints/{id}/status", bouncer.PublicAccess(httperror.LoggerHandler(h.endpointStatusInspect))).Methods(http.MethodGet) + h.Handle("/endpoints", bouncer.AdminAccess(httperror.LoggerHandler(h.endpointDeleteBatchDeprecated))).Methods(http.MethodDelete) + + return h +} diff --git a/api/http/handler/endpoints/sort.go b/api/http/handler/endpoints/sort.go new file mode 100644 index 0000000..55f60cd --- /dev/null +++ b/api/http/handler/endpoints/sort.go @@ -0,0 +1,124 @@ +package endpoints + +import ( + "slices" + + "github.com/fvbommel/sortorder" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/internal/endpointutils" +) + +type comp[T any] func(a, b T) int + +func stringComp(a, b string) int { + if sortorder.NaturalLess(a, b) { + return -1 + } else if sortorder.NaturalLess(b, a) { + return 1 + } else { + return 0 + } +} + +func healthRank(endpoint *portainer.Endpoint, settings *portainer.Settings) int { + status := resolveEndpointStatus(endpoint, settings) + if status == statusDown { + return 0 + } + if isOutdated(endpoint) { + return 1 + } + if status == statusHeartbeat { + return 2 + } + return 3 +} + +func sortEnvironmentsByField(environments []portainer.Endpoint, environmentGroups []portainer.EndpointGroup, sortField sortKey, isSortDesc bool, settings *portainer.Settings) { + if sortField == "" { + return + } + + var less comp[portainer.Endpoint] + switch sortField { + case sortKeyName: + less = func(a, b portainer.Endpoint) int { + return stringComp(a.Name, b.Name) + } + + case sortKeyGroup: + environmentGroupNames := make(map[portainer.EndpointGroupID]string, 0) + for _, group := range environmentGroups { + environmentGroupNames[group.ID] = group.Name + } + + // set the "unassigned" group name to be empty string + environmentGroupNames[1] = "" + + less = func(a, b portainer.Endpoint) int { + aGroup := environmentGroupNames[a.GroupID] + bGroup := environmentGroupNames[b.GroupID] + + return stringComp(aGroup, bGroup) + } + + case sortKeyStatus: + less = func(a, b portainer.Endpoint) int { + return int(a.Status - b.Status) + } + + case sortKeyLastCheckInDate: + less = func(a, b portainer.Endpoint) int { + return int(a.LastCheckInDate - b.LastCheckInDate) + } + case sortKeyEdgeID: + less = func(a, b portainer.Endpoint) int { + return stringComp(a.EdgeID, b.EdgeID) + } + + case sortKeyPlatformType: + less = func(a, b portainer.Endpoint) int { + return int(endpointutils.EndpointPlatformType(&a) - endpointutils.EndpointPlatformType(&b)) + } + case sortKeyHealth: + less = func(a, b portainer.Endpoint) int { + return healthRank(&a, settings) - healthRank(&b, settings) + } + case sortKeyId: + less = func(a, b portainer.Endpoint) int { + return int(a.ID - b.ID) + } + } + + slices.SortStableFunc(environments, func(a, b portainer.Endpoint) int { + mul := 1 + if isSortDesc { + mul = -1 + } + + return less(a, b) * mul + }) + +} + +type sortKey string + +const ( + sortKeyName sortKey = "Name" + sortKeyGroup sortKey = "Group" + sortKeyStatus sortKey = "Status" + sortKeyLastCheckInDate sortKey = "LastCheckIn" + sortKeyEdgeID sortKey = "EdgeID" + sortKeyPlatformType sortKey = "PlatformType" + sortKeyHealth sortKey = "Health" + sortKeyId sortKey = "Id" +) + +func getSortKey(sortField string) sortKey { + fieldAsSortKey := sortKey(sortField) + if slices.Contains([]sortKey{sortKeyName, sortKeyGroup, sortKeyStatus, sortKeyLastCheckInDate, sortKeyEdgeID, sortKeyPlatformType, sortKeyHealth, sortKeyId}, fieldAsSortKey) { + return fieldAsSortKey + } + + return "" +} diff --git a/api/http/handler/endpoints/sort_test.go b/api/http/handler/endpoints/sort_test.go new file mode 100644 index 0000000..3adf4fa --- /dev/null +++ b/api/http/handler/endpoints/sort_test.go @@ -0,0 +1,243 @@ +package endpoints + +import ( + "testing" + "time" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/slicesx" + + "github.com/stretchr/testify/assert" +) + +func TestSortEndpointsByField(t *testing.T) { + t.Parallel() + environments := []portainer.Endpoint{ + {ID: 0, Name: "Environment 1", GroupID: 1, Status: 1, LastCheckInDate: 3, EdgeID: "edge32"}, + {ID: 1, Name: "Environment 2", GroupID: 2, Status: 2, LastCheckInDate: 6, EdgeID: "edge57"}, + {ID: 2, Name: "Environment 3", GroupID: 1, Status: 3, LastCheckInDate: 2, EdgeID: "test87"}, + {ID: 3, Name: "Environment 4", GroupID: 2, Status: 4, LastCheckInDate: 1, EdgeID: "abc123"}, + } + + environmentGroups := []portainer.EndpointGroup{ + {ID: 1, Name: "Group 1"}, + {ID: 2, Name: "Group 2"}, + } + + tests := []struct { + name string + sortField sortKey + isSortDesc bool + expected []portainer.EndpointID + }{ + { + name: "sort without value", + sortField: "", + expected: []portainer.EndpointID{ + environments[0].ID, + environments[1].ID, + environments[2].ID, + environments[3].ID, + }, + }, + { + name: "sort by name ascending", + sortField: "Name", + isSortDesc: false, + expected: []portainer.EndpointID{ + environments[0].ID, + environments[1].ID, + environments[2].ID, + environments[3].ID, + }, + }, + { + name: "sort by name descending", + sortField: "Name", + isSortDesc: true, + expected: []portainer.EndpointID{ + environments[3].ID, + environments[2].ID, + environments[1].ID, + environments[0].ID, + }, + }, + { + name: "sort by group name ascending", + sortField: "Group", + isSortDesc: false, + expected: []portainer.EndpointID{ + environments[0].ID, + environments[2].ID, + environments[1].ID, + environments[3].ID, + }, + }, + { + name: "sort by group name descending", + sortField: "Group", + isSortDesc: true, + expected: []portainer.EndpointID{ + environments[1].ID, + environments[3].ID, + environments[0].ID, + environments[2].ID, + }, + }, + + { + name: "sort by status ascending", + sortField: "Status", + isSortDesc: false, + expected: []portainer.EndpointID{ + environments[0].ID, + environments[1].ID, + environments[2].ID, + environments[3].ID, + }, + }, + { + name: "sort by status descending", + sortField: "Status", + isSortDesc: true, + expected: []portainer.EndpointID{ + environments[3].ID, + environments[2].ID, + environments[1].ID, + environments[0].ID, + }, + }, + { + name: "sort by last check-in ascending", + sortField: "LastCheckIn", + isSortDesc: false, + expected: []portainer.EndpointID{ + environments[3].ID, + environments[2].ID, + environments[0].ID, + environments[1].ID, + }, + }, + { + name: "sort by last check-in descending", + sortField: "LastCheckIn", + isSortDesc: true, + expected: []portainer.EndpointID{ + environments[1].ID, + environments[0].ID, + environments[2].ID, + environments[3].ID, + }, + }, + { + name: "sort by edge ID ascending", + sortField: "EdgeID", + expected: []portainer.EndpointID{ + environments[3].ID, + environments[0].ID, + environments[1].ID, + environments[2].ID, + }, + }, + { + name: "sort by edge ID descending", + sortField: "EdgeID", + isSortDesc: true, + expected: []portainer.EndpointID{ + environments[2].ID, + environments[1].ID, + environments[0].ID, + environments[3].ID, + }, + }, + { + name: "sort by platform type ascending groups same-platform types together", + sortField: "PlatformType", + expected: []portainer.EndpointID{ + environments[0].ID, + environments[1].ID, + environments[2].ID, + environments[3].ID, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + is := assert.New(t) + sortEnvironmentsByField(environments, environmentGroups, "Name", false, nil) // reset to default sort order + + sortEnvironmentsByField(environments, environmentGroups, tt.sortField, tt.isSortDesc, nil) + + is.Equal(tt.expected, getEndpointIDs(environments)) + }) + } +} + +func getEndpointIDs(environments []portainer.Endpoint) []portainer.EndpointID { + return slicesx.Map(environments, func(environment portainer.Endpoint) portainer.EndpointID { + return environment.ID + }) +} + +func TestSortEndpointsByHealth(t *testing.T) { + t.Parallel() + + settings := &portainer.Settings{EdgeAgentCheckinInterval: 30} + + // Down: non-edge with Status=2 + down := portainer.Endpoint{ + ID: 0, + Name: "down-env", + Type: portainer.DockerEnvironment, + Status: portainer.EndpointStatusDown, + } + // Outdated: edge agent with empty version and a prior check-in (old agent style, no version reported) + outdated := portainer.Endpoint{ + ID: 1, + Name: "outdated-env", + Type: portainer.EdgeAgentOnDockerEnvironment, + Status: portainer.EndpointStatusUp, + LastCheckInDate: time.Now().Unix(), + // IsEdgeEndpoint + Agent.Version == "" + LastCheckInDate > 0 → isOutdated returns true + } + // Heartbeat: edge that checked in recently with a current agent version (not outdated) + heartbeat := portainer.Endpoint{ + ID: 2, + Name: "heartbeat-env", + Type: portainer.EdgeAgentOnDockerEnvironment, + LastCheckInDate: time.Now().Unix(), + } + heartbeat.Agent.Version = portainer.APIVersion + // Up: non-edge with Status=1 + up := portainer.Endpoint{ + ID: 3, + Name: "up-env", + Type: portainer.DockerEnvironment, + Status: portainer.EndpointStatusUp, + } + + t.Run("ascending: Down → Outdated → Heartbeat → Up", func(t *testing.T) { + environments := []portainer.Endpoint{up, heartbeat, outdated, down} + sortEnvironmentsByField(environments, nil, sortKeyHealth, false, settings) + assert.Equal(t, + []portainer.EndpointID{down.ID, outdated.ID, heartbeat.ID, up.ID}, + getEndpointIDs(environments), + ) + }) + + t.Run("descending: Up → Heartbeat → Outdated → Down", func(t *testing.T) { + environments := []portainer.Endpoint{down, outdated, heartbeat, up} + sortEnvironmentsByField(environments, nil, sortKeyHealth, true, settings) + assert.Equal(t, + []portainer.EndpointID{up.ID, heartbeat.ID, outdated.ID, down.ID}, + getEndpointIDs(environments), + ) + }) +} + +func TestGetSortKey(t *testing.T) { + assert.Equal(t, sortKey("Name"), getSortKey("Name")) + assert.Equal(t, sortKey("PlatformType"), getSortKey("PlatformType")) + assert.Equal(t, sortKey(""), getSortKey("unknown")) +} diff --git a/api/http/handler/endpoints/unique_name.go b/api/http/handler/endpoints/unique_name.go new file mode 100644 index 0000000..bad167d --- /dev/null +++ b/api/http/handler/endpoints/unique_name.go @@ -0,0 +1,18 @@ +package endpoints + +import portainer "github.com/portainer/portainer/api" + +func (handler *Handler) isNameUnique(name string, endpointID portainer.EndpointID) (bool, error) { + endpoints, err := handler.DataStore.Endpoint().Endpoints() + if err != nil { + return false, err + } + + for _, endpoint := range endpoints { + if endpoint.Name == name && (endpointID == 0 || endpoint.ID != endpointID) { + return false, nil + } + } + + return true, nil +} diff --git a/api/http/handler/endpoints/update_edge_relations.go b/api/http/handler/endpoints/update_edge_relations.go new file mode 100644 index 0000000..c487519 --- /dev/null +++ b/api/http/handler/endpoints/update_edge_relations.go @@ -0,0 +1,48 @@ +package endpoints + +import ( + "github.com/pkg/errors" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/internal/edge" + "github.com/portainer/portainer/api/internal/endpointutils" + "github.com/portainer/portainer/api/set" +) + +// updateEdgeRelations updates the edge stacks associated to an edge endpoint +func (handler *Handler) updateEdgeRelations(tx dataservices.DataStoreTx, endpoint *portainer.Endpoint) error { + if !endpointutils.IsEdgeEndpoint(endpoint) { + return nil + } + + relation, err := tx.EndpointRelation().EndpointRelation(endpoint.ID) + if err != nil { + return errors.WithMessage(err, "Unable to retrieve environment relation inside the database") + } + + endpointGroup, err := tx.EndpointGroup().Read(endpoint.GroupID) + if err != nil { + return errors.WithMessage(err, "Unable to find environment group inside the database") + } + + edgeGroups, err := tx.EdgeGroup().ReadAll() + if err != nil { + return errors.WithMessage(err, "Unable to retrieve edge groups from the database") + } + + edgeStacks, err := tx.EdgeStack().EdgeStacks() + if err != nil { + return errors.WithMessage(err, "Unable to retrieve edge stacks from the database") + } + + currentEdgeStackSet := set.ToSet(edge.EndpointRelatedEdgeStacks(endpoint, endpointGroup, edgeGroups, edgeStacks)) + + relation.EdgeStacks = currentEdgeStackSet + + err = tx.EndpointRelation().UpdateEndpointRelation(endpoint.ID, relation) + if err != nil { + return errors.WithMessage(err, "Unable to persist environment relation changes inside the database") + } + + return nil +} diff --git a/api/http/handler/endpoints/utils_update_edge_groups.go b/api/http/handler/endpoints/utils_update_edge_groups.go new file mode 100644 index 0000000..6207acb --- /dev/null +++ b/api/http/handler/endpoints/utils_update_edge_groups.go @@ -0,0 +1,66 @@ +package endpoints + +import ( + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/set" + + "github.com/pkg/errors" +) + +func updateEnvironmentEdgeGroups(tx dataservices.DataStoreTx, newEdgeGroups []portainer.EdgeGroupID, environmentID portainer.EndpointID) (bool, error) { + edgeGroups, err := tx.EdgeGroup().ReadAll() + if err != nil { + return false, errors.WithMessage(err, "Unable to retrieve edge groups from the database") + } + + newEdgeGroupsSet := set.ToSet(newEdgeGroups) + + environmentEdgeGroupsSet := set.Set[portainer.EdgeGroupID]{} + for _, edgeGroup := range edgeGroups { + if edgeGroup.EndpointIDs.Contains(environmentID) { + environmentEdgeGroupsSet[edgeGroup.ID] = true + } + } + + union := set.Union(newEdgeGroupsSet, environmentEdgeGroupsSet) + intersection := set.Intersection(newEdgeGroupsSet, environmentEdgeGroupsSet) + + if len(union) <= len(intersection) { + return false, nil + } + + updateSet := func(groupIDs set.Set[portainer.EdgeGroupID], updateItem func(*portainer.EdgeGroup)) error { + for groupID := range groupIDs { + group, err := tx.EdgeGroup().Read(groupID) + if err != nil { + return errors.WithMessage(err, "Unable to find a Edge group inside the database") + } + + updateItem(group) + + err = tx.EdgeGroup().Update(groupID, group) + if err != nil { + return errors.WithMessage(err, "Unable to persist Edge group changes inside the database") + } + } + + return nil + } + + removeEdgeGroups := environmentEdgeGroupsSet.Difference(newEdgeGroupsSet) + if err := updateSet(removeEdgeGroups, func(edgeGroup *portainer.EdgeGroup) { + edgeGroup.EndpointIDs.Remove(environmentID) + }); err != nil { + return false, err + } + + addToEdgeGroups := newEdgeGroupsSet.Difference(environmentEdgeGroupsSet) + if err := updateSet(addToEdgeGroups, func(edgeGroup *portainer.EdgeGroup) { + edgeGroup.EndpointIDs.Add(environmentID) + }); err != nil { + return false, err + } + + return true, nil +} diff --git a/api/http/handler/endpoints/utils_update_edge_groups_test.go b/api/http/handler/endpoints/utils_update_edge_groups_test.go new file mode 100644 index 0000000..6812124 --- /dev/null +++ b/api/http/handler/endpoints/utils_update_edge_groups_test.go @@ -0,0 +1,152 @@ +package endpoints + +import ( + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/datastore" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_updateEdgeGroups(t *testing.T) { + t.Parallel() + createGroups := func(store *datastore.Store, names []string) ([]portainer.EdgeGroup, error) { + groups := make([]portainer.EdgeGroup, len(names)) + for index, name := range names { + group := &portainer.EdgeGroup{ + Name: name, + Dynamic: false, + TagIDs: make([]portainer.TagID, 0), + } + + if err := store.EdgeGroup().Create(group); err != nil { + return nil, err + } + + groups[index] = *group + } + + return groups, nil + } + + checkGroups := func(store *datastore.Store, is *assert.Assertions, groupIDs []portainer.EdgeGroupID, endpointID portainer.EndpointID) { + for _, groupID := range groupIDs { + group, err := store.EdgeGroup().Read(groupID) + require.NoError(t, err) + + is.True(group.EndpointIDs.Contains(endpointID), + "expected endpoint to be in group") + } + } + + groupsByName := func(groups []portainer.EdgeGroup, groupNames []string) []portainer.EdgeGroup { + result := make([]portainer.EdgeGroup, len(groupNames)) + for i, tagName := range groupNames { + for j, tag := range groups { + if tag.Name == tagName { + result[i] = groups[j] + + break + } + } + } + + return result + } + + type testCase struct { + title string + endpoint *portainer.Endpoint + groupNames []string + endpointGroupNames []string + groupsToApply []string + shouldNotBeUpdated bool + } + + testFn := func(t *testing.T, testCase testCase) { + is := assert.New(t) + _, store := datastore.MustNewTestStore(t, false, true) + + err := store.Endpoint().Create(testCase.endpoint) + require.NoError(t, err) + + groups, err := createGroups(store, testCase.groupNames) + require.NoError(t, err) + + endpointGroups := groupsByName(groups, testCase.endpointGroupNames) + for _, group := range endpointGroups { + group.EndpointIDs.Add(testCase.endpoint.ID) + + err = store.EdgeGroup().Update(group.ID, &group) + require.NoError(t, err) + } + + expectedGroups := groupsByName(groups, testCase.groupsToApply) + + expectedIDs := make([]portainer.EdgeGroupID, len(expectedGroups)) + for i, tag := range expectedGroups { + expectedIDs[i] = tag.ID + } + + err = store.UpdateTx(func(tx dataservices.DataStoreTx) error { + updated, err := updateEnvironmentEdgeGroups(tx, expectedIDs, testCase.endpoint.ID) + require.NoError(t, err) + + is.Equal(testCase.shouldNotBeUpdated, !updated) + + return nil + }) + + require.NoError(t, err) + + checkGroups(store, is, expectedIDs, testCase.endpoint.ID) + } + + testCases := []testCase{ + { + title: "applying edge groups to an endpoint without edge groups", + endpoint: &portainer.Endpoint{}, + groupNames: []string{"edge group1", "edge group2", "edge group3"}, + endpointGroupNames: []string{}, + groupsToApply: []string{"edge group1", "edge group2", "edge group3"}, + }, + { + title: "applying edge groups to an endpoint with edge groups", + endpoint: &portainer.Endpoint{}, + groupNames: []string{"edge group1", "edge group2", "edge group3", "edge group4", "edge group5", "edge group6"}, + endpointGroupNames: []string{"edge group1", "edge group2", "edge group3"}, + groupsToApply: []string{"edge group4", "edge group5", "edge group6"}, + }, + { + title: "applying edge groups to an endpoint with edge groups that are already applied", + endpoint: &portainer.Endpoint{}, + groupNames: []string{"edge group1", "edge group2", "edge group3"}, + endpointGroupNames: []string{"edge group1", "edge group2", "edge group3"}, + groupsToApply: []string{"edge group1", "edge group2", "edge group3"}, + shouldNotBeUpdated: true, + }, + { + title: "adding new edge groups to an endpoint with edge groups ", + endpoint: &portainer.Endpoint{}, + groupNames: []string{"edge group1", "edge group2", "edge group3", "edge group4", "edge group5", "edge group6"}, + endpointGroupNames: []string{"edge group1", "edge group2", "edge group3"}, + groupsToApply: []string{"edge group1", "edge group2", "edge group3", "edge group4", "edge group5", "edge group6"}, + }, + { + title: "mixing edge groups that are already applied and new edge groups", + endpoint: &portainer.Endpoint{}, + groupNames: []string{"edge group1", "edge group2", "edge group3", "edge group4", "edge group5", "edge group6"}, + endpointGroupNames: []string{"edge group1", "edge group2", "edge group3"}, + groupsToApply: []string{"edge group2", "edge group4", "edge group5"}, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.title, func(t *testing.T) { + testFn(t, testCase) + }) + } +} diff --git a/api/http/handler/endpoints/utils_update_tags.go b/api/http/handler/endpoints/utils_update_tags.go new file mode 100644 index 0000000..b2d5b5d --- /dev/null +++ b/api/http/handler/endpoints/utils_update_tags.go @@ -0,0 +1,56 @@ +package endpoints + +import ( + "github.com/pkg/errors" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/set" +) + +// updateEnvironmentTags updates the tags associated to an environment +func updateEnvironmentTags(tx dataservices.DataStoreTx, newTags []portainer.TagID, oldTags []portainer.TagID, environmentID portainer.EndpointID) (bool, error) { + payloadTagSet := set.ToSet(newTags) + environmentTagSet := set.ToSet(oldTags) + union := set.Union(payloadTagSet, environmentTagSet) + intersection := set.Intersection(payloadTagSet, environmentTagSet) + + if len(union) <= len(intersection) { + return false, nil + } + + updateSet := func(tagIDs set.Set[portainer.TagID], updateItem func(*portainer.Tag)) error { + for tagID := range tagIDs { + tag, err := tx.Tag().Read(tagID) + if err != nil { + return errors.WithMessage(err, "Unable to find a tag inside the database") + } + + updateItem(tag) + + err = tx.Tag().Update(tagID, tag) + if err != nil { + return errors.WithMessage(err, "Unable to persist tag changes inside the database") + } + } + + return nil + } + + removeTags := environmentTagSet.Difference(payloadTagSet) + err := updateSet(removeTags, func(tag *portainer.Tag) { + delete(tag.Endpoints, environmentID) + }) + if err != nil { + return false, err + } + + addTags := payloadTagSet.Difference(environmentTagSet) + err = updateSet(addTags, func(tag *portainer.Tag) { + tag.Endpoints[environmentID] = true + }) + if err != nil { + return false, err + } + + return true, nil +} diff --git a/api/http/handler/endpoints/utils_update_tags_test.go b/api/http/handler/endpoints/utils_update_tags_test.go new file mode 100644 index 0000000..7c045a0 --- /dev/null +++ b/api/http/handler/endpoints/utils_update_tags_test.go @@ -0,0 +1,165 @@ +package endpoints + +import ( + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/datastore" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_updateTags(t *testing.T) { + t.Parallel() + createTags := func(store *datastore.Store, tagNames []string) ([]portainer.Tag, error) { + tags := make([]portainer.Tag, len(tagNames)) + for index, tagName := range tagNames { + tag := &portainer.Tag{ + Name: tagName, + Endpoints: make(map[portainer.EndpointID]bool), + EndpointGroups: make(map[portainer.EndpointGroupID]bool), + } + + err := store.Tag().Create(tag) + if err != nil { + return nil, err + } + + tags[index] = *tag + } + + return tags, nil + } + + checkTags := func(store *datastore.Store, is *assert.Assertions, tagIDs []portainer.TagID, endpointID portainer.EndpointID) { + for _, tagID := range tagIDs { + tag, err := store.Tag().Read(tagID) + require.NoError(t, err) + + _, ok := tag.Endpoints[endpointID] + is.True(ok, "expected endpoint to be tagged") + } + } + + tagsByName := func(tags []portainer.Tag, tagNames []string) []portainer.Tag { + result := make([]portainer.Tag, len(tagNames)) + for i, tagName := range tagNames { + for j, tag := range tags { + if tag.Name == tagName { + result[i] = tags[j] + break + } + } + } + + return result + } + + getIDs := func(tags []portainer.Tag) []portainer.TagID { + ids := make([]portainer.TagID, len(tags)) + for i, tag := range tags { + ids[i] = tag.ID + } + + return ids + } + + type testCase struct { + title string + endpoint *portainer.Endpoint + tagNames []string + endpointTagNames []string + tagsToApply []string + shouldNotBeUpdated bool + } + + testFn := func(t *testing.T, testCase testCase) { + is := assert.New(t) + _, store := datastore.MustNewTestStore(t, false, true) + + err := store.Endpoint().Create(testCase.endpoint) + require.NoError(t, err) + + tags, err := createTags(store, testCase.tagNames) + require.NoError(t, err) + + endpointTags := tagsByName(tags, testCase.endpointTagNames) + for _, tag := range endpointTags { + tag.Endpoints[testCase.endpoint.ID] = true + + err = store.Tag().Update(tag.ID, &tag) + require.NoError(t, err) + } + + endpointTagIDs := getIDs(endpointTags) + testCase.endpoint.TagIDs = endpointTagIDs + err = store.Endpoint().UpdateEndpoint(testCase.endpoint.ID, testCase.endpoint) + require.NoError(t, err) + + expectedTags := tagsByName(tags, testCase.tagsToApply) + expectedTagIDs := make([]portainer.TagID, len(expectedTags)) + for i, tag := range expectedTags { + expectedTagIDs[i] = tag.ID + } + + err = store.UpdateTx(func(tx dataservices.DataStoreTx) error { + updated, err := updateEnvironmentTags(tx, expectedTagIDs, testCase.endpoint.TagIDs, testCase.endpoint.ID) + require.NoError(t, err) + + is.Equal(testCase.shouldNotBeUpdated, !updated) + + return nil + }) + + require.NoError(t, err) + + checkTags(store, is, expectedTagIDs, testCase.endpoint.ID) + } + + testCases := []testCase{ + { + title: "applying tags to an endpoint without tags", + endpoint: &portainer.Endpoint{}, + tagNames: []string{"tag1", "tag2", "tag3"}, + endpointTagNames: []string{}, + tagsToApply: []string{"tag1", "tag2", "tag3"}, + }, + { + title: "applying tags to an endpoint with tags", + endpoint: &portainer.Endpoint{}, + tagNames: []string{"tag1", "tag2", "tag3", "tag4", "tag5", "tag6"}, + endpointTagNames: []string{"tag1", "tag2", "tag3"}, + tagsToApply: []string{"tag4", "tag5", "tag6"}, + }, + { + title: "applying tags to an endpoint with tags that are already applied", + endpoint: &portainer.Endpoint{}, + tagNames: []string{"tag1", "tag2", "tag3"}, + endpointTagNames: []string{"tag1", "tag2", "tag3"}, + tagsToApply: []string{"tag1", "tag2", "tag3"}, + shouldNotBeUpdated: true, + }, + { + title: "adding new tags to an endpoint with tags ", + endpoint: &portainer.Endpoint{}, + tagNames: []string{"tag1", "tag2", "tag3", "tag4", "tag5", "tag6"}, + endpointTagNames: []string{"tag1", "tag2", "tag3"}, + tagsToApply: []string{"tag1", "tag2", "tag3", "tag4", "tag5", "tag6"}, + }, + { + title: "mixing tags that are already applied and new tags", + endpoint: &portainer.Endpoint{}, + tagNames: []string{"tag1", "tag2", "tag3", "tag4", "tag5", "tag6"}, + endpointTagNames: []string{"tag1", "tag2", "tag3"}, + tagsToApply: []string{"tag2", "tag4", "tag5"}, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.title, func(t *testing.T) { + testFn(t, testCase) + }) + } +} diff --git a/api/http/handler/file/handler.go b/api/http/handler/file/handler.go new file mode 100644 index 0000000..f0e4435 --- /dev/null +++ b/api/http/handler/file/handler.go @@ -0,0 +1,69 @@ +package file + +import ( + "net/http" + "strings" + + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/pkg/featureflags" + + "github.com/klauspost/compress/gzhttp" +) + +// Handler represents an HTTP API handler for managing static files. +type Handler struct { + http.Handler + wasInstanceDisabled func() bool +} + +// NewHandler creates a handler to serve static files. +func NewHandler(assetPublicPath string, csp bool, wasInstanceDisabled func() bool) *Handler { + h := &Handler{ + Handler: security.MWSecureHeaders( + gzhttp.GzipHandler(http.FileServer(http.Dir(assetPublicPath))), + featureflags.IsEnabled("hsts"), + csp, + ), + wasInstanceDisabled: wasInstanceDisabled, + } + + return h +} + +func isHTML(acceptContent []string) bool { + for _, accept := range acceptContent { + if strings.Contains(accept, "text/html") { + return true + } + } + + return false +} + +func (handler *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if handler.wasInstanceDisabled() { + if r.RequestURI == "/" || r.RequestURI == "/index.html" { + http.Redirect(w, r, "/timeout.html", http.StatusTemporaryRedirect) + + return + } + } else { + if strings.HasPrefix(r.RequestURI, "/timeout.html") { + http.Redirect(w, r, "/", http.StatusTemporaryRedirect) + + return + } + } + + if r.RequestURI == "/" || strings.HasSuffix(r.RequestURI, ".html") { + w.Header().Set("Permissions-Policy", strings.Join(permissions, ",")) + } + + if !isHTML(r.Header["Accept"]) { + w.Header().Set("Cache-Control", "max-age=31536000") + } else { + w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") + } + + handler.Handler.ServeHTTP(w, r) +} diff --git a/api/http/handler/file/handler_test.go b/api/http/handler/file/handler_test.go new file mode 100644 index 0000000..3f3502f --- /dev/null +++ b/api/http/handler/file/handler_test.go @@ -0,0 +1,73 @@ +package file_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/portainer/portainer/api/http/handler/file" + "github.com/stretchr/testify/require" +) + +func TestNormalServe(t *testing.T) { + t.Parallel() + handler := file.NewHandler("", false, func() bool { return false }) + require.NotNil(t, handler) + + request := func(path string) (*http.Request, *httptest.ResponseRecorder) { + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, path, nil) + handler.ServeHTTP(rr, req) + return req, rr + } + + _, rr := request("/timeout.html") + require.Equal(t, http.StatusTemporaryRedirect, rr.Result().StatusCode) + loc, err := rr.Result().Location() + require.NoError(t, err) + require.NotNil(t, loc) + require.Equal(t, "/", loc.Path) + + _, rr = request("/") + require.Equal(t, http.StatusOK, rr.Result().StatusCode) +} + +func TestPermissionsPolicyHeader(t *testing.T) { + t.Parallel() + handler := file.NewHandler("", false, func() bool { return false }) + require.NotNil(t, handler) + + test := func(path string, exist bool) { + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, path, nil) + handler.ServeHTTP(rr, req) + + require.Equal(t, exist, rr.Result().Header.Get("Permissions-Policy") != "") + } + + test("/", true) + test("/index.html", true) + test("/api", false) + test("/an/image.png", false) +} + +func TestRedirectInstanceDisabled(t *testing.T) { + t.Parallel() + handler := file.NewHandler("", false, func() bool { return true }) + require.NotNil(t, handler) + + test := func(path string) { + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, path, nil) + handler.ServeHTTP(rr, req) + + require.Equal(t, http.StatusTemporaryRedirect, rr.Result().StatusCode) + loc, err := rr.Result().Location() + require.NoError(t, err) + require.NotNil(t, loc) + require.Equal(t, "/timeout.html", loc.Path) + } + + test("/") + test("/index.html") +} diff --git a/api/http/handler/file/permissions_list.go b/api/http/handler/file/permissions_list.go new file mode 100644 index 0000000..4355372 --- /dev/null +++ b/api/http/handler/file/permissions_list.go @@ -0,0 +1,91 @@ +package file + +var permissions = []string{ + "accelerometer=()", + "ambient-light-sensor=()", + "attribution-reporting=()", + "autoplay=()", + "battery=()", + "browsing-topics=()", + "camera=()", + "captured-surface-control=()", + "ch-device-memory=()", + "ch-downlink=()", + "ch-dpr=()", + "ch-ect=()", + "ch-prefers-color-scheme=()", + "ch-prefers-reduced-motion=()", + "ch-prefers-reduced-transparency=()", + "ch-rtt=()", + "ch-save-data=()", + "ch-ua=()", + "ch-ua-arch=()", + "ch-ua-bitness=()", + "ch-ua-form-factors=()", + "ch-ua-full-version=()", + "ch-ua-full-version-list=()", + "ch-ua-mobile=()", + "ch-ua-model=()", + "ch-ua-platform=()", + "ch-ua-platform-version=()", + "ch-ua-wow64=()", + "ch-viewport-height=()", + "ch-viewport-width=()", + "ch-width=()", + "compute-pressure=()", + "conversion-measurement=()", + "cross-origin-isolated=()", + "deferred-fetch=()", + "deferred-fetch-minimal=()", + "display-capture=()", + "document-domain=()", + "encrypted-media=()", + "execution-while-not-rendered=()", + "execution-while-out-of-viewport=()", + "focus-without-user-activation=()", + "fullscreen=()", + "gamepad=()", + "geolocation=()", + "gyroscope=()", + "hid=()", + "identity-credentials-get=()", + "idle-detection=()", + "interest-cohort=()", + "join-ad-interest-group=()", + "keyboard-map=()", + "language-detector=()", + "local-fonts=()", + "magnetometer=()", + "microphone=()", + "midi=()", + "navigation-override=()", + "otp-credentials=()", + "payment=()", + "picture-in-picture=()", + "private-aggregation=()", + "private-state-token-issuance=()", + "private-state-token-redemption=()", + "publickey-credentials-create=()", + "publickey-credentials-get=()", + "rewriter=()", + "run-ad-auction=()", + "screen-wake-lock=()", + "serial=()", + "shared-storage=()", + "shared-storage-select-url=()", + "speaker-selection=()", + "storage-access=()", + "summarizer=()", + "sync-script=()", + "sync-xhr=()", + "translator=()", + "trust-token-redemption=()", + "unload=()", + "usb=()", + "vertical-scroll=()", + "web-share=()", + "window-management=()", + "window-placement=()", + "writer=()", + "xr-spatial-tracking=()", +} diff --git a/api/http/handler/gitops/git_repo_file_preview.go b/api/http/handler/gitops/git_repo_file_preview.go new file mode 100644 index 0000000..dab2f3d --- /dev/null +++ b/api/http/handler/gitops/git_repo_file_preview.go @@ -0,0 +1,151 @@ +package gitops + +import ( + "context" + "errors" + "fmt" + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices/source" + gittypes "github.com/portainer/portainer/api/git/types" + "github.com/portainer/portainer/api/gitops/sources" + "github.com/portainer/portainer/api/http/security" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" + "github.com/portainer/portainer/pkg/libhttp/ssrf" + "github.com/portainer/portainer/pkg/validate" + "github.com/rs/zerolog/log" +) + +type fileResponse struct { + FileContent string +} + +type repositoryFilePreviewPayload struct { + // SourceID resolves URL and auth from the stored Source record. + // When set, the inline Repository/Username/Password/TLSSkipVerify fields are ignored. + SourceID portainer.SourceID `json:"sourceID" example:"1"` + Reference string `json:"reference" example:"refs/heads/master"` + // Path to file whose content will be read + TargetFile string `json:"targetFile" example:"docker-compose.yml"` + + // URL of a Git repository to preview. + // Deprecated: use SourceID instead + Repository string `json:"repository" example:"https://github.com/openfaas/faas"` + // Username for git authentication. + // Deprecated: use SourceID instead + Username string `json:"username" example:"myGitUsername"` + // Password for git authentication. + // Deprecated: use SourceID instead + Password string `json:"password" example:"myGitPassword"` + // TLSSkipVerify skips SSL verification when cloning the Git repository. + // Deprecated: use SourceID instead + TLSSkipVerify bool `json:"tlsSkipVerify" example:"false"` +} + +func (payload *repositoryFilePreviewPayload) Validate(r *http.Request) error { + if payload.SourceID == 0 { + if len(payload.Repository) == 0 || !validate.IsURL(payload.Repository) { + return errors.New("invalid repository URL. Must correspond to a valid URL format") + } + } + + if len(payload.Reference) == 0 { + payload.Reference = "refs/heads/main" + } + + if len(payload.TargetFile) == 0 { + return errors.New("invalid target filename") + } + + return nil +} + +// @id GitOperationRepoFilePreview +// @summary preview the content of target file in the git repository +// @description Retrieve the compose file content based on git repository configuration +// @description **Access policy**: authenticated +// @tags gitops +// @security ApiKeyAuth +// @security jwt +// @produce json +// @param body body repositoryFilePreviewPayload true "Template details" +// @success 200 {object} fileResponse "Success" +// @failure 400 "Invalid request" +// @failure 404 "Source not found" +// @failure 500 "Server error" +// @router /gitops/repo/file/preview [post] +func (handler *Handler) gitOperationRepoFilePreview(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload repositoryFilePreviewPayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + repoURL := payload.Repository + username := payload.Username + password := payload.Password + tlsSkipVerify := payload.TLSSkipVerify + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve user info from request context", err) + } + userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships) + + if payload.SourceID != 0 { + src, httpErr := sources.ValidateGitSourceAccess(handler.dataStore, userContext, payload.SourceID) + if httpErr != nil { + return httpErr + } + + repoURL = src.Git.URL + if src.Git.Authentication != nil { + username = src.Git.Authentication.Username + password = src.Git.Authentication.Password + } + tlsSkipVerify = src.Git.TLSSkipVerify + } + + if err := ssrf.CheckURL(r.Context(), repoURL); err != nil { + return httperror.BadRequest("Repository URL blocked by SSRF policy", err) + } + + projectPath, err := handler.fileService.GetTemporaryPath() + if err != nil { + return httperror.InternalServerError("Unable to create temporary folder", err) + } + + err = handler.gitService.CloneRepository( + context.TODO(), + projectPath, + repoURL, + payload.Reference, + username, + password, + tlsSkipVerify, + ) + if err != nil { + if errors.Is(err, gittypes.ErrAuthenticationFailure) { + return httperror.BadRequest("Invalid git credential", err) + } + + newErr := fmt.Errorf("unable to clone git repository, error: %w", err) + return httperror.InternalServerError(newErr.Error(), newErr) + } + + defer func() { + if err := handler.fileService.RemoveDirectory(projectPath); err != nil { + log.Warn().Err(err).Msg("failed to remove temporary project folder") + } + }() + + fileContent, err := handler.fileService.GetFileContent(projectPath, payload.TargetFile) + if err != nil { + return httperror.InternalServerError("Unable to retrieve custom template file from disk", err) + } + + return response.JSON(w, &fileResponse{FileContent: string(fileContent)}) +} diff --git a/api/http/handler/gitops/handler.go b/api/http/handler/gitops/handler.go new file mode 100644 index 0000000..6e7a826 --- /dev/null +++ b/api/http/handler/gitops/handler.go @@ -0,0 +1,46 @@ +package gitops + +import ( + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/kubernetes/cli" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + + "github.com/gorilla/mux" + + "github.com/portainer/portainer/api/http/handler/gitops/sources" + "github.com/portainer/portainer/api/http/handler/gitops/workflows" +) + +// Handler is the HTTP handler used to handle git repo operation +type Handler struct { + *mux.Router + dataStore dataservices.DataStore + gitService portainer.GitService + fileService portainer.FileService +} + +func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStore, gitService portainer.GitService, fileService portainer.FileService, k8sFactory *cli.ClientFactory) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + dataStore: dataStore, + gitService: gitService, + fileService: fileService, + } + + authenticatedRouter := h.NewRoute().Subrouter() + authenticatedRouter.Use(bouncer.AuthenticatedAccess) + + authenticatedRouter.Handle("/gitops/repo/file/preview", httperror.LoggerHandler(h.gitOperationRepoFilePreview)).Methods(http.MethodPost) + + workflowsHandler := workflows.NewHandler(dataStore, gitService, k8sFactory) + authenticatedRouter.PathPrefix("/gitops/workflows").Handler(workflowsHandler) + + sourcesHandler := sources.NewHandler(bouncer, dataStore, gitService, k8sFactory) + authenticatedRouter.PathPrefix("/gitops/sources").Handler(sourcesHandler) + + return h +} diff --git a/api/http/handler/gitops/sources/cache_test.go b/api/http/handler/gitops/sources/cache_test.go new file mode 100644 index 0000000..2d30799 --- /dev/null +++ b/api/http/handler/gitops/sources/cache_test.go @@ -0,0 +1,172 @@ +package sources + +import ( + "net/http" + "net/http/httptest" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/datastore" + gittypes "github.com/portainer/portainer/api/git/types" + + "github.com/segmentio/encoding/json" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Each test below primes the source cache with a read before mutating, then +// reads again and asserts the change is reflected. The handler is built with a +// non-expiring cache (newTestHandlerNoCacheExpiry), so a fresh result can only +// come from invalidation on the write — not from the TTL elapsing. + +func TestGitSourceCreate_InvalidatesCache(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + user := &portainer.User{ID: 1, Role: portainer.AdministratorRole} + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + return tx.User().Create(user) + })) + + h := newTestHandlerNoCacheExpiry(t, store) + + // prime the cache with the (empty) list + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildListReq(t, user.ID, "")) + require.Empty(t, decodeSources(t, rr)) + + body, err := json.Marshal(GitSourceCreatePayload{ + URL: "https://github.com/org/repo.git", + Name: "my-source", + }) + require.NoError(t, err) + + rr = httptest.NewRecorder() + h.ServeHTTP(rr, buildCreateReq(t, user.ID, body)) + require.Equal(t, http.StatusCreated, rr.Code) + + // the new source must appear despite the primed empty cache + rr = httptest.NewRecorder() + h.ServeHTTP(rr, buildListReq(t, user.ID, "")) + got := decodeSources(t, rr) + require.Len(t, got, 1) + require.Equal(t, "my-source", got[0].Name) +} + +func TestSourceDelete_InvalidatesCache(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + var srcID portainer.SourceID + user := &portainer.User{ID: 1, Role: portainer.AdministratorRole} + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + src := &portainer.Source{Name: "to-delete", Type: portainer.SourceTypeGit, Git: &gittypes.GitSource{URL: "https://github.com/org/repo"}} + require.NoError(t, tx.Source().Create(adminUserContext, src)) + srcID = src.ID + + return tx.User().Create(user) + })) + + h := newTestHandlerNoCacheExpiry(t, store) + + // prime the cache with the source present + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildListReq(t, user.ID, "")) + require.Len(t, decodeSources(t, rr), 1) + + rr = httptest.NewRecorder() + h.ServeHTTP(rr, buildDeleteReq(t, user.ID, int(srcID))) + require.Equal(t, http.StatusNoContent, rr.Code) + + // the deleted source must be gone despite the primed cache + rr = httptest.NewRecorder() + h.ServeHTTP(rr, buildListReq(t, user.ID, "")) + require.Empty(t, decodeSources(t, rr)) +} + +func TestGitSourceUpdate_InvalidatesCache(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + var srcID portainer.SourceID + user := &portainer.User{ID: 1, Role: portainer.AdministratorRole} + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + src := &portainer.Source{Name: "old-name", Type: portainer.SourceTypeGit, Git: &gittypes.GitSource{URL: "https://github.com/org/repo"}} + require.NoError(t, tx.Source().Create(adminUserContext, src)) + srcID = src.ID + + return tx.User().Create(user) + })) + + h := newTestHandlerNoCacheExpiry(t, store) + + // prime the cache with the original name + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildListReq(t, user.ID, "")) + primed := decodeSources(t, rr) + require.Len(t, primed, 1) + require.Equal(t, "old-name", primed[0].Name) + + body, err := json.Marshal(GitSourceUpdatePayload{Name: new("new-name")}) + require.NoError(t, err) + + rr = httptest.NewRecorder() + h.ServeHTTP(rr, buildUpdateReq(t, user.ID, int(srcID), body)) + require.Equal(t, http.StatusOK, rr.Code) + + // the updated name must be reflected despite the primed cache + rr = httptest.NewRecorder() + h.ServeHTTP(rr, buildListReq(t, user.ID, "")) + got := decodeSources(t, rr) + require.Len(t, got, 1) + require.Equal(t, "new-name", got[0].Name) +} + +// TestSourceMutation_InvalidatesSummaryCache verifies the summary endpoint, which +// shares the cache entry with the list via getSources, also sees fresh data. +func TestSourceMutation_InvalidatesSummaryCache(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + user := &portainer.User{ID: 1, Role: portainer.AdministratorRole} + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + return tx.User().Create(user) + })) + + h := newTestHandlerNoCacheExpiry(t, store) + + // prime the cache with the empty summary + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildSummaryReq(t, user.ID)) + require.Equal(t, 0, summaryTotal(t, rr)) + + body, err := json.Marshal(GitSourceCreatePayload{ + URL: "https://github.com/org/repo.git", + Name: "my-source", + }) + require.NoError(t, err) + + rr = httptest.NewRecorder() + h.ServeHTTP(rr, buildCreateReq(t, user.ID, body)) + require.Equal(t, http.StatusCreated, rr.Code) + + // the summary count must reflect the new source despite the primed cache + rr = httptest.NewRecorder() + h.ServeHTTP(rr, buildSummaryReq(t, user.ID)) + assert.Equal(t, 1, summaryTotal(t, rr)) +} + +func summaryTotal(t *testing.T, rr *httptest.ResponseRecorder) int { + t.Helper() + require.Equal(t, http.StatusOK, rr.Code, "unexpected status: %s", rr.Body.String()) + + var summary map[string]int + require.NoError(t, json.NewDecoder(rr.Body).Decode(&summary)) + + total := 0 + for _, count := range summary { + total += count + } + return total +} diff --git a/api/http/handler/gitops/sources/create_git.go b/api/http/handler/gitops/sources/create_git.go new file mode 100644 index 0000000..c7d84b4 --- /dev/null +++ b/api/http/handler/gitops/sources/create_git.go @@ -0,0 +1,144 @@ +package sources + +import ( + "errors" + "net/http" + "strings" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/dataservices/source" + gittypes "github.com/portainer/portainer/api/git/types" + "github.com/portainer/portainer/api/http/security" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" + "github.com/portainer/portainer/pkg/validate" +) + +// GitAuthenticationPayload holds authentication parameters for a git source +type GitAuthenticationPayload struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type SourceAccessControlPayload struct { + Public bool `json:"public" example:"true"` + AdministratorsOnly bool `json:"administratorsOnly" example:"true"` + UserAccesses []portainer.UserID `json:"userAccesses"` + TeamAccesses []portainer.TeamID `json:"teamAccesses"` +} + +// GitSourceCreatePayload holds the parameters for creating a git-backed source +type GitSourceCreatePayload struct { + SourceAccessControlPayload + Name string `json:"name"` + URL string `json:"url" validate:"required"` + TLSSkipVerify bool `json:"tlsSkipVerify"` + Authentication *GitAuthenticationPayload `json:"authentication"` +} + +// Validate implements the portainer.Validatable interface +func (payload *GitSourceCreatePayload) Validate(_ *http.Request) error { + if !validate.IsURL(payload.URL) { + return errors.New("invalid repository URL. Must correspond to a valid URL format") + } + + return nil +} + +// @id GitOpsSourcesCreateGit +// @summary Create a Git source +// @description Creates a new GitOps source backed by a Git repository. +// @description **Access policy**: authenticated +// @tags gitops +// @security ApiKeyAuth +// @security jwt +// @accept json +// @produce json +// @param body body GitSourceCreatePayload true "Git source details" +// @success 201 {object} portainer.Source +// @failure 400 "Invalid request payload" +// @failure 403 "Access denied" +// @failure 409 "A source with this URL and credentials already exists" +// @failure 500 "Server error" +// @router /gitops/sources/git [post] +func (h *Handler) gitSourceCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload GitSourceCreatePayload + + if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) + } + + src, err := BuildGitSource(payload) + if err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships) + + if err := h.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + return tx.Source().Create(userContext, src) + }); errors.Is(err, source.ErrDuplicateSource) { + return httperror.Conflict("A source with this URL and credentials already exists", err) + } else if err != nil { + return httperror.InternalServerError("Unable to create source", err) + } + + if src, err = h.testAndSaveSourceConnection(r.Context(), userContext, src); err != nil { + return httperror.InternalServerError("Unable to persist source status", err) + } + + h.invalidateCache() + + src.Git = gittypes.SanitizeGitSource(src.Git) + + return response.JSONWithStatus(w, src, http.StatusCreated) +} + +// BuildGitSource constructs a portainer.Source from a GitSourceCreatePayload +func BuildGitSource(payload GitSourceCreatePayload) (*portainer.Source, error) { + src := BuildBaseGitSource(payload) + src.Git.Authentication = BuildAuth(payload.Authentication) + + return src, nil +} + +// BuildBaseGitSource constructs the source skeleton (name, URL, TLS, accesses) without authentication. +func BuildBaseGitSource(payload GitSourceCreatePayload) *portainer.Source { + name := payload.Name + if strings.TrimSpace(name) == "" { + name = gittypes.RepoName(payload.URL) + } + + return &portainer.Source{ + Name: name, + Type: portainer.SourceTypeGit, + Git: &gittypes.GitSource{ + URL: payload.URL, + TLSSkipVerify: payload.TLSSkipVerify, + }, + UserAccesses: payload.UserAccesses, + TeamAccesses: payload.TeamAccesses, + Public: payload.Public, + AdministratorsOnly: payload.AdministratorsOnly, + } +} + +// BuildAuth constructs basic git authentication from the payload, returning nil +// when no authentication is provided. +func BuildAuth(payload *GitAuthenticationPayload) *gittypes.GitAuthentication { + if payload == nil { + return nil + } + + return &gittypes.GitAuthentication{ + Username: payload.Username, + Password: payload.Password, + } +} diff --git a/api/http/handler/gitops/sources/create_git_test.go b/api/http/handler/gitops/sources/create_git_test.go new file mode 100644 index 0000000..6f59d9e --- /dev/null +++ b/api/http/handler/gitops/sources/create_git_test.go @@ -0,0 +1,291 @@ +package sources + +import ( + "net/http" + "net/http/httptest" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/datastore" + + "github.com/segmentio/encoding/json" + "github.com/stretchr/testify/require" +) + +func TestBuildGitSource_DerivesNameFromURL(t *testing.T) { + t.Parallel() + + src, err := BuildGitSource(GitSourceCreatePayload{ + URL: "https://github.com/org/my-repo.git", + }) + require.NoError(t, err) + + require.Equal(t, "my-repo", src.Name) + require.Equal(t, portainer.SourceTypeGit, src.Type) + require.Nil(t, src.Git.Authentication) +} + +func TestBuildGitSource_UsesExplicitName(t *testing.T) { + t.Parallel() + + src, err := BuildGitSource(GitSourceCreatePayload{ + Name: "custom-name", + URL: "https://github.com/org/repo.git", + }) + require.NoError(t, err) + + require.Equal(t, "custom-name", src.Name) +} + +func TestBuildGitSource_WithAuthentication(t *testing.T) { + t.Parallel() + + src, err := BuildGitSource(GitSourceCreatePayload{ + URL: "https://github.com/org/repo.git", + Authentication: &GitAuthenticationPayload{ + Username: "alice", + Password: "secret", + }, + }) + require.NoError(t, err) + + require.NotNil(t, src.Git.Authentication) + require.Equal(t, "alice", src.Git.Authentication.Username) + require.Equal(t, "secret", src.Git.Authentication.Password) +} + +func TestGitSourceCreatePayload_Validate_EmptyURL(t *testing.T) { + t.Parallel() + + err := (&GitSourceCreatePayload{}).Validate(nil) + require.Error(t, err) +} + +func TestGitSourceCreatePayload_Validate_ValidURL(t *testing.T) { + t.Parallel() + + err := (&GitSourceCreatePayload{URL: "https://github.com/org/repo.git"}).Validate(nil) + require.NoError(t, err) +} + +func TestGitSourceCreate_Success(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}) + })) + + h := newTestHandler(t, store) + + body, err := json.Marshal(GitSourceCreatePayload{ + URL: "https://github.com/org/repo.git", + Name: "my-source", + }) + require.NoError(t, err) + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildCreateReq(t, 1, body)) + + require.Equal(t, http.StatusCreated, rr.Code) + + var src portainer.Source + err = json.NewDecoder(rr.Body).Decode(&src) + require.NoError(t, err) + require.Equal(t, "my-source", src.Name) + require.Equal(t, portainer.SourceTypeGit, src.Type) + require.NotZero(t, src.ID) + require.NotNil(t, src.Git) + require.Equal(t, "https://github.com/org/repo", src.Git.URL) +} + +func TestGitSourceCreate_SanitizesCredentials(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}) + })) + + h := newTestHandler(t, store) + + body, err := json.Marshal(GitSourceCreatePayload{ + URL: "https://github.com/org/repo.git", + Authentication: &GitAuthenticationPayload{ + Username: "alice", + Password: "secret", + }, + }) + require.NoError(t, err) + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildCreateReq(t, 1, body)) + + require.Equal(t, http.StatusCreated, rr.Code) + + var src portainer.Source + err = json.NewDecoder(rr.Body).Decode(&src) + require.NoError(t, err) + require.NotNil(t, src.Git) + require.NotNil(t, src.Git.Authentication) + require.Equal(t, "alice", src.Git.Authentication.Username) + require.Empty(t, src.Git.Authentication.Password) +} + +func TestGitSourceCreate_MissingURL(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}) + })) + + h := newTestHandler(t, store) + + body, err := json.Marshal(GitSourceCreatePayload{Name: "no-url"}) + require.NoError(t, err) + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildCreateReq(t, 1, body)) + + require.Equal(t, http.StatusBadRequest, rr.Code) +} + +func TestGitSourceCreate_ConflictOnDuplicateURLAndCredentials(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}) + })) + + h := newTestHandler(t, store) + + body, err := json.Marshal(GitSourceCreatePayload{ + URL: "https://github.com/org/repo.git", + Authentication: &GitAuthenticationPayload{ + Username: "alice", + Password: "secret", + }, + }) + require.NoError(t, err) + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildCreateReq(t, 1, body)) + require.Equal(t, http.StatusCreated, rr.Code) + + rr = httptest.NewRecorder() + h.ServeHTTP(rr, buildCreateReq(t, 1, body)) + require.Equal(t, http.StatusConflict, rr.Code) +} + +func TestGitSourceCreate_AllowsDuplicateURLWithDifferentCredentials(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}) + })) + + h := newTestHandler(t, store) + + first, err := json.Marshal(GitSourceCreatePayload{ + URL: "https://github.com/org/repo.git", + Authentication: &GitAuthenticationPayload{ + Username: "alice", + Password: "secret", + }, + }) + require.NoError(t, err) + + second, err := json.Marshal(GitSourceCreatePayload{ + URL: "https://github.com/org/repo.git", + Authentication: &GitAuthenticationPayload{ + Username: "bob", + Password: "other", + }, + }) + require.NoError(t, err) + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildCreateReq(t, 1, first)) + require.Equal(t, http.StatusCreated, rr.Code) + + rr = httptest.NewRecorder() + h.ServeHTTP(rr, buildCreateReq(t, 1, second)) + require.Equal(t, http.StatusCreated, rr.Code) +} + +func TestGitSourceCreate_ConflictOnDuplicateAuthlessSource(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}) + })) + + h := newTestHandler(t, store) + + body, err := json.Marshal(GitSourceCreatePayload{ + URL: "https://github.com/org/repo.git", + }) + require.NoError(t, err) + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildCreateReq(t, 1, body)) + require.Equal(t, http.StatusCreated, rr.Code) + + rr = httptest.NewRecorder() + h.ServeHTTP(rr, buildCreateReq(t, 1, body)) + require.Equal(t, http.StatusConflict, rr.Code) +} + +func TestGitSourceCreate_AllowsAuthlessAndAuthenticatedSameURL(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}) + })) + + h := newTestHandler(t, store) + + authless, err := json.Marshal(GitSourceCreatePayload{ + URL: "https://github.com/org/repo.git", + }) + require.NoError(t, err) + + authenticated, err := json.Marshal(GitSourceCreatePayload{ + URL: "https://github.com/org/repo.git", + Authentication: &GitAuthenticationPayload{ + Username: "alice", + Password: "secret", + }, + }) + require.NoError(t, err) + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildCreateReq(t, 1, authless)) + require.Equal(t, http.StatusCreated, rr.Code) + + rr = httptest.NewRecorder() + h.ServeHTTP(rr, buildCreateReq(t, 1, authenticated)) + require.Equal(t, http.StatusCreated, rr.Code) +} + +func TestGitSourceCreate_MalformedJSON(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}) + })) + + h := newTestHandler(t, store) + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildCreateReq(t, 1, []byte("not-valid-json{"))) + + require.Equal(t, http.StatusBadRequest, rr.Code) +} diff --git a/api/http/handler/gitops/sources/delete.go b/api/http/handler/gitops/sources/delete.go new file mode 100644 index 0000000..5171d6d --- /dev/null +++ b/api/http/handler/gitops/sources/delete.go @@ -0,0 +1,97 @@ +package sources + +import ( + "errors" + "net/http" + "slices" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + dserrors "github.com/portainer/portainer/api/dataservices/errors" + "github.com/portainer/portainer/api/dataservices/source" + "github.com/portainer/portainer/api/http/security" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +var ErrSourceInUse = errors.New("source is used by one or more workflows or custom templates") + +// @id GitOpsSourcesDelete +// @summary Delete a source +// @description Deletes an existing GitOps source. Returns 409 if the source is referenced by any workflow or custom template. +// @description **Access policy**: authenticated +// @tags gitops +// @security ApiKeyAuth +// @security jwt +// @param id path int true "Source identifier" +// @success 204 "Source deleted" +// @failure 400 "Invalid request" +// @failure 403 "Access denied" +// @failure 404 "Source not found" +// @failure 409 "Source is in use by one or more workflows or custom templates" +// @failure 500 "Server error" +// @router /gitops/sources/{id} [delete] +func (h *Handler) sourceDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + sourceID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid source identifier route variable", err) + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) + } + + if err := h.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + + userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships) + if exists, err := tx.Source().Exists(userContext, portainer.SourceID(sourceID)); err != nil { + return err + } else if !exists { + return dserrors.ErrObjectNotFound + } + + workflows, err := tx.Workflow().ReadAll() + if err != nil { + return err + } + + for _, wf := range workflows { + if slices.ContainsFunc(wf.Artifacts, func(as portainer.Artifact) bool { + return slices.ContainsFunc(as.Files, func(f portainer.ArtifactFile) bool { + return f.SourceID == portainer.SourceID(sourceID) + }) + }) { + return ErrSourceInUse + } + } + + templates, err := tx.CustomTemplate().ReadAll(func(t portainer.CustomTemplate) bool { + return t.Artifact != nil && slices.ContainsFunc(t.Artifact.Files, func(f portainer.ArtifactFile) bool { + return f.SourceID == portainer.SourceID(sourceID) + }) + }) + if err != nil { + return err + } + + if len(templates) > 0 { + return ErrSourceInUse + } + + return tx.Source().Delete(userContext, portainer.SourceID(sourceID)) + }); h.dataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find a source with the specified identifier", err) + } else if errors.Is(err, ErrSourceInUse) { + return httperror.Conflict("Source is used by one or more workflows or custom templates", err) + } else if errors.Is(err, source.ErrNotEnoughPermission) { + return httperror.Forbidden("Not enough permissions to delete source", err) + } else if err != nil { + return httperror.InternalServerError("Unable to delete source", err) + } + + h.invalidateCache() + + return response.Empty(w) +} diff --git a/api/http/handler/gitops/sources/delete_test.go b/api/http/handler/gitops/sources/delete_test.go new file mode 100644 index 0000000..833c9ca --- /dev/null +++ b/api/http/handler/gitops/sources/delete_test.go @@ -0,0 +1,125 @@ +package sources + +import ( + "net/http" + "net/http/httptest" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/datastore" + gittypes "github.com/portainer/portainer/api/git/types" + + "github.com/stretchr/testify/require" +) + +func TestSourceDelete_Success(t *testing.T) { + t.Parallel() + + _, store := datastore.MustNewTestStore(t, false, true) + + var srcID portainer.SourceID + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + src := &portainer.Source{Name: "to-delete", Type: portainer.SourceTypeGit, Git: &gittypes.GitSource{URL: "http://github.com/org/repo"}} + err := tx.Source().Create(adminUserContext, src) + require.NoError(t, err) + srcID = src.ID + + return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}) + })) + + h := newTestHandler(t, store) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildDeleteReq(t, 1, int(srcID))) + + require.Equal(t, http.StatusNoContent, rr.Code) +} + +func TestSourceDelete_NotFound(t *testing.T) { + t.Parallel() + + _, store := datastore.MustNewTestStore(t, false, true) + + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}) + })) + + h := newTestHandler(t, store) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildDeleteReq(t, 1, 99)) + + require.Equal(t, http.StatusNotFound, rr.Code) +} + +func TestSourceDelete_InUse(t *testing.T) { + t.Parallel() + + _, store := datastore.MustNewTestStore(t, false, true) + + var srcID portainer.SourceID + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + src := &portainer.Source{Name: "in-use", Type: portainer.SourceTypeGit, Git: &gittypes.GitSource{URL: "http://github.com/org/repo"}} + err := tx.Source().Create(adminUserContext, src) + require.NoError(t, err) + srcID = src.ID + + wf := &portainer.Workflow{Artifacts: []portainer.Artifact{{Files: []portainer.ArtifactFile{{SourceID: src.ID}}}}} + err = tx.Workflow().Create(wf) + require.NoError(t, err) + + return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}) + })) + + h := newTestHandler(t, store) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildDeleteReq(t, 1, int(srcID))) + + require.Equal(t, http.StatusConflict, rr.Code) +} + +func TestSourceDelete_NonNumericID(t *testing.T) { + t.Parallel() + + _, store := datastore.MustNewTestStore(t, false, true) + + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}) + })) + + h := newTestHandler(t, store) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildDeleteReqWithRawID(t, 1, "not-a-number")) + + require.Equal(t, http.StatusBadRequest, rr.Code) +} + +func TestSourceDelete_InUseByCustomTemplate(t *testing.T) { + t.Parallel() + + _, store := datastore.MustNewTestStore(t, false, true) + + var srcID portainer.SourceID + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + src := &portainer.Source{Name: "in-use-by-template", Type: portainer.SourceTypeGit, Git: &gittypes.GitSource{URL: "http://github.com/org/repo"}} + err := tx.Source().Create(adminUserContext, src) + require.NoError(t, err) + srcID = src.ID + + ct := &portainer.CustomTemplate{ + ID: 1, + Artifact: &portainer.Artifact{ + Files: []portainer.ArtifactFile{{SourceID: src.ID}}, + }, + } + err = tx.CustomTemplate().Create(ct) + require.NoError(t, err) + + return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}) + })) + + h := newTestHandler(t, store) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildDeleteReq(t, 1, int(srcID))) + + require.Equal(t, http.StatusConflict, rr.Code) +} diff --git a/api/http/handler/gitops/sources/fetch.go b/api/http/handler/gitops/sources/fetch.go new file mode 100644 index 0000000..b4de307 --- /dev/null +++ b/api/http/handler/gitops/sources/fetch.go @@ -0,0 +1,77 @@ +package sources + +import ( + "slices" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + ce "github.com/portainer/portainer/api/gitops/workflows" + "github.com/portainer/portainer/api/set" + "github.com/portainer/portainer/api/slicesx" +) + +// FetchSourceWorkflows returns the workflows and stats for a single source. +func FetchSourceWorkflows(tx dataservices.DataStoreTx, src *portainer.Source) ([]ce.Workflow, ce.SourceStats, error) { + wfs, err := tx.Workflow().ReadAll(func(wf portainer.Workflow) bool { + return slices.ContainsFunc(wf.Artifacts, func(artifact portainer.Artifact) bool { + return slices.ContainsFunc(artifact.Files, func(f portainer.ArtifactFile) bool { + return f.SourceID == src.ID + }) + }) + }) + if err != nil { + return nil, ce.SourceStats{}, err + } + + if len(wfs) == 0 { + return nil, ce.SourceStats{}, nil + } + + wfIDSet := set.ToSet(slicesx.Map(wfs, func(wf portainer.Workflow) portainer.WorkflowID { return wf.ID })) + + stacks, err := tx.Stack().ReadAll(func(s portainer.Stack) bool { + _, ok := wfIDSet[s.WorkflowID] + return ok + }) + if err != nil { + return nil, ce.SourceStats{}, err + } + + artifactByStack := make(map[portainer.StackID]portainer.ArtifactFile) + for _, wf := range wfs { + for _, artifact := range wf.Artifacts { + if artifact.StackID == 0 { + continue + } + if _, exists := artifactByStack[artifact.StackID]; exists { + continue + } + for _, file := range artifact.Files { + if file.SourceID == src.ID { + artifactByStack[artifact.StackID] = file + break + } + } + } + } + + unknown := ce.WorkflowPhaseStatus{Status: ce.StatusUnknown} + items := make([]ce.Workflow, 0, len(stacks)) + stats := ce.SourceStats{EndpointIDs: set.Set[portainer.EndpointID]{}} + + for _, stack := range stacks { + cfg := src.Git.ToRepoConfig() + if file, ok := artifactByStack[stack.ID]; ok { + cfg.ReferenceName = file.Ref + cfg.ConfigFilePath = file.Path + cfg.ConfigHash = file.Hash + } + items = append(items, ce.MapStackToWorkflow(stack, src.ID, cfg, unknown, unknown)) + stats.WorkflowCount++ + if stack.EndpointID != 0 { + stats.EndpointIDs.Add(stack.EndpointID) + } + } + + return items, stats, nil +} diff --git a/api/http/handler/gitops/sources/get.go b/api/http/handler/gitops/sources/get.go new file mode 100644 index 0000000..0014b54 --- /dev/null +++ b/api/http/handler/gitops/sources/get.go @@ -0,0 +1,179 @@ +package sources + +import ( + "errors" + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + sourceDS "github.com/portainer/portainer/api/dataservices/source" + gittypes "github.com/portainer/portainer/api/git/types" + "github.com/portainer/portainer/api/gitops/workflows" + "github.com/portainer/portainer/api/http/security" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +type gitAuthInfo struct { + Username string `json:"username"` +} + +type connectionInfo struct { + TLSSkipVerify bool `json:"tlsSkipVerify"` + Authentication *gitAuthInfo `json:"authentication,omitempty"` +} + +type AutoUpdateInfo struct { + Mechanism string `json:"mechanism,omitempty"` + FetchInterval string `json:"fetchInterval,omitempty"` +} + +type SourceAccess struct { + Public bool `json:"public,omitempty"` + Users []portainer.UserID `json:"users,omitempty"` + Teams []portainer.TeamID `json:"teams,omitempty"` +} + +// SourceDetail extends Source with connection settings and linked workflows. +type SourceDetail struct { + Source + Connection connectionInfo `json:"connection" validate:"required"` + AutoUpdate *AutoUpdateInfo `json:"autoUpdate,omitempty"` + Workflows []workflows.Workflow `json:"workflows"` + Access SourceAccess `json:"access"` +} + +// @id GitOpsSourceGet +// @summary Get a GitOps source by ID +// @description Returns a single GitOps source with its connection settings and linked workflows. +// @description **Access policy**: authenticated +// @tags gitops +// @security ApiKeyAuth +// @security jwt +// @produce json +// @param id path int true "Source identifier" +// @success 200 {object} SourceDetail +// @failure 400 "Invalid request" +// @failure 403 "Access denied" +// @failure 404 "Source not found" +// @failure 500 "Server error" +// @router /gitops/sources/{id} [get] +func (h *Handler) getSource(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + srcID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid source identifier route variable", err) + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) + } + + sourceID := portainer.SourceID(srcID) + + var source *portainer.Source + var sourceWfs []workflows.Workflow + var stats workflows.SourceStats + + err = h.dataStore.ViewTx(func(tx dataservices.DataStoreTx) error { + var err error + userContext := sourceDS.NewUserContext(securityContext.User, securityContext.UserMemberships) + source, err = tx.Source().Read(userContext, sourceID) + if err != nil { + return err + } + + sourceWfs, stats, err = FetchSourceWorkflows(tx, source) + return err + }) + + if h.dataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Source not found", err) + } else if errors.Is(err, sourceDS.ErrNotEnoughPermission) { + return httperror.Forbidden("Not enough permissions to retrieve source", err) + } else if err != nil { + return httperror.InternalServerError("Unable to retrieve source", err) + } + + access := BuildSourceAccess(source) + + detail := BuildSourceDetail(h.buildSource(source, stats), source.Git, sourceWfs, access) + return response.JSON(w, detail) +} + +func BuildSourceDetail(baseSource Source, cfg *gittypes.GitSource, sourceWfs []workflows.Workflow, access SourceAccess) SourceDetail { + var autoUpdate *AutoUpdateInfo + if len(sourceWfs) > 0 { + autoUpdate = BuildAutoUpdateInfo(sourceWfs[0].AutoUpdate) + } + + return SourceDetail{ + Source: baseSource, + Connection: buildConnectionInfo(cfg), + AutoUpdate: autoUpdate, + Workflows: redactWorkflowCredentials(sourceWfs), + Access: access, + } +} + +func BuildSourceAccess(source *portainer.Source) SourceAccess { + if source == nil { + return SourceAccess{} + } + + if source.AdministratorsOnly { + return SourceAccess{} + } + + if source.Public { + return SourceAccess{ + Public: true, + } + } + + return SourceAccess{ + Public: source.Public, + Users: source.UserAccesses, + Teams: source.TeamAccesses, + } +} + +func buildConnectionInfo(cfg *gittypes.GitSource) connectionInfo { + if cfg == nil { + return connectionInfo{} + } + return connectionInfo{ + TLSSkipVerify: cfg.TLSSkipVerify, + Authentication: buildGitAuthInfo(cfg.Authentication), + } +} + +func buildGitAuthInfo(auth *gittypes.GitAuthentication) *gitAuthInfo { + if auth == nil { + return nil + } + return &gitAuthInfo{ + Username: auth.Username, + } +} + +func BuildAutoUpdateInfo(autoUpdate *portainer.AutoUpdateSettings) *AutoUpdateInfo { + if autoUpdate == nil { + return nil + } + + switch { + case autoUpdate.Interval != "": + return &AutoUpdateInfo{ + Mechanism: "Interval", + FetchInterval: autoUpdate.Interval, + } + case autoUpdate.Webhook != "": + return &AutoUpdateInfo{ + Mechanism: "Webhook", + } + default: + return nil + } +} diff --git a/api/http/handler/gitops/sources/get_test.go b/api/http/handler/gitops/sources/get_test.go new file mode 100644 index 0000000..b9900e0 --- /dev/null +++ b/api/http/handler/gitops/sources/get_test.go @@ -0,0 +1,117 @@ +package sources + +import ( + "net/http" + "net/http/httptest" + "strconv" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/datastore" + gittypes "github.com/portainer/portainer/api/git/types" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetSource_NotFound(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}) + })) + + h := newTestHandler(t, store) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildGetReq(t, 1, "999")) + assert.Equal(t, http.StatusNotFound, rr.Code) +} + +func TestGetSource_ReturnsDetail(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + cfg := &gittypes.GitSource{ + URL: "https://github.com/org/repo", + TLSSkipVerify: true, + } + + var srcID portainer.SourceID + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + stack := &portainer.Stack{ID: 1, Name: "my-stack"} + srcID = createGitWorkflow(t, tx, stack, cfg) + require.NoError(t, tx.Stack().Create(stack)) + return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}) + })) + + h := newTestHandler(t, store) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildGetReq(t, 1, strconv.Itoa(int(srcID)))) + + detail := decodeSourceDetail(t, rr) + assert.Equal(t, srcID, detail.ID) + assert.Equal(t, "repo", detail.Name) + assert.Equal(t, 1, detail.UsedBy) + assert.True(t, detail.Connection.TLSSkipVerify) + require.Len(t, detail.Workflows, 1) + assert.Equal(t, "my-stack", detail.Workflows[0].Name) +} + +func TestGetSource_RedactsCredentials(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + cfg := &gittypes.GitSource{ + URL: "https://github.com/org/secure", + Authentication: &gittypes.GitAuthentication{Username: "user", Password: "s3cr3t"}, + } + + var srcID portainer.SourceID + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + stack := &portainer.Stack{ID: 1, Name: "secure-stack"} + srcID = createGitWorkflow(t, tx, stack, cfg) + require.NoError(t, tx.Stack().Create(stack)) + return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}) + })) + + h := newTestHandler(t, store) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildGetReq(t, 1, strconv.Itoa(int(srcID)))) + + detail := decodeSourceDetail(t, rr) + require.Len(t, detail.Workflows, 1) + require.NotNil(t, detail.Workflows[0].GitConfig) + require.NotNil(t, detail.Workflows[0].GitConfig.Authentication) + assert.Equal(t, "user", detail.Workflows[0].GitConfig.Authentication.Username) + assert.Empty(t, detail.Workflows[0].GitConfig.Authentication.Password) +} + +func TestGetSource_AutoUpdate(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + cfg := gitCfg("https://github.com/org/polled") + + var srcID portainer.SourceID + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + stack := &portainer.Stack{ + ID: 1, + Name: "polled-stack", + AutoUpdate: &portainer.AutoUpdateSettings{Interval: "5m"}, + } + srcID = createGitWorkflow(t, tx, stack, cfg) + require.NoError(t, tx.Stack().Create(stack)) + return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}) + })) + + h := newTestHandler(t, store) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildGetReq(t, 1, strconv.Itoa(int(srcID)))) + + detail := decodeSourceDetail(t, rr) + require.NotNil(t, detail.AutoUpdate) + assert.Equal(t, "Interval", detail.AutoUpdate.Mechanism) + assert.Equal(t, "5m", detail.AutoUpdate.FetchInterval) +} diff --git a/api/http/handler/gitops/sources/handler.go b/api/http/handler/gitops/sources/handler.go new file mode 100644 index 0000000..cefcfdd --- /dev/null +++ b/api/http/handler/gitops/sources/handler.go @@ -0,0 +1,62 @@ +package sources + +import ( + "net/http" + "time" + + gocache "github.com/patrickmn/go-cache" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/kubernetes/cli" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + + "github.com/gorilla/mux" +) + +const ( + cacheTTL = 30 * time.Second + cacheCleanupInterval = 10 * time.Minute +) + +// Handler is the HTTP handler for the GitOps sources API. +type Handler struct { + *mux.Router + dataStore dataservices.DataStore + gitService portainer.GitService + cache *gocache.Cache + k8sFactory *cli.ClientFactory +} + +func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStore, gitService portainer.GitService, k8sFactory *cli.ClientFactory) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + dataStore: dataStore, + gitService: gitService, + cache: gocache.New(cacheTTL, cacheCleanupInterval), + k8sFactory: k8sFactory, + } + + authenticatedRouter := h.PathPrefix("/gitops/sources").Subrouter() + authenticatedRouter.Use(bouncer.AuthenticatedAccess) + authenticatedRouter.Handle("", httperror.LoggerHandler(h.list)).Methods(http.MethodGet) + authenticatedRouter.Handle("/summary", httperror.LoggerHandler(h.summary)).Methods(http.MethodGet) + authenticatedRouter.Handle("/{id}", httperror.LoggerHandler(h.getSource)).Methods(http.MethodGet) + authenticatedRouter.Handle("/git", httperror.LoggerHandler(h.gitSourceCreate)).Methods(http.MethodPost) + authenticatedRouter.Handle("/test", httperror.LoggerHandler(h.gitSourceTest)).Methods(http.MethodPost) + authenticatedRouter.Handle("/{id}", httperror.LoggerHandler(h.gitSourceUpdate)).Methods(http.MethodPut) + authenticatedRouter.Handle("/{id}", httperror.LoggerHandler(h.sourceDelete)).Methods(http.MethodDelete) + authenticatedRouter.Handle("/{id}/test", httperror.LoggerHandler(h.sourceTestConnection)).Methods(http.MethodPost) + + adminRouter := h.PathPrefix("/gitops/sources").Subrouter() + adminRouter.Use(bouncer.AdminAccess) + adminRouter.Handle("/{id}/access", httperror.LoggerHandler(h.gitSourceUpdateAccess)).Methods(http.MethodPut) + + return h +} + +// invalidateCache clears the cached source lists so the next read reflects +// the latest datastore state. Called after any mutating operation. +func (h *Handler) invalidateCache() { + h.cache.Flush() +} diff --git a/api/http/handler/gitops/sources/helpers_test.go b/api/http/handler/gitops/sources/helpers_test.go new file mode 100644 index 0000000..c724f92 --- /dev/null +++ b/api/http/handler/gitops/sources/helpers_test.go @@ -0,0 +1,150 @@ +package sources + +import ( + "bytes" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/dataservices/source" + gittypes "github.com/portainer/portainer/api/git/types" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/testhelpers" + + gocache "github.com/patrickmn/go-cache" + "github.com/segmentio/encoding/json" + "github.com/stretchr/testify/require" +) + +var adminUserContext = source.InsecureNewAdminContext() + +// createGitWorkflow creates a Source and Workflow for the given config and +// wires them up by setting stack.WorkflowID before creating the stack. +func createGitWorkflow(t *testing.T, tx dataservices.DataStoreTx, stack *portainer.Stack, cfg *gittypes.GitSource) portainer.SourceID { + t.Helper() + + src := &portainer.Source{ + Name: gittypes.RepoName(cfg.URL), + Type: portainer.SourceTypeGit, + Git: cfg, + } + require.NoError(t, tx.Source().Create(adminUserContext, src)) + + wf := &portainer.Workflow{ + Artifacts: []portainer.Artifact{{ + StackID: stack.ID, + Files: []portainer.ArtifactFile{{SourceID: src.ID}}, + }}, + } + require.NoError(t, tx.Workflow().Create(wf)) + + stack.WorkflowID = wf.ID + + return src.ID +} + +func newTestHandler(t *testing.T, store dataservices.DataStore) *Handler { + t.Helper() + return NewHandler(testhelpers.NewTestRequestBouncer(), store, nil, nil) +} + +// newTestHandlerNoCacheExpiry returns a handler whose source cache never expires, +// so cache-invalidation tests can prove a write clears the cache rather than the +// TTL simply elapsing between requests. +func newTestHandlerNoCacheExpiry(t *testing.T, store dataservices.DataStore) *Handler { + t.Helper() + h := newTestHandler(t, store) + h.cache = gocache.New(gocache.NoExpiration, cacheCleanupInterval) + return h +} + +func adminRestrictedContext(userID portainer.UserID) *security.RestrictedRequestContext { + return &security.RestrictedRequestContext{ + UserID: userID, + IsAdmin: true, + User: &portainer.User{ID: userID, Role: portainer.AdministratorRole}, + } +} + +// withSecurityContext attaches the admin token data and restricted request +// context for userID to req, mirroring what the auth middleware sets up in +// production. +func withSecurityContext(req *http.Request, userID portainer.UserID) *http.Request { + req = req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ID: userID})) + req = req.WithContext(security.StoreRestrictedRequestContext(req, adminRestrictedContext(userID))) + return req +} + +func buildListReq(t *testing.T, userID portainer.UserID, query string) *http.Request { + t.Helper() + req := httptest.NewRequest(http.MethodGet, "/gitops/sources?"+query, nil) + return withSecurityContext(req, userID) +} + +func buildGetReq(t *testing.T, userID portainer.UserID, id string) *http.Request { + t.Helper() + req := httptest.NewRequest(http.MethodGet, "/gitops/sources/"+id, nil) + return withSecurityContext(req, userID) +} + +func decodeSources(t *testing.T, rr *httptest.ResponseRecorder) []Source { + t.Helper() + require.Equal(t, http.StatusOK, rr.Code, "unexpected status: %s", rr.Body.String()) + var items []Source + require.NoError(t, json.NewDecoder(rr.Body).Decode(&items)) + return items +} + +func decodeSourceDetail(t *testing.T, rr *httptest.ResponseRecorder) SourceDetail { + t.Helper() + require.Equal(t, http.StatusOK, rr.Code, "unexpected status: %s", rr.Body.String()) + var item SourceDetail + require.NoError(t, json.NewDecoder(rr.Body).Decode(&item)) + return item +} + +func gitCfg(url string) *gittypes.GitSource { + return &gittypes.GitSource{URL: url} +} + +func buildCreateReq(t *testing.T, userID portainer.UserID, body []byte) *http.Request { + t.Helper() + req := httptest.NewRequest(http.MethodPost, "/gitops/sources/git", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + return withSecurityContext(req, userID) +} + +func buildUpdateReq(t *testing.T, userID portainer.UserID, id int, body []byte) *http.Request { + t.Helper() + req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/gitops/sources/%d", id), bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + return withSecurityContext(req, userID) +} + +func buildDeleteReq(t *testing.T, userID portainer.UserID, id int) *http.Request { + t.Helper() + req := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/gitops/sources/%d", id), nil) + return withSecurityContext(req, userID) +} + +func buildSummaryReq(t *testing.T, userID portainer.UserID) *http.Request { + t.Helper() + req := httptest.NewRequest(http.MethodGet, "/gitops/sources/summary", nil) + return withSecurityContext(req, userID) +} + +func buildUpdateReqWithRawID(t *testing.T, userID portainer.UserID, id string, body []byte) *http.Request { + t.Helper() + req := httptest.NewRequest(http.MethodPut, "/gitops/sources/"+id, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + return withSecurityContext(req, userID) +} + +func buildDeleteReqWithRawID(t *testing.T, userID portainer.UserID, id string) *http.Request { + t.Helper() + req := httptest.NewRequest(http.MethodDelete, "/gitops/sources/"+id, nil) + return withSecurityContext(req, userID) +} diff --git a/api/http/handler/gitops/sources/list.go b/api/http/handler/gitops/sources/list.go new file mode 100644 index 0000000..8a75dd3 --- /dev/null +++ b/api/http/handler/gitops/sources/list.go @@ -0,0 +1,135 @@ +package sources + +import ( + "context" + "net/http" + "slices" + "strconv" + "strings" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/gitops/workflows" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/http/utils/filters" + "github.com/portainer/portainer/api/slicesx" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" + + gocache "github.com/patrickmn/go-cache" +) + +// @id GitOpsSourcesList +// @summary List all GitOps sources +// @description Returns a deduplicated list of git repositories used across all GitOps workflows. +// @description **Access policy**: authenticated +// @tags gitops +// @security ApiKeyAuth +// @security jwt +// @produce json +// @param search query string false "Search term (matches URL)" +// @param sort query string false "Sort field: name | status | type" +// @param order query string false "Sort order: asc or desc" +// @param start query int false "Pagination start index" +// @param limit query int false "Pagination limit (0 = unlimited)" +// @param status query string false "Filter by status: healthy | syncing | error | paused | unknown" +// @param type query SourceType false "Filter by source type: git | oci | helm" +// @success 200 {array} Source +// @failure 400 "Invalid status parameter" +// @failure 403 "Access denied" +// @failure 500 "Server error" +// @router /gitops/sources [get] +func (h *Handler) list(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + params := filters.ExtractListModifiersQueryParams(r) + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) + } + + key := cacheKey(securityContext) + + sources, err := h.getSources(r.Context(), key, securityContext) + if err != nil { + return httperror.InternalServerError("Unable to retrieve sources", err) + } + + if status, _ := request.RetrieveQueryParameter(r, "status", true); status != "" { + s, err := workflows.ParseStatus(status) + if err != nil { + return httperror.BadRequest("Invalid status parameter", err) + } + sources = slicesx.FilterInPlace(sources, func(i Source) bool { return i.Status == s }) + } + + if sourceType, _ := request.RetrieveQueryParameter(r, "type", true); sourceType != "" { + t, err := parseSourceType(sourceType) + if err != nil { + return httperror.BadRequest("Invalid type parameter", err) + } + sources = slicesx.FilterInPlace(sources, func(i Source) bool { return i.Type == t }) + } + + results := filters.SearchOrderAndPaginate(sources, params, filters.Config[Source]{ + SearchAccessors: []filters.SearchAccessor[Source]{ + func(s Source) (string, error) { return s.URL, nil }, + }, + SortBindings: []filters.SortBinding[Source]{ + {Key: "name", Fn: func(a, b Source) int { return strings.Compare(a.Name, b.Name) }}, + {Key: "status", Fn: func(a, b Source) int { return strings.Compare(string(a.Status), string(b.Status)) }}, + {Key: "type", Fn: func(a, b Source) int { return strings.Compare(string(a.Type), string(b.Type)) }}, + }, + }) + + filters.ApplyFilterResultsHeaders(&w, results) + return response.JSON(w, results.Items) +} + +func (h *Handler) getSources(ctx context.Context, key string, sc *security.RestrictedRequestContext) ([]Source, error) { + if cached, ok := h.cache.Get(key); ok { + return slices.Clone(cached.([]Source)), nil + } + + result, err := h.fetchSources(ctx, sc) + if err != nil { + return nil, err + } + h.cache.Set(key, result, gocache.DefaultExpiration) + return slices.Clone(result), nil +} + +func cacheKey(sc *security.RestrictedRequestContext) string { + teamIDs := make([]string, len(sc.UserMemberships)) + for i, membership := range sc.UserMemberships { + teamIDs[i] = strconv.Itoa(int(membership.TeamID)) + } + slices.Sort(teamIDs) + + return strconv.Itoa(int(sc.UserID)) + ":" + strconv.FormatBool(sc.IsAdmin) + ":" + strings.Join(teamIDs, ",") +} + +func (h *Handler) fetchSources(ctx context.Context, sc *security.RestrictedRequestContext) ([]Source, error) { + var allSrcs []portainer.Source + var stats map[portainer.SourceID]workflows.SourceStats + + if err := h.dataStore.ViewTx(func(tx dataservices.DataStoreTx) error { + var err error + allSrcs, stats, err = workflows.FetchSourceStats(tx, h.k8sFactory, sc) + return err + }); err != nil { + return nil, err + } + + result := make([]Source, 0, len(allSrcs)) + for _, src := range allSrcs { + stat, ok := stats[src.ID] + if !ok { + stat = workflows.SourceStats{} + } + + result = append(result, h.buildSource(&src, stat)) + } + + return result, nil +} diff --git a/api/http/handler/gitops/sources/list_test.go b/api/http/handler/gitops/sources/list_test.go new file mode 100644 index 0000000..2d1bff4 --- /dev/null +++ b/api/http/handler/gitops/sources/list_test.go @@ -0,0 +1,127 @@ +package sources + +import ( + "net/http/httptest" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/datastore" + gittypes "github.com/portainer/portainer/api/git/types" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSourcesList_GroupsByURLAndCredentials(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + cfg := gitCfg("https://github.com/org/repo") + src := &portainer.Source{Name: "repo", Type: portainer.SourceTypeGit, Git: cfg} + require.NoError(t, tx.Source().Create(adminUserContext, src)) + + wfA := &portainer.Workflow{Artifacts: []portainer.Artifact{{Files: []portainer.ArtifactFile{{SourceID: src.ID}}}}} + require.NoError(t, tx.Workflow().Create(wfA)) + + wfB := &portainer.Workflow{Artifacts: []portainer.Artifact{{Files: []portainer.ArtifactFile{{SourceID: src.ID}}}}} + require.NoError(t, tx.Workflow().Create(wfB)) + + require.NoError(t, tx.Stack().Create(&portainer.Stack{ID: 1, Name: "stack-a", WorkflowID: wfA.ID})) + require.NoError(t, tx.Stack().Create(&portainer.Stack{ID: 2, Name: "stack-b", WorkflowID: wfB.ID})) + + return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}) + })) + + h := newTestHandler(t, store) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildListReq(t, 1, "")) + + sources := decodeSources(t, rr) + require.Len(t, sources, 1) + assert.Equal(t, 2, sources[0].UsedBy) +} + +func TestSourcesList_SeparatesCredentials(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + cfg1 := gitCfg("https://github.com/org/repo") + cfg1.Authentication = &gittypes.GitAuthentication{Username: "alice", Password: "pass1"} + cfg2 := gitCfg("https://github.com/org/repo") + cfg2.Authentication = &gittypes.GitAuthentication{Username: "bob", Password: "pass2"} + + stackA := &portainer.Stack{ID: 1, Name: "stack-a"} + createGitWorkflow(t, tx, stackA, cfg1) + require.NoError(t, tx.Stack().Create(stackA)) + + stackB := &portainer.Stack{ID: 2, Name: "stack-b"} + createGitWorkflow(t, tx, stackB, cfg2) + require.NoError(t, tx.Stack().Create(stackB)) + + return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}) + })) + + h := newTestHandler(t, store) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildListReq(t, 1, "")) + + sources := decodeSources(t, rr) + assert.Len(t, sources, 2) +} + +func TestSourcesList_StatusFilter(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + // With nil gitService, source git-phase status is always StatusUnknown. + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + stack := &portainer.Stack{ID: 1} + createGitWorkflow(t, tx, stack, gitCfg("https://github.com/org/app")) + require.NoError(t, tx.Stack().Create(stack)) + return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}) + })) + + h := newTestHandler(t, store) + + t.Run("status=unknown matches sources with unknown status", func(t *testing.T) { + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildListReq(t, 1, "status=unknown")) + sources := decodeSources(t, rr) + assert.Len(t, sources, 1) + }) + + t.Run("status=healthy excludes sources with unknown status", func(t *testing.T) { + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildListReq(t, 1, "status=healthy")) + sources := decodeSources(t, rr) + assert.Empty(t, sources) + }) +} + +func TestSourcesList_SearchByURL(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + stackA := &portainer.Stack{ID: 1} + createGitWorkflow(t, tx, stackA, gitCfg("https://github.com/org/app")) + require.NoError(t, tx.Stack().Create(stackA)) + + stackB := &portainer.Stack{ID: 2} + createGitWorkflow(t, tx, stackB, gitCfg("https://github.com/org/infra")) + require.NoError(t, tx.Stack().Create(stackB)) + + return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}) + })) + + h := newTestHandler(t, store) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildListReq(t, 1, "search=app")) + + sources := decodeSources(t, rr) + require.Len(t, sources, 1) + assert.Equal(t, "app", sources[0].Name) +} diff --git a/api/http/handler/gitops/sources/source_connection.go b/api/http/handler/gitops/sources/source_connection.go new file mode 100644 index 0000000..b9ec547 --- /dev/null +++ b/api/http/handler/gitops/sources/source_connection.go @@ -0,0 +1,162 @@ +package sources + +import ( + "context" + "errors" + "io" + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/dataservices/source" + gittypes "github.com/portainer/portainer/api/git/types" + "github.com/portainer/portainer/api/gitops/workflows" + "github.com/portainer/portainer/api/http/security" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +// @id GitOpsSourcesTestById +// @summary Test the connection of a stored source +// @description Tests connectivity for a GitOps source, applying optional overrides to the stored configuration. +// @description **Access policy**: authenticated +// @tags gitops +// @security ApiKeyAuth +// @security jwt +// @accept json +// @produce json +// @param id path int true "Source identifier" +// @param body body GitSourceUpdatePayload false "Optional connection overrides; omitted fields fall back to stored values" +// @success 200 {object} ConnectionTestResult "Connection test result" +// @failure 400 "Invalid request payload" +// @failure 403 "Access denied" +// @failure 404 "Source not found" +// @failure 500 "Server error" +// @router /gitops/sources/{id}/test [post] +func (h *Handler) sourceTestConnection(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + sourceID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid source identifier route variable", err) + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) + } + + var payload GitSourceUpdatePayload + if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil && !errors.Is(err, io.EOF) { + return httperror.BadRequest("Invalid request payload", err) + } + + var src *portainer.Source + if err := h.dataStore.ViewTx(func(tx dataservices.DataStoreTx) error { + userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships) + src, err = tx.Source().Read(userContext, portainer.SourceID(sourceID)) + return err + }); h.dataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find a source with the specified identifier", err) + } else if errors.Is(err, source.ErrNotEnoughPermission) { + return httperror.Forbidden("Not enough permissions to retrieve source", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find source", err) + } + + if err := ApplyGitSourceChanges(src, payload); errors.Is(err, ErrNotGitSource) { + return httperror.BadRequest("Source is not a Git source", err) + } else if err != nil { + return httperror.InternalServerError("Unable to apply source changes", err) + } + + if src.Git == nil { + return httperror.InternalServerError("Source has no git configuration", nil) + } + + result := testSourceConnection(r.Context(), h.gitService, src.Git) + + return response.JSON(w, result) +} + +type ConnectionTestResult struct { + Success bool `json:"success"` + Error string `json:"error,omitempty"` +} + +// @id GitOpsSourcesTest +// @summary Test a Git source connection +// @description Tests connectivity for Git connection details that have not been persisted yet. +// @description **Access policy**: authenticated +// @tags gitops +// @security ApiKeyAuth +// @security jwt +// @accept json +// @produce json +// @param body body GitSourceCreatePayload true "Git connection details" +// @success 200 {object} ConnectionTestResult "Connection test result" +// @failure 400 "Invalid request payload" +// @failure 403 "Access denied" +// @failure 500 "Server error" +// @router /gitops/sources/test [post] +func (h *Handler) gitSourceTest(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload GitSourceCreatePayload + if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + src, err := BuildGitSource(payload) + if err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + if src.Git == nil { + return httperror.InternalServerError("Source has no git configuration", nil) + } + + result := testSourceConnection(r.Context(), h.gitService, src.Git) + + return response.JSON(w, result) +} + +// testSourceConnection verifies that a git repository is reachable with the given config. +func testSourceConnection(ctx context.Context, gitService portainer.GitService, config *gittypes.GitSource) ConnectionTestResult { + var username, password string + if config.Authentication != nil { + username = config.Authentication.Username + password = config.Authentication.Password + } + + _, err := gitService.ListRefs(ctx, config.URL, username, password, false, config.TLSSkipVerify) + if err != nil { + return ConnectionTestResult{Success: false, Error: err.Error()} + } + + return ConnectionTestResult{Success: true} +} +func (h *Handler) testAndSaveSourceConnection(ctx context.Context, userContext source.UserContext, src *portainer.Source) (*portainer.Source, error) { + if h.gitService == nil || src.Git == nil { + return src, nil + } + + result := testSourceConnection(ctx, h.gitService, src.Git) + + var checkErr error + if !result.Success { + checkErr = errors.New(result.Error) + } + + var updated *portainer.Source + if err := h.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + if err := workflows.SaveSourceStatus(tx, userContext, src.ID, checkErr); err != nil { + return err + } + + var readErr error + updated, readErr = tx.Source().Read(userContext, src.ID) + + return readErr + }); err != nil { + return nil, err + } + + return updated, nil +} diff --git a/api/http/handler/gitops/sources/summary.go b/api/http/handler/gitops/sources/summary.go new file mode 100644 index 0000000..c9b00db --- /dev/null +++ b/api/http/handler/gitops/sources/summary.go @@ -0,0 +1,54 @@ +package sources + +import ( + "net/http" + + ce "github.com/portainer/portainer/api/gitops/workflows" + "github.com/portainer/portainer/api/http/security" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +// @id GitOpsSourcesSummary +// @summary Summarize GitOps source status counts +// @description Returns a count of sources per status. +// @description **Access policy**: authenticated +// @tags gitops +// @security ApiKeyAuth +// @security jwt +// @produce json +// @success 200 {object} ce.StatusSummary +// @failure 403 "Access denied" +// @failure 500 "Server error" +// @router /gitops/sources/summary [get] +func (h *Handler) summary(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) + } + + key := cacheKey(securityContext) + + sources, err := h.getSources(r.Context(), key, securityContext) + if err != nil { + return httperror.InternalServerError("Unable to retrieve sources", err) + } + + summary := ce.StatusSummary{} + for _, s := range sources { + switch s.Status { + case ce.StatusHealthy: + summary.Healthy++ + case ce.StatusSyncing: + summary.Syncing++ + case ce.StatusError: + summary.Error++ + case ce.StatusPaused: + summary.Paused++ + default: + summary.Unknown++ + } + } + + return response.JSON(w, summary) +} diff --git a/api/http/handler/gitops/sources/summary_test.go b/api/http/handler/gitops/sources/summary_test.go new file mode 100644 index 0000000..d643375 --- /dev/null +++ b/api/http/handler/gitops/sources/summary_test.go @@ -0,0 +1,66 @@ +package sources + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/datastore" + gittypes "github.com/portainer/portainer/api/git/types" + ceWorkflows "github.com/portainer/portainer/api/gitops/workflows" + + "github.com/segmentio/encoding/json" + "github.com/stretchr/testify/require" +) + +func TestSourcesSummary_Empty(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}) + })) + + h := newTestHandler(t, store) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildSummaryReq(t, 1)) + + require.Equal(t, http.StatusOK, rr.Code) + + var summary ceWorkflows.StatusSummary + err := json.NewDecoder(rr.Body).Decode(&summary) + require.NoError(t, err) + require.Equal(t, ceWorkflows.StatusSummary{}, summary) +} + +func TestSourcesSummary_CountsByStatus(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + for idx, name := range []string{"source-a", "source-b", "source-c"} { + err := tx.Source().Create(adminUserContext, &portainer.Source{Name: name, Type: portainer.SourceTypeGit, Git: &gittypes.GitSource{URL: fmt.Sprintf("http://github.com/org/repo%d", idx)}}) + require.NoError(t, err) + } + + return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}) + })) + + h := newTestHandler(t, store) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildSummaryReq(t, 1)) + + require.Equal(t, http.StatusOK, rr.Code) + + var summary ceWorkflows.StatusSummary + err := json.NewDecoder(rr.Body).Decode(&summary) + require.NoError(t, err) + require.Equal(t, 3, summary.Unknown) + require.Zero(t, summary.Healthy) + require.Zero(t, summary.Error) + require.Zero(t, summary.Syncing) + require.Zero(t, summary.Paused) +} diff --git a/api/http/handler/gitops/sources/types.go b/api/http/handler/gitops/sources/types.go new file mode 100644 index 0000000..ec401bf --- /dev/null +++ b/api/http/handler/gitops/sources/types.go @@ -0,0 +1,51 @@ +package sources + +import ( + "fmt" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/gitops/workflows" +) + +// Source represents a unique git repository used as a GitOps source across one or more workflows. +type Source struct { + ID portainer.SourceID `json:"id" validate:"required"` + Name string `json:"name" validate:"required"` + Type SourceType `json:"type" validate:"required"` + URL string `json:"url" validate:"required"` + Status workflows.Status `json:"status" validate:"required"` + Error string `json:"error,omitempty"` + UsedBy int `json:"usedBy"` + Environments int `json:"environments"` + LastSync int64 `json:"lastSync"` +} + +type SourceType string + +const ( + SourceTypeGit SourceType = "git" + SourceTypeHelm SourceType = "helm" + SourceTypeOCI SourceType = "oci" +) + +func parseSourceType(s string) (SourceType, error) { + switch SourceType(s) { + case SourceTypeGit, SourceTypeHelm, SourceTypeOCI: + return SourceType(s), nil + default: + return "", fmt.Errorf("invalid source type %q: must be git, helm, or oci", s) + } +} + +func sourceTypeString(t portainer.SourceType) SourceType { + switch t { + case portainer.SourceTypeGit: + return SourceTypeGit + case portainer.SourceTypeHelm: + return SourceTypeHelm + case portainer.SourceTypeRegistry: + return SourceTypeOCI + default: + return SourceTypeGit + } +} diff --git a/api/http/handler/gitops/sources/update_access.go b/api/http/handler/gitops/sources/update_access.go new file mode 100644 index 0000000..cf59a73 --- /dev/null +++ b/api/http/handler/gitops/sources/update_access.go @@ -0,0 +1,103 @@ +package sources + +import ( + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/dataservices/source" + "github.com/portainer/portainer/api/http/security" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +type SourceAccessUpdatePayload struct { + Public bool `json:"public"` + Users []portainer.UserID `json:"users,omitempty"` + Teams []portainer.TeamID `json:"teams,omitempty"` +} + +// @id GitOpsSourcesUpdateAccess +// @summary Update a GitOps source's access control +// @description Updates the access control settings for an existing GitOps source. +// @description **Access policy**: administrator +// @tags gitops +// @security ApiKeyAuth +// @security jwt +// @accept json +// @produce json +// @param id path int true "Source identifier" +// @param body body SourceAccessUpdatePayload true "Source access control" +// @success 200 {object} portainer.Source +// @failure 400 "Invalid request payload" +// @failure 403 "Access denied" +// @failure 404 "Source not found" +// @failure 500 "Server error" +// @router /gitops/sources/{id}/access [put] +func (h *Handler) gitSourceUpdateAccess(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + id, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid source identifier route variable", err) + } + + var payload SourceAccessUpdatePayload + + if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) + } + + sourceID := portainer.SourceID(id) + + var src *portainer.Source + + if err := h.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + var err error + + userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships) + if src, err = tx.Source().Read(userContext, sourceID); err != nil { + return err + } + + ApplySourceAccessChanges(src, payload) + + return tx.Source().Update(userContext, src.ID, src) + }); h.dataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find a source with the specified identifier", err) + } else if err != nil { + return httperror.InternalServerError("Unable to update source access", err) + } + + h.invalidateCache() + + return response.JSON(w, src) +} + +// Validate implements the portainer.Validatable interface +func (payload *SourceAccessUpdatePayload) Validate(_ *http.Request) error { + return nil +} + +// ApplySourceAccessChanges applies the payload access changes to the source in place. +func ApplySourceAccessChanges(src *portainer.Source, payload SourceAccessUpdatePayload) { + src.Public = payload.Public + + if payload.Public { + src.AdministratorsOnly = false + src.UserAccesses = []portainer.UserID{} + src.TeamAccesses = []portainer.TeamID{} + } else if len(payload.Users) == 0 && len(payload.Teams) == 0 { + src.AdministratorsOnly = true + src.UserAccesses = []portainer.UserID{} + src.TeamAccesses = []portainer.TeamID{} + } else { + src.AdministratorsOnly = false + src.UserAccesses = payload.Users + src.TeamAccesses = payload.Teams + } +} diff --git a/api/http/handler/gitops/sources/update_git.go b/api/http/handler/gitops/sources/update_git.go new file mode 100644 index 0000000..106a730 --- /dev/null +++ b/api/http/handler/gitops/sources/update_git.go @@ -0,0 +1,187 @@ +package sources + +import ( + "errors" + "net/http" + "strings" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/dataservices/source" + gittypes "github.com/portainer/portainer/api/git/types" + "github.com/portainer/portainer/api/http/security" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" + "github.com/portainer/portainer/pkg/validate" +) + +var ( + ErrNotGitSource = errors.New("source is not a Git source") +) + +// GitSourceUpdatePayload holds the parameters for updating a git-backed source +type GitSourceUpdatePayload struct { + Name *string `json:"name"` + URL *string `json:"url"` + TLSSkipVerify *bool `json:"tlsSkipVerify"` + Authentication *GitAuthenticationUpdatePayload `json:"authentication"` +} + +type GitAuthenticationUpdatePayload struct { + Username *string `json:"username"` + Password *string `json:"password"` +} + +// Validate implements the portainer.Validatable interface +func (payload *GitSourceUpdatePayload) Validate(_ *http.Request) error { + if payload.URL != nil && !validate.IsURL(*payload.URL) { + return errors.New("invalid repository URL. Must correspond to a valid URL format") + } + + return nil +} + +// @id GitOpsSourcesUpdateGit +// @summary Update a Git source +// @description Updates an existing GitOps source backed by a Git repository. +// @description **Access policy**: authenticated +// @tags gitops +// @security ApiKeyAuth +// @security jwt +// @accept json +// @produce json +// @param id path int true "Source identifier" +// @param body body GitSourceUpdatePayload true "Git source details" +// @success 200 {object} portainer.Source +// @failure 400 "Invalid request payload" +// @failure 403 "Access denied" +// @failure 404 "Source not found" +// @failure 409 "A source with this URL and credentials already exists" +// @failure 500 "Server error" +// @router /gitops/sources/{id} [put] +func (h *Handler) gitSourceUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + id, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid source identifier route variable", err) + } + + var payload GitSourceUpdatePayload + + if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) + } + + sourceID := portainer.SourceID(id) + userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships) + + var src *portainer.Source + + if err := h.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + var err error + + if src, err = tx.Source().Read(userContext, sourceID); err != nil { + return err + } + + if err := ApplyGitSourceChanges(src, payload); err != nil { + return err + } + + src.Status = portainer.SourceStatusUnknown + src.StatusError = "" + + return tx.Source().Update(userContext, src.ID, src) + }); h.dataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find a source with the specified identifier", err) + } else if errors.Is(err, ErrNotGitSource) { + return httperror.BadRequest("Source is not a Git source", err) + } else if errors.Is(err, source.ErrNotEnoughPermission) { + return httperror.Forbidden("Not enough permissions to update source", err) + } else if errors.Is(err, source.ErrDuplicateSource) { + return httperror.Conflict("A source with this URL and credentials already exists", err) + } else if err != nil { + return httperror.InternalServerError("Unable to update source", err) + } + + if src, err = h.testAndSaveSourceConnection(r.Context(), userContext, src); err != nil { + return httperror.InternalServerError("Unable to persist source status", err) + } + + h.invalidateCache() + + src.Git = gittypes.SanitizeGitSource(src.Git) + + return response.JSON(w, src) +} + +// ApplyGitSourceChanges applies the payload changes to the source in place +func ApplyGitSourceChanges(src *portainer.Source, payload GitSourceUpdatePayload) error { + if err := ApplyBaseGitSourceChanges(src, payload); err != nil { + return err + } + + if payload.Authentication == nil { + return nil + } + + if *payload.Authentication == (GitAuthenticationUpdatePayload{}) { + src.Git.Authentication = nil + return nil + } + + src.Git.Authentication = ApplyAuthChanges(src.Git.Authentication, *payload.Authentication) + + return nil +} + +// ApplyBaseGitSourceChanges applies the non-authentication field changes (name, +// URL, reference, TLS) to the source in place, ensuring src.Git is set +func ApplyBaseGitSourceChanges(src *portainer.Source, payload GitSourceUpdatePayload) error { + if src.Type != portainer.SourceTypeGit { + return ErrNotGitSource + } + + if payload.Name != nil && strings.TrimSpace(*payload.Name) != "" { + src.Name = *payload.Name + } + + if src.Git == nil { + src.Git = &gittypes.GitSource{} + } + + if payload.URL != nil { + src.Git.URL = *payload.URL + } + + if payload.TLSSkipVerify != nil { + src.Git.TLSSkipVerify = *payload.TLSSkipVerify + } + + return nil +} + +// ApplyAuthChanges returns a copy of the existing authentication (or a fresh +// one) with the basic credential changes applied. +func ApplyAuthChanges(existing *gittypes.GitAuthentication, payload GitAuthenticationUpdatePayload) *gittypes.GitAuthentication { + auth := &gittypes.GitAuthentication{} + if existing != nil { + copied := *existing + auth = &copied + } + + if payload.Username != nil { + auth.Username = *payload.Username + } + + if payload.Password != nil { + auth.Password = *payload.Password + } + + return auth +} diff --git a/api/http/handler/gitops/sources/update_git_test.go b/api/http/handler/gitops/sources/update_git_test.go new file mode 100644 index 0000000..e22cadc --- /dev/null +++ b/api/http/handler/gitops/sources/update_git_test.go @@ -0,0 +1,347 @@ +package sources + +import ( + "net/http" + "net/http/httptest" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/datastore" + gittypes "github.com/portainer/portainer/api/git/types" + + "github.com/segmentio/encoding/json" + "github.com/stretchr/testify/require" +) + +func TestGitSourceUpdate_Success(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + var srcID portainer.SourceID + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + src := &portainer.Source{Name: "old-name", Type: portainer.SourceTypeGit, Git: &gittypes.GitSource{URL: "http://github.com/org/repo"}} + err := tx.Source().Create(adminUserContext, src) + require.NoError(t, err) + srcID = src.ID + + return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}) + })) + + h := newTestHandler(t, store) + + body, err := json.Marshal(GitSourceUpdatePayload{ + URL: new("https://github.com/org/new.git"), + Name: new("new-name"), + }) + require.NoError(t, err) + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildUpdateReq(t, 1, int(srcID), body)) + + require.Equal(t, http.StatusOK, rr.Code) + + var src portainer.Source + err = json.NewDecoder(rr.Body).Decode(&src) + require.NoError(t, err) + require.Equal(t, "new-name", src.Name) + require.NotNil(t, src.Git) + require.Equal(t, "https://github.com/org/new", src.Git.URL) +} + +func TestGitSourceUpdate_PreservesAuthWhenNotProvided(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + var srcID portainer.SourceID + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + src := &portainer.Source{ + Name: "auth-source", + Type: portainer.SourceTypeGit, + Git: &gittypes.GitSource{ + URL: "https://github.com/org/repo.git", + Authentication: &gittypes.GitAuthentication{ + Username: "alice", + Password: "secret", + }, + }, + } + err := tx.Source().Create(adminUserContext, src) + require.NoError(t, err) + srcID = src.ID + + return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}) + })) + + h := newTestHandler(t, store) + + body, err := json.Marshal(GitSourceUpdatePayload{ + URL: new("https://github.com/org/repo.git"), + Name: new("renamed"), + }) + require.NoError(t, err) + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildUpdateReq(t, 1, int(srcID), body)) + + require.Equal(t, http.StatusOK, rr.Code) + + var stored *portainer.Source + require.NoError(t, store.ViewTx(func(tx dataservices.DataStoreTx) error { + var err error + stored, err = tx.Source().Read(adminUserContext, srcID) + return err + })) + require.NotNil(t, stored.Git) + require.NotNil(t, stored.Git.Authentication) + require.Equal(t, "alice", stored.Git.Authentication.Username) + require.Equal(t, "secret", stored.Git.Authentication.Password) +} + +func TestGitSourceUpdate_ClearsAuthWhenRequested(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + var srcID portainer.SourceID + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + src := &portainer.Source{ + Name: "auth-source", + Type: portainer.SourceTypeGit, + Git: &gittypes.GitSource{ + URL: "https://github.com/org/repo.git", + Authentication: &gittypes.GitAuthentication{ + Username: "alice", + Password: "secret", + }, + }, + } + err := tx.Source().Create(adminUserContext, src) + require.NoError(t, err) + srcID = src.ID + + return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}) + })) + + h := newTestHandler(t, store) + + body, err := json.Marshal(GitSourceUpdatePayload{ + URL: new("https://github.com/org/repo.git"), + Authentication: &GitAuthenticationUpdatePayload{}, + }) + require.NoError(t, err) + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildUpdateReq(t, 1, int(srcID), body)) + + require.Equal(t, http.StatusOK, rr.Code) + + var stored *portainer.Source + require.NoError(t, store.ViewTx(func(tx dataservices.DataStoreTx) error { + var err error + stored, err = tx.Source().Read(adminUserContext, srcID) + return err + })) + require.NotNil(t, stored.Git) + require.Nil(t, stored.Git.Authentication) +} + +func TestGitSourceUpdate_ReplacesAuthWhenProvided(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + var srcID portainer.SourceID + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + src := &portainer.Source{ + Name: "auth-source", + Type: portainer.SourceTypeGit, + Git: &gittypes.GitSource{ + URL: "https://github.com/org/repo.git", + Authentication: &gittypes.GitAuthentication{ + Username: "alice", + Password: "secret", + }, + }, + } + err := tx.Source().Create(adminUserContext, src) + require.NoError(t, err) + srcID = src.ID + + return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}) + })) + + h := newTestHandler(t, store) + + body, err := json.Marshal(GitSourceUpdatePayload{ + URL: new("https://github.com/org/repo.git"), + Authentication: &GitAuthenticationUpdatePayload{ + Username: new("bob"), + Password: new("new-secret"), + }, + }) + require.NoError(t, err) + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildUpdateReq(t, 1, int(srcID), body)) + + require.Equal(t, http.StatusOK, rr.Code) + + var stored *portainer.Source + require.NoError(t, store.ViewTx(func(tx dataservices.DataStoreTx) error { + var err error + stored, err = tx.Source().Read(adminUserContext, srcID) + return err + })) + require.NotNil(t, stored.Git) + require.NotNil(t, stored.Git.Authentication) + require.Equal(t, "bob", stored.Git.Authentication.Username) + require.Equal(t, "new-secret", stored.Git.Authentication.Password) +} + +func TestGitSourceUpdate_NotFound(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}) + })) + + h := newTestHandler(t, store) + + body, err := json.Marshal(GitSourceUpdatePayload{URL: new("https://github.com/org/repo.git")}) + require.NoError(t, err) + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildUpdateReq(t, 1, 99, body)) + + require.Equal(t, http.StatusNotFound, rr.Code) +} + +func TestGitSourceUpdate_ConflictOnDuplicateURL(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + var srcID portainer.SourceID + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + existing := &portainer.Source{ + Name: "existing", + Type: portainer.SourceTypeGit, + Git: &gittypes.GitSource{ + URL: "https://github.com/org/existing.git", + }, + } + err := tx.Source().Create(adminUserContext, existing) + require.NoError(t, err) + + src := &portainer.Source{Name: "other", Type: portainer.SourceTypeGit, Git: &gittypes.GitSource{URL: "http://github.com/org/repo"}} + err = tx.Source().Create(adminUserContext, src) + require.NoError(t, err) + srcID = src.ID + + return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}) + })) + + h := newTestHandler(t, store) + + body, err := json.Marshal(GitSourceUpdatePayload{ + URL: new("https://github.com/org/existing.git"), + }) + require.NoError(t, err) + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildUpdateReq(t, 1, int(srcID), body)) + + require.Equal(t, http.StatusConflict, rr.Code) +} + +func TestGitSourceUpdate_MalformedJSON(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + var srcID portainer.SourceID + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + src := &portainer.Source{Name: "src", Type: portainer.SourceTypeGit, Git: &gittypes.GitSource{URL: "http://github.com/org/repo"}} + err := tx.Source().Create(adminUserContext, src) + require.NoError(t, err) + srcID = src.ID + + return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}) + })) + + h := newTestHandler(t, store) + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildUpdateReq(t, 1, int(srcID), []byte("not-valid-json{"))) + + require.Equal(t, http.StatusBadRequest, rr.Code) +} + +func TestGitSourceUpdate_ConflictWhenAuthChangesMatchAnotherSource(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + var srcID portainer.SourceID + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + existing := &portainer.Source{ + Name: "existing", + Type: portainer.SourceTypeGit, + Git: &gittypes.GitSource{ + URL: "https://github.com/org/repo.git", + Authentication: &gittypes.GitAuthentication{ + Username: "alice", + Password: "secret", + }, + }, + } + if err := tx.Source().Create(adminUserContext, existing); err != nil { + return err + } + + other := &portainer.Source{ + Name: "other", + Type: portainer.SourceTypeGit, + Git: &gittypes.GitSource{URL: "https://github.com/org/repo.git"}, + } + if err := tx.Source().Create(adminUserContext, other); err != nil { + return err + } + srcID = other.ID + + return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}) + })) + + h := newTestHandler(t, store) + + alice := "alice" + secret := "secret" + body, err := json.Marshal(GitSourceUpdatePayload{ + Authentication: &GitAuthenticationUpdatePayload{ + Username: &alice, + Password: &secret, + }, + }) + require.NoError(t, err) + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildUpdateReq(t, 1, int(srcID), body)) + require.Equal(t, http.StatusConflict, rr.Code) +} + +func TestGitSourceUpdate_NonNumericID(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}) + })) + + h := newTestHandler(t, store) + + body, err := json.Marshal(GitSourceUpdatePayload{URL: new("https://github.com/org/repo.git")}) + require.NoError(t, err) + + req := buildUpdateReqWithRawID(t, 1, "not-a-number", body) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + require.Equal(t, http.StatusBadRequest, rr.Code) +} diff --git a/api/http/handler/gitops/sources/utils.go b/api/http/handler/gitops/sources/utils.go new file mode 100644 index 0000000..86aecd4 --- /dev/null +++ b/api/http/handler/gitops/sources/utils.go @@ -0,0 +1,43 @@ +package sources + +import ( + portainer "github.com/portainer/portainer/api" + gittypes "github.com/portainer/portainer/api/git/types" + ce "github.com/portainer/portainer/api/gitops/workflows" +) + +func (h *Handler) buildSource(src *portainer.Source, stats ce.SourceStats) Source { + phase := ce.SourceStatusToPhase(src.Status, src.StatusError) + + url := "" + if src.Git != nil { + url = gittypes.SanitizeURL(src.Git.URL) + } + + return Source{ + ID: src.ID, + Name: src.Name, + Type: sourceTypeString(src.Type), + URL: url, + Status: phase.Status, + Error: phase.Error, + UsedBy: stats.WorkflowCount, + Environments: len(stats.EndpointIDs), + LastSync: src.LastSync, + } +} + +func redactWorkflowCredentials(wfs []ce.Workflow) []ce.Workflow { + redacted := make([]ce.Workflow, len(wfs)) + for i, wf := range wfs { + redacted[i] = wf + if wf.GitConfig != nil && wf.GitConfig.Authentication != nil { + cfg := *wf.GitConfig + auth := *wf.GitConfig.Authentication + auth.Password = "" + cfg.Authentication = &auth + redacted[i].GitConfig = &cfg + } + } + return redacted +} diff --git a/api/http/handler/gitops/sources/utils_test.go b/api/http/handler/gitops/sources/utils_test.go new file mode 100644 index 0000000..8bbfb17 --- /dev/null +++ b/api/http/handler/gitops/sources/utils_test.go @@ -0,0 +1,82 @@ +package sources + +import ( + "testing" + + portainer "github.com/portainer/portainer/api" + gittypes "github.com/portainer/portainer/api/git/types" + ce "github.com/portainer/portainer/api/gitops/workflows" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRedactWorkflowCredentials(t *testing.T) { + t.Parallel() + + t.Run("clears password and preserves username", func(t *testing.T) { + t.Parallel() + wfs := []ce.Workflow{{GitConfig: &gittypes.RepoConfig{ + Authentication: &gittypes.GitAuthentication{Username: "user", Password: "s3cr3t"}, + }}} + got := redactWorkflowCredentials(wfs) + require.NotNil(t, got[0].GitConfig.Authentication) + assert.Equal(t, "user", got[0].GitConfig.Authentication.Username) + assert.Empty(t, got[0].GitConfig.Authentication.Password) + }) + + t.Run("does not mutate the original slice", func(t *testing.T) { + t.Parallel() + wfs := []ce.Workflow{{GitConfig: &gittypes.RepoConfig{ + Authentication: &gittypes.GitAuthentication{Password: "s3cr3t"}, + }}} + _ = redactWorkflowCredentials(wfs) + assert.Equal(t, "s3cr3t", wfs[0].GitConfig.Authentication.Password) + }) + + t.Run("nil GitConfig is safe", func(t *testing.T) { + t.Parallel() + assert.NotPanics(t, func() { redactWorkflowCredentials([]ce.Workflow{{}}) }) + }) + + t.Run("nil Authentication is safe", func(t *testing.T) { + t.Parallel() + wfs := []ce.Workflow{{GitConfig: &gittypes.RepoConfig{}}} + assert.NotPanics(t, func() { redactWorkflowCredentials(wfs) }) + }) +} + +func TestBuildAutoUpdateInfo(t *testing.T) { + t.Parallel() + + assert.Nil(t, BuildAutoUpdateInfo(nil)) + assert.Nil(t, BuildAutoUpdateInfo(&portainer.AutoUpdateSettings{})) + + got := BuildAutoUpdateInfo(&portainer.AutoUpdateSettings{Interval: "5m"}) + require.NotNil(t, got) + assert.Equal(t, "Interval", got.Mechanism) + assert.Equal(t, "5m", got.FetchInterval) + + got = BuildAutoUpdateInfo(&portainer.AutoUpdateSettings{Webhook: "abc123"}) + require.NotNil(t, got) + assert.Equal(t, "Webhook", got.Mechanism) + assert.Empty(t, got.FetchInterval) +} + +func TestBuildConnectionInfo(t *testing.T) { + t.Parallel() + + assert.Equal(t, connectionInfo{}, buildConnectionInfo(nil)) + + cfg := &gittypes.GitSource{ + TLSSkipVerify: true, + Authentication: &gittypes.GitAuthentication{Username: "user"}, + } + got := buildConnectionInfo(cfg) + assert.True(t, got.TLSSkipVerify) + require.NotNil(t, got.Authentication) + assert.Equal(t, "user", got.Authentication.Username) + + got = buildConnectionInfo(&gittypes.GitSource{}) + assert.Nil(t, got.Authentication) +} diff --git a/api/http/handler/gitops/workflows/fetch.go b/api/http/handler/gitops/workflows/fetch.go new file mode 100644 index 0000000..a3c054f --- /dev/null +++ b/api/http/handler/gitops/workflows/fetch.go @@ -0,0 +1,219 @@ +package workflows + +import ( + "errors" + "fmt" + "slices" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/dataservices/source" + svc "github.com/portainer/portainer/api/gitops/workflows" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/kubernetes/cli" + "github.com/portainer/portainer/api/set" + + "github.com/rs/zerolog/log" +) + +// ErrNotFound indicates a workflow or one of its artifacts is not visible to the requesting user, +// either because it does not exist or because access has been filtered out. +var ErrNotFound = errors.New("not found") + +// ErrResourceNotAccessible indicates sc cannot access a resource that does exist. Callers +// resolving a single artifact translate this to ErrNotFound to avoid leaking its existence. +var ErrResourceNotAccessible = errors.New("resource not accessible") + +// fetchWorkflowByID returns the detail view of a single workflow, resolving each of its +// Artifacts to its backing Stack or EdgeStack and filtering out any the user cannot access. +// A workflow with zero artifacts to begin with is a valid, visible state and is returned as-is. +// ErrNotFound is returned when the workflow itself does not exist, or when it had artifacts but +// every one of them was filtered out (existence-hiding for the common single-artifact case). +func fetchWorkflowByID( + tx dataservices.DataStoreTx, + k8sFactory *cli.ClientFactory, + sc *security.RestrictedRequestContext, + workflowID portainer.WorkflowID, +) (*WorkflowDetail, error) { + wf, err := tx.Workflow().Read(workflowID) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrNotFound, err) + } + + sourceMap := map[portainer.SourceID]portainer.Source{} + if len(wf.Artifacts) > 0 { + userContext := source.NewUserContext(sc.User, sc.UserMemberships) + sourceMap, err = svc.LoadWorkflowSources(tx, userContext, wf) + if err != nil { + return nil, err + } + } + + artifacts := make([]svc.ArtifactDetail, 0, len(wf.Artifacts)) + for _, a := range wf.Artifacts { + detail, err := fetchArtifactDetail(tx, k8sFactory, sc, a, sourceMap) + if errors.Is(err, ErrNotFound) || errors.Is(err, ErrResourceNotAccessible) { + continue + } + if err != nil { + return nil, err + } + artifacts = append(artifacts, *detail) + } + + if len(wf.Artifacts) > 0 && len(artifacts) == 0 { + return nil, ErrNotFound + } + + return &WorkflowDetail{ + ID: int(wf.ID), + Name: wf.Name, + Artifacts: artifacts, + }, nil +} + +// fetchArtifactDetail resolves a single Artifact to its backing Stack or EdgeStack. +func fetchArtifactDetail( + tx dataservices.DataStoreTx, + k8sFactory *cli.ClientFactory, + sc *security.RestrictedRequestContext, + artifact portainer.Artifact, + sourceMap map[portainer.SourceID]portainer.Source, +) (*svc.ArtifactDetail, error) { + switch { + case artifact.StackID != 0: + stack, err := tx.Stack().Read(artifact.StackID) + if tx.IsErrObjectNotFound(err) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + return fetchStackArtifact(tx, k8sFactory, sc, *stack, artifact, sourceMap) + + case artifact.EdgeStackID != 0: + if !sc.IsAdmin { + return nil, ErrResourceNotAccessible + } + edgeStack, err := tx.EdgeStack().EdgeStack(artifact.EdgeStackID) + if tx.IsErrObjectNotFound(err) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + return fetchEdgeStackArtifact(tx, *edgeStack, artifact, sourceMap) + + default: + return nil, ErrNotFound + } +} + +// fetchStackArtifact resolves a stack-backed Artifact, applying the same endpoint/type-match and +// access checks as FetchWorkflows, scoped to a single stack. +func fetchStackArtifact( + tx dataservices.DataStoreTx, + k8sFactory *cli.ClientFactory, + sc *security.RestrictedRequestContext, + stack portainer.Stack, + artifact portainer.Artifact, + sourceMap map[portainer.SourceID]portainer.Source, +) (*svc.ArtifactDetail, error) { + endpointMap, err := svc.BuildEndpointMap(tx, []portainer.Stack{stack}) + if err != nil { + return nil, err + } + + if err := checkStackAccessible(tx, k8sFactory, sc, stack, endpointMap, artifact, sourceMap); err != nil { + return nil, err + } + + sourcePhase, artifactPhase := svc.ArtifactPhases(artifact.Files, sourceMap) + + detail := svc.MapStackToArtifactDetail(stack, artifact.Files, sourcePhase, artifactPhase) + return &detail, nil +} + +// checkStackAccessible returns ErrResourceNotAccessible if sc cannot access stack, either because +// its endpoint's Kubernetes namespace/source access is insufficient or because Docker's +// resource-control-based UAC filters it out. +func checkStackAccessible( + tx dataservices.DataStoreTx, + k8sFactory *cli.ClientFactory, + sc *security.RestrictedRequestContext, + stack portainer.Stack, + endpointMap map[portainer.EndpointID]portainer.Endpoint, + artifact portainer.Artifact, + sourceMap map[portainer.SourceID]portainer.Source, +) error { + if stack.Type != portainer.KubernetesStack { + accessible, err := svc.FilterDockerStacksByAccess(tx, []portainer.Stack{stack}, sc) + if err != nil { + return err + } + + if len(accessible) == 0 { + return ErrResourceNotAccessible + } + + return nil + } + + ep, epOk := endpointMap[stack.EndpointID] + if !epOk { + return ErrResourceNotAccessible + } + + access, err := svc.ResolveKubeAccess(k8sFactory, sc, &ep) + if err != nil { + log.Warn().Err(err).Str("context", "checkStackAccessible").Int("endpoint_id", int(ep.ID)).Msg("Failed to resolve kube access for endpoint, filtering artifact") + return ErrResourceNotAccessible + } + + if (!access.IsKubeAdmin && !slices.Contains(access.NonAdminNamespaces, stack.Namespace)) || !HasAccessibleSource(artifact.Files, sourceMap) { + return ErrResourceNotAccessible + } + + return nil +} + +// fetchEdgeStackArtifact resolves an edge-stack-backed Artifact. The caller (fetchArtifactDetail) +// has already gated access via sc.IsAdmin, so edge stacks are visible to admins only. +func fetchEdgeStackArtifact( + tx dataservices.DataStoreTx, + edgeStack portainer.EdgeStack, + artifact portainer.Artifact, + sourceMap map[portainer.SourceID]portainer.Source, +) (*svc.ArtifactDetail, error) { + statuses, err := tx.EdgeStackStatus().ReadAll(edgeStack.ID) + if err != nil { + return nil, err + } + + groupIDSet := set.ToSet(edgeStack.EdgeGroups) + edgeGroups, err := tx.EdgeGroup().ReadAll(func(g portainer.EdgeGroup) bool { + return groupIDSet.Contains(g.ID) + }) + if err != nil { + return nil, err + } + + groupEndpoints, err := svc.BuildGroupEndpoints(tx, edgeGroups) + if err != nil { + return nil, err + } + + sourcePhase, artifactPhase := svc.ArtifactPhases(artifact.Files, sourceMap) + + detail := svc.MapEdgeStackToArtifactDetail(edgeStack, artifact.Files, statuses, groupEndpoints, sourcePhase, artifactPhase) + return &detail, nil +} + +// HasAccessibleSource reports whether any of the artifact's files has a source visible in +// sourceMap. +func HasAccessibleSource(files []portainer.ArtifactFile, sourceMap map[portainer.SourceID]portainer.Source) bool { + return slices.ContainsFunc(files, func(f portainer.ArtifactFile) bool { + _, ok := sourceMap[f.SourceID] + return ok + }) +} diff --git a/api/http/handler/gitops/workflows/fetch_test.go b/api/http/handler/gitops/workflows/fetch_test.go new file mode 100644 index 0000000..f4dca87 --- /dev/null +++ b/api/http/handler/gitops/workflows/fetch_test.go @@ -0,0 +1,279 @@ +package workflows + +import ( + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/dataservices/source" + "github.com/portainer/portainer/api/datastore" + gittypes "github.com/portainer/portainer/api/git/types" + ce "github.com/portainer/portainer/api/gitops/workflows" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/stacks/stackutils" + + "github.com/stretchr/testify/require" +) + +func adminContext() *security.RestrictedRequestContext { + return &security.RestrictedRequestContext{ + IsAdmin: true, + UserID: 1, + User: &portainer.User{ID: 1, Role: portainer.AdministratorRole}, + } +} + +func nonAdminContext() *security.RestrictedRequestContext { + return &security.RestrictedRequestContext{ + IsAdmin: false, + UserID: 2, + User: &portainer.User{ID: 2, Role: portainer.StandardUserRole}, + } +} + +func mustCreateGitWorkflow(t *testing.T, tx dataservices.DataStoreTx, stack *portainer.Stack) { + t.Helper() + + cfg := stack.GitConfig + + src := &portainer.Source{Type: portainer.SourceTypeGit, Git: &gittypes.GitSource{URL: cfg.URL, Authentication: cfg.Authentication, TLSSkipVerify: cfg.TLSSkipVerify}} + require.NoError(t, tx.Source().Create(source.InsecureNewAdminContext(), src)) + + wf := &portainer.Workflow{Artifacts: []portainer.Artifact{{ + StackID: stack.ID, + Files: []portainer.ArtifactFile{{SourceID: src.ID}}, + }}} + require.NoError(t, tx.Workflow().Create(wf)) + + stack.WorkflowID = wf.ID + stack.GitConfig = nil + + require.NoError(t, tx.Stack().Create(stack)) +} + +func TestFetchWorkflowByID_SingleStackArtifact(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + var wfID portainer.WorkflowID + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + stack := &portainer.Stack{ID: 1, Name: "gitops-stack", GitConfig: &gittypes.RepoConfig{URL: "https://github.com/x/repo", ConfigFilePath: "docker-compose.yml"}} + mustCreateGitWorkflow(t, tx, stack) + wfID = stack.WorkflowID + + return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}) + })) + + var detail *WorkflowDetail + require.NoError(t, store.ViewTx(func(tx dataservices.DataStoreTx) error { + var err error + detail, err = fetchWorkflowByID(tx, nil, adminContext(), wfID) + return err + })) + + require.Len(t, detail.Artifacts, 1) + require.Equal(t, "gitops-stack", detail.Artifacts[0].Name) + require.Equal(t, ce.TypeStack, detail.Artifacts[0].Type) + require.Len(t, detail.Artifacts[0].Files, 1) +} + +func TestFetchWorkflowByID_EdgeStackArtifact_Admin(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + var wfID portainer.WorkflowID + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + wf := &portainer.Workflow{Artifacts: []portainer.Artifact{{EdgeStackID: 1}}} + require.NoError(t, tx.Workflow().Create(wf)) + wfID = wf.ID + + require.NoError(t, tx.EdgeStack().Create(1, &portainer.EdgeStack{ID: 1, Name: "edge-stack"})) + + return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}) + })) + + var detail *WorkflowDetail + require.NoError(t, store.ViewTx(func(tx dataservices.DataStoreTx) error { + var err error + detail, err = fetchWorkflowByID(tx, nil, adminContext(), wfID) + return err + })) + + require.Len(t, detail.Artifacts, 1) + require.Equal(t, "edge-stack", detail.Artifacts[0].Name) + require.Equal(t, ce.TypeEdgeStack, detail.Artifacts[0].Type) +} + +func TestFetchWorkflowByID_EdgeStackArtifactFilteredForNonAdmin_SiblingStackSurvives(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + var wfID portainer.WorkflowID + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + wf := &portainer.Workflow{Artifacts: []portainer.Artifact{{StackID: 1}, {EdgeStackID: 1}}} + require.NoError(t, tx.Workflow().Create(wf)) + wfID = wf.ID + + require.NoError(t, tx.Stack().Create(&portainer.Stack{ID: 1, Name: "docker-stack", WorkflowID: wf.ID})) + require.NoError(t, tx.ResourceControl().Create(&portainer.ResourceControl{ + ResourceID: stackutils.ResourceControlID(0, "docker-stack"), + Type: portainer.StackResourceControl, + Public: true, + })) + require.NoError(t, tx.EdgeStack().Create(1, &portainer.EdgeStack{ID: 1, Name: "edge-stack"})) + + require.NoError(t, tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})) + return tx.User().Create(&portainer.User{ID: 2, Role: portainer.StandardUserRole}) + })) + + var detail *WorkflowDetail + require.NoError(t, store.ViewTx(func(tx dataservices.DataStoreTx) error { + var err error + detail, err = fetchWorkflowByID(tx, nil, nonAdminContext(), wfID) + return err + })) + + require.Len(t, detail.Artifacts, 1) + require.Equal(t, "docker-stack", detail.Artifacts[0].Name) +} + +func TestFetchWorkflowByID_K8sStackWithNoAccessibleSourceIsFiltered(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + var wfID portainer.WorkflowID + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + require.NoError(t, tx.Endpoint().Create(&portainer.Endpoint{ID: 1, Type: portainer.KubernetesLocalEnvironment})) + + wf := &portainer.Workflow{Artifacts: []portainer.Artifact{{StackID: 1}}} + require.NoError(t, tx.Workflow().Create(wf)) + wfID = wf.ID + + require.NoError(t, tx.Stack().Create(&portainer.Stack{ID: 1, Name: "k8s-stack", Type: portainer.KubernetesStack, EndpointID: 1, WorkflowID: wf.ID})) + + return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}) + })) + + err := store.ViewTx(func(tx dataservices.DataStoreTx) error { + _, err := fetchWorkflowByID(tx, nil, adminContext(), wfID) + return err + }) + require.ErrorIs(t, err, ErrNotFound) +} + +func TestFetchWorkflowByID_K8sStackWithAccessibleSourceIsReturned(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + var wfID portainer.WorkflowID + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + require.NoError(t, tx.Endpoint().Create(&portainer.Endpoint{ID: 1, Type: portainer.KubernetesLocalEnvironment})) + + src := &portainer.Source{Type: portainer.SourceTypeGit, Git: &gittypes.GitSource{URL: "https://github.com/x/repo"}} + require.NoError(t, tx.Source().Create(source.InsecureNewAdminContext(), src)) + + wf := &portainer.Workflow{Artifacts: []portainer.Artifact{{ + StackID: 1, + Files: []portainer.ArtifactFile{{SourceID: src.ID}}, + }}} + require.NoError(t, tx.Workflow().Create(wf)) + wfID = wf.ID + + require.NoError(t, tx.Stack().Create(&portainer.Stack{ID: 1, Name: "k8s-stack", Type: portainer.KubernetesStack, EndpointID: 1, WorkflowID: wf.ID})) + + return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}) + })) + + var detail *WorkflowDetail + require.NoError(t, store.ViewTx(func(tx dataservices.DataStoreTx) error { + var err error + detail, err = fetchWorkflowByID(tx, nil, adminContext(), wfID) + return err + })) + + require.Len(t, detail.Artifacts, 1) + require.Equal(t, "k8s-stack", detail.Artifacts[0].Name) +} + +func TestFetchWorkflowByID_ZeroArtifactsIsNotAnError(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + var wfID portainer.WorkflowID + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + wf := &portainer.Workflow{Name: "empty-workflow"} + require.NoError(t, tx.Workflow().Create(wf)) + wfID = wf.ID + + return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}) + })) + + var detail *WorkflowDetail + require.NoError(t, store.ViewTx(func(tx dataservices.DataStoreTx) error { + var err error + detail, err = fetchWorkflowByID(tx, nil, adminContext(), wfID) + return err + })) + + require.Equal(t, "empty-workflow", detail.Name) + require.Empty(t, detail.Artifacts) +} + +func TestFetchWorkflowByID_AllArtifactsFilteredOutReturnsNotFound(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + var wfID portainer.WorkflowID + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + wf := &portainer.Workflow{Artifacts: []portainer.Artifact{{EdgeStackID: 1}}} + require.NoError(t, tx.Workflow().Create(wf)) + wfID = wf.ID + + require.NoError(t, tx.EdgeStack().Create(1, &portainer.EdgeStack{ID: 1, Name: "edge-stack"})) + + return tx.User().Create(&portainer.User{ID: 2, Role: portainer.StandardUserRole}) + })) + + err := store.ViewTx(func(tx dataservices.DataStoreTx) error { + _, err := fetchWorkflowByID(tx, nil, nonAdminContext(), wfID) + return err + }) + require.ErrorIs(t, err, ErrNotFound) +} + +func TestFetchWorkflowByID_StaleArtifactReferenceIsFiltered(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + var wfID portainer.WorkflowID + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + wf := &portainer.Workflow{Artifacts: []portainer.Artifact{{StackID: 999}}} + require.NoError(t, tx.Workflow().Create(wf)) + wfID = wf.ID + + return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}) + })) + + err := store.ViewTx(func(tx dataservices.DataStoreTx) error { + _, err := fetchWorkflowByID(tx, nil, adminContext(), wfID) + return err + }) + require.ErrorIs(t, err, ErrNotFound) +} + +func TestFetchWorkflowByID_NotFoundWorkflowID(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}) + })) + + err := store.ViewTx(func(tx dataservices.DataStoreTx) error { + _, err := fetchWorkflowByID(tx, nil, adminContext(), 999) + return err + }) + require.True(t, store.IsErrObjectNotFound(err)) + require.ErrorIs(t, err, ErrNotFound) + +} diff --git a/api/http/handler/gitops/workflows/filter_test.go b/api/http/handler/gitops/workflows/filter_test.go new file mode 100644 index 0000000..544ae9d --- /dev/null +++ b/api/http/handler/gitops/workflows/filter_test.go @@ -0,0 +1,80 @@ +package workflows + +import ( + "net/http/httptest" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/internal/authorization" + "github.com/portainer/portainer/api/stacks/stackutils" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWorkflowsList_RBAC_NonAdminNoAccess(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + user := &portainer.User{ + ID: 1, + Username: "standard", + Role: portainer.StandardUserRole, + PortainerAuthorizations: authorization.DefaultPortainerAuthorizations(), + } + require.NoError(t, store.User().Create(user)) + + require.NoError(t, store.Endpoint().Create(&portainer.Endpoint{ID: 1, Name: "test-env"})) + + // Stack on endpoint 1 WITHOUT resource control — non-admin cannot see it + createGitStack(t, store, &portainer.Stack{ + ID: 1, Name: "no-rc-stack", EndpointID: 1, + GitConfig: gitConfig("https://github.com/x/no-rc"), + }) + + h := NewHandler(store, nil, nil) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.StandardUserRole, "")) + + items := decodeWorkflows(t, rr) + assert.Empty(t, items, "non-admin without resource control access should see no stacks") +} + +func TestWorkflowsList_RBAC_NonAdminWithAccess(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + user := &portainer.User{ + ID: 1, + Username: "standard", + Role: portainer.StandardUserRole, + PortainerAuthorizations: authorization.DefaultPortainerAuthorizations(), + } + require.NoError(t, store.User().Create(user)) + + require.NoError(t, store.Endpoint().Create(&portainer.Endpoint{ID: 1, Name: "test-env"})) + + const stackName = "rc-stack" + createGitStack(t, store, &portainer.Stack{ + ID: 1, Name: stackName, EndpointID: 1, + GitConfig: gitConfig("https://github.com/x/rc"), + }) + + require.NoError(t, store.ResourceControl().Create(&portainer.ResourceControl{ + ID: 1, + ResourceID: stackutils.ResourceControlID(1, stackName), + Type: portainer.StackResourceControl, + UserAccesses: []portainer.UserResourceAccess{ + {UserID: 1, AccessLevel: portainer.ReadWriteAccessLevel}, + }, + })) + + h := NewHandler(store, nil, nil) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.StandardUserRole, "")) + + items := decodeWorkflows(t, rr) + require.Len(t, items, 1) + assert.Equal(t, stackName, items[0].Name) +} diff --git a/api/http/handler/gitops/workflows/get.go b/api/http/handler/gitops/workflows/get.go new file mode 100644 index 0000000..ff05f05 --- /dev/null +++ b/api/http/handler/gitops/workflows/get.go @@ -0,0 +1,63 @@ +package workflows + +import ( + "errors" + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + svc "github.com/portainer/portainer/api/gitops/workflows" + "github.com/portainer/portainer/api/http/security" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +// WorkflowDetail is the response for GET /gitops/workflows/{id} +type WorkflowDetail struct { + ID int `json:"id" validate:"required"` + Name string `json:"name" validate:"required"` + Artifacts []svc.ArtifactDetail `json:"artifacts,omitempty"` +} + +// @id GitOpsWorkflowGet +// @summary Get a GitOps workflow by ID +// @description Returns the detail view of a single GitOps workflow, with one entry per backing +// @description stack or edge stack artifact. +// @description **Access policy**: authenticated +// @tags gitops +// @security ApiKeyAuth +// @security jwt +// @produce json +// @param id path int true "Workflow identifier" +// @success 200 {object} WorkflowDetail +// @failure 400 "Invalid request" +// @failure 404 "Workflow not found" +// @failure 500 "Server error" +// @router /gitops/workflows/{id} [get] +func (h *Handler) get(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + id, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid workflow identifier route variable", err) + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) + } + + var detail *WorkflowDetail + err = h.dataStore.ViewTx(func(tx dataservices.DataStoreTx) error { + var err error + detail, err = fetchWorkflowByID(tx, h.k8sFactory, securityContext, portainer.WorkflowID(id)) + return err + }) + + if h.dataStore.IsErrObjectNotFound(err) || errors.Is(err, ErrNotFound) { + return httperror.NotFound("Workflow not found", err) + } else if err != nil { + return httperror.InternalServerError("Unable to retrieve workflow", err) + } + + return response.JSON(w, detail) +} diff --git a/api/http/handler/gitops/workflows/get_test.go b/api/http/handler/gitops/workflows/get_test.go new file mode 100644 index 0000000..584108a --- /dev/null +++ b/api/http/handler/gitops/workflows/get_test.go @@ -0,0 +1,147 @@ +package workflows + +import ( + "net/http" + "net/http/httptest" + "strconv" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/dataservices/source" + "github.com/portainer/portainer/api/datastore" + gittypes "github.com/portainer/portainer/api/git/types" + + "github.com/portainer/portainer/api/http/security" + + "github.com/segmentio/encoding/json" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// buildWorkflowGetReq creates an HTTP GET request for /gitops/workflows/{id} with a +// security context pre-populated. +func buildWorkflowGetReq(t *testing.T, userID portainer.UserID, role portainer.UserRole, id string) *http.Request { + t.Helper() + req := httptest.NewRequest(http.MethodGet, "/gitops/workflows/"+id, nil) + ctx := security.StoreTokenData(req, &portainer.TokenData{ID: userID}) + req = req.WithContext(ctx) + ctx = security.StoreRestrictedRequestContext(req, &security.RestrictedRequestContext{ + UserID: userID, + IsAdmin: security.IsAdminRole(role), + User: &portainer.User{ID: userID, Role: role}, + }) + return req.WithContext(ctx) +} + +func decodeWorkflowDetail(t *testing.T, rr *httptest.ResponseRecorder) WorkflowDetail { + t.Helper() + require.Equal(t, http.StatusOK, rr.Code, "unexpected status: %s", rr.Body.String()) + var detail WorkflowDetail + require.NoError(t, json.NewDecoder(rr.Body).Decode(&detail)) + return detail +} + +func TestWorkflowGet_MultiFileStackArtifact(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + var wfID portainer.WorkflowID + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + src := &portainer.Source{Type: portainer.SourceTypeGit, Git: &gittypes.GitSource{URL: "https://github.com/x/repo"}} + require.NoError(t, tx.Source().Create(source.InsecureNewAdminContext(), src)) + + wf := &portainer.Workflow{Artifacts: []portainer.Artifact{{ + StackID: 1, + Files: []portainer.ArtifactFile{ + {SourceID: src.ID, Path: "docker-compose.yml"}, + {SourceID: src.ID, Path: "override.yml"}, + }, + }}} + require.NoError(t, tx.Workflow().Create(wf)) + wfID = wf.ID + + require.NoError(t, tx.Stack().Create(&portainer.Stack{ID: 1, Name: "gitops-stack", WorkflowID: wf.ID})) + return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}) + })) + + h := NewHandler(store, nil, nil) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildWorkflowGetReq(t, 1, portainer.AdministratorRole, strconv.Itoa(int(wfID)))) + + detail := decodeWorkflowDetail(t, rr) + assert.Equal(t, int(wfID), detail.ID) + require.Len(t, detail.Artifacts, 1) + assert.Equal(t, "gitops-stack", detail.Artifacts[0].Name) + assert.Len(t, detail.Artifacts[0].Files, 2) +} + +func TestWorkflowGet_ZeroArtifactWorkflowReturnsEmptyArray(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + var wfID portainer.WorkflowID + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + wf := &portainer.Workflow{Name: "empty-workflow"} + require.NoError(t, tx.Workflow().Create(wf)) + wfID = wf.ID + return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}) + })) + + h := NewHandler(store, nil, nil) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildWorkflowGetReq(t, 1, portainer.AdministratorRole, strconv.Itoa(int(wfID)))) + + detail := decodeWorkflowDetail(t, rr) + assert.Equal(t, "empty-workflow", detail.Name) + assert.Empty(t, detail.Artifacts) +} + +func TestWorkflowGet_NonexistentWorkflowReturns404(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}) + })) + + h := NewHandler(store, nil, nil) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildWorkflowGetReq(t, 1, portainer.AdministratorRole, "999")) + assert.Equal(t, http.StatusNotFound, rr.Code) +} + +func TestWorkflowGet_AllArtifactsFilteredReturns404(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + var wfID portainer.WorkflowID + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + wf := &portainer.Workflow{Artifacts: []portainer.Artifact{{EdgeStackID: 1}}} + require.NoError(t, tx.Workflow().Create(wf)) + wfID = wf.ID + + require.NoError(t, tx.EdgeStack().Create(1, &portainer.EdgeStack{ID: 1, Name: "edge-stack"})) + require.NoError(t, tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})) + return tx.User().Create(&portainer.User{ID: 2, Role: portainer.StandardUserRole}) + })) + + h := NewHandler(store, nil, nil) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildWorkflowGetReq(t, 2, portainer.StandardUserRole, strconv.Itoa(int(wfID)))) + assert.Equal(t, http.StatusNotFound, rr.Code) +} + +func TestWorkflowGet_InvalidIDRouteVar(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}) + })) + + h := NewHandler(store, nil, nil) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildWorkflowGetReq(t, 1, portainer.AdministratorRole, "not-a-number")) + assert.Equal(t, http.StatusBadRequest, rr.Code) +} diff --git a/api/http/handler/gitops/workflows/handler.go b/api/http/handler/gitops/workflows/handler.go new file mode 100644 index 0000000..53adb7d --- /dev/null +++ b/api/http/handler/gitops/workflows/handler.go @@ -0,0 +1,43 @@ +package workflows + +import ( + "net/http" + "time" + + gocache "github.com/patrickmn/go-cache" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/kubernetes/cli" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + + "github.com/gorilla/mux" +) + +const ( + cacheTTL = 30 * time.Second + cacheCleanupInterval = 10 * time.Minute +) + +type Handler struct { + *mux.Router + dataStore dataservices.DataStore + gitService portainer.GitService + cache *gocache.Cache + k8sFactory *cli.ClientFactory +} + +func NewHandler(dataStore dataservices.DataStore, gitService portainer.GitService, k8sFactory *cli.ClientFactory) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + dataStore: dataStore, + gitService: gitService, + cache: gocache.New(cacheTTL, cacheCleanupInterval), + k8sFactory: k8sFactory, + } + + h.Handle("/gitops/workflows", httperror.LoggerHandler(h.list)).Methods(http.MethodGet) + h.Handle("/gitops/workflows/summary", httperror.LoggerHandler(h.summary)).Methods(http.MethodGet) + h.Handle("/gitops/workflows/{id}", httperror.LoggerHandler(h.get)).Methods(http.MethodGet) + + return h +} diff --git a/api/http/handler/gitops/workflows/helpers_test.go b/api/http/handler/gitops/workflows/helpers_test.go new file mode 100644 index 0000000..7631bfd --- /dev/null +++ b/api/http/handler/gitops/workflows/helpers_test.go @@ -0,0 +1,75 @@ +package workflows + +import ( + "net/http" + "net/http/httptest" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/dataservices/source" + gittypes "github.com/portainer/portainer/api/git/types" + ce "github.com/portainer/portainer/api/gitops/workflows" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/pkg/fips" + + "github.com/segmentio/encoding/json" + "github.com/stretchr/testify/require" +) + +func init() { + fips.InitFIPS(false) +} + +// buildWorkflowsReq creates an HTTP GET request with security context pre-populated. +func buildWorkflowsReq(t *testing.T, userID portainer.UserID, role portainer.UserRole, query string) *http.Request { + t.Helper() + req := httptest.NewRequest(http.MethodGet, "/gitops/workflows?"+query, nil) + ctx := security.StoreTokenData(req, &portainer.TokenData{ID: userID}) + req = req.WithContext(ctx) + ctx = security.StoreRestrictedRequestContext(req, &security.RestrictedRequestContext{ + UserID: userID, + IsAdmin: security.IsAdminRole(role), + User: &portainer.User{ID: userID, Role: role}, + }) + return req.WithContext(ctx) +} + +// decodeWorkflows decodes a 200 JSON response into a slice of ce.Workflow. +func decodeWorkflows(t *testing.T, rr *httptest.ResponseRecorder) []ce.Workflow { + t.Helper() + require.Equal(t, http.StatusOK, rr.Code, "unexpected status: %s", rr.Body.String()) + var items []ce.Workflow + require.NoError(t, json.NewDecoder(rr.Body).Decode(&items)) + return items +} + +// gitConfig is a convenience constructor for test RepoConfigs. +func gitConfig(url string) *gittypes.RepoConfig { + return &gittypes.RepoConfig{URL: url, ConfigFilePath: "docker-compose.yml"} +} + +// createGitStack creates Source, Workflow, and Stack records with WorkflowID properly wired. +func createGitStack(t *testing.T, tx dataservices.DataStoreTx, stack *portainer.Stack) { + t.Helper() + + if stack.GitConfig != nil { + src := &portainer.Source{Git: &gittypes.GitSource{URL: stack.GitConfig.URL, Authentication: stack.GitConfig.Authentication, TLSSkipVerify: stack.GitConfig.TLSSkipVerify}, Type: portainer.SourceTypeGit} + require.NoError(t, tx.Source().Create(source.InsecureNewAdminContext(), src)) + + wf := &portainer.Workflow{Artifacts: []portainer.Artifact{{ + StackID: stack.ID, + Files: []portainer.ArtifactFile{{ + SourceID: src.ID, + Path: stack.GitConfig.ConfigFilePath, + Ref: stack.GitConfig.ReferenceName, + Hash: stack.GitConfig.ConfigHash, + }}, + }}} + require.NoError(t, tx.Workflow().Create(wf)) + + stack.WorkflowID = wf.ID + } + + require.NoError(t, tx.Stack().Create(stack)) +} diff --git a/api/http/handler/gitops/workflows/list.go b/api/http/handler/gitops/workflows/list.go new file mode 100644 index 0000000..c425cec --- /dev/null +++ b/api/http/handler/gitops/workflows/list.go @@ -0,0 +1,160 @@ +package workflows + +import ( + "cmp" + "context" + "net/http" + "slices" + "strconv" + "strings" + + gocache "github.com/patrickmn/go-cache" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + svc "github.com/portainer/portainer/api/gitops/workflows" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/http/utils/filters" + "github.com/portainer/portainer/api/set" + "github.com/portainer/portainer/api/slicesx" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +// @id GitOpsWorkflowsList +// @summary List all GitOps workflows +// @description Returns a unified list of all stacks that have GitOps (GitConfig) configured. +// @description **Access policy**: authenticated +// @tags gitops +// @security ApiKeyAuth +// @security jwt +// @produce json +// @param search query string false "Search term (matches name or repository URL)" +// @param sort query string false "Sort field: name | type | status | creationDate | lastSyncDate" +// @param order query string false "Sort order: asc or desc" +// @param start query int false "Pagination start index" +// @param limit query int false "Pagination limit (0 = unlimited)" +// @param endpointIds query []int false "Filter by environment IDs (e.g. endpointIds[]=1&endpointIds[]=2)" +// @param status query string false "Filter by status: healthy | syncing | error | paused | unknown" +// @param type query string false "Filter by type: stack" +// @param platform query string false "Filter by platform: dockerStandalone | dockerSwarm | kubernetes" +// @success 200 {array} svc.Workflow +// @failure 500 "Server error" +// @router /gitops/workflows [get] +func (h *Handler) list(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + params := filters.ExtractListModifiersQueryParams(r) + + endpointIDs, err := request.RetrieveNumberArrayQueryParameter[portainer.EndpointID](r, "endpointIds") + if err != nil { + return httperror.BadRequest("Invalid endpointIds parameter", err) + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) + } + + key := cacheKey(securityContext, endpointIDs) + + items, err := h.getWorkflows(r.Context(), key, securityContext, endpointIDs) + if err != nil { + return httperror.InternalServerError("Unable to retrieve workflows", err) + } + + if status, _ := request.RetrieveQueryParameter(r, "status", true); status != "" { + s, err := svc.ParseStatus(status) + if err != nil { + return httperror.BadRequest("Invalid status parameter", err) + } + items = slicesx.FilterInPlace(items, func(i svc.Workflow) bool { return svc.EffectiveStatus(i) == s }) + } + + if workflowType, _ := request.RetrieveQueryParameter(r, "type", true); workflowType != "" { + t, err := svc.ParseType(workflowType) + if err != nil { + return httperror.BadRequest("Invalid type parameter", err) + } + items = slicesx.FilterInPlace(items, func(i svc.Workflow) bool { return i.Type == t }) + } + + if platform, _ := request.RetrieveQueryParameter(r, "platform", true); platform != "" { + p, err := svc.ParsePlatform(platform) + if err != nil { + return httperror.BadRequest("Invalid platform parameter", err) + } + items = slicesx.FilterInPlace(items, func(i svc.Workflow) bool { return i.Platform == p }) + } + + results := filters.SearchOrderAndPaginate(items, params, filters.Config[svc.Workflow]{ + SearchAccessors: []filters.SearchAccessor[svc.Workflow]{ + func(i svc.Workflow) (string, error) { return i.Name, nil }, + func(i svc.Workflow) (string, error) { + if i.GitConfig == nil { + return "", nil + } + return i.GitConfig.URL, nil + }, + }, + SortBindings: []filters.SortBinding[svc.Workflow]{ + {Key: "name", Fn: func(a, b svc.Workflow) int { return strings.Compare(a.Name, b.Name) }}, + {Key: "type", Fn: func(a, b svc.Workflow) int { return strings.Compare(string(a.Type), string(b.Type)) }}, + {Key: "status", Fn: func(a, b svc.Workflow) int { + return strings.Compare(string(svc.EffectiveStatus(a)), string(svc.EffectiveStatus(b))) + }}, + {Key: "creationDate", Fn: func(a, b svc.Workflow) int { return cmp.Compare(a.CreationDate, b.CreationDate) }}, + {Key: "lastSyncDate", Fn: func(a, b svc.Workflow) int { return cmp.Compare(a.LastSyncDate, b.LastSyncDate) }, NullsLast: func(i svc.Workflow) bool { return i.LastSyncDate == 0 }}, + {Key: "platform", Fn: func(a, b svc.Workflow) int { return strings.Compare(string(a.Platform), string(b.Platform)) }}, + }, + }) + + filters.ApplyFilterResultsHeaders(&w, results) + return response.JSON(w, redactWorkflowCredentials(results.Items)) +} + +func redactWorkflowCredentials(items []svc.Workflow) []svc.Workflow { + for i := range items { + if items[i].GitConfig != nil && items[i].GitConfig.Authentication != nil { + gc := *items[i].GitConfig + auth := *gc.Authentication + auth.Password = "" + gc.Authentication = &auth + items[i].GitConfig = &gc + } + } + return items +} + +func (h *Handler) getWorkflows(ctx context.Context, key string, sc *security.RestrictedRequestContext, endpointIDs []portainer.EndpointID) ([]svc.Workflow, error) { + if cached, ok := h.cache.Get(key); ok { + return slices.Clone(cached.([]svc.Workflow)), nil + } + + var result []svc.Workflow + err := h.dataStore.ViewTx(func(tx dataservices.DataStoreTx) error { + var err error + result, err = svc.FetchWorkflows(tx, h.k8sFactory, sc, set.ToSet(endpointIDs)) + return err + }) + if err != nil { + return nil, err + } + h.cache.Set(key, result, gocache.DefaultExpiration) + + return slices.Clone(result), nil +} + +func cacheKey(sc *security.RestrictedRequestContext, endpointIDs []portainer.EndpointID) string { + ids := make([]string, len(endpointIDs)) + for i, id := range endpointIDs { + ids[i] = strconv.Itoa(int(id)) + } + slices.Sort(ids) + + teamIDs := make([]string, len(sc.UserMemberships)) + for i, membership := range sc.UserMemberships { + teamIDs[i] = strconv.Itoa(int(membership.TeamID)) + } + slices.Sort(teamIDs) + + return strconv.Itoa(int(sc.UserID)) + ":" + strconv.FormatBool(sc.IsAdmin) + ":" + strings.Join(ids, ",") + ":" + strings.Join(teamIDs, ",") +} diff --git a/api/http/handler/gitops/workflows/list_test.go b/api/http/handler/gitops/workflows/list_test.go new file mode 100644 index 0000000..59bc530 --- /dev/null +++ b/api/http/handler/gitops/workflows/list_test.go @@ -0,0 +1,384 @@ +package workflows + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + "testing/synctest" + "time" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/datastore" + gittypes "github.com/portainer/portainer/api/git/types" + ce "github.com/portainer/portainer/api/gitops/workflows" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWorkflowsList_GitConfigFilter(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + createGitStack(t, tx, &portainer.Stack{ + ID: 1, Name: "gitops-stack", + GitConfig: gitConfig("https://github.com/example/repo"), + }) + require.NoError(t, tx.Stack().Create(&portainer.Stack{ID: 2, Name: "plain-stack"})) + require.NoError(t, tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})) + return nil + })) + + h := NewHandler(store, nil, nil) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, "")) + + items := decodeWorkflows(t, rr) + require.Len(t, items, 1) + assert.Equal(t, "gitops-stack", items[0].Name) + assert.Equal(t, ce.TypeStack, items[0].Type) + assert.Equal(t, "https://github.com/example/repo", items[0].GitConfig.URL) + assert.Equal(t, "docker-compose.yml", items[0].GitConfig.ConfigFilePath) +} + +func TestWorkflowsList_EndpointIDsFilter(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + for i := 1; i <= 3; i++ { + createGitStack(t, tx, &portainer.Stack{ + ID: portainer.StackID(i), + Name: fmt.Sprintf("env%d-stack", i), + EndpointID: portainer.EndpointID(i), + GitConfig: gitConfig(fmt.Sprintf("https://github.com/x/%d", i)), + }) + } + require.NoError(t, tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})) + return nil + })) + + h := NewHandler(store, nil, nil) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, "endpointIds[]=1&endpointIds[]=2")) + + items := decodeWorkflows(t, rr) + require.Len(t, items, 2) + names := []string{items[0].Name, items[1].Name} + assert.Contains(t, names, "env1-stack") + assert.Contains(t, names, "env2-stack") +} + +func TestWorkflowsList_Pagination(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + for i := 1; i <= 5; i++ { + createGitStack(t, tx, &portainer.Stack{ + ID: portainer.StackID(i), + Name: fmt.Sprintf("stack-%d", i), + GitConfig: gitConfig(fmt.Sprintf("https://github.com/x/y-%d", i)), + }) + } + + require.NoError(t, tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})) + return nil + })) + + h := NewHandler(store, nil, nil) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, "start=0&limit=2")) + + items := decodeWorkflows(t, rr) + assert.Len(t, items, 2) + assert.Equal(t, "5", rr.Header().Get("X-Total-Count")) +} + +func TestWorkflowsList_Search(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + for _, s := range []*portainer.Stack{ + {ID: 1, Name: "alpha", GitConfig: gitConfig("https://github.com/org/alpha")}, + {ID: 2, Name: "beta", GitConfig: gitConfig("https://github.com/org/beta")}, + } { + createGitStack(t, tx, s) + } + + require.NoError(t, tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})) + return nil + })) + + h := NewHandler(store, nil, nil) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, "search=alpha")) + + items := decodeWorkflows(t, rr) + require.Len(t, items, 1) + assert.Equal(t, "alpha", items[0].Name) +} + +func TestWorkflowsList_SearchByURL(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + createGitStack(t, tx, &portainer.Stack{ + ID: 1, Name: "stack-org1", + GitConfig: gitConfig("https://github.com/org1/repo"), + }) + createGitStack(t, tx, &portainer.Stack{ + ID: 2, Name: "stack-org2", + GitConfig: gitConfig("https://github.com/org2/repo"), + }) + + require.NoError(t, tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})) + return nil + })) + + h := NewHandler(store, nil, nil) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, "search=org1")) + + items := decodeWorkflows(t, rr) + require.Len(t, items, 1) + assert.Equal(t, "stack-org1", items[0].Name) +} + +func TestWorkflowsList_Sort(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + for i, name := range []string{"gamma", "alpha", "beta"} { + createGitStack(t, tx, &portainer.Stack{ + ID: portainer.StackID(i + 1), + Name: name, + GitConfig: gitConfig("https://github.com/x/" + name), + }) + } + require.NoError(t, tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})) + return nil + })) + + h := NewHandler(store, nil, nil) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, "sort=name&order=desc")) + + items := decodeWorkflows(t, rr) + require.Len(t, items, 3) + assert.Equal(t, "gamma", items[0].Name) + assert.Equal(t, "beta", items[1].Name) + assert.Equal(t, "alpha", items[2].Name) +} + +// Uses testing/synctest to control time.Now() without real sleeps. +// The Handler is created outside the bubble so its go-cache cleanup goroutine +// does not join the bubble. Inside the bubble all time.Now() calls return +// fake time, so cache.Set stores a fake expiry and cache.Get compares +// against the same fake clock — consistent without touching real time. + +func TestWorkflowsList_Cache(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + createGitStack(t, tx, &portainer.Stack{ + ID: 1, Name: "initial-stack", + GitConfig: gitConfig("https://github.com/x/initial"), + }) + + require.NoError(t, tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})) + return nil + })) + + // Create the handler outside the bubble so the go-cache cleanup goroutine + // is not part of the bubble and does not block synctest.Test from returning. + h := NewHandler(store, nil, nil) + + synctest.Test(t, func(t *testing.T) { + // First request at fake T=0: populates cache. + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, "")) + require.Len(t, decodeWorkflows(t, rr), 1) + + // Mutate the store while cache is still warm. + createGitStack(t, store, &portainer.Stack{ + ID: 2, Name: "new-stack", + GitConfig: gitConfig("https://github.com/x/new"), + }) + + // Second request — same cache key, should return stale cached result. + rr = httptest.NewRecorder() + h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, "")) + assert.Len(t, decodeWorkflows(t, rr), 1, "cache hit: new stack should not appear yet") + + // Advance fake clock past the cache TTL. synctest unblocks immediately + // since no other goroutines are in the bubble. + time.Sleep(cacheTTL + time.Second) + + // Third request — cache expired, should now fetch fresh data. + rr = httptest.NewRecorder() + h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, "")) + assert.Len(t, decodeWorkflows(t, rr), 2, "after TTL expiry: both stacks should appear") + }) +} + +func TestWorkflowsList_CacheImmutableAfterSort(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + for i, name := range []string{"alpha", "beta", "gamma"} { + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + createGitStack(t, tx, &portainer.Stack{ + ID: portainer.StackID(i + 1), + Name: name, + GitConfig: gitConfig("https://github.com/x/" + name), + }) + + require.NoError(t, tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})) + return nil + })) + } + + h := NewHandler(store, nil, nil) + + // First request: no sort — cache miss, populates cache as [alpha, beta, gamma]. + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, "")) + items := decodeWorkflows(t, rr) + require.Len(t, items, 3) + require.Equal(t, "alpha", items[0].Name) + + // Second request: sort desc — cache hit, sorts the shared slice in-place to [gamma, beta, alpha]. + rr = httptest.NewRecorder() + h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, "sort=name&order=desc")) + items = decodeWorkflows(t, rr) + require.Len(t, items, 3) + require.Equal(t, "gamma", items[0].Name) + + // Third request: no sort — should still return insertion order [alpha, beta, gamma], + // but without a defensive clone the mutated cache returns [gamma, beta, alpha]. + rr = httptest.NewRecorder() + h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, "")) + items = decodeWorkflows(t, rr) + require.Len(t, items, 3) + assert.Equal(t, "alpha", items[0].Name, "sort must not mutate the cached slice") +} + +func TestWorkflowsList_CacheSeparateKeys(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + createGitStack(t, tx, &portainer.Stack{ + ID: 1, Name: "env1-stack", EndpointID: 1, + GitConfig: gitConfig("https://github.com/x/1"), + }) + createGitStack(t, tx, &portainer.Stack{ + ID: 2, Name: "env2-stack", EndpointID: 2, + GitConfig: gitConfig("https://github.com/x/2"), + }) + require.NoError(t, tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})) + return nil + })) + + h := NewHandler(store, nil, nil) + + rr1 := httptest.NewRecorder() + h.ServeHTTP(rr1, buildWorkflowsReq(t, 1, portainer.AdministratorRole, "endpointIds[]=1")) + items1 := decodeWorkflows(t, rr1) + require.Len(t, items1, 1) + assert.Equal(t, "env1-stack", items1[0].Name) + + rr2 := httptest.NewRecorder() + h.ServeHTTP(rr2, buildWorkflowsReq(t, 1, portainer.AdministratorRole, "endpointIds[]=2")) + items2 := decodeWorkflows(t, rr2) + require.Len(t, items2, 1) + assert.Equal(t, "env2-stack", items2[0].Name) +} + +func TestWorkflowsList_StatusFilter(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + createGitStack(t, tx, &portainer.Stack{ + ID: 1, Name: "healthy-stack", + GitConfig: gitConfig("https://github.com/x/1"), + }) + createGitStack(t, tx, &portainer.Stack{ + ID: 2, Name: "error-stack", + GitConfig: gitConfig("https://github.com/x/2"), + DeploymentStatus: []portainer.StackDeploymentStatus{{Status: portainer.StackStatusError}}, + }) + require.NoError(t, tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})) + return nil + })) + + h := NewHandler(store, nil, nil) + + t.Run("status=healthy returns only healthy workflows", func(t *testing.T) { + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, "status=healthy")) + items := decodeWorkflows(t, rr) + require.Len(t, items, 1) + assert.Equal(t, "healthy-stack", items[0].Name) + }) + + t.Run("status=error returns only error workflows", func(t *testing.T) { + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, "status=error")) + items := decodeWorkflows(t, rr) + require.Len(t, items, 1) + assert.Equal(t, "error-stack", items[0].Name) + }) +} + +func TestWorkflowsList_InvalidFilterParams(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + require.NoError(t, store.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})) + h := NewHandler(store, nil, nil) + + for _, query := range []string{"status=garbage", "type=garbage", "platform=garbage"} { + t.Run(query, func(t *testing.T) { + t.Parallel() + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, query)) + assert.Equal(t, http.StatusBadRequest, rr.Code) + }) + } +} + +func TestWorkflowsList_RedactsCredentials(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + cfg := gitConfig("https://github.com/x/secure") + cfg.Authentication = &gittypes.GitAuthentication{Username: "user", Password: "s3cr3t"} + + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + createGitStack(t, tx, &portainer.Stack{ + ID: 1, Name: "secure-stack", GitConfig: cfg, + }) + require.NoError(t, tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})) + return nil + })) + + h := NewHandler(store, nil, nil) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, "")) + + items := decodeWorkflows(t, rr) + require.Len(t, items, 1) + require.NotNil(t, items[0].GitConfig) + require.NotNil(t, items[0].GitConfig.Authentication) + assert.Equal(t, "user", items[0].GitConfig.Authentication.Username) + assert.Empty(t, items[0].GitConfig.Authentication.Password) +} diff --git a/api/http/handler/gitops/workflows/status_test.go b/api/http/handler/gitops/workflows/status_test.go new file mode 100644 index 0000000..b169c41 --- /dev/null +++ b/api/http/handler/gitops/workflows/status_test.go @@ -0,0 +1,84 @@ +package workflows + +import ( + "net/http/httptest" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/datastore" + ce "github.com/portainer/portainer/api/gitops/workflows" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWorkflowsList_StackStatusDerivation(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + deployStatus []portainer.StackDeploymentStatus + expectedStatus ce.Status + }{ + { + name: "no deployment status gives healthy", + expectedStatus: ce.StatusHealthy, + }, + { + name: "active gives healthy", + deployStatus: []portainer.StackDeploymentStatus{{Status: portainer.StackStatusActive}}, + expectedStatus: ce.StatusHealthy, + }, + { + name: "error gives error", + deployStatus: []portainer.StackDeploymentStatus{{Status: portainer.StackStatusError}}, + expectedStatus: ce.StatusError, + }, + { + name: "deploying gives syncing", + deployStatus: []portainer.StackDeploymentStatus{{Status: portainer.StackStatusDeploying}}, + expectedStatus: ce.StatusSyncing, + }, + { + name: "inactive gives paused", + deployStatus: []portainer.StackDeploymentStatus{{Status: portainer.StackStatusInactive}}, + expectedStatus: ce.StatusPaused, + }, + { + name: "last entry wins", + deployStatus: []portainer.StackDeploymentStatus{ + {Status: portainer.StackStatusDeploying}, + {Status: portainer.StackStatusActive}, + }, + expectedStatus: ce.StatusHealthy, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + createGitStack(t, tx, &portainer.Stack{ + ID: 1, + Name: "status-stack", + DeploymentStatus: tc.deployStatus, + GitConfig: gitConfig("https://github.com/x/y"), + }) + + require.NoError(t, tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})) + return nil + })) + + h := NewHandler(store, nil, nil) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, "")) + + items := decodeWorkflows(t, rr) + require.Len(t, items, 1) + assert.Equal(t, tc.expectedStatus, items[0].Status.Target.Status, tc.name) + }) + } +} diff --git a/api/http/handler/gitops/workflows/summary.go b/api/http/handler/gitops/workflows/summary.go new file mode 100644 index 0000000..b46a77c --- /dev/null +++ b/api/http/handler/gitops/workflows/summary.go @@ -0,0 +1,35 @@ +package workflows + +import ( + "net/http" + + svc "github.com/portainer/portainer/api/gitops/workflows" + "github.com/portainer/portainer/api/http/security" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +// @id GitOpsWorkflowsSummary +// @summary Summarize GitOps workflow status counts +// @description Returns a count of workflows per status across all environments. +// @description **Access policy**: authenticated +// @tags gitops +// @security ApiKeyAuth +// @security jwt +// @produce json +// @success 200 {object} svc.StatusSummary +// @failure 500 "Server error" +// @router /gitops/workflows/summary [get] +func (h *Handler) summary(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) + } + + items, err := h.getWorkflows(r.Context(), cacheKey(securityContext, nil), securityContext, nil) + if err != nil { + return httperror.InternalServerError("Unable to retrieve workflows", err) + } + + return response.JSON(w, svc.CountByStatus(items)) +} diff --git a/api/http/handler/gitops/workflows/summary_test.go b/api/http/handler/gitops/workflows/summary_test.go new file mode 100644 index 0000000..8a9f0d7 --- /dev/null +++ b/api/http/handler/gitops/workflows/summary_test.go @@ -0,0 +1,93 @@ +package workflows + +import ( + "net/http" + "net/http/httptest" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/datastore" + ce "github.com/portainer/portainer/api/gitops/workflows" + "github.com/portainer/portainer/api/http/security" + + "github.com/segmentio/encoding/json" + "github.com/stretchr/testify/require" +) + +func buildSummaryReq(t *testing.T, userID portainer.UserID, role portainer.UserRole) *http.Request { + t.Helper() + req := httptest.NewRequest(http.MethodGet, "/gitops/workflows/summary", nil) + ctx := security.StoreTokenData(req, &portainer.TokenData{ID: userID}) + req = req.WithContext(ctx) + ctx = security.StoreRestrictedRequestContext(req, &security.RestrictedRequestContext{ + UserID: userID, + IsAdmin: security.IsAdminRole(role), + User: &portainer.User{ID: userID, Role: role}, + }) + return req.WithContext(ctx) +} + +func decodeSummary(t *testing.T, rr *httptest.ResponseRecorder) ce.StatusSummary { + t.Helper() + require.Equal(t, http.StatusOK, rr.Code, "unexpected status: %s", rr.Body.String()) + var s ce.StatusSummary + require.NoError(t, json.NewDecoder(rr.Body).Decode(&s)) + return s +} + +func TestWorkflowsSummary_Empty(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}) + })) + + h := NewHandler(store, nil, nil) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildSummaryReq(t, 1, portainer.AdministratorRole)) + + s := decodeSummary(t, rr) + require.Equal(t, ce.StatusSummary{}, s) +} + +func TestWorkflowsSummary_CountsHealthyAndError(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + // No deployment status, target healthy, effective status = healthy. + createGitStack(t, tx, &portainer.Stack{ + ID: 1, Name: "healthy-stack", + GitConfig: gitConfig("https://github.com/x/1"), + }) + + // Error deployment status, target error, effective status = error. + createGitStack(t, tx, &portainer.Stack{ + ID: 2, Name: "error-stack", + GitConfig: gitConfig("https://github.com/x/2"), + DeploymentStatus: []portainer.StackDeploymentStatus{{Status: portainer.StackStatusError}}, + }) + + // Deploying deployment status, target syncing, effective status = syncing. + createGitStack(t, tx, &portainer.Stack{ + ID: 3, Name: "syncing-stack", + GitConfig: gitConfig("https://github.com/x/3"), + DeploymentStatus: []portainer.StackDeploymentStatus{{Status: portainer.StackStatusDeploying}}, + }) + + return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}) + })) + + h := NewHandler(store, nil, nil) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildSummaryReq(t, 1, portainer.AdministratorRole)) + + s := decodeSummary(t, rr) + require.Equal(t, 1, s.Healthy) + require.Equal(t, 1, s.Error) + require.Equal(t, 1, s.Syncing) + require.Zero(t, s.Paused) + require.Zero(t, s.Unknown) +} diff --git a/api/http/handler/handler.go b/api/http/handler/handler.go new file mode 100644 index 0000000..33d3417 --- /dev/null +++ b/api/http/handler/handler.go @@ -0,0 +1,292 @@ +package handler + +import ( + "net/http" + "strings" + + "github.com/portainer/portainer/api/http/handler/auth" + "github.com/portainer/portainer/api/http/handler/backup" + "github.com/portainer/portainer/api/http/handler/customtemplates" + "github.com/portainer/portainer/api/http/handler/docker" + "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/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/file" + "github.com/portainer/portainer/api/http/handler/gitops" + "github.com/portainer/portainer/api/http/handler/helm" + "github.com/portainer/portainer/api/http/handler/kubernetes" + "github.com/portainer/portainer/api/http/handler/ldap" + "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/ssl" + "github.com/portainer/portainer/api/http/handler/stacks" + "github.com/portainer/portainer/api/http/handler/storybook" + "github.com/portainer/portainer/api/http/handler/system" + "github.com/portainer/portainer/api/http/handler/tags" + "github.com/portainer/portainer/api/http/handler/teammemberships" + "github.com/portainer/portainer/api/http/handler/teams" + "github.com/portainer/portainer/api/http/handler/templates" + "github.com/portainer/portainer/api/http/handler/upload" + "github.com/portainer/portainer/api/http/handler/users" + "github.com/portainer/portainer/api/http/handler/webhooks" + "github.com/portainer/portainer/api/http/handler/websocket" +) + +// Handler is a collection of all the service handlers. +type Handler struct { + AuthHandler *auth.Handler + BackupHandler *backup.Handler + CustomTemplatesHandler *customtemplates.Handler + DockerHandler *docker.Handler + EdgeGroupsHandler *edgegroups.Handler + EdgeJobsHandler *edgejobs.Handler + EdgeStacksHandler *edgestacks.Handler + EndpointEdgeHandler *endpointedge.Handler + EndpointGroupHandler *endpointgroups.Handler + EndpointHandler *endpoints.Handler + EndpointHelmHandler *helm.Handler + EndpointProxyHandler *endpointproxy.Handler + GitOperationHandler *gitops.Handler + HelmTemplatesHandler *helm.Handler + KubernetesHandler *kubernetes.Handler + FileHandler *file.Handler + LDAPHandler *ldap.Handler + MOTDHandler *motd.Handler + RegistryHandler *registries.Handler + ResourceControlHandler *resourcecontrols.Handler + RoleHandler *roles.Handler + SettingsHandler *settings.Handler + SSLHandler *ssl.Handler + StackHandler *stacks.Handler + StorybookHandler *storybook.Handler + SystemHandler *system.Handler + TagHandler *tags.Handler + TeamMembershipHandler *teammemberships.Handler + TeamHandler *teams.Handler + TemplatesHandler *templates.Handler + UploadHandler *upload.Handler + UserHandler *users.Handler + WebSocketHandler *websocket.Handler + WebhookHandler *webhooks.Handler + UserHelmHandler *helm.Handler +} + +// @title PortainerCE API +// @version 2.43.0 +// @description.markdown +// @x-tagGroups [{"name":"Access Control","tags":["auth","roles","team_memberships","teams","users"]},{"name":"Administration","tags":["backup","ldap","motd","settings","status","system","ssl","upload"]},{"name":"Docker","tags":["templates","custom_templates","docker","registries","resource_controls","stacks","webhooks","websocket"]},{"name":"Edge Compute","tags":["edge_agent","edge_groups","edge_jobs","edge","edge_stacks"]},{"name":"Environment Management","tags":["endpoint_groups","endpoints","tags"]},{"name":"GitOps","tags":["gitops"]},{"name":"Kubernetes","tags":["helm","kubernetes"]}] +// @termsOfService + +// @contact.email info@portainer.io + +// @license.name zlib +// @license.url https://github.com/portainer/portainer/blob/develop/LICENSE + +// @host +// @BasePath /api +// @schemes http https + +// @securitydefinitions.apikey ApiKeyAuth +// @in header +// @name X-API-KEY + +// @securitydefinitions.apikey jwt +// @in header +// @name Authorization + +// @tag.name auth +// @tag.description Authenticate against Portainer HTTP API +// @tag.x-displayName Authentication +// @tag.name backup +// @tag.description Manage backups +// @tag.x-displayName Backup +// @tag.name custom_templates +// @tag.description Manage Custom Templates +// @tag.x-displayName Custom templates +// @tag.name docker +// @tag.description Manage Docker resources +// @tag.x-displayName Docker resources +// @tag.name edge +// @tag.description Manage Edge related settings +// @tag.x-displayName Edge settings +// @tag.name edge_agent +// @tag.description Manage an Edge agent +// @tag.x-displayName Edge agent +// @tag.name edge_groups +// @tag.description Manage Edge Groups +// @tag.x-displayName Edge groups +// @tag.name edge_jobs +// @tag.description Manage Edge Jobs +// @tag.x-displayName Edge jobs +// @tag.name edge_stacks +// @tag.description Manage Edge Stacks +// @tag.x-displayName Edge stacks +// @tag.name edge_templates +// @tag.description Manage Edge Templates +// @tag.x-displayName Edge templates +// @tag.name endpoint_groups +// @tag.description Manage environment groups +// @tag.x-displayName Environment groups +// @tag.name endpoints +// @tag.description Manage environments +// @tag.x-displayName Environments +// @tag.name gitops +// @tag.description Operate git repository +// @tag.x-displayName GitOps +// @tag.name helm +// @tag.description Manage Helm charts +// @tag.x-displayName Helm charts +// @tag.name kubernetes +// @tag.description Manage Kubernetes cluster +// @tag.x-displayName Kubernetes +// @tag.name ldap +// @tag.description Manage LDAP settings +// @tag.x-displayName LDAP +// @tag.name motd +// @tag.description Fetch the message of the day +// @tag.x-displayName Message of the day +// @tag.name registries +// @tag.description Manage Docker registries +// @tag.x-displayName Registries +// @tag.name resource_controls +// @tag.description Manage access control on Docker resources +// @tag.x-displayName Resource controls +// @tag.name roles +// @tag.description Manage roles +// @tag.x-displayName Roles +// @tag.name settings +// @tag.description Manage Portainer settings +// @tag.x-displayName Portainer settings +// @tag.name ssl +// @tag.description Manage ssl settings +// @tag.x-displayName SSL +// @tag.name stacks +// @tag.description Manage stacks +// @tag.x-displayName Stacks +// @tag.name status +// @tag.description Information about the Portainer instance +// @tag.x-displayName Portainer status +// @tag.name system +// @tag.description Manage Portainer system +// @tag.x-displayName Portainer system +// @tag.name tags +// @tag.description Manage tags +// @tag.x-displayName Tags +// @tag.name team_memberships +// @tag.description Manage team memberships +// @tag.x-displayName Team memberships +// @tag.name teams +// @tag.description Manage teams +// @tag.x-displayName Teams +// @tag.name templates +// @tag.description Manage App Templates +// @tag.x-displayName App templates +// @tag.name upload +// @tag.description Upload files +// @tag.x-displayName Upload files +// @tag.name users +// @tag.description Manage users +// @tag.x-displayName Users +// @tag.name webhooks +// @tag.description Manage webhooks +// @tag.x-displayName Webhooks +// @tag.name websocket +// @tag.description Create exec sessions using websockets +// @tag.x-displayName Websocket + +// ServeHTTP delegates a request to the appropriate subhandler. +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasPrefix(r.URL.Path, "/api/endpoints") && strings.Contains(r.URL.Path, "/edge/"): + h.EndpointEdgeHandler.ServeHTTP(w, r) + case strings.HasPrefix(r.URL.Path, "/api/auth"): + http.StripPrefix("/api", h.AuthHandler).ServeHTTP(w, r) + case strings.HasPrefix(r.URL.Path, "/api/backup"): + http.StripPrefix("/api", h.BackupHandler).ServeHTTP(w, r) + case strings.HasPrefix(r.URL.Path, "/api/restore"): + http.StripPrefix("/api", h.BackupHandler).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/endpoint_groups"): + http.StripPrefix("/api", h.EndpointGroupHandler).ServeHTTP(w, r) + case strings.HasPrefix(r.URL.Path, "/api/kubernetes"): + http.StripPrefix("/api", h.KubernetesHandler).ServeHTTP(w, r) + case strings.HasPrefix(r.URL.Path, "/api/docker"): + http.StripPrefix("/api", h.DockerHandler).ServeHTTP(w, r) + + // Helm subpath under kubernetes -> /api/endpoints/{id}/kubernetes/helm + case strings.HasPrefix(r.URL.Path, "/api/endpoints/") && strings.Contains(r.URL.Path, "/kubernetes/helm"): + http.StripPrefix("/api/endpoints", h.EndpointHelmHandler).ServeHTTP(w, r) + + case strings.HasPrefix(r.URL.Path, "/api/endpoints"): + 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, "/azure/"): + http.StripPrefix("/api/endpoints", h.EndpointProxyHandler).ServeHTTP(w, r) + case strings.Contains(r.URL.Path, "/agent/"): + http.StripPrefix("/api/endpoints", h.EndpointProxyHandler).ServeHTTP(w, r) + default: + http.StripPrefix("/api", h.EndpointHandler).ServeHTTP(w, r) + } + case strings.HasPrefix(r.URL.Path, "/api/gitops"): + http.StripPrefix("/api", h.GitOperationHandler).ServeHTTP(w, r) + case strings.HasPrefix(r.URL.Path, "/api/ldap"): + http.StripPrefix("/api", h.LDAPHandler).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"): + http.StripPrefix("/api", h.RegistryHandler).ServeHTTP(w, r) + case strings.HasPrefix(r.URL.Path, "/api/resource_controls"): + 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/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.SystemHandler).ServeHTTP(w, r) + case strings.HasPrefix(r.URL.Path, "/api/system"): + http.StripPrefix("/api", h.SystemHandler).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/helm"): + http.StripPrefix("/api", h.HelmTemplatesHandler).ServeHTTP(w, r) + case strings.HasPrefix(r.URL.Path, "/api/templates"): + http.StripPrefix("/api", h.TemplatesHandler).ServeHTTP(w, r) + case strings.HasPrefix(r.URL.Path, "/api/upload"): + http.StripPrefix("/api", h.UploadHandler).ServeHTTP(w, r) + case strings.HasPrefix(r.URL.Path, "/api/users"): + http.StripPrefix("/api", h.UserHandler).ServeHTTP(w, r) + case strings.HasPrefix(r.URL.Path, "/api/ssl"): + http.StripPrefix("/api", h.SSLHandler).ServeHTTP(w, r) + case strings.HasPrefix(r.URL.Path, "/api/teams"): + http.StripPrefix("/api", h.TeamHandler).ServeHTTP(w, r) + case strings.HasPrefix(r.URL.Path, "/api/team_memberships"): + http.StripPrefix("/api", h.TeamMembershipHandler).ServeHTTP(w, r) + case strings.HasPrefix(r.URL.Path, "/api/websocket"): + http.StripPrefix("/api", h.WebSocketHandler).ServeHTTP(w, r) + case strings.HasPrefix(r.URL.Path, "/api/webhooks"): + http.StripPrefix("/api", h.WebhookHandler).ServeHTTP(w, r) + case strings.HasPrefix(r.URL.Path, "/storybook"): + http.StripPrefix("/storybook", h.StorybookHandler).ServeHTTP(w, r) + case strings.HasPrefix(r.URL.Path, "/"): + h.FileHandler.ServeHTTP(w, r) + } +} diff --git a/api/http/handler/helm/handler.go b/api/http/handler/helm/handler.go new file mode 100644 index 0000000..f7f14a1 --- /dev/null +++ b/api/http/handler/helm/handler.go @@ -0,0 +1,130 @@ +package helm + +import ( + "fmt" + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/http/middlewares" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/kubernetes" + "github.com/portainer/portainer/pkg/libhelm/options" + libhelmtypes "github.com/portainer/portainer/pkg/libhelm/types" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + + "github.com/gorilla/mux" +) + +// Handler is the HTTP handler used to handle environment(endpoint) group operations. +type Handler struct { + *mux.Router + requestBouncer security.BouncerService + dataStore dataservices.DataStore + jwtService portainer.JWTService + kubeClusterAccessService kubernetes.KubeClusterAccessService + kubernetesDeployer portainer.KubernetesDeployer + helmPackageManager libhelmtypes.HelmPackageManager +} + +// NewHandler creates a handler to manage endpoint group operations. +func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStore, jwtService portainer.JWTService, kubernetesDeployer portainer.KubernetesDeployer, helmPackageManager libhelmtypes.HelmPackageManager, kubeClusterAccessService kubernetes.KubeClusterAccessService) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + requestBouncer: bouncer, + dataStore: dataStore, + jwtService: jwtService, + kubernetesDeployer: kubernetesDeployer, + helmPackageManager: helmPackageManager, + kubeClusterAccessService: kubeClusterAccessService, + } + + h.Use(middlewares.WithEndpoint(dataStore.Endpoint(), "id"), + bouncer.AuthenticatedAccess) + + // `helm list -o json` + h.Handle("/{id}/kubernetes/helm", + httperror.LoggerHandler(h.helmList)).Methods(http.MethodGet) + + // `helm delete RELEASE_NAME` + h.Handle("/{id}/kubernetes/helm/{release}", + httperror.LoggerHandler(h.helmDelete)).Methods(http.MethodDelete) + + // `helm install [NAME] [CHART] flags` + h.Handle("/{id}/kubernetes/helm", + httperror.LoggerHandler(h.helmInstall)).Methods(http.MethodPost) + + // `helm get all [RELEASE_NAME]` + h.Handle("/{id}/kubernetes/helm/{release}", + httperror.LoggerHandler(h.helmGet)).Methods(http.MethodGet) + + // `helm history [RELEASE_NAME]` + h.Handle("/{id}/kubernetes/helm/{release}/history", + httperror.LoggerHandler(h.helmGetHistory)).Methods(http.MethodGet) + + // `helm rollback [RELEASE_NAME] [REVISION]` + h.Handle("/{id}/kubernetes/helm/{release}/rollback", + httperror.LoggerHandler(h.helmRollback)).Methods(http.MethodPost) + + return h +} + +// NewTemplateHandler creates a template handler to manage environment(endpoint) group operations. +func NewTemplateHandler(bouncer security.BouncerService, helmPackageManager libhelmtypes.HelmPackageManager) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + helmPackageManager: helmPackageManager, + requestBouncer: bouncer, + } + + h.Use(bouncer.AuthenticatedAccess) + + h.Handle("/templates/helm", + httperror.LoggerHandler(h.helmRepoSearch)).Methods(http.MethodGet) + + // helm show [COMMAND] [CHART] [REPO] flags + h.Handle("/templates/helm/{command:chart|values|readme}", + httperror.LoggerHandler(h.helmShow)).Methods(http.MethodGet) + + return h +} + +// getHelmClusterAccess obtains the core k8s cluster access details from request. +// The cluster access includes the cluster server url, the user's bearer token and the tls certificate. +// The cluster access is passed in as kube config CLI params to helm. +func (handler *Handler) getHelmClusterAccess(r *http.Request) (*options.KubernetesClusterAccess, *httperror.HandlerError) { + endpoint, err := middlewares.FetchEndpoint(r) + if err != nil { + return nil, httperror.NotFound("Unable to find an environment on request context", err) + } + + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + return nil, httperror.InternalServerError("Unable to retrieve user authentication token", err) + } + + bearerToken, _, err := handler.jwtService.GenerateToken(tokenData) + if err != nil { + return nil, httperror.Unauthorized("Unauthorized", err) + } + + sslSettings, err := handler.dataStore.SSLSettings().Settings() + if err != nil { + return nil, httperror.InternalServerError("Unable to retrieve settings from the database", err) + } + + hostURL := "localhost" + if !sslSettings.SelfSigned { + hostURL = r.Host + } + + kubeConfigInternal := handler.kubeClusterAccessService.GetClusterDetails(hostURL, endpoint.ID, true) + return &options.KubernetesClusterAccess{ + ClusterName: fmt.Sprintf("%s-%s", "portainer-cluster", endpoint.Name), + ContextName: fmt.Sprintf("%s-%s", "portainer-ctx", endpoint.Name), + UserName: fmt.Sprintf("%s-%s", "portainer-sa-user", tokenData.Username), + ClusterServerURL: kubeConfigInternal.ClusterServerURL, + CertificateAuthorityFile: kubeConfigInternal.CertificateAuthorityFile, + AuthToken: bearerToken, + }, nil +} diff --git a/api/http/handler/helm/helm_delete.go b/api/http/handler/helm/helm_delete.go new file mode 100644 index 0000000..f222243 --- /dev/null +++ b/api/http/handler/helm/helm_delete.go @@ -0,0 +1,55 @@ +package helm + +import ( + "net/http" + + "github.com/portainer/portainer/pkg/libhelm/options" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +// @id HelmDelete +// @summary Delete Helm Release +// @description +// @description **Access policy**: authenticated +// @tags helm +// @security ApiKeyAuth +// @security jwt +// @param id path int true "Environment(Endpoint) identifier" +// @param release path string true "The name of the release/application to uninstall" +// @param namespace query string false "An optional namespace" +// @success 204 "Success" +// @failure 400 "Invalid environment(endpoint) id or bad request" +// @failure 401 "Unauthorized" +// @failure 404 "Environment(Endpoint) or ServiceAccount not found" +// @failure 500 "Server error or helm error" +// @router /endpoints/{id}/kubernetes/helm/{release} [delete] +func (handler *Handler) helmDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + release, err := request.RetrieveRouteVariableValue(r, "release") + if err != nil { + return httperror.BadRequest("No release specified", err) + } + + clusterAccess, httperr := handler.getHelmClusterAccess(r) + if httperr != nil { + return httperr + } + + uninstallOpts := options.UninstallOptions{ + Name: release, + KubernetesClusterAccess: clusterAccess, + } + + q := r.URL.Query() + if namespace := q.Get("namespace"); namespace != "" { + uninstallOpts.Namespace = namespace + } + + err = handler.helmPackageManager.Uninstall(uninstallOpts) + if err != nil { + return httperror.InternalServerError("Helm returned an error", err) + } + + return response.Empty(w) +} diff --git a/api/http/handler/helm/helm_delete_test.go b/api/http/handler/helm/helm_delete_test.go new file mode 100644 index 0000000..cf0e4e3 --- /dev/null +++ b/api/http/handler/helm/helm_delete_test.go @@ -0,0 +1,38 @@ +package helm + +import ( + "net/http" + "net/http/httptest" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/testhelpers" + "github.com/portainer/portainer/pkg/libhelm/options" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_helmDelete(t *testing.T) { + is := assert.New(t) + h := newTestHelmHandler(t) + + // Install a single chart directly, to be deleted by the handler + options := options.InstallOptions{Name: "nginx-1", Chart: "nginx", Namespace: "default"} + + _, err := h.helmPackageManager.Upgrade(options) + require.NoError(t, err) + + t.Run("helmDelete succeeds with admin user", func(t *testing.T) { + req := httptest.NewRequest(http.MethodDelete, "/1/kubernetes/helm/"+options.Name, nil) + ctx := security.StoreTokenData(req, &portainer.TokenData{ID: 1, Username: "admin", Role: 1}) + req = req.WithContext(ctx) + testhelpers.AddTestSecurityCookie(req, "Bearer dummytoken") + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + is.Equal(http.StatusNoContent, rr.Code, "Status should be 204") + }) +} diff --git a/api/http/handler/helm/helm_get.go b/api/http/handler/helm/helm_get.go new file mode 100644 index 0000000..cb58694 --- /dev/null +++ b/api/http/handler/helm/helm_get.go @@ -0,0 +1,67 @@ +package helm + +import ( + "net/http" + + "github.com/portainer/portainer/pkg/libhelm/options" + _ "github.com/portainer/portainer/pkg/libhelm/release" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +// @id HelmGet +// @summary Get a helm release +// @description Get details of a helm release by release name +// @description **Access policy**: authenticated +// @tags helm +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment(Endpoint) identifier" +// @param release path string true "Helm release name" +// @param namespace query string false "specify an optional namespace" +// @param showResources query boolean false "show resources of the release" +// @param revision query int false "specify an optional revision" +// @success 200 {object} release.Release "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier." +// @failure 500 "Server error occurred while attempting to retrieve the release." +// @router /endpoints/{id}/kubernetes/helm/{release} [get] +func (handler *Handler) helmGet(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + release, err := request.RetrieveRouteVariableValue(r, "release") + if err != nil { + return httperror.BadRequest("No release specified", err) + } + + clusterAccess, httperr := handler.getHelmClusterAccess(r) + if httperr != nil { + return httperr + } + + // build the get options + getOpts := options.GetOptions{ + KubernetesClusterAccess: clusterAccess, + Name: release, + } + namespace, _ := request.RetrieveQueryParameter(r, "namespace", true) + // optional namespace. The library defaults to "default" + if namespace != "" { + getOpts.Namespace = namespace + } + showResources, _ := request.RetrieveBooleanQueryParameter(r, "showResources", true) + getOpts.ShowResources = showResources + revision, _ := request.RetrieveNumericQueryParameter(r, "revision", true) + // optional revision. The library defaults to the latest revision if not specified + if revision > 0 { + getOpts.Revision = revision + } + + releases, err := handler.helmPackageManager.Get(getOpts) + if err != nil { + return httperror.InternalServerError("Helm returned an error", err) + } + + return response.JSON(w, releases) +} diff --git a/api/http/handler/helm/helm_get_test.go b/api/http/handler/helm/helm_get_test.go new file mode 100644 index 0000000..555275a --- /dev/null +++ b/api/http/handler/helm/helm_get_test.go @@ -0,0 +1,48 @@ +package helm + +import ( + "io" + "net/http" + "net/http/httptest" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/testhelpers" + "github.com/portainer/portainer/pkg/libhelm/options" + "github.com/portainer/portainer/pkg/libhelm/release" + + "github.com/segmentio/encoding/json" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_helmGet(t *testing.T) { + is := assert.New(t) + h := newTestHelmHandler(t) + + // Install a single chart, to be retrieved by the handler + options := options.InstallOptions{Name: "nginx-1", Chart: "nginx", Namespace: "default"} + + _, err := h.helmPackageManager.Upgrade(options) + require.NoError(t, err) + + t.Run("helmGet sucessfuly retrieves helm release", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/1/kubernetes/helm/"+options.Name+"?namespace="+options.Namespace, nil) + ctx := security.StoreTokenData(req, &portainer.TokenData{ID: 1, Username: "admin", Role: 1}) + req = req.WithContext(ctx) + testhelpers.AddTestSecurityCookie(req, "Bearer dummytoken") + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + data := release.Release{} + body, err := io.ReadAll(rr.Body) + require.NoError(t, err, "ReadAll should not return error") + + err = json.Unmarshal(body, &data) + require.NoError(t, err) + is.Equal(http.StatusOK, rr.Code, "Status should be 200") + is.Equal("nginx-1", data.Name) + }) +} diff --git a/api/http/handler/helm/helm_history.go b/api/http/handler/helm/helm_history.go new file mode 100644 index 0000000..6a24648 --- /dev/null +++ b/api/http/handler/helm/helm_history.go @@ -0,0 +1,58 @@ +package helm + +import ( + "net/http" + + "github.com/portainer/portainer/pkg/libhelm/options" + _ "github.com/portainer/portainer/pkg/libhelm/release" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +// @id HelmGetHistory +// @summary Get a historical list of releases +// @description Get a historical list of releases by release name +// @description **Access policy**: authenticated +// @tags helm +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment(Endpoint) identifier" +// @param release path string true "Helm release name" +// @param namespace query string false "specify an optional namespace" +// @success 200 {array} release.Release "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier." +// @failure 500 "Server error occurred while attempting to retrieve the historical list of releases." +// @router /endpoints/{id}/kubernetes/helm/{release}/history [get] +func (handler *Handler) helmGetHistory(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + release, err := request.RetrieveRouteVariableValue(r, "release") + if err != nil { + return httperror.BadRequest("No release specified", err) + } + + clusterAccess, httperr := handler.getHelmClusterAccess(r) + if httperr != nil { + return httperr + } + + historyOptions := options.HistoryOptions{ + KubernetesClusterAccess: clusterAccess, + Name: release, + } + + // optional namespace. The library defaults to "default" + namespace, _ := request.RetrieveQueryParameter(r, "namespace", true) + if namespace != "" { + historyOptions.Namespace = namespace + } + + releases, err := handler.helmPackageManager.GetHistory(historyOptions) + if err != nil { + return httperror.InternalServerError("Helm returned an error", err) + } + + return response.JSON(w, releases) +} diff --git a/api/http/handler/helm/helm_history_test.go b/api/http/handler/helm/helm_history_test.go new file mode 100644 index 0000000..f53a5f9 --- /dev/null +++ b/api/http/handler/helm/helm_history_test.go @@ -0,0 +1,49 @@ +package helm + +import ( + "io" + "net/http" + "net/http/httptest" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/testhelpers" + "github.com/portainer/portainer/pkg/libhelm/options" + "github.com/portainer/portainer/pkg/libhelm/release" + + "github.com/segmentio/encoding/json" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_helmGetHistory(t *testing.T) { + is := assert.New(t) + h := newTestHelmHandler(t) + + // Install a single chart, to be retrieved by the handler + options := options.InstallOptions{Name: "nginx-1", Chart: "nginx", Namespace: "default"} + + _, err := h.helmPackageManager.Upgrade(options) + require.NoError(t, err) + + t.Run("helmGetHistory sucessfuly retrieves helm release history", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/1/kubernetes/helm/"+options.Name+"/history?namespace="+options.Namespace, nil) + ctx := security.StoreTokenData(req, &portainer.TokenData{ID: 1, Username: "admin", Role: 1}) + req = req.WithContext(ctx) + testhelpers.AddTestSecurityCookie(req, "Bearer dummytoken") + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + data := []release.Release{} + body, err := io.ReadAll(rr.Body) + require.NoError(t, err, "ReadAll should not return error") + + err = json.Unmarshal(body, &data) + require.NoError(t, err) + is.Equal(http.StatusOK, rr.Code, "Status should be 200") + is.Len(data, 1) + is.Equal("nginx-1", data[0].Name) + }) +} diff --git a/api/http/handler/helm/helm_install.go b/api/http/handler/helm/helm_install.go new file mode 100644 index 0000000..1934fa9 --- /dev/null +++ b/api/http/handler/helm/helm_install.go @@ -0,0 +1,174 @@ +package helm + +import ( + "fmt" + "net/http" + "os" + "strings" + + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/kubernetes" + "github.com/portainer/portainer/api/kubernetes/validation" + "github.com/portainer/portainer/api/logs" + "github.com/portainer/portainer/pkg/libhelm/options" + "github.com/portainer/portainer/pkg/libhelm/release" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" + "github.com/portainer/portainer/pkg/libhttp/ssrf" + "github.com/rs/zerolog/log" + + "github.com/pkg/errors" +) + +type installChartPayload struct { + Namespace string `json:"namespace"` + Name string `json:"name"` + Chart string `json:"chart"` + Repo string `json:"repo"` + Values string `json:"values"` + Version string `json:"version"` + Atomic bool `json:"atomic"` +} + +var errChartNameInvalid = errors.New("invalid chart name. " + + "Chart name must consist of lower case alphanumeric characters, '-' or '.'," + + " and must start and end with an alphanumeric character", +) + +// @id HelmInstall +// @summary Install Helm Chart +// @description +// @description **Access policy**: authenticated +// @tags helm +// @security ApiKeyAuth +// @security jwt +// @accept json +// @produce json +// @param id path int true "Environment(Endpoint) identifier" +// @param payload body installChartPayload true "Chart details" +// @param dryRun query bool false "Dry run" +// @success 201 {object} release.Release "Created" +// @failure 400 "Invalid request payload" +// @failure 401 "Unauthorized" +// @failure 404 "Environment(Endpoint) or ServiceAccount not found" +// @failure 500 "Server error" +// @router /endpoints/{id}/kubernetes/helm [post] +func (handler *Handler) helmInstall(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + dryRun, err := request.RetrieveBooleanQueryParameter(r, "dryRun", true) + if err != nil { + return httperror.BadRequest("Invalid dryRun query parameter", err) + } + + var payload installChartPayload + if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil { + return httperror.BadRequest("Invalid Helm install payload", err) + } + + if err := ssrf.CheckURL(r.Context(), payload.Repo); err != nil { + return httperror.BadRequest("Repository URL blocked by SSRF policy", err) + } + + release, err := handler.installChart(r, payload, dryRun) + if err != nil { + return httperror.InternalServerError("Unable to install a chart", err) + } + + return response.JSONWithStatus(w, release, http.StatusCreated) +} + +func (p *installChartPayload) Validate(_ *http.Request) error { + var required []string + if p.Repo == "" { + required = append(required, "repo") + } + + if p.Name == "" { + required = append(required, "name") + } + + if p.Namespace == "" { + required = append(required, "namespace") + } + + if p.Chart == "" { + required = append(required, "chart") + } + + if len(required) > 0 { + return fmt.Errorf("required field(s) missing: %s", strings.Join(required, ", ")) + } + + if err := validation.IsDNS1123Subdomain(p.Name); err != nil { + return errChartNameInvalid + } + + return nil +} + +func (handler *Handler) installChart(r *http.Request, p installChartPayload, dryRun bool) (*release.Release, error) { + clusterAccess, httperr := handler.getHelmClusterAccess(r) + if httperr != nil { + return nil, httperr.Err + } + + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + return nil, errors.Wrap(err, "unable to retrieve user details from authentication token") + } + + var username string + if err := handler.dataStore.ViewTx(func(tx dataservices.DataStoreTx) error { + user, err := tx.User().Read(tokenData.ID) + if err != nil { + return errors.Wrap(err, "unable to load user information from the database") + } + username = user.Username + return nil + }); err != nil { + return nil, err + } + + installOpts := options.InstallOptions{ + Name: p.Name, + Chart: p.Chart, + Version: p.Version, + Namespace: p.Namespace, + Repo: p.Repo, + Atomic: p.Atomic, + DryRun: dryRun, + KubernetesClusterAccess: clusterAccess, + HelmAppLabels: kubernetes.GetHelmAppLabels(p.Name, username), + } + + if p.Values != "" { + file, err := os.CreateTemp("", "helm-values") + if err != nil { + return nil, err + } + defer func() { + if err := os.Remove(file.Name()); err != nil { + log.Warn().Err(err).Msg("failed to remove temporary helm values file") + } + }() + + if _, err := file.WriteString(p.Values); err != nil { + logs.CloseAndLogErr(file) + return nil, err + } + + if err := file.Close(); err != nil { + return nil, err + } + + installOpts.ValuesFile = file.Name() + } + + release, err := handler.helmPackageManager.Upgrade(installOpts) + if err != nil { + return nil, err + } + + return release, nil +} diff --git a/api/http/handler/helm/helm_install_test.go b/api/http/handler/helm/helm_install_test.go new file mode 100644 index 0000000..2ce6fb0 --- /dev/null +++ b/api/http/handler/helm/helm_install_test.go @@ -0,0 +1,76 @@ +package helm + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/exec/exectest" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/testhelpers" + "github.com/portainer/portainer/api/jwt" + "github.com/portainer/portainer/api/kubernetes" + "github.com/portainer/portainer/pkg/libhelm/options" + "github.com/portainer/portainer/pkg/libhelm/release" + helmtest "github.com/portainer/portainer/pkg/libhelm/test" + + "github.com/segmentio/encoding/json" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newTestHelmHandler(t *testing.T) *Handler { + t.Helper() + + _, store := datastore.MustNewTestStore(t, true, true) + + err := store.Endpoint().Create(&portainer.Endpoint{ID: 1}) + require.NoError(t, err) + + err = store.User().Create(&portainer.User{Username: "admin", Role: portainer.AdministratorRole}) + require.NoError(t, err) + + jwtService, err := jwt.NewService("1h", store) + require.NoError(t, err) + + kubernetesDeployer := exectest.NewKubernetesDeployer() + helmPackageManager := helmtest.NewMockHelmPackageManager() + kubeClusterAccessService := kubernetes.NewKubeClusterAccessService("", "", "") + + return NewHandler(testhelpers.NewTestRequestBouncer(), store, jwtService, kubernetesDeployer, helmPackageManager, kubeClusterAccessService) +} + +func Test_helmInstall(t *testing.T) { + is := assert.New(t) + h := newTestHelmHandler(t) + + // Install a single chart. We expect to get these values back + options := options.InstallOptions{Name: "nginx-1", Chart: "nginx", Namespace: "default", Repo: "https://charts.bitnami.com/bitnami"} + optdata, err := json.Marshal(options) + require.NoError(t, err) + + t.Run("helmInstall succeeds with admin user", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/1/kubernetes/helm", bytes.NewBuffer(optdata)) + ctx := security.StoreTokenData(req, &portainer.TokenData{ID: 1, Username: "admin", Role: 1}) + req = req.WithContext(ctx) + testhelpers.AddTestSecurityCookie(req, "Bearer dummytoken") + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + is.Equal(http.StatusCreated, rr.Code, "Status should be 201") + + body, err := io.ReadAll(rr.Body) + require.NoError(t, err, "ReadAll should not return error") + + resp := release.Release{} + err = json.Unmarshal(body, &resp) + require.NoError(t, err, "response should be json") + is.Equal(options.Name, resp.Name, "Name doesn't match") + is.Equal(options.Namespace, resp.Namespace, "Namespace doesn't match") + }) +} diff --git a/api/http/handler/helm/helm_list.go b/api/http/handler/helm/helm_list.go new file mode 100644 index 0000000..124d010 --- /dev/null +++ b/api/http/handler/helm/helm_list.go @@ -0,0 +1,63 @@ +package helm + +import ( + "net/http" + + "github.com/portainer/portainer/pkg/libhelm/options" + _ "github.com/portainer/portainer/pkg/libhelm/release" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +// @id HelmList +// @summary List Helm Releases +// @description +// @description **Access policy**: authenticated +// @tags helm +// @security ApiKeyAuth +// @security jwt +// @produce json +// @param id path int true "Environment(Endpoint) identifier" +// @param namespace query string false "specify an optional namespace" +// @param filter query string false "specify an optional filter" +// @param selector query string false "specify an optional selector" +// @success 200 {array} release.ReleaseElement "Success" +// @failure 400 "Invalid environment(endpoint) identifier" +// @failure 401 "Unauthorized" +// @failure 404 "Environment(Endpoint) or ServiceAccount not found" +// @failure 500 "Server error" +// @router /endpoints/{id}/kubernetes/helm [get] +func (handler *Handler) helmList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + clusterAccess, httperr := handler.getHelmClusterAccess(r) + if httperr != nil { + return httperr + } + + listOpts := options.ListOptions{ + KubernetesClusterAccess: clusterAccess, + } + + // optional namespace. The library defaults to "default" + namespace, _ := request.RetrieveQueryParameter(r, "namespace", true) + if namespace != "" { + listOpts.Namespace = namespace + } + + // optional filter + if filter, _ := request.RetrieveQueryParameter(r, "filter", true); filter != "" { + listOpts.Filter = filter + } + + // optional selector + if selector, _ := request.RetrieveQueryParameter(r, "selector", true); selector != "" { + listOpts.Selector = selector + } + + releases, err := handler.helmPackageManager.List(listOpts) + if err != nil { + return httperror.InternalServerError("Helm returned an error", err) + } + + return response.JSON(w, releases) +} diff --git a/api/http/handler/helm/helm_list_test.go b/api/http/handler/helm/helm_list_test.go new file mode 100644 index 0000000..a07b771 --- /dev/null +++ b/api/http/handler/helm/helm_list_test.go @@ -0,0 +1,52 @@ +package helm + +import ( + "io" + "net/http" + "net/http/httptest" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/testhelpers" + "github.com/portainer/portainer/pkg/libhelm/options" + "github.com/portainer/portainer/pkg/libhelm/release" + + "github.com/segmentio/encoding/json" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_helmList(t *testing.T) { + is := assert.New(t) + h := newTestHelmHandler(t) + + // Install a single chart. We expect to get these values back + options := options.InstallOptions{Name: "nginx-1", Chart: "nginx", Namespace: "default"} + + _, err := h.helmPackageManager.Upgrade(options) + require.NoError(t, err) + + t.Run("helmList", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/1/kubernetes/helm", nil) + ctx := security.StoreTokenData(req, &portainer.TokenData{ID: 1, Username: "admin", Role: 1}) + req = req.WithContext(ctx) + testhelpers.AddTestSecurityCookie(req, "Bearer dummytoken") + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + is.Equal(http.StatusOK, rr.Code, "Status should be 200 OK") + + body, err := io.ReadAll(rr.Body) + require.NoError(t, err, "ReadAll should not return error") + + data := []release.ReleaseElement{} + err = json.Unmarshal(body, &data) + require.NoError(t, err) + if is.Len(data, 1, "Expected one chart entry") { + is.Equal(options.Name, data[0].Name, "Name doesn't match") + is.Equal(options.Chart, data[0].Chart, "Chart doesn't match") + } + }) +} diff --git a/api/http/handler/helm/helm_repo_search.go b/api/http/handler/helm/helm_repo_search.go new file mode 100644 index 0000000..7f940b0 --- /dev/null +++ b/api/http/handler/helm/helm_repo_search.go @@ -0,0 +1,72 @@ +package helm + +import ( + "fmt" + "net/http" + "net/url" + + "github.com/portainer/portainer/pkg/libhelm/options" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/ssrf" + "github.com/rs/zerolog/log" + + "github.com/pkg/errors" +) + +// @id HelmRepoSearch +// @summary Search Helm Charts +// @description +// @description **Access policy**: authenticated +// @tags helm +// @param repo query string true "Helm repository URL" +// @param chart query string false "Helm chart name" +// @param useCache query string false "If true will use cache to search" +// @security ApiKeyAuth +// @security jwt +// @produce json +// @success 200 {object} string "Success" +// @failure 400 "Bad request" +// @failure 401 "Unauthorized" +// @failure 404 "Not found" +// @failure 500 "Server error" +// @router /templates/helm [get] +func (handler *Handler) helmRepoSearch(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + repo := r.URL.Query().Get("repo") + if repo == "" { + return httperror.BadRequest("Bad request", errors.New("missing `repo` query parameter")) + } + + chart, _ := request.RetrieveQueryParameter(r, "chart", false) + // If true will useCache to search, will always add to cache after + useCache, _ := request.RetrieveBooleanQueryParameter(r, "useCache", false) + + _, err := url.ParseRequestURI(repo) + if err != nil { + return httperror.BadRequest("Bad request", errors.Wrap(err, fmt.Sprintf("provided URL %q is not valid", repo))) + } + + if err := ssrf.CheckURL(r.Context(), repo); err != nil { + return httperror.BadRequest("Repository URL blocked by SSRF policy", err) + } + + searchOpts := options.SearchRepoOptions{ + Repo: repo, + Chart: chart, + UseCache: useCache, + } + + result, err := handler.helmPackageManager.SearchRepo(searchOpts) + if err != nil { + log.Warn().Err(err).Str("repo", repo).Msg("helm repo search failed") + return httperror.InternalServerError("Search failed", errors.New("failed to search Helm repository")) + } + + w.Header().Set("Content-Type", "text/plain") + + if _, err := w.Write(result); err != nil { + log.Warn().Err(err).Msg("failed to write helm repo search response") + } + + return nil +} diff --git a/api/http/handler/helm/helm_repo_search_test.go b/api/http/handler/helm/helm_repo_search_test.go new file mode 100644 index 0000000..f5f653b --- /dev/null +++ b/api/http/handler/helm/helm_repo_search_test.go @@ -0,0 +1,52 @@ +package helm + +import ( + "io" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/portainer/portainer/api/internal/testhelpers" + "github.com/portainer/portainer/pkg/libhelm/test" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_helmRepoSearch(t *testing.T) { + t.Parallel() + is := assert.New(t) + + helmPackageManager := test.NewMockHelmPackageManager() + h := NewTemplateHandler(testhelpers.NewTestRequestBouncer(), helmPackageManager) + + assert.NotNil(t, h, "Handler should not fail") + + repos := []string{"https://charts.bitnami.com/bitnami", "https://portainer.github.io/k8s"} + + for _, repo := range repos { + t.Run(repo, func(t *testing.T) { + repoUrlEncoded := url.QueryEscape(repo) + req := httptest.NewRequest(http.MethodGet, "/templates/helm?repo="+repoUrlEncoded, nil) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + is.Equal(http.StatusOK, rr.Code, "Status should be 200 OK") + + body, err := io.ReadAll(rr.Body) + require.NoError(t, err, "ReadAll should not return error") + is.NotEmpty(body, "Body should not be empty") + }) + } + + t.Run("fails on invalid URL", func(t *testing.T) { + repo := "abc.com" + repoUrlEncoded := url.QueryEscape(repo) + req := httptest.NewRequest(http.MethodGet, "/templates/helm?repo="+repoUrlEncoded, nil) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + is.Equal(http.StatusBadRequest, rr.Code, "Status should be 400 Bad request") + }) +} diff --git a/api/http/handler/helm/helm_rollback.go b/api/http/handler/helm/helm_rollback.go new file mode 100644 index 0000000..d98f7a4 --- /dev/null +++ b/api/http/handler/helm/helm_rollback.go @@ -0,0 +1,105 @@ +package helm + +import ( + "net/http" + "time" + + "github.com/portainer/portainer/pkg/libhelm/options" + _ "github.com/portainer/portainer/pkg/libhelm/release" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +// @id HelmRollback +// @summary Rollback a helm release +// @description Rollback a helm release to a previous revision +// @description **Access policy**: authenticated +// @tags helm +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment(Endpoint) identifier" +// @param release path string true "Helm release name" +// @param namespace query string false "specify an optional namespace" +// @param revision query int false "specify the revision to rollback to (defaults to previous revision if not specified)" +// @param wait query boolean false "wait for resources to be ready (default: false)" +// @param waitForJobs query boolean false "wait for jobs to complete before marking the release as successful (default: false)" +// @param recreate query boolean false "performs pods restart for the resource if applicable (default: true)" +// @param force query boolean false "force resource update through delete/recreate if needed (default: false)" +// @param timeout query int false "time to wait for any individual Kubernetes operation in seconds (default: 300)" +// @success 200 {object} release.Release "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier or release name." +// @failure 500 "Server error occurred while attempting to rollback the release." +// @router /endpoints/{id}/kubernetes/helm/{release}/rollback [post] +func (handler *Handler) helmRollback(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + release, err := request.RetrieveRouteVariableValue(r, "release") + if err != nil { + return httperror.BadRequest("No release specified", err) + } + + clusterAccess, httperr := handler.getHelmClusterAccess(r) + if httperr != nil { + return httperr + } + + // build the rollback options + rollbackOpts := options.RollbackOptions{ + KubernetesClusterAccess: clusterAccess, + Name: release, + // Set default values + Recreate: true, // Default to recreate pods (restart) + Timeout: 5 * time.Minute, // Default timeout of 5 minutes + } + + namespace, _ := request.RetrieveQueryParameter(r, "namespace", true) + // optional namespace. The library defaults to "default" + if namespace != "" { + rollbackOpts.Namespace = namespace + } + + revision, _ := request.RetrieveNumericQueryParameter(r, "revision", true) + // optional revision. If not specified, it will rollback to the previous revision + if revision > 0 { + rollbackOpts.Version = revision + } + + // Default for wait is false, only set to true if explicitly requested + wait, err := request.RetrieveBooleanQueryParameter(r, "wait", true) + if err == nil { + rollbackOpts.Wait = wait + } + + // Default for waitForJobs is false, only set to true if explicitly requested + waitForJobs, err := request.RetrieveBooleanQueryParameter(r, "waitForJobs", true) + if err == nil { + rollbackOpts.WaitForJobs = waitForJobs + } + + // Default for recreate is true (set above), override if specified + recreate, err := request.RetrieveBooleanQueryParameter(r, "recreate", true) + if err == nil { + rollbackOpts.Recreate = recreate + } + + // Default for force is false, only set to true if explicitly requested + force, err := request.RetrieveBooleanQueryParameter(r, "force", true) + if err == nil { + rollbackOpts.Force = force + } + + timeout, _ := request.RetrieveNumericQueryParameter(r, "timeout", true) + // Override default timeout if specified + if timeout > 0 { + rollbackOpts.Timeout = time.Duration(timeout) * time.Second + } + + releaseInfo, err := handler.helmPackageManager.Rollback(rollbackOpts) + if err != nil { + return httperror.InternalServerError("Failed to rollback helm release", err) + } + + return response.JSON(w, releaseInfo) +} diff --git a/api/http/handler/helm/helm_show.go b/api/http/handler/helm/helm_show.go new file mode 100644 index 0000000..1c5860b --- /dev/null +++ b/api/http/handler/helm/helm_show.go @@ -0,0 +1,84 @@ +package helm + +import ( + "fmt" + "net/http" + "net/url" + + "github.com/portainer/portainer/pkg/libhelm/options" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/ssrf" + + "github.com/pkg/errors" + "github.com/rs/zerolog/log" +) + +// @id HelmShow +// @summary Show Helm Chart Information +// @description +// @description **Access policy**: authenticated +// @tags helm +// @param repo query string true "Helm repository URL" +// @param chart query string true "Chart name" +// @param version query string false "Chart version" +// @param command path string true "chart/values/readme" +// @security ApiKeyAuth +// @security jwt +// @accept json +// @produce text/plain +// @success 200 {object} string "Success" +// @failure 401 "Unauthorized" +// @failure 404 "Environment(Endpoint) or ServiceAccount not found" +// @failure 500 "Server error" +// @router /templates/helm/{command} [get] +func (handler *Handler) helmShow(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + repo := r.URL.Query().Get("repo") + if repo == "" { + return httperror.BadRequest("Bad request", errors.New("missing `repo` query parameter")) + } + _, err := url.ParseRequestURI(repo) + if err != nil { + return httperror.BadRequest("Bad request", errors.Wrap(err, fmt.Sprintf("provided URL %q is not valid", repo))) + } + + if err := ssrf.CheckURL(r.Context(), repo); err != nil { + return httperror.BadRequest("Repository URL blocked by SSRF policy", err) + } + + chart := r.URL.Query().Get("chart") + if chart == "" { + return httperror.BadRequest("Bad request", errors.New("missing `chart` query parameter")) + } + + version, err := request.RetrieveQueryParameter(r, "version", true) + if err != nil { + return httperror.BadRequest("Bad request", errors.Wrap(err, fmt.Sprintf("provided version %q is not valid", version))) + } + + cmd, err := request.RetrieveRouteVariableValue(r, "command") + if err != nil { + cmd = "all" + log.Debug().Str("default_command", cmd).Msg("command not provided, using default") + } + + showOptions := options.ShowOptions{ + OutputFormat: options.ShowOutputFormat(cmd), + Chart: chart, + Repo: repo, + Version: version, + } + result, err := handler.helmPackageManager.Show(showOptions) + if err != nil { + log.Warn().Err(err).Str("repo", repo).Str("chart", chart).Msg("helm show failed") + return httperror.InternalServerError("Unable to show chart", errors.New("failed to retrieve Helm chart information")) + } + + w.Header().Set("Content-Type", "text/plain") + + if _, err := w.Write(result); err != nil { + log.Warn().Err(err).Msg("failed to write helm show response") + } + + return nil +} diff --git a/api/http/handler/helm/helm_show_test.go b/api/http/handler/helm/helm_show_test.go new file mode 100644 index 0000000..bf18449 --- /dev/null +++ b/api/http/handler/helm/helm_show_test.go @@ -0,0 +1,50 @@ +package helm + +import ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/portainer/portainer/api/internal/testhelpers" + "github.com/portainer/portainer/pkg/libhelm/test" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_helmShow(t *testing.T) { + t.Parallel() + is := assert.New(t) + + helmPackageManager := test.NewMockHelmPackageManager() + h := NewTemplateHandler(testhelpers.NewTestRequestBouncer(), helmPackageManager) + + is.NotNil(h, "Handler should not fail") + + commands := map[string]string{ + "values": test.MockDataValues, + "chart": test.MockDataChart, + "readme": test.MockDataReadme, + } + + for cmd, expect := range commands { + t.Run(cmd, func(t *testing.T) { + is.NotNil(h, "Handler should not fail") + + repoUrlEncoded := url.QueryEscape("https://charts.bitnami.com/bitnami") + chart := "nginx" + req := httptest.NewRequest("GET", fmt.Sprintf("/templates/helm/%s?repo=%s&chart=%s", cmd, repoUrlEncoded, chart), nil) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + is.Equal(http.StatusOK, rr.Code, "Status should be 200 OK") + + body, err := io.ReadAll(rr.Body) + require.NoError(t, err, "ReadAll should not return error") + is.Equal(string(body), expect, "Unexpected search response") + }) + } +} diff --git a/api/http/handler/kubernetes/application.go b/api/http/handler/kubernetes/application.go new file mode 100644 index 0000000..cb67cd7 --- /dev/null +++ b/api/http/handler/kubernetes/application.go @@ -0,0 +1,143 @@ +package kubernetes + +import ( + "net/http" + + models "github.com/portainer/portainer/api/http/models/kubernetes" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" + "github.com/rs/zerolog/log" + k8serrors "k8s.io/apimachinery/pkg/api/errors" +) + +// @id GetApplicationsResources +// @summary Get the total resource requests and limits of all applications +// @description Get the total CPU (cores) and memory (bytes) requests and limits of all applications across all namespaces. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment(Endpoint) identifier" +// @param node query string true "Node name" +// @success 200 {object} models.K8sApplicationResource "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier." +// @failure 500 "Server error occurred while attempting to retrieve the total resource requests and limits for all applications from the cluster." +// @router /kubernetes/{id}/metrics/applications_resources [get] +func (handler *Handler) getApplicationsResources(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + node, err := request.RetrieveQueryParameter(r, "node", true) + if err != nil { + log.Error().Err(err).Str("context", "getApplicationsResources").Msg("Unable to parse the namespace query parameter") + return httperror.BadRequest("Unable to parse the node query parameter", err) + } + + cli, httpErr := handler.prepareKubeClient(r) + if httpErr != nil { + log.Error().Err(httpErr).Str("context", "getApplicationsResources").Msg("Unable to prepare kube client") + return httperror.InternalServerError("Unable to prepare kube client", httpErr) + } + + applicationsResources, err := cli.GetApplicationsResource("", node) + if err != nil { + if k8serrors.IsUnauthorized(err) { + log.Error().Err(err).Str("context", "getApplicationsResources").Msg("Unable to get the total resource requests and limits for all applications in the namespace") + return httperror.Unauthorized("Unable to get the total resource requests and limits for all applications in the namespace", err) + } + + if k8serrors.IsForbidden(err) { + log.Error().Err(err).Str("context", "getApplicationsResources").Msg("Unable to get the total resource requests and limits for all applications in the namespace") + return httperror.Forbidden("Unable to get the total resource requests and limits for all applications in the namespace", err) + } + + log.Error().Err(err).Str("context", "getApplicationsResources").Msg("Unable to calculate the total resource requests and limits for all applications in the namespace") + return httperror.InternalServerError("Unable to calculate the total resource requests and limits for all applications in the namespace", err) + } + + return response.JSON(w, applicationsResources) +} + +// @id GetAllKubernetesApplications +// @summary Get a list of applications across all namespaces in the cluster. If the nodeName is provided, it will return the applications running on that node. +// @description Get a list of applications across all namespaces in the cluster. If the nodeName is provided, it will return the applications running on that node. +// @description **Access policy**: authenticated +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment(Endpoint) identifier" +// @param namespace query string true "Namespace name" +// @param nodeName query string true "Node name" +// @success 200 {array} models.K8sApplication "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier." +// @failure 500 "Server error occurred while attempting to retrieve the list of applications from the cluster." +// @router /kubernetes/{id}/applications [get] +func (handler *Handler) GetAllKubernetesApplications(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + applications, err := handler.getAllKubernetesApplications(r) + if err != nil { + return err + } + + return response.JSON(w, applications) +} + +// @id GetAllKubernetesApplicationsCount +// @summary Get Applications count +// @description Get the count of Applications across all namespaces in the cluster. If the nodeName is provided, it will return the count of applications running on that node. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment identifier" +// @success 200 {integer} integer "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier." +// @failure 500 "Server error occurred while attempting to retrieve the count of all applications from the cluster." +// @router /kubernetes/{id}/applications/count [get] +func (handler *Handler) getAllKubernetesApplicationsCount(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + applications, err := handler.getAllKubernetesApplications(r) + if err != nil { + return err + } + + return response.JSON(w, len(applications)) +} + +func (handler *Handler) getAllKubernetesApplications(r *http.Request) ([]models.K8sApplication, *httperror.HandlerError) { + namespace, err := request.RetrieveQueryParameter(r, "namespace", true) + if err != nil { + log.Error().Err(err).Str("context", "getAllKubernetesApplications").Msg("Unable to parse the namespace query parameter") + return nil, httperror.BadRequest("Unable to parse the namespace query parameter", err) + } + + nodeName, err := request.RetrieveQueryParameter(r, "nodeName", true) + if err != nil { + log.Error().Err(err).Str("context", "getAllKubernetesApplications").Msg("Unable to parse the nodeName query parameter") + return nil, httperror.BadRequest("Unable to parse the nodeName query parameter", err) + } + + cli, httpErr := handler.prepareKubeClient(r) + if httpErr != nil { + log.Error().Err(httpErr).Str("context", "getAllKubernetesApplications").Str("namespace", namespace).Str("nodeName", nodeName).Msg("Unable to get a Kubernetes client for the user") + return nil, httperror.InternalServerError("Unable to get a Kubernetes client for the user", httpErr) + } + + applications, err := cli.GetApplications(namespace, nodeName) + if err != nil { + if k8serrors.IsUnauthorized(err) { + log.Error().Err(err).Str("context", "getAllKubernetesApplications").Str("namespace", namespace).Str("nodeName", nodeName).Msg("Unable to get the list of applications") + return nil, httperror.Unauthorized("Unable to get the list of applications", err) + } + + log.Error().Err(err).Str("context", "getAllKubernetesApplications").Str("namespace", namespace).Str("nodeName", nodeName).Msg("Unable to get the list of applications") + return nil, httperror.InternalServerError("Unable to get the list of applications", err) + } + + return applications, nil +} diff --git a/api/http/handler/kubernetes/client.go b/api/http/handler/kubernetes/client.go new file mode 100644 index 0000000..c570210 --- /dev/null +++ b/api/http/handler/kubernetes/client.go @@ -0,0 +1,44 @@ +package kubernetes + +import ( + "net/http" + + "github.com/portainer/portainer/api/http/middlewares" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/kubernetes/cli" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/rs/zerolog/log" +) + +// prepareKubeClient is a helper function to prepare a Kubernetes client for the user +// it first fetches getProxyKubeClient to grab the user's admin status and non admin namespaces +// then these two values are parsed to create a privileged client +func (handler *Handler) prepareKubeClient(r *http.Request) (*cli.KubeClient, *httperror.HandlerError) { + cli, httpErr := handler.getProxyKubeClient(r) + if httpErr != nil { + log.Error().Err(httpErr.Err).Str("context", "prepareKubeClient").Msg("Unable to get a Kubernetes client for the user.") + return nil, httperror.InternalServerError("Unable to get a Kubernetes client for the user.", httpErr) + } + + endpoint, err := middlewares.FetchEndpoint(r) + if err != nil { + log.Error().Err(err).Str("context", "prepareKubeClient").Msg("Unable to find the Kubernetes endpoint associated to the request.") + return nil, httperror.NotFound("Unable to find the Kubernetes endpoint associated to the request.", err) + } + + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + log.Error().Err(err).Str("context", "prepareKubeClient").Msg("Unable to retrieve token data associated to the request.") + return nil, httperror.InternalServerError("Unable to retrieve token data associated to the request.", err) + } + + pcli, err := handler.KubernetesClientFactory.GetPrivilegedUserKubeClient(endpoint, tokenData.ID) + if err != nil { + log.Error().Err(err).Str("context", "prepareKubeClient").Msg("Unable to get a privileged Kubernetes client for the user.") + return nil, httperror.InternalServerError("Unable to get a privileged Kubernetes client for the user.", err) + } + pcli.SetIsKubeAdmin(cli.GetIsKubeAdmin()) + pcli.SetClientNonAdminNamespaces(cli.GetClientNonAdminNamespaces()) + + return pcli, nil +} diff --git a/api/http/handler/kubernetes/cluster_role_bindings.go b/api/http/handler/kubernetes/cluster_role_bindings.go new file mode 100644 index 0000000..83621a9 --- /dev/null +++ b/api/http/handler/kubernetes/cluster_role_bindings.go @@ -0,0 +1,83 @@ +package kubernetes + +import ( + "net/http" + + models "github.com/portainer/portainer/api/http/models/kubernetes" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" + "github.com/rs/zerolog/log" +) + +// @id GetAllKubernetesClusterRoleBindings +// @summary Get a list of kubernetes cluster role bindings +// @description Get a list of kubernetes cluster role bindings within the given environment at the cluster level. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment identifier" +// @success 200 {array} kubernetes.K8sClusterRoleBinding "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier." +// @failure 500 "Server error occurred while attempting to retrieve the list of cluster role bindings." +// @router /kubernetes/{id}/clusterrolebindings [get] +func (handler *Handler) getAllKubernetesClusterRoleBindings(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + cli, httpErr := handler.getProxyKubeClient(r) + if httpErr != nil { + log.Error().Err(httpErr.Err).Str("context", "getAllKubernetesClusterRoleBindings").Msg("user is not authorized to fetch cluster role bindings from the Kubernetes cluster.") + return httperror.Forbidden("User is not authorized to fetch cluster role bindings from the Kubernetes cluster.", httpErr) + } + + if !cli.GetIsKubeAdmin() { + log.Error().Str("context", "getAllKubernetesClusterRoleBindings").Msg("user is not authorized to fetch cluster role bindings from the Kubernetes cluster.") + return httperror.Forbidden("User is not authorized to fetch cluster role bindings from the Kubernetes cluster.", nil) + } + + clusterrolebindings, err := cli.GetClusterRoleBindings() + if err != nil { + log.Error().Err(err).Str("context", "getAllKubernetesClusterRoleBindings").Msg("Unable to fetch cluster role bindings.") + return httperror.InternalServerError("Unable to fetch cluster role bindings.", err) + } + + return response.JSON(w, clusterrolebindings) +} + +// @id DeleteClusterRoleBindings +// @summary Delete cluster role bindings +// @description Delete the provided list of cluster role bindings. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @accept json +// @param id path int true "Environment identifier" +// @param payload body models.K8sClusterRoleBindingDeleteRequests true "A list of cluster role bindings to delete" +// @success 204 "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier or unable to find a specific cluster role binding." +// @failure 500 "Server error occurred while attempting to delete cluster role bindings." +// @router /kubernetes/{id}/cluster_role_bindings/delete [POST] +func (handler *Handler) deleteClusterRoleBindings(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload models.K8sClusterRoleBindingDeleteRequests + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + cli, handlerErr := handler.getProxyKubeClient(r) + if handlerErr != nil { + return handlerErr + } + + err = cli.DeleteClusterRoleBindings(payload) + if err != nil { + return httperror.InternalServerError("Failed to delete cluster role bindings", err) + } + + return response.Empty(w) +} diff --git a/api/http/handler/kubernetes/cluster_roles.go b/api/http/handler/kubernetes/cluster_roles.go new file mode 100644 index 0000000..6d5d028 --- /dev/null +++ b/api/http/handler/kubernetes/cluster_roles.go @@ -0,0 +1,83 @@ +package kubernetes + +import ( + "net/http" + + models "github.com/portainer/portainer/api/http/models/kubernetes" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" + "github.com/rs/zerolog/log" +) + +// @id GetAllKubernetesClusterRoles +// @summary Get a list of kubernetes cluster roles +// @description Get a list of kubernetes cluster roles within the given environment at the cluster level. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment identifier" +// @success 200 {array} kubernetes.K8sClusterRole "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier." +// @failure 500 "Server error occurred while attempting to retrieve the list of cluster roles." +// @router /kubernetes/{id}/clusterroles [get] +func (handler *Handler) getAllKubernetesClusterRoles(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + cli, httpErr := handler.getProxyKubeClient(r) + if httpErr != nil { + log.Error().Err(httpErr.Err).Str("context", "getAllKubernetesClusterRoles").Msg("user is not authorized to fetch cluster roles from the Kubernetes cluster.") + return httperror.Forbidden("User is not authorized to fetch cluster roles from the Kubernetes cluster.", httpErr) + } + + if !cli.GetIsKubeAdmin() { + log.Error().Str("context", "getAllKubernetesClusterRoles").Msg("user is not authorized to fetch cluster roles from the Kubernetes cluster.") + return httperror.Forbidden("User is not authorized to fetch cluster roles from the Kubernetes cluster.", nil) + } + + clusterroles, err := cli.GetClusterRoles() + if err != nil { + log.Error().Err(err).Str("context", "getAllKubernetesClusterRoles").Msg("Unable to fetch clusterroles.") + return httperror.InternalServerError("Unable to fetch clusterroles.", err) + } + + return response.JSON(w, clusterroles) +} + +// @id DeleteClusterRoles +// @summary Delete cluster roles +// @description Delete the provided list of cluster roles. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @accept json +// @param id path int true "Environment identifier" +// @param payload body models.K8sClusterRoleDeleteRequests true "A list of cluster roles to delete" +// @success 204 "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier or unable to find a specific cluster role." +// @failure 500 "Server error occurred while attempting to delete cluster roles." +// @router /kubernetes/{id}/cluster_roles/delete [POST] +func (handler *Handler) deleteClusterRoles(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload models.K8sClusterRoleDeleteRequests + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + cli, handlerErr := handler.getProxyKubeClient(r) + if handlerErr != nil { + return handlerErr + } + + err = cli.DeleteClusterRoles(payload) + if err != nil { + return httperror.InternalServerError("Failed to delete cluster roles", err) + } + + return response.Empty(w) +} diff --git a/api/http/handler/kubernetes/config.go b/api/http/handler/kubernetes/config.go new file mode 100644 index 0000000..cc8e544 --- /dev/null +++ b/api/http/handler/kubernetes/config.go @@ -0,0 +1,294 @@ +package kubernetes + +import ( + "crypto/x509" + "encoding/pem" + "errors" + "fmt" + "net/http" + "net/url" + "os" + "strings" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/endpointutils" + kcli "github.com/portainer/portainer/api/kubernetes/cli" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" + "github.com/rs/zerolog/log" + + clientV1 "k8s.io/client-go/tools/clientcmd/api/v1" +) + +// @id GetKubernetesConfig +// @summary Generate a kubeconfig file +// @description Generate a kubeconfig file that allows a client to communicate with the Kubernetes API server +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce application/json, application/yaml +// @param ids query []int false "will include only these environments(endpoints)" +// @param excludeIds query []int false "will exclude these environments(endpoints)" +// @success 200 {object} interface{} "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier." +// @failure 500 "Server error occurred while attempting to generate the kubeconfig file." +// @router /kubernetes/config [get] +func (handler *Handler) getKubernetesConfig(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + log.Error().Err(err).Str("context", "getKubernetesConfig").Msg("Permission denied to access environment") + return httperror.Forbidden("Permission denied to access environment", err) + } + + bearerToken, err := handler.JwtService.GenerateTokenForKubeconfig(tokenData) + if err != nil { + log.Error().Err(err).Str("context", "getKubernetesConfig").Msg("Unable to generate JWT token") + return httperror.InternalServerError("Unable to generate JWT token", err) + } + + endpoints, handlerErr := handler.filterUserKubeEndpoints(r) + if handlerErr != nil { + log.Error().Err(handlerErr).Str("context", "getKubernetesConfig").Msg("Unable to filter user kube endpoints") + return handlerErr + } + + if len(endpoints) == 0 { + log.Error().Str("context", "getKubernetesConfig").Msg("Empty endpoints list") + return httperror.BadRequest("empty endpoints list", errors.New("empty endpoints list")) + } + + config := handler.buildConfig(r, tokenData, bearerToken, endpoints, false) + + return writeFileContent(w, r, endpoints, tokenData, config) +} + +func (handler *Handler) filterUserKubeEndpoints(r *http.Request) ([]portainer.Endpoint, *httperror.HandlerError) { + var endpointIDs []portainer.EndpointID + _ = request.RetrieveJSONQueryParameter(r, "ids", &endpointIDs, true) + + var excludeEndpointIDs []portainer.EndpointID + _ = request.RetrieveJSONQueryParameter(r, "excludeIds", &excludeEndpointIDs, true) + + if len(endpointIDs) > 0 && len(excludeEndpointIDs) > 0 { + log.Error().Str("context", "filterUserKubeEndpoints").Msg("Can't provide both 'ids' and 'excludeIds' parameters") + return nil, httperror.BadRequest("Can't provide both 'ids' and 'excludeIds' parameters", errors.New("invalid parameters")) + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + log.Error().Err(err).Str("context", "filterUserKubeEndpoints").Msg("Unable to retrieve info from request context") + return nil, httperror.InternalServerError("Unable to retrieve info from request context", err) + } + + endpointGroups, err := handler.DataStore.EndpointGroup().ReadAll() + if err != nil { + log.Error().Err(err).Str("context", "filterUserKubeEndpoints").Msg("Unable to retrieve environment groups from the database") + return nil, httperror.InternalServerError("Unable to retrieve environment groups from the database", err) + } + + if len(endpointIDs) > 0 { + var endpoints []portainer.Endpoint + for _, endpointID := range endpointIDs { + endpoint, err := handler.DataStore.Endpoint().Endpoint(endpointID) + if err != nil { + log.Error().Err(err).Str("context", "filterUserKubeEndpoints").Msg("Unable to retrieve environment from the database") + return nil, httperror.InternalServerError("Unable to retrieve environment from the database", err) + } + if !endpointutils.IsKubernetesEndpoint(endpoint) { + continue + } + endpoints = append(endpoints, *endpoint) + } + + filteredEndpoints := security.FilterEndpoints(endpoints, endpointGroups, securityContext) + + return filteredEndpoints, nil + } + + var kubeEndpoints []portainer.Endpoint + endpoints, err := handler.DataStore.Endpoint().Endpoints() + if err != nil { + log.Error().Err(err).Str("context", "filterUserKubeEndpoints").Msg("Unable to retrieve environments from the database") + return nil, httperror.InternalServerError("Unable to retrieve environments from the database", err) + } + + for _, endpoint := range endpoints { + if !endpointutils.IsKubernetesEndpoint(&endpoint) { + continue + } + + kubeEndpoints = append(kubeEndpoints, endpoint) + } + + filteredEndpoints := security.FilterEndpoints(kubeEndpoints, endpointGroups, securityContext) + if len(excludeEndpointIDs) > 0 { + filteredEndpoints = endpointutils.FilterByExcludeIDs(filteredEndpoints, excludeEndpointIDs) + } + + return filteredEndpoints, nil +} + +func (handler *Handler) buildConfig(r *http.Request, tokenData *portainer.TokenData, bearerToken string, endpoints []portainer.Endpoint, isInternal bool) *clientV1.Config { + var configAuthInfos []clientV1.NamedAuthInfo + + configClusters := make([]clientV1.NamedCluster, len(endpoints)) + configContexts := make([]clientV1.NamedContext, len(endpoints)) + authInfosSet := make(map[string]bool) + + for idx, endpoint := range endpoints { + instanceID := handler.KubernetesClientFactory.GetInstanceID() + serviceAccountName := kcli.UserServiceAccountName(int(tokenData.ID), instanceID) + + configClusters[idx] = handler.buildCluster(r, endpoint, isInternal) + configContexts[idx] = buildContext(serviceAccountName, endpoint) + + if !authInfosSet[serviceAccountName] { + configAuthInfos = append(configAuthInfos, buildAuthInfo(serviceAccountName, bearerToken)) + authInfosSet[serviceAccountName] = true + } + } + + return &clientV1.Config{ + APIVersion: "v1", + Kind: "Config", + Clusters: configClusters, + Contexts: configContexts, + CurrentContext: configContexts[0].Name, + AuthInfos: configAuthInfos, + } +} + +// buildCluster builds a Kubernetes cluster configuration based on the endpoint and if it's used internally or externally. +func (handler *Handler) buildCluster(r *http.Request, endpoint portainer.Endpoint, isInternal bool) clientV1.NamedCluster { + kubeConfigInternal := handler.kubeClusterAccessService.GetClusterDetails(r.Host, endpoint.ID, isInternal) + + if isInternal { + return clientV1.NamedCluster{ + Name: buildClusterName(endpoint.Name), + Cluster: clientV1.Cluster{ + Server: kubeConfigInternal.ClusterServerURL, + InsecureSkipTLSVerify: true, + }, + } + } + + selfSignedCert := false + serverUrl, err := url.Parse(kubeConfigInternal.ClusterServerURL) + if err != nil { + log.Warn().Err(err).Msg("Failed to parse server URL") + } + + if strings.EqualFold(serverUrl.Scheme, "https") { + var certPem []byte + var err error + + if kubeConfigInternal.CertificateAuthorityData != "" { + certPem = []byte(kubeConfigInternal.CertificateAuthorityData) + } else if kubeConfigInternal.CertificateAuthorityFile != "" { + certPem, err = os.ReadFile(kubeConfigInternal.CertificateAuthorityFile) + if err != nil { + log.Warn().Err(err).Msg("Failed to open certificate file") + } + } + + if certPem != nil { + selfSignedCert, err = IsSelfSignedCertificate(certPem) + if err != nil { + log.Warn().Err(err).Msg("Failed to verify if certificate is self-signed") + } + } + } + + return clientV1.NamedCluster{ + Name: buildClusterName(endpoint.Name), + Cluster: clientV1.Cluster{ + Server: kubeConfigInternal.ClusterServerURL, + InsecureSkipTLSVerify: selfSignedCert, + }, + } +} + +func buildClusterName(endpointName string) string { + return "portainer-cluster-" + endpointName +} + +func buildContext(serviceAccountName string, endpoint portainer.Endpoint) clientV1.NamedContext { + return clientV1.NamedContext{ + Name: "portainer-ctx-" + endpoint.Name, + Context: clientV1.Context{ + AuthInfo: serviceAccountName, + Cluster: buildClusterName(endpoint.Name), + }, + } +} + +func buildAuthInfo(serviceAccountName string, bearerToken string) clientV1.NamedAuthInfo { + return clientV1.NamedAuthInfo{ + Name: serviceAccountName, + AuthInfo: clientV1.AuthInfo{ + Token: bearerToken, + }, + } +} + +func writeFileContent(w http.ResponseWriter, r *http.Request, endpoints []portainer.Endpoint, tokenData *portainer.TokenData, config *clientV1.Config) *httperror.HandlerError { + filenameSuffix := "kubeconfig" + if len(endpoints) == 1 { + filenameSuffix = endpoints[0].Name + } + filenameBase := fmt.Sprintf("%s-%s", tokenData.Username, filenameSuffix) + + if r.Header.Get("Accept") == "text/yaml" { + yaml, err := kcli.GenerateYAML(config) + if err != nil { + log.Error().Err(err).Str("context", "writeFileContent").Msg("Failed to generate Kubeconfig") + return httperror.InternalServerError("Failed to generate Kubeconfig", err) + } + + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; %s.yaml", filenameBase)) + return response.YAML(w, yaml) + } + + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; %s.json", filenameBase)) + return response.JSON(w, config) +} + +func IsSelfSignedCertificate(certPem []byte) (bool, error) { + if certPem == nil { + return false, errors.New("certificate data is empty") + } + + if !strings.Contains(string(certPem), "BEGIN CERTIFICATE") { + certPem = fmt.Appendf(nil, "-----BEGIN CERTIFICATE-----\n%s\n-----END CERTIFICATE-----", string(certPem)) + } + + block, _ := pem.Decode(certPem) + if block == nil { + return false, errors.New("failed to decode certificate") + } + + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return false, err + } + + if cert.Issuer.String() != cert.Subject.String() { + return false, nil + } + + roots := x509.NewCertPool() + roots.AddCert(cert) + + opts := x509.VerifyOptions{ + Roots: roots, + CurrentTime: cert.NotBefore, + } + + _, err = cert.Verify(opts) + return err == nil, err +} diff --git a/api/http/handler/kubernetes/config_test.go b/api/http/handler/kubernetes/config_test.go new file mode 100644 index 0000000..e113a73 --- /dev/null +++ b/api/http/handler/kubernetes/config_test.go @@ -0,0 +1,188 @@ +package kubernetes + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestIsSelfSignedCertificate(t *testing.T) { + t.Parallel() + + tc := []struct { + name string + cert string + expected bool + }{ + { + name: "portainer self-signed", + cert: `-----BEGIN CERTIFICATE----- +MIIBUTCB+KADAgECAhBB7psNiJlJd/nRCCKUPVenMAoGCCqGSM49BAMCMAAwHhcN +MjUwMzEzMDQwODI0WhcNMzAwMzEzMDQwODI0WjAAMFkwEwYHKoZIzj0CAQYIKoZI +zj0DAQcDQgAESdGCaXq0r1GDxF89yKjjLeCIixiPDdXAg+lw4NqAWeJq2AOo+8IH +vcCq9bSlYlezK8RzTsbf9Z1m5jRqUEbSjqNUMFIwDgYDVR0PAQH/BAQDAgWgMBMG +A1UdJQQMMAoGCCsGAQUFBwMBMAwGA1UdEwEB/wQCMAAwHQYDVR0RAQH/BBMwEYIJ +bG9jYWxob3N0hwQAAAAAMAoGCCqGSM49BAMCA0gAMEUCIApLliukFaCZHbc/2pkH +0VDY+fBMb12jhmVpgKh1Cqg9AiEAwFrMQLUkzATUpiHuukdUg5VsUiMIkWTPLglz +E4+1dRc= +-----END CERTIFICATE----- +`, + expected: true, + }, + { + name: "portainer self-signed without header", + cert: `MIIBUzCB+aADAgECAhEAjsskPzuCS5BeHjXGwYqc2jAKBggqhkjOPQQDAjAAMB4XDTI1MDMxMzA0MzQyNloXDTMwMDMxMzA0MzQyNlowADBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABITD+dNDLYQbLYDE3UMlTzD61OYRSVkVZspdp1MvZITIG4VOxtfQUqcW3P7OHQdoi52GIQ/GM6iDgxwB1BOyi3mjVDBSMA4GA1UdDwEB/wQEAwIFoDATBgNVHSUEDDAKBggrBgEFBQcDATAMBgNVHRMBAf8EAjAAMB0GA1UdEQEB/wQTMBGCCWxvY2FsaG9zdIcEAAAAADAKBggqhkjOPQQDAgNJADBGAiEA8SmyeYLhrnrNLAFcxZp0dk6nMN70XVAfqGnbK/s8NR8CIQDgQdqhfge8QvN2TsH4gg98a9VHDv+RlcOlJ80SS+G/Ww==`, + expected: true, + }, + { + name: "custom certificate generated by openssl", + cert: `-----BEGIN CERTIFICATE----- +MIIB9TCCAZugAwIBAgIULTkNYfYHiqfOiX7mKOIGxRefx/YwCgYIKoZIzj0EAwIw +SDELMAkGA1UEBhMCVVMxCzAJBgNVBAgTAkNBMRYwFAYDVQQHEw1TYW4gRnJhbmNp +c2NvMRQwEgYDVQQDEwtleGFtcGxlLm5ldDAeFw0yNTAyMjgwNjI3MDBaFw0zNTAy +MjYwNjI3MDBaMAAwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAT3WlLvbGw7wPkQ +3LuHFJEaNrDv3n359JMV1CkjQi3U37u0fJrjd+8o7TxPBYgt9HDD9vsURhy41DNo +g71F2AIto4GqMIGnMA4GA1UdDwEB/wQEAwIFoDAdBgNVHSUEFjAUBggrBgEFBQcD +AQYIKwYBBQUHAwIwDAYDVR0TAQH/BAIwADAdBgNVHQ4EFgQU+nMxx/VCE9fzrlHI +FX9mF5SRPrkwHwYDVR0jBBgwFoAUOlUIToGwnBOqzZ1dBfOvdKbwNaAwKAYDVR0R +AQH/BB4wHIIaZWRnZS4xNzIuMTcuMjIxLjIwOC5uaXAuaW8wCgYIKoZIzj0EAwID +SAAwRQIgeYrkjY0z/ypMKXZbvbMi8qOK44qoISKkSErBUCBLuwoCIQDRaJA9r931 +utpXXnysVGecVXHHKOOl1YhWglmuPvcZhw== +-----END CERTIFICATE-----`, + expected: false, + }, + { + name: "google.com certificate", + cert: `-----BEGIN CERTIFICATE----- +MIIOITCCDQmgAwIBAgIQKS0IQxknY8USDjt3IYchljANBgkqhkiG9w0BAQsFADA7 +MQswCQYDVQQGEwJVUzEeMBwGA1UEChMVR29vZ2xlIFRydXN0IFNlcnZpY2VzMQww +CgYDVQQDEwNXUjIwHhcNMjUwMjI2MTUzMjU1WhcNMjUwNTIxMTUzMjU0WjAXMRUw +EwYDVQQDDAwqLmdvb2dsZS5jb20wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARx +nMOmIG3BuO7my/BbF/rGPAMH/JbxBDufbYFQHV+6l5pF5sdT/Zov3X+qsR3IYFl7 +F2a0gAUmK1Bq7//zTb3uo4IMDjCCDAowDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQM +MAoGCCsGAQUFBwMBMAwGA1UdEwEB/wQCMAAwHQYDVR0OBBYEFN+aEjBz3PaUtelz +3g9rVTkGRgU0MB8GA1UdIwQYMBaAFN4bHu15FdQ+NyTDIbvsNDltQrIwMFgGCCsG +AQUFBwEBBEwwSjAhBggrBgEFBQcwAYYVaHR0cDovL28ucGtpLmdvb2cvd3IyMCUG +CCsGAQUFBzAChhlodHRwOi8vaS5wa2kuZ29vZy93cjIuY3J0MIIJ5AYDVR0RBIIJ +2zCCCdeCDCouZ29vZ2xlLmNvbYIWKi5hcHBlbmdpbmUuZ29vZ2xlLmNvbYIJKi5i +ZG4uZGV2ghUqLm9yaWdpbi10ZXN0LmJkbi5kZXaCEiouY2xvdWQuZ29vZ2xlLmNv +bYIYKi5jcm93ZHNvdXJjZS5nb29nbGUuY29tghgqLmRhdGFjb21wdXRlLmdvb2ds +ZS5jb22CCyouZ29vZ2xlLmNhggsqLmdvb2dsZS5jbIIOKi5nb29nbGUuY28uaW6C +DiouZ29vZ2xlLmNvLmpwgg4qLmdvb2dsZS5jby51a4IPKi5nb29nbGUuY29tLmFy +gg8qLmdvb2dsZS5jb20uYXWCDyouZ29vZ2xlLmNvbS5icoIPKi5nb29nbGUuY29t +LmNvgg8qLmdvb2dsZS5jb20ubXiCDyouZ29vZ2xlLmNvbS50coIPKi5nb29nbGUu +Y29tLnZuggsqLmdvb2dsZS5kZYILKi5nb29nbGUuZXOCCyouZ29vZ2xlLmZyggsq +Lmdvb2dsZS5odYILKi5nb29nbGUuaXSCCyouZ29vZ2xlLm5sggsqLmdvb2dsZS5w +bIILKi5nb29nbGUucHSCDyouZ29vZ2xlYXBpcy5jboIRKi5nb29nbGV2aWRlby5j +b22CDCouZ3N0YXRpYy5jboIQKi5nc3RhdGljLWNuLmNvbYIPZ29vZ2xlY25hcHBz +LmNughEqLmdvb2dsZWNuYXBwcy5jboIRZ29vZ2xlYXBwcy1jbi5jb22CEyouZ29v +Z2xlYXBwcy1jbi5jb22CDGdrZWNuYXBwcy5jboIOKi5na2VjbmFwcHMuY26CEmdv +b2dsZWRvd25sb2Fkcy5jboIUKi5nb29nbGVkb3dubG9hZHMuY26CEHJlY2FwdGNo +YS5uZXQuY26CEioucmVjYXB0Y2hhLm5ldC5jboIQcmVjYXB0Y2hhLWNuLm5ldIIS +Ki5yZWNhcHRjaGEtY24ubmV0ggt3aWRldmluZS5jboINKi53aWRldmluZS5jboIR +YW1wcHJvamVjdC5vcmcuY26CEyouYW1wcHJvamVjdC5vcmcuY26CEWFtcHByb2pl +Y3QubmV0LmNughMqLmFtcHByb2plY3QubmV0LmNughdnb29nbGUtYW5hbHl0aWNz +LWNuLmNvbYIZKi5nb29nbGUtYW5hbHl0aWNzLWNuLmNvbYIXZ29vZ2xlYWRzZXJ2 +aWNlcy1jbi5jb22CGSouZ29vZ2xlYWRzZXJ2aWNlcy1jbi5jb22CEWdvb2dsZXZh +ZHMtY24uY29tghMqLmdvb2dsZXZhZHMtY24uY29tghFnb29nbGVhcGlzLWNuLmNv +bYITKi5nb29nbGVhcGlzLWNuLmNvbYIVZ29vZ2xlb3B0aW1pemUtY24uY29tghcq +Lmdvb2dsZW9wdGltaXplLWNuLmNvbYISZG91YmxlY2xpY2stY24ubmV0ghQqLmRv +dWJsZWNsaWNrLWNuLm5ldIIYKi5mbHMuZG91YmxlY2xpY2stY24ubmV0ghYqLmcu +ZG91YmxlY2xpY2stY24ubmV0gg5kb3VibGVjbGljay5jboIQKi5kb3VibGVjbGlj +ay5jboIUKi5mbHMuZG91YmxlY2xpY2suY26CEiouZy5kb3VibGVjbGljay5jboIR +ZGFydHNlYXJjaC1jbi5uZXSCEyouZGFydHNlYXJjaC1jbi5uZXSCHWdvb2dsZXRy +YXZlbGFkc2VydmljZXMtY24uY29tgh8qLmdvb2dsZXRyYXZlbGFkc2VydmljZXMt +Y24uY29tghhnb29nbGV0YWdzZXJ2aWNlcy1jbi5jb22CGiouZ29vZ2xldGFnc2Vy +dmljZXMtY24uY29tghdnb29nbGV0YWdtYW5hZ2VyLWNuLmNvbYIZKi5nb29nbGV0 +YWdtYW5hZ2VyLWNuLmNvbYIYZ29vZ2xlc3luZGljYXRpb24tY24uY29tghoqLmdv +b2dsZXN5bmRpY2F0aW9uLWNuLmNvbYIkKi5zYWZlZnJhbWUuZ29vZ2xlc3luZGlj +YXRpb24tY24uY29tghZhcHAtbWVhc3VyZW1lbnQtY24uY29tghgqLmFwcC1tZWFz +dXJlbWVudC1jbi5jb22CC2d2dDEtY24uY29tgg0qLmd2dDEtY24uY29tggtndnQy +LWNuLmNvbYINKi5ndnQyLWNuLmNvbYILMm1kbi1jbi5uZXSCDSouMm1kbi1jbi5u +ZXSCFGdvb2dsZWZsaWdodHMtY24ubmV0ghYqLmdvb2dsZWZsaWdodHMtY24ubmV0 +ggxhZG1vYi1jbi5jb22CDiouYWRtb2ItY24uY29tghRnb29nbGVzYW5kYm94LWNu +LmNvbYIWKi5nb29nbGVzYW5kYm94LWNuLmNvbYIeKi5zYWZlbnVwLmdvb2dsZXNh +bmRib3gtY24uY29tgg0qLmdzdGF0aWMuY29tghQqLm1ldHJpYy5nc3RhdGljLmNv +bYIKKi5ndnQxLmNvbYIRKi5nY3BjZG4uZ3Z0MS5jb22CCiouZ3Z0Mi5jb22CDiou +Z2NwLmd2dDIuY29tghAqLnVybC5nb29nbGUuY29tghYqLnlvdXR1YmUtbm9jb29r +aWUuY29tggsqLnl0aW1nLmNvbYILYW5kcm9pZC5jb22CDSouYW5kcm9pZC5jb22C +EyouZmxhc2guYW5kcm9pZC5jb22CBGcuY26CBiouZy5jboIEZy5jb4IGKi5nLmNv +ggZnb28uZ2yCCnd3dy5nb28uZ2yCFGdvb2dsZS1hbmFseXRpY3MuY29tghYqLmdv +b2dsZS1hbmFseXRpY3MuY29tggpnb29nbGUuY29tghJnb29nbGVjb21tZXJjZS5j +b22CFCouZ29vZ2xlY29tbWVyY2UuY29tgghnZ3BodC5jboIKKi5nZ3BodC5jboIK +dXJjaGluLmNvbYIMKi51cmNoaW4uY29tggh5b3V0dS5iZYILeW91dHViZS5jb22C +DSoueW91dHViZS5jb22CEW11c2ljLnlvdXR1YmUuY29tghMqLm11c2ljLnlvdXR1 +YmUuY29tghR5b3V0dWJlZWR1Y2F0aW9uLmNvbYIWKi55b3V0dWJlZWR1Y2F0aW9u +LmNvbYIPeW91dHViZWtpZHMuY29tghEqLnlvdXR1YmVraWRzLmNvbYIFeXQuYmWC +ByoueXQuYmWCGmFuZHJvaWQuY2xpZW50cy5nb29nbGUuY29tghMqLmFuZHJvaWQu +Z29vZ2xlLmNughIqLmNocm9tZS5nb29nbGUuY26CFiouZGV2ZWxvcGVycy5nb29n +bGUuY26CFSouYWlzdHVkaW8uZ29vZ2xlLmNvbTATBgNVHSAEDDAKMAgGBmeBDAEC +ATA2BgNVHR8ELzAtMCugKaAnhiVodHRwOi8vYy5wa2kuZ29vZy93cjIvb0JGWVlh +aHpnVkkuY3JsMIIBBAYKKwYBBAHWeQIEAgSB9QSB8gDwAHcAzxFW7tUufK/zh1vZ +aS6b6RpxZ0qwF+ysAdJbd87MOwgAAAGVQxqxaQAABAMASDBGAiEAk6r74vfyJIaa +hYTWqNRsjl/RpCWq/wyzzMi21zgGmfkCIQCZafyS/fl0tiutICL9aOSnDBRfPYqd +CeNqKOy11EjvigB1AN6FgddQJHxrzcuvVjfF54HGTORu1hdjn480pybJ4r03AAAB +lUMasUkAAAQDAEYwRAIgYfG2iyRnmn8MI86RFDxOQW1/IOBAjQxNfIQ8toZlZkoC +IA1BHw7cqmlTP7Ks+ebX6hGfNlVsgTQS8iYyKL5/BSvTMA0GCSqGSIb3DQEBCwUA +A4IBAQAYSNtoW72rqhPfjV5Ug1ENbbimfqmqiJS4JdzaEFRpftzachTuvx8relaY ++7FAz5y4YULu9LGNjpBRYW8yW9pgfWyc53CCHSkDODguUOMCRo3hdglxZ2d5pJ/8 +TQY4zRBd8OHzOAx2kH6jLEj9I0nDie3vowSYm7FCBRLjzfForRNQWmzPu+5hS3De +QM0R2jWpmPcG3ffQ5qQwnAQnP9HCK9oEZ5cFqLvOQWfttj/rzKOz856iSEoRpf8S +wVFRu3Uv2TXQ6UYF2cDfiWCe6/mO35CIynC6FVkunze/Q/2rtaCDttLRYZcLllj8 +PSl7nmLhtqDlO7da/S34BFiyyRjN +-----END CERTIFICATE----- +`, + expected: false, + }, + { + name: "let's encrypt certificate", + cert: `-----BEGIN CERTIFICATE----- +MIIGMjCCBRqgAwIBAgISBVHH05rEMkaCuDQvABDjiam0MA0GCSqGSIb3DQEBCwUA +MDMxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MQwwCgYDVQQD +EwNSMTAwHhcNMjUwMzEzMDIyMzE2WhcNMjUwNjExMDIyMzE1WjAkMSIwIAYDVQQD +Exlvei1kZW1vLnBvcnRhaW5lcmNsb3VkLmlvMIICIjANBgkqhkiG9w0BAQEFAAOC +Ag8AMIICCgKCAgEAwNCcr9azSaldEwgL54bQScuWBnmw3FMHgEATxDVp2MEawQkV +I3VScUcJWBnlHlb7TUanRC/c/vJGbzc+KDuCRTZ2/Ob2yQ9G5mZjGttBAnBSQPpV +arEEBFCClhVBn4LhLNmIsCjCy25+m0HY/dwWbKjTMT/KxpTa3L3mdmIFa7XNs6W2 +vEZGwYM+2JPMJ9DwemVrrrvRqd5vLWTZcWvWJQ7HMfw3PoELpeqyycmxDqd9PCMz +yMp8q3UwLDur3+KfDXGtGOoubxcOuJrpemOe8JeM5cEYEhvOy8D16zmWwWYDT19D +ElFfUbM0GGITpJ41Qie03DvmI0hDYDqTEZfKza967VsvD7K9bFgLHmHdv7gLNutB +FConpziNqslapWwQ5j7bKircxKjRQVkOiXH48m2IUzylqWgJPVMvHukRu0YVnvbt +Q53xNVZQEbjvZmIuz8jqo22Y/1Jr7Plnb1lUvvDznA58MHT0KA4LSZwk9tvMJJCw +vh7AoWB6/Jnl8QVnApOdCa6M/An128rBwgrCmp0wSvhMecTkWC8/gsah0Q5wKFL3 +ziBth728Qy8RlNghRUw88e/y4pdGHN8egjK1NpdgsvTFdRNQ8qwu0lx9pO3b6TNQ +qDG5pirXjS/DhPYvZtJRDK6SMTHJNm+0NGdWB8qpNssFrU6u2cRl0533LtECAwEA +AaOCAk0wggJJMA4GA1UdDwEB/wQEAwIFoDAdBgNVHSUEFjAUBggrBgEFBQcDAQYI +KwYBBQUHAwIwDAYDVR0TAQH/BAIwADAdBgNVHQ4EFgQUiQi/3pZamfPxRGPI8DTZ +tej1494wHwYDVR0jBBgwFoAUu7zDR6XkvKnGw6RyDBCNojXhyOgwVwYIKwYBBQUH +AQEESzBJMCIGCCsGAQUFBzABhhZodHRwOi8vcjEwLm8ubGVuY3Iub3JnMCMGCCsG +AQUFBzAChhdodHRwOi8vcjEwLmkubGVuY3Iub3JnLzAkBgNVHREEHTAbghlvei1k +ZW1vLnBvcnRhaW5lcmNsb3VkLmlvMBMGA1UdIAQMMAowCAYGZ4EMAQIBMC4GA1Ud +HwQnMCUwI6AhoB+GHWh0dHA6Ly9yMTAuYy5sZW5jci5vcmcvNTMuY3JsMIIBBAYK +KwYBBAHWeQIEAgSB9QSB8gDwAHcAzPsPaoVxCWX+lZtTzumyfCLphVwNl422qX5U +wP5MDbAAAAGVjYW7/QAABAMASDBGAiEA8CjMOIj7wqQ60BX22A5pDkA23IxZPzwV +1MF5+VSgdqgCIQCZhry5AK2VyZX/cIODEl6eHBCUWS4vHB+J8RxeclKCpAB1AKLj +CuRF772tm3447Udnd1PXgluElNcrXhssxLlQpEfnAAABlY2Fu/QAAAQDAEYwRAIg +bwjJgZJew/1LoL9yzDD1P4Xkd8ezFucxfU3AzlV1XEYCIH5RPyW1HP9GSr+aAx+I +o3inVl1NagJFYiApAPvFmIEgMA0GCSqGSIb3DQEBCwUAA4IBAQATJWi1sJSBstO+ +hyH7DsrAtDhiQTOWzUZezBlgCn8hfmA3nX5uKsHyxPPPEQ/GFYOltRD/+34X9kFF +YNzUjJOP0bGk45I1JbspxRRvtbDpk0+dj2VE2toM8vLRDz3+DB4YB2lFofYlex++ +16xFzOIE+ZW41qBs3G8InsyHADsaFY2CQ9re/kZvenptU/ax1U2a21JJ3TT2DmXW +AHZYQ5/whVIowsebw1e28I12VhLl2BKn7v4MpCn3GUzBBQAEbJ6TIjHtFKWWnVfH +FisaUX6N4hMzGZVJOsbH4QVBGuNwUshHiD8MSpbans2w+T4bCe11XayerqxFhTao +w/pjiPVy +-----END CERTIFICATE----- +`, + expected: false, + }, + } + + for _, tt := range tc { + t.Run(tt.name, func(t *testing.T) { + actual, err := IsSelfSignedCertificate([]byte(tt.cert)) + require.NoError(t, err) + assert.Equal(t, tt.expected, actual) + }) + } +} diff --git a/api/http/handler/kubernetes/configmaps.go b/api/http/handler/kubernetes/configmaps.go new file mode 100644 index 0000000..bd10ae1 --- /dev/null +++ b/api/http/handler/kubernetes/configmaps.go @@ -0,0 +1,157 @@ +package kubernetes + +import ( + "net/http" + + models "github.com/portainer/portainer/api/http/models/kubernetes" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" + "github.com/rs/zerolog/log" + k8serrors "k8s.io/apimachinery/pkg/api/errors" +) + +// @id GetKubernetesConfigMap +// @summary Get a ConfigMap +// @description Get a ConfigMap by name for a given namespace. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment identifier" +// @param namespace path string true "The namespace name where the configmap is located" +// @param configmap path string true "The configmap name to get details for" +// @success 200 {object} models.K8sConfigMap "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier or a configmap with the specified name in the given namespace." +// @failure 500 "Server error occurred while attempting to retrieve a configmap by name within the specified namespace." +// @router /kubernetes/{id}/namespaces/{namespace}/configmaps/{configmap} [get] +func (handler *Handler) getKubernetesConfigMap(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + namespace, err := request.RetrieveRouteVariableValue(r, "namespace") + if err != nil { + log.Error().Err(err).Str("context", "getKubernetesConfigMap").Str("namespace", namespace).Msg("Unable to retrieve namespace identifier route variable") + return httperror.BadRequest("Unable to retrieve namespace identifier route variable", err) + } + + configMapName, err := request.RetrieveRouteVariableValue(r, "configmap") + if err != nil { + log.Error().Err(err).Str("context", "getKubernetesConfigMap").Str("namespace", namespace).Msg("Unable to retrieve configMap identifier route variable") + return httperror.BadRequest("Unable to retrieve configMap identifier route variable", err) + } + + cli, httpErr := handler.getProxyKubeClient(r) + if httpErr != nil { + log.Error().Err(httpErr).Str("context", "getKubernetesConfigMap").Str("namespace", namespace).Str("configMap", configMapName).Msg("Unable to get a Kubernetes client for the user") + return httperror.InternalServerError("Unable to get a Kubernetes client for the user", httpErr) + } + + configMap, err := cli.GetConfigMap(namespace, configMapName) + if err != nil { + if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) { + log.Error().Err(err).Str("context", "getKubernetesConfigMap").Str("namespace", namespace).Str("configMap", configMapName).Msg("Unauthorized access to the Kubernetes API") + return httperror.Forbidden("Unauthorized access to the Kubernetes API", err) + } + + if k8serrors.IsNotFound(err) { + log.Error().Err(err).Str("context", "getKubernetesConfigMap").Str("namespace", namespace).Str("configMap", configMapName).Msg("Unable to retrieve configMap") + return httperror.NotFound("Unable to retrieve configMap", err) + } + + log.Error().Err(err).Str("context", "getKubernetesConfigMap").Str("namespace", namespace).Str("configMap", configMapName).Msg("Unable to retrieve configMap") + return httperror.InternalServerError("Unable to retrieve configMap", err) + } + + configMapWithApplications, err := cli.CombineConfigMapWithApplications(configMap) + if err != nil { + log.Error().Err(err).Str("context", "getKubernetesConfigMap").Str("namespace", namespace).Str("configMap", configMapName).Msg("Unable to combine configMap with applications") + return httperror.InternalServerError("Unable to combine configMap with applications", err) + } + + return response.JSON(w, configMapWithApplications) +} + +// @id GetAllKubernetesConfigMaps +// @summary Get a list of ConfigMaps +// @description Get a list of ConfigMaps across all namespaces in the cluster. For non-admin users, it will only return ConfigMaps based on the namespaces that they have access to. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment identifier" +// @param isUsed query bool true "Set to true to include information about applications that use the ConfigMaps in the response" +// @success 200 {array} models.K8sConfigMap "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier." +// @failure 500 "Server error occurred while attempting to retrieve all configmaps from the cluster." +// @router /kubernetes/{id}/configmaps [get] +func (handler *Handler) GetAllKubernetesConfigMaps(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + configMaps, err := handler.getAllKubernetesConfigMaps(r) + if err != nil { + return err + } + + return response.JSON(w, configMaps) +} + +// @id GetAllKubernetesConfigMapsCount +// @summary Get ConfigMaps count +// @description Get the count of ConfigMaps across all namespaces in the cluster. For non-admin users, it will only return the count of ConfigMaps based on the namespaces that they have access to. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment identifier" +// @success 200 {integer} integer "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier." +// @failure 500 "Server error occurred while attempting to retrieve the count of all configmaps from the cluster." +// @router /kubernetes/{id}/configmaps/count [get] +func (handler *Handler) getAllKubernetesConfigMapsCount(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + configMaps, err := handler.getAllKubernetesConfigMaps(r) + if err != nil { + return err + } + + return response.JSON(w, len(configMaps)) +} + +func (handler *Handler) getAllKubernetesConfigMaps(r *http.Request) ([]models.K8sConfigMap, *httperror.HandlerError) { + isUsed, err := request.RetrieveBooleanQueryParameter(r, "isUsed", true) + if err != nil { + log.Error().Err(err).Str("context", "getAllKubernetesConfigMaps").Msg("Unable to retrieve isUsed query parameter") + return nil, httperror.BadRequest("Unable to retrieve isUsed query parameter", err) + } + + cli, httpErr := handler.prepareKubeClient(r) + if httpErr != nil { + log.Error().Err(httpErr).Str("context", "getAllKubernetesConfigMaps").Msg("Unable to prepare kube client") + return nil, httperror.InternalServerError("Unable to prepare kube client", httpErr) + } + + configMaps, err := cli.GetConfigMaps("") + if err != nil { + if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) { + log.Error().Err(err).Str("context", "getAllKubernetesConfigMaps").Msg("Unauthorized access to the Kubernetes API") + return nil, httperror.Forbidden("Unauthorized access to the Kubernetes API", err) + } + + log.Error().Err(err).Str("context", "getAllKubernetesConfigMaps").Msg("Unable to get configMaps") + return nil, httperror.InternalServerError("Unable to get configMaps", err) + } + + if isUsed { + err = cli.SetConfigMapsIsUsed(&configMaps) + if err != nil { + log.Error().Err(err).Str("context", "getAllKubernetesConfigMaps").Msg("Unable to combine configMaps with associated applications") + return nil, httperror.InternalServerError("Unable to combine configMaps with associated applications", err) + } + } + + return configMaps, nil +} diff --git a/api/http/handler/kubernetes/cron_job.go b/api/http/handler/kubernetes/cron_job.go new file mode 100644 index 0000000..cad65bf --- /dev/null +++ b/api/http/handler/kubernetes/cron_job.go @@ -0,0 +1,78 @@ +package kubernetes + +import ( + "net/http" + + models "github.com/portainer/portainer/api/http/models/kubernetes" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" + "github.com/rs/zerolog/log" +) + +// @id GetKubernetesCronJobs +// @summary Get a list of kubernetes Cron Jobs +// @description Get a list of kubernetes Cron Jobs that the user has access to. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment identifier" +// @success 200 {array} models.K8sCronJob "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier." +// @failure 500 "Server error occurred while attempting to retrieve the list of Cron Jobs." +// @router /kubernetes/{id}/cron_jobs [get] +func (handler *Handler) getAllKubernetesCronJobs(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + cli, httpErr := handler.prepareKubeClient(r) + if httpErr != nil { + log.Error().Err(httpErr).Str("context", "GetAllKubernetesCronJobs").Msg("Unable to prepare kube client") + return httperror.InternalServerError("unable to prepare kube client. Error: ", httpErr) + } + + cronJobs, err := cli.GetCronJobs("") + if err != nil { + log.Error().Err(err).Str("context", "GetAllKubernetesCronJobs").Msg("Unable to fetch Cron Jobs across all namespaces") + return httperror.InternalServerError("unable to fetch Cron Jobs. Error: ", err) + } + + return response.JSON(w, cronJobs) +} + +// @id DeleteCronJobs +// @summary Delete Cron Jobs +// @description Delete the provided list of Cron Jobs. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @accept json +// @param id path int true "Environment identifier" +// @param payload body models.K8sCronJobDeleteRequests true "A map where the key is the namespace and the value is an array of Cron Jobs to delete" +// @success 204 "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier or unable to find a specific service account." +// @failure 500 "Server error occurred while attempting to delete Cron Jobs." +// @router /kubernetes/{id}/cron_jobs/delete [POST] +func (handler *Handler) deleteKubernetesCronJobs(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload models.K8sCronJobDeleteRequests + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + cli, handlerErr := handler.getProxyKubeClient(r) + if handlerErr != nil { + return handlerErr + } + + err = cli.DeleteCronJobs(payload) + if err != nil { + return httperror.InternalServerError("Unable to delete Cron Jobs", err) + } + + return response.Empty(w) +} diff --git a/api/http/handler/kubernetes/dashboard.go b/api/http/handler/kubernetes/dashboard.go new file mode 100644 index 0000000..b27790f --- /dev/null +++ b/api/http/handler/kubernetes/dashboard.go @@ -0,0 +1,36 @@ +package kubernetes + +import ( + "net/http" + + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +// @id GetKubernetesDashboard +// @summary Get the dashboard summary data +// @description Get the dashboard summary data which is simply a count of a range of different commonly used kubernetes resources. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @accept json +// @produce json +// @param id path int true "Environment (Endpoint) identifier" +// @success 200 {array} kubernetes.K8sDashboard "Success" +// @failure 400 "Invalid request" +// @failure 500 "Server error" +// @router /kubernetes/{id}/dashboard [get] +func (handler *Handler) getKubernetesDashboard(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + + cli, httpErr := handler.getProxyKubeClient(r) + if httpErr != nil { + return httpErr + } + + dashboard, err := cli.GetDashboard() + if err != nil { + return httperror.InternalServerError("Unable to retrieve dashboard data", err) + } + + return response.JSON(w, dashboard) +} diff --git a/api/http/handler/kubernetes/deprecated_routes.go b/api/http/handler/kubernetes/deprecated_routes.go new file mode 100644 index 0000000..e471c2e --- /dev/null +++ b/api/http/handler/kubernetes/deprecated_routes.go @@ -0,0 +1,54 @@ +package kubernetes + +import ( + "bytes" + "io" + "net/http" + + models "github.com/portainer/portainer/api/http/models/kubernetes" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" +) + +// @id UpdateKubernetesNamespaceDeprecated +// @summary Update a namespace +// @description Update a namespace within the given environment. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @accept json +// @produce json +// @param id path int true "Environment identifier" +// @param namespace path string true "Namespace" +// @param body body models.K8sNamespaceDetails true "Namespace details" +// @success 200 {object} portainer.K8sNamespaceInfo "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier or unable to find a specific namespace." +// @failure 500 "Server error occurred while attempting to update the namespace." +// @router /kubernetes/{id}/namespaces [put] +func deprecatedNamespaceParser(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) { + environmentId, err := request.RetrieveRouteVariableValue(r, "id") + if err != nil { + return "", httperror.BadRequest("Invalid query parameter: id", err) + } + + // Restore the original body for further use + bodyBytes, err := io.ReadAll(r.Body) + if err != nil { + return "", httperror.InternalServerError("Unable to read request body", err) + } + + r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) + + payload := models.K8sNamespaceDetails{} + if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil { + return "", httperror.BadRequest("Invalid request. Unable to parse namespace payload", err) + } + namespaceName := payload.Name + + r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) + + return "/kubernetes/" + environmentId + "/namespaces/" + namespaceName, nil +} diff --git a/api/http/handler/kubernetes/describe.go b/api/http/handler/kubernetes/describe.go new file mode 100644 index 0000000..ce8f174 --- /dev/null +++ b/api/http/handler/kubernetes/describe.go @@ -0,0 +1,73 @@ +package kubernetes + +import ( + "net/http" + + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" + "github.com/portainer/portainer/pkg/libkubectl" + "github.com/rs/zerolog/log" +) + +type describeResourceResponse struct { + Describe string `json:"describe"` +} + +// @id DescribeResource +// @summary Get a description of a kubernetes resource +// @description Get a description of a kubernetes resource. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment identifier" +// @param name query string true "Resource name" +// @param kind query string true "Resource kind" +// @param namespace query string false "Namespace" +// @success 200 {object} describeResourceResponse "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier." +// @failure 500 "Server error occurred while attempting to retrieve resource description" +// @router /kubernetes/{id}/describe [get] +func (handler *Handler) describeResource(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + name, err := request.RetrieveQueryParameter(r, "name", false) + if err != nil { + log.Error().Err(err).Str("context", "describeResource").Msg("Invalid query parameter name") + return httperror.BadRequest("an error occurred during the describeResource operation, invalid query parameter name. Error: ", err) + } + + kind, err := request.RetrieveQueryParameter(r, "kind", false) + if err != nil { + log.Error().Err(err).Str("context", "describeResource").Msg("Invalid query parameter kind") + return httperror.BadRequest("an error occurred during the describeResource operation, invalid query parameter kind. Error: ", err) + } + + namespace, err := request.RetrieveQueryParameter(r, "namespace", true) + if err != nil { + log.Error().Err(err).Str("context", "describeResource").Msg("Invalid query parameter namespace") + return httperror.BadRequest("an error occurred during the describeResource operation, invalid query parameter namespace. Error: ", err) + } + + // fetches the token and the correct server URL for the endpoint, similar to getHelmClusterAccess + libKubectlAccess, err := handler.getLibKubectlAccess(r) + if err != nil { + return httperror.InternalServerError("an error occurred during the describeResource operation, failed to get libKubectlAccess. Error: ", err) + } + + client, err := libkubectl.NewClient(libKubectlAccess, namespace, "", true) + if err != nil { + log.Error().Err(err).Str("context", "describeResource").Msg("Failed to create kubernetes client") + return httperror.InternalServerError("an error occurred during the describeResource operation, failed to create kubernetes client. Error: ", err) + } + + out, err := client.Describe(namespace, name, kind) + if err != nil { + log.Error().Err(err).Str("context", "describeResource").Msg("Failed to describe kubernetes resource") + return httperror.InternalServerError("an error occurred during the describeResource operation, failed to describe kubernetes resource. Error: ", err) + } + + return response.JSON(w, describeResourceResponse{Describe: out}) +} diff --git a/api/http/handler/kubernetes/endpoint_authorization_test.go b/api/http/handler/kubernetes/endpoint_authorization_test.go new file mode 100644 index 0000000..77eb969 --- /dev/null +++ b/api/http/handler/kubernetes/endpoint_authorization_test.go @@ -0,0 +1,124 @@ +package kubernetes + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/authorization" + "github.com/portainer/portainer/api/internal/testhelpers" + "github.com/portainer/portainer/api/jwt" + "github.com/portainer/portainer/api/kubernetes" + kubeclient "github.com/portainer/portainer/api/kubernetes/cli" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// denyingBouncer satisfies security.BouncerService but rejects AuthorizedEndpointOperation. +type denyingBouncer struct{} + +func (denyingBouncer) PublicAccess(h http.Handler) http.Handler { return h } +func (denyingBouncer) AdminAccess(h http.Handler) http.Handler { return h } +func (denyingBouncer) RestrictedAccess(h http.Handler) http.Handler { return h } +func (denyingBouncer) TeamLeaderAccess(h http.Handler) http.Handler { return h } +func (denyingBouncer) AuthenticatedAccess(h http.Handler) http.Handler { return h } +func (denyingBouncer) EdgeComputeOperation(h http.Handler) http.Handler { return h } +func (denyingBouncer) AuthorizedEndpointOperation(_ *http.Request, _ *portainer.Endpoint) error { + return security.ErrAuthorizationRequired +} +func (denyingBouncer) AuthorizedEdgeEndpointOperation(_ *http.Request, _ *portainer.Endpoint) error { + return nil +} +func (denyingBouncer) CookieAuthLookup(*http.Request) (*portainer.TokenData, error) { return nil, nil } +func (denyingBouncer) JWTAuthLookup(*http.Request) (*portainer.TokenData, error) { return nil, nil } +func (denyingBouncer) TrustedEdgeEnvironmentAccess(dataservices.DataStoreTx, *portainer.Endpoint) error { + return nil +} +func (denyingBouncer) RevokeJWT(string) {} +func (denyingBouncer) DisableCSP() {} + +func newEndpointAuthTestHandler(t *testing.T, bouncer security.BouncerService) (*Handler, *portainer.User, string) { + t.Helper() + + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{}`)) + })) + t.Cleanup(srv.Close) + + _, store := datastore.MustNewTestStore(t, true, true) + + err := store.Endpoint().Create(&portainer.Endpoint{ + ID: 1, + Type: portainer.KubernetesLocalEnvironment, + }) + require.NoError(t, err) + + u := &portainer.User{Username: "user", Role: portainer.StandardUserRole} + err = store.User().Create(u) + require.NoError(t, err) + + jwtService, err := jwt.NewService("1h", store) + require.NoError(t, err) + + tk, _, err := jwtService.GenerateToken(&portainer.TokenData{ID: u.ID, Username: u.Username, Role: u.Role}) + require.NoError(t, err) + + kubeClusterAccessService := kubernetes.NewKubeClusterAccessService("", "", "") + + srvURL, err := url.Parse(srv.URL) + require.NoError(t, err) + + cli := testhelpers.NewKubernetesClient() + factory, err := kubeclient.NewClientFactory(nil, nil, store, "", ":"+srvURL.Port(), "") + require.NoError(t, err) + + authorizationService := authorization.NewService(store) + handler := NewHandler(bouncer, authorizationService, store, jwtService, kubeClusterAccessService, factory, cli) + + return handler, u, tk +} + +func newEndpointAuthRequest(t *testing.T, method, path string, u *portainer.User, tk string) *http.Request { + t.Helper() + + req := httptest.NewRequest(method, path, nil) + ctx := security.StoreTokenData(req, &portainer.TokenData{ID: u.ID, Username: u.Username, Role: u.Role}) + req = req.WithContext(ctx) + ctx = security.StoreRestrictedRequestContext(req, &security.RestrictedRequestContext{IsAdmin: false, UserID: u.ID}) + req = req.WithContext(ctx) + testhelpers.AddTestSecurityCookie(req, tk) + return req +} + +func TestEndpointAuthorization_DeniedUser_Returns403(t *testing.T) { + t.Parallel() + + routes := []struct { + method string + path string + }{ + {http.MethodGet, "/kubernetes/1/namespaces"}, + {http.MethodGet, "/kubernetes/1/configmaps"}, + {http.MethodGet, "/kubernetes/1/services"}, + {http.MethodGet, "/kubernetes/1/secrets"}, + {http.MethodGet, "/kubernetes/1/ingresses"}, + } + + handler, u, tk := newEndpointAuthTestHandler(t, denyingBouncer{}) + + for _, route := range routes { + t.Run(route.method+" "+route.path, func(t *testing.T) { + req := newEndpointAuthRequest(t, route.method, route.path, u, tk) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusForbidden, rr.Code) + }) + } +} diff --git a/api/http/handler/kubernetes/event.go b/api/http/handler/kubernetes/event.go new file mode 100644 index 0000000..25f0243 --- /dev/null +++ b/api/http/handler/kubernetes/event.go @@ -0,0 +1,102 @@ +package kubernetes + +import ( + "net/http" + + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" + "github.com/rs/zerolog/log" + k8serrors "k8s.io/apimachinery/pkg/api/errors" +) + +// @id getKubernetesEventsForNamespace +// @summary Gets kubernetes events for namespace +// @description Get events by optional query param resourceId for a given namespace. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment identifier" +// @param namespace path string true "The namespace name the events are associated to" +// @param resourceId query string false "The resource id of the involved kubernetes object" example:"e5b021b6-4bce-4c06-bd3b-6cca906797aa" +// @success 200 {object} []kubernetes.K8sEvent "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 500 "Server error occurred while attempting to retrieve the events within the specified namespace." +// @router /kubernetes/{id}/namespaces/{namespace}/events [get] +func (handler *Handler) getKubernetesEventsForNamespace(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + namespace, err := request.RetrieveRouteVariableValue(r, "namespace") + if err != nil { + log.Error().Err(err).Str("context", "getKubernetesEvents").Str("namespace", namespace).Msg("Unable to retrieve namespace identifier route variable") + return httperror.BadRequest("Unable to retrieve namespace identifier route variable", err) + } + + resourceId, err := request.RetrieveQueryParameter(r, "resourceId", true) + if err != nil { + log.Error().Err(err).Str("context", "getKubernetesEvents").Msg("Unable to retrieve resourceId query parameter") + return httperror.BadRequest("Unable to retrieve resourceId query parameter", err) + } + + cli, httpErr := handler.getProxyKubeClient(r) + if httpErr != nil { + log.Error().Err(httpErr).Str("context", "getKubernetesEvents").Str("resourceId", resourceId).Msg("Unable to get a Kubernetes client for the user") + return httperror.InternalServerError("Unable to get a Kubernetes client for the user", httpErr) + } + + events, err := cli.GetEvents(namespace, resourceId) + if err != nil { + if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) { + log.Error().Err(err).Str("context", "getKubernetesEvents").Msg("Unauthorized access to the Kubernetes API") + return httperror.Forbidden("Unauthorized access to the Kubernetes API", err) + } + + log.Error().Err(err).Str("context", "getKubernetesEvents").Msg("Unable to retrieve events") + return httperror.InternalServerError("Unable to retrieve events", err) + } + + return response.JSON(w, events) +} + +// @id getAllKubernetesEvents +// @summary Gets kubernetes events +// @description Get events by query param resourceId +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment identifier" +// @param resourceId query string false "The resource id of the involved kubernetes object" example:"e5b021b6-4bce-4c06-bd3b-6cca906797aa" +// @success 200 {object} []kubernetes.K8sEvent "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 500 "Server error occurred while attempting to retrieve the events." +// @router /kubernetes/{id}/events [get] +func (handler *Handler) getAllKubernetesEvents(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + resourceId, err := request.RetrieveQueryParameter(r, "resourceId", true) + if err != nil { + log.Error().Err(err).Str("context", "getKubernetesEvents").Msg("Unable to retrieve resourceId query parameter") + return httperror.BadRequest("Unable to retrieve resourceId query parameter", err) + } + + cli, httpErr := handler.getProxyKubeClient(r) + if httpErr != nil { + log.Error().Err(httpErr).Str("context", "getKubernetesEvents").Str("resourceId", resourceId).Msg("Unable to get a Kubernetes client for the user") + return httperror.InternalServerError("Unable to get a Kubernetes client for the user", httpErr) + } + + events, err := cli.GetEvents("", resourceId) + if err != nil { + if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) { + log.Error().Err(err).Str("context", "getKubernetesEvents").Msg("Unauthorized access to the Kubernetes API") + return httperror.Forbidden("Unauthorized access to the Kubernetes API", err) + } + + log.Error().Err(err).Str("context", "getKubernetesEvents").Msg("Unable to retrieve events") + return httperror.InternalServerError("Unable to retrieve events", err) + } + + return response.JSON(w, events) +} diff --git a/api/http/handler/kubernetes/event_test.go b/api/http/handler/kubernetes/event_test.go new file mode 100644 index 0000000..2c46cd2 --- /dev/null +++ b/api/http/handler/kubernetes/event_test.go @@ -0,0 +1,75 @@ +package kubernetes + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/authorization" + "github.com/portainer/portainer/api/internal/testhelpers" + "github.com/portainer/portainer/api/jwt" + "github.com/portainer/portainer/api/kubernetes" + kubeclient "github.com/portainer/portainer/api/kubernetes/cli" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Currently this test just tests the HTTP Handler is setup correctly, in the future we should move the ClientFactory to a mock in order +// test the logic in event.go +func TestGetKubernetesEvents(t *testing.T) { + t.Parallel() + is := assert.New(t) + + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{}`)) + })) + defer srv.Close() + + _, store := datastore.MustNewTestStore(t, true, true) + + err := store.Endpoint().Create(&portainer.Endpoint{ID: 1, Type: portainer.AgentOnKubernetesEnvironment}) + require.NoError(t, err, "error creating environment") + + u := &portainer.User{Username: "admin", Role: portainer.AdministratorRole} + err = store.User().Create(u) + require.NoError(t, err, "error creating a user") + + jwtService, err := jwt.NewService("1h", store) + require.NoError(t, err, "Error initiating jwt service") + + tk, _, err := jwtService.GenerateToken(&portainer.TokenData{ID: u.ID, Username: u.Username, Role: u.Role}) + require.NoError(t, err) + + kubeClusterAccessService := kubernetes.NewKubeClusterAccessService("", "", "") + + srvURL, err := url.Parse(srv.URL) + require.NoError(t, err) + + cli := testhelpers.NewKubernetesClient() + factory, err := kubeclient.NewClientFactory(nil, nil, store, "", ":"+srvURL.Port(), "") + require.NoError(t, err) + + authorizationService := authorization.NewService(store) + handler := NewHandler(testhelpers.NewTestRequestBouncer(), authorizationService, store, jwtService, kubeClusterAccessService, factory, cli) + is.NotNil(handler, "Handler should not fail") + + req := httptest.NewRequest(http.MethodGet, "/kubernetes/1/events?resourceId=8", nil) + ctx := security.StoreTokenData(req, &portainer.TokenData{ID: u.ID, Username: u.Username, Role: u.Role}) + req = req.WithContext(ctx) + + ctx = security.StoreRestrictedRequestContext(req, &security.RestrictedRequestContext{UserID: u.ID, IsAdmin: true}) + req = req.WithContext(ctx) + + testhelpers.AddTestSecurityCookie(req, tk) + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + is.Equal(http.StatusOK, rr.Code, "Status should be 200") +} diff --git a/api/http/handler/kubernetes/handler.go b/api/http/handler/kubernetes/handler.go new file mode 100644 index 0000000..c8a429d --- /dev/null +++ b/api/http/handler/kubernetes/handler.go @@ -0,0 +1,332 @@ +package kubernetes + +import ( + "fmt" + "net/http" + "net/url" + "strconv" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/http/middlewares" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/authorization" + "github.com/portainer/portainer/api/kubernetes" + "github.com/portainer/portainer/api/kubernetes/cli" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libkubectl" + "github.com/rs/zerolog/log" + + "github.com/gorilla/mux" +) + +// Handler is the HTTP handler which will natively deal with to external environments(endpoints). +type Handler struct { + *mux.Router + authorizationService *authorization.Service + DataStore dataservices.DataStore + KubernetesClientFactory *cli.ClientFactory + JwtService portainer.JWTService + kubeClusterAccessService kubernetes.KubeClusterAccessService +} + +// NewHandler creates a handler to process pre-proxied requests to external APIs. +func NewHandler(bouncer security.BouncerService, authorizationService *authorization.Service, dataStore dataservices.DataStore, jwtService portainer.JWTService, kubeClusterAccessService kubernetes.KubeClusterAccessService, kubernetesClientFactory *cli.ClientFactory, kubernetesClient portainer.KubeClient) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + authorizationService: authorizationService, + DataStore: dataStore, + JwtService: jwtService, + kubeClusterAccessService: kubeClusterAccessService, + KubernetesClientFactory: kubernetesClientFactory, + } + + kubeRouter := h.PathPrefix("/kubernetes").Subrouter() + kubeRouter.Use(bouncer.AuthenticatedAccess) + kubeRouter.PathPrefix("/config").Handler( + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.getKubernetesConfig))).Methods(http.MethodGet) + + // endpoints + endpointRouter := kubeRouter.PathPrefix("/{id}").Subrouter() + endpointRouter.Use(middlewares.WithEndpoint(dataStore.Endpoint(), "id")) + endpointRouter.Use(middlewares.CheckEndpointAuthorization(bouncer)) + endpointRouter.Use(h.kubeClientMiddleware) + + endpointRouter.Handle("/applications", httperror.LoggerHandler(h.GetAllKubernetesApplications)).Methods(http.MethodGet) + endpointRouter.Handle("/applications/count", httperror.LoggerHandler(h.getAllKubernetesApplicationsCount)).Methods(http.MethodGet) + endpointRouter.Handle("/configmaps", httperror.LoggerHandler(h.GetAllKubernetesConfigMaps)).Methods(http.MethodGet) + endpointRouter.Handle("/configmaps/count", httperror.LoggerHandler(h.getAllKubernetesConfigMapsCount)).Methods(http.MethodGet) + endpointRouter.Handle("/cron_jobs", httperror.LoggerHandler(h.getAllKubernetesCronJobs)).Methods(http.MethodGet) + endpointRouter.Handle("/cron_jobs/delete", httperror.LoggerHandler(h.deleteKubernetesCronJobs)).Methods(http.MethodPost) + endpointRouter.Handle("/events", httperror.LoggerHandler(h.getAllKubernetesEvents)).Methods(http.MethodGet) + endpointRouter.Handle("/jobs", httperror.LoggerHandler(h.getAllKubernetesJobs)).Methods(http.MethodGet) + endpointRouter.Handle("/jobs/delete", httperror.LoggerHandler(h.deleteKubernetesJobs)).Methods(http.MethodPost) + endpointRouter.Handle("/cluster_roles", httperror.LoggerHandler(h.getAllKubernetesClusterRoles)).Methods(http.MethodGet) + endpointRouter.Handle("/cluster_roles/delete", httperror.LoggerHandler(h.deleteClusterRoles)).Methods(http.MethodPost) + endpointRouter.Handle("/cluster_role_bindings", httperror.LoggerHandler(h.getAllKubernetesClusterRoleBindings)).Methods(http.MethodGet) + endpointRouter.Handle("/cluster_role_bindings/delete", httperror.LoggerHandler(h.deleteClusterRoleBindings)).Methods(http.MethodPost) + endpointRouter.Handle("/configmaps", httperror.LoggerHandler(h.GetAllKubernetesConfigMaps)).Methods(http.MethodGet) + endpointRouter.Handle("/configmaps/count", httperror.LoggerHandler(h.getAllKubernetesConfigMapsCount)).Methods(http.MethodGet) + endpointRouter.Handle("/dashboard", httperror.LoggerHandler(h.getKubernetesDashboard)).Methods(http.MethodGet) + endpointRouter.Handle("/nodes_limits", httperror.LoggerHandler(h.getKubernetesNodesLimits)).Methods(http.MethodGet) + endpointRouter.Handle("/max_resource_limits", httperror.LoggerHandler(h.getKubernetesMaxResourceLimits)).Methods(http.MethodGet) + endpointRouter.Handle("/metrics/applications_resources", httperror.LoggerHandler(h.getApplicationsResources)).Methods(http.MethodGet) + endpointRouter.Handle("/metrics/nodes", httperror.LoggerHandler(h.getKubernetesMetricsForAllNodes)).Methods(http.MethodGet) + endpointRouter.Handle("/metrics/nodes/{name}", httperror.LoggerHandler(h.getKubernetesMetricsForNode)).Methods(http.MethodGet) + endpointRouter.Handle("/metrics/pods/namespace/{namespace}", httperror.LoggerHandler(h.getKubernetesMetricsForAllPods)).Methods(http.MethodGet) + endpointRouter.Handle("/metrics/pods/namespace/{namespace}/{name}", httperror.LoggerHandler(h.getKubernetesMetricsForPod)).Methods(http.MethodGet) + endpointRouter.Handle("/ingresscontrollers", httperror.LoggerHandler(h.getAllKubernetesIngressControllers)).Methods(http.MethodGet) + endpointRouter.Handle("/ingresscontrollers", httperror.LoggerHandler(h.updateKubernetesIngressControllers)).Methods(http.MethodPut) + endpointRouter.Handle("/ingresses/delete", httperror.LoggerHandler(h.deleteKubernetesIngresses)).Methods(http.MethodPost) + endpointRouter.Handle("/ingresses", httperror.LoggerHandler(h.GetAllKubernetesClusterIngresses)).Methods(http.MethodGet) + endpointRouter.Handle("/ingresses/count", httperror.LoggerHandler(h.getAllKubernetesClusterIngressesCount)).Methods(http.MethodGet) + endpointRouter.Handle("/services", httperror.LoggerHandler(h.GetAllKubernetesServices)).Methods(http.MethodGet) + endpointRouter.Handle("/services/count", httperror.LoggerHandler(h.getAllKubernetesServicesCount)).Methods(http.MethodGet) + endpointRouter.Handle("/secrets", httperror.LoggerHandler(h.GetAllKubernetesSecrets)).Methods(http.MethodGet) + endpointRouter.Handle("/secrets/count", httperror.LoggerHandler(h.getAllKubernetesSecretsCount)).Methods(http.MethodGet) + endpointRouter.Handle("/services/delete", httperror.LoggerHandler(h.deleteKubernetesServices)).Methods(http.MethodPost) + endpointRouter.Handle("/rbac_enabled", httperror.LoggerHandler(h.getKubernetesRBACStatus)).Methods(http.MethodGet) + endpointRouter.Handle("/namespaces", httperror.LoggerHandler(h.createKubernetesNamespace)).Methods(http.MethodPost) + endpointRouter.Handle("/namespaces", httperror.LoggerHandler(h.deleteKubernetesNamespace)).Methods(http.MethodDelete) + endpointRouter.Handle("/namespaces", httperror.LoggerHandler(h.getKubernetesNamespaces)).Methods(http.MethodGet) + endpointRouter.Handle("/namespaces/count", httperror.LoggerHandler(h.getKubernetesNamespacesCount)).Methods(http.MethodGet) + endpointRouter.Handle("/namespaces/{namespace}", httperror.LoggerHandler(h.getKubernetesNamespace)).Methods(http.MethodGet) + endpointRouter.Handle("/namespaces/{namespace}", httperror.LoggerHandler(h.updateKubernetesNamespace)).Methods(http.MethodPut) + endpointRouter.Handle("/volumes", httperror.LoggerHandler(h.GetAllKubernetesVolumes)).Methods(http.MethodGet) + endpointRouter.Handle("/volumes/count", httperror.LoggerHandler(h.getAllKubernetesVolumesCount)).Methods(http.MethodGet) + endpointRouter.Handle("/persistent_volumes", httperror.LoggerHandler(h.getAllKubernetesPersistentVolumes)).Methods(http.MethodGet) + endpointRouter.Handle("/persistent_volumes/delete", httperror.LoggerHandler(h.deleteKubernetesPersistentVolumes)).Methods(http.MethodPost) + endpointRouter.Handle("/persistent_volumes/reclaim_policy", httperror.LoggerHandler(h.updateKubernetesPVReclaimPolicy)).Methods(http.MethodPut) + endpointRouter.Handle("/persistent_volumes/{name}", httperror.LoggerHandler(h.getKubernetesPersistentVolume)).Methods(http.MethodGet) + endpointRouter.Handle("/persistent_volume_claims", httperror.LoggerHandler(h.getAllKubernetesPersistentVolumeClaims)).Methods(http.MethodGet) + endpointRouter.Handle("/persistent_volume_claims/delete", httperror.LoggerHandler(h.deleteKubernetesPersistentVolumeClaims)).Methods(http.MethodPost) + endpointRouter.Handle("/persistent_volume_claims/resize", httperror.LoggerHandler(h.resizeKubernetesPersistentVolumeClaim)).Methods(http.MethodPut) + endpointRouter.Handle("/storage_classes", httperror.LoggerHandler(h.getAllKubernetesStorageClasses)).Methods(http.MethodGet) + endpointRouter.Handle("/storage_classes/delete", httperror.LoggerHandler(h.deleteKubernetesStorageClasses)).Methods(http.MethodPost) + endpointRouter.Handle("/storage_classes/{name}", httperror.LoggerHandler(h.getKubernetesStorageClass)).Methods(http.MethodGet) + endpointRouter.Handle("/storage_classes/{name}/default", httperror.LoggerHandler(h.setDefaultKubernetesStorageClass)).Methods(http.MethodPut) + endpointRouter.Handle("/service_accounts", httperror.LoggerHandler(h.getAllKubernetesServiceAccounts)).Methods(http.MethodGet) + endpointRouter.Handle("/service_accounts/delete", httperror.LoggerHandler(h.deleteKubernetesServiceAccounts)).Methods(http.MethodPost) + endpointRouter.Handle("/roles", httperror.LoggerHandler(h.getAllKubernetesRoles)).Methods(http.MethodGet) + endpointRouter.Handle("/roles/delete", httperror.LoggerHandler(h.deleteRoles)).Methods(http.MethodPost) + endpointRouter.Handle("/role_bindings", httperror.LoggerHandler(h.getAllKubernetesRoleBindings)).Methods(http.MethodGet) + endpointRouter.Handle("/role_bindings/delete", httperror.LoggerHandler(h.deleteRoleBindings)).Methods(http.MethodPost) + endpointRouter.Handle("/cluster_roles", httperror.LoggerHandler(h.getAllKubernetesClusterRoles)).Methods(http.MethodGet) + endpointRouter.Handle("/cluster_roles/delete", httperror.LoggerHandler(h.deleteClusterRoles)).Methods(http.MethodPost) + endpointRouter.Handle("/cluster_role_bindings", httperror.LoggerHandler(h.getAllKubernetesClusterRoleBindings)).Methods(http.MethodGet) + endpointRouter.Handle("/cluster_role_bindings/delete", httperror.LoggerHandler(h.deleteClusterRoleBindings)).Methods(http.MethodPost) + endpointRouter.Handle("/describe", httperror.LoggerHandler(h.describeResource)).Methods(http.MethodGet) + endpointRouter.Handle("/nodes", httperror.LoggerHandler(h.getKubernetesNodes)).Methods(http.MethodGet) + endpointRouter.Handle("/nodes/{name}/drain", httperror.LoggerHandler(h.drainNode)).Methods(http.MethodPost) + endpointRouter.Handle("/version", httperror.LoggerHandler(h.getKubernetesVersion)).Methods(http.MethodGet) + + // namespaces + // in the future this piece of code might be in another package (or a few different packages - namespaces/namespace?) + // to keep it simple, we've decided to leave it like this. + namespaceRouter := endpointRouter.PathPrefix("/namespaces/{namespace}").Subrouter() + namespaceRouter.Handle("/configmaps/{configmap}", httperror.LoggerHandler(h.getKubernetesConfigMap)).Methods(http.MethodGet) + namespaceRouter.Handle("/events", httperror.LoggerHandler(h.getKubernetesEventsForNamespace)).Methods(http.MethodGet) + namespaceRouter.Handle("/system", bouncer.AdminAccess(httperror.LoggerHandler(h.namespacesToggleSystem))).Methods(http.MethodPut) + namespaceRouter.Handle("/ingresscontrollers", httperror.LoggerHandler(h.getKubernetesIngressControllersByNamespace)).Methods(http.MethodGet) + namespaceRouter.Handle("/ingresscontrollers", httperror.LoggerHandler(h.updateKubernetesIngressControllersByNamespace)).Methods(http.MethodPut) + namespaceRouter.Handle("/ingresses/{ingress}", httperror.LoggerHandler(h.getKubernetesIngress)).Methods(http.MethodGet) + namespaceRouter.Handle("/ingresses", httperror.LoggerHandler(h.createKubernetesIngress)).Methods(http.MethodPost) + namespaceRouter.Handle("/ingresses", httperror.LoggerHandler(h.updateKubernetesIngress)).Methods(http.MethodPut) + namespaceRouter.Handle("/ingresses", httperror.LoggerHandler(h.getKubernetesIngresses)).Methods(http.MethodGet) + namespaceRouter.Handle("/secrets/{secret}", httperror.LoggerHandler(h.getKubernetesSecret)).Methods(http.MethodGet) + namespaceRouter.Handle("/services", httperror.LoggerHandler(h.createKubernetesService)).Methods(http.MethodPost) + namespaceRouter.Handle("/services", httperror.LoggerHandler(h.updateKubernetesService)).Methods(http.MethodPut) + namespaceRouter.Handle("/services", httperror.LoggerHandler(h.getKubernetesServicesByNamespace)).Methods(http.MethodGet) + namespaceRouter.Handle("/service_accounts/{name}", httperror.LoggerHandler(h.getKubernetesServiceAccount)).Methods(http.MethodGet) + namespaceRouter.Handle("/service_accounts/{name}/image_pull_secrets", httperror.LoggerHandler(h.updateKubernetesServiceAccountImagePullSecrets)).Methods(http.MethodPut) + namespaceRouter.Handle("/volumes", httperror.LoggerHandler(h.GetKubernetesVolumesInNamespace)).Methods(http.MethodGet) + namespaceRouter.Handle("/volumes/{volume}", httperror.LoggerHandler(h.getKubernetesVolume)).Methods(http.MethodGet) + namespaceRouter.Handle("/persistent_volume_claims", httperror.LoggerHandler(h.getKubernetesPVCsInNamespace)).Methods(http.MethodGet) + namespaceRouter.Handle("/persistent_volume_claims/{name}", httperror.LoggerHandler(h.getKubernetesPersistentVolumeClaim)).Methods(http.MethodGet) + namespaceRouter.Handle("/pods/{name}", httperror.LoggerHandler(h.deleteKubernetesPod)).Methods(http.MethodDelete) + namespaceRouter.Handle("/pods/{name}/restart", httperror.LoggerHandler(h.restartKubernetesPod)).Methods(http.MethodPost) + + // Deprecated + endpointRouter.Handle("/namespaces", middlewares.Deprecated(endpointRouter, deprecatedNamespaceParser)).Methods(http.MethodPut) + + return h +} + +// getProxyKubeClient gets a kubeclient for the user. It's generally what you want as it retrieves the kubeclient +// from the Authorization token of the currently logged in user. The kubeclient that is not from the proxy is actually using +// admin permissions. If you're unsure which one to use, use this. +func (h *Handler) getProxyKubeClient(r *http.Request) (portainer.KubeClient, *httperror.HandlerError) { + endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return nil, httperror.BadRequest(fmt.Sprintf("an error occurred during the getProxyKubeClient operation, the environment identifier route variable is invalid for /api/kubernetes/%d. Error: ", endpointID), err) + } + + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + return nil, httperror.Forbidden(fmt.Sprintf("an error occurred during the getProxyKubeClient operation, permission denied to access the environment /api/kubernetes/%d. Error: ", endpointID), err) + } + + cli, ok := h.KubernetesClientFactory.GetProxyKubeClient(strconv.Itoa(endpointID), strconv.Itoa(int(tokenData.ID))) + if !ok { + return nil, httperror.InternalServerError("an error occurred during the getProxyKubeClient operation,failed to get proxy KubeClient", nil) + } + + return cli, nil +} + +// kubeClientMiddleware is a middleware that will create a kubeclient for the user if it doesn't exist +// and store it in the factory for future use. +// if there is a kubeclient against this auth token already, the existing one will be reused. +// otherwise, generate a new one +func (handler *Handler) kubeClientMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set(portainer.PortainerCacheHeader, "true") + + if handler.KubernetesClientFactory == nil { + next.ServeHTTP(w, r) + return + } + + endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + httperror.WriteError(w, http.StatusBadRequest, fmt.Sprintf("an error occurred during the KubeClientMiddleware operation, the environment identifier route variable is invalid for /api/kubernetes/%d. Error: ", endpointID), err) + return + } + + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + httperror.WriteError(w, http.StatusForbidden, "an error occurred during the KubeClientMiddleware operation, permission denied to access the environment. Error: ", err) + return + } + + // Check if we have a kubeclient against this auth token already, otherwise generate a new one + _, ok := handler.KubernetesClientFactory.GetProxyKubeClient(strconv.Itoa(endpointID), strconv.Itoa(int(tokenData.ID))) + if ok { + next.ServeHTTP(w, r) + return + } + + endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) + if err != nil { + if handler.DataStore.IsErrObjectNotFound(err) { + httperror.WriteError(w, http.StatusNotFound, + "an error occurred during the KubeClientMiddleware operation, unable to find an environment with the specified environment identifier inside the database. Error: ", err) + return + } + + httperror.WriteError(w, http.StatusInternalServerError, "an error occurred during the KubeClientMiddleware operation, error reading from the Portainer database. Error: ", err) + return + } + + user, err := security.RetrieveUserFromRequest(r, handler.DataStore) + if err != nil { + httperror.WriteError(w, http.StatusInternalServerError, "an error occurred during the KubeClientMiddleware operation, unable to retrieve the user from request. Error: ", err) + return + } + log. + Debug(). + Str("context", "KubeClientMiddleware"). + Str("endpoint", endpoint.Name). + Str("user", user.Username). + Msg("Creating a Kubernetes client") + + isKubeAdmin := true + nonAdminNamespaces := []string{} + if user.Role != portainer.AdministratorRole { + pcli, err := handler.KubernetesClientFactory.GetPrivilegedKubeClient(endpoint) + if err != nil { + httperror.WriteError(w, http.StatusInternalServerError, "an error occurred during the KubeClientMiddleware operation, unable to get privileged kube client to grab all namespaces. Error: ", err) + return + } + + teamMemberships, err := handler.DataStore.TeamMembership().TeamMembershipsByUserID(user.ID) + if err != nil { + httperror.WriteError(w, http.StatusInternalServerError, "an error occurred during the KubeClientMiddleware operation, unable to get team memberships for user: ", err) + return + } + teamIDs := []int{} + for _, membership := range teamMemberships { + teamIDs = append(teamIDs, int(membership.TeamID)) + } + + nonAdminNamespaces, err = pcli.GetNonAdminNamespaces(int(user.ID), teamIDs, endpoint.Kubernetes.Configuration.RestrictDefaultNamespace) + if err != nil { + httperror.WriteError(w, http.StatusInternalServerError, "an error occurred during the KubeClientMiddleware operation, unable to retrieve non-admin namespaces. Error: ", err) + return + } + isKubeAdmin = false + } + + bearerToken, err := handler.JwtService.GenerateTokenForKubeconfig(tokenData) + if err != nil { + httperror.WriteError(w, http.StatusInternalServerError, "an error occurred during the KubeClientMiddleware operation, unable to generate token for kubeconfig. Error: ", err) + return + } + + config := handler.buildConfig(r, tokenData, bearerToken, []portainer.Endpoint{*endpoint}, true) + if len(config.Clusters) == 0 { + httperror.WriteError(w, http.StatusInternalServerError, "an error occurred during the KubeClientMiddleware operation, unable to build kubeconfig. Error: ", nil) + return + } + + // Manually setting serverURL to localhost to route the request to proxy server + serverURL, err := url.Parse(config.Clusters[0].Cluster.Server) + if err != nil { + httperror.WriteError(w, http.StatusInternalServerError, "an error occurred during the KubeClientMiddleware operation, unable to parse server URL for building kubeconfig. Error: ", err) + return + } + + serverURL.Scheme = "https" + serverURL.Host = "localhost" + handler.KubernetesClientFactory.GetAddrHTTPS() + config.Clusters[0].Cluster.Server = serverURL.String() + + yaml, err := cli.GenerateYAML(config) + if err != nil { + httperror.WriteError(w, http.StatusInternalServerError, "an error occurred during the KubeClientMiddleware operation, unable to generate kubeconfig YAML. Error: ", err) + return + } + + kubeCli, err := handler.KubernetesClientFactory.CreateKubeClientFromKubeConfig(endpoint.Name, []byte(yaml), isKubeAdmin, nonAdminNamespaces) + if err != nil { + httperror.WriteError(w, http.StatusInternalServerError, "an error occurred during the KubeClientMiddleware operation, unable to create kubernetes client from kubeconfig. Error: ", err) + return + } + + handler.KubernetesClientFactory.SetProxyKubeClient(strconv.Itoa(int(endpoint.ID)), strconv.Itoa(int(tokenData.ID)), kubeCli) + next.ServeHTTP(w, r) + }) +} + +func (handler *Handler) getLibKubectlAccess(r *http.Request) (*libkubectl.ClientAccess, error) { + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + return nil, httperror.InternalServerError("Unable to retrieve user authentication token", err) + } + + bearerToken, _, err := handler.JwtService.GenerateToken(tokenData) + if err != nil { + return nil, httperror.Unauthorized("Unauthorized", err) + } + + endpoint, err := middlewares.FetchEndpoint(r) + if err != nil { + return nil, httperror.InternalServerError("Unable to find the Kubernetes endpoint associated to the request.", err) + } + + sslSettings, err := handler.DataStore.SSLSettings().Settings() + if err != nil { + return nil, httperror.InternalServerError("Unable to retrieve settings from the database", err) + } + + hostURL := "localhost" + if !sslSettings.SelfSigned { + hostURL = r.Host + } + + kubeConfigInternal := handler.kubeClusterAccessService.GetClusterDetails(hostURL, endpoint.ID, true) + return &libkubectl.ClientAccess{ + Token: bearerToken, + ServerUrl: kubeConfigInternal.ClusterServerURL, + }, nil +} diff --git a/api/http/handler/kubernetes/ingresses.go b/api/http/handler/kubernetes/ingresses.go new file mode 100644 index 0000000..e46d807 --- /dev/null +++ b/api/http/handler/kubernetes/ingresses.go @@ -0,0 +1,843 @@ +package kubernetes + +import ( + "net/http" + "slices" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/http/middlewares" + models "github.com/portainer/portainer/api/http/models/kubernetes" + "github.com/portainer/portainer/api/http/security" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" + "github.com/rs/zerolog/log" + k8serrors "k8s.io/apimachinery/pkg/api/errors" +) + +// @id GetAllKubernetesIngressControllers +// @summary Get a list of ingress controllers +// @description Get a list of ingress controllers for the given environment. If the allowedOnly query parameter is set, only ingress controllers that are allowed by the environment's ingress configuration will be returned. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment identifier" +// @param allowedOnly query boolean false "Only return allowed ingress controllers" +// @success 200 {object} models.K8sIngressControllers "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier." +// @failure 500 "Server error occurred while attempting to retrieve ingress controllers" +// @router /kubernetes/{id}/ingresscontrollers [get] +func (handler *Handler) getAllKubernetesIngressControllers(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + allowedOnly, err := request.RetrieveBooleanQueryParameter(r, "allowedOnly", true) + if err != nil { + log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Invalid allowedOnly boolean query parameter") + return httperror.BadRequest("Invalid allowedOnly boolean query parameter", err) + } + + // Get endpoint from context (may have policies applied in-memory) + endpoint, err := middlewares.FetchEndpoint(r) + if err != nil { + log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Unable to fetch endpoint") + return httperror.InternalServerError(err.Error(), err) + } + + cli, err := handler.KubernetesClientFactory.GetPrivilegedKubeClient(endpoint) + if err != nil { + log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Unable to create Kubernetes client") + return httperror.InternalServerError("Unable to create Kubernetes client", err) + } + + controllers, err := cli.GetIngressControllers() + if err != nil { + if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) { + log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Unauthorized access to the Kubernetes API") + return httperror.Forbidden("Unauthorized access to the Kubernetes API", err) + } + + log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Unable to retrieve ingress controllers from the Kubernetes") + return httperror.InternalServerError("Unable to retrieve ingress controllers from the Kubernetes", err) + } + + // Add none controller if "AllowNone" is set for endpoint. + // Use the policy-applied endpoint for this check since it affects what's shown to the user. + if endpoint.Kubernetes.Configuration.AllowNoneIngressClass { + controllers = append(controllers, models.K8sIngressController{ + Name: "none", + ClassName: "none", + Type: "custom", + }) + } + + // Fetch raw endpoint and update IngressClasses within a transaction. + // This prevents policy-applied values from being persisted to the database. + var updatedClasses []portainer.KubernetesIngressClassConfig + err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + rawEndpoint, err := tx.Endpoint().Endpoint(endpoint.ID) + if err != nil { + return err + } + + // Use raw endpoint's IngressClasses for building updatedClasses to persist original DB values. + existingClasses := rawEndpoint.Kubernetes.Configuration.IngressClasses + updatedClasses = []portainer.KubernetesIngressClassConfig{} + for i := range controllers { + controllers[i].Availability = true + if controllers[i].ClassName != "none" { + controllers[i].New = true + } + + updatedClass := portainer.KubernetesIngressClassConfig{ + Name: controllers[i].ClassName, + Type: controllers[i].Type, + } + + // Check if the controller is already known. + for _, existingClass := range existingClasses { + if controllers[i].ClassName != existingClass.Name { + continue + } + controllers[i].New = false + controllers[i].Availability = !existingClass.GloballyBlocked + updatedClass.GloballyBlocked = existingClass.GloballyBlocked + updatedClass.BlockedNamespaces = existingClass.BlockedNamespaces + } + updatedClasses = append(updatedClasses, updatedClass) + } + + rawEndpoint.Kubernetes.Configuration.IngressClasses = updatedClasses + return tx.Endpoint().UpdateEndpoint(rawEndpoint.ID, rawEndpoint) + }) + if err != nil { + log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Unable to store found IngressClasses inside the database") + return httperror.InternalServerError("Unable to store found IngressClasses inside the database", err) + } + + // If the allowedOnly query parameter was set. We need to prune out + // disallowed controllers from the response. + if allowedOnly { + allowedControllers := models.K8sIngressControllers{} + for _, controller := range controllers { + if controller.Availability { + allowedControllers = append(allowedControllers, controller) + } + } + controllers = allowedControllers + } + + return response.JSON(w, controllers) +} + +// @id GetKubernetesIngressControllersByNamespace +// @summary Get a list ingress controllers by namespace +// @description Get a list of ingress controllers for the given environment in the provided namespace. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment identifier" +// @param namespace path string true "Namespace" +// @success 200 {object} models.K8sIngressControllers "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier or a namespace with the specified name." +// @failure 500 "Server error occurred while attempting to retrieve ingress controllers by a namespace" +// @router /kubernetes/{id}/namespaces/{namespace}/ingresscontrollers [get] +func (handler *Handler) getKubernetesIngressControllersByNamespace(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + namespace, err := request.RetrieveRouteVariableValue(r, "namespace") + if err != nil { + log.Error().Err(err).Str("context", "getKubernetesIngressControllersByNamespace").Msg("Unable to retrieve namespace identifier from request") + return httperror.BadRequest("Unable to retrieve namespace identifier from request", err) + } + + endpoint, err := middlewares.FetchEndpoint(r) + if err != nil { + log.Error().Err(err).Str("context", "getKubernetesIngressControllersByNamespace").Msg("Unable to fetch endpoint") + return httperror.InternalServerError(err.Error(), err) + } + + cli, err := handler.KubernetesClientFactory.GetPrivilegedKubeClient(endpoint) + if err != nil { + log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Unable to create Kubernetes client") + return httperror.InternalServerError("Unable to create Kubernetes client", err) + } + + currentControllers, err := cli.GetIngressControllers() + if err != nil { + if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) { + log.Error().Err(err).Str("context", "getKubernetesIngressControllersByNamespace").Str("namespace", namespace).Msg("Unauthorized access to the Kubernetes API") + return httperror.Forbidden("Unauthorized access to the Kubernetes API", err) + } + + log.Error().Err(err).Str("context", "getKubernetesIngressControllersByNamespace").Str("namespace", namespace).Msg("Unable to retrieve ingress controllers from the Kubernetes") + return httperror.InternalServerError("Unable to retrieve ingress controllers from the Kubernetes", err) + } + + // Add none controller if "AllowNone" is set for endpoint. + // Use the policy-applied endpoint for this check since it affects what's shown to the user. + if endpoint.Kubernetes.Configuration.AllowNoneIngressClass { + currentControllers = append(currentControllers, models.K8sIngressController{ + Name: "none", + ClassName: "none", + Type: "custom", + }) + } + + // Use policy-applied endpoint for ingressAvailabilityPerNamespace since it affects the response. + ingressAvailabilityPerNamespace := endpoint.Kubernetes.Configuration.IngressAvailabilityPerNamespace + controllers := models.K8sIngressControllers{} + + // Fetch raw endpoint and update IngressClasses within a transaction. + // This prevents policy-applied values from being persisted to the database. + err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + rawEndpoint, err := tx.Endpoint().Endpoint(endpoint.ID) + if err != nil { + return err + } + + // Use raw endpoint's IngressClasses for building updatedClasses to persist original DB values. + existingClasses := rawEndpoint.Kubernetes.Configuration.IngressClasses + updatedClasses := []portainer.KubernetesIngressClassConfig{} + + for i := range currentControllers { + globallyblocked := false + currentControllers[i].Availability = true + if currentControllers[i].ClassName != "none" { + currentControllers[i].New = true + } + + updatedClass := portainer.KubernetesIngressClassConfig{ + Name: currentControllers[i].ClassName, + Type: currentControllers[i].Type, + } + + // Check if the controller is blocked globally or in the current + // namespace. + for _, existingClass := range existingClasses { + if currentControllers[i].ClassName != existingClass.Name { + continue + } + currentControllers[i].New = false + updatedClass.GloballyBlocked = existingClass.GloballyBlocked + updatedClass.BlockedNamespaces = existingClass.BlockedNamespaces + + globallyblocked = existingClass.GloballyBlocked + + // Check if the current namespace is blocked if ingressAvailabilityPerNamespace is set to true + if ingressAvailabilityPerNamespace { + for _, ns := range existingClass.BlockedNamespaces { + if namespace == ns { + currentControllers[i].Availability = false + } + } + } + } + if !globallyblocked { + controllers = append(controllers, currentControllers[i]) + } + updatedClasses = append(updatedClasses, updatedClass) + } + + // Update the database to match the list of found controllers. + // This includes pruning out controllers which no longer exist. + rawEndpoint.Kubernetes.Configuration.IngressClasses = updatedClasses + return tx.Endpoint().UpdateEndpoint(rawEndpoint.ID, rawEndpoint) + }) + if err != nil { + log.Error().Err(err).Str("context", "getKubernetesIngressControllersByNamespace").Msg("Unable to store found IngressClasses inside the database") + return httperror.InternalServerError("Unable to store found IngressClasses inside the database", err) + } + return response.JSON(w, controllers) +} + +// @id UpdateKubernetesIngressControllers +// @summary Update (block/unblock) ingress controllers +// @description Update (block/unblock) ingress controllers for the provided environment. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @accept json +// @produce json +// @param id path int true "Environment identifier" +// @param body body models.K8sIngressControllers true "Ingress controllers" +// @success 204 "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier or unable to find the ingress controllers to update." +// @failure 500 "Server error occurred while attempting to update ingress controllers." +// @router /kubernetes/{id}/ingresscontrollers [put] +func (handler *Handler) updateKubernetesIngressControllers(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpoint, err := middlewares.FetchEndpoint(r) + if err != nil { + log.Error().Err(err).Str("context", "updateKubernetesIngressControllers").Msg("Unable to retrieve environment") + return httperror.BadRequest("Unable to retrieve environment", err) + } + + payload := models.K8sIngressControllers{} + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + log.Error().Err(err).Str("context", "updateKubernetesIngressControllers").Msg("Unable to decode and validate the request payload") + return httperror.BadRequest("Unable to decode and validate the request payload", err) + } + + cli, err := handler.KubernetesClientFactory.GetPrivilegedKubeClient(endpoint) + if err != nil { + log.Error().Err(err).Str("context", "updateKubernetesIngressControllers").Msg("Unable to get privileged kube client") + return httperror.InternalServerError("Unable to get privileged kube client", err) + } + + controllers, err := cli.GetIngressControllers() + if err != nil { + if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) { + log.Error().Err(err).Str("context", "updateKubernetesIngressControllers").Msg("Unauthorized access to the Kubernetes API") + return httperror.Forbidden("Unauthorized access to the Kubernetes API", err) + } + + if k8serrors.IsNotFound(err) { + log.Error().Err(err).Str("context", "updateKubernetesIngressControllers").Msg("Unable to retrieve ingress controllers from the Kubernetes") + return httperror.NotFound("Unable to retrieve ingress controllers from the Kubernetes", err) + } + + log.Error().Err(err).Str("context", "updateKubernetesIngressControllers").Msg("Unable to retrieve ingress controllers from the Kubernetes") + return httperror.InternalServerError("Unable to retrieve ingress controllers from the Kubernetes", err) + } + + // Add none controller if "AllowNone" is set for endpoint. + // Use policy-applied endpoint for this check since it affects the response. + if endpoint.Kubernetes.Configuration.AllowNoneIngressClass { + controllers = append(controllers, models.K8sIngressController{ + Name: "none", + ClassName: "none", + Type: "custom", + }) + } + + // Fetch raw endpoint and update IngressClasses within a transaction. + // This prevents policy-applied values from being persisted to the database. + err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + rawEndpoint, err := tx.Endpoint().Endpoint(endpoint.ID) + if err != nil { + return err + } + + // Use raw endpoint's IngressClasses for building updatedClasses to persist original DB values. + existingClasses := rawEndpoint.Kubernetes.Configuration.IngressClasses + updatedClasses := []portainer.KubernetesIngressClassConfig{} + for i := range controllers { + controllers[i].Availability = true + controllers[i].New = true + + updatedClass := portainer.KubernetesIngressClassConfig{ + Name: controllers[i].ClassName, + Type: controllers[i].Type, + } + + // Check if the controller is already known. + for _, existingClass := range existingClasses { + if controllers[i].ClassName != existingClass.Name { + continue + } + controllers[i].New = false + controllers[i].Availability = !existingClass.GloballyBlocked + updatedClass.GloballyBlocked = existingClass.GloballyBlocked + updatedClass.BlockedNamespaces = existingClass.BlockedNamespaces + } + updatedClasses = append(updatedClasses, updatedClass) + } + + for _, p := range payload { + for i := range controllers { + // Now set new payload data + if updatedClasses[i].Name == p.ClassName { + updatedClasses[i].GloballyBlocked = !p.Availability + } + } + } + + rawEndpoint.Kubernetes.Configuration.IngressClasses = updatedClasses + return tx.Endpoint().UpdateEndpoint(rawEndpoint.ID, rawEndpoint) + }) + if err != nil { + log.Error().Err(err).Str("context", "updateKubernetesIngressControllers").Msg("Unable to store found IngressClasses inside the database") + return httperror.InternalServerError("Unable to store found IngressClasses inside the database", err) + } + return response.Empty(w) +} + +// @id UpdateKubernetesIngressControllersByNamespace +// @summary Update (block/unblock) ingress controllers by namespace +// @description Update (block/unblock) ingress controllers by namespace for the provided environment. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @accept json +// @produce json +// @param id path int true "Environment identifier" +// @param namespace path string true "Namespace name" +// @param body body models.K8sIngressControllers true "Ingress controllers" +// @success 204 "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier." +// @failure 500 "Server error occurred while attempting to update ingress controllers by namespace." +// @router /kubernetes/{id}/namespaces/{namespace}/ingresscontrollers [put] +func (handler *Handler) updateKubernetesIngressControllersByNamespace(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + namespace, err := request.RetrieveRouteVariableValue(r, "namespace") + if err != nil { + log.Error().Err(err).Str("context", "updateKubernetesIngressControllersByNamespace").Msg("Unable to retrieve namespace from request") + return httperror.BadRequest("Unable to retrieve namespace from request", err) + } + + payload := models.K8sIngressControllers{} + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + log.Error().Err(err).Str("context", "updateKubernetesIngressControllersByNamespace").Str("namespace", namespace).Msg("Unable to decode and validate the request payload") + return httperror.BadRequest("Unable to decode and validate the request payload", err) + } + + endpoint, err := middlewares.FetchEndpoint(r) + if err != nil { + log.Error().Err(err).Str("context", "updateKubernetesIngressControllersByNamespace").Msg("Unable to fetch endpoint") + return httperror.InternalServerError("Unable to fetch endpoint", err) + } + + // Fetch raw endpoint and update IngressClasses within a transaction. + // This prevents policy-applied values from being persisted to the database. + err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + rawEndpoint, err := tx.Endpoint().Endpoint(endpoint.ID) + if err != nil { + return err + } + + // Use raw endpoint's IngressClasses for building updatedClasses to persist original DB values. + existingClasses := rawEndpoint.Kubernetes.Configuration.IngressClasses + updatedClasses := []portainer.KubernetesIngressClassConfig{} + + for _, p := range payload { + for _, existingClass := range existingClasses { + if p.ClassName != existingClass.Name { + continue + } + updatedClass := portainer.KubernetesIngressClassConfig{ + Name: existingClass.Name, + Type: existingClass.Type, + GloballyBlocked: existingClass.GloballyBlocked, + } + + // Handle "allow" + if p.Availability { + // remove the namespace from the list of blocked namespaces + // in the existingClass. + for _, blockedNS := range existingClass.BlockedNamespaces { + if blockedNS != namespace { + updatedClass.BlockedNamespaces = append(updatedClass.BlockedNamespaces, blockedNS) + } + } + + updatedClasses = append(updatedClasses, updatedClass) + break + } + + // Handle "disallow" + // If it's meant to be blocked we need to add the current + // namespace. First, check if it's already in the + // BlockedNamespaces and if not we append it. + updatedClass.BlockedNamespaces = existingClass.BlockedNamespaces + if !slices.Contains(updatedClass.BlockedNamespaces, namespace) { + updatedClass.BlockedNamespaces = append(updatedClass.BlockedNamespaces, namespace) + } + updatedClasses = append(updatedClasses, updatedClass) + break + } + } + + // At this point it's possible we had an existing class which was globally + // blocked and thus not included in the payload. As a result it is not yet + // part of updatedClasses, but we MUST include it or we would remove the + // global block. + for _, existingClass := range existingClasses { + found := false + + for _, updatedClass := range updatedClasses { + if existingClass.Name == updatedClass.Name { + found = true + break + } + } + + if !found { + updatedClasses = append(updatedClasses, existingClass) + } + } + + rawEndpoint.Kubernetes.Configuration.IngressClasses = updatedClasses + return tx.Endpoint().UpdateEndpoint(rawEndpoint.ID, rawEndpoint) + }) + if err != nil { + log.Error().Err(err).Str("context", "updateKubernetesIngressControllersByNamespace").Str("namespace", namespace).Msg("Unable to store BlockedIngressClasses inside the database") + return httperror.InternalServerError("Unable to store BlockedIngressClasses inside the database", err) + } + return response.Empty(w) +} + +// @id GetAllKubernetesClusterIngresses +// @summary Get kubernetes ingresses at the cluster level +// @description Get kubernetes ingresses at the cluster level for the provided environment. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment identifier" +// @param withServices query boolean false "Lookup services associated with each ingress" +// @success 200 {array} models.K8sIngressInfo "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier." +// @failure 500 "Server error occurred while attempting to retrieve ingresses." +// @router /kubernetes/{id}/ingresses [get] +func (handler *Handler) GetAllKubernetesClusterIngresses(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + ingresses, err := handler.getKubernetesClusterIngresses(r) + if err != nil { + return err + } + + return response.JSON(w, ingresses) +} + +// @id GetAllKubernetesClusterIngressesCount +// @summary Get Ingresses count +// @description Get the number of kubernetes ingresses within the given environment. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment identifier" +// @success 200 {integer} integer "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier." +// @failure 500 "Server error occurred while attempting to retrieve ingresses count." +// @router /kubernetes/{id}/ingresses/count [get] +func (handler *Handler) getAllKubernetesClusterIngressesCount(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + ingresses, err := handler.getKubernetesClusterIngresses(r) + if err != nil { + return err + } + + return response.JSON(w, len(ingresses)) +} + +func (handler *Handler) getKubernetesClusterIngresses(r *http.Request) ([]models.K8sIngressInfo, *httperror.HandlerError) { + withServices, err := request.RetrieveBooleanQueryParameter(r, "withServices", true) + if err != nil { + log.Error().Err(err).Str("context", "getKubernetesClusterIngresses").Msg("Unable to retrieve withApplications query parameter") + return nil, httperror.BadRequest("Unable to retrieve withApplications query parameter", err) + } + + cli, httpErr := handler.prepareKubeClient(r) + if httpErr != nil { + log.Error().Err(httpErr).Str("context", "getKubernetesClusterIngresses").Msg("Unable to get a Kubernetes client for the user") + return nil, httperror.InternalServerError("Unable to get a Kubernetes client for the user", httpErr) + } + + ingresses, err := cli.GetIngresses("") + if err != nil { + if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) { + log.Error().Err(err).Str("context", "getKubernetesClusterIngresses").Msg("Unauthorized access to the Kubernetes API") + return nil, httperror.Forbidden("Unauthorized access to the Kubernetes API", err) + } + + if k8serrors.IsNotFound(err) { + log.Error().Err(err).Str("context", "getKubernetesClusterIngresses").Msg("Unable to retrieve ingresses from the Kubernetes for a cluster level user") + return nil, httperror.NotFound("Unable to retrieve ingresses from the Kubernetes for a cluster level user", err) + } + + log.Error().Err(err).Str("context", "getKubernetesClusterIngresses").Msg("Unable to retrieve ingresses from the Kubernetes for a cluster level user") + return nil, httperror.InternalServerError("Unable to retrieve ingresses from the Kubernetes for a cluster level user", err) + } + + if withServices { + ingressesWithServices, err := cli.CombineIngressesWithServices(ingresses) + if err != nil { + log.Error().Err(err).Str("context", "getKubernetesClusterIngresses").Msg("Unable to combine ingresses with services") + return nil, httperror.InternalServerError("Unable to combine ingresses with services", err) + } + + return ingressesWithServices, nil + } + + return ingresses, nil +} + +// @id GetAllKubernetesIngresses +// @summary Get a list of Ingresses +// @description Get a list of Ingresses. If namespace is provided, it will return the list of Ingresses in that namespace. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment identifier" +// @param namespace path string true "Namespace name" +// @success 200 {array} models.K8sIngressInfo "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier." +// @failure 500 "Server error occurred while attempting to retrieve ingresses" +// @router /kubernetes/{id}/namespaces/{namespace}/ingresses [get] +func (handler *Handler) getKubernetesIngresses(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + namespace, err := request.RetrieveRouteVariableValue(r, "namespace") + if err != nil { + log.Error().Err(err).Str("context", "getKubernetesIngresses").Msg("Unable to retrieve namespace from request") + return httperror.BadRequest("Unable to retrieve namespace from request", err) + } + + cli, handlerErr := handler.getProxyKubeClient(r) + if handlerErr != nil { + return handlerErr + } + + ingresses, err := cli.GetIngresses(namespace) + if err != nil { + if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) { + log.Error().Err(err).Str("context", "getKubernetesIngresses").Str("namespace", namespace).Msg("Unauthorized access to the Kubernetes API") + return httperror.Forbidden("Unauthorized access to the Kubernetes API", err) + } + + log.Error().Err(err).Str("context", "getKubernetesIngresses").Str("namespace", namespace).Msg("Unable to retrieve ingresses from the Kubernetes for a namespace level user") + return httperror.InternalServerError("Unable to retrieve ingresses from the Kubernetes for a namespace level user", err) + } + + return response.JSON(w, ingresses) +} + +// @id GetKubernetesIngress +// @summary Get an Ingress by name +// @description Get an Ingress by name for the provided environment. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment identifier" +// @param namespace path string true "Namespace name" +// @param ingress path string true "Ingress name" +// @success 200 {object} models.K8sIngressInfo "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier or unable to find an ingress with the specified name." +// @failure 500 "Server error occurred while attempting to retrieve an ingress." +// @router /kubernetes/{id}/namespaces/{namespace}/ingresses/{ingress} [get] +func (handler *Handler) getKubernetesIngress(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + namespace, err := request.RetrieveRouteVariableValue(r, "namespace") + if err != nil { + log.Error().Err(err).Str("context", "getKubernetesIngress").Msg("Unable to retrieve namespace from request") + return httperror.BadRequest("Unable to retrieve namespace from request", err) + } + + ingressName, err := request.RetrieveRouteVariableValue(r, "ingress") + if err != nil { + log.Error().Err(err).Str("context", "getKubernetesIngress").Msg("Unable to retrieve ingress from request") + return httperror.BadRequest("Unable to retrieve ingress from request", err) + } + + cli, handlerErr := handler.getProxyKubeClient(r) + if handlerErr != nil { + return handlerErr + } + + ingress, err := cli.GetIngress(namespace, ingressName) + if err != nil { + if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) { + log.Error().Err(err).Str("context", "getKubernetesIngress").Str("namespace", namespace).Str("ingress", ingressName).Msg("Unauthorized access to the Kubernetes API") + return httperror.Forbidden("Unauthorized access to the Kubernetes API", err) + } + + if k8serrors.IsNotFound(err) { + log.Error().Err(err).Str("context", "getKubernetesIngress").Str("namespace", namespace).Str("ingress", ingressName).Msg("Unable to retrieve ingress from the Kubernetes for a namespace level user") + return httperror.NotFound("Unable to retrieve ingress from the Kubernetes for a namespace level user", err) + } + + log.Error().Err(err).Str("context", "getKubernetesIngress").Str("namespace", namespace).Str("ingress", ingressName).Msg("Unable to retrieve ingress from the Kubernetes for a namespace level user") + return httperror.InternalServerError("Unable to retrieve ingress from the Kubernetes for a namespace level user", err) + } + + return response.JSON(w, ingress) +} + +// @id CreateKubernetesIngress +// @summary Create an Ingress +// @description Create an Ingress for the provided environment. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @accept json +// @produce json +// @param id path int true "Environment identifier" +// @param namespace path string true "Namespace name" +// @param body body models.K8sIngressInfo true "Ingress details" +// @success 204 "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier." +// @failure 409 "Conflict - an ingress with the same name already exists in the specified namespace." +// @failure 500 "Server error occurred while attempting to create an ingress." +// @router /kubernetes/{id}/namespaces/{namespace}/ingresses [post] +func (handler *Handler) createKubernetesIngress(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + namespace, err := request.RetrieveRouteVariableValue(r, "namespace") + if err != nil { + log.Error().Err(err).Str("context", "createKubernetesIngress").Msg("Unable to retrieve namespace from request") + return httperror.BadRequest("Unable to retrieve namespace from request", err) + } + + payload := models.K8sIngressInfo{} + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + log.Error().Err(err).Str("context", "createKubernetesIngress").Msg("Unable to decode and validate the request payload") + return httperror.BadRequest("Unable to decode and validate the request payload", err) + } + + owner := "admin" + tokenData, err := security.RetrieveTokenData(r) + if err == nil && tokenData != nil { + owner = tokenData.Username + } + + cli, handlerErr := handler.getProxyKubeClient(r) + if handlerErr != nil { + return handlerErr + } + + err = cli.CreateIngress(namespace, payload, owner) + if err != nil { + if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) { + log.Error().Err(err).Str("context", "createKubernetesIngress").Str("namespace", namespace).Msg("Unauthorized access to the Kubernetes API") + return httperror.Forbidden("Unauthorized access to the Kubernetes API", err) + } + + if k8serrors.IsAlreadyExists(err) { + log.Error().Err(err).Str("context", "createKubernetesIngress").Str("namespace", namespace).Msg("Ingress already exists") + return httperror.Conflict("Ingress already exists", err) + } + + log.Error().Err(err).Str("context", "createKubernetesIngress").Str("namespace", namespace).Msg("Unable to create an ingress") + return httperror.InternalServerError("Unable to create an ingress", err) + } + + return response.Empty(w) +} + +// @id DeleteKubernetesIngresses +// @summary Delete one or more Ingresses +// @description Delete one or more Ingresses in the provided environment. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @accept json +// @produce json +// @param id path int true "Environment identifier" +// @param body body models.K8sIngressDeleteRequests true "Ingress details" +// @success 204 "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier or unable to find a specific ingress." +// @failure 500 "Server error occurred while attempting to delete specified ingresses." +// @router /kubernetes/{id}/ingresses/delete [post] +func (handler *Handler) deleteKubernetesIngresses(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + cli, handlerErr := handler.getProxyKubeClient(r) + if handlerErr != nil { + return handlerErr + } + + payload := models.K8sIngressDeleteRequests{} + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + log.Error().Err(err).Str("context", "deleteKubernetesIngresses").Msg("Unable to decode and validate the request payload") + return httperror.BadRequest("Unable to decode and validate the request payload", err) + } + + err = cli.DeleteIngresses(payload) + if err != nil { + if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) { + log.Error().Err(err).Str("context", "deleteKubernetesIngresses").Msg("Unauthorized access to the Kubernetes API") + return httperror.Forbidden("Unauthorized access to the Kubernetes API", err) + } + + if k8serrors.IsNotFound(err) { + log.Error().Err(err).Str("context", "deleteKubernetesIngresses").Msg("Unable to retrieve ingresses from the Kubernetes for a namespace level user") + return httperror.NotFound("Unable to retrieve ingresses from the Kubernetes for a namespace level user", err) + } + + log.Error().Err(err).Str("context", "deleteKubernetesIngresses").Msg("Unable to delete ingresses") + return httperror.InternalServerError("Unable to delete ingresses", err) + } + + return response.Empty(w) +} + +// @id UpdateKubernetesIngress +// @summary Update an Ingress +// @description Update an Ingress for the provided environment. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @accept json +// @produce json +// @param id path int true "Environment identifier" +// @param namespace path string true "Namespace name" +// @param body body models.K8sIngressInfo true "Ingress details" +// @success 204 "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier or unable to find the specified ingress." +// @failure 500 "Server error occurred while attempting to update the specified ingress." +// @router /kubernetes/{id}/namespaces/{namespace}/ingresses [put] +func (handler *Handler) updateKubernetesIngress(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + namespace, err := request.RetrieveRouteVariableValue(r, "namespace") + if err != nil { + log.Error().Err(err).Str("context", "updateKubernetesIngress").Msg("Unable to retrieve namespace from request") + return httperror.BadRequest("Unable to retrieve namespace from request", err) + } + + payload := models.K8sIngressInfo{} + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + log.Error().Err(err).Str("context", "updateKubernetesIngress").Msg("Unable to decode and validate the request payload") + return httperror.BadRequest("Unable to decode and validate the request payload", err) + } + + cli, handlerErr := handler.getProxyKubeClient(r) + if handlerErr != nil { + return handlerErr + } + + err = cli.UpdateIngress(namespace, payload) + if err != nil { + if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) { + log.Error().Err(err).Str("context", "updateKubernetesIngress").Str("namespace", namespace).Msg("Unauthorized access to the Kubernetes API") + return httperror.Forbidden("Unauthorized access to the Kubernetes API", err) + } + + if k8serrors.IsNotFound(err) { + log.Error().Err(err).Str("context", "updateKubernetesIngress").Str("namespace", namespace).Msg("Unable to retrieve ingresses from the K ubernetes for a namespace level user") + return httperror.NotFound("Unable to retrieve ingresses from the Kubernetes for a namespace level user", err) + } + + log.Error().Err(err).Str("context", "updateKubernetesIngress").Str("namespace", namespace).Msg("Unable to update ingress in a namespace") + return httperror.InternalServerError("Unable to update ingress in a namespace", err) + } + + return response.Empty(w) +} diff --git a/api/http/handler/kubernetes/job.go b/api/http/handler/kubernetes/job.go new file mode 100644 index 0000000..be1d67b --- /dev/null +++ b/api/http/handler/kubernetes/job.go @@ -0,0 +1,85 @@ +package kubernetes + +import ( + "net/http" + + models "github.com/portainer/portainer/api/http/models/kubernetes" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" + "github.com/rs/zerolog/log" +) + +// @id GetKubernetesJobs +// @summary Get a list of kubernetes Jobs +// @description Get a list of kubernetes Jobs that the user has access to. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment identifier" +// @param includeCronJobChildren query bool false "Whether to include Jobs that have a cronjob owner" +// @success 200 {array} models.K8sJob "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier." +// @failure 500 "Server error occurred while attempting to retrieve the list of Jobs." +// @router /kubernetes/{id}/jobs [get] +func (handler *Handler) getAllKubernetesJobs(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + includeCronJobChildren, err := request.RetrieveBooleanQueryParameter(r, "includeCronJobChildren", true) + if err != nil { + log.Error().Err(err).Str("context", "GetAllKubernetesJobs").Msg("Invalid query parameter includeCronJobChildren") + return httperror.BadRequest("an error occurred during the GetAllKubernetesJobs operation, invalid query parameter includeCronJobChildren. Error: ", err) + } + + cli, httpErr := handler.prepareKubeClient(r) + if httpErr != nil { + log.Error().Err(httpErr).Str("context", "GetAllKubernetesJobs").Msg("Unable to prepare kube client") + return httperror.InternalServerError("unable to prepare kube client. Error: ", httpErr) + } + + jobs, err := cli.GetJobs("", includeCronJobChildren) + if err != nil { + log.Error().Err(err).Str("context", "GetAllKubernetesJobs").Msg("Unable to fetch Jobs across all namespaces") + return httperror.InternalServerError("unable to fetch Jobs. Error: ", err) + } + + return response.JSON(w, jobs) +} + +// @id DeleteJobs +// @summary Delete Jobs +// @description Delete the provided list of Jobs. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @accept json +// @param id path int true "Environment identifier" +// @param payload body models.K8sJobDeleteRequests true "A map where the key is the namespace and the value is an array of Jobs to delete" +// @success 204 "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier or unable to find a specific service account." +// @failure 500 "Server error occurred while attempting to delete Jobs." +// @router /kubernetes/{id}/jobs/delete [POST] +func (handler *Handler) deleteKubernetesJobs(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload models.K8sJobDeleteRequests + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + cli, handlerErr := handler.getProxyKubeClient(r) + if handlerErr != nil { + return handlerErr + } + + err = cli.DeleteJobs(payload) + if err != nil { + return httperror.InternalServerError("Unable to delete Jobs", err) + } + + return response.Empty(w) +} diff --git a/api/http/handler/kubernetes/metrics.go b/api/http/handler/kubernetes/metrics.go new file mode 100644 index 0000000..aeee919 --- /dev/null +++ b/api/http/handler/kubernetes/metrics.go @@ -0,0 +1,183 @@ +package kubernetes + +import ( + "net/http" + + "github.com/portainer/portainer/api/http/middlewares" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" + "github.com/rs/zerolog/log" + + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// @id GetKubernetesMetricsForAllNodes +// @summary Get a list of nodes with their live metrics +// @description Get a list of metrics associated with all nodes of a cluster. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment identifier" +// @success 200 {object} v1beta1.NodeMetricsList "Success" +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 500 "Server error occurred while attempting to retrieve the list of nodes with their live metrics." +// @router /kubernetes/{id}/metrics/nodes [get] +func (handler *Handler) getKubernetesMetricsForAllNodes(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpoint, err := middlewares.FetchEndpoint(r) + if err != nil { + return httperror.InternalServerError(err.Error(), err) + } + + cli, err := handler.KubernetesClientFactory.CreateRemoteMetricsClient(endpoint) + if err != nil { + log.Error().Err(err).Str("context", "getKubernetesMetricsForAllNodes").Msg("Failed to create metrics KubeClient") + return httperror.InternalServerError("failed to create metrics KubeClient", nil) + } + + metrics, err := cli.MetricsV1beta1().NodeMetricses().List(r.Context(), v1.ListOptions{}) + if err != nil { + log.Error().Err(err).Str("context", "getKubernetesMetricsForAllNodes").Msg("Failed to fetch metrics") + return httperror.InternalServerError("Failed to fetch metrics", err) + } + + return response.JSON(w, metrics) +} + +// @id GetKubernetesMetricsForNode +// @summary Get live metrics for a node +// @description Get live metrics for the specified node. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment identifier" +// @param name path string true "Node identifier" +// @success 200 {object} v1beta1.NodeMetrics "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 500 "Server error occurred while attempting to retrieve the live metrics for the specified node." +// @router /kubernetes/{id}/metrics/nodes/{name} [get] +func (handler *Handler) getKubernetesMetricsForNode(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpoint, err := middlewares.FetchEndpoint(r) + if err != nil { + log.Error().Err(err).Str("context", "getKubernetesMetricsForNode").Msg("Failed to fetch endpoint") + return httperror.InternalServerError(err.Error(), err) + } + + cli, err := handler.KubernetesClientFactory.CreateRemoteMetricsClient(endpoint) + if err != nil { + log.Error().Err(err).Str("context", "getKubernetesMetricsForNode").Msg("Failed to create metrics KubeClient") + return httperror.InternalServerError("failed to create metrics KubeClient", nil) + } + + nodeName, err := request.RetrieveRouteVariableValue(r, "name") + if err != nil { + log.Error().Err(err).Str("context", "getKubernetesMetricsForNode").Msg("Invalid node identifier route variable") + return httperror.BadRequest("Invalid node identifier route variable", err) + } + + metrics, err := cli.MetricsV1beta1().NodeMetricses().Get( + r.Context(), + nodeName, + v1.GetOptions{}, + ) + if err != nil { + log.Error().Err(err).Str("context", "getKubernetesMetricsForNode").Msg("Failed to fetch metrics") + return httperror.InternalServerError("Failed to fetch metrics", err) + } + + return response.JSON(w, metrics) +} + +// @id GetKubernetesMetricsForAllPods +// @summary Get a list of pods with their live metrics +// @description Get a list of pods with their live metrics for the specified namespace. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment identifier" +// @param namespace path string true "Namespace" +// @success 200 {object} v1beta1.PodMetricsList "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 500 "Server error occurred while attempting to retrieve the list of pods with their live metrics." +// @router /kubernetes/{id}/metrics/pods/{namespace} [get] +func (handler *Handler) getKubernetesMetricsForAllPods(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpoint, err := middlewares.FetchEndpoint(r) + if err != nil { + log.Error().Err(err).Str("context", "getKubernetesMetricsForAllPods").Msg("Failed to fetch endpoint") + return httperror.InternalServerError(err.Error(), err) + } + + cli, err := handler.KubernetesClientFactory.CreateRemoteMetricsClient(endpoint) + if err != nil { + log.Error().Err(err).Str("context", "getKubernetesMetricsForAllPods").Msg("Failed to create metrics KubeClient") + return httperror.InternalServerError("failed to create metrics KubeClient", nil) + } + + namespace, err := request.RetrieveRouteVariableValue(r, "namespace") + if err != nil { + log.Error().Err(err).Str("context", "getKubernetesMetricsForAllPods").Msg("Invalid namespace identifier route variable") + return httperror.BadRequest("Invalid namespace identifier route variable", err) + } + + metrics, err := cli.MetricsV1beta1().PodMetricses(namespace).List(r.Context(), v1.ListOptions{}) + if err != nil { + log.Error().Err(err).Str("context", "getKubernetesMetricsForAllPods").Msg("Failed to fetch metrics") + return httperror.InternalServerError("Failed to fetch metrics", err) + } + + return response.JSON(w, metrics) +} + +// @id GetKubernetesMetricsForPod +// @summary Get live metrics for a pod +// @description Get live metrics for the specified pod. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment identifier" +// @param namespace path string true "Namespace" +// @param name path string true "Pod identifier" +// @success 200 {object} v1beta1.PodMetrics "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 500 "Server error occurred while attempting to retrieve the live metrics for the specified pod." +// @router /kubernetes/{id}/metrics/pods/{namespace}/{name} [get] +func (handler *Handler) getKubernetesMetricsForPod(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpoint, err := middlewares.FetchEndpoint(r) + if err != nil { + log.Error().Err(err).Str("context", "getKubernetesMetricsForPod").Msg("Failed to fetch endpoint") + return httperror.InternalServerError(err.Error(), err) + } + + cli, err := handler.KubernetesClientFactory.CreateRemoteMetricsClient(endpoint) + if err != nil { + log.Error().Err(err).Str("context", "getKubernetesMetricsForPod").Msg("Failed to create metrics KubeClient") + return httperror.InternalServerError("failed to create metrics KubeClient", nil) + } + + namespace, err := request.RetrieveRouteVariableValue(r, "namespace") + if err != nil { + log.Error().Err(err).Str("context", "getKubernetesMetricsForPod").Msg("Invalid namespace identifier route variable") + return httperror.BadRequest("Invalid namespace identifier route variable", err) + } + + podName, err := request.RetrieveRouteVariableValue(r, "name") + if err != nil { + log.Error().Err(err).Str("context", "getKubernetesMetricsForPod").Msg("Invalid pod identifier route variable") + return httperror.BadRequest("Invalid pod identifier route variable", err) + } + + metrics, err := cli.MetricsV1beta1().PodMetricses(namespace).Get(r.Context(), podName, v1.GetOptions{}) + if err != nil { + log.Error().Err(err).Str("context", "getKubernetesMetricsForPod").Str("namespace", namespace).Str("pod", podName).Msg("Failed to fetch metrics") + return httperror.InternalServerError("Failed to fetch metrics", err) + } + + return response.JSON(w, metrics) +} diff --git a/api/http/handler/kubernetes/namespaces.go b/api/http/handler/kubernetes/namespaces.go new file mode 100644 index 0000000..75dae9e --- /dev/null +++ b/api/http/handler/kubernetes/namespaces.go @@ -0,0 +1,295 @@ +package kubernetes + +import ( + "errors" + "fmt" + "net/http" + + models "github.com/portainer/portainer/api/http/models/kubernetes" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" + "github.com/rs/zerolog/log" + k8serrors "k8s.io/apimachinery/pkg/api/errors" +) + +// @id GetKubernetesNamespaces +// @summary Get a list of namespaces +// @description Get a list of all namespaces within the given environment based on the user role and permissions. If the user is an admin, they can access all namespaces. If the user is not an admin, they can only access namespaces that they have access to. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment identifier" +// @param withResourceQuota query boolean true "When set to true, include the resource quota information as part of the Namespace information. Default is false" +// @param withUnhealthyEvents query boolean true "When set to true, include the unhealthy events information as part of the Namespace information. Default is false" +// @success 200 {array} portainer.K8sNamespaceInfo "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier." +// @failure 500 "Server error occurred while attempting to retrieve the list of namespaces." +// @router /kubernetes/{id}/namespaces [get] +func (handler *Handler) getKubernetesNamespaces(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + withResourceQuota, err := request.RetrieveBooleanQueryParameter(r, "withResourceQuota", true) + if err != nil { + log.Error().Err(err).Str("context", "GetKubernetesNamespaces").Msg("Invalid query parameter withResourceQuota") + return httperror.BadRequest("an error occurred during the GetKubernetesNamespaces operation, invalid query parameter withResourceQuota. Error: ", err) + } + + withUnhealthyEvents, err := request.RetrieveBooleanQueryParameter(r, "withUnhealthyEvents", true) + if err != nil { + log.Error().Err(err).Str("context", "GetKubernetesNamespaces").Msg("Invalid query parameter withUnhealthyEvents") + return httperror.BadRequest("an error occurred during the GetKubernetesNamespaces operation, invalid query parameter withUnhealthyEvents. Error: ", err) + } + + cli, httpErr := handler.prepareKubeClient(r) + if httpErr != nil { + log.Error().Err(httpErr).Str("context", "GetKubernetesNamespaces").Msg("Unable to get a Kubernetes client for the user") + return httperror.InternalServerError("an error occurred during the GetKubernetesNamespaces operation, unable to get a Kubernetes client for the user. Error: ", httpErr) + } + + namespaces, err := cli.GetNamespaces() + if err != nil { + log.Error().Err(err).Str("context", "GetKubernetesNamespaces").Msg("Unable to retrieve namespaces from the Kubernetes cluster") + return httperror.InternalServerError("an error occurred during the GetKubernetesNamespaces operation, unable to retrieve namespaces from the Kubernetes cluster. Error: ", err) + } + + if withUnhealthyEvents { + namespaces, err = cli.CombineNamespacesWithUnhealthyEvents(namespaces) + if err != nil { + log.Error().Err(err).Str("context", "GetKubernetesNamespaces").Msg("Unable to combine namespaces with unhealthy events") + return httperror.InternalServerError("an error occurred during the GetKubernetesNamespaces operation, unable to combine namespaces with unhealthy events. Error: ", err) + } + } + + if withResourceQuota { + return cli.CombineNamespacesWithResourceQuotas(namespaces, w) + } + + return response.JSON(w, cli.ConvertNamespaceMapToSlice(namespaces)) +} + +// @id GetKubernetesNamespacesCount +// @summary Get the total number of kubernetes namespaces within the given Portainer environment. +// @description Get the total number of kubernetes namespaces within the given environment, including the system namespaces. The total count depends on the user's role and permissions. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment identifier" +// @success 200 {integer} integer "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier." +// @failure 500 "Server error occurred while attempting to compute the namespace count." +// @router /kubernetes/{id}/namespaces/count [get] +func (handler *Handler) getKubernetesNamespacesCount(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + cli, httpErr := handler.prepareKubeClient(r) + if httpErr != nil { + log.Error().Err(httpErr).Str("context", "GetKubernetesNamespacesCount").Msg("Unable to get a Kubernetes client for the user") + return httperror.InternalServerError("an error occurred during the GetKubernetesNamespacesCount operation, unable to get a Kubernetes client for the user. Error: ", httpErr) + } + + namespaces, err := cli.GetNamespaces() + if err != nil { + log.Error().Err(err).Str("context", "GetKubernetesNamespacesCount").Msg("Unable to retrieve namespaces from the Kubernetes cluster to count the total") + return httperror.InternalServerError("an error occurred during the GetKubernetesNamespacesCount operation, unable to retrieve namespaces from the Kubernetes cluster to count the total. Error: ", err) + } + + return response.JSON(w, len(namespaces)) +} + +// @id GetKubernetesNamespace +// @summary Get namespace details +// @description Get namespace details for the provided namespace within the given environment. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment identifier" +// @param namespace path string true "The namespace name to get details for" +// @param withResourceQuota query boolean true "When set to true, include the resource quota information as part of the Namespace information. Default is false" +// @success 200 {object} portainer.K8sNamespaceInfo "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier or unable to find a specific namespace." +// @failure 500 "Server error occurred while attempting to retrieve specified namespace information." +// @router /kubernetes/{id}/namespaces/{namespace} [get] +func (handler *Handler) getKubernetesNamespace(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + namespaceName, err := request.RetrieveRouteVariableValue(r, "namespace") + if err != nil { + log.Error().Err(err).Str("context", "GetKubernetesNamespace").Msg("Invalid namespace parameter namespace") + return httperror.BadRequest("an error occurred during the GetKubernetesNamespace operation, invalid namespace parameter namespace. Error: ", err) + } + + withResourceQuota, err := request.RetrieveBooleanQueryParameter(r, "withResourceQuota", true) + if err != nil { + log.Error().Err(err).Str("context", "GetKubernetesNamespace").Msg("Invalid query parameter withResourceQuota") + return httperror.BadRequest("an error occurred during the GetKubernetesNamespace operation for the namespace %s, invalid query parameter withResourceQuota. Error: ", err) + } + + cli, httpErr := handler.getProxyKubeClient(r) + if httpErr != nil { + log.Error().Err(httpErr).Str("context", "GetKubernetesNamespace").Msg("Unable to get a Kubernetes client for the user") + return httperror.InternalServerError("an error occurred during the GetKubernetesNamespace operation for the namespace %s, unable to get a Kubernetes client for the user. Error: ", httpErr) + } + + namespaceInfo, err := cli.GetNamespace(namespaceName) + if err != nil { + if k8serrors.IsNotFound(err) { + log.Error().Err(err).Str("context", "GetKubernetesNamespace").Msg("Unable to find the namespace") + return httperror.NotFound(fmt.Sprintf("an error occurred during the GetKubernetesNamespace operation for the namespace %s, unable to find the namespace. Error: ", namespaceName), err) + } + + if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) { + log.Error().Err(err).Str("context", "GetKubernetesNamespace").Msg("Unauthorized to access the namespace") + return httperror.Forbidden(fmt.Sprintf("an error occurred during the GetKubernetesNamespace operation, unauthorized to access the namespace: %s. Error: ", namespaceName), err) + } + + log.Error().Err(err).Str("context", "GetKubernetesNamespace").Msg("Unable to get the namespace") + return httperror.InternalServerError(fmt.Sprintf("an error occurred during the GetKubernetesNamespace operation, unable to get the namespace: %s. Error: ", namespaceName), err) + } + + if withResourceQuota { + return cli.CombineNamespaceWithResourceQuota(namespaceInfo, w) + } + + return response.JSON(w, namespaceInfo) +} + +// @id CreateKubernetesNamespace +// @summary Create a namespace +// @description Create a namespace within the given environment. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @accept json +// @produce json +// @param id path int true "Environment identifier" +// @param body body models.K8sNamespaceDetails true "Namespace configuration details" +// @success 200 {object} portainer.K8sNamespaceInfo "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 409 "Conflict - the namespace already exists." +// @failure 500 "Server error occurred while attempting to create the namespace." +// @router /kubernetes/{id}/namespaces [post] +func (handler *Handler) createKubernetesNamespace(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + payload := models.K8sNamespaceDetails{} + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + log.Error().Err(err).Str("context", "CreateKubernetesNamespace").Msg("Invalid request payload") + return httperror.BadRequest("an error occurred during the CreateKubernetesNamespace operation, invalid request payload. Error: ", err) + } + + namespaceName := payload.Name + cli, httpErr := handler.getProxyKubeClient(r) + if httpErr != nil { + log.Error().Err(httpErr).Str("context", "CreateKubernetesNamespace").Str("namespace", namespaceName).Msg("Unable to get a Kubernetes client for the user") + return httperror.InternalServerError("an error occurred during the CreateKubernetesNamespace operation for the namespace %s, unable to get a Kubernetes client for the user. Error: ", httpErr) + } + + namespace, err := cli.CreateNamespace(payload) + if err != nil { + if k8serrors.IsAlreadyExists(err) { + log.Error().Err(err).Str("context", "CreateKubernetesNamespace").Str("namespace", namespaceName).Msg("The namespace already exists") + return httperror.Conflict(fmt.Sprintf("an error occurred during the CreateKubernetesNamespace operation, the namespace %s already exists. Error: ", namespaceName), err) + } + + log.Error().Err(err).Str("context", "CreateKubernetesNamespace").Str("namespace", namespaceName).Msg("Unable to create the namespace") + return httperror.InternalServerError("an error occurred during the CreateKubernetesNamespace operation, unable to create the namespace: "+namespaceName, err) + } + + return response.JSON(w, namespace) +} + +// @id DeleteKubernetesNamespace +// @summary Delete a kubernetes namespace +// @description Delete a kubernetes namespace within the given environment. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @param id path int true "Environment identifier" +// @success 200 {string} string "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 403 "Unauthorized access or operation not allowed." +// @failure 500 "Server error occurred while attempting to delete the namespace." +// @router /kubernetes/{id}/namespaces [delete] +func (handler *Handler) deleteKubernetesNamespace(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + namespaceNames, err := request.GetPayload[deleteKubernetesNamespacePayload](r) + if err != nil { + log.Error().Err(err).Str("context", "DeleteKubernetesNamespace").Msg("Invalid namespace identifier route variable") + return httperror.BadRequest("an error occurred during the DeleteKubernetesNamespace operation, invalid namespace identifier route variable. Error: ", err) + } + + cli, httpErr := handler.getProxyKubeClient(r) + if httpErr != nil { + log.Error().Err(httpErr).Str("context", "DeleteKubernetesNamespace").Msg("Unable to get a Kubernetes client for the user") + return httperror.InternalServerError("an error occurred during the DeleteKubernetesNamespace operation for the namespace %s, unable to get a Kubernetes client for the user. Error: ", httpErr) + } + + for _, namespaceName := range *namespaceNames { + if _, err := cli.DeleteNamespace(namespaceName); err != nil { + if k8serrors.IsNotFound(err) { + log.Error().Err(err).Str("context", "DeleteKubernetesNamespace").Str("namespace", namespaceName).Msg("Unable to find the namespace") + return httperror.NotFound("an error occurred during the DeleteKubernetesNamespace operation for the namespace "+namespaceName+", unable to find the namespace. Error: ", err) + } + + log.Error().Err(err).Str("context", "DeleteKubernetesNamespace").Str("namespace", namespaceName).Msg("Unable to delete the namespace") + return httperror.InternalServerError("an error occurred during the DeleteKubernetesNamespace operation for the namespace "+namespaceName+", unable to delete the Kubernetes namespace. Error: ", err) + } + } + + return response.JSON(w, namespaceNames) +} + +type deleteKubernetesNamespacePayload []string + +func (payload deleteKubernetesNamespacePayload) Validate(r *http.Request) error { + if len(payload) == 0 { + return errors.New("namespace names are required") + } + + return nil +} + +// @id UpdateKubernetesNamespace +// @summary Update a namespace +// @description Update a namespace within the given environment. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @accept json +// @produce json +// @param id path int true "Environment identifier" +// @param namespace path string true "Namespace" +// @param body body models.K8sNamespaceDetails true "Namespace details" +// @success 200 {object} portainer.K8sNamespaceInfo "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier or unable to find a specific namespace." +// @failure 500 "Server error occurred while attempting to update the namespace." +// @router /kubernetes/{id}/namespaces/{namespace} [put] +func (handler *Handler) updateKubernetesNamespace(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + payload := models.K8sNamespaceDetails{} + if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil { + return httperror.BadRequest("an error occurred during the UpdateKubernetesNamespace operation, invalid request payload. Error: ", err) + } + + namespaceName := payload.Name + cli, httpErr := handler.getProxyKubeClient(r) + if httpErr != nil { + return httperror.InternalServerError(fmt.Sprintf("an error occurred during the UpdateKubernetesNamespace operation for the namespace %s, unable to get a Kubernetes client for the user. Error: ", namespaceName), httpErr) + } + + namespace, err := cli.UpdateNamespace(payload) + if err != nil { + return httperror.InternalServerError(fmt.Sprintf("an error occurred during the UpdateKubernetesNamespace operation for the namespace %s, unable to update the Kubernetes namespace. Error: ", namespaceName), err) + } + + return response.JSON(w, namespace) +} diff --git a/api/http/handler/kubernetes/node.go b/api/http/handler/kubernetes/node.go new file mode 100644 index 0000000..09ba02f --- /dev/null +++ b/api/http/handler/kubernetes/node.go @@ -0,0 +1,105 @@ +package kubernetes + +import ( + "net/http" + + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" + "github.com/portainer/portainer/pkg/libkubectl" + "github.com/rs/zerolog/log" + corev1 "k8s.io/api/core/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" +) + +// KubernetesNodeResponse is the documented response model for cluster node endpoints. +type KubernetesNodeResponse corev1.Node + +// @id GetKubernetesNodes +// @summary Get Kubernetes cluster nodes +// @description Returns the list of Kubernetes nodes for the selected environment. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment(Endpoint) identifier" +// @success 200 {array} KubernetesNodeResponse "Success" +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource." +// @failure 500 "Server error occurred while attempting to retrieve the list of nodes." +// @router /kubernetes/{id}/nodes [get] +func (handler *Handler) getKubernetesNodes(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + cli, httpErr := handler.getProxyKubeClient(r) + if httpErr != nil { + log.Error(). + Err(httpErr). + Int("status_code", httpErr.StatusCode). + Str("message", httpErr.Message). + Str("context", "getKubernetesNodes"). + Msg("Unable to prepare kube client") + return httpErr + } + + nodes, err := cli.GetClusterNodes() + if err != nil { + if k8serrors.IsUnauthorized(err) { + log.Error().Err(err).Str("context", "getKubernetesNodes").Msg("Unable to retrieve nodes") + return httperror.Unauthorized("Unable to retrieve nodes", err) + } + + if k8serrors.IsForbidden(err) { + log.Error().Err(err).Str("context", "getKubernetesNodes").Msg("Unable to retrieve nodes") + return httperror.Forbidden("Unable to retrieve nodes", err) + } + + log.Error().Err(err).Str("context", "getKubernetesNodes").Msg("Unable to retrieve nodes") + return httperror.InternalServerError("Unable to retrieve nodes", err) + } + + return response.JSON(w, nodes) +} + +// @id drainNode +// @summary Drain a Kubernetes node +// @description Drain a Kubernetes node by safely evicting all pods from the node, preparing it for maintenance or removal +// @description **Access policy**: authenticated +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @accept json +// @param id path int true "Environment(Endpoint) identifier" +// @param name path string true "Name of the node to drain" +// @success 204 "Success" +// @failure 400 "Invalid request, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier or unable to find the specified node." +// @failure 500 "Server error occurred while attempting to drain node." +// @router /kubernetes/{id}/nodes/{name}/drain [post] +func (handler *Handler) drainNode(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + name, err := request.RetrieveRouteVariableValue(r, "name") + if err != nil { + log.Error().Err(err).Str("context", "drainNode").Msg("Invalid node name route variable") + return httperror.BadRequest("Invalid node name route variable", err) + } + + kubeCtlAccess, err := handler.getLibKubectlAccess(r) + if err != nil { + log.Error().Err(err).Str("context", "drainNode").Str("node name", name).Msg("Unable to get a Kubernetes client for the user") + return httperror.InternalServerError("Unable to get a Kubernetes client for the user", err) + } + + client, err := libkubectl.NewClient(kubeCtlAccess, "", "", true) + if err != nil { + log.Error().Err(err).Str("context", "drainNode").Msg("Failed to create kubernetes client") + return httperror.InternalServerError("an error occurred during the drainNode operation, failed to create kubernetes client. Error: ", err) + } + + output, err := client.DrainNode(name) + if err != nil { + log.Error().Err(err).Str("context", "drainNode").Msg("Failed to drain node") + return httperror.InternalServerError("an error occurred during the drainNode operation, failed to drain node. Error: ", err) + } + log.Debug().Str("context", "drainNode").Str("node name", name).Str("output", output).Msg("Drained node") + + return response.Empty(w) +} diff --git a/api/http/handler/kubernetes/node_test.go b/api/http/handler/kubernetes/node_test.go new file mode 100644 index 0000000..768cf7f --- /dev/null +++ b/api/http/handler/kubernetes/node_test.go @@ -0,0 +1,104 @@ +package kubernetes + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/authorization" + "github.com/portainer/portainer/api/internal/testhelpers" + "github.com/portainer/portainer/api/jwt" + "github.com/portainer/portainer/api/kubernetes" + kubeclient "github.com/portainer/portainer/api/kubernetes/cli" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newNodeTestHandler(t *testing.T) (*Handler, *portainer.User, string) { + t.Helper() + + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{}`)) + })) + t.Cleanup(srv.Close) + + _, store := datastore.MustNewTestStore(t, true, true) + + // KubernetesLocalEnvironment avoids the nil signatureService panic that + // AgentOnKubernetesEnvironment triggers via buildAgentConfig. + err := store.Endpoint().Create(&portainer.Endpoint{ + ID: 1, + Type: portainer.KubernetesLocalEnvironment, + }) + require.NoError(t, err) + + u := &portainer.User{Username: "admin", Role: portainer.AdministratorRole} + err = store.User().Create(u) + require.NoError(t, err) + + jwtService, err := jwt.NewService("1h", store) + require.NoError(t, err) + + tk, _, err := jwtService.GenerateToken(&portainer.TokenData{ID: u.ID, Username: u.Username, Role: u.Role}) + require.NoError(t, err) + + kubeClusterAccessService := kubernetes.NewKubeClusterAccessService("", "", "") + + srvURL, err := url.Parse(srv.URL) + require.NoError(t, err) + + cli := testhelpers.NewKubernetesClient() + factory, err := kubeclient.NewClientFactory(nil, nil, store, "", ":"+srvURL.Port(), "") + require.NoError(t, err) + + authorizationService := authorization.NewService(store) + handler := NewHandler(testhelpers.NewTestRequestBouncer(), authorizationService, store, jwtService, kubeClusterAccessService, factory, cli) + + return handler, u, tk +} + +func newNodeRequest(t *testing.T, method, path string, u *portainer.User, tk string) *http.Request { + t.Helper() + + req := httptest.NewRequest(method, path, nil) + ctx := security.StoreTokenData(req, &portainer.TokenData{ID: u.ID, Username: u.Username, Role: u.Role}) + req = req.WithContext(ctx) + ctx = security.StoreRestrictedRequestContext(req, &security.RestrictedRequestContext{IsAdmin: true, UserID: u.ID}) + req = req.WithContext(ctx) + testhelpers.AddTestSecurityCookie(req, tk) + return req +} + +func TestGetKubernetesNodes_ReachesKubernetesLayer(t *testing.T) { + t.Parallel() + handler, u, tk := newNodeTestHandler(t) + + req := newNodeRequest(t, http.MethodGet, "/kubernetes/1/nodes", u, tk) + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + // The TLS test server returns "{}" which the k8s client decodes as an empty + // NodeList, so the handler returns 200 with an empty array. + assert.NotEqual(t, http.StatusBadRequest, rr.Code, "should not be rejected at the handler layer") + assert.NotEqual(t, http.StatusNotFound, rr.Code, "route must be registered") + assert.Equal(t, http.StatusOK, rr.Code) +} + +func TestGetKubernetesNodes_WrongMethodReturns404(t *testing.T) { + t.Parallel() + handler, u, tk := newNodeTestHandler(t) + + req := newNodeRequest(t, http.MethodPost, "/kubernetes/1/nodes", u, tk) + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + // Gorilla mux returns 404 for unregistered methods when no MethodNotAllowedHandler is set. + assert.Equal(t, http.StatusNotFound, rr.Code) +} diff --git a/api/http/handler/kubernetes/nodes_limits.go b/api/http/handler/kubernetes/nodes_limits.go new file mode 100644 index 0000000..c25b8bd --- /dev/null +++ b/api/http/handler/kubernetes/nodes_limits.go @@ -0,0 +1,88 @@ +package kubernetes + +import ( + "net/http" + + "github.com/portainer/portainer/api/http/middlewares" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/response" + "github.com/rs/zerolog/log" +) + +// @id GetKubernetesNodesLimits +// @summary Get CPU and memory limits of all nodes within k8s cluster +// @description Get CPU and memory limits of all nodes within k8s cluster. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment(Endpoint) identifier" +// @success 200 {object} portainer.K8sNodesLimits "Success" +// @failure 400 "Invalid request" +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier." +// @failure 500 "Server error occurred while attempting to retrieve nodes limits." +// @router /kubernetes/{id}/nodes_limits [get] +func (handler *Handler) getKubernetesNodesLimits(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpoint, err := middlewares.FetchEndpoint(r) + if err != nil { + log.Error().Err(err).Str("context", "GetKubernetesNodesLimits").Msg("Unable to find an environment on request context") + return httperror.NotFound("Unable to find an environment on request context", err) + } + + cli, err := handler.KubernetesClientFactory.GetPrivilegedKubeClient(endpoint) + if err != nil { + log.Error().Err(err).Str("context", "GetKubernetesNodesLimits").Msg("Unable to create Kubernetes client") + return httperror.InternalServerError("Unable to create Kubernetes client", err) + } + + nodesLimits, err := cli.GetNodesLimits() + if err != nil { + log.Error().Err(err).Str("context", "GetKubernetesNodesLimits").Msg("Unable to retrieve nodes limits") + return httperror.InternalServerError("Unable to retrieve nodes limits", err) + } + + return response.JSON(w, nodesLimits) +} + +// @id GetKubernetesMaxResourceLimits +// @summary Get max CPU and memory limits of all nodes within k8s cluster +// @description Get max CPU and memory limits (unused resources) of all nodes within k8s cluster. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment(Endpoint) identifier" +// @success 200 {object} portainer.K8sNodesLimits "Success" +// @failure 400 "Invalid request" +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier." +// @failure 500 "Server error occurred while attempting to retrieve nodes limits." +// @router /kubernetes/{id}/max_resource_limits [get] +func (handler *Handler) getKubernetesMaxResourceLimits(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpoint, err := middlewares.FetchEndpoint(r) + if err != nil { + log.Error().Err(err).Str("context", "GetKubernetesMaxResourceLimits").Msg("Unable to find an environment on request context") + return httperror.NotFound("Unable to find an environment on request context", err) + } + + cli, err := handler.KubernetesClientFactory.GetPrivilegedKubeClient(endpoint) + if err != nil { + log.Error().Err(err).Str("context", "GetKubernetesMaxResourceLimits").Msg("Unable to create Kubernetes client") + return httperror.InternalServerError("Unable to create Kubernetes client", err) + } + + overCommit := endpoint.Kubernetes.Configuration.EnableResourceOverCommit + overCommitPercent := endpoint.Kubernetes.Configuration.ResourceOverCommitPercentage + + // name is set to "" so all namespaces resources are considered when calculating max resource limits + resourceLimit, err := cli.GetMaxResourceLimits("", overCommit, overCommitPercent) + if err != nil { + log.Error().Err(err).Str("context", "GetKubernetesMaxResourceLimits").Msg("Unable to retrieve max resource limit") + return httperror.InternalServerError("Unable to retrieve max resource limit", err) + } + + return response.JSON(w, resourceLimit) +} diff --git a/api/http/handler/kubernetes/persistent_volume_claims.go b/api/http/handler/kubernetes/persistent_volume_claims.go new file mode 100644 index 0000000..5f257d3 --- /dev/null +++ b/api/http/handler/kubernetes/persistent_volume_claims.go @@ -0,0 +1,237 @@ +package kubernetes + +import ( + "errors" + "net/http" + + models "github.com/portainer/portainer/api/http/models/kubernetes" + kcli "github.com/portainer/portainer/api/kubernetes/cli" + "github.com/rs/zerolog/log" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +// @id GetAllKubernetesPersistentVolumeClaims +// @summary Get all PersistentVolumeClaims +// @description Get a list of all PersistentVolumeClaims within the given environment. Scoped by namespace for non-admin users. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment identifier" +// @success 200 {array} models.K8sPersistentVolumeClaim "Success" +// @failure 400 "Invalid request payload." +// @failure 403 "Unauthorized access or operation not allowed." +// @failure 500 "Server error occurred while attempting to retrieve persistent volume claims." +// @router /kubernetes/{id}/persistent_volume_claims [get] +func (handler *Handler) getAllKubernetesPersistentVolumeClaims(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + cli, httpErr := handler.prepareKubeClient(r) + if httpErr != nil { + log.Error().Err(httpErr).Str("context", "GetAllKubernetesPersistentVolumeClaims").Msg("Unable to get Kubernetes client") + return httpErr + } + + pvcs, err := cli.GetPersistentVolumeClaims("") + if err != nil { + if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) { + return httperror.Forbidden("unauthorized access to persistent volume claims", err) + } + + log.Error().Err(err).Str("context", "GetAllKubernetesPersistentVolumeClaims").Msg("Failed to retrieve persistent volume claims") + return httperror.InternalServerError("failed to retrieve persistent volume claims", err) + } + + pvcs, err = cli.CombineClaimsWithApplications(pvcs) + if err != nil { + log.Warn().Err(err).Str("context", "GetAllKubernetesPersistentVolumeClaims").Msg("Failed to enrich PVCs with owning applications") + } + + return response.JSON(w, pvcs) +} + +// @id GetKubernetesPersistentVolumeClaimsInNamespace +// @summary Get PersistentVolumeClaims in a namespace +// @description Get a list of PersistentVolumeClaims in the specified namespace. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment identifier" +// @param namespace path string true "Namespace name" +// @success 200 {array} models.K8sPersistentVolumeClaim "Success" +// @failure 400 "Invalid request payload." +// @failure 403 "Unauthorized access or operation not allowed." +// @failure 500 "Server error occurred while attempting to retrieve persistent volume claims." +// @router /kubernetes/{id}/namespaces/{namespace}/persistent_volume_claims [get] +func (handler *Handler) getKubernetesPVCsInNamespace(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + namespace, err := request.RetrieveRouteVariableValue(r, "namespace") + if err != nil { + return httperror.BadRequest("invalid namespace identifier", err) + } + + cli, httpErr := handler.prepareKubeClient(r) + if httpErr != nil { + log.Error().Err(httpErr).Str("context", "GetKubernetesPVCsInNamespace").Msg("Unable to get Kubernetes client") + return httpErr + } + + pvcs, err := cli.GetPersistentVolumeClaims(namespace) + if err != nil { + if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) { + return httperror.Forbidden("unauthorized access to persistent volume claims", err) + } + + log.Error().Err(err).Str("context", "GetKubernetesPVCsInNamespace").Str("namespace", namespace).Msg("Failed to retrieve persistent volume claims") + return httperror.InternalServerError("failed to retrieve persistent volume claims", err) + } + + pvcs, err = cli.CombineClaimsWithApplications(pvcs) + if err != nil { + log.Warn().Err(err).Str("context", "GetKubernetesPVCsInNamespace").Str("namespace", namespace).Msg("Failed to enrich PVCs with owning applications") + } + + return response.JSON(w, pvcs) +} + +// @id GetKubernetesPersistentVolumeClaim +// @summary Get a specific PersistentVolumeClaim +// @description Get a PersistentVolumeClaim by name within a namespace. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment identifier" +// @param namespace path string true "Namespace name" +// @param name path string true "PVC name" +// @success 200 {object} models.K8sPersistentVolumeClaim "Success" +// @failure 400 "Invalid request payload." +// @failure 403 "Unauthorized access or operation not allowed." +// @failure 404 "PVC not found." +// @failure 500 "Server error occurred while attempting to retrieve the persistent volume claim." +// @router /kubernetes/{id}/namespaces/{namespace}/persistent_volume_claims/{name} [get] +func (handler *Handler) getKubernetesPersistentVolumeClaim(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + namespace, err := request.RetrieveRouteVariableValue(r, "namespace") + if err != nil { + return httperror.BadRequest("invalid namespace identifier", err) + } + + name, err := request.RetrieveRouteVariableValue(r, "name") + if err != nil { + return httperror.BadRequest("invalid PVC name", err) + } + + cli, httpErr := handler.prepareKubeClient(r) + if httpErr != nil { + log.Error().Err(httpErr).Str("context", "GetKubernetesPersistentVolumeClaim").Msg("Unable to get Kubernetes client") + return httpErr + } + + pvc, err := cli.GetPersistentVolumeClaim(namespace, name) + if err != nil { + if k8serrors.IsNotFound(err) { + return httperror.NotFound("persistent volume claim not found", err) + } + + if errors.Is(err, kcli.ErrUnauthorized) || k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) { + return httperror.Forbidden("unauthorized access to the Kubernetes API", err) + } + + log.Error().Err(err).Str("context", "GetKubernetesPersistentVolumeClaim").Str("namespace", namespace).Str("name", name).Msg("Failed to retrieve persistent volume claim") + return httperror.InternalServerError("failed to retrieve persistent volume claim", err) + } + + return response.JSON(w, pvc) +} + +// @id DeleteKubernetesPersistentVolumeClaims +// @summary Delete PersistentVolumeClaims +// @description Delete the provided list of PersistentVolumeClaims. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @accept json +// @produce json +// @param id path int true "Environment identifier" +// @param body body []models.K8sVolumeDeleteRequest true "List of PVCs to delete (namespace + name)" +// @success 204 "Success" +// @failure 400 "Invalid request payload." +// @failure 403 "Unauthorized access or operation not allowed." +// @failure 500 "Server error occurred while attempting to delete persistent volume claims." +// @router /kubernetes/{id}/persistent_volume_claims/delete [post] +func (handler *Handler) deleteKubernetesPersistentVolumeClaims(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload models.K8sVolumeDeleteRequests + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return httperror.BadRequest("unable to decode and validate the request payload", err) + } + + cli, httpErr := handler.getProxyKubeClient(r) + if httpErr != nil { + return httpErr + } + + err = cli.DeletePersistentVolumeClaims(payload) + if err != nil { + if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) { + return httperror.Forbidden("unauthorized access to the Kubernetes API", err) + } + + if k8serrors.IsNotFound(err) { + return httperror.NotFound("unable to find the persistent volume claims to delete", err) + } + + log.Error().Err(err).Str("context", "DeleteKubernetesPersistentVolumeClaims").Msg("Unable to delete persistent volume claims") + return httperror.InternalServerError("unable to delete persistent volume claims", err) + } + + return response.Empty(w) +} + +// @id ResizeKubernetesPersistentVolumeClaim +// @summary Resize a PersistentVolumeClaim +// @description Resize a PVC to a new size. The StorageClass must support volume expansion. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @accept json +// @produce json +// @param id path int true "Environment identifier" +// @param body body models.K8sPVCResizeRequest true "PVC resize request" +// @success 204 "Success" +// @failure 400 "Invalid request payload." +// @failure 403 "Unauthorized access or operation not allowed." +// @failure 500 "Server error occurred while attempting to resize the persistent volume claim." +// @router /kubernetes/{id}/persistent_volume_claims/resize [put] +func (handler *Handler) resizeKubernetesPersistentVolumeClaim(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload models.K8sPVCResizeRequest + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return httperror.BadRequest("unable to decode and validate the request payload", err) + } + + cli, httpErr := handler.getProxyKubeClient(r) + if httpErr != nil { + return httpErr + } + + err = cli.ResizePersistentVolumeClaim(payload.Namespace, payload.Name, payload.NewSize) + if err != nil { + if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) { + return httperror.Forbidden("unauthorized access to the Kubernetes API", err) + } + + if k8serrors.IsNotFound(err) { + return httperror.NotFound("persistent volume claim not found", err) + } + + log.Error().Err(err).Str("context", "ResizeKubernetesPersistentVolumeClaim"). + Str("namespace", payload.Namespace).Str("name", payload.Name). + Msg("Unable to resize persistent volume claim") + return httperror.InternalServerError("unable to resize persistent volume claim", err) + } + + return response.Empty(w) +} diff --git a/api/http/handler/kubernetes/persistent_volumes.go b/api/http/handler/kubernetes/persistent_volumes.go new file mode 100644 index 0000000..e94de88 --- /dev/null +++ b/api/http/handler/kubernetes/persistent_volumes.go @@ -0,0 +1,180 @@ +package kubernetes + +import ( + "errors" + "net/http" + + models "github.com/portainer/portainer/api/http/models/kubernetes" + kcli "github.com/portainer/portainer/api/kubernetes/cli" + "github.com/rs/zerolog/log" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +// @id GetAllKubernetesPersistentVolumes +// @summary Get all PersistentVolumes in the cluster +// @description Get a list of all PersistentVolumes in the given environment. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment identifier" +// @success 200 {array} models.K8sPersistentVolume "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 403 "Unauthorized access or operation not allowed." +// @failure 500 "Server error occurred while attempting to retrieve persistent volumes." +// @router /kubernetes/{id}/persistent_volumes [get] +func (handler *Handler) getAllKubernetesPersistentVolumes(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + cli, httpErr := handler.prepareKubeClient(r) + if httpErr != nil { + log.Error().Err(httpErr).Str("context", "GetAllKubernetesPersistentVolumes").Msg("Unable to get Kubernetes client") + return httpErr + } + + pvs, err := cli.GetPersistentVolumes() + if err != nil { + if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) { + return httperror.Forbidden("unauthorized access to persistent volumes", err) + } + + log.Error().Err(err).Str("context", "GetAllKubernetesPersistentVolumes").Msg("Failed to retrieve persistent volumes") + return httperror.InternalServerError("failed to retrieve persistent volumes", err) + } + + return response.JSON(w, pvs) +} + +// @id GetKubernetesPersistentVolume +// @summary Get a specific PersistentVolume +// @description Get a PersistentVolume by name in the given environment. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment identifier" +// @param name path string true "PersistentVolume name" +// @success 200 {object} models.K8sPersistentVolume "Success" +// @failure 400 "Invalid request payload." +// @failure 403 "Unauthorized access or operation not allowed." +// @failure 404 "PersistentVolume not found." +// @failure 500 "Server error occurred while attempting to retrieve the persistent volume." +// @router /kubernetes/{id}/persistent_volumes/{name} [get] +func (handler *Handler) getKubernetesPersistentVolume(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + name, err := request.RetrieveRouteVariableValue(r, "name") + if err != nil { + return httperror.BadRequest("invalid persistent volume name", err) + } + + cli, httpErr := handler.prepareKubeClient(r) + if httpErr != nil { + log.Error().Err(httpErr).Str("context", "GetKubernetesPersistentVolume").Msg("Unable to get Kubernetes client") + return httpErr + } + + pv, err := cli.GetPersistentVolume(name) + if err != nil { + if k8serrors.IsNotFound(err) { + return httperror.NotFound("persistent volume not found", err) + } + + if errors.Is(err, kcli.ErrUnauthorized) || k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) { + return httperror.Forbidden("unauthorized access to the Kubernetes API", err) + } + + log.Error().Err(err).Str("context", "GetKubernetesPersistentVolume").Str("name", name).Msg("Failed to retrieve persistent volume") + return httperror.InternalServerError("failed to retrieve persistent volume", err) + } + + return response.JSON(w, pv) +} + +// @id DeleteKubernetesPersistentVolumes +// @summary Delete PersistentVolumes +// @description Delete the provided list of PersistentVolumes. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @accept json +// @produce json +// @param id path int true "Environment identifier" +// @param body body models.K8sPVDeleteRequest true "List of PV names to delete" +// @success 204 "Success" +// @failure 400 "Invalid request payload." +// @failure 403 "Unauthorized access or operation not allowed." +// @failure 500 "Server error occurred while attempting to delete persistent volumes." +// @router /kubernetes/{id}/persistent_volumes/delete [post] +func (handler *Handler) deleteKubernetesPersistentVolumes(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload models.K8sPVDeleteRequest + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return httperror.BadRequest("unable to decode and validate the request payload", err) + } + + cli, httpErr := handler.getProxyKubeClient(r) + if httpErr != nil { + return httpErr + } + + err = cli.DeletePersistentVolumes(payload) + if err != nil { + if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) { + return httperror.Forbidden("unauthorized access to the Kubernetes API", err) + } + + if k8serrors.IsNotFound(err) { + return httperror.NotFound("unable to find the persistent volumes to delete", err) + } + + log.Error().Err(err).Str("context", "DeleteKubernetesPersistentVolumes").Msg("Unable to delete persistent volumes") + return httperror.InternalServerError("unable to delete persistent volumes", err) + } + + return response.Empty(w) +} + +// @id UpdateKubernetesPersistentVolumeReclaimPolicy +// @summary Update reclaim policy of a PersistentVolume +// @description Update the reclaim policy of a PersistentVolume. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @accept json +// @produce json +// @param id path int true "Environment identifier" +// @param body body models.K8sPVReclaimPolicyRequest true "Reclaim policy update request" +// @success 204 "Success" +// @failure 400 "Invalid request payload." +// @failure 403 "Unauthorized access or operation not allowed." +// @failure 500 "Server error occurred while attempting to update reclaim policy." +// @router /kubernetes/{id}/persistent_volumes/reclaim_policy [put] +func (handler *Handler) updateKubernetesPVReclaimPolicy(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload models.K8sPVReclaimPolicyRequest + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return httperror.BadRequest("unable to decode and validate the request payload", err) + } + + cli, httpErr := handler.getProxyKubeClient(r) + if httpErr != nil { + return httpErr + } + + err = cli.UpdatePersistentVolumeReclaimPolicy(payload.Name, payload.ReclaimPolicy) + if err != nil { + if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) { + return httperror.Forbidden("unauthorized access to the Kubernetes API", err) + } + + if k8serrors.IsNotFound(err) { + return httperror.NotFound("persistent volume not found", err) + } + + log.Error().Err(err).Str("context", "UpdateKubernetesPVReclaimPolicy").Str("name", payload.Name).Msg("Unable to update reclaim policy") + return httperror.InternalServerError("unable to update reclaim policy", err) + } + + return response.Empty(w) +} diff --git a/api/http/handler/kubernetes/pod.go b/api/http/handler/kubernetes/pod.go new file mode 100644 index 0000000..e6f5f6a --- /dev/null +++ b/api/http/handler/kubernetes/pod.go @@ -0,0 +1,127 @@ +package kubernetes + +import ( + "net/http" + + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" + "github.com/rs/zerolog/log" + k8serrors "k8s.io/apimachinery/pkg/api/errors" +) + +// @id DeleteKubernetesPod +// @summary Delete a kubernetes pod +// @description Delete a single Kubernetes pod in the given namespace. The owning +// @description controller (Deployment, StatefulSet, DaemonSet, ...) is responsible +// @description for recreating the pod. For naked pods the pod is removed permanently. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment(Endpoint) identifier" +// @param namespace path string true "Namespace" +// @param name path string true "Pod name" +// @success 204 "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier or unable to find the specified pod." +// @failure 500 "Server error occurred while attempting to delete the pod." +// @router /kubernetes/{id}/namespaces/{namespace}/pods/{name} [delete] +func (handler *Handler) deleteKubernetesPod(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + namespace, name, httpErr := parseNamespaceAndPodName(r) + if httpErr != nil { + return httpErr + } + + cli, httpErr := handler.prepareKubeClient(r) + if httpErr != nil { + log.Error().Err(httpErr).Str("context", "DeleteKubernetesPod").Str("namespace", namespace).Str("name", name).Msg("Unable to get a Kubernetes client for the user") + return httperror.InternalServerError("Unable to get a Kubernetes client for the user", httpErr) + } + + if err := cli.DeletePod(namespace, name); err != nil { + if k8serrors.IsNotFound(err) { + log.Error().Err(err).Str("context", "DeleteKubernetesPod").Str("namespace", namespace).Str("name", name).Msg("Pod not found") + return httperror.NotFound("Pod not found", err) + } + if k8serrors.IsForbidden(err) { + log.Error().Err(err).Str("context", "DeleteKubernetesPod").Str("namespace", namespace).Str("name", name).Msg("Permission denied to delete the pod") + return httperror.Forbidden("Permission denied to delete the pod", err) + } + log.Error().Err(err).Str("context", "DeleteKubernetesPod").Str("namespace", namespace).Str("name", name).Msg("Unable to delete the pod") + return httperror.InternalServerError("Unable to delete the pod", err) + } + + return response.Empty(w) +} + +// @id RestartKubernetesPod +// @summary Restart all containers in a Kubernetes pod +// @description Restart all containers in a single Kubernetes pod in place using +// @description the Kubernetes 1.35 alpha pod-restart subresource. The pod itself +// @description is preserved. Requires the cluster to expose the corresponding +// @description subresource (and the matching feature gate to be enabled). +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment(Endpoint) identifier" +// @param namespace path string true "Namespace" +// @param name path string true "Pod name" +// @success 204 "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment, the specified pod, or the cluster does not expose the pod-restart subresource (Kubernetes <1.35 or feature gate disabled)." +// @failure 405 "The cluster does not support the pod-restart subresource (Kubernetes <1.35 or feature gate disabled)." +// @failure 500 "Server error occurred while attempting to restart the pod." +// @router /kubernetes/{id}/namespaces/{namespace}/pods/{name}/restart [post] +func (handler *Handler) restartKubernetesPod(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + namespace, name, httpErr := parseNamespaceAndPodName(r) + if httpErr != nil { + return httpErr + } + + cli, httpErr := handler.prepareKubeClient(r) + if httpErr != nil { + log.Error().Err(httpErr).Str("context", "RestartKubernetesPod").Str("namespace", namespace).Str("name", name).Msg("Unable to get a Kubernetes client for the user") + return httperror.InternalServerError("Unable to get a Kubernetes client for the user", httpErr) + } + + if err := cli.RestartPod(namespace, name); err != nil { + if k8serrors.IsNotFound(err) { + log.Error().Err(err).Str("context", "RestartKubernetesPod").Str("namespace", namespace).Str("name", name).Msg("Pod or pod-restart subresource not found") + return httperror.NotFound("Pod or pod-restart subresource not found. The Kubernetes 1.35 alpha pod-restart subresource is required and may need its feature gate enabled.", err) + } + if k8serrors.IsMethodNotSupported(err) { + log.Error().Err(err).Str("context", "RestartKubernetesPod").Str("namespace", namespace).Str("name", name).Msg("Pod-restart subresource not supported") + return httperror.NewError(http.StatusMethodNotAllowed, "The cluster does not support the pod-restart subresource (Kubernetes <1.35 or feature gate disabled).", err) + } + if k8serrors.IsForbidden(err) { + log.Error().Err(err).Str("context", "RestartKubernetesPod").Str("namespace", namespace).Str("name", name).Msg("Permission denied to restart the pod") + return httperror.Forbidden("Permission denied to restart the pod", err) + } + log.Error().Err(err).Str("context", "RestartKubernetesPod").Str("namespace", namespace).Str("name", name).Msg("Unable to restart the pod") + return httperror.InternalServerError("Unable to restart the pod", err) + } + + return response.Empty(w) +} + +func parseNamespaceAndPodName(r *http.Request) (string, string, *httperror.HandlerError) { + namespace, err := request.RetrieveRouteVariableValue(r, "namespace") + if err != nil { + log.Error().Err(err).Str("context", "parseNamespaceAndPodName").Msg("Invalid namespace route variable") + return "", "", httperror.BadRequest("Invalid namespace route variable", err) + } + + name, err := request.RetrieveRouteVariableValue(r, "name") + if err != nil { + log.Error().Err(err).Str("context", "parseNamespaceAndPodName").Msg("Invalid pod name route variable") + return "", "", httperror.BadRequest("Invalid pod name route variable", err) + } + + return namespace, name, nil +} diff --git a/api/http/handler/kubernetes/pod_test.go b/api/http/handler/kubernetes/pod_test.go new file mode 100644 index 0000000..3f2b311 --- /dev/null +++ b/api/http/handler/kubernetes/pod_test.go @@ -0,0 +1,131 @@ +package kubernetes + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/authorization" + "github.com/portainer/portainer/api/internal/testhelpers" + "github.com/portainer/portainer/api/jwt" + "github.com/portainer/portainer/api/kubernetes" + kubeclient "github.com/portainer/portainer/api/kubernetes/cli" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newPodTestHandler(t *testing.T) (*Handler, *portainer.User, string) { + t.Helper() + + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{}`)) + })) + t.Cleanup(srv.Close) + + _, store := datastore.MustNewTestStore(t, true, true) + + // KubernetesLocalEnvironment avoids the nil signatureService panic that + // AgentOnKubernetesEnvironment triggers via buildAgentConfig. + err := store.Endpoint().Create(&portainer.Endpoint{ + ID: 1, + Type: portainer.KubernetesLocalEnvironment, + }) + require.NoError(t, err) + + u := &portainer.User{Username: "admin", Role: portainer.AdministratorRole} + err = store.User().Create(u) + require.NoError(t, err) + + jwtService, err := jwt.NewService("1h", store) + require.NoError(t, err) + + tk, _, err := jwtService.GenerateToken(&portainer.TokenData{ID: u.ID, Username: u.Username, Role: u.Role}) + require.NoError(t, err) + + kubeClusterAccessService := kubernetes.NewKubeClusterAccessService("", "", "") + + srvURL, err := url.Parse(srv.URL) + require.NoError(t, err) + + cli := testhelpers.NewKubernetesClient() + factory, err := kubeclient.NewClientFactory(nil, nil, store, "", ":"+srvURL.Port(), "") + require.NoError(t, err) + + authorizationService := authorization.NewService(store) + handler := NewHandler(testhelpers.NewTestRequestBouncer(), authorizationService, store, jwtService, kubeClusterAccessService, factory, cli) + + return handler, u, tk +} + +func newPodRequest(t *testing.T, method, path string, u *portainer.User, tk string) *http.Request { + t.Helper() + + req := httptest.NewRequest(method, path, nil) + ctx := security.StoreTokenData(req, &portainer.TokenData{ID: u.ID, Username: u.Username, Role: u.Role}) + req = req.WithContext(ctx) + ctx = security.StoreRestrictedRequestContext(req, &security.RestrictedRequestContext{IsAdmin: true, UserID: u.ID}) + req = req.WithContext(ctx) + testhelpers.AddTestSecurityCookie(req, tk) + return req +} + +func TestDeleteKubernetesPod_ReachesKubernetesLayer(t *testing.T) { + t.Parallel() + handler, u, tk := newPodTestHandler(t) + + req := newPodRequest(t, http.MethodDelete, "/kubernetes/1/namespaces/default/pods/my-pod", u, tk) + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + // A valid request must pass all handler-level checks. Without a live + // cluster the privileged kube client cannot be created, so we expect 500 + // rather than a 4xx client error. + assert.NotEqual(t, http.StatusBadRequest, rr.Code, "should not be rejected at the handler layer") + assert.NotEqual(t, http.StatusNotFound, rr.Code, "route must be registered") + assert.Equal(t, http.StatusInternalServerError, rr.Code, "without a live cluster the kube client is unavailable") +} + +func TestDeleteKubernetesPod_WrongMethodReturns404(t *testing.T) { + t.Parallel() + handler, u, tk := newPodTestHandler(t) + + req := newPodRequest(t, http.MethodGet, "/kubernetes/1/namespaces/default/pods/my-pod", u, tk) + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + // Gorilla mux returns 404 for unregistered methods when no MethodNotAllowedHandler is set. + assert.Equal(t, http.StatusNotFound, rr.Code) +} + +func TestRestartKubernetesPod_ReachesKubernetesLayer(t *testing.T) { + t.Parallel() + handler, u, tk := newPodTestHandler(t) + + req := newPodRequest(t, http.MethodPost, "/kubernetes/1/namespaces/default/pods/my-pod/restart", u, tk) + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + assert.NotEqual(t, http.StatusBadRequest, rr.Code, "should not be rejected at the handler layer") + assert.NotEqual(t, http.StatusNotFound, rr.Code, "route must be registered") + assert.Equal(t, http.StatusInternalServerError, rr.Code, "without a live cluster the kube client is unavailable") +} + +func TestRestartKubernetesPod_WrongMethodReturns404(t *testing.T) { + t.Parallel() + handler, u, tk := newPodTestHandler(t) + + req := newPodRequest(t, http.MethodGet, "/kubernetes/1/namespaces/default/pods/my-pod/restart", u, tk) + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusNotFound, rr.Code) +} diff --git a/api/http/handler/kubernetes/rbac.go b/api/http/handler/kubernetes/rbac.go new file mode 100644 index 0000000..43f5c51 --- /dev/null +++ b/api/http/handler/kubernetes/rbac.go @@ -0,0 +1,39 @@ +package kubernetes + +import ( + "net/http" + + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/response" + "github.com/rs/zerolog/log" +) + +// @id GetKubernetesRBACStatus +// @summary Check if RBAC is enabled +// @description Check if RBAC is enabled in the specified Kubernetes cluster. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment(Endpoint) identifier" +// @success 200 {boolean} bool "RBAC status" +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier." +// @failure 500 "Server error occurred while attempting to retrieve the RBAC status." +// @router /kubernetes/{id}/rbac_enabled [get] +func (handler *Handler) getKubernetesRBACStatus(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + cli, handlerErr := handler.getProxyKubeClient(r) + if handlerErr != nil { + log.Error().Err(handlerErr).Str("context", "GetKubernetesRBACStatus").Msg("Unable to get a Kubernetes client for the user") + return httperror.InternalServerError("Unable to get a Kubernetes client for the user. Error: ", handlerErr) + } + + isRBACEnabled, err := cli.IsRBACEnabled() + if err != nil { + log.Error().Err(err).Str("context", "GetKubernetesRBACStatus").Msg("Failed to check RBAC status") + return httperror.InternalServerError("Failed to check RBAC status", err) + } + + return response.JSON(w, isRBACEnabled) +} diff --git a/api/http/handler/kubernetes/role_bindings.go b/api/http/handler/kubernetes/role_bindings.go new file mode 100644 index 0000000..0f5260b --- /dev/null +++ b/api/http/handler/kubernetes/role_bindings.go @@ -0,0 +1,77 @@ +package kubernetes + +import ( + "net/http" + + models "github.com/portainer/portainer/api/http/models/kubernetes" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" + "github.com/rs/zerolog/log" +) + +// @id GetKubernetesRoleBindings +// @summary Get a list of kubernetes role bindings +// @description Get a list of kubernetes role bindings that the user has access to. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment identifier" +// @success 200 {array} kubernetes.K8sRoleBinding "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier." +// @failure 500 "Server error occurred while attempting to retrieve the list of role bindings." +// @router /kubernetes/{id}/rolebindings [get] +func (handler *Handler) getAllKubernetesRoleBindings(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + cli, httpErr := handler.prepareKubeClient(r) + if httpErr != nil { + log.Error().Err(httpErr).Str("context", "GetAllKubernetesRoleBindings").Msg("Unable to prepare kube client") + return httperror.InternalServerError("unable to prepare kube client. Error: ", httpErr) + } + + rolebindings, err := cli.GetRoleBindings("") + if err != nil { + log.Error().Err(err).Str("context", "GetAllKubernetesRoleBindings").Msg("Unable to fetch rolebindings") + return httperror.InternalServerError("unable to fetch rolebindings. Error: ", err) + } + + return response.JSON(w, rolebindings) +} + +// @id DeleteRoleBindings +// @summary Delete role bindings +// @description Delete the provided list of role bindings. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @accept json +// @param id path int true "Environment identifier" +// @param payload body models.K8sRoleBindingDeleteRequests true "A map where the key is the namespace and the value is an array of role bindings to delete" +// @success 204 "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier or unable to find a specific role binding." +// @failure 500 "Server error occurred while attempting to delete role bindings." +// @router /kubernetes/{id}/role_bindings/delete [POST] +func (h *Handler) deleteRoleBindings(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload models.K8sRoleBindingDeleteRequests + + if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + cli, handlerErr := h.getProxyKubeClient(r) + if handlerErr != nil { + return handlerErr + } + + if err := cli.DeleteRoleBindings(payload); err != nil { + return httperror.InternalServerError("Failed to delete role bindings", err) + } + + return response.Empty(w) +} diff --git a/api/http/handler/kubernetes/roles.go b/api/http/handler/kubernetes/roles.go new file mode 100644 index 0000000..65ae353 --- /dev/null +++ b/api/http/handler/kubernetes/roles.go @@ -0,0 +1,78 @@ +package kubernetes + +import ( + "net/http" + + models "github.com/portainer/portainer/api/http/models/kubernetes" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" + "github.com/rs/zerolog/log" +) + +// @id GetKubernetesRoles +// @summary Get a list of kubernetes roles +// @description Get a list of kubernetes roles that the user has access to. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment identifier" +// @success 200 {array} kubernetes.K8sRole "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier." +// @failure 500 "Server error occurred while attempting to retrieve the list of roles." +// @router /kubernetes/{id}/roles [get] +func (handler *Handler) getAllKubernetesRoles(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + cli, httpErr := handler.prepareKubeClient(r) + if httpErr != nil { + log.Error().Err(httpErr).Str("context", "GetAllKubernetesRoles").Msg("Unable to prepare kube client") + return httperror.InternalServerError("unable to prepare kube client. Error: ", httpErr) + } + + roles, err := cli.GetRoles("") + if err != nil { + log.Error().Err(err).Str("context", "GetAllKubernetesRoles").Msg("Unable to fetch roles across all namespaces") + return httperror.InternalServerError("unable to fetch roles across all namespaces. Error: ", err) + } + + return response.JSON(w, roles) +} + +// @id DeleteRoles +// @summary Delete roles +// @description Delete the provided list of roles. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @accept json +// @param id path int true "Environment identifier" +// @param payload body models.K8sRoleDeleteRequests true "A map where the key is the namespace and the value is an array of roles to delete" +// @success 204 "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier or unable to find a specific role." +// @failure 500 "Server error occurred while attempting to delete roles." +// @router /kubernetes/{id}/roles/delete [POST] +func (h *Handler) deleteRoles(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload models.K8sRoleDeleteRequests + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + cli, handlerErr := h.getProxyKubeClient(r) + if handlerErr != nil { + return handlerErr + } + + err = cli.DeleteRoles(payload) + if err != nil { + return httperror.InternalServerError("Failed to delete roles", err) + } + + return response.Empty(w) +} diff --git a/api/http/handler/kubernetes/secrets.go b/api/http/handler/kubernetes/secrets.go new file mode 100644 index 0000000..782e610 --- /dev/null +++ b/api/http/handler/kubernetes/secrets.go @@ -0,0 +1,141 @@ +package kubernetes + +import ( + "net/http" + + models "github.com/portainer/portainer/api/http/models/kubernetes" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" + "github.com/rs/zerolog/log" +) + +// @id GetKubernetesSecret +// @summary Get a Secret +// @description Get a Secret by name for a given namespace. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment identifier" +// @param namespace path string true "The namespace name where the secret is located" +// @param secret path string true "The secret name to get details for" +// @success 200 {object} models.K8sSecret "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier." +// @failure 500 "Server error occurred while attempting to retrieve a secret by name belong in a namespace." +// @router /kubernetes/{id}/namespaces/{namespace}/secrets/{secret} [get] +func (handler *Handler) getKubernetesSecret(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + namespace, err := request.RetrieveRouteVariableValue(r, "namespace") + if err != nil { + log.Error().Err(err).Str("context", "GetKubernetesSecret").Str("namespace", namespace).Msg("Unable to retrieve namespace identifier route variable") + return httperror.BadRequest("unable to retrieve namespace identifier route variable. Error: ", err) + } + + secretName, err := request.RetrieveRouteVariableValue(r, "secret") + if err != nil { + log.Error().Err(err).Str("context", "GetKubernetesSecret").Str("namespace", namespace).Msg("Unable to retrieve secret identifier route variable") + return httperror.BadRequest("unable to retrieve secret identifier route variable. Error: ", err) + } + + cli, httpErr := handler.getProxyKubeClient(r) + if httpErr != nil { + log.Error().Err(httpErr).Str("context", "GetKubernetesSecret").Str("namespace", namespace).Msg("Unable to get a Kubernetes client for the user") + return httperror.InternalServerError("unable to get a Kubernetes client for the user. Error: ", httpErr) + } + + secret, err := cli.GetSecret(namespace, secretName) + if err != nil { + log.Error().Err(err).Str("context", "GetKubernetesSecret").Str("namespace", namespace).Str("secret", secretName).Msg("Unable to get secret") + return httperror.InternalServerError("unable to get secret. Error: ", err) + } + + secretWithApplication, err := cli.CombineSecretWithApplications(secret) + if err != nil { + log.Error().Err(err).Str("context", "GetKubernetesSecret").Str("namespace", namespace).Str("secret", secretName).Msg("Unable to combine secret with associated applications") + return httperror.InternalServerError("unable to combine secret with associated applications. Error: ", err) + } + + return response.JSON(w, secretWithApplication) +} + +// @id GetKubernetesSecrets +// @summary Get a list of Secrets +// @description Get a list of Secrets for a given namespace. If isUsed is set to true, information about the applications that use the secrets is also returned. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment identifier" +// @param isUsed query bool true "When set to true, associate the Secrets with the applications that use them" +// @success 200 {array} models.K8sSecret "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier." +// @failure 500 "Server error occurred while attempting to retrieve all secrets from the cluster." +// @router /kubernetes/{id}/secrets [get] +func (handler *Handler) GetAllKubernetesSecrets(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + secrets, err := handler.getAllKubernetesSecrets(r) + if err != nil { + return err + } + + return response.JSON(w, secrets) +} + +// @id GetKubernetesSecretsCount +// @summary Get Secrets count +// @description Get the count of Secrets across all namespaces that the user has access to. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment identifier" +// @success 200 {integer} integer "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier." +// @failure 500 "Server error occurred while attempting to retrieve the count of all secrets from the cluster." +// @router /kubernetes/{id}/secrets/count [get] +func (handler *Handler) getAllKubernetesSecretsCount(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + secrets, err := handler.getAllKubernetesSecrets(r) + if err != nil { + return err + } + + return response.JSON(w, len(secrets)) +} + +func (handler *Handler) getAllKubernetesSecrets(r *http.Request) ([]models.K8sSecret, *httperror.HandlerError) { + isUsed, err := request.RetrieveBooleanQueryParameter(r, "isUsed", true) + if err != nil { + log.Error().Err(err).Str("context", "GetAllKubernetesSecrets").Msg("Unable to retrieve isUsed query parameter") + return nil, httperror.BadRequest("unable to retrieve isUsed query parameter. Error: ", err) + } + + cli, httpErr := handler.prepareKubeClient(r) + if httpErr != nil { + log.Error().Err(httpErr).Str("context", "GetAllKubernetesSecrets").Msg("Unable to prepare kube client") + return nil, httperror.InternalServerError("unable to prepare kube client. Error: ", httpErr) + } + + secrets, err := cli.GetSecrets("") + if err != nil { + log.Error().Err(err).Str("context", "GetAllKubernetesSecrets").Msg("Unable to get secrets") + return nil, httperror.InternalServerError("unable to get secrets. Error: ", err) + } + + if isUsed { + err = cli.SetSecretsIsUsed(&secrets) + if err != nil { + log.Error().Err(err).Str("context", "GetAllKubernetesSecrets").Msg("Unable to combine secrets with associated applications") + return nil, httperror.InternalServerError("unable to combine secrets with associated applications. Error: ", err) + } + } + + return secrets, nil +} diff --git a/api/http/handler/kubernetes/service_accounts.go b/api/http/handler/kubernetes/service_accounts.go new file mode 100644 index 0000000..813d456 --- /dev/null +++ b/api/http/handler/kubernetes/service_accounts.go @@ -0,0 +1,172 @@ +package kubernetes + +import ( + "net/http" + + models "github.com/portainer/portainer/api/http/models/kubernetes" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" + "github.com/rs/zerolog/log" + k8serrors "k8s.io/apimachinery/pkg/api/errors" +) + +// @id GetKubernetesServiceAccounts +// @summary Get a list of kubernetes service accounts +// @description Get a list of kubernetes service accounts that the user has access to. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment identifier" +// @success 200 {array} kubernetes.K8sServiceAccount "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier." +// @failure 500 "Server error occurred while attempting to retrieve the list of service accounts." +// @router /kubernetes/{id}/serviceaccounts [get] +func (handler *Handler) getAllKubernetesServiceAccounts(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + cli, httpErr := handler.prepareKubeClient(r) + if httpErr != nil { + log.Error().Err(httpErr).Str("context", "GetAllKubernetesServiceAccounts").Msg("Unable to prepare kube client") + return httperror.InternalServerError("unable to prepare kube client. Error: ", httpErr) + } + + serviceAccounts, err := cli.GetServiceAccounts("") + if err != nil { + log.Error().Err(err).Str("context", "GetAllKubernetesServiceAccounts").Msg("Unable to fetch service accounts across all namespaces") + return httperror.InternalServerError("unable to fetch service accounts. Error: ", err) + } + + return response.JSON(w, serviceAccounts) +} + +// @id GetKubernetesServiceAccount +// @summary Get a kubernetes service account +// @description Get a kubernetes service account in the given namespace. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment identifier" +// @param namespace path string true "Namespace" +// @param name path string true "Service account name" +// @success 200 {object} models.K8sServiceAccount "Success" +// @failure 400 "Invalid request" +// @failure 401 "Unauthorized" +// @failure 403 "Permission denied" +// @failure 404 "Service account not found" +// @failure 500 "Server error" +// @router /kubernetes/{id}/namespaces/{namespace}/service_accounts/{name} [get] +func (handler *Handler) getKubernetesServiceAccount(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + namespace, err := request.RetrieveRouteVariableValue(r, "namespace") + if err != nil { + return httperror.BadRequest("Invalid namespace", err) + } + + name, err := request.RetrieveRouteVariableValue(r, "name") + if err != nil { + return httperror.BadRequest("Invalid name", err) + } + + cli, httpErr := handler.prepareKubeClient(r) + if httpErr != nil { + return httperror.InternalServerError("Unable to prepare kube client", httpErr) + } + + sa, err := cli.GetServiceAccount(namespace, name) + if err != nil { + return httperror.InternalServerError("Unable to retrieve service account", err) + } + + return response.JSON(w, sa) +} + +// @id DeleteServiceAccounts +// @summary Delete service accounts +// @description Delete the provided list of service accounts. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @accept json +// @param id path int true "Environment identifier" +// @param payload body models.K8sServiceAccountDeleteRequests true "A map where the key is the namespace and the value is an array of service accounts to delete" +// @success 204 "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier or unable to find a specific service account." +// @failure 500 "Server error occurred while attempting to delete service accounts." +// @router /kubernetes/{id}/service_accounts/delete [POST] +func (handler *Handler) deleteKubernetesServiceAccounts(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload models.K8sServiceAccountDeleteRequests + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + cli, handlerErr := handler.getProxyKubeClient(r) + if handlerErr != nil { + return handlerErr + } + + err = cli.DeleteServiceAccounts(payload) + if err != nil { + return httperror.InternalServerError("Unable to delete service accounts", err) + } + + return response.Empty(w) +} + +// @id UpdateKubernetesServiceAccountImagePullSecrets +// @summary Update image pull secrets for a service account +// @description Replace the imagePullSecrets list on a service account with the provided list. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @accept json +// @param id path int true "Environment identifier" +// @param namespace path string true "Namespace" +// @param name path string true "Service account name" +// @param payload body models.K8sServiceAccountImagePullSecretsUpdatePayload true "New imagePullSecrets list" +// @success 204 "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier, namespace, or service account." +// @failure 500 "Server error occurred while attempting to update image pull secrets for the service account." +// @router /kubernetes/{id}/namespaces/{namespace}/service_accounts/{name}/image_pull_secrets [put] +func (handler *Handler) updateKubernetesServiceAccountImagePullSecrets(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + namespace, err := request.RetrieveRouteVariableValue(r, "namespace") + if err != nil { + return httperror.BadRequest("Invalid namespace", err) + } + + name, err := request.RetrieveRouteVariableValue(r, "name") + if err != nil { + return httperror.BadRequest("Invalid name", err) + } + + var payload models.K8sServiceAccountImagePullSecretsUpdatePayload + if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + cli, handlerErr := handler.getProxyKubeClient(r) + if handlerErr != nil { + return handlerErr + } + + if err := cli.UpdateServiceAccountImagePullSecrets(namespace, name, payload.SecretNames); err != nil { + if k8serrors.IsNotFound(err) { + log.Error().Err(err).Str("context", "UpdateKubernetesServiceAccountImagePullSecrets").Msg("Unable to find service account") + return httperror.NotFound("Unable to find service account", err) + } + + log.Error().Err(err).Str("context", "UpdateKubernetesServiceAccountImagePullSecrets").Msg("Unable to update image pull secrets") + return httperror.InternalServerError("Unable to update image pull secrets", err) + } + + return response.Empty(w) +} diff --git a/api/http/handler/kubernetes/service_accounts_test.go b/api/http/handler/kubernetes/service_accounts_test.go new file mode 100644 index 0000000..e08e78c --- /dev/null +++ b/api/http/handler/kubernetes/service_accounts_test.go @@ -0,0 +1,220 @@ +package kubernetes + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/datastore" + models "github.com/portainer/portainer/api/http/models/kubernetes" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/authorization" + "github.com/portainer/portainer/api/internal/testhelpers" + "github.com/portainer/portainer/api/jwt" + "github.com/portainer/portainer/api/kubernetes" + kubeclient "github.com/portainer/portainer/api/kubernetes/cli" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newServiceAccountTestHandler(t *testing.T) (*Handler, *portainer.User, string) { + t.Helper() + + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{}`)) + })) + t.Cleanup(srv.Close) + + _, store := datastore.MustNewTestStore(t, true, true) + + err := store.Endpoint().Create(&portainer.Endpoint{ + ID: 1, + Type: portainer.AgentOnKubernetesEnvironment, + }) + require.NoError(t, err, "error creating environment") + + u := &portainer.User{Username: "admin", Role: portainer.AdministratorRole} + err = store.User().Create(u) + require.NoError(t, err, "error creating a user") + + jwtService, err := jwt.NewService("1h", store) + require.NoError(t, err, "error initiating jwt service") + + tk, _, err := jwtService.GenerateToken(&portainer.TokenData{ID: u.ID, Username: u.Username, Role: u.Role}) + require.NoError(t, err) + + kubeClusterAccessService := kubernetes.NewKubeClusterAccessService("", "", "") + + srvURL, err := url.Parse(srv.URL) + require.NoError(t, err) + + cli := testhelpers.NewKubernetesClient() + factory, err := kubeclient.NewClientFactory(nil, nil, store, "", ":"+srvURL.Port(), "") + require.NoError(t, err) + + authorizationService := authorization.NewService(store) + handler := NewHandler(testhelpers.NewTestRequestBouncer(), authorizationService, store, jwtService, kubeClusterAccessService, factory, cli) + + return handler, u, tk +} + +func newServiceAccountRequest(t *testing.T, method, path string, body []byte, u *portainer.User, tk string) *http.Request { + t.Helper() + + var req *http.Request + if body != nil { + req = httptest.NewRequest(method, path, bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + } else { + req = httptest.NewRequest(method, path, nil) + } + + ctx := security.StoreTokenData(req, &portainer.TokenData{ID: u.ID, Username: u.Username, Role: u.Role}) + req = req.WithContext(ctx) + ctx = security.StoreRestrictedRequestContext(req, &security.RestrictedRequestContext{IsAdmin: true, UserID: u.ID}) + req = req.WithContext(ctx) + testhelpers.AddTestSecurityCookie(req, tk) + + return req +} + +func TestDeleteKubernetesServiceAccounts_ValidPayload(t *testing.T) { + t.Parallel() + handler, u, tk := newServiceAccountTestHandler(t) + + payload := models.K8sServiceAccountDeleteRequests{ + "default": {"sa-1", "sa-2"}, + } + body, err := json.Marshal(payload) + require.NoError(t, err) + + req := newServiceAccountRequest(t, http.MethodPost, "/kubernetes/1/service_accounts/delete", body, u, tk) + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + assert.NotEqual(t, http.StatusBadRequest, rr.Code, "should not return bad request for valid payload") +} + +func TestDeleteKubernetesServiceAccounts_InvalidPayload(t *testing.T) { + t.Parallel() + handler, u, tk := newServiceAccountTestHandler(t) + + payload := models.K8sServiceAccountDeleteRequests{} + body, err := json.Marshal(payload) + require.NoError(t, err) + + req := newServiceAccountRequest(t, http.MethodPost, "/kubernetes/1/service_accounts/delete", body, u, tk) + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusBadRequest, rr.Code, "should return bad request for invalid payload") + bodyData, err := io.ReadAll(rr.Result().Body) + require.NoError(t, err) + assert.NotEmpty(t, string(bodyData), "should have error response body") +} + +func TestDeleteKubernetesServiceAccounts_EmptyNamespace(t *testing.T) { + t.Parallel() + handler, u, tk := newServiceAccountTestHandler(t) + + payload := models.K8sServiceAccountDeleteRequests{ + "": {"sa-1"}, + } + body, err := json.Marshal(payload) + require.NoError(t, err) + + req := newServiceAccountRequest(t, http.MethodPost, "/kubernetes/1/service_accounts/delete", body, u, tk) + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusBadRequest, rr.Code, "should return bad request for empty namespace") + bodyData, err := io.ReadAll(rr.Result().Body) + require.NoError(t, err) + assert.NotEmpty(t, string(bodyData), "should have error response body") +} + +func TestUpdateKubernetesServiceAccountImagePullSecrets_ValidPayload(t *testing.T) { + t.Parallel() + handler, u, tk := newServiceAccountTestHandler(t) + + payload := map[string][]string{"secretNames": {"secret-1", "secret-2"}} + body, err := json.Marshal(payload) + require.NoError(t, err) + + req := newServiceAccountRequest(t, http.MethodPut, "/kubernetes/1/namespaces/default/service_accounts/my-sa/image_pull_secrets", body, u, tk) + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + assert.NotEqual(t, http.StatusBadRequest, rr.Code, "should not return bad request for valid payload") +} + +func TestUpdateKubernetesServiceAccountImagePullSecrets_InvalidPayload(t *testing.T) { + t.Parallel() + handler, u, tk := newServiceAccountTestHandler(t) + + req := newServiceAccountRequest(t, http.MethodPut, "/kubernetes/1/namespaces/default/service_accounts/my-sa/image_pull_secrets", []byte("not-json"), u, tk) + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusBadRequest, rr.Code, "should return bad request for malformed JSON") +} + +func TestUpdateKubernetesServiceAccountImagePullSecrets_EmptySecretNames(t *testing.T) { + t.Parallel() + handler, u, tk := newServiceAccountTestHandler(t) + + payload := map[string][]string{"secretNames": {}} + body, err := json.Marshal(payload) + require.NoError(t, err) + + req := newServiceAccountRequest(t, http.MethodPut, "/kubernetes/1/namespaces/default/service_accounts/my-sa/image_pull_secrets", body, u, tk) + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + assert.NotEqual(t, http.StatusBadRequest, rr.Code, "empty secretNames should be valid (clears all imagePullSecrets)") +} + +func TestUpdateKubernetesServiceAccountImagePullSecrets_ReachesKubernetesLayer(t *testing.T) { + t.Parallel() + // Verifies that valid JSON passes all handler-level checks and reaches the + // Kubernetes layer. Without a live cluster the proxy client is unavailable, + // so we get 500 — not a 4xx client error. + handler, u, tk := newServiceAccountTestHandler(t) + + payload := map[string][]string{"secretNames": {"secret-1", "secret-2"}} + body, err := json.Marshal(payload) + require.NoError(t, err) + + req := newServiceAccountRequest(t, http.MethodPut, "/kubernetes/1/namespaces/default/service_accounts/my-sa/image_pull_secrets", body, u, tk) + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + assert.NotEqual(t, http.StatusBadRequest, rr.Code, "valid payload must not be rejected at the handler layer") + assert.Equal(t, http.StatusInternalServerError, rr.Code, "without a live cluster the proxy client is unavailable") +} + +func TestUpdateKubernetesServiceAccountImagePullSecrets_WrongMethodReturns404(t *testing.T) { + t.Parallel() + handler, u, tk := newServiceAccountTestHandler(t) + + req := newServiceAccountRequest(t, http.MethodGet, "/kubernetes/1/namespaces/default/service_accounts/my-sa/image_pull_secrets", nil, u, tk) + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + // Gorilla mux returns 404 (not 405) for method mismatches when no MethodNotAllowedHandler is set + assert.Equal(t, http.StatusNotFound, rr.Code, "unregistered method on this route returns 404") +} diff --git a/api/http/handler/kubernetes/services.go b/api/http/handler/kubernetes/services.go new file mode 100644 index 0000000..0274476 --- /dev/null +++ b/api/http/handler/kubernetes/services.go @@ -0,0 +1,305 @@ +package kubernetes + +import ( + "net/http" + + models "github.com/portainer/portainer/api/http/models/kubernetes" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" + "github.com/rs/zerolog/log" + k8serrors "k8s.io/apimachinery/pkg/api/errors" +) + +// @id GetKubernetesServices +// @summary Get a list of services +// @description Get a list of services that the user has access to. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment identifier" +// @param withApplications query boolean false "Lookup applications associated with each service" +// @success 200 {array} models.K8sServiceInfo "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier." +// @failure 500 "Server error occurred while attempting to retrieve all services." +// @router /kubernetes/{id}/services [get] +func (handler *Handler) GetAllKubernetesServices(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + services, err := handler.getAllKubernetesServices(r) + if err != nil { + return err + } + + return response.JSON(w, services) +} + +// @id GetAllKubernetesServicesCount +// @summary Get services count +// @description Get the count of services that the user has access to. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment identifier" +// @success 200 {integer} integer "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier." +// @failure 500 "Server error occurred while attempting to retrieve the total count of all services." +// @router /kubernetes/{id}/services/count [get] +func (handler *Handler) getAllKubernetesServicesCount(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + services, err := handler.getAllKubernetesServices(r) + if err != nil { + return err + } + + return response.JSON(w, len(services)) +} + +func (handler *Handler) getAllKubernetesServices(r *http.Request) ([]models.K8sServiceInfo, *httperror.HandlerError) { + withApplications, err := request.RetrieveBooleanQueryParameter(r, "withApplications", true) + if err != nil { + log.Error().Err(err).Str("context", "GetAllKubernetesServices").Msg("Unable to retrieve withApplications identifier") + return nil, httperror.BadRequest("unable to retrieve withApplications query parameter. Error: ", err) + } + + cli, httpErr := handler.prepareKubeClient(r) + if httpErr != nil { + log.Error().Err(httpErr).Str("context", "GetAllKubernetesServices").Msg("Unable to get a Kubernetes client for the user") + return nil, httperror.InternalServerError("unable to get a Kubernetes client for the user. Error: ", httpErr) + } + + services, err := cli.GetServices("") + if err != nil { + if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) { + log.Error().Err(err).Str("context", "GetAllKubernetesServices").Msg("Unauthorized access to the Kubernetes API") + return nil, httperror.Forbidden("unauthorized access to the Kubernetes API. Error: ", err) + } + + log.Error().Err(err).Str("context", "GetAllKubernetesServices").Msg("Unable to retrieve services from the Kubernetes for a cluster level user") + return nil, httperror.InternalServerError("unable to retrieve services from the Kubernetes for a cluster level user. Error: ", err) + } + + if withApplications && len(services) > 0 { + servicesWithApplications, err := cli.CombineServicesWithApplications(services) + if err != nil { + log.Error().Err(err).Str("context", "GetAllKubernetesServices").Msg("Unable to combine services with applications") + return nil, httperror.InternalServerError("unable to combine services with applications. Error: ", err) + } + + return servicesWithApplications, nil + } + + return services, nil +} + +// @id GetKubernetesServicesByNamespace +// @summary Get a list of services for a given namespace +// @description Get a list of services for a given namespace. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment identifier" +// @param namespace path string true "Namespace name" +// @success 200 {array} models.K8sServiceInfo "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier." +// @failure 500 "Server error occurred while attempting to retrieve all services for a namespace." +// @router /kubernetes/{id}/namespaces/{namespace}/services [get] +func (handler *Handler) getKubernetesServicesByNamespace(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + namespace, err := request.RetrieveRouteVariableValue(r, "namespace") + if err != nil { + log.Error().Err(err).Str("context", "GetKubernetesServicesByNamespace").Str("namespace", namespace).Msg("Unable to retrieve namespace identifier route variable") + return httperror.BadRequest("unable to retrieve namespace identifier route variable. Error: ", err) + } + + cli, httpError := handler.getProxyKubeClient(r) + if httpError != nil { + return httpError + } + + services, err := cli.GetServices(namespace) + if err != nil { + if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) { + log.Error().Err(err).Str("context", "GetKubernetesServicesByNamespace").Str("namespace", namespace).Msg("Unauthorized access to the Kubernetes API") + return httperror.Forbidden("unauthorized access to the Kubernetes API. Error: ", err) + } + + log.Error().Err(err).Str("context", "GetKubernetesServicesByNamespace").Str("namespace", namespace).Msg("Unable to retrieve services from the Kubernetes for a namespace level user") + return httperror.InternalServerError("unable to retrieve services from the Kubernetes for a namespace level user. Error: ", err) + } + + return response.JSON(w, services) +} + +// @id CreateKubernetesService +// @summary Create a service +// @description Create a service within a given namespace +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @accept json +// @produce json +// @param id path int true "Environment identifier" +// @param namespace path string true "Namespace name" +// @param body body models.K8sServiceInfo true "Service definition" +// @success 204 "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier." +// @failure 500 "Server error occurred while attempting to create a service." +// @router /kubernetes/{id}/namespaces/{namespace}/services [post] +func (handler *Handler) createKubernetesService(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + namespace, err := request.RetrieveRouteVariableValue(r, "namespace") + if err != nil { + log.Error().Err(err).Str("context", "CreateKubernetesService").Str("namespace", namespace).Msg("Unable to retrieve namespace identifier route variable") + return httperror.BadRequest("unable to retrieve namespace identifier route variable. Error: ", err) + } + + var payload models.K8sServiceInfo + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + log.Error().Err(err).Str("context", "CreateKubernetesService").Str("namespace", namespace).Msg("Unable to decode and validate the request payload") + return httperror.BadRequest("unable to decode and validate the request payload. Error: ", err) + } + + serviceName := payload.Name + cli, httpError := handler.getProxyKubeClient(r) + if httpError != nil { + log.Error().Err(httpError).Str("context", "CreateKubernetesService").Str("namespace", namespace).Str("service", serviceName).Msg("Unable to get a Kubernetes client for the user") + return httperror.InternalServerError("unable to get a Kubernetes client for the user. Error: ", httpError) + } + + err = cli.CreateService(namespace, payload) + if err != nil { + if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) { + log.Error().Err(err).Str("context", "CreateKubernetesService").Str("namespace", namespace).Str("service", serviceName).Msg("Unauthorized access to the Kubernetes API") + return httperror.Forbidden("unauthorized access to the Kubernetes API. Error: ", err) + } + + if k8serrors.IsAlreadyExists(err) { + log.Error().Err(err).Str("context", "CreateKubernetesService").Str("namespace", namespace).Str("service", serviceName).Msg("A service with the same name already exists in the namespace") + return httperror.Conflict("a service with the same name already exists in the namespace. Error: ", err) + } + + log.Error().Err(err).Str("context", "CreateKubernetesService").Str("namespace", namespace).Str("service", serviceName).Msg("Unable to create a service") + return httperror.InternalServerError("unable to create a service. Error: ", err) + } + + return response.Empty(w) +} + +// @id DeleteKubernetesServices +// @summary Delete services +// @description Delete the provided list of services. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @accept json +// @produce json +// @param id path int true "Environment identifier" +// @param body body models.K8sServiceDeleteRequests true "A map where the key is the namespace and the value is an array of services to delete" +// @success 204 "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier or unable to find a specific service." +// @failure 500 "Server error occurred while attempting to delete services." +// @router /kubernetes/{id}/services/delete [post] +func (handler *Handler) deleteKubernetesServices(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + payload := models.K8sServiceDeleteRequests{} + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + log.Error().Err(err).Str("context", "DeleteKubernetesServices").Msg("Unable to decode and validate the request payload") + return httperror.BadRequest("unable to decode and validate the request payload. Error: ", err) + } + + cli, httpError := handler.getProxyKubeClient(r) + if httpError != nil { + return httpError + } + + err = cli.DeleteServices(payload) + if err != nil { + if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) { + log.Error().Err(err).Str("context", "DeleteKubernetesServices").Msg("Unauthorized access to the Kubernetes API") + return httperror.Forbidden("unauthorized access to the Kubernetes API. Error: ", err) + } + + if k8serrors.IsNotFound(err) { + log.Error().Err(err).Str("context", "DeleteKubernetesServices").Msg("Unable to find the services to delete") + return httperror.NotFound("unable to find the services to delete. Error: ", err) + } + + log.Error().Err(err).Str("context", "DeleteKubernetesServices").Msg("Unable to delete services") + return httperror.InternalServerError("unable to delete services. Error: ", err) + } + + return response.Empty(w) +} + +// @id UpdateKubernetesService +// @summary Update a service +// @description Update a service within a given namespace. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @accept json +// @produce json +// @param id path int true "Environment identifier" +// @param namespace path string true "Namespace name" +// @param body body models.K8sServiceInfo true "Service definition" +// @success 204 "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier or unable to find the service to update." +// @failure 500 "Server error occurred while attempting to update a service." +// @router /kubernetes/{id}/namespaces/{namespace}/services [put] +func (handler *Handler) updateKubernetesService(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + namespace, err := request.RetrieveRouteVariableValue(r, "namespace") + if err != nil { + log.Error().Err(err).Str("context", "UpdateKubernetesService").Str("namespace", namespace).Msg("Unable to retrieve namespace identifier route variable") + return httperror.BadRequest("unable to retrieve namespace identifier route variable. Error: ", err) + } + + var payload models.K8sServiceInfo + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + log.Error().Err(err).Str("context", "UpdateKubernetesService").Str("namespace", namespace).Msg("Unable to decode and validate the request payload") + return httperror.BadRequest("unable to decode and validate the request payload. Error: ", err) + } + + serviceName := payload.Name + cli, httpError := handler.getProxyKubeClient(r) + if httpError != nil { + log.Error().Err(httpError).Str("context", "UpdateKubernetesService").Str("namespace", namespace).Str("service", serviceName).Msg("Unable to get a Kubernetes client for the user") + return httperror.InternalServerError("unable to get a Kubernetes client for the user. Error: ", httpError) + } + + err = cli.UpdateService(namespace, payload) + if err != nil { + if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) { + log.Error().Err(err).Str("context", "UpdateKubernetesService").Str("namespace", namespace).Str("service", serviceName).Msg("Unauthorized access to the Kubernetes API") + return httperror.Forbidden("unauthorized access to the Kubernetes API. Error: ", err) + } + + if k8serrors.IsNotFound(err) { + log.Error().Err(err).Str("context", "UpdateKubernetesService").Str("namespace", namespace).Str("service", serviceName).Msg("Unable to find the service to update") + return httperror.NotFound("unable to find the service to update. Error: ", err) + } + + log.Error().Err(err).Str("context", "UpdateKubernetesService").Str("namespace", namespace).Str("service", serviceName).Msg("Unable to update a service") + return httperror.InternalServerError("unable to update a service. Error: ", err) + } + + return nil +} diff --git a/api/http/handler/kubernetes/storage_classes.go b/api/http/handler/kubernetes/storage_classes.go new file mode 100644 index 0000000..14ed920 --- /dev/null +++ b/api/http/handler/kubernetes/storage_classes.go @@ -0,0 +1,177 @@ +package kubernetes + +import ( + "net/http" + + models "github.com/portainer/portainer/api/http/models/kubernetes" + "github.com/rs/zerolog/log" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +// @id GetAllKubernetesStorageClasses +// @summary Get all StorageClasses +// @description Get a list of all StorageClasses in the given environment. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment identifier" +// @success 200 {array} models.K8sStorageClass "Success" +// @failure 400 "Invalid request payload." +// @failure 403 "Unauthorized access or operation not allowed." +// @failure 500 "Server error occurred while attempting to retrieve storage classes." +// @router /kubernetes/{id}/storage_classes [get] +func (handler *Handler) getAllKubernetesStorageClasses(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + cli, httpErr := handler.prepareKubeClient(r) + if httpErr != nil { + log.Error().Err(httpErr).Str("context", "GetAllKubernetesStorageClasses").Msg("Unable to get Kubernetes client") + return httpErr + } + + storageClasses, err := cli.GetStorageClasses() + if err != nil { + if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) { + return httperror.Forbidden("unauthorized access to storage classes", err) + } + + log.Error().Err(err).Str("context", "GetAllKubernetesStorageClasses").Msg("Failed to retrieve storage classes") + return httperror.InternalServerError("failed to retrieve storage classes", err) + } + + return response.JSON(w, storageClasses) +} + +// @id GetKubernetesStorageClass +// @summary Get a specific StorageClass +// @description Get a StorageClass by name in the given environment. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment identifier" +// @param name path string true "StorageClass name" +// @success 200 {object} models.K8sStorageClass "Success" +// @failure 400 "Invalid request payload." +// @failure 403 "Unauthorized access or operation not allowed." +// @failure 404 "StorageClass not found." +// @failure 500 "Server error occurred while attempting to retrieve the storage class." +// @router /kubernetes/{id}/storage_classes/{name} [get] +func (handler *Handler) getKubernetesStorageClass(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + name, err := request.RetrieveRouteVariableValue(r, "name") + if err != nil { + return httperror.BadRequest("invalid storage class name", err) + } + + cli, httpErr := handler.prepareKubeClient(r) + if httpErr != nil { + log.Error().Err(httpErr).Str("context", "GetKubernetesStorageClass").Msg("Unable to get Kubernetes client") + return httpErr + } + + sc, err := cli.GetStorageClass(name) + if err != nil { + if k8serrors.IsNotFound(err) { + return httperror.NotFound("storage class not found", err) + } + + if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) { + return httperror.Forbidden("unauthorized access to the Kubernetes API", err) + } + + log.Error().Err(err).Str("context", "GetKubernetesStorageClass").Str("name", name).Msg("Failed to retrieve storage class") + return httperror.InternalServerError("failed to retrieve storage class", err) + } + + return response.JSON(w, sc) +} + +// @id DeleteKubernetesStorageClasses +// @summary Delete StorageClasses +// @description Delete the provided list of StorageClasses. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @accept json +// @produce json +// @param id path int true "Environment identifier" +// @param body body models.K8sStorageClassDeleteRequest true "List of StorageClass names to delete" +// @success 204 "Success" +// @failure 400 "Invalid request payload." +// @failure 403 "Unauthorized access or operation not allowed." +// @failure 500 "Server error occurred while attempting to delete storage classes." +// @router /kubernetes/{id}/storage_classes/delete [post] +func (handler *Handler) deleteKubernetesStorageClasses(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload models.K8sStorageClassDeleteRequest + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return httperror.BadRequest("unable to decode and validate the request payload", err) + } + + cli, httpErr := handler.getProxyKubeClient(r) + if httpErr != nil { + return httpErr + } + + err = cli.DeleteStorageClasses(payload) + if err != nil { + if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) { + return httperror.Forbidden("unauthorized access to the Kubernetes API", err) + } + + if k8serrors.IsNotFound(err) { + return httperror.NotFound("unable to find the storage classes to delete", err) + } + + log.Error().Err(err).Str("context", "DeleteKubernetesStorageClasses").Msg("Unable to delete storage classes") + return httperror.InternalServerError("unable to delete storage classes", err) + } + + return response.Empty(w) +} + +// @id SetDefaultKubernetesStorageClass +// @summary Set a StorageClass as default +// @description Set the specified StorageClass as the cluster default, removing default from any other. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @accept json +// @produce json +// @param id path int true "Environment identifier" +// @param name path string true "StorageClass name" +// @success 204 "Success" +// @failure 400 "Invalid request payload." +// @failure 403 "Unauthorized access or operation not allowed." +// @failure 500 "Server error occurred while attempting to set default storage class." +// @router /kubernetes/{id}/storage_classes/{name}/default [put] +func (handler *Handler) setDefaultKubernetesStorageClass(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + name, err := request.RetrieveRouteVariableValue(r, "name") + if err != nil { + return httperror.BadRequest("invalid storage class name", err) + } + + cli, httpErr := handler.getProxyKubeClient(r) + if httpErr != nil { + return httpErr + } + + err = cli.SetDefaultStorageClass(name) + if err != nil { + if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) { + return httperror.Forbidden("unauthorized access to the Kubernetes API", err) + } + + if k8serrors.IsNotFound(err) { + return httperror.NotFound("storage class not found", err) + } + + log.Error().Err(err).Str("context", "SetDefaultKubernetesStorageClass").Str("name", name).Msg("Unable to set default storage class") + return httperror.InternalServerError("unable to set default storage class", err) + } + + return response.Empty(w) +} diff --git a/api/http/handler/kubernetes/toggle_system.go b/api/http/handler/kubernetes/toggle_system.go new file mode 100644 index 0000000..ad7d19d --- /dev/null +++ b/api/http/handler/kubernetes/toggle_system.go @@ -0,0 +1,67 @@ +package kubernetes + +import ( + "net/http" + + "github.com/portainer/portainer/api/http/middlewares" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +type namespacesToggleSystemPayload struct { + // Toggle the system state of this namespace to true or false + System bool `example:"true"` +} + +func (payload *namespacesToggleSystemPayload) Validate(r *http.Request) error { + return nil +} + +// @id KubernetesNamespacesToggleSystem +// @summary Toggle the system state for a namespace +// @description Toggle the system state for a namespace +// @description **Access policy**: Administrator or environment administrator. +// @security ApiKeyAuth || jwt +// @tags kubernetes +// @accept json +// @param id path int true "Environment identifier" +// @param namespace path string true "Namespace name" +// @param body body namespacesToggleSystemPayload true "Update details" +// @success 204 "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 404 "Unable to find an environment with the specified identifier or unable to find the namespace to update." +// @failure 500 "Server error occurred while attempting to update the system state of the namespace." +// @router /kubernetes/{id}/namespaces/{namespace}/system [put] +func (handler *Handler) namespacesToggleSystem(rw http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpoint, err := middlewares.FetchEndpoint(r) + if err != nil { + return httperror.NotFound("Unable to find an environment on request context", err) + } + + namespaceName, err := request.RetrieveRouteVariableValue(r, "namespace") + if err != nil { + return httperror.BadRequest("Invalid namespace identifier route variable", err) + } + + var payload namespacesToggleSystemPayload + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + kubeClient, err := handler.KubernetesClientFactory.GetPrivilegedKubeClient(endpoint) + if err != nil { + return httperror.InternalServerError("Unable to create kubernetes client", err) + } + + err = kubeClient.ToggleSystemState(namespaceName, payload.System) + if err != nil { + return httperror.InternalServerError("Unable to toggle system status", err) + } + + return response.Empty(rw) + +} diff --git a/api/http/handler/kubernetes/version.go b/api/http/handler/kubernetes/version.go new file mode 100644 index 0000000..a619ac7 --- /dev/null +++ b/api/http/handler/kubernetes/version.go @@ -0,0 +1,74 @@ +package kubernetes + +import ( + "net/http" + + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/response" + "github.com/rs/zerolog/log" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/version" +) + +// kubernetesVersionResponse is the shape returned by GetKubernetesVersion. +// It augments the standard Kubernetes /version payload with capability +// flags computed by Portainer (e.g. whether the API server registers the +// 1.35 alpha pod-restart subresource) so the UI can gate features without +// a second round-trip. +type kubernetesVersionResponse struct { + *version.Info + // SupportsPodRestart is true when the cluster exposes the `pods/restart` + // subresource via API discovery — i.e. the feature gate is enabled and + // the cluster version is recent enough. This is the authoritative + // signal for whether Portainer can call the pod-restart endpoint, and + // is preferred over a raw Kubernetes-version comparison. + SupportsPodRestart bool `json:"supportsPodRestart"` +} + +// @id GetKubernetesVersion +// @summary Get the Kubernetes cluster version and Portainer-relevant capabilities +// @description Get the Kubernetes cluster version (major, minor, gitVersion, ...) +// @description as reported by the cluster's discovery API, augmented with capability +// @description flags Portainer uses to gate UI features (e.g. supportsPodRestart). +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment(Endpoint) identifier" +// @success 200 {object} kubernetesVersionResponse "Success" +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation." +// @failure 404 "Unable to find an environment with the specified identifier." +// @failure 500 "Server error occurred while attempting to retrieve the cluster version." +// @router /kubernetes/{id}/version [get] +func (handler *Handler) getKubernetesVersion(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + cli, httpErr := handler.prepareKubeClient(r) + if httpErr != nil { + log.Error().Err(httpErr).Str("context", "GetKubernetesVersion").Msg("Unable to get a Kubernetes client for the user") + return httperror.InternalServerError("Unable to get a Kubernetes client for the user", httpErr) + } + + info, err := cli.ServerVersion() + if err != nil { + if k8serrors.IsForbidden(err) { + log.Error().Err(err).Str("context", "GetKubernetesVersion").Msg("Permission denied to retrieve cluster version") + return httperror.Forbidden("Permission denied to retrieve cluster version", err) + } + log.Error().Err(err).Str("context", "GetKubernetesVersion").Msg("Unable to retrieve cluster version") + return httperror.InternalServerError("Unable to retrieve cluster version", err) + } + + // A discovery failure shouldn't fail the whole request — the cluster + // version is still useful to the caller. We log it and treat the + // capability as unsupported (safe default that hides the action). + supportsPodRestart, err := cli.SupportsPodRestart(r.Context()) + if err != nil { + log.Warn().Err(err).Str("context", "GetKubernetesVersion").Msg("Unable to probe pod-restart subresource via API discovery; assuming unsupported") + supportsPodRestart = false + } + + return response.JSON(w, kubernetesVersionResponse{ + Info: info, + SupportsPodRestart: supportsPodRestart, + }) +} diff --git a/api/http/handler/kubernetes/version_test.go b/api/http/handler/kubernetes/version_test.go new file mode 100644 index 0000000..edb1d55 --- /dev/null +++ b/api/http/handler/kubernetes/version_test.go @@ -0,0 +1,115 @@ +package kubernetes + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/authorization" + "github.com/portainer/portainer/api/internal/testhelpers" + "github.com/portainer/portainer/api/jwt" + "github.com/portainer/portainer/api/kubernetes" + kubeclient "github.com/portainer/portainer/api/kubernetes/cli" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newVersionTestHandler(t *testing.T) (*Handler, *portainer.User, string) { + t.Helper() + + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{}`)) + })) + t.Cleanup(srv.Close) + + _, store := datastore.MustNewTestStore(t, true, true) + + // KubernetesLocalEnvironment avoids the nil signatureService panic that + // AgentOnKubernetesEnvironment triggers via buildAgentConfig. + err := store.Endpoint().Create(&portainer.Endpoint{ + ID: 1, + Type: portainer.KubernetesLocalEnvironment, + }) + require.NoError(t, err) + + u := &portainer.User{Username: "admin", Role: portainer.AdministratorRole} + err = store.User().Create(u) + require.NoError(t, err) + + jwtService, err := jwt.NewService("1h", store) + require.NoError(t, err) + + tk, _, err := jwtService.GenerateToken(&portainer.TokenData{ID: u.ID, Username: u.Username, Role: u.Role}) + require.NoError(t, err) + + kubeClusterAccessService := kubernetes.NewKubeClusterAccessService("", "", "") + + srvURL, err := url.Parse(srv.URL) + require.NoError(t, err) + + cli := testhelpers.NewKubernetesClient() + factory, err := kubeclient.NewClientFactory(nil, nil, store, "", ":"+srvURL.Port(), "") + require.NoError(t, err) + + authorizationService := authorization.NewService(store) + handler := NewHandler(testhelpers.NewTestRequestBouncer(), authorizationService, store, jwtService, kubeClusterAccessService, factory, cli) + + return handler, u, tk +} + +func newVersionRequest(t *testing.T, method, path string, u *portainer.User, tk string) *http.Request { + t.Helper() + + req := httptest.NewRequest(method, path, nil) + ctx := security.StoreTokenData(req, &portainer.TokenData{ID: u.ID, Username: u.Username, Role: u.Role}) + req = req.WithContext(ctx) + ctx = security.StoreRestrictedRequestContext(req, &security.RestrictedRequestContext{IsAdmin: true, UserID: u.ID}) + req = req.WithContext(ctx) + testhelpers.AddTestSecurityCookie(req, tk) + return req +} + +func TestGetKubernetesVersion_ReachesKubernetesLayer(t *testing.T) { + t.Parallel() + handler, u, tk := newVersionTestHandler(t) + + req := newVersionRequest(t, http.MethodGet, "/kubernetes/1/version", u, tk) + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + // A valid GET must not be rejected at the handler layer. Without a live + // cluster the privileged kube client cannot be created (500), not a 4xx. + assert.NotEqual(t, http.StatusBadRequest, rr.Code, "should not be rejected at the handler layer") + assert.NotEqual(t, http.StatusNotFound, rr.Code, "route must be registered") + assert.Equal(t, http.StatusInternalServerError, rr.Code, "without a live cluster the kube client is unavailable") +} + +func TestGetKubernetesVersion_WrongMethodReturns404(t *testing.T) { + t.Parallel() + handler, u, tk := newVersionTestHandler(t) + + req := newVersionRequest(t, http.MethodPost, "/kubernetes/1/version", u, tk) + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusNotFound, rr.Code) +} + +func TestGetKubernetesVersion_DeleteMethodReturns404(t *testing.T) { + t.Parallel() + handler, u, tk := newVersionTestHandler(t) + + req := newVersionRequest(t, http.MethodDelete, "/kubernetes/1/version", u, tk) + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusNotFound, rr.Code) +} diff --git a/api/http/handler/kubernetes/volumes.go b/api/http/handler/kubernetes/volumes.go new file mode 100644 index 0000000..70163f7 --- /dev/null +++ b/api/http/handler/kubernetes/volumes.go @@ -0,0 +1,177 @@ +package kubernetes + +import ( + "net/http" + + models "github.com/portainer/portainer/api/http/models/kubernetes" + "github.com/rs/zerolog/log" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +// @id GetAllKubernetesVolumes +// @summary Get Kubernetes volumes within the given Portainer environment +// @description Get a list of all kubernetes volumes within the given environment (Endpoint). The Endpoint ID must be a valid Portainer environment identifier. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment identifier" +// @param withApplications query boolean false "When set to True, include the applications that are using the volumes. It is set to false by default" +// @success 200 {object} map[string]kubernetes.K8sVolumeInfo "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 403 "Unauthorized access or operation not allowed." +// @failure 500 "Server error occurred while attempting to retrieve kubernetes volumes." +// @router /kubernetes/{id}/volumes [get] +func (handler *Handler) GetAllKubernetesVolumes(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + volumes, err := handler.getKubernetesVolumes(r, "") + if err != nil { + return err + } + + return response.JSON(w, volumes) +} + +// @id getAllKubernetesVolumesCount +// @summary Get the total number of kubernetes volumes within the given Portainer environment. +// @description Get the total number of kubernetes volumes within the given environment (Endpoint). The total count depends on the user's role and permissions. The Endpoint ID must be a valid Portainer environment identifier. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment identifier" +// @success 200 {integer} integer "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 403 "Unauthorized access or operation not allowed." +// @failure 500 "Server error occurred while attempting to retrieve kubernetes volumes count." +// @router /kubernetes/{id}/volumes/count [get] +func (handler *Handler) getAllKubernetesVolumesCount(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + volumes, err := handler.getKubernetesVolumes(r, "") + if err != nil { + return err + } + + return response.JSON(w, len(volumes)) +} + +// @id GetKubernetesVolumesInNamespace +// @summary Get Kubernetes volumes within a namespace in the given Portainer environment +// @description Get a list of kubernetes volumes within the specified namespace in the given environment (Endpoint). The Endpoint ID must be a valid Portainer environment identifier. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment identifier" +// @param namespace path string true "Namespace identifier" +// @param withApplications query boolean false "When set to True, include the applications that are using the volumes. It is set to false by default" +// @success 200 {object} map[string]kubernetes.K8sVolumeInfo "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 403 "Unauthorized access or operation not allowed." +// @failure 500 "Server error occurred while attempting to retrieve kubernetes volumes in the namespace." +// @router /kubernetes/{id}/namespaces/{namespace}/volumes [get] +func (handler *Handler) GetKubernetesVolumesInNamespace(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + namespace, err := request.RetrieveRouteVariableValue(r, "namespace") + if err != nil { + log.Error().Err(err).Str("context", "GetKubernetesVolumesInNamespace").Msg("Unable to retrieve namespace identifier") + return httperror.BadRequest("Invalid namespace identifier", err) + } + + volumes, httpErr := handler.getKubernetesVolumes(r, namespace) + if httpErr != nil { + return httpErr + } + + return response.JSON(w, volumes) +} + +// @id GetKubernetesVolume +// @summary Get a Kubernetes volume within the given Portainer environment +// @description Get a Kubernetes volume within the given environment (Endpoint). The Endpoint ID must be a valid Portainer environment identifier. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment identifier" +// @param namespace path string true "Namespace identifier" +// @param volume path string true "Volume name" +// @success 200 {object} kubernetes.K8sVolumeInfo "Success" +// @failure 400 "Invalid request" +// @failure 500 "Server error" +// @router /kubernetes/{id}/volumes/{namespace}/{volume} [get] +func (handler *Handler) getKubernetesVolume(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + namespace, err := request.RetrieveRouteVariableValue(r, "namespace") + if err != nil { + log.Error().Err(err).Str("context", "GetKubernetesVolume").Msg("Unable to retrieve namespace identifier") + return httperror.BadRequest("Invalid namespace identifier", err) + } + + volumeName, err := request.RetrieveRouteVariableValue(r, "volume") + if err != nil { + log.Error().Err(err).Str("context", "GetKubernetesVolume").Msg("Unable to retrieve volume name") + return httperror.BadRequest("Invalid volume name", err) + } + + cli, httpErr := handler.prepareKubeClient(r) + if httpErr != nil { + log.Error().Err(httpErr).Str("context", "GetKubernetesVolume").Msg("Unable to get Kubernetes client") + return httperror.InternalServerError("Failed to prepare Kubernetes client", httpErr) + } + + volume, err := cli.GetVolume(namespace, volumeName) + if err != nil { + if k8serrors.IsUnauthorized(err) { + log.Error().Err(err).Str("context", "GetKubernetesVolume").Str("namespace", namespace).Str("volume", volumeName).Msg("Unauthorized access") + return httperror.Unauthorized("Unauthorized access to volume", err) + } + + if k8serrors.IsNotFound(err) { + log.Error().Err(err).Str("context", "GetKubernetesVolume").Str("namespace", namespace).Str("volume", volumeName).Msg("Volume not found") + return httperror.NotFound("Volume not found", err) + } + + log.Error().Err(err).Str("context", "GetKubernetesVolume").Str("namespace", namespace).Str("volume", volumeName).Msg("Failed to retrieve volume") + return httperror.InternalServerError("Failed to retrieve volume", err) + } + + return response.JSON(w, volume) +} + +func (handler *Handler) getKubernetesVolumes(r *http.Request, namespace string) ([]models.K8sVolumeInfo, *httperror.HandlerError) { + withApplications, err := request.RetrieveBooleanQueryParameter(r, "withApplications", true) + if err != nil { + log.Error().Err(err).Str("context", "GetKubernetesVolumes").Bool("withApplications", withApplications).Msg("Unable to parse query parameter") + return nil, httperror.BadRequest("Invalid 'withApplications' parameter", err) + } + + cli, httpErr := handler.prepareKubeClient(r) + if httpErr != nil { + log.Error().Err(httpErr).Str("context", "GetKubernetesVolumes").Msg("Unable to get Kubernetes client") + return nil, httperror.InternalServerError("Failed to prepare Kubernetes client", httpErr) + } + + volumes, err := cli.GetVolumes(namespace) + if err != nil { + if k8serrors.IsUnauthorized(err) { + log.Error().Err(err).Str("context", "GetKubernetesVolumes").Msg("Unauthorized access") + return nil, httperror.Unauthorized("Unauthorized access to volumes", err) + } + + log.Error().Err(err).Str("context", "GetKubernetesVolumes").Msg("Failed to retrieve volumes") + return nil, httperror.InternalServerError("Failed to retrieve volumes", err) + } + + if withApplications { + volumesWithApplications, err := cli.CombineVolumesWithApplications(&volumes) + if err != nil { + log.Error().Err(err).Str("context", "GetKubernetesVolumes").Msg("Failed to combine volumes with applications") + return nil, httperror.InternalServerError("Failed to combine volumes with applications", err) + } + + return *volumesWithApplications, nil + } + + return volumes, nil +} diff --git a/api/http/handler/ldap/handler.go b/api/http/handler/ldap/handler.go new file mode 100644 index 0000000..6b40783 --- /dev/null +++ b/api/http/handler/ldap/handler.go @@ -0,0 +1,55 @@ +package ldap + +import ( + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/filesystem" + "github.com/portainer/portainer/api/http/security" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + + "github.com/gorilla/mux" +) + +// Handler is the HTTP handler used to handle LDAP search Operations +type Handler struct { + *mux.Router + DataStore dataservices.DataStore + FileService portainer.FileService + LDAPService portainer.LDAPService +} + +// NewHandler returns a new Handler +func NewHandler(bouncer security.BouncerService) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + } + + h.Handle("/ldap/check", + bouncer.AdminAccess(httperror.LoggerHandler(h.ldapCheck))).Methods(http.MethodPost) + + return h +} + +func (handler *Handler) prefillSettings(ldapSettings *portainer.LDAPSettings) error { + if !ldapSettings.AnonymousMode && ldapSettings.Password == "" { + settings, err := handler.DataStore.Settings().Settings() + if err != nil { + return err + } + + ldapSettings.Password = settings.LDAPSettings.Password + } + + if (ldapSettings.TLSConfig.TLS || ldapSettings.StartTLS) && !ldapSettings.TLSConfig.TLSSkipVerify { + caCertPath, err := handler.FileService.GetPathForTLSFile(filesystem.LDAPStorePath, portainer.TLSFileCA) + if err != nil { + return err + } + + ldapSettings.TLSConfig.TLSCACertPath = caCertPath + } + + return nil +} diff --git a/api/http/handler/ldap/ldap_check.go b/api/http/handler/ldap/ldap_check.go new file mode 100644 index 0000000..cbee185 --- /dev/null +++ b/api/http/handler/ldap/ldap_check.go @@ -0,0 +1,53 @@ +package ldap + +import ( + "net/http" + + portainer "github.com/portainer/portainer/api" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +type checkPayload struct { + LDAPSettings portainer.LDAPSettings +} + +func (payload *checkPayload) Validate(r *http.Request) error { + return nil +} + +// @id LDAPCheck +// @summary Test LDAP connectivity +// @description Test LDAP connectivity using LDAP details +// @description **Access policy**: administrator +// @tags ldap +// @security ApiKeyAuth +// @security jwt +// @accept json +// @param body body checkPayload true "details" +// @success 204 "Success" +// @failure 400 "Invalid request" +// @failure 500 "Server error" +// @router /ldap/check [post] +func (handler *Handler) ldapCheck(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload checkPayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + settings := &payload.LDAPSettings + + err = handler.prefillSettings(settings) + if err != nil { + return httperror.InternalServerError("Unable to fetch default settings", err) + } + + err = handler.LDAPService.TestConnectivity(settings) + if err != nil { + return httperror.InternalServerError("Unable to connect to LDAP server", err) + } + + return response.Empty(w) +} diff --git a/api/http/handler/motd/handler.go b/api/http/handler/motd/handler.go new file mode 100644 index 0000000..bf04306 --- /dev/null +++ b/api/http/handler/motd/handler.go @@ -0,0 +1,27 @@ +package motd + +import ( + "net/http" + + "github.com/gorilla/mux" + "github.com/portainer/portainer/api/http/security" + motdservice "github.com/portainer/portainer/api/motd" +) + +// Handler is the HTTP handler used to handle MOTD operations. +type Handler struct { + *mux.Router + motdService *motdservice.Service +} + +// NewHandler returns a new Handler +func NewHandler(bouncer security.BouncerService, svc *motdservice.Service) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + motdService: svc, + } + h.Handle("/motd", + bouncer.RestrictedAccess(http.HandlerFunc(h.motd))).Methods(http.MethodGet) + + return h +} diff --git a/api/http/handler/motd/motd.go b/api/http/handler/motd/motd.go new file mode 100644 index 0000000..a48dcdd --- /dev/null +++ b/api/http/handler/motd/motd.go @@ -0,0 +1,26 @@ +package motd + +import ( + "net/http" + + _ "github.com/portainer/portainer/api/motd" + "github.com/portainer/portainer/pkg/libhttp/response" + "github.com/rs/zerolog/log" +) + +// @id MOTD +// @summary fetches the message of the day +// @description **Access policy**: restricted +// @tags motd +// @security ApiKeyAuth +// @security jwt +// @produce json +// @success 200 {object} motd.Motd +// @router /motd [get] +func (handler *Handler) motd(w http.ResponseWriter, r *http.Request) { + motd := handler.motdService.GetCached() + + if err := response.JSON(w, motd); err != nil { + log.Warn().Err(err).Msg("failed to send MOTD response") + } +} diff --git a/api/http/handler/registries/handler.go b/api/http/handler/registries/handler.go new file mode 100644 index 0000000..5eb1966 --- /dev/null +++ b/api/http/handler/registries/handler.go @@ -0,0 +1,203 @@ +package registries + +import ( + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + httperrors "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/api/http/proxy" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/registryutils/access" + "github.com/portainer/portainer/api/kubernetes/cli" + "github.com/portainer/portainer/api/pendingactions" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + + "github.com/gorilla/mux" + + "github.com/pkg/errors" + "github.com/rs/zerolog/log" +) + +func hideFields(registry *portainer.Registry, hideAccesses bool) { + registry.Password = "" + if registry.ManagementConfiguration != nil { + // TLS and SkipTLSVerify should be retained since it's not sensitive information + minimalManagementConfig := &portainer.RegistryManagementConfiguration{} + minimalManagementConfig.TLSConfig = registry.ManagementConfiguration.TLSConfig + registry.ManagementConfiguration = minimalManagementConfig + } + if hideAccesses { + if registry.ManagementConfiguration != nil { + registry.ManagementConfiguration.TLSConfig.TLSCACertPath = "" + registry.ManagementConfiguration.TLSConfig.TLSCertPath = "" + registry.ManagementConfiguration.TLSConfig.TLSKeyPath = "" + } + registry.RegistryAccesses = nil + } +} + +// Handler is the HTTP handler used to handle registry operations. +type Handler struct { + *mux.Router + requestBouncer security.BouncerService + DataStore dataservices.DataStore + FileService portainer.FileService + ProxyManager *proxy.Manager + K8sClientFactory *cli.ClientFactory + PendingActionsService *pendingactions.PendingActionsService +} + +// NewHandler creates a handler to manage registry operations. +func NewHandler(bouncer security.BouncerService) *Handler { + h := newHandler(bouncer) + h.initRouter(bouncer) + + return h +} + +func newHandler(bouncer security.BouncerService) *Handler { + return &Handler{ + Router: mux.NewRouter(), + requestBouncer: bouncer, + } +} + +func (handler *Handler) initRouter(bouncer accessGuard) { + adminRouter := handler.NewRoute().Subrouter() + adminRouter.Use(bouncer.AdminAccess) + adminRouter.Handle("/registries", httperror.LoggerHandler(handler.registryList)).Methods(http.MethodGet) + adminRouter.Handle("/registries", httperror.LoggerHandler(handler.registryCreate)).Methods(http.MethodPost) + adminRouter.Handle("/registries/{id}", httperror.LoggerHandler(handler.registryUpdate)).Methods(http.MethodPut) + adminRouter.Handle("/registries/{id}/configure", httperror.LoggerHandler(handler.registryConfigure)).Methods(http.MethodPost) + adminRouter.Handle("/registries/{id}", httperror.LoggerHandler(handler.registryDelete)).Methods(http.MethodDelete) + adminRouter.PathPrefix("/registries/proxies/gitlab").Handler(httperror.LoggerHandler(handler.proxyRequestsToGitlabAPIWithoutRegistry)) + + // Use registry-specific access bouncer for inspect and repositories endpoints + registryAccessRouter := handler.NewRoute().Subrouter() + registryAccessRouter.Use(bouncer.AuthenticatedAccess, handler.RegistryAccess) + registryAccessRouter.Handle("/registries/{id}", httperror.LoggerHandler(handler.registryInspect)).Methods(http.MethodGet) + + // Keep the gitlab proxy on the regular authenticated router as it doesn't require specific registry access + authenticatedRouter := handler.NewRoute().Subrouter() + authenticatedRouter.Use(bouncer.AuthenticatedAccess) + authenticatedRouter.Handle("/registries/ping", httperror.LoggerHandler(handler.pingRegistry)).Methods(http.MethodPost) +} + +type accessGuard interface { + AdminAccess(h http.Handler) http.Handler + AuthenticatedAccess(h http.Handler) http.Handler + AuthorizedEndpointOperation(r *http.Request, endpoint *portainer.Endpoint) error +} + +func (handler *Handler) registriesHaveSameURLAndCredentials(r1, r2 *portainer.Registry) bool { + hasSameUrl := r1.URL == r2.URL + hasSameCredentials := r1.Authentication == r2.Authentication && (!r1.Authentication || (r1.Authentication && r1.Username == r2.Username)) + + if r1.Type != portainer.GitlabRegistry || r2.Type != portainer.GitlabRegistry { + return hasSameUrl && hasSameCredentials + } + + return hasSameUrl && hasSameCredentials && r1.Gitlab.ProjectPath == r2.Gitlab.ProjectPath +} + +// this function validates that +// 1. user has the appropriate authorizations to perform the request +// 2. user has a direct or indirect access to the registry +func (handler *Handler) userHasRegistryAccess(r *http.Request, registry *portainer.Registry) (hasAccess bool, isAdmin bool, err error) { + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return false, false, err + } + + // Portainer admins always have access to everything + if securityContext.IsAdmin { + return true, true, nil + } + + // mandatory query param that should become a path param + endpointIdStr, err := request.RetrieveNumericQueryParameter(r, "endpointId", false) + if err != nil { + return false, false, err + } + + endpointId := portainer.EndpointID(endpointIdStr) + + endpoint, err := handler.DataStore.Endpoint().Endpoint(endpointId) + if err != nil { + return false, false, err + } + + // validate that the request is allowed for the user (READ/WRITE authorization on request path) + if err := handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint); errors.Is(err, security.ErrAuthorizationRequired) { + return false, false, nil + } else if err != nil { + return false, false, err + } + + // Use the enhanced registry access utility function that includes namespace validation + _, err = access.GetAccessibleRegistry( + handler.DataStore, + handler.K8sClientFactory, + securityContext.UserID, + endpointId, + registry.ID, + ) + if err != nil { + return false, false, nil // No access + } + + return true, false, nil +} + +// RegistryAccess defines a security check for registry-specific API endpoints. +// Authentication is required to access these endpoints. +// The user must have direct or indirect access to the specific registry being requested. +// This bouncer validates registry access using the userHasRegistryAccess logic. +func (handler *Handler) RegistryAccess(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // First ensure the user is authenticated + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + httperror.WriteError(w, http.StatusUnauthorized, "Authentication required", httperrors.ErrUnauthorized) + return + } + + // Extract registry ID from the route + registryID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + httperror.WriteError(w, http.StatusBadRequest, "Invalid registry identifier route variable", err) + return + } + + // Get the registry from the database + registry, err := handler.DataStore.Registry().Read(portainer.RegistryID(registryID)) + if handler.DataStore.IsErrObjectNotFound(err) { + httperror.WriteError(w, http.StatusNotFound, "Unable to find a registry with the specified identifier inside the database", err) + return + } else if err != nil { + httperror.WriteError(w, http.StatusInternalServerError, "Unable to find a registry with the specified identifier inside the database", err) + return + } + + // Check if user has access to this registry + hasAccess, _, err := handler.userHasRegistryAccess(r, registry) + if err != nil { + httperror.WriteError(w, http.StatusInternalServerError, "Unable to retrieve info from request context", err) + return + } + if !hasAccess { + log.Debug(). + Int("registry_id", registryID). + Str("registry_name", registry.Name). + Int("user_id", int(tokenData.ID)). + Str("context", "RegistryAccessBouncer"). + Msg("User access denied to registry") + httperror.WriteError(w, http.StatusForbidden, "Access denied to resource", httperrors.ErrResourceAccessDenied) + return + } + + next.ServeHTTP(w, r) + }) +} diff --git a/api/http/handler/registries/proxy_gitlab.go b/api/http/handler/registries/proxy_gitlab.go new file mode 100644 index 0000000..0f13e4d --- /dev/null +++ b/api/http/handler/registries/proxy_gitlab.go @@ -0,0 +1,23 @@ +package registries + +import ( + "net/http" + + httperror "github.com/portainer/portainer/pkg/libhttp/error" +) + +// request on /api/registries/proxies/gitlab +func (handler *Handler) proxyRequestsToGitlabAPIWithoutRegistry(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + domain := r.Header.Get("X-Gitlab-Domain") + if domain == "" { + return httperror.BadRequest("No Gitlab domain provided", nil) + } + + proxy, err := handler.ProxyManager.CreateGitlabProxy(domain) + if err != nil { + return httperror.InternalServerError("Unable to create gitlab proxy", err) + } + + http.StripPrefix("/registries/proxies/gitlab", proxy).ServeHTTP(w, r) + return nil +} diff --git a/api/http/handler/registries/registry_access_test.go b/api/http/handler/registries/registry_access_test.go new file mode 100644 index 0000000..4ccf714 --- /dev/null +++ b/api/http/handler/registries/registry_access_test.go @@ -0,0 +1,86 @@ +package registries + +import ( + "net/http" + "net/http/httptest" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/testhelpers" + + "github.com/gorilla/mux" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_RegistryAccess_RequiresAuthentication(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + registry := &portainer.Registry{ + ID: 1, + Name: "test-registry", + URL: "https://registry.test.com", + } + + err := store.Registry().Create(registry) + require.NoError(t, err) + + handler := &Handler{DataStore: store} + req := httptest.NewRequest(http.MethodGet, "/registries/1", nil) + req = mux.SetURLVars(req, map[string]string{"id": "1"}) + rr := httptest.NewRecorder() + testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) + bouncer := handler.RegistryAccess(testHandler) + bouncer.ServeHTTP(rr, req) + assert.Equal(t, http.StatusUnauthorized, rr.Code) +} + +func Test_RegistryAccess_InvalidRegistryID(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + user := &portainer.User{ID: 1, Username: "test", Role: portainer.StandardUserRole} + err := store.User().Create(user) + require.NoError(t, err) + + handler := &Handler{DataStore: store} + req := httptest.NewRequest(http.MethodGet, "/registries/invalid", nil) + req = mux.SetURLVars(req, map[string]string{"id": "invalid"}) + tokenData := &portainer.TokenData{ID: 1, Role: portainer.StandardUserRole} + req = req.WithContext(security.StoreTokenData(req, tokenData)) + rr := httptest.NewRecorder() + + testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) + + bouncer := handler.RegistryAccess(testHandler) + bouncer.ServeHTTP(rr, req) + assert.Equal(t, http.StatusBadRequest, rr.Code) +} + +func Test_RegistryAccess_RegistryNotFound(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + user := &portainer.User{ID: 1, Username: "test", Role: portainer.StandardUserRole} + err := store.User().Create(user) + require.NoError(t, err) + + handler := &Handler{ + DataStore: store, + requestBouncer: testhelpers.NewTestRequestBouncer(), + } + + req := httptest.NewRequest(http.MethodGet, "/registries/999", nil) + req = mux.SetURLVars(req, map[string]string{"id": "999"}) + tokenData := &portainer.TokenData{ID: 1, Role: portainer.StandardUserRole} + req = req.WithContext(security.StoreTokenData(req, tokenData)) + + rr := httptest.NewRecorder() + + testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) + + bouncer := handler.RegistryAccess(testHandler) + bouncer.ServeHTTP(rr, req) + assert.Equal(t, http.StatusNotFound, rr.Code) +} diff --git a/api/http/handler/registries/registry_configure.go b/api/http/handler/registries/registry_configure.go new file mode 100644 index 0000000..15e4792 --- /dev/null +++ b/api/http/handler/registries/registry_configure.go @@ -0,0 +1,186 @@ +package registries + +import ( + "errors" + "net/http" + "strconv" + + portainer "github.com/portainer/portainer/api" + httperrors "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/api/http/security" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +type registryConfigurePayload struct { + // Is authentication against this registry enabled + Authentication bool `example:"false" validate:"required"` + // Username used to authenticate against this registry. Required when Authentication is true + Username string `example:"registry_user"` + // Password used to authenticate against this registry. required when Authentication is true + Password string `example:"registry_password"` + // ECR region + Region string + // Use TLS + TLS bool `example:"true"` + // Skip the verification of the server TLS certificate + TLSSkipVerify bool `example:"false"` + // The TLS CA certificate file + TLSCACertFile []byte + // The TLS client certificate file + TLSCertFile []byte + // The TLS client key file + TLSKeyFile []byte +} + +func (payload *registryConfigurePayload) Validate(r *http.Request) error { + useAuthentication, _ := request.RetrieveBooleanMultiPartFormValue(r, "Authentication", true) + payload.Authentication = useAuthentication + + if useAuthentication { + username, err := request.RetrieveMultiPartFormValue(r, "Username", false) + if err != nil { + return errors.New("invalid username") + } + payload.Username = username + + password, _ := request.RetrieveMultiPartFormValue(r, "Password", true) + payload.Password = password + + region, _ := request.RetrieveMultiPartFormValue(r, "Region", true) + payload.Region = region + } + + useTLS, _ := request.RetrieveBooleanMultiPartFormValue(r, "TLS", true) + payload.TLS = useTLS + + skipTLSVerify, _ := request.RetrieveBooleanMultiPartFormValue(r, "TLSSkipVerify", true) + payload.TLSSkipVerify = skipTLSVerify + + if useTLS && !skipTLSVerify { + numCertsProvided := 0 + cert, _, err := request.RetrieveMultiPartFormFile(r, "TLSCertFile") + if err == nil { + payload.TLSCertFile = cert + numCertsProvided++ + } + + key, _, err := request.RetrieveMultiPartFormFile(r, "TLSKeyFile") + if err == nil { + payload.TLSKeyFile = key + numCertsProvided++ + } + + ca, _, err := request.RetrieveMultiPartFormFile(r, "TLSCACertFile") + if err == nil { + payload.TLSCACertFile = ca + numCertsProvided++ + } + + if numCertsProvided != 0 && numCertsProvided != 3 { + return errors.New("invalid TLS configuration: provide TLS certificate, key, and CA together, or none") + } + } + + return nil +} + +// @id RegistryConfigure +// @summary Configures a registry +// @description Configures a registry. +// @description **Access policy**: restricted +// @tags registries +// @security ApiKeyAuth +// @security jwt +// @accept json +// @produce json +// @param id path int true "Registry identifier" +// @param body body registryConfigurePayload true "Registry configuration" +// @success 204 "Success" +// @failure 400 "Invalid request" +// @failure 403 "Permission denied" +// @failure 404 "Registry not found" +// @failure 500 "Server error" +// @router /registries/{id}/configure [post] +func (handler *Handler) registryConfigure(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) + } + if !securityContext.IsAdmin { + return httperror.Forbidden("Permission denied to configure registry", httperrors.ErrResourceAccessDenied) + } + + payload := ®istryConfigurePayload{} + err = payload.Validate(r) + if err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + registryID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid registry identifier route variable", err) + } + + registry, err := handler.DataStore.Registry().Read(portainer.RegistryID(registryID)) + if handler.DataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find a registry with the specified identifier inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find a registry with the specified identifier inside the database", err) + } + + if payload.Authentication { + registry.Authentication = true + + registry.Username = payload.Username + + if payload.Password != "" { + registry.Password = payload.Password + } + + if payload.Region != "" { + registry.Ecr.Region = payload.Region + } + } + + var tlsConfig portainer.TLSConfiguration + if payload.TLS { + tlsConfig = portainer.TLSConfiguration{ + TLS: true, + TLSSkipVerify: payload.TLSSkipVerify, + } + + if !payload.TLSSkipVerify { + folder := strconv.Itoa(int(registry.ID)) + + certPath, err := handler.FileService.StoreRegistryManagementFileFromBytes(folder, "cert.pem", payload.TLSCertFile) + if err != nil { + return httperror.InternalServerError("Unable to persist TLS certificate file on disk", err) + } + tlsConfig.TLSCertPath = certPath + + keyPath, err := handler.FileService.StoreRegistryManagementFileFromBytes(folder, "key.pem", payload.TLSKeyFile) + if err != nil { + return httperror.InternalServerError("Unable to persist TLS key file on disk", err) + } + tlsConfig.TLSKeyPath = keyPath + + cacertPath, err := handler.FileService.StoreRegistryManagementFileFromBytes(folder, "ca.pem", payload.TLSCACertFile) + if err != nil { + return httperror.InternalServerError("Unable to persist TLS CA certificate file on disk", err) + } + tlsConfig.TLSCACertPath = cacertPath + } + } + + registry.ManagementConfiguration = syncConfig(registry) + registry.ManagementConfiguration.TLSConfig = tlsConfig + + err = handler.DataStore.Registry().Update(registry.ID, registry) + if err != nil { + return httperror.InternalServerError("Unable to persist registry changes inside the database", err) + } + + return response.Empty(w) +} diff --git a/api/http/handler/registries/registry_configure_test.go b/api/http/handler/registries/registry_configure_test.go new file mode 100644 index 0000000..3b91604 --- /dev/null +++ b/api/http/handler/registries/registry_configure_test.go @@ -0,0 +1,102 @@ +package registries + +import ( + "bytes" + "mime/multipart" + "net/http" + "net/http/httptest" + "testing" +) + +// helper to build a multipart request for registry configure validation +func newConfigureRequest(t *testing.T, tls bool, skipVerify bool, includeCert bool, includeKey bool, includeCA bool) *http.Request { + t.Helper() + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + + // flags + _ = writer.WriteField("TLS", map[bool]string{true: "true", false: "false"}[tls]) + _ = writer.WriteField("TLSSkipVerify", map[bool]string{true: "true", false: "false"}[skipVerify]) + + // files + if includeCert { + fw, err := writer.CreateFormFile("TLSCertFile", "cert.pem") + if err != nil { + t.Fatalf("failed to create cert file: %v", err) + } + _, _ = fw.Write([]byte("CERTDATA")) + } + if includeKey { + fw, err := writer.CreateFormFile("TLSKeyFile", "key.pem") + if err != nil { + t.Fatalf("failed to create key file: %v", err) + } + _, _ = fw.Write([]byte("KEYDATA")) + } + if includeCA { + fw, err := writer.CreateFormFile("TLSCACertFile", "ca.pem") + if err != nil { + t.Fatalf("failed to create ca file: %v", err) + } + _, _ = fw.Write([]byte("CADATA")) + } + + _ = writer.Close() + + req := httptest.NewRequest(http.MethodPost, "/registries/1/configure", body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + return req +} + +func Test_registryConfigurePayload_Validate_TLSBundleRules(t *testing.T) { + t.Parallel() + // passes when all three are uploaded + { + req := newConfigureRequest(t, true, false, true, true, true) + p := ®istryConfigurePayload{} + if err := p.Validate(req); err != nil { + t.Fatalf("expected validation to pass when all certs provided, got error: %v", err) + } + if len(p.TLSCertFile) == 0 || len(p.TLSKeyFile) == 0 || len(p.TLSCACertFile) == 0 { + t.Fatalf("expected payload to contain all cert bytes") + } + } + + // passes when none are uploaded + { + req := newConfigureRequest(t, true, false, false, false, false) + p := ®istryConfigurePayload{} + if err := p.Validate(req); err != nil { + t.Fatalf("expected validation to pass when no certs provided, got error: %v", err) + } + if len(p.TLSCertFile) != 0 || len(p.TLSKeyFile) != 0 || len(p.TLSCACertFile) != 0 { + t.Fatalf("expected payload to have no cert bytes when none provided") + } + } + + // fails on partial uploads (1 or 2 of the files) + partialCases := []struct { + name string + cert bool + key bool + ca bool + }{ + {"only-cert", true, false, false}, + {"only-key", false, true, false}, + {"only-ca", false, false, true}, + {"cert-and-key", true, true, false}, + {"cert-and-ca", true, false, true}, + {"key-and-ca", false, true, true}, + } + + for _, tc := range partialCases { + t.Run(tc.name, func(t *testing.T) { + req := newConfigureRequest(t, true, false, tc.cert, tc.key, tc.ca) + p := ®istryConfigurePayload{} + if err := p.Validate(req); err == nil { + t.Fatalf("expected validation to fail on partial cert upload") + } + }) + } +} diff --git a/api/http/handler/registries/registry_create.go b/api/http/handler/registries/registry_create.go new file mode 100644 index 0000000..1f4bd63 --- /dev/null +++ b/api/http/handler/registries/registry_create.go @@ -0,0 +1,147 @@ +package registries + +import ( + "errors" + "fmt" + "net/http" + + portainer "github.com/portainer/portainer/api" + httperrors "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/api/http/security" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +type registryCreatePayload struct { + // Name that will be used to identify this registry + Name string `example:"my-registry" validate:"required"` + // Registry Type. Valid values are: + // 1 (Quay.io), + // 2 (Azure container registry), + // 3 (custom registry), + // 4 (Gitlab registry), + // 5 (ProGet registry), + // 6 (DockerHub) + // 7 (ECR) + Type portainer.RegistryType `example:"1" validate:"required" enums:"1,2,3,4,5,6,7"` + // URL or IP address of the Docker registry + URL string `example:"registry.mydomain.tld:2375/feed" validate:"required"` + // BaseURL required for ProGet registry + BaseURL string `example:"registry.mydomain.tld:2375"` + // Is authentication against this registry enabled + Authentication bool `example:"false" validate:"required"` + // Username used to authenticate against this registry. Required when Authentication is true + Username string `example:"registry_user"` + // Password used to authenticate against this registry. required when Authentication is true + Password string `example:"registry_password"` + // Gitlab specific details, required when type = 4 + Gitlab portainer.GitlabRegistryData + // Quay specific details, required when type = 1 + Quay portainer.QuayRegistryData + // ECR specific details, required when type = 7 + Ecr portainer.EcrData + // Use TLS + TLS bool `example:"true"` +} + +func (payload *registryCreatePayload) Validate(_ *http.Request) error { + if len(payload.Name) == 0 { + return errors.New("invalid registry name") + } + if len(payload.URL) == 0 { + return errors.New("invalid registry URL") + } + + if payload.Authentication { + if len(payload.Username) == 0 || len(payload.Password) == 0 { + return errors.New("invalid credentials. Username and password must be specified when authentication is enabled") + } + if payload.Type == portainer.EcrRegistry { + if len(payload.Ecr.Region) == 0 { + return errors.New("invalid credentials: access key ID, secret access key and region must be specified when authentication is enabled") + } + } + } + + switch payload.Type { + case portainer.QuayRegistry, portainer.AzureRegistry, portainer.CustomRegistry, portainer.GitlabRegistry, portainer.ProGetRegistry, portainer.DockerHubRegistry, portainer.EcrRegistry: + default: + return errors.New("invalid registry type. Valid values are: 1 (Quay.io), 2 (Azure container registry), 3 (custom registry), 4 (Gitlab registry), 5 (ProGet registry), 6 (DockerHub), 7 (ECR)") + } + + if payload.Type == portainer.ProGetRegistry && payload.BaseURL == "" { + return fmt.Errorf("BaseURL is required for registry type %d (ProGet)", portainer.ProGetRegistry) + } + + return nil +} + +// @id RegistryCreate +// @summary Create a new registry +// @description Create a new registry. +// @description **Access policy**: restricted +// @tags registries +// @security ApiKeyAuth +// @security jwt +// @accept json +// @produce json +// @param body body registryCreatePayload true "Registry details" +// @success 200 {object} portainer.Registry "Success" +// @failure 400 "Invalid request" +// @failure 409 "Another registry with the same name or same URL & credentials already exists" +// @failure 500 "Server error" +// @router /registries [post] +func (handler *Handler) registryCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) + } + if !securityContext.IsAdmin { + return httperror.Forbidden("Permission denied to create registry", httperrors.ErrResourceAccessDenied) + } + + var payload registryCreatePayload + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + registry := &portainer.Registry{ + Type: payload.Type, + Name: payload.Name, + URL: payload.URL, + BaseURL: payload.BaseURL, + Authentication: payload.Authentication, + Username: payload.Username, + Password: payload.Password, + Gitlab: payload.Gitlab, + Quay: payload.Quay, + RegistryAccesses: portainer.RegistryAccesses{}, + Ecr: payload.Ecr, + } + + registry.ManagementConfiguration = syncConfig(registry) + registry.ManagementConfiguration.TLSConfig.TLS = payload.TLS + + registries, err := handler.DataStore.Registry().ReadAll() + if err != nil { + return httperror.InternalServerError("Unable to retrieve registries from the database", err) + } + for _, r := range registries { + if r.Name == registry.Name { + return httperror.Conflict("Another registry with the same name already exists", errors.New("a registry is already defined with this name")) + } + if handler.registriesHaveSameURLAndCredentials(&r, registry) { + return httperror.Conflict("Another registry with the same URL and credentials already exists", errors.New("a registry is already defined for this URL and credentials")) + } + } + + err = handler.DataStore.Registry().Create(registry) + if err != nil { + return httperror.InternalServerError("Unable to persist the registry inside the database", err) + } + + hideFields(registry, true) + return response.JSON(w, registry) +} diff --git a/api/http/handler/registries/registry_create_test.go b/api/http/handler/registries/registry_create_test.go new file mode 100644 index 0000000..c96c812 --- /dev/null +++ b/api/http/handler/registries/registry_create_test.go @@ -0,0 +1,95 @@ +package registries + +import ( + "bytes" + "net/http" + "net/http/httptest" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/security" + + "github.com/segmentio/encoding/json" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_registryCreatePayload_Validate(t *testing.T) { + t.Parallel() + basePayload := registryCreatePayload{Name: "Test registry", URL: "http://example.com"} + t.Run("Can't create a ProGet registry if BaseURL is empty", func(t *testing.T) { + payload := basePayload + payload.Type = portainer.ProGetRegistry + err := payload.Validate(nil) + require.Error(t, err) + }) + t.Run("Can create a GitLab registry if BaseURL is empty", func(t *testing.T) { + payload := basePayload + payload.Type = portainer.GitlabRegistry + err := payload.Validate(nil) + require.NoError(t, err) + }) + t.Run("Can create a ProGet registry if BaseURL is not empty", func(t *testing.T) { + payload := basePayload + payload.Type = portainer.ProGetRegistry + payload.BaseURL = "http://example.com" + err := payload.Validate(nil) + require.NoError(t, err) + }) + t.Run("Can't create a AWS ECR registry if authentication required, but access key ID, secret access key or region is empty", func(t *testing.T) { + payload := basePayload + payload.Type = portainer.EcrRegistry + payload.Authentication = true + err := payload.Validate(nil) + require.Error(t, err) + }) + t.Run("Do not require access key ID, secret access key, region for public AWS ECR registry", func(t *testing.T) { + payload := basePayload + payload.Type = portainer.EcrRegistry + payload.Authentication = false + err := payload.Validate(nil) + require.NoError(t, err) + }) +} + +func TestHandler_registryCreate(t *testing.T) { + t.Parallel() + + handler, _ := newTestHandler(t) + + payload := registryCreatePayload{ + Name: "Test registry", + Type: portainer.ProGetRegistry, + URL: "http://example.com", + BaseURL: "http://example.com", + Authentication: false, + Username: "username", + Password: "password", + Gitlab: portainer.GitlabRegistryData{}, + } + payloadBytes, err := json.Marshal(payload) + require.NoError(t, err) + + r := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(payloadBytes)) + w := httptest.NewRecorder() + + restrictedContext := &security.RestrictedRequestContext{IsAdmin: true, UserID: 1} + + ctx := security.StoreRestrictedRequestContext(r, restrictedContext) + r = r.WithContext(ctx) + + handlerError := handler.registryCreate(w, r) + require.Nil(t, handlerError) + + registry := portainer.Registry{} + err = json.NewDecoder(w.Body).Decode(®istry) + require.NoError(t, err) + + assert.Equal(t, payload.Name, registry.Name) + assert.Equal(t, payload.Type, registry.Type) + assert.Equal(t, payload.URL, registry.URL) + assert.Equal(t, payload.BaseURL, registry.BaseURL) + assert.Equal(t, payload.Authentication, registry.Authentication) + assert.Equal(t, payload.Username, registry.Username) + assert.Empty(t, registry.Password) +} diff --git a/api/http/handler/registries/registry_delete.go b/api/http/handler/registries/registry_delete.go new file mode 100644 index 0000000..a6bf967 --- /dev/null +++ b/api/http/handler/registries/registry_delete.go @@ -0,0 +1,120 @@ +package registries + +import ( + "fmt" + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + httperrors "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/registryutils" + "github.com/portainer/portainer/api/pendingactions/handlers" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" + + "github.com/rs/zerolog/log" +) + +// cleanupRegistryFromNamespaces removes the registry imagePullSecret from the +// default service account and deletes the registry secret in each namespace. +// It returns the list of namespaces that failed either operation so the caller +// can schedule a pending action for retry. +func cleanupRegistryFromNamespaces(cli portainer.KubeClient, registryID portainer.RegistryID, namespaces []string, endpointID portainer.EndpointID) []string { + secretName := registryutils.RegistrySecretName(registryID) + failed := make([]string, 0) + + for _, ns := range namespaces { + if err := cli.RemoveImagePullSecretFromServiceAccount(ns, "default", secretName); err != nil { + failed = append(failed, ns) + log.Warn().Err(err).Msgf("Unable to remove registry secret from default service account in namespace %q for environment %d. Retrying offline", ns, endpointID) + continue + } + + if err := cli.DeleteRegistrySecret(registryID, ns); err != nil { + failed = append(failed, ns) + log.Warn().Err(err).Msgf("Unable to delete registry secret %q from namespace %q for environment %d. Retrying offline", secretName, ns, endpointID) + } + } + + return failed +} + +// @id RegistryDelete +// @summary Remove a registry +// @description Remove a registry +// @description **Access policy**: restricted +// @tags registries +// @security ApiKeyAuth +// @security jwt +// @param id path int true "Registry identifier" +// @success 204 "Success" +// @failure 400 "Invalid request" +// @failure 404 "Registry not found" +// @failure 500 "Server error" +// @router /registries/{id} [delete] +func (handler *Handler) registryDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) + } else if !securityContext.IsAdmin { + return httperror.Forbidden("Permission denied to delete registry", httperrors.ErrResourceAccessDenied) + } + + registryID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid registry identifier route variable", err) + } + + registry, err := handler.DataStore.Registry().Read(portainer.RegistryID(registryID)) + if err != nil { + return httperror.InternalServerError(fmt.Sprintf("Unable to load registry %q from the database", registry.Name), err) + } + + if err := handler.DataStore.Registry().Delete(portainer.RegistryID(registryID)); err != nil { + return httperror.InternalServerError("Unable to remove the registry from the database", err) + } + + handler.deleteKubernetesSecrets(handler.DataStore, registry) + + return response.Empty(w) +} + +func (handler *Handler) deleteKubernetesSecrets(tx dataservices.DataStoreTx, registry *portainer.Registry) { + for endpointId, access := range registry.RegistryAccesses { + if access.Namespaces == nil { + continue + } + + // Obtain a kubeclient for the endpoint + endpoint, err := tx.Endpoint().Endpoint(endpointId) + if err != nil { + // Skip environments that can't be loaded from the DB + log.Warn().Err(err).Msgf("Unable to load the environment with id %d from the database", endpointId) + + continue + } + + cli, err := handler.K8sClientFactory.GetPrivilegedKubeClient(endpoint) + if err != nil { + // Skip environments that can't get a kubeclient from + log.Warn().Err(err).Msgf("Unable to get kubernetes client for environment %d", endpointId) + + continue + } + + failedNamespaces := cleanupRegistryFromNamespaces(cli, registry.ID, access.Namespaces, endpointId) + + if len(failedNamespaces) == 0 { + continue + } + + if err := handler.PendingActionsService.Create( + tx, + handlers.NewDeleteK8sRegistrySecrets(endpointId, registry.ID, failedNamespaces), + ); err != nil { + log.Warn().Err(err).Msg("unable to schedule pending action to delete kubernetes registry secrets") + } + } +} diff --git a/api/http/handler/registries/registry_delete_test.go b/api/http/handler/registries/registry_delete_test.go new file mode 100644 index 0000000..86aa306 --- /dev/null +++ b/api/http/handler/registries/registry_delete_test.go @@ -0,0 +1,235 @@ +package registries + +import ( + "errors" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/internal/testhelpers" + kubecli "github.com/portainer/portainer/api/kubernetes/cli" + "github.com/portainer/portainer/api/pendingactions" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + kfake "k8s.io/client-go/kubernetes/fake" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// spyKubeClient for registry delete tests - same pattern as endpoint_registry_access_test.go +type deleteSpyKubeClient struct { + portainer.KubeClient + + deleteSecretErrors map[string]error + removePullSecretErrors map[string]error + + deletedSecrets []string + removedPullSecrets []string +} + +func newDeleteSpy() *deleteSpyKubeClient { + return &deleteSpyKubeClient{ + deleteSecretErrors: make(map[string]error), + removePullSecretErrors: make(map[string]error), + } +} + +func (s *deleteSpyKubeClient) DeleteRegistrySecret(_ portainer.RegistryID, namespace string) error { + s.deletedSecrets = append(s.deletedSecrets, namespace) + return s.deleteSecretErrors[namespace] +} + +func (s *deleteSpyKubeClient) RemoveImagePullSecretFromServiceAccount(namespace, _, _ string) error { + s.removedPullSecrets = append(s.removedPullSecrets, namespace) + return s.removePullSecretErrors[namespace] +} + +func newTestHandler(t *testing.T) (*Handler, dataservices.DataStore) { + t.Helper() + + _, store := datastore.MustNewTestStore(t, false, false) + require.NotNil(t, store) + + handler := NewHandler(testhelpers.NewTestRequestBouncer()) + handler.DataStore = store + + return handler, store +} + +// --- cleanupRegistryFromNamespaces unit tests --- + +func TestCleanupRegistryFromNamespaces(t *testing.T) { + t.Parallel() + const registryID portainer.RegistryID = 3 + const endpointID portainer.EndpointID = 1 + + t.Run("all namespaces succeed - returns empty failed list", func(t *testing.T) { + spy := newDeleteSpy() + failed := cleanupRegistryFromNamespaces(spy, registryID, []string{"ns-a", "ns-b"}, endpointID) + assert.Empty(t, failed) + assert.ElementsMatch(t, []string{"ns-a", "ns-b"}, spy.removedPullSecrets) + assert.ElementsMatch(t, []string{"ns-a", "ns-b"}, spy.deletedSecrets) + }) + + t.Run("SA removal fails - namespace in failed list and secret not deleted", func(t *testing.T) { + spy := newDeleteSpy() + spy.removePullSecretErrors["ns-a"] = errors.New("sa error") + failed := cleanupRegistryFromNamespaces(spy, registryID, []string{"ns-a", "ns-b"}, endpointID) + assert.Equal(t, []string{"ns-a"}, failed) + assert.ElementsMatch(t, []string{"ns-a", "ns-b"}, spy.removedPullSecrets) + assert.Equal(t, []string{"ns-b"}, spy.deletedSecrets, "ns-a secret must not be deleted when SA removal fails") + }) + + t.Run("secret deletion fails - namespace in failed list", func(t *testing.T) { + spy := newDeleteSpy() + spy.deleteSecretErrors["ns-a"] = errors.New("delete error") + failed := cleanupRegistryFromNamespaces(spy, registryID, []string{"ns-a", "ns-b"}, endpointID) + assert.Equal(t, []string{"ns-a"}, failed) + assert.ElementsMatch(t, []string{"ns-a", "ns-b"}, spy.removedPullSecrets) + assert.ElementsMatch(t, []string{"ns-a", "ns-b"}, spy.deletedSecrets) + }) + + t.Run("both operations fail for all namespaces - all in failed list", func(t *testing.T) { + spy := newDeleteSpy() + spy.removePullSecretErrors["ns-a"] = errors.New("err") + spy.removePullSecretErrors["ns-b"] = errors.New("err") + failed := cleanupRegistryFromNamespaces(spy, registryID, []string{"ns-a", "ns-b"}, endpointID) + assert.ElementsMatch(t, []string{"ns-a", "ns-b"}, failed) + assert.Empty(t, spy.deletedSecrets) + }) + + t.Run("empty namespace list - returns empty failed list", func(t *testing.T) { + spy := newDeleteSpy() + failed := cleanupRegistryFromNamespaces(spy, registryID, []string{}, endpointID) + assert.Empty(t, failed) + assert.Empty(t, spy.removedPullSecrets) + assert.Empty(t, spy.deletedSecrets) + }) +} + +// --- deleteKubernetesSecrets integration tests --- + +func TestDeleteKubernetesSecrets(t *testing.T) { + t.Parallel() + const registryID portainer.RegistryID = 3 + const endpointID portainer.EndpointID = 1 + + newHandlerWithFakeK8s := func(t *testing.T, endpoint *portainer.Endpoint, registry *portainer.Registry) (*Handler, *datastore.Store) { + t.Helper() + _, store := datastore.MustNewTestStore(t, true, false) + + require.NoError(t, store.Endpoint().Create(endpoint)) + require.NoError(t, store.Registry().Create(registry)) + + defaultSA := &v1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{Name: "default", Namespace: "ns-a"}, + } + fakeK8s := kfake.NewSimpleClientset(defaultSA) + factory := kubecli.NewTestClientFactory(endpointID, kubecli.NewTestKubeClient(fakeK8s)) + pas := pendingactions.NewService(store, nil) + + h := &Handler{ + DataStore: store, + K8sClientFactory: factory, + PendingActionsService: pas, + requestBouncer: testhelpers.NewTestRequestBouncer(), + } + return h, store + } + + t.Run("GetPrivilegedKubeClient fails - no pending action created", func(t *testing.T) { + // KubernetesLocalEnvironment calls rest.InClusterConfig() which fails outside + // a real cluster, causing GetPrivilegedKubeClient to return an error gracefully. + endpoint := &portainer.Endpoint{ + ID: endpointID, + Name: "test-env", + Type: portainer.KubernetesLocalEnvironment, + } + registry := &portainer.Registry{ + ID: registryID, + RegistryAccesses: portainer.RegistryAccesses{ + endpointID: portainer.RegistryAccessPolicies{Namespaces: []string{"ns-a"}}, + }, + } + _, store := datastore.MustNewTestStore(t, true, false) + require.NoError(t, store.Endpoint().Create(endpoint)) + require.NoError(t, store.Registry().Create(registry)) + + // Empty factory: endpoint not in cache, CreateConfig will fail → returns error, not panic + emptyFactory, err := kubecli.NewClientFactory(nil, nil, nil, "test", "", "") + require.NoError(t, err) + + pas := pendingactions.NewService(store, nil) + h := &Handler{ + DataStore: store, + K8sClientFactory: emptyFactory, + PendingActionsService: pas, + requestBouncer: testhelpers.NewTestRequestBouncer(), + } + + h.deleteKubernetesSecrets(store, registry) + + actions, err := store.PendingActions().ReadAll(func(portainer.PendingAction) bool { return true }) + require.NoError(t, err) + assert.Empty(t, actions, "no pending action should be created when kube client cannot be obtained") + }) + + t.Run("all namespaces succeed - no pending action created", func(t *testing.T) { + endpoint := &portainer.Endpoint{ + ID: endpointID, + Name: "test-env", + Type: portainer.AgentOnKubernetesEnvironment, + } + registry := &portainer.Registry{ + ID: registryID, + RegistryAccesses: portainer.RegistryAccesses{ + endpointID: portainer.RegistryAccessPolicies{Namespaces: []string{"ns-a"}}, + }, + } + _, store := datastore.MustNewTestStore(t, true, false) + require.NoError(t, store.Endpoint().Create(endpoint)) + require.NoError(t, store.Registry().Create(registry)) + + defaultSA := &v1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{Name: "default", Namespace: "ns-a"}, + } + fakeK8s := kfake.NewSimpleClientset(defaultSA) + factory := kubecli.NewTestClientFactory(endpointID, kubecli.NewTestKubeClient(fakeK8s)) + pas := pendingactions.NewService(store, nil) + h := &Handler{ + DataStore: store, + K8sClientFactory: factory, + PendingActionsService: pas, + requestBouncer: testhelpers.NewTestRequestBouncer(), + } + + h.deleteKubernetesSecrets(store, registry) + + actions, err := store.PendingActions().ReadAll(func(portainer.PendingAction) bool { return true }) + require.NoError(t, err) + assert.Empty(t, actions) + }) + + t.Run("registry with no Kubernetes namespaces - no operations attempted", func(t *testing.T) { + endpoint := &portainer.Endpoint{ + ID: endpointID, + Name: "test-env", + Type: portainer.AgentOnKubernetesEnvironment, + } + registry := &portainer.Registry{ + ID: registryID, + RegistryAccesses: portainer.RegistryAccesses{ + endpointID: portainer.RegistryAccessPolicies{Namespaces: nil}, + }, + } + h, store := newHandlerWithFakeK8s(t, endpoint, registry) + + h.deleteKubernetesSecrets(store, registry) + + actions, err := store.PendingActions().ReadAll(func(portainer.PendingAction) bool { return true }) + require.NoError(t, err) + assert.Empty(t, actions) + }) +} diff --git a/api/http/handler/registries/registry_inspect.go b/api/http/handler/registries/registry_inspect.go new file mode 100644 index 0000000..f606a95 --- /dev/null +++ b/api/http/handler/registries/registry_inspect.go @@ -0,0 +1,56 @@ +package registries + +import ( + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/security" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" + + "github.com/rs/zerolog/log" +) + +// @id RegistryInspect +// @summary Inspect a registry +// @description Retrieve details about a registry. +// @description **Access policy**: restricted +// @tags registries +// @security ApiKeyAuth +// @security jwt +// @produce json +// @param id path int true "Registry identifier" +// @success 200 {object} portainer.Registry "Success" +// @failure 400 "Invalid request" +// @failure 403 "Permission denied to access registry" +// @failure 404 "Registry not found" +// @failure 500 "Server error" +// @router /registries/{id} [get] +func (handler *Handler) registryInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + registryID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid registry identifier route variable", err) + } + + log.Debug(). + Int("registry_id", registryID). + Str("context", "RegistryInspectHandler"). + Msg("Starting registry inspection") + + registry, err := handler.DataStore.Registry().Read(portainer.RegistryID(registryID)) + if handler.DataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find a registry with the specified identifier inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find a registry with the specified identifier inside the database", err) + } + + // Check if user is admin to determine if we should hide sensitive fields + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) + } + + hideFields(registry, !securityContext.IsAdmin) + return response.JSON(w, registry) +} diff --git a/api/http/handler/registries/registry_list.go b/api/http/handler/registries/registry_list.go new file mode 100644 index 0000000..eecb751 --- /dev/null +++ b/api/http/handler/registries/registry_list.go @@ -0,0 +1,44 @@ +package registries + +import ( + "net/http" + + httperrors "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/api/http/security" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +// @id RegistryList +// @summary List Registries +// @description List all registries based on the current user authorizations. +// @description Will return all registries if using an administrator account otherwise it +// @description will only return authorized registries. +// @description **Access policy**: restricted +// @tags registries +// @security ApiKeyAuth +// @security jwt +// @produce json +// @success 200 {array} portainer.Registry "Success" +// @failure 500 "Server error" +// @router /registries [get] +func (handler *Handler) registryList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) + } + if !securityContext.IsAdmin { + return httperror.Forbidden("Permission denied to list registries, use /endpoints/:endpointId/registries route instead", httperrors.ErrResourceAccessDenied) + } + + registries, err := handler.DataStore.Registry().ReadAll() + if err != nil { + return httperror.InternalServerError("Unable to retrieve registries from the database", err) + } + + for idx := range registries { + hideFields(®istries[idx], false) + } + + return response.JSON(w, registries) +} diff --git a/api/http/handler/registries/registry_ping.go b/api/http/handler/registries/registry_ping.go new file mode 100644 index 0000000..d7b053e --- /dev/null +++ b/api/http/handler/registries/registry_ping.go @@ -0,0 +1,180 @@ +package registries + +import ( + "context" + "errors" + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/pkg/fips" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" + "github.com/portainer/portainer/pkg/liboras" + + "github.com/rs/zerolog/log" + "oras.land/oras-go/v2/registry/remote/errcode" +) + +type registryPingPayload struct { + // Registry Type. Valid values are: + // 1 (Quay.io), + // 2 (Azure container registry), + // 3 (custom registry), + // 4 (Gitlab registry), + // 5 (ProGet registry), + // 6 (DockerHub) + // 7 (ECR) + // 8 (Github registry) + Type portainer.RegistryType `example:"6" validate:"required" enums:"1,2,3,4,5,6,7,8"` + // URL or IP address of the Docker registry + URL string `example:"registry-1.docker.io" validate:"required"` + // Username used to authenticate against this registry + Username string `example:"registry_user"` + // Password used to authenticate against this registry + Password string `example:"registry_password"` + // Use TLS + TLS bool `example:"true"` +} + +type registryPingResponse struct { + // Success indicates if the registry connection was successful + Success bool `json:"success" example:"true"` + // Message provides details about the connection test result + Message string `json:"message" example:"Registry connection successful"` +} + +func (payload *registryPingPayload) Validate(_ *http.Request) error { + if len(payload.Username) == 0 || len(payload.Password) == 0 { + return httperror.BadRequest("Username and password are required", nil) + } + + switch payload.Type { + case portainer.QuayRegistry, portainer.AzureRegistry, portainer.CustomRegistry, portainer.GitlabRegistry, portainer.ProGetRegistry, portainer.DockerHubRegistry, portainer.EcrRegistry, portainer.GithubRegistry: + default: + return httperror.BadRequest("Invalid registry type. Valid values are: 1 (Quay.io), 2 (Azure container registry), 3 (custom registry), 4 (Gitlab registry), 5 (ProGet registry), 6 (DockerHub), 7 (ECR), 8 (Github registry)", nil) + } + + return nil +} + +// @id RegistryPing +// @summary Test registry connection +// @description Test connection to a registry with provided credentials +// @description **Access policy**: authenticated +// @tags registries +// @security ApiKeyAuth +// @security jwt +// @accept json +// @produce json +// @param body body registryPingPayload true "Registry credentials to test" +// @success 200 {object} registryPingResponse "Success" +// @failure 400 "Invalid request" +// @failure 500 "Server error" +// @router /registries/ping [post] +func (handler *Handler) pingRegistry(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload registryPingPayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + // Create a temporary registry configuration for testing + tempRegistry := &portainer.Registry{ + Type: payload.Type, + URL: payload.URL, + Authentication: true, + Username: payload.Username, + Password: payload.Password, + } + + // For DockerHub, ensure URL is set correctly + if payload.Type == portainer.DockerHubRegistry && payload.URL == "" { + tempRegistry.URL = "registry-1.docker.io" + } + + // Set up TLS configuration + if payload.Type == portainer.CustomRegistry { + tempRegistry.ManagementConfiguration = &portainer.RegistryManagementConfiguration{ + Type: payload.Type, + TLSConfig: portainer.TLSConfiguration{ + TLS: payload.TLS || fips.FIPSMode(), + }, + } + } + + // Test the registry connection + success, message := handler.testRegistryConnection(tempRegistry) + + responseData := registryPingResponse{ + Success: success, + Message: message, + } + + return response.JSON(w, responseData) +} + +// testRegistryConnection tests if we can connect to the registry +func (handler *Handler) testRegistryConnection(registry *portainer.Registry) (bool, string) { + registryClient, err := liboras.CreateClient(*registry) + if err != nil { + log.Error().Err(err).Str("registryURL", registry.URL).Msg("Failed to create registry client") + return false, "Connection error: Failed to create registry client - " + err.Error() + } + + ctx := context.Background() + err = registryClient.Ping(ctx) + if err != nil { + errorMessage := categorizeRegistryError(err, registry.URL) + return false, errorMessage + } + + log.Debug().Str("registryURL", registry.URL).Msg("Registry ping successful") + return true, "Registry connection successful" +} + +// categorizeRegistryError analyzes the error and returns a user-friendly message +// that distinguishes between connection errors and authentication errors +func categorizeRegistryError(err error, registryURL string) string { + if err == nil { + return "" + } + + var userMessage string + + var errResp *errcode.ErrorResponse + if errors.As(err, &errResp) { + + // 401 Unauthorized or 403 Forbidden = authentication/authorization issue + if errResp.StatusCode == http.StatusUnauthorized || errResp.StatusCode == http.StatusForbidden { + userMessage = "Access token invalid: Authentication failed - please verify your username and access token" + } else { + userMessage = "Connection error: " + err.Error() + } + + logEvent := log.Error(). + Err(err). + Str("registryURL", registryURL). + Int("statusCode", errResp.StatusCode). + Str("userMessage", userMessage) + + if len(errResp.Errors) > 0 { + logEvent.Interface("errors", errResp.Errors) + } + + logEvent.Msg("Registry ping failed") + + return userMessage + } + + // Default: treat everything else as connection error + userMessage = "Connection error: " + err.Error() + + log.Error(). + Err(err). + Str("registryURL", registryURL). + Str("userMessage", userMessage). + Msg("Registry ping failed") + + return userMessage +} diff --git a/api/http/handler/registries/registry_ping_test.go b/api/http/handler/registries/registry_ping_test.go new file mode 100644 index 0000000..6570804 --- /dev/null +++ b/api/http/handler/registries/registry_ping_test.go @@ -0,0 +1,332 @@ +package registries + +import ( + "bytes" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/security" + + "github.com/segmentio/encoding/json" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "oras.land/oras-go/v2/registry/remote/errcode" +) + +func Test_categorizeRegistryError(t *testing.T) { + t.Parallel() + tests := []struct { + name string + err error + registryURL string + want string + }{ + { + name: "nil error returns empty string", + err: nil, + registryURL: "registry.example.com", + want: "", + }, + { + name: "401 Unauthorized returns access token invalid message", + err: &errcode.ErrorResponse{ + StatusCode: http.StatusUnauthorized, + }, + registryURL: "registry-1.docker.io", + want: "Access token invalid: Authentication failed - please verify your username and access token", + }, + { + name: "403 Forbidden returns access token invalid message", + err: &errcode.ErrorResponse{ + StatusCode: http.StatusForbidden, + }, + registryURL: "registry-1.docker.io", + want: "Access token invalid: Authentication failed - please verify your username and access token", + }, + { + name: "500 Internal Server Error returns connection error", + err: &errcode.ErrorResponse{ + StatusCode: http.StatusInternalServerError, + Method: "GET", + URL: &url.URL{Scheme: "https", Host: "registry-1.docker.io", Path: "/v2/"}, + Errors: errcode.Errors{}, + }, + registryURL: "registry-1.docker.io", + want: "Connection error: GET \"https://registry-1.docker.io/v2/\": response status code 500: Internal Server Error", + }, + { + name: "404 Not Found returns connection error", + err: &errcode.ErrorResponse{ + StatusCode: http.StatusNotFound, + Method: "GET", + URL: &url.URL{Scheme: "https", Host: "registry.example.com", Path: "/v2/"}, + Errors: errcode.Errors{}, + }, + registryURL: "registry.example.com", + want: "Connection error: GET \"https://registry.example.com/v2/\": response status code 404: Not Found", + }, + { + name: "400 Bad Request with error details returns connection error with details", + err: &errcode.ErrorResponse{ + StatusCode: http.StatusBadRequest, + Method: "GET", + URL: &url.URL{Scheme: "https", Host: "registry.example.com", Path: "/v2/"}, + Errors: errcode.Errors{ + { + Code: errcode.ErrorCodeNameInvalid, + Message: "invalid repository name", + }, + }, + }, + registryURL: "registry.example.com", + want: "Connection error: GET \"https://registry.example.com/v2/\": response status code 400: name invalid: invalid repository name", + }, + { + name: "non-errcode error returns connection error", + err: errors.New("dial tcp: lookup registry.example.com: no such host"), + registryURL: "registry.example.com", + want: "Connection error: dial tcp: lookup registry.example.com: no such host", + }, + { + name: "network timeout error returns connection error", + err: errors.New("context deadline exceeded"), + registryURL: "registry.example.com", + want: "Connection error: context deadline exceeded", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := categorizeRegistryError(tt.err, tt.registryURL) + assert.Equal(t, tt.want, got) + }) + } +} + +func Test_registryPingPayload_Validate(t *testing.T) { + t.Parallel() + tests := []struct { + name string + payload registryPingPayload + wantErr bool + errMsg string + }{ + { + name: "valid DockerHub payload", + payload: registryPingPayload{ + Type: 6, // DockerHub + URL: "registry-1.docker.io", + Username: "testuser", + Password: "testpass", + }, + wantErr: false, + }, + { + name: "valid custom registry payload", + payload: registryPingPayload{ + Type: 3, // Custom + URL: "registry.example.com", + Username: "admin", + Password: "secret", + TLS: true, + }, + wantErr: false, + }, + { + name: "empty username returns error", + payload: registryPingPayload{ + Type: 6, + URL: "registry-1.docker.io", + Username: "", + Password: "testpass", + }, + wantErr: true, + errMsg: "Username and password are required", + }, + { + name: "empty password returns error", + payload: registryPingPayload{ + Type: 6, + URL: "registry-1.docker.io", + Username: "testuser", + Password: "", + }, + wantErr: true, + errMsg: "Username and password are required", + }, + { + name: "invalid registry type returns error", + payload: registryPingPayload{ + Type: 99, // Invalid type + URL: "registry-1.docker.io", + Username: "testuser", + Password: "testpass", + }, + wantErr: true, + errMsg: "Invalid registry type", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.payload.Validate(nil) + if tt.wantErr { + require.Error(t, err) + if tt.errMsg != "" { + assert.Contains(t, err.Error(), tt.errMsg) + } + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestHandler_pingRegistry(t *testing.T) { + t.Parallel() + + handler, _ := newTestHandler(t) + + tests := []struct { + name string + payload registryPingPayload + wantStatusCode int + wantSuccess bool + checkResponse func(t *testing.T, resp registryPingResponse) + }{ + { + name: "invalid payload - empty username", + payload: registryPingPayload{ + Type: portainer.DockerHubRegistry, + URL: "registry-1.docker.io", + Username: "", + Password: "testpass", + }, + wantStatusCode: http.StatusBadRequest, + }, + { + name: "invalid payload - empty password", + payload: registryPingPayload{ + Type: portainer.DockerHubRegistry, + URL: "registry-1.docker.io", + Username: "testuser", + Password: "", + }, + wantStatusCode: http.StatusBadRequest, + }, + { + name: "invalid payload - invalid registry type", + payload: registryPingPayload{ + Type: 99, + URL: "registry-1.docker.io", + Username: "testuser", + Password: "testpass", + }, + wantStatusCode: http.StatusBadRequest, + }, + { + name: "valid payload with invalid credentials returns 200 with success=false", + payload: registryPingPayload{ + Type: portainer.DockerHubRegistry, + URL: "registry-1.docker.io", + Username: "invalid-user", + Password: "invalid-pass", + }, + wantStatusCode: http.StatusOK, + wantSuccess: false, + checkResponse: func(t *testing.T, resp registryPingResponse) { + assert.False(t, resp.Success) + assert.Contains(t, resp.Message, "Access token invalid") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + payloadBytes, err := json.Marshal(tt.payload) + require.NoError(t, err) + + r := httptest.NewRequest(http.MethodPost, "/registries/ping", bytes.NewReader(payloadBytes)) + w := httptest.NewRecorder() + + // Set up security context + restrictedContext := &security.RestrictedRequestContext{ + IsAdmin: true, + UserID: 1, + UserMemberships: []portainer.TeamMembership{}, + } + ctx := security.StoreRestrictedRequestContext(r, restrictedContext) + r = r.WithContext(ctx) + + handlerErr := handler.pingRegistry(w, r) + + if tt.wantStatusCode != http.StatusOK { + // For error cases, check the handler returns an error + require.NotNil(t, handlerErr) + assert.Equal(t, tt.wantStatusCode, handlerErr.StatusCode) + } else { + // For success cases (200), even if the ping failed + require.Nil(t, handlerErr) + assert.Equal(t, http.StatusOK, w.Code) + + var resp registryPingResponse + err := json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + + assert.Equal(t, tt.wantSuccess, resp.Success) + + if tt.checkResponse != nil { + tt.checkResponse(t, resp) + } + } + }) + } +} + +func TestHandler_pingRegistry_DockerHubURL(t *testing.T) { + t.Parallel() + + handler, _ := newTestHandler(t) + + t.Run("empty URL for DockerHub gets default URL", func(t *testing.T) { + payload := registryPingPayload{ + Type: portainer.DockerHubRegistry, + URL: "", // Empty URL + Username: "testuser", + Password: "testpass", + } + + payloadBytes, err := json.Marshal(payload) + require.NoError(t, err) + + r := httptest.NewRequest(http.MethodPost, "/registries/ping", bytes.NewReader(payloadBytes)) + w := httptest.NewRecorder() + + restrictedContext := &security.RestrictedRequestContext{ + IsAdmin: true, + UserID: 1, + UserMemberships: []portainer.TeamMembership{}, + } + ctx := security.StoreRestrictedRequestContext(r, restrictedContext) + r = r.WithContext(ctx) + + handlerErr := handler.pingRegistry(w, r) + + // Should succeed (handler returns nil), but the ping itself will fail with auth error + require.Nil(t, handlerErr) + assert.Equal(t, http.StatusOK, w.Code) + + var resp registryPingResponse + err = json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + + // The ping will fail (invalid credentials), but that's expected + // We're just testing that the URL defaulting logic works + assert.False(t, resp.Success) + assert.Contains(t, resp.Message, "Access token invalid") + }) +} diff --git a/api/http/handler/registries/registry_update.go b/api/http/handler/registries/registry_update.go new file mode 100644 index 0000000..5b8b560 --- /dev/null +++ b/api/http/handler/registries/registry_update.go @@ -0,0 +1,214 @@ +package registries + +import ( + "cmp" + "errors" + "net/http" + + portainer "github.com/portainer/portainer/api" + httperrors "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/endpointutils" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +type registryUpdatePayload struct { + // Name that will be used to identify this registry + Name *string `validate:"required" example:"my-registry"` + // URL or IP address of the Docker registry + URL *string `validate:"required" example:"registry.mydomain.tld:2375"` + // BaseURL is used for quay registry + BaseURL *string `json:",omitempty" example:"registry.mydomain.tld:2375"` + // Is authentication against this registry enabled + Authentication *bool `example:"false" validate:"required"` + // Username used to authenticate against this registry. Required when Authentication is true + Username *string `example:"registry_user"` + // Password used to authenticate against this registry. required when Authentication is true + Password *string `example:"registry_password"` + // Quay data + Quay *portainer.QuayRegistryData + // Registry access control + RegistryAccesses *portainer.RegistryAccesses `json:",omitempty"` + // ECR data + Ecr *portainer.EcrData `json:",omitempty"` +} + +func (payload *registryUpdatePayload) Validate(r *http.Request) error { + return nil +} + +// @id RegistryUpdate +// @summary Update a registry +// @description Update a registry +// @description **Access policy**: restricted +// @tags registries +// @security ApiKeyAuth +// @security jwt +// @accept json +// @produce json +// @param id path int true "Registry identifier" +// @param body body registryUpdatePayload true "Registry details" +// @success 200 {object} portainer.Registry "Success" +// @failure 400 "Invalid request" +// @failure 404 "Registry not found" +// @failure 409 "Another registry with the same name or same URL & credentials already exists" +// @failure 500 "Server error" +// @router /registries/{id} [put] +func (handler *Handler) registryUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) + } + + if !securityContext.IsAdmin { + return httperror.Forbidden("Permission denied to update registry", httperrors.ErrResourceAccessDenied) + } + + registryID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid registry identifier route variable", err) + } + + registry, err := handler.DataStore.Registry().Read(portainer.RegistryID(registryID)) + if handler.DataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find a registry with the specified identifier inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find a registry with the specified identifier inside the database", err) + } + + registries, err := handler.DataStore.Registry().ReadAll() + if err != nil { + return httperror.InternalServerError("Unable to retrieve registries from the database", err) + } + + var payload registryUpdatePayload + if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + registry.Name = *cmp.Or(payload.Name, ®istry.Name) + + // Enforce name uniqueness across registries check is performed even if Name + // didn't change (Name not in payload) as we need to enforce this rule on + // updates not performed with frontend (e.g. on direct API requests) + // See https://portainer.atlassian.net/browse/EE-2706 for more details + for _, r := range registries { + if r.ID != registry.ID && r.Name == registry.Name { + return httperror.Conflict("Another registry with the same name already exists", errors.New("a registry is already defined with this name")) + } + } + + if registry.Type == portainer.ProGetRegistry && payload.BaseURL != nil { + registry.BaseURL = *payload.BaseURL + } + + shouldUpdateSecrets := false + + if payload.Authentication != nil { + shouldUpdateSecrets = shouldUpdateSecrets || (registry.Authentication != *payload.Authentication) + + if *payload.Authentication { + registry.Authentication = true + + if payload.Username != nil { + shouldUpdateSecrets = shouldUpdateSecrets || (registry.Username != *payload.Username) + registry.Username = *payload.Username + } + + if payload.Password != nil && *payload.Password != "" { + shouldUpdateSecrets = shouldUpdateSecrets || (registry.Password != *payload.Password) + registry.Password = *payload.Password + } + + if registry.Type == portainer.EcrRegistry && payload.Ecr != nil && payload.Ecr.Region != "" { + shouldUpdateSecrets = shouldUpdateSecrets || (registry.Ecr.Region != payload.Ecr.Region) + registry.Ecr.Region = payload.Ecr.Region + } + } else { + registry.Authentication = false + registry.Username = "" + registry.Password = "" + + registry.Ecr.Region = "" + + registry.AccessToken = "" + registry.AccessTokenExpiry = 0 + } + } + + registry.ManagementConfiguration = syncConfig(registry) + + if payload.URL != nil { + shouldUpdateSecrets = shouldUpdateSecrets || (*payload.URL != registry.URL) + + registry.URL = *payload.URL + + for _, r := range registries { + if r.ID != registry.ID && handler.registriesHaveSameURLAndCredentials(&r, registry) { + return httperror.Conflict("Another registry with the same URL and credentials already exists", errors.New("a registry is already defined for this URL and credentials")) + } + } + } + + if shouldUpdateSecrets { + registry.AccessToken = "" + registry.AccessTokenExpiry = 0 + + for endpointID, endpointAccess := range registry.RegistryAccesses { + endpoint, err := handler.DataStore.Endpoint().Endpoint(endpointID) + if err != nil { + return httperror.InternalServerError("Unable to update access to registry", err) + } + + if endpointutils.IsKubernetesEndpoint(endpoint) { + err = handler.updateEndpointRegistryAccess(endpoint, registry, endpointAccess) + if err != nil { + return httperror.InternalServerError("Unable to update access to registry", err) + } + } + } + } + + registry.Quay = *cmp.Or(payload.Quay, ®istry.Quay) + + if err := handler.DataStore.Registry().Update(registry.ID, registry); err != nil { + return httperror.InternalServerError("Unable to persist registry changes inside the database", err) + } + + hideFields(registry, true) + + return response.JSON(w, registry) +} + +func syncConfig(registry *portainer.Registry) *portainer.RegistryManagementConfiguration { + config := cmp.Or(registry.ManagementConfiguration, &portainer.RegistryManagementConfiguration{}) + + config.Authentication = registry.Authentication + config.Username = registry.Username + config.Password = registry.Password + config.Ecr = registry.Ecr + config.Type = registry.Type + + return config +} + +func (handler *Handler) updateEndpointRegistryAccess(endpoint *portainer.Endpoint, registry *portainer.Registry, endpointAccess portainer.RegistryAccessPolicies) error { + cli, err := handler.K8sClientFactory.GetPrivilegedKubeClient(endpoint) + if err != nil { + return err + } + + for _, namespace := range endpointAccess.Namespaces { + if err := cli.DeleteRegistrySecret(registry.ID, namespace); err != nil { + return err + } + + if err := cli.CreateRegistrySecret(registry, namespace); err != nil { + return err + } + } + + return nil +} diff --git a/api/http/handler/registries/registry_update_test.go b/api/http/handler/registries/registry_update_test.go new file mode 100644 index 0000000..033296e --- /dev/null +++ b/api/http/handler/registries/registry_update_test.go @@ -0,0 +1,63 @@ +package registries + +import ( + "bytes" + "net/http" + "net/http/httptest" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/security" + + "github.com/segmentio/encoding/json" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHandler_registryUpdate(t *testing.T) { + t.Parallel() + + handler, store := newTestHandler(t) + + registry := &portainer.Registry{Type: portainer.ProGetRegistry} + + err := store.Registry().Create(registry) + require.NoError(t, err) + + payload := registryUpdatePayload{ + Name: new("Updated test registry"), + URL: new("http://example.org/feed"), + BaseURL: new("http://example.org"), + Authentication: new(true), + Username: new("username"), + Password: new("password"), + } + + payloadBytes, err := json.Marshal(payload) + require.NoError(t, err) + + r := httptest.NewRequest(http.MethodPut, "/registries/1", bytes.NewReader(payloadBytes)) + w := httptest.NewRecorder() + + restrictedContext := &security.RestrictedRequestContext{IsAdmin: true, UserID: 1} + + ctx := security.StoreRestrictedRequestContext(r, restrictedContext) + r = r.WithContext(ctx) + + handler.ServeHTTP(w, r) + require.Equal(t, http.StatusOK, w.Code) + + updatedRegistry := portainer.Registry{} + err = json.NewDecoder(w.Body).Decode(&updatedRegistry) + require.NoError(t, err) + + // Registry type should remain intact + assert.Equal(t, registry.Type, updatedRegistry.Type) + + assert.Equal(t, *payload.Name, updatedRegistry.Name) + assert.Equal(t, *payload.URL, updatedRegistry.URL) + assert.Equal(t, *payload.BaseURL, updatedRegistry.BaseURL) + assert.Equal(t, *payload.Authentication, updatedRegistry.Authentication) + assert.Equal(t, *payload.Username, updatedRegistry.Username) + assert.Empty(t, updatedRegistry.Password) +} diff --git a/api/http/handler/resourcecontrols/handler.go b/api/http/handler/resourcecontrols/handler.go new file mode 100644 index 0000000..0529454 --- /dev/null +++ b/api/http/handler/resourcecontrols/handler.go @@ -0,0 +1,31 @@ +package resourcecontrols + +import ( + "net/http" + + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/http/security" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + + "github.com/gorilla/mux" +) + +// Handler is the HTTP handler used to handle resource control operations. +type Handler struct { + *mux.Router + DataStore dataservices.DataStore +} + +// NewHandler creates a handler to manage resource control operations. +func NewHandler(bouncer security.BouncerService) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + } + h.Handle("/resource_controls", + bouncer.AdminAccess(httperror.LoggerHandler(h.resourceControlCreate))).Methods(http.MethodPost) + h.Handle("/resource_controls/{id}", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.resourceControlUpdate))).Methods(http.MethodPut) + h.Handle("/resource_controls/{id}", + bouncer.AdminAccess(httperror.LoggerHandler(h.resourceControlDelete))).Methods(http.MethodDelete) + return h +} diff --git a/api/http/handler/resourcecontrols/resourcecontrol_create.go b/api/http/handler/resourcecontrols/resourcecontrol_create.go new file mode 100644 index 0000000..f4e03a1 --- /dev/null +++ b/api/http/handler/resourcecontrols/resourcecontrol_create.go @@ -0,0 +1,116 @@ +package resourcecontrols + +import ( + "errors" + "net/http" + + portainer "github.com/portainer/portainer/api" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +type resourceControlCreatePayload struct { + // + ResourceID string `example:"617c5f22bb9b023d6daab7cba43a57576f83492867bc767d1c59416b065e5f08" validate:"required"` + // Type of Resource. Valid values are: 1 - container, 2 - service + // 3 - volume, 4 - network, 5 - secret, 6 - stack, 7 - config, 8 - custom template, 9 - azure-container-group + Type portainer.ResourceControlType `example:"1" validate:"required" enums:"1,2,3,4,5,6,7,8,9"` + // Permit access to the associated resource to any user + Public bool `example:"true"` + // Permit access to resource only to admins + AdministratorsOnly bool `example:"true"` + // List of user identifiers with access to the associated resource + Users []int `example:"1,4"` + // List of team identifiers with access to the associated resource + Teams []int `example:"56,7"` + // List of Docker resources that will inherit this access control + SubResourceIDs []string `example:"617c5f22bb9b023d6daab7cba43a57576f83492867bc767d1c59416b065e5f08"` +} + +var errResourceControlAlreadyExists = errors.New("A resource control is already applied on this resource") //http/resourceControl + +func (payload *resourceControlCreatePayload) Validate(r *http.Request) error { + if len(payload.ResourceID) == 0 { + return errors.New("invalid payload: invalid resource identifier") + } + + if payload.Type <= 0 || payload.Type >= 10 { + return errors.New("invalid payload: Invalid type value. Value must be one of: 1 - container, 2 - service, 3 - volume, 4 - network, 5 - secret, 6 - stack, 7 - config, 8 - custom template, 9 - azure-container-group") + } + + if len(payload.Users) == 0 && len(payload.Teams) == 0 && !payload.Public && !payload.AdministratorsOnly { + return errors.New("invalid payload: must specify Users, Teams, Public or AdministratorsOnly") + } + + if payload.Public && payload.AdministratorsOnly { + return errors.New("invalid payload: cannot set both public and administrators only flags to true") + } + return nil +} + +// @id ResourceControlCreate +// @summary Create a new resource control +// @description Create a new resource control to restrict access to a Docker resource. +// @description **Access policy**: administrator +// @tags resource_controls +// @security ApiKeyAuth +// @security jwt +// @accept json +// @produce json +// @param body body resourceControlCreatePayload true "Resource control details" +// @success 200 {object} portainer.ResourceControl "Success" +// @failure 400 "Invalid request" +// @failure 409 "A resource control is already associated to this resource" +// @failure 500 "Server error" +// @router /resource_controls [post] +func (handler *Handler) resourceControlCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload resourceControlCreatePayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + rc, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(payload.ResourceID, payload.Type) + if err != nil { + return httperror.InternalServerError("Unable to retrieve resource controls from the database", err) + } + if rc != nil { + return httperror.Conflict("A resource control is already associated to this resource", errResourceControlAlreadyExists) + } + + var userAccesses = make([]portainer.UserResourceAccess, 0) + for _, v := range payload.Users { + userAccess := portainer.UserResourceAccess{ + UserID: portainer.UserID(v), + AccessLevel: portainer.ReadWriteAccessLevel, + } + userAccesses = append(userAccesses, userAccess) + } + + var teamAccesses = make([]portainer.TeamResourceAccess, 0) + for _, v := range payload.Teams { + teamAccess := portainer.TeamResourceAccess{ + TeamID: portainer.TeamID(v), + AccessLevel: portainer.ReadWriteAccessLevel, + } + teamAccesses = append(teamAccesses, teamAccess) + } + + resourceControl := portainer.ResourceControl{ + ResourceID: payload.ResourceID, + SubResourceIDs: payload.SubResourceIDs, + Type: payload.Type, + Public: payload.Public, + AdministratorsOnly: payload.AdministratorsOnly, + UserAccesses: userAccesses, + TeamAccesses: teamAccesses, + } + + err = handler.DataStore.ResourceControl().Create(&resourceControl) + if err != nil { + return httperror.InternalServerError("Unable to persist the resource control inside the database", err) + } + + return response.JSON(w, resourceControl) +} diff --git a/api/http/handler/resourcecontrols/resourcecontrol_delete.go b/api/http/handler/resourcecontrols/resourcecontrol_delete.go new file mode 100644 index 0000000..1c8ae26 --- /dev/null +++ b/api/http/handler/resourcecontrols/resourcecontrol_delete.go @@ -0,0 +1,43 @@ +package resourcecontrols + +import ( + "net/http" + + portainer "github.com/portainer/portainer/api" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +// @id ResourceControlDelete +// @summary Remove a resource control +// @description Remove a resource control. +// @description **Access policy**: administrator +// @tags resource_controls +// @security ApiKeyAuth +// @security jwt +// @param id path int true "Resource control identifier" +// @success 204 "Success" +// @failure 400 "Invalid request" +// @failure 404 "Resource control not found" +// @failure 500 "Server error" +// @router /resource_controls/{id} [delete] +func (handler *Handler) resourceControlDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + resourceControlID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid resource control identifier route variable", err) + } + + _, err = handler.DataStore.ResourceControl().Read(portainer.ResourceControlID(resourceControlID)) + if handler.DataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find a resource control with the specified identifier inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find a resource control with the specified identifier inside the database", err) + } + + if err := handler.DataStore.ResourceControl().Delete(portainer.ResourceControlID(resourceControlID)); err != nil { + return httperror.InternalServerError("Unable to remove the resource control from the database", err) + } + + return response.Empty(w) +} diff --git a/api/http/handler/resourcecontrols/resourcecontrol_update.go b/api/http/handler/resourcecontrols/resourcecontrol_update.go new file mode 100644 index 0000000..409ddb7 --- /dev/null +++ b/api/http/handler/resourcecontrols/resourcecontrol_update.go @@ -0,0 +1,114 @@ +package resourcecontrols + +import ( + "errors" + "net/http" + + portainer "github.com/portainer/portainer/api" + httperrors "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/api/http/security" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +type resourceControlUpdatePayload struct { + // Permit access to the associated resource to any user + Public bool `example:"true"` + // List of user identifiers with access to the associated resource + Users []int `example:"4"` + // List of team identifiers with access to the associated resource + Teams []int `example:"7"` + // Permit access to resource only to admins + AdministratorsOnly bool `example:"true"` +} + +func (payload *resourceControlUpdatePayload) Validate(r *http.Request) error { + if len(payload.Users) == 0 && len(payload.Teams) == 0 && !payload.Public && !payload.AdministratorsOnly { + return errors.New("invalid payload: must specify Users, Teams, Public or AdministratorsOnly") + } + + if payload.Public && payload.AdministratorsOnly { + return errors.New("invalid payload: cannot set public and administrators only") + } + + return nil +} + +// @id ResourceControlUpdate +// @summary Update a resource control +// @description Update a resource control +// @description **Access policy**: authenticated +// @tags resource_controls +// @security ApiKeyAuth +// @security jwt +// @accept json +// @produce json +// @param id path int true "Resource control identifier" +// @param body body resourceControlUpdatePayload true "Resource control details" +// @success 200 {object} portainer.ResourceControl "Success" +// @failure 400 "Invalid request" +// @failure 403 "Unauthorized" +// @failure 404 "Resource control not found" +// @failure 500 "Server error" +// @router /resource_controls/{id} [put] +func (handler *Handler) resourceControlUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + resourceControlID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid resource control identifier route variable", err) + } + + var payload resourceControlUpdatePayload + if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + resourceControl, err := handler.DataStore.ResourceControl().Read(portainer.ResourceControlID(resourceControlID)) + if handler.DataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find a resource control with the specified identifier inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find a resource control with the specified identifier inside the database", err) + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) + } + + if !security.AuthorizedResourceControlAccess(resourceControl, securityContext) { + return httperror.Forbidden("Permission denied to access the resource control", httperrors.ErrResourceAccessDenied) + } + + resourceControl.Public = payload.Public + resourceControl.AdministratorsOnly = payload.AdministratorsOnly + + var userAccesses = make([]portainer.UserResourceAccess, 0) + for _, v := range payload.Users { + userAccess := portainer.UserResourceAccess{ + UserID: portainer.UserID(v), + AccessLevel: portainer.ReadWriteAccessLevel, + } + userAccesses = append(userAccesses, userAccess) + } + resourceControl.UserAccesses = userAccesses + + var teamAccesses = make([]portainer.TeamResourceAccess, 0) + for _, v := range payload.Teams { + teamAccess := portainer.TeamResourceAccess{ + TeamID: portainer.TeamID(v), + AccessLevel: portainer.ReadWriteAccessLevel, + } + teamAccesses = append(teamAccesses, teamAccess) + } + resourceControl.TeamAccesses = teamAccesses + + if !security.AuthorizedResourceControlUpdate(resourceControl, securityContext) { + return httperror.Forbidden("Permission denied to update the resource control", httperrors.ErrResourceAccessDenied) + } + + if err := handler.DataStore.ResourceControl().Update(resourceControl.ID, resourceControl); err != nil { + return httperror.InternalServerError("Unable to persist resource control changes inside the database", err) + } + + return response.JSON(w, resourceControl) +} diff --git a/api/http/handler/roles/handler.go b/api/http/handler/roles/handler.go new file mode 100644 index 0000000..5ff59dd --- /dev/null +++ b/api/http/handler/roles/handler.go @@ -0,0 +1,28 @@ +package roles + +import ( + "net/http" + + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/http/security" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + + "github.com/gorilla/mux" +) + +// Handler is the HTTP handler used to handle role operations. +type Handler struct { + *mux.Router + DataStore dataservices.DataStore +} + +// NewHandler creates a handler to manage role operations. +func NewHandler(bouncer security.BouncerService) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + } + h.Handle("/roles", + bouncer.AdminAccess(httperror.LoggerHandler(h.roleList))).Methods(http.MethodGet) + + return h +} diff --git a/api/http/handler/roles/role_list.go b/api/http/handler/roles/role_list.go new file mode 100644 index 0000000..425e1e7 --- /dev/null +++ b/api/http/handler/roles/role_list.go @@ -0,0 +1,28 @@ +package roles + +import ( + "net/http" + + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +// @id RoleList +// @summary List roles +// @description List all roles available for use +// @description **Access policy**: administrator +// @tags roles +// @security ApiKeyAuth +// @security jwt +// @produce json +// @success 200 {array} portainer.Role "Success" +// @failure 500 "Server error" +// @router /roles [get] +func (handler *Handler) roleList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + roles, err := handler.DataStore.Role().ReadAll() + if err != nil { + return httperror.InternalServerError("Unable to retrieve authorization sets from the database", err) + } + + return response.JSON(w, roles) +} diff --git a/api/http/handler/settings/handler.go b/api/http/handler/settings/handler.go new file mode 100644 index 0000000..81a9f58 --- /dev/null +++ b/api/http/handler/settings/handler.go @@ -0,0 +1,45 @@ +package settings + +import ( + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/http/security" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + + "github.com/gorilla/mux" +) + +func hideFields(settings *portainer.Settings) { + settings.LDAPSettings.Password = "" + settings.OAuthSettings.ClientSecret = "" + settings.OAuthSettings.KubeSecretKey = nil +} + +// Handler is the HTTP handler used to handle settings operations. +type Handler struct { + *mux.Router + DataStore dataservices.DataStore + FileService portainer.FileService + JWTService portainer.JWTService + LDAPService portainer.LDAPService + SnapshotService portainer.SnapshotService + SetupTokenRequired bool +} + +// NewHandler creates a handler to manage settings operations. +func NewHandler(bouncer security.BouncerService) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + } + + h.Handle("/settings", + bouncer.AdminAccess(httperror.LoggerHandler(h.settingsInspect))).Methods(http.MethodGet) + h.Handle("/settings", + bouncer.AdminAccess(httperror.LoggerHandler(h.settingsUpdate))).Methods(http.MethodPut) + h.Handle("/settings/public", + bouncer.PublicAccess(httperror.LoggerHandler(h.settingsPublic))).Methods(http.MethodGet) + + return h +} diff --git a/api/http/handler/settings/settings_inspect.go b/api/http/handler/settings/settings_inspect.go new file mode 100644 index 0000000..ccee2cc --- /dev/null +++ b/api/http/handler/settings/settings_inspect.go @@ -0,0 +1,29 @@ +package settings + +import ( + "net/http" + + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +// @id SettingsInspect +// @summary Retrieve Portainer settings +// @description Retrieve Portainer settings. +// @description **Access policy**: administrator +// @tags settings +// @security ApiKeyAuth +// @security jwt +// @produce json +// @success 200 {object} portainer.Settings "Success" +// @failure 500 "Server error" +// @router /settings [get] +func (handler *Handler) settingsInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + settings, err := handler.DataStore.Settings().Settings() + if err != nil { + return httperror.InternalServerError("Unable to retrieve the settings from the database", err) + } + + hideFields(settings) + return response.JSON(w, settings) +} diff --git a/api/http/handler/settings/settings_public.go b/api/http/handler/settings/settings_public.go new file mode 100644 index 0000000..3943961 --- /dev/null +++ b/api/http/handler/settings/settings_public.go @@ -0,0 +1,112 @@ +package settings + +import ( + "fmt" + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/pkg/featureflags" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +type publicSettingsResponse struct { + // 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 + LogoURL string `json:"LogoURL" example:"https://mycompany.mydomain.tld/logo.png"` + // Active authentication method for the Portainer instance. Valid values are: 1 for internal, 2 for LDAP, or 3 for oauth + AuthenticationMethod portainer.AuthenticationMethod `json:"AuthenticationMethod" example:"1"` + // The minimum required length for a password of any user when using internal auth mode + RequiredPasswordLength int `json:"RequiredPasswordLength" example:"1"` + // Deployment options for encouraging deployment as code + GlobalDeploymentOptions portainer.GlobalDeploymentOptions `json:"GlobalDeploymentOptions"` + // Whether edge compute features are enabled + EnableEdgeComputeFeatures bool `json:"EnableEdgeComputeFeatures" example:"true"` + // Supported feature flags + Features map[featureflags.Feature]bool `json:"Features"` + // The URL used for oauth login + OAuthLoginURI string `json:"OAuthLoginURI" example:"https://gitlab.com/oauth"` + // The URL used for oauth logout + OAuthLogoutURI string `json:"OAuthLogoutURI" example:"https://gitlab.com/oauth/logout"` + // The expiry of a Kubeconfig + KubeconfigExpiry string `example:"24h" default:"0"` + // Whether team sync is enabled + TeamSync bool `json:"TeamSync" example:"true"` + + Edge struct { + // The ping interval for edge agent - used in edge async mode [seconds] + PingInterval int `json:"PingInterval" example:"60"` + // The snapshot interval for edge agent - used in edge async mode [seconds] + SnapshotInterval int `json:"SnapshotInterval" example:"60"` + // The command list interval for edge agent - used in edge async mode [seconds] + CommandInterval int `json:"CommandInterval" example:"60"` + // The check in interval for edge agent (in seconds) - used in non async mode [seconds] + CheckinInterval int `example:"60"` + } + + IsDockerDesktopExtension bool `json:"IsDockerDesktopExtension" example:"false"` + // Whether the setup wizard must send the X-Setup-Token header for admin init / restore + RequiresSetupToken bool `json:"RequiresSetupToken" example:"false"` +} + +// @id SettingsPublic +// @summary Retrieve Portainer public settings +// @description Retrieve public settings. Returns a small set of settings that are not reserved to administrators only. +// @description **Access policy**: public +// @tags settings +// @produce json +// @success 200 {object} publicSettingsResponse "Success" +// @failure 500 "Server error" +// @router /settings/public [get] +func (handler *Handler) settingsPublic(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + settings, err := handler.DataStore.Settings().Settings() + if err != nil { + return httperror.InternalServerError("Unable to retrieve the settings from the database", err) + } + + publicSettings := generatePublicSettings(settings) + publicSettings.RequiresSetupToken = handler.SetupTokenRequired + + return response.JSON(w, publicSettings) +} + +func generatePublicSettings(appSettings *portainer.Settings) *publicSettingsResponse { + publicSettings := &publicSettingsResponse{ + LogoURL: appSettings.LogoURL, + AuthenticationMethod: appSettings.AuthenticationMethod, + RequiredPasswordLength: appSettings.InternalAuthSettings.RequiredPasswordLength, + EnableEdgeComputeFeatures: appSettings.EnableEdgeComputeFeatures, + GlobalDeploymentOptions: appSettings.GlobalDeploymentOptions, + KubeconfigExpiry: appSettings.KubeconfigExpiry, + Features: featureflags.FeatureFlags(), + } + + publicSettings.Edge.PingInterval = appSettings.Edge.PingInterval + publicSettings.Edge.SnapshotInterval = appSettings.Edge.SnapshotInterval + publicSettings.Edge.CommandInterval = appSettings.Edge.CommandInterval + publicSettings.Edge.CheckinInterval = appSettings.EdgeAgentCheckinInterval + + publicSettings.IsDockerDesktopExtension = appSettings.IsDockerDesktopExtension + + // If OAuth authentication is on, compose the related fields from application settings + if publicSettings.AuthenticationMethod == portainer.AuthenticationOAuth { + publicSettings.OAuthLogoutURI = appSettings.OAuthSettings.LogoutURI + publicSettings.OAuthLoginURI = fmt.Sprintf("%s?response_type=code&client_id=%s&redirect_uri=%s&scope=%s", + appSettings.OAuthSettings.AuthorizationURI, + appSettings.OAuthSettings.ClientID, + appSettings.OAuthSettings.RedirectURI, + appSettings.OAuthSettings.Scopes) + + // Control prompt=login param according to the SSO setting + if !appSettings.OAuthSettings.SSO { + publicSettings.OAuthLoginURI += "&prompt=login" + } + } + // If LDAP authentication is on, compose the related fields from application settings + if publicSettings.AuthenticationMethod == portainer.AuthenticationLDAP && appSettings.LDAPSettings.GroupSearchSettings != nil { + if len(appSettings.LDAPSettings.GroupSearchSettings) > 0 { + publicSettings.TeamSync = len(appSettings.LDAPSettings.GroupSearchSettings[0].GroupBaseDN) > 0 + } + } + + return publicSettings +} diff --git a/api/http/handler/settings/settings_public_test.go b/api/http/handler/settings/settings_public_test.go new file mode 100644 index 0000000..b052e61 --- /dev/null +++ b/api/http/handler/settings/settings_public_test.go @@ -0,0 +1,74 @@ +package settings + +import ( + "fmt" + "testing" + + portainer "github.com/portainer/portainer/api" +) + +const ( + dummyOAuthClientID = "1a2b3c4d" + dummyOAuthScopes = "scopes" + dummyOAuthAuthenticationURI = "example.com/auth" + dummyOAuthRedirectURI = "example.com/redirect" + dummyOAuthLogoutURI = "example.com/logout" +) + +func newTestSettings() (loginURI string, settings *portainer.Settings) { + loginURI = fmt.Sprintf("%s?response_type=code&client_id=%s&redirect_uri=%s&scope=%s", + dummyOAuthAuthenticationURI, + dummyOAuthClientID, + dummyOAuthRedirectURI, + dummyOAuthScopes) + settings = &portainer.Settings{ + AuthenticationMethod: portainer.AuthenticationOAuth, + OAuthSettings: portainer.OAuthSettings{ + AuthorizationURI: dummyOAuthAuthenticationURI, + ClientID: dummyOAuthClientID, + Scopes: dummyOAuthScopes, + RedirectURI: dummyOAuthRedirectURI, + LogoutURI: dummyOAuthLogoutURI, + }, + } + return +} + +func TestGeneratePublicSettingsWithSSO(t *testing.T) { + t.Parallel() + dummyOAuthLoginURI, mockAppSettings := newTestSettings() + + mockAppSettings.OAuthSettings.SSO = true + publicSettings := generatePublicSettings(mockAppSettings) + if publicSettings.AuthenticationMethod != portainer.AuthenticationOAuth { + t.Errorf("wrong AuthenticationMethod, want: %d, got: %d", portainer.AuthenticationOAuth, publicSettings.AuthenticationMethod) + } + + if publicSettings.OAuthLoginURI != dummyOAuthLoginURI { + t.Errorf("wrong OAuthLoginURI when SSO is switched on, want: %s, got: %s", dummyOAuthLoginURI, publicSettings.OAuthLoginURI) + } + + if publicSettings.OAuthLogoutURI != dummyOAuthLogoutURI { + t.Errorf("wrong OAuthLogoutURI, want: %s, got: %s", dummyOAuthLogoutURI, publicSettings.OAuthLogoutURI) + } +} + +func TestGeneratePublicSettingsWithoutSSO(t *testing.T) { + t.Parallel() + dummyOAuthLoginURI, mockAppSettings := newTestSettings() + + mockAppSettings.OAuthSettings.SSO = false + publicSettings := generatePublicSettings(mockAppSettings) + if publicSettings.AuthenticationMethod != portainer.AuthenticationOAuth { + t.Errorf("wrong AuthenticationMethod, want: %d, got: %d", portainer.AuthenticationOAuth, publicSettings.AuthenticationMethod) + } + + expectedOAuthLoginURI := dummyOAuthLoginURI + "&prompt=login" + if publicSettings.OAuthLoginURI != expectedOAuthLoginURI { + t.Errorf("wrong OAuthLoginURI when SSO is switched off, want: %s, got: %s", expectedOAuthLoginURI, publicSettings.OAuthLoginURI) + } + + if publicSettings.OAuthLogoutURI != dummyOAuthLogoutURI { + t.Errorf("wrong OAuthLogoutURI, want: %s, got: %s", dummyOAuthLogoutURI, publicSettings.OAuthLogoutURI) + } +} diff --git a/api/http/handler/settings/settings_update.go b/api/http/handler/settings/settings_update.go new file mode 100644 index 0000000..4a24bf9 --- /dev/null +++ b/api/http/handler/settings/settings_update.go @@ -0,0 +1,267 @@ +package settings + +import ( + "cmp" + "net/http" + "strings" + "time" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/filesystem" + "github.com/portainer/portainer/api/internal/edge" + "github.com/portainer/portainer/pkg/libhelm" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" + "github.com/portainer/portainer/pkg/libhttp/ssrf" + "github.com/portainer/portainer/pkg/validate" + + "github.com/pkg/errors" + "golang.org/x/oauth2" +) + +type settingsUpdatePayload struct { + // 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 + LogoURL *string `example:"https://mycompany.mydomain.tld/logo.png"` + // A list of label name & value that will be used to hide containers when querying containers + BlackListedLabels []portainer.Pair + // Active authentication method for the Portainer instance. Valid values are: 1 for internal, 2 for LDAP, or 3 for oauth + AuthenticationMethod *int `example:"1"` + InternalAuthSettings *portainer.InternalAuthSettings + LDAPSettings *portainer.LDAPSettings + OAuthSettings *portainer.OAuthSettings + // The interval in which environment(endpoint) snapshots are created + SnapshotInterval *string `example:"5m"` + // URL to the templates that will be displayed in the UI when navigating to App Templates + TemplatesURL *string `example:"https://raw.githubusercontent.com/portainer/templates/master/templates.json"` + // Deployment options for encouraging deployment as code + GlobalDeploymentOptions *portainer.GlobalDeploymentOptions // The default check in interval for edge agent (in seconds) + EdgeAgentCheckinInterval *int `example:"5"` + // Whether edge compute features are enabled + EnableEdgeComputeFeatures *bool `example:"true"` + // The duration of a user session + UserSessionTimeout *string `example:"5m"` + // The expiry of a Kubeconfig + KubeconfigExpiry *string `example:"24h" default:"0"` + // Helm repository URL + HelmRepositoryURL *string `example:"https://charts.bitnami.com/bitnami"` + // Kubectl Shell Image + KubectlShellImage *string `example:"portainer/kubectl-shell:latest"` + // TrustOnFirstConnect makes Portainer accepting edge agent connection by default + TrustOnFirstConnect *bool `example:"false"` + // EnforceEdgeID makes Portainer store the Edge ID instead of accepting anyone + EnforceEdgeID *bool `example:"false"` + // EdgePortainerURL is the URL that is exposed to edge agents + EdgePortainerURL *string `json:"EdgePortainerURL"` + // ForceSecureCookies forces the Secure attribute on auth cookies regardless of the detected scheme + ForceSecureCookies *bool `example:"false"` +} + +func (payload *settingsUpdatePayload) Validate(r *http.Request) error { + 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 != "" && !validate.IsURL(*payload.LogoURL) { + return errors.New("Invalid logo URL. Must correspond to a valid URL format") + } + + if payload.TemplatesURL != nil && *payload.TemplatesURL != "" && !validate.IsURL(*payload.TemplatesURL) { + return errors.New("Invalid external templates URL. Must correspond to a valid URL format") + } + + if payload.HelmRepositoryURL != nil && *payload.HelmRepositoryURL != "" && !validate.IsURL(*payload.HelmRepositoryURL) { + return errors.New("Invalid Helm repository URL. Must correspond to a valid URL format") + } + + if payload.HelmRepositoryURL != nil && *payload.HelmRepositoryURL != "" { + if err := ssrf.CheckURL(r.Context(), *payload.HelmRepositoryURL); err != nil { + return errors.New("Invalid Helm repository URL. Must correspond to a valid URL format") + } + } + + if payload.UserSessionTimeout != nil { + if _, err := time.ParseDuration(*payload.UserSessionTimeout); err != nil { + return errors.New("Invalid user session timeout") + } + } + + if payload.KubeconfigExpiry != nil { + if _, err := time.ParseDuration(*payload.KubeconfigExpiry); err != nil { + return errors.New("Invalid Kubeconfig Expiry") + } + } + + if payload.EdgePortainerURL != nil && *payload.EdgePortainerURL != "" { + if _, err := edge.ParseHostForEdge(*payload.EdgePortainerURL); err != nil { + return err + } + } + + if payload.OAuthSettings != nil { + if payload.OAuthSettings.AuthStyle < oauth2.AuthStyleAutoDetect || payload.OAuthSettings.AuthStyle > oauth2.AuthStyleInHeader { + return errors.New("Invalid OAuth AuthStyle") + } + } + + return nil +} + +// @id SettingsUpdate +// @summary Update Portainer settings +// @description Update Portainer settings. +// @description **Access policy**: administrator +// @tags settings +// @security ApiKeyAuth +// @security jwt +// @accept json +// @produce json +// @param body body settingsUpdatePayload true "New settings" +// @success 200 {object} portainer.Settings "Success" +// @failure 400 "Invalid request" +// @failure 500 "Server error" +// @router /settings [put] +func (handler *Handler) settingsUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload settingsUpdatePayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + var settings *portainer.Settings + if err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + settings, err = handler.updateSettings(tx, payload) + + return err + }); err != nil { + return response.TxErrorResponse(err) + } + + hideFields(settings) + return response.JSON(w, settings) +} + +func (handler *Handler) updateSettings(tx dataservices.DataStoreTx, payload settingsUpdatePayload) (*portainer.Settings, error) { + settings, err := tx.Settings().Settings() + if err != nil { + return nil, httperror.InternalServerError("Unable to retrieve the settings from the database", err) + } + + if payload.AuthenticationMethod != nil { + settings.AuthenticationMethod = portainer.AuthenticationMethod(*payload.AuthenticationMethod) + } + + settings.LogoURL = *cmp.Or(payload.LogoURL, &settings.LogoURL) + settings.TemplatesURL = *cmp.Or(payload.TemplatesURL, &settings.TemplatesURL) + + // Update the global deployment options, and the environment deployment options if they have changed + settings.GlobalDeploymentOptions = *cmp.Or(payload.GlobalDeploymentOptions, &settings.GlobalDeploymentOptions) + + if payload.HelmRepositoryURL != nil { + settings.HelmRepositoryURL = "" + if *payload.HelmRepositoryURL != "" { + newHelmRepo := strings.TrimSuffix(strings.ToLower(*payload.HelmRepositoryURL), "/") + + if newHelmRepo != settings.HelmRepositoryURL && newHelmRepo != portainer.DefaultHelmRepositoryURL { + if err := libhelm.ValidateHelmRepositoryURL(*payload.HelmRepositoryURL, nil); err != nil { + return nil, httperror.BadRequest("Invalid Helm repository URL. Must correspond to a valid URL format", err) + } + } + + settings.HelmRepositoryURL = newHelmRepo + } + } + + if payload.BlackListedLabels != nil { + settings.BlackListedLabels = payload.BlackListedLabels + } + + if payload.InternalAuthSettings != nil { + settings.InternalAuthSettings.RequiredPasswordLength = payload.InternalAuthSettings.RequiredPasswordLength + } + + if payload.LDAPSettings != nil { + ldapReaderDN := cmp.Or(payload.LDAPSettings.ReaderDN, settings.LDAPSettings.ReaderDN) + ldapPassword := cmp.Or(payload.LDAPSettings.Password, settings.LDAPSettings.Password) + + settings.LDAPSettings = *payload.LDAPSettings + settings.LDAPSettings.ReaderDN = ldapReaderDN + settings.LDAPSettings.Password = ldapPassword + } + + if payload.OAuthSettings != nil { + clientSecret := payload.OAuthSettings.ClientSecret + if clientSecret == "" { + clientSecret = settings.OAuthSettings.ClientSecret + } + + kubeSecret := payload.OAuthSettings.KubeSecretKey + if kubeSecret == nil { + kubeSecret = settings.OAuthSettings.KubeSecretKey + } + + settings.OAuthSettings = *payload.OAuthSettings + settings.OAuthSettings.ClientSecret = clientSecret + settings.OAuthSettings.KubeSecretKey = kubeSecret + settings.OAuthSettings.AuthStyle = payload.OAuthSettings.AuthStyle + } + + settings.EnableEdgeComputeFeatures = *cmp.Or(payload.EnableEdgeComputeFeatures, &settings.EnableEdgeComputeFeatures) + settings.TrustOnFirstConnect = *cmp.Or(payload.TrustOnFirstConnect, &settings.TrustOnFirstConnect) + settings.EnforceEdgeID = *cmp.Or(payload.EnforceEdgeID, &settings.EnforceEdgeID) + settings.EdgePortainerURL = *cmp.Or(payload.EdgePortainerURL, &settings.EdgePortainerURL) + settings.ForceSecureCookies = *cmp.Or(payload.ForceSecureCookies, &settings.ForceSecureCookies) + + if payload.SnapshotInterval != nil && *payload.SnapshotInterval != settings.SnapshotInterval { + if err := handler.updateSnapshotInterval(settings, *payload.SnapshotInterval); err != nil { + return nil, httperror.InternalServerError("Unable to update snapshot interval", err) + } + } + + settings.EdgeAgentCheckinInterval = *cmp.Or(payload.EdgeAgentCheckinInterval, &settings.EdgeAgentCheckinInterval) + settings.KubeconfigExpiry = *cmp.Or(payload.KubeconfigExpiry, &settings.KubeconfigExpiry) + + if payload.UserSessionTimeout != nil { + settings.UserSessionTimeout = *payload.UserSessionTimeout + + userSessionDuration, _ := time.ParseDuration(*payload.UserSessionTimeout) + + handler.JWTService.SetUserSessionDuration(userSessionDuration) + } + + if err := handler.updateTLS(settings); err != nil { + return nil, err + } + + settings.KubectlShellImage = *cmp.Or(payload.KubectlShellImage, &settings.KubectlShellImage) + + if err := tx.Settings().UpdateSettings(settings); err != nil { + return nil, httperror.InternalServerError("Unable to persist settings changes inside the database", err) + } + + return settings, nil +} + +func (handler *Handler) updateSnapshotInterval(settings *portainer.Settings, snapshotInterval string) error { + settings.SnapshotInterval = snapshotInterval + + return handler.SnapshotService.SetSnapshotInterval(snapshotInterval) +} + +func (handler *Handler) updateTLS(settings *portainer.Settings) error { + if (settings.LDAPSettings.TLSConfig.TLS || settings.LDAPSettings.StartTLS) && !settings.LDAPSettings.TLSConfig.TLSSkipVerify { + caCertPath, _ := handler.FileService.GetPathForTLSFile(filesystem.LDAPStorePath, portainer.TLSFileCA) + settings.LDAPSettings.TLSConfig.TLSCACertPath = caCertPath + + return nil + } + + settings.LDAPSettings.TLSConfig.TLSCACertPath = "" + + if err := handler.FileService.DeleteTLSFiles(filesystem.LDAPStorePath); err != nil { + return httperror.InternalServerError("Unable to remove TLS files from disk", err) + } + + return nil +} diff --git a/api/http/handler/ssl/handler.go b/api/http/handler/ssl/handler.go new file mode 100644 index 0000000..c78b2e1 --- /dev/null +++ b/api/http/handler/ssl/handler.go @@ -0,0 +1,30 @@ +package ssl + +import ( + "net/http" + + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/ssl" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + + "github.com/gorilla/mux" +) + +// Handler is the HTTP handler used to handle MOTD operations. +type Handler struct { + *mux.Router + SSLService *ssl.Service +} + +// NewHandler returns a new Handler +func NewHandler(bouncer security.BouncerService) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + } + h.Handle("/ssl", + bouncer.AdminAccess(httperror.LoggerHandler(h.sslInspect))).Methods(http.MethodGet) + h.Handle("/ssl", + bouncer.AdminAccess(httperror.LoggerHandler(h.sslUpdate))).Methods(http.MethodPut) + + return h +} diff --git a/api/http/handler/ssl/ssl_inspect.go b/api/http/handler/ssl/ssl_inspect.go new file mode 100644 index 0000000..2a36494 --- /dev/null +++ b/api/http/handler/ssl/ssl_inspect.go @@ -0,0 +1,30 @@ +package ssl + +import ( + "net/http" + + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +// @id SSLInspect +// @summary Inspect the ssl settings +// @description Retrieve the ssl settings. +// @description **Access policy**: administrator +// @tags ssl +// @security ApiKeyAuth +// @security jwt +// @produce json +// @success 200 {object} portainer.SSLSettings "Success" +// @failure 400 "Invalid request" +// @failure 403 "Permission denied to access settings" +// @failure 500 "Server error" +// @router /ssl [get] +func (handler *Handler) sslInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + settings, err := handler.SSLService.GetSSLSettings() + if err != nil { + return httperror.InternalServerError("Failed to fetch certificate info", err) + } + + return response.JSON(w, settings) +} diff --git a/api/http/handler/ssl/ssl_update.go b/api/http/handler/ssl/ssl_update.go new file mode 100644 index 0000000..71aa303 --- /dev/null +++ b/api/http/handler/ssl/ssl_update.go @@ -0,0 +1,61 @@ +package ssl + +import ( + "errors" + "net/http" + + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +type sslUpdatePayload struct { + // SSL Certificates + Cert *string + Key *string + HTTPEnabled *bool +} + +func (payload *sslUpdatePayload) Validate(r *http.Request) error { + if (payload.Cert == nil || payload.Key == nil) && payload.Cert != payload.Key { + return errors.New("both certificate and key files should be provided") + } + + return nil +} + +// @id SSLUpdate +// @summary Update the ssl settings +// @description Update the ssl settings. +// @description **Access policy**: administrator +// @tags ssl +// @security ApiKeyAuth +// @security jwt +// @accept json +// @produce json +// @param body body sslUpdatePayload true "SSL Settings" +// @success 204 "Success" +// @failure 400 "Invalid request" +// @failure 403 "Permission denied to access settings" +// @failure 500 "Server error" +// @router /ssl [put] +func (handler *Handler) sslUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload sslUpdatePayload + if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + if payload.Cert != nil { + if err := handler.SSLService.SetCertificates([]byte(*payload.Cert), []byte(*payload.Key)); err != nil { + return httperror.InternalServerError("Failed to save certificate", err) + } + } + + if payload.HTTPEnabled != nil { + if err := handler.SSLService.SetHTTPEnabled(*payload.HTTPEnabled); err != nil { + return httperror.InternalServerError("Failed to force https", err) + } + } + + return response.Empty(w) +} diff --git a/api/http/handler/stacks/create_compose_stack.go b/api/http/handler/stacks/create_compose_stack.go new file mode 100644 index 0000000..46d5153 --- /dev/null +++ b/api/http/handler/stacks/create_compose_stack.go @@ -0,0 +1,409 @@ +package stacks + +import ( + "fmt" + "net/http" + "strings" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices/source" + "github.com/portainer/portainer/api/filesystem" + "github.com/portainer/portainer/api/git/update" + "github.com/portainer/portainer/api/gitops/sources" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/stacks/deployments" + "github.com/portainer/portainer/api/stacks/stackbuilders" + "github.com/portainer/portainer/api/stacks/stackutils" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/validate" + + "github.com/pkg/errors" + "github.com/rs/zerolog/log" +) + +type composeStackFromFileContentPayload struct { + // Name of the stack + Name string `example:"myStack" validate:"required"` + // Content of the Stack file + StackFileContent string `example:"version: 3\n services:\n web:\n image:nginx" validate:"required"` + // A list of environment variables used during stack deployment + Env []portainer.Pair + // Whether the stack is from a app template + FromAppTemplate bool `example:"false"` +} + +func (payload *composeStackFromFileContentPayload) Validate(r *http.Request) error { + if len(payload.Name) == 0 { + return errors.New("Invalid stack name") + } + + if len(payload.StackFileContent) == 0 { + return errors.New("Invalid stack file content") + } + return nil +} + +func createStackPayloadFromComposeFileContentPayload(name string, fileContent string, env []portainer.Pair, fromAppTemplate bool) stackbuilders.StackPayload { + return stackbuilders.StackPayload{ + Name: name, + StackFileContent: []byte(fileContent), + Env: env, + FromAppTemplate: fromAppTemplate, + } +} + +func (handler *Handler) checkAndCleanStackDupFromSwarm(_ http.ResponseWriter, _ *http.Request, _ *portainer.Endpoint, _ portainer.UserID, stack *portainer.Stack) error { + resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl) + if err != nil { + return err + } + + // stop scheduler updates of the stack before removal + if stack.AutoUpdate != nil { + deployments.StopAutoupdate(stack.ID, stack.AutoUpdate.JobID, handler.Scheduler) + } + + err = handler.DataStore.Stack().Delete(stack.ID) + if err != nil { + return err + } + + if resourceControl != nil { + err = handler.DataStore.ResourceControl().Delete(resourceControl.ID) + if err != nil { + log.Error(). + Str("stack", fmt.Sprintf("%+v", stack)). + Msg("unable to remove the associated resource control from the database for stack") + } + } + + if exists, _ := handler.FileService.FileExists(stack.ProjectPath); exists { + err = handler.FileService.RemoveDirectory(stack.ProjectPath) + if err != nil { + log.Warn(). + Str("stack", fmt.Sprintf("%+v", stack)). + Msg("unable to remove stack files from disk for stack") + } + } + + return nil +} + +func (handler *Handler) ensureUniqueComposeStackName(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint, userID portainer.UserID, name string) *httperror.HandlerError { + isUnique, err := handler.checkUniqueStackNameInDocker(endpoint, name, 0, false) + if err != nil { + return httperror.InternalServerError("Unable to check for name collision", err) + } else if isUnique { + return nil + } + + stacks, err := handler.DataStore.Stack().StacksByName(name) + if err != nil { + return httperror.InternalServerError("Unable to retrieve the stack from the database", err) + } + + for _, stack := range stacks { + if stack.EndpointID != endpoint.ID { + continue + } + + if stack.Type != portainer.DockerComposeStack { + if err := handler.checkAndCleanStackDupFromSwarm(w, r, endpoint, userID, &stack); err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + continue + } + + return stackExistsError(name) + } + + return nil +} + +// @id StackCreateDockerStandaloneString +// @summary Deploy a new compose stack from a text +// @description Deploy a new stack into a Docker environment specified via the environment identifier. +// @description **Access policy**: authenticated +// @tags stacks +// @security ApiKeyAuth +// @security jwt +// @accept json +// @produce json +// @param body body composeStackFromFileContentPayload true "stack config" +// @param endpointId query int true "Identifier of the environment that will be used to deploy the stack" +// @success 200 {object} portainer.Stack +// @failure 400 "Invalid request" +// @failure 500 "Server error" +// @router /stacks/create/standalone/string [post] +func (handler *Handler) createComposeStackFromFileContent(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint, userID portainer.UserID) *httperror.HandlerError { + var payload composeStackFromFileContentPayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + payload.Name = handler.ComposeStackManager.NormalizeStackName(payload.Name) + + if err := handler.ensureUniqueComposeStackName(w, r, endpoint, userID, payload.Name); err != nil { + return err + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) + } + + stackPayload := createStackPayloadFromComposeFileContentPayload(payload.Name, payload.StackFileContent, payload.Env, payload.FromAppTemplate) + + composeStackBuilder := stackbuilders.CreateComposeStackFileBuilder(securityContext, + handler.DataStore, + handler.FileService, + handler.StackDeployer) + + stack, httpErr := stackbuilders.Build(r.Context(), handler.DataStore, composeStackBuilder, &stackPayload, endpoint, userID) + if httpErr != nil { + return httpErr + } + + return handler.decorateStackResponse(w, stack, userID) +} + +type composeStackFromGitRepositoryPayload struct { + // Name of the stack + Name string `example:"myStack" validate:"required"` + // SourceID references an existing Source for git credentials/URL. + // When set, the inline URL and authentication fields are ignored. + SourceID portainer.SourceID `example:"1"` + // Deprecated: use SourceID instead. URL of a Git repository hosting the Stack file. + RepositoryURL string `example:"https://github.com/openfaas/faas"` + // Reference name of a Git repository hosting the Stack file + RepositoryReferenceName string `example:"refs/heads/master"` + // Deprecated: use SourceID instead. Use basic authentication to clone the Git repository. + RepositoryAuthentication bool `example:"true"` + // Deprecated: use SourceID instead. Username used in basic authentication. + RepositoryUsername string `example:"myGitUsername"` + // Deprecated: use SourceID instead. Password used in basic authentication. + RepositoryPassword string `example:"myGitPassword"` + // Path to the Stack file inside the Git repository + ComposeFile string `example:"docker-compose.yml" default:"docker-compose.yml"` + // Applicable when deploying with multiple stack files + AdditionalFiles []string `example:"[nz.compose.yml, uat.compose.yml]"` + // Optional GitOps update configuration + AutoUpdate *portainer.AutoUpdateSettings + // A list of environment variables used during stack deployment + Env []portainer.Pair + // Whether the stack is from a app template + FromAppTemplate bool `example:"false"` + // Deprecated: use SourceID instead. TLSSkipVerify skips SSL verification when cloning the Git repository. + TLSSkipVerify bool `example:"false"` +} + +func createStackPayloadFromComposeGitPayload(name, repoUrl, repoReference, repoUsername, repoPassword string, repoAuthentication bool, composeFile string, additionalFiles []string, autoUpdate *portainer.AutoUpdateSettings, env []portainer.Pair, fromAppTemplate bool, repoSkipSSLVerify bool, sourceID portainer.SourceID) stackbuilders.StackPayload { + return stackbuilders.StackPayload{ + Name: name, + RepositoryConfigPayload: stackbuilders.RepositoryConfigPayload{ + SourceID: sourceID, + URL: repoUrl, + ReferenceName: repoReference, + Authentication: repoAuthentication, + Username: repoUsername, + Password: repoPassword, + TLSSkipVerify: repoSkipSSLVerify, + }, + ComposeFile: composeFile, + AdditionalFiles: additionalFiles, + AutoUpdate: autoUpdate, + Env: env, + FromAppTemplate: fromAppTemplate, + } +} + +func (payload *composeStackFromGitRepositoryPayload) Validate(r *http.Request) error { + if len(payload.Name) == 0 { + return errors.New("Invalid stack name") + } + + if payload.SourceID == 0 { + if len(payload.RepositoryURL) == 0 || !validate.IsURL(payload.RepositoryURL) { + return errors.New("Invalid repository URL. Must correspond to a valid URL format") + } + if payload.RepositoryAuthentication && len(payload.RepositoryPassword) == 0 { + return errors.New("Invalid repository credentials. Password must be specified when authentication is enabled") + } + } + + return update.ValidateAutoUpdateSettings(payload.AutoUpdate) +} + +// @id StackCreateDockerStandaloneRepository +// @summary Deploy a new compose stack from repository +// @description Deploy a new stack into a Docker environment specified via the environment identifier. +// @description **Access policy**: authenticated +// @tags stacks +// @security ApiKeyAuth +// @security jwt +// @produce json +// @accept json +// @param endpointId query int true "Identifier of the environment that will be used to deploy the stack" +// @param body body composeStackFromGitRepositoryPayload true "stack config" +// @success 200 {object} portainer.Stack +// @failure 400 "Invalid request" +// @failure 409 "Stack name or webhook ID already exists" +// @failure 500 "Server error" +// @router /stacks/create/standalone/repository [post] +func (handler *Handler) createComposeStackFromGitRepository(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint, userID portainer.UserID) *httperror.HandlerError { + var payload composeStackFromGitRepositoryPayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + payload.Name = handler.ComposeStackManager.NormalizeStackName(payload.Name) + if payload.ComposeFile == "" { + payload.ComposeFile = filesystem.ComposeFileDefaultName + } + + if err := handler.ensureUniqueComposeStackName(w, r, endpoint, userID, payload.Name); err != nil { + return err + } + + //make sure the webhook ID is unique + if payload.AutoUpdate != nil && payload.AutoUpdate.Webhook != "" { + isUnique, err := handler.checkUniqueWebhookID(handler.DataStore, payload.AutoUpdate.Webhook) + if err != nil { + return httperror.InternalServerError("Unable to check for webhook ID collision", err) + } + if !isUnique { + return httperror.Conflict(fmt.Sprintf("Webhook ID: %s already exists", payload.AutoUpdate.Webhook), stackutils.ErrWebhookIDAlreadyExists) + } + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve user info from request context", err) + } + userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships) + + if payload.SourceID != 0 { + if _, httpErr := sources.ValidateGitSourceAccess(handler.DataStore, userContext, payload.SourceID); httpErr != nil { + return httpErr + } + } + + stackPayload := createStackPayloadFromComposeGitPayload(payload.Name, + strings.TrimSuffix(payload.RepositoryURL, "/"), + payload.RepositoryReferenceName, + payload.RepositoryUsername, + payload.RepositoryPassword, + payload.RepositoryAuthentication, + payload.ComposeFile, + payload.AdditionalFiles, + payload.AutoUpdate, + payload.Env, + payload.FromAppTemplate, + payload.TLSSkipVerify, + payload.SourceID, + ) + + composeStackBuilder := stackbuilders.CreateComposeStackGitBuilder(securityContext, + handler.DataStore, + handler.FileService, + handler.GitService, + handler.Scheduler, + handler.StackDeployer) + + stack, httpErr := stackbuilders.Build(r.Context(), handler.DataStore, composeStackBuilder, &stackPayload, endpoint, userID) + if httpErr != nil { + return httpErr + } + + return handler.decorateStackResponse(w, stack, userID) +} + +type composeStackFromFileUploadPayload struct { + Name string + StackFileContent []byte + Env []portainer.Pair +} + +func createStackPayloadFromComposeFileUploadPayload(name string, fileContentBytes []byte, env []portainer.Pair) stackbuilders.StackPayload { + return stackbuilders.StackPayload{ + Name: name, + StackFileContent: fileContentBytes, + Env: env, + } +} + +func decodeRequestForm(r *http.Request) (*composeStackFromFileUploadPayload, error) { + payload := &composeStackFromFileUploadPayload{} + name, err := request.RetrieveMultiPartFormValue(r, "Name", false) + if err != nil { + return nil, errors.New("Invalid stack name") + } + payload.Name = name + + composeFileContent, _, err := request.RetrieveMultiPartFormFile(r, "file") + if err != nil { + return nil, 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 nil, errors.New("Invalid Env parameter") + } + payload.Env = env + return payload, nil +} + +// @id StackCreateDockerStandaloneFile +// @summary Deploy a new compose stack from a file +// @description Deploy a new stack into a Docker environment specified via the environment identifier. +// @description **Access policy**: authenticated +// @tags stacks +// @security ApiKeyAuth +// @security jwt +// @accept multipart/form-data +// @produce json +// @param Name formData string true "Name of the stack" +// @param Env formData string false "Environment variables passed during deployment, represented as a JSON array [{'name': 'name', 'value': 'value'}]." +// @param file formData file false "Stack file" +// @param endpointId query int true "Identifier of the environment that will be used to deploy the stack" +// @success 200 {object} portainer.Stack +// @failure 400 "Invalid request" +// @failure 500 "Server error" +// @router /stacks/create/standalone/file [post] +func (handler *Handler) createComposeStackFromFileUpload(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint, userID portainer.UserID) *httperror.HandlerError { + payload, err := decodeRequestForm(r) + if err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + payload.Name = handler.ComposeStackManager.NormalizeStackName(payload.Name) + + if err := handler.ensureUniqueComposeStackName(w, r, endpoint, userID, payload.Name); err != nil { + return err + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) + } + + stackPayload := createStackPayloadFromComposeFileUploadPayload(payload.Name, payload.StackFileContent, payload.Env) + + composeStackBuilder := stackbuilders.CreateComposeStackFileBuilder(securityContext, + handler.DataStore, + handler.FileService, + handler.StackDeployer) + + stack, httpErr := stackbuilders.Build(r.Context(), handler.DataStore, composeStackBuilder, &stackPayload, endpoint, userID) + if httpErr != nil { + return httpErr + } + + return handler.decorateStackResponse(w, stack, userID) +} diff --git a/api/http/handler/stacks/create_compose_stack_test.go b/api/http/handler/stacks/create_compose_stack_test.go new file mode 100644 index 0000000..89b6591 --- /dev/null +++ b/api/http/handler/stacks/create_compose_stack_test.go @@ -0,0 +1,31 @@ +package stacks + +import ( + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/stretchr/testify/assert" +) + +func TestComposeGitPayload_ValidateWithSourceID_URLNotRequired(t *testing.T) { + t.Parallel() + payload := &composeStackFromGitRepositoryPayload{ + Name: "mystack", + SourceID: portainer.SourceID(1), + // RepositoryURL intentionally omitted + } + + err := payload.Validate(nil) + assert.NoError(t, err) +} + +func TestComposeGitPayload_ValidateWithoutSourceID_URLRequired(t *testing.T) { + t.Parallel() + payload := &composeStackFromGitRepositoryPayload{ + Name: "mystack", + // SourceID and RepositoryURL both omitted + } + + err := payload.Validate(nil) + assert.Error(t, err) +} 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 0000000..48766e8 --- /dev/null +++ b/api/http/handler/stacks/create_kubernetes_stack.go @@ -0,0 +1,325 @@ +package stacks + +import ( + "fmt" + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices/source" + "github.com/portainer/portainer/api/git/update" + "github.com/portainer/portainer/api/gitops/sources" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/endpointutils" + "github.com/portainer/portainer/api/internal/registryutils" + "github.com/portainer/portainer/api/stacks/stackbuilders" + "github.com/portainer/portainer/api/stacks/stackutils" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" + "github.com/portainer/portainer/pkg/libhttp/ssrf" + "github.com/portainer/portainer/pkg/validate" + + "github.com/pkg/errors" +) + +type kubernetesStringDeploymentPayload struct { + StackName string + ComposeFormat bool + Namespace string + StackFileContent string + // Whether the stack is from a app template + FromAppTemplate bool `example:"false"` +} + +func createStackPayloadFromK8sFileContentPayload(name, namespace, fileContent string, composeFormat, fromAppTemplate bool) stackbuilders.StackPayload { + return stackbuilders.StackPayload{ + StackName: name, + Namespace: namespace, + StackFileContent: []byte(fileContent), + FromAppTemplate: fromAppTemplate, + } +} + +type kubernetesGitDeploymentPayload struct { + StackName string + ComposeFormat bool + Namespace string + // SourceID references an existing Source for git credentials/URL. + // When set, the inline URL and authentication fields are ignored. + SourceID portainer.SourceID `example:"1"` + // Deprecated: use SourceID instead. URL of a Git repository hosting the Stack file. + RepositoryURL string + // Deprecated: use SourceID instead. Reference name of a Git repository hosting the Stack file. + RepositoryReferenceName string + // Deprecated: use SourceID instead. Use basic authentication to clone the Git repository. + RepositoryAuthentication bool + // Deprecated: use SourceID instead. Username used in basic authentication. + RepositoryUsername string + // Deprecated: use SourceID instead. Password used in basic authentication. + RepositoryPassword string + ManifestFile string + AdditionalFiles []string + AutoUpdate *portainer.AutoUpdateSettings + // Deprecated: use SourceID instead. TLSSkipVerify skips SSL verification when cloning the Git repository. + TLSSkipVerify bool `example:"false"` +} + +func createStackPayloadFromK8sGitPayload(name, repoUrl, repoReference, repoUsername, repoPassword string, repoAuthentication, composeFormat bool, namespace, manifest string, additionalFiles []string, autoUpdate *portainer.AutoUpdateSettings, repoSkipSSLVerify bool, sourceID portainer.SourceID) stackbuilders.StackPayload { + return stackbuilders.StackPayload{ + StackName: name, + RepositoryConfigPayload: stackbuilders.RepositoryConfigPayload{ + SourceID: sourceID, + URL: repoUrl, + ReferenceName: repoReference, + Authentication: repoAuthentication, + Username: repoUsername, + Password: repoPassword, + TLSSkipVerify: repoSkipSSLVerify, + }, + Namespace: namespace, + ManifestFile: manifest, + AdditionalFiles: additionalFiles, + AutoUpdate: autoUpdate, + } +} + +type kubernetesManifestURLDeploymentPayload struct { + StackName string + Namespace string + ComposeFormat bool + ManifestURL string +} + +func createStackPayloadFromK8sUrlPayload(name, namespace, manifestUrl string, composeFormat bool) stackbuilders.StackPayload { + return stackbuilders.StackPayload{ + StackName: name, + Namespace: namespace, + ManifestURL: manifestUrl, + } +} + +func (payload *kubernetesStringDeploymentPayload) Validate(r *http.Request) error { + if len(payload.StackFileContent) == 0 { + return errors.New("Invalid stack file content") + } + + return nil +} + +func (payload *kubernetesGitDeploymentPayload) Validate(r *http.Request) error { + if payload.SourceID == 0 { + if len(payload.RepositoryURL) == 0 || !validate.IsURL(payload.RepositoryURL) { + return errors.New("Invalid repository URL. Must correspond to a valid URL format") + } + if payload.RepositoryAuthentication && len(payload.RepositoryPassword) == 0 { + return errors.New("Invalid repository credentials. Password must be specified when authentication is enabled") + } + } + + if len(payload.ManifestFile) == 0 { + return errors.New("Invalid manifest file in repository") + } + + return update.ValidateAutoUpdateSettings(payload.AutoUpdate) +} + +func (payload *kubernetesManifestURLDeploymentPayload) Validate(r *http.Request) error { + if len(payload.ManifestURL) == 0 || !validate.IsURL(payload.ManifestURL) { + return errors.New("Invalid manifest URL") + } + + if err := ssrf.CheckURL(r.Context(), payload.ManifestURL); err != nil { + return err + } + + return nil +} + +type createKubernetesStackResponse struct { + Output string `json:"Output"` +} + +// @id StackCreateKubernetesFile +// @summary Deploy a new kubernetes stack from a file +// @description Deploy a new stack into a Docker environment specified via the environment identifier. +// @description **Access policy**: authenticated +// @tags stacks +// @security ApiKeyAuth +// @security jwt +// @produce json +// @param body body kubernetesStringDeploymentPayload true "stack config" +// @param endpointId query int true "Identifier of the environment that will be used to deploy the stack" +// @success 200 {object} portainer.Stack +// @failure 400 "Invalid request" +// @failure 500 "Server error" +// @router /stacks/create/kubernetes/string [post] +func (handler *Handler) createKubernetesStackFromFileContent(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint, userID portainer.UserID) *httperror.HandlerError { + if !endpointutils.IsKubernetesEndpoint(endpoint) { + return httperror.BadRequest("Environment type does not match", errors.New("Environment type does not match")) + } + + var payload kubernetesStringDeploymentPayload + if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + user, err := handler.DataStore.User().Read(userID) + if err != nil { + return httperror.InternalServerError("Unable to load user information from the database", err) + } + + stackPayload := createStackPayloadFromK8sFileContentPayload(payload.StackName, payload.Namespace, payload.StackFileContent, payload.ComposeFormat, payload.FromAppTemplate) + + k8sStackBuilder := stackbuilders.CreateK8sStackFileContentBuilder(handler.DataStore, + handler.FileService, + handler.StackDeployer, + handler.KubernetesDeployer, + user) + + // Refresh ECR registry secret if needed + // RefreshEcrSecret method checks if the namespace has any ECR registry + // otherwise return nil + cli, err := handler.KubernetesClientFactory.GetPrivilegedKubeClient(endpoint) + if err == nil { + if err := registryutils.RefreshEcrSecret(cli, endpoint, handler.DataStore, payload.Namespace); err != nil { + return httperror.InternalServerError("Unable to refresh ECR registry secret", err) + } + } + + if _, err := stackbuilders.Build(r.Context(), handler.DataStore, k8sStackBuilder, &stackPayload, endpoint, userID); err != nil { + return err + } + + resp := &createKubernetesStackResponse{ + Output: k8sStackBuilder.GetResponse(), + } + + return response.JSON(w, resp) +} + +// @id StackCreateKubernetesGit +// @summary Deploy a new kubernetes stack from a git repository +// @description Deploy a new stack into a Docker environment specified via the environment identifier. +// @description **Access policy**: authenticated +// @tags stacks +// @security ApiKeyAuth +// @security jwt +// @produce json +// @param body body kubernetesGitDeploymentPayload true "stack config" +// @param endpointId query int true "Identifier of the environment that will be used to deploy the stack" +// @success 200 {object} portainer.Stack +// @failure 400 "Invalid request" +// @failure 409 "Stack name or webhook ID already exists" +// @failure 500 "Server error" +// @router /stacks/create/kubernetes/repository [post] +func (handler *Handler) createKubernetesStackFromGitRepository(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint, userID portainer.UserID) *httperror.HandlerError { + if !endpointutils.IsKubernetesEndpoint(endpoint) { + return httperror.BadRequest("Environment type does not match", errors.New("Environment type does not match")) + } + + var payload kubernetesGitDeploymentPayload + if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + user, err := handler.DataStore.User().Read(userID) + if err != nil { + return httperror.InternalServerError("Unable to load user information from the database", err) + } + + // Make sure the webhook ID is unique + if payload.AutoUpdate != nil && payload.AutoUpdate.Webhook != "" { + if isUnique, err := handler.checkUniqueWebhookID(handler.DataStore, payload.AutoUpdate.Webhook); err != nil { + return httperror.InternalServerError("Unable to check for webhook ID collision", err) + } else if !isUnique { + return httperror.Conflict(fmt.Sprintf("Webhook ID: %s already exists", payload.AutoUpdate.Webhook), stackutils.ErrWebhookIDAlreadyExists) + } + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve user info from request context", err) + } + userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships) + if payload.SourceID != 0 { + if _, httpErr := sources.ValidateGitSourceAccess(handler.DataStore, userContext, payload.SourceID); httpErr != nil { + return httpErr + } + } + + stackPayload := createStackPayloadFromK8sGitPayload(payload.StackName, + payload.RepositoryURL, + payload.RepositoryReferenceName, + payload.RepositoryUsername, + payload.RepositoryPassword, + payload.RepositoryAuthentication, + payload.ComposeFormat, + payload.Namespace, + payload.ManifestFile, + payload.AdditionalFiles, + payload.AutoUpdate, + payload.TLSSkipVerify, + payload.SourceID, + ) + + k8sStackBuilder := stackbuilders.CreateKubernetesStackGitBuilder(handler.DataStore, + handler.FileService, + handler.GitService, + handler.Scheduler, + handler.StackDeployer, + handler.KubernetesDeployer, + user) + + if _, err := stackbuilders.Build(r.Context(), handler.DataStore, k8sStackBuilder, &stackPayload, endpoint, userID); err != nil { + return err + } + + return response.JSON(w, &createKubernetesStackResponse{ + Output: k8sStackBuilder.GetResponse(), + }) +} + +// @id StackCreateKubernetesUrl +// @summary Deploy a new kubernetes stack from a url +// @description Deploy a new stack into a Docker environment specified via the environment identifier. +// @description **Access policy**: authenticated +// @tags stacks +// @security ApiKeyAuth +// @security jwt +// @produce json +// @param body body kubernetesManifestURLDeploymentPayload true "stack config" +// @param endpointId query int true "Identifier of the environment that will be used to deploy the stack" +// @success 200 {object} portainer.Stack +// @failure 400 "Invalid request" +// @failure 500 "Server error" +// @router /stacks/create/kubernetes/url [post] +func (handler *Handler) createKubernetesStackFromManifestURL(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint, userID portainer.UserID) *httperror.HandlerError { + var payload kubernetesManifestURLDeploymentPayload + if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + user, err := handler.DataStore.User().Read(userID) + if err != nil { + return httperror.InternalServerError("Unable to load user information from the database", err) + } + + stackPayload := createStackPayloadFromK8sUrlPayload(payload.StackName, + payload.Namespace, + payload.ManifestURL, + payload.ComposeFormat) + + k8sStackBuilder := stackbuilders.CreateKubernetesStackUrlBuilder(handler.DataStore, + handler.FileService, + handler.StackDeployer, + handler.KubernetesDeployer, + user) + + if _, err := stackbuilders.Build(r.Context(), handler.DataStore, k8sStackBuilder, &stackPayload, endpoint, userID); err != nil { + return err + } + + return response.JSON(w, &createKubernetesStackResponse{ + Output: k8sStackBuilder.GetResponse(), + }) +} diff --git a/api/http/handler/stacks/create_kubernetes_stack_test.go b/api/http/handler/stacks/create_kubernetes_stack_test.go new file mode 100644 index 0000000..82f4729 --- /dev/null +++ b/api/http/handler/stacks/create_kubernetes_stack_test.go @@ -0,0 +1,67 @@ +package stacks + +import ( + "testing" + + portainer "github.com/portainer/portainer/api" + + "github.com/stretchr/testify/require" +) + +func TestKubernetesGitDeploymentPayloadValidate_WithSourceID_URLNotRequired(t *testing.T) { + t.Parallel() + + p := kubernetesGitDeploymentPayload{ + SourceID: portainer.SourceID(1), + ManifestFile: "manifest.yaml", + } + err := p.Validate(nil) + require.NoError(t, err) +} + +func TestKubernetesGitDeploymentPayloadValidate_WithSourceID_AuthNotRequired(t *testing.T) { + t.Parallel() + + p := kubernetesGitDeploymentPayload{ + SourceID: portainer.SourceID(1), + RepositoryAuthentication: true, + // Password intentionally omitted — should not fail when SourceID is set + ManifestFile: "manifest.yaml", + } + err := p.Validate(nil) + require.NoError(t, err) +} + +func TestKubernetesGitDeploymentPayloadValidate_WithoutSourceID_URLRequired(t *testing.T) { + t.Parallel() + + p := kubernetesGitDeploymentPayload{ + ManifestFile: "manifest.yaml", + // SourceID and RepositoryURL both omitted + } + err := p.Validate(nil) + require.Error(t, err) +} + +func TestCreateStackPayloadFromK8sGitPayload_WithSourceID(t *testing.T) { + t.Parallel() + + p := createStackPayloadFromK8sGitPayload( + "k8s-stack", + "", + "", + "", + "", + false, + false, + "default", + "manifest.yaml", + nil, + nil, + false, + portainer.SourceID(7), + ) + + require.Equal(t, portainer.SourceID(7), p.SourceID) + require.Equal(t, "manifest.yaml", p.ManifestFile) +} diff --git a/api/http/handler/stacks/create_swarm_stack.go b/api/http/handler/stacks/create_swarm_stack.go new file mode 100644 index 0000000..127e637 --- /dev/null +++ b/api/http/handler/stacks/create_swarm_stack.go @@ -0,0 +1,362 @@ +package stacks + +import ( + "fmt" + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices/source" + "github.com/portainer/portainer/api/git/update" + "github.com/portainer/portainer/api/gitops/sources" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/stacks/stackbuilders" + "github.com/portainer/portainer/api/stacks/stackutils" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + valid "github.com/portainer/portainer/pkg/validate" + + "github.com/pkg/errors" +) + +type swarmStackFromFileContentPayload struct { + // Name of the stack + Name string `example:"myStack" validate:"required"` + // Swarm cluster identifier + SwarmID string `example:"jpofkc0i9uo9wtx1zesuk649w" validate:"required"` + // Content of the Stack file + StackFileContent string `example:"version: 3\n services:\n web:\n image:nginx" validate:"required"` + // A list of environment variables used during stack deployment + Env []portainer.Pair + // Whether the stack is from a app template + FromAppTemplate bool `example:"false"` +} + +func (payload *swarmStackFromFileContentPayload) Validate(r *http.Request) error { + if len(payload.Name) == 0 { + return errors.New("Invalid stack name") + } + if len(payload.SwarmID) == 0 { + return errors.New("Invalid Swarm ID") + } + if len(payload.StackFileContent) == 0 { + return errors.New("Invalid stack file content") + } + return nil +} + +func createStackPayloadFromSwarmFileContentPayload(name string, swarmID string, fileContent string, env []portainer.Pair, fromAppTemplate bool) stackbuilders.StackPayload { + return stackbuilders.StackPayload{ + Name: name, + SwarmID: swarmID, + StackFileContent: []byte(fileContent), + Env: env, + FromAppTemplate: fromAppTemplate, + } +} + +// @id StackCreateDockerSwarmString +// @summary Deploy a new swarm stack from a text +// @description Deploy a new stack into a Docker environment specified via the environment identifier. +// @description **Access policy**: authenticated +// @tags stacks +// @security ApiKeyAuth +// @security jwt +// @accept json +// @produce json +// @param body body swarmStackFromFileContentPayload true "stack config" +// @param endpointId query int true "Identifier of the environment that will be used to deploy the stack" +// @success 200 {object} portainer.Stack +// @failure 400 "Invalid request" +// @failure 500 "Server error" +// @router /stacks/create/swarm/string [post] +func (handler *Handler) createSwarmStackFromFileContent(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint, userID portainer.UserID) *httperror.HandlerError { + var payload swarmStackFromFileContentPayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + payload.Name = handler.SwarmStackManager.NormalizeStackName(payload.Name) + + isUnique, err := handler.checkUniqueStackNameInDocker(endpoint, payload.Name, 0, true) + if err != nil { + return httperror.InternalServerError("Unable to check for name collision", err) + } + if !isUnique { + return stackExistsError(payload.Name) + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) + } + + stackPayload := createStackPayloadFromSwarmFileContentPayload(payload.Name, payload.SwarmID, payload.StackFileContent, payload.Env, payload.FromAppTemplate) + + swarmStackBuilder := stackbuilders.CreateSwarmStackFileBuilder(securityContext, + handler.DataStore, + handler.FileService, + handler.StackDeployer) + + stack, httpErr := stackbuilders.Build(r.Context(), handler.DataStore, swarmStackBuilder, &stackPayload, endpoint, userID) + if httpErr != nil { + return httpErr + } + + return handler.decorateStackResponse(w, stack, userID) +} + +type swarmStackFromGitRepositoryPayload struct { + // Name of the stack + Name string `example:"myStack" validate:"required"` + // Swarm cluster identifier + SwarmID string `example:"jpofkc0i9uo9wtx1zesuk649w" validate:"required"` + // A list of environment variables used during stack deployment + Env []portainer.Pair + + // SourceID references an existing Source for git credentials/URL. + // When set, the inline URL and authentication fields are ignored. + SourceID portainer.SourceID `example:"1"` + // Deprecated: use SourceID instead. URL of a Git repository hosting the Stack file. + RepositoryURL string `example:"https://github.com/openfaas/faas"` + // Reference name of a Git repository hosting the Stack file + RepositoryReferenceName string `example:"refs/heads/master"` + // Deprecated: use SourceID instead. Use basic authentication to clone the Git repository. + RepositoryAuthentication bool `example:"true"` + // Deprecated: use SourceID instead. Username used in basic authentication. + RepositoryUsername string `example:"myGitUsername"` + // Deprecated: use SourceID instead. Password used in basic authentication. + RepositoryPassword string `example:"myGitPassword"` + // Whether the stack is from a app template + FromAppTemplate bool `example:"false"` + // Path to the Stack file inside the Git repository + ComposeFile string `example:"docker-compose.yml" default:"docker-compose.yml"` + // Applicable when deploying with multiple stack files + AdditionalFiles []string `example:"[nz.compose.yml, uat.compose.yml]"` + // Optional GitOps update configuration + AutoUpdate *portainer.AutoUpdateSettings + // Deprecated: use SourceID instead. TLSSkipVerify skips SSL verification when cloning the Git repository. + TLSSkipVerify bool `example:"false"` +} + +func (payload *swarmStackFromGitRepositoryPayload) Validate(r *http.Request) error { + if len(payload.Name) == 0 { + return errors.New("Invalid stack name") + } + if len(payload.SwarmID) == 0 { + return errors.New("Invalid Swarm ID") + } + + if payload.SourceID == 0 { + if len(payload.RepositoryURL) == 0 || !valid.IsURL(payload.RepositoryURL) { + return errors.New("Invalid repository URL. Must correspond to a valid URL format") + } + if payload.RepositoryAuthentication && len(payload.RepositoryPassword) == 0 { + return errors.New("Invalid repository credentials. Password must be specified when authentication is enabled") + } + } + + return update.ValidateAutoUpdateSettings(payload.AutoUpdate) +} + +func createStackPayloadFromSwarmGitPayload(name, swarmID, repoUrl, repoReference, repoUsername, repoPassword string, repoAuthentication bool, composeFile string, additionalFiles []string, autoUpdate *portainer.AutoUpdateSettings, env []portainer.Pair, fromAppTemplate bool, repoSkipSSLVerify bool, sourceID portainer.SourceID) stackbuilders.StackPayload { + return stackbuilders.StackPayload{ + Name: name, + SwarmID: swarmID, + RepositoryConfigPayload: stackbuilders.RepositoryConfigPayload{ + SourceID: sourceID, + URL: repoUrl, + ReferenceName: repoReference, + Authentication: repoAuthentication, + Username: repoUsername, + Password: repoPassword, + TLSSkipVerify: repoSkipSSLVerify, + }, + ComposeFile: composeFile, + AdditionalFiles: additionalFiles, + AutoUpdate: autoUpdate, + Env: env, + FromAppTemplate: fromAppTemplate, + } +} + +// @id StackCreateDockerSwarmRepository +// @summary Deploy a new swarm stack from a git repository +// @description Deploy a new stack into a Docker environment specified via the environment identifier. +// @description **Access policy**: authenticated +// @tags stacks +// @security ApiKeyAuth +// @security jwt +// @produce json +// @accept json +// @param endpointId query int true "Identifier of the environment that will be used to deploy the stack" +// @param body body swarmStackFromGitRepositoryPayload true "stack config" +// @success 200 {object} portainer.Stack +// @failure 400 "Invalid request" +// @failure 409 "Stack name or webhook ID already exists" +// @failure 500 "Server error" +// @router /stacks/create/swarm/repository [post] +func (handler *Handler) createSwarmStackFromGitRepository(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint, userID portainer.UserID) *httperror.HandlerError { + var payload swarmStackFromGitRepositoryPayload + if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + payload.Name = handler.SwarmStackManager.NormalizeStackName(payload.Name) + + if isUnique, err := handler.checkUniqueStackNameInDocker(endpoint, payload.Name, 0, true); err != nil { + return httperror.InternalServerError("Unable to check for name collision", err) + } else if !isUnique { + return stackExistsError(payload.Name) + } + + //make sure the webhook ID is unique + if payload.AutoUpdate != nil && payload.AutoUpdate.Webhook != "" { + if isUnique, err := handler.checkUniqueWebhookID(handler.DataStore, payload.AutoUpdate.Webhook); err != nil { + return httperror.InternalServerError("Unable to check for webhook ID collision", err) + } else if !isUnique { + return httperror.Conflict(fmt.Sprintf("Webhook ID: %s already exists", payload.AutoUpdate.Webhook), stackutils.ErrWebhookIDAlreadyExists) + } + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve user info from request context", err) + } + userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships) + if payload.SourceID != 0 { + if _, httpErr := sources.ValidateGitSourceAccess(handler.DataStore, userContext, payload.SourceID); httpErr != nil { + return httpErr + } + } + + stackPayload := createStackPayloadFromSwarmGitPayload(payload.Name, + payload.SwarmID, + payload.RepositoryURL, + payload.RepositoryReferenceName, + payload.RepositoryUsername, + payload.RepositoryPassword, + payload.RepositoryAuthentication, + payload.ComposeFile, + payload.AdditionalFiles, + payload.AutoUpdate, + payload.Env, + payload.FromAppTemplate, + payload.TLSSkipVerify, + payload.SourceID, + ) + + swarmStackBuilder := stackbuilders.CreateSwarmStackGitBuilder(securityContext, + handler.DataStore, + handler.FileService, + handler.GitService, + handler.Scheduler, + handler.StackDeployer) + + stack, httpErr := stackbuilders.Build(r.Context(), handler.DataStore, swarmStackBuilder, &stackPayload, endpoint, userID) + if httpErr != nil { + return httpErr + } + + return handler.decorateStackResponse(w, stack, userID) +} + +type swarmStackFromFileUploadPayload struct { + Name string + SwarmID string + StackFileContent []byte + Env []portainer.Pair +} + +func createStackPayloadFromSwarmFileUploadPayload(name, swarmID string, fileContentBytes []byte, env []portainer.Pair) stackbuilders.StackPayload { + return stackbuilders.StackPayload{ + Name: name, + SwarmID: swarmID, + StackFileContent: fileContentBytes, + Env: env, + } +} + +func (payload *swarmStackFromFileUploadPayload) Validate(r *http.Request) error { + name, err := request.RetrieveMultiPartFormValue(r, "Name", false) + if err != nil { + return errors.New("Invalid stack name") + } + payload.Name = name + + swarmID, err := request.RetrieveMultiPartFormValue(r, "SwarmID", false) + if err != nil { + return errors.New("Invalid Swarm ID") + } + payload.SwarmID = swarmID + + composeFileContent, _, err := request.RetrieveMultiPartFormFile(r, "file") + if err != nil { + 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 errors.New("Invalid Env parameter") + } + payload.Env = env + return nil +} + +// @id StackCreateDockerSwarmFile +// @summary Deploy a new swarm stack from a file +// @description Deploy a new stack into a Docker environment specified via the environment identifier. +// @description **Access policy**: authenticated +// @tags stacks +// @security ApiKeyAuth +// @security jwt +// @accept multipart/form-data +// @produce json +// @param Name formData string false "Name of the stack" +// @param SwarmID formData string false "Swarm cluster identifier." +// @param Env formData string false "Environment variables passed during deployment, represented as a JSON array [{'name': 'name', 'value': 'value'}]. Optional" +// @param file formData file false "Stack file" +// @param endpointId query int true "Identifier of the environment that will be used to deploy the stack" +// @success 200 {object} portainer.Stack +// @failure 400 "Invalid request" +// @failure 500 "Server error" +// @router /stacks/create/swarm/file [post] +func (handler *Handler) createSwarmStackFromFileUpload(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint, userID portainer.UserID) *httperror.HandlerError { + var payload swarmStackFromFileUploadPayload + err := payload.Validate(r) + if err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + payload.Name = handler.SwarmStackManager.NormalizeStackName(payload.Name) + + isUnique, err := handler.checkUniqueStackNameInDocker(endpoint, payload.Name, 0, true) + + if err != nil { + return httperror.InternalServerError("Unable to check for name collision", err) + } + if !isUnique { + return stackExistsError(payload.Name) + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) + } + + stackPayload := createStackPayloadFromSwarmFileUploadPayload(payload.Name, payload.SwarmID, payload.StackFileContent, payload.Env) + + swarmStackBuilder := stackbuilders.CreateSwarmStackFileBuilder(securityContext, + handler.DataStore, + handler.FileService, + handler.StackDeployer) + + stack, httpErr := stackbuilders.Build(r.Context(), handler.DataStore, swarmStackBuilder, &stackPayload, endpoint, userID) + if httpErr != nil { + return httpErr + } + + return handler.decorateStackResponse(w, stack, userID) +} diff --git a/api/http/handler/stacks/create_swarm_stack_test.go b/api/http/handler/stacks/create_swarm_stack_test.go new file mode 100644 index 0000000..7efb832 --- /dev/null +++ b/api/http/handler/stacks/create_swarm_stack_test.go @@ -0,0 +1,31 @@ +package stacks + +import ( + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/stretchr/testify/assert" +) + +func TestSwarmGitPayload_ValidateWithSourceID_URLNotRequired(t *testing.T) { + t.Parallel() + payload := &swarmStackFromGitRepositoryPayload{ + Name: "myswarm", + SwarmID: "swarm-abc", + SourceID: portainer.SourceID(1), + } + + err := payload.Validate(nil) + assert.NoError(t, err) +} + +func TestSwarmGitPayload_ValidateWithoutSourceID_URLRequired(t *testing.T) { + t.Parallel() + payload := &swarmStackFromGitRepositoryPayload{ + Name: "myswarm", + SwarmID: "swarm-abc", + } + + err := payload.Validate(nil) + assert.Error(t, err) +} diff --git a/api/http/handler/stacks/deploy_gate.go b/api/http/handler/stacks/deploy_gate.go new file mode 100644 index 0000000..88c1f16 --- /dev/null +++ b/api/http/handler/stacks/deploy_gate.go @@ -0,0 +1,30 @@ +package stacks + +type deployGate struct { + start chan struct{} + abort chan struct{} +} + +func newDeployGate() *deployGate { + return &deployGate{ + start: make(chan struct{}), + abort: make(chan struct{}), + } +} + +func (gate deployGate) wait() bool { + select { + case <-gate.start: + return true + case <-gate.abort: + return false + } +} + +func (gate deployGate) startDeploy() { + close(gate.start) +} + +func (gate deployGate) abortDeploy() { + close(gate.abort) +} diff --git a/api/http/handler/stacks/handler.go b/api/http/handler/stacks/handler.go new file mode 100644 index 0000000..352f640 --- /dev/null +++ b/api/http/handler/stacks/handler.go @@ -0,0 +1,216 @@ +package stacks + +import ( + "context" + "fmt" + "net/http" + "strings" + "sync" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + dockerclient "github.com/portainer/portainer/api/docker/client" + "github.com/portainer/portainer/api/docker/consts" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/authorization" + "github.com/portainer/portainer/api/internal/endpointutils" + "github.com/portainer/portainer/api/kubernetes/cli" + "github.com/portainer/portainer/api/logs" + "github.com/portainer/portainer/api/scheduler" + "github.com/portainer/portainer/api/stacks/deployments" + "github.com/portainer/portainer/api/stacks/stackutils" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/container" + "github.com/gorilla/mux" + "github.com/pkg/errors" +) + +// Handler is the HTTP handler used to handle stack operations. +type Handler struct { + stackCreationMutex *sync.Mutex + stackDeletionMutex *sync.Mutex + requestBouncer security.BouncerService + *mux.Router + DataStore dataservices.DataStore + DockerClientFactory *dockerclient.ClientFactory + FileService portainer.FileService + GitService portainer.GitService + SwarmStackManager portainer.SwarmStackManager + ComposeStackManager portainer.ComposeStackManager + KubernetesDeployer portainer.KubernetesDeployer + KubernetesClientFactory *cli.ClientFactory + Scheduler *scheduler.Scheduler + StackDeployer deployments.StackDeployer +} + +func stackExistsError(name string) *httperror.HandlerError { + msg := fmt.Sprintf("A stack with the normalized name '%s' already exists", name) + err := errors.New(msg) + return httperror.Conflict(msg, err) +} + +// NewHandler creates a handler to manage stack operations. +func NewHandler(bouncer security.BouncerService) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + stackCreationMutex: &sync.Mutex{}, + stackDeletionMutex: &sync.Mutex{}, + requestBouncer: bouncer, + } + + h.Handle("/stacks/create/{type}/{method}", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackCreate))).Methods(http.MethodPost) + h.Handle("/stacks", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackList))).Methods(http.MethodGet) + h.Handle("/stacks/{id}", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackInspect))).Methods(http.MethodGet) + h.Handle("/stacks/{id}", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackDelete))).Methods(http.MethodDelete) + h.Handle("/stacks/{id}/associate", + bouncer.AdminAccess(httperror.LoggerHandler(h.stackAssociate))).Methods(http.MethodPut) + h.Handle("/stacks/name/{name}", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackDeleteKubernetesByName))).Methods(http.MethodDelete) + h.Handle("/stacks/{id}", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackUpdate))).Methods(http.MethodPut) + h.Handle("/stacks/{id}/git", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackUpdateGit))).Methods(http.MethodPost) + h.Handle("/stacks/{id}/git/redeploy", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackGitRedeploy))).Methods(http.MethodPut) + h.Handle("/stacks/{id}/file", + 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) + h.Handle("/stacks/webhooks/{webhookID}", + bouncer.PublicAccess(httperror.LoggerHandler(h.webhookInvoke))).Methods(http.MethodPost) + + return h +} + +func (handler *Handler) userCanAccessStack(securityContext *security.RestrictedRequestContext, resourceControl *portainer.ResourceControl) (bool, error) { + user, err := handler.DataStore.User().Read(securityContext.UserID) + if err != nil { + return false, err + } + + userTeamIDs := authorization.TeamIDs(securityContext.UserMemberships) + + if resourceControl != nil && authorization.UserCanAccessResource(securityContext.UserID, userTeamIDs, resourceControl) { + return true, nil + } + + return stackutils.UserIsAdminOrEndpointAdmin(user), nil +} + +func (handler *Handler) userIsAdmin(userID portainer.UserID) (bool, error) { + user, err := handler.DataStore.User().Read(userID) + if err != nil { + return false, err + } + + isAdmin := user.Role == portainer.AdministratorRole + + return isAdmin, nil +} + +func (handler *Handler) userCanCreateStack(securityContext *security.RestrictedRequestContext) (bool, error) { + user, err := handler.DataStore.User().Read(securityContext.UserID) + if err != nil { + return false, err + } + + return stackutils.UserIsAdminOrEndpointAdmin(user), nil +} + +// if stack management is disabled for non admins and the user isn't an admin, then return false. Otherwise return true +func (handler *Handler) userCanManageStacks(securityContext *security.RestrictedRequestContext, endpoint *portainer.Endpoint) (bool, error) { + // When the endpoint is deleted, stacks that the deleted endpoint created will be tagged as an orphan stack + // An orphan stack can be adopted by admins + if endpoint == nil { + return true, nil + } + + if endpointutils.IsDockerEndpoint(endpoint) && !endpoint.SecuritySettings.AllowStackManagementForRegularUsers { + canCreate, err := handler.userCanCreateStack(securityContext) + + if err != nil { + return false, fmt.Errorf("failed to get user from the database: %w", err) + } + + return canCreate, nil + } + return true, nil +} + +func (handler *Handler) checkUniqueStackName(endpoint *portainer.Endpoint, name string, stackID portainer.StackID) (bool, error) { + stacks, err := handler.DataStore.Stack().ReadAll() + if err != nil { + return false, err + } + + for _, stack := range stacks { + if strings.EqualFold(stack.Name, name) && (stackID == 0 || stackID != stack.ID) && stack.EndpointID == endpoint.ID { + return false, nil + } + } + + return true, nil +} + +func (handler *Handler) checkUniqueStackNameInDocker(endpoint *portainer.Endpoint, name string, stackID portainer.StackID, swarmMode bool) (bool, error) { + isUniqueStackName, err := handler.checkUniqueStackName(endpoint, name, stackID) + if err != nil { + return false, err + } + + if handler.DockerClientFactory == nil { + return isUniqueStackName, nil + } + + dockerClient, err := handler.DockerClientFactory.CreateClient(endpoint, "", nil) + if err != nil { + return false, err + } + defer logs.CloseAndLogErr(dockerClient) + if swarmMode { + services, err := dockerClient.ServiceList(context.Background(), types.ServiceListOptions{}) + if err != nil { + return false, err + } + + for _, service := range services { + serviceNS, ok := service.Spec.Labels["com.docker.stack.namespace"] + if ok && serviceNS == name { + return false, nil + } + } + } + + containers, err := dockerClient.ContainerList(context.Background(), container.ListOptions{All: true}) + if err != nil { + return false, err + } + + for _, container := range containers { + containerNS, ok := container.Labels[consts.ComposeStackNameLabel] + + if ok && containerNS == name { + return false, nil + } + } + + return isUniqueStackName, nil +} + +func (handler *Handler) checkUniqueWebhookID(tx dataservices.DataStoreTx, webhookID string) (bool, error) { + _, err := tx.Stack().StackByWebhookID(webhookID) + if tx.IsErrObjectNotFound(err) { + return true, nil + } + return false, err +} diff --git a/api/http/handler/stacks/response.go b/api/http/handler/stacks/response.go new file mode 100644 index 0000000..d1de852 --- /dev/null +++ b/api/http/handler/stacks/response.go @@ -0,0 +1,85 @@ +package stacks + +import ( + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/dataservices/source" + gittypes "github.com/portainer/portainer/api/git/types" + "github.com/portainer/portainer/api/gitops/workflows" + "github.com/portainer/portainer/api/http/security" +) + +// stackResponse extends a Stack response with the git source identifier. +type stackResponse struct { + portainer.Stack + GitSourceId portainer.SourceID `json:"GitSourceId,omitempty"` +} + +// loadGitConfigForStack reads the merged GitConfig (Source URL/auth/TLS + Artifact ref/path/hash) +// and the SourceID for the given stack. +func loadGitConfigForStack(tx dataservices.DataStoreTx, userContext source.UserContext, workflowID portainer.WorkflowID, stackID portainer.StackID) (*gittypes.RepoConfig, portainer.SourceID, error) { + src, file, err := workflows.GitSourceAndArtifactForStack(tx, userContext, workflowID, stackID) + if err != nil || src == nil { + return nil, 0, err + } + + return workflows.MergeSourceAndFile(src, file), src.ID, nil +} + +// saveStackGitConfig persists the stack's git settings. When newSourceID is non-zero the stack's +// artifact is repointed to that existing Source (selected by the caller) without modifying any +// Source's git config; otherwise the target Source is derived from cfg.URL. +func saveStackGitConfig(tx dataservices.DataStoreTx, userContext source.UserContext, workflowID portainer.WorkflowID, stackID portainer.StackID, oldSourceID, newSourceID portainer.SourceID, cfg *gittypes.RepoConfig) error { + matchArtifact := func(a portainer.Artifact) bool { + return a.StackID == stackID + } + + if newSourceID != 0 { + return workflows.SaveWorkflowArtifact(tx, workflowID, matchArtifact, oldSourceID, portainer.ArtifactFile{ + SourceID: newSourceID, + Ref: cfg.ReferenceName, + Path: cfg.ConfigFilePath, + Hash: cfg.ConfigHash, + }) + } + + return workflows.SaveWorkflowGitConfig(tx, userContext, workflowID, matchArtifact, oldSourceID, cfg) +} + +func persistSourceSyncError(tx dataservices.DataStoreTx, securityContext *security.RestrictedRequestContext, sourceID portainer.SourceID, syncErr error) error { + userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships) + + return workflows.SaveSourceStatus(tx, userContext, sourceID, syncErr) +} + +// newStackResponse fills stack.GitConfig and returns a response that also includes GitSourceId. +func newStackResponse(tx dataservices.DataStoreTx, userContext source.UserContext, stack *portainer.Stack) (*stackResponse, error) { + if stack.WorkflowID == 0 { + return &stackResponse{Stack: *stack}, nil + } + + gitConfig, gitSourceID, err := loadGitConfigForStack(tx, userContext, stack.WorkflowID, stack.ID) + if err != nil { + return nil, err + } + + stack.GitConfig = gittypes.SanitizeRepoConfig(gitConfig) + + return &stackResponse{Stack: *stack, GitSourceId: gitSourceID}, nil +} + +// fillStackGitConfig populates stack.GitConfig from the merged Source+Artifact for backwards-compatible responses. +func fillStackGitConfig(tx dataservices.DataStoreTx, userContext source.UserContext, stack *portainer.Stack) error { + if stack.WorkflowID == 0 { + return nil + } + + gitConfig, _, err := loadGitConfigForStack(tx, userContext, stack.WorkflowID, stack.ID) + if err != nil { + return err + } + + stack.GitConfig = gittypes.SanitizeRepoConfig(gitConfig) + + return nil +} diff --git a/api/http/handler/stacks/stack_associate.go b/api/http/handler/stacks/stack_associate.go new file mode 100644 index 0000000..c9f7393 --- /dev/null +++ b/api/http/handler/stacks/stack_associate.go @@ -0,0 +1,134 @@ +package stacks + +import ( + "errors" + "fmt" + "net/http" + "time" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/dataservices/source" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/stacks/stackutils" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +// @id StackAssociate +// @summary Associate an orphaned stack to a new environment(endpoint) +// @description **Access policy**: administrator +// @tags stacks +// @security ApiKeyAuth +// @security jwt +// @produce json +// @param id path int true "Stack identifier" +// @param endpointId query int true "Environment identifier" +// @param swarmId query int true "Swarm identifier" +// @param orphanedRunning query boolean true "Indicates whether the stack is orphaned" +// @success 200 {object} portainer.Stack "Success" +// @failure 400 "Invalid request" +// @failure 403 "Permission denied" +// @failure 404 "Stack not found" +// @failure 500 "Server error" +// @router /stacks/{id}/associate [put] +func (handler *Handler) stackAssociate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + stackID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid stack identifier route variable", err) + } + + endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", false) + if err != nil { + return httperror.BadRequest("Invalid query parameter: endpointId", err) + } + + swarmId, err := request.RetrieveQueryParameter(r, "swarmId", true) + if err != nil { + return httperror.BadRequest("Invalid query parameter: swarmId", err) + } + + orphanedRunning, err := request.RetrieveBooleanQueryParameter(r, "orphanedRunning", false) + if err != nil { + return httperror.BadRequest("Invalid query parameter: orphanedRunning", err) + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) + } + + user, err := handler.DataStore.User().Read(securityContext.UserID) + if err != nil { + return httperror.InternalServerError("Unable to load user information from the database", err) + } + + stack, err := handler.DataStore.Stack().Read(portainer.StackID(stackID)) + if handler.DataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find a stack with the specified identifier inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find a stack with the specified identifier inside the database", err) + } + + resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl) + if err != nil { + return httperror.InternalServerError("Unable to retrieve a resource control associated to the stack", err) + } + + if resourceControl != nil { + resourceControl.ResourceID = fmt.Sprintf("%d_%s", endpointID, stack.Name) + + err = handler.DataStore.ResourceControl().Update(resourceControl.ID, resourceControl) + if err != nil { + return httperror.InternalServerError("Unable to persist resource control changes inside the database", err) + } + } + + endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) + if handler.DataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find an environment with the specified identifier inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find an environment with the specified identifier inside the database", err) + } + + canManage, err := handler.userCanManageStacks(securityContext, endpoint) + if err != nil { + return httperror.InternalServerError("Unable to verify user authorizations to validate stack deletion", err) + } + if !canManage { + errMsg := "Stack management is disabled for non-admin users" + return httperror.Forbidden(errMsg, errors.New(errMsg)) + } + + stack.EndpointID = portainer.EndpointID(endpointID) + stack.SwarmID = swarmId + + if orphanedRunning { + stack.Status = portainer.StackStatusActive + } else { + stack.Status = portainer.StackStatusInactive + } + + stack.CreationDate = time.Now().Unix() + stack.CreatedBy = user.Username + stack.UpdateDate = 0 + stack.UpdatedBy = "" + + err = handler.DataStore.Stack().Update(stack.ID, stack) + if err != nil { + return httperror.InternalServerError("Unable to persist the stack changes inside the database", err) + } + + stack.ResourceControl = resourceControl + + err = handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error { + userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships) + if err := fillStackGitConfig(tx, userContext, stack); err != nil { + return httperror.InternalServerError("Unable to load git config for stack", err) + } + return nil + }) + + return response.TxResponse(w, stack, err) +} diff --git a/api/http/handler/stacks/stack_create.go b/api/http/handler/stacks/stack_create.go new file mode 100644 index 0000000..fdc9e45 --- /dev/null +++ b/api/http/handler/stacks/stack_create.go @@ -0,0 +1,156 @@ +package stacks + +import ( + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/dataservices/source" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/authorization" + "github.com/portainer/portainer/api/stacks/stackutils" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" + + "github.com/pkg/errors" +) + +func (handler *Handler) stackCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + stackType, err := request.RetrieveRouteVariableValue(r, "type") + if err != nil { + return httperror.BadRequest("Invalid path parameter: type", err) + } + + method, err := request.RetrieveRouteVariableValue(r, "method") + if err != nil { + return httperror.BadRequest("Invalid path parameter: method", err) + } + + endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", false) + if err != nil { + return httperror.BadRequest("Invalid query parameter: endpointId", err) + } + + endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) + if handler.DataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find an environment with the specified identifier inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find an environment with the specified identifier inside the database", err) + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve user info from request context", err) + } + + canManage, err := handler.userCanManageStacks(securityContext, endpoint) + if err != nil { + return httperror.InternalServerError("Unable to verify user authorizations to validate stack deletion", err) + } + if !canManage { + errMsg := "Stack creation is disabled for non-admin users" + return httperror.Forbidden(errMsg, errors.New(errMsg)) + } + + err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) + if err != nil { + return httperror.Forbidden("Permission denied to access environment", err) + } + + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve user details from authentication token", err) + } + + switch stackType { + case "swarm": + return handler.createSwarmStack(w, r, method, endpoint, tokenData.ID) + case "standalone": + return handler.createComposeStack(w, r, method, endpoint, tokenData.ID) + case "kubernetes": + return handler.createKubernetesStack(w, r, method, endpoint, tokenData.ID) + } + + return httperror.BadRequest("Invalid value for query parameter: type. Value must be one of: 1 (Swarm stack) or 2 (Compose stack)", errors.New(request.ErrInvalidQueryParameter)) +} + +func (handler *Handler) createComposeStack(w http.ResponseWriter, r *http.Request, method string, endpoint *portainer.Endpoint, userID portainer.UserID) *httperror.HandlerError { + switch method { + case "string": + return handler.createComposeStackFromFileContent(w, r, endpoint, userID) + case "repository": + return handler.createComposeStackFromGitRepository(w, r, endpoint, userID) + case "file": + return handler.createComposeStackFromFileUpload(w, r, endpoint, userID) + } + + return httperror.BadRequest("Invalid value for query parameter: method. Value must be one of: string, repository or file", errors.New(request.ErrInvalidQueryParameter)) +} + +func (handler *Handler) createSwarmStack(w http.ResponseWriter, r *http.Request, method string, endpoint *portainer.Endpoint, userID portainer.UserID) *httperror.HandlerError { + switch method { + case "string": + return handler.createSwarmStackFromFileContent(w, r, endpoint, userID) + case "repository": + return handler.createSwarmStackFromGitRepository(w, r, endpoint, userID) + case "file": + return handler.createSwarmStackFromFileUpload(w, r, endpoint, userID) + } + + return httperror.BadRequest("Invalid value for query parameter: method. Value must be one of: string, repository or file", errors.New(request.ErrInvalidQueryParameter)) +} + +func (handler *Handler) createKubernetesStack(w http.ResponseWriter, r *http.Request, method string, endpoint *portainer.Endpoint, userID portainer.UserID) *httperror.HandlerError { + switch method { + case "string": + return handler.createKubernetesStackFromFileContent(w, r, endpoint, userID) + case "repository": + return handler.createKubernetesStackFromGitRepository(w, r, endpoint, userID) + case "url": + return handler.createKubernetesStackFromManifestURL(w, r, endpoint, userID) + } + + return httperror.BadRequest("Invalid value for query parameter: method. Value must be one of: string or repository", errors.New(request.ErrInvalidQueryParameter)) +} + +func (handler *Handler) decorateStackResponse(w http.ResponseWriter, stack *portainer.Stack, userID portainer.UserID) *httperror.HandlerError { + var resourceControl *portainer.ResourceControl + + isAdmin, err := handler.userIsAdmin(userID) + if err != nil { + return httperror.InternalServerError("Unable to load user information from the database", err) + } + + if isAdmin { + resourceControl = authorization.NewAdministratorsOnlyResourceControl(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl) + } else { + resourceControl = authorization.NewPrivateResourceControl(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl, userID) + } + + err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + err = tx.ResourceControl().Create(resourceControl) + if err != nil { + return httperror.InternalServerError("Unable to persist resource control inside the database", err) + } + stack.ResourceControl = resourceControl + + user, err := tx.User().Read(userID) + if err != nil { + return httperror.InternalServerError("Unable to read user", err) + } + + userMemberships, err := tx.TeamMembership().TeamMembershipsByUserID(userID) + if err != nil { + return httperror.InternalServerError("Unable to read user's team memberships", err) + } + + userContext := source.NewUserContext(user, userMemberships) + if err := fillStackGitConfig(tx, userContext, stack); err != nil { + return httperror.InternalServerError("Unable to load git config for stack", err) + } + return nil + }) + + return response.TxResponse(w, stack, err) +} diff --git a/api/http/handler/stacks/stack_delete.go b/api/http/handler/stacks/stack_delete.go new file mode 100644 index 0000000..9af95d1 --- /dev/null +++ b/api/http/handler/stacks/stack_delete.go @@ -0,0 +1,371 @@ +package stacks + +import ( + "context" + "errors" + "fmt" + "net/http" + "strconv" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/filesystem" + httperrors "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/stacks/deployments" + "github.com/portainer/portainer/api/stacks/stackutils" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" + + "github.com/rs/zerolog/log" +) + +// @id StackDelete +// @summary Remove a stack +// @description Remove a stack. +// @description **Access policy**: restricted +// @tags stacks +// @security ApiKeyAuth +// @security jwt +// @param id path int true "Stack identifier" +// @param external query boolean false "Set to true to delete an external stack. Only external Swarm stacks are supported" +// @param endpointId query int true "Environment identifier" +// @success 204 "Success" +// @failure 400 "Invalid request" +// @failure 403 "Permission denied" +// @failure 404 "Not found" +// @failure 500 "Server error" +// @router /stacks/{id} [delete] +func (handler *Handler) stackDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + stackID, err := request.RetrieveRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid stack identifier route variable", err) + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) + } + + externalStack, _ := request.RetrieveBooleanQueryParameter(r, "external", true) + if externalStack { + return handler.deleteExternalStack(r, w, stackID, securityContext) + } + + id, err := strconv.Atoi(stackID) + if err != nil { + return httperror.BadRequest("Invalid stack identifier route variable", err) + } + + stack, err := handler.DataStore.Stack().Read(portainer.StackID(id)) + if handler.DataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find a stack with the specified identifier inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find a stack with the specified identifier inside the database", err) + } + + endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", true) + if err != nil { + return httperror.BadRequest("Invalid query parameter: endpointId", err) + } + + isOrphaned := portainer.EndpointID(endpointID) != stack.EndpointID + + if isOrphaned && !securityContext.IsAdmin { + return httperror.Forbidden("Permission denied to remove orphaned stack", errors.New("Permission denied to remove orphaned stack")) + } + + endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) + if handler.DataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find the endpoint associated to the stack inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find the endpoint associated to the stack inside the database", err) + } + + resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl) + if err != nil { + return httperror.InternalServerError("Unable to retrieve a resource control associated to the stack", err) + } + + if !isOrphaned { + if err := handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint); err != nil { + return httperror.Forbidden("Permission denied to access endpoint", err) + } + + if stack.Type == portainer.DockerSwarmStack || stack.Type == portainer.DockerComposeStack { + access, err := handler.userCanAccessStack(securityContext, resourceControl) + if err != nil { + return httperror.InternalServerError("Unable to verify user authorizations to validate stack access", err) + } + if !access { + return httperror.Forbidden("Access denied to resource", httperrors.ErrResourceAccessDenied) + } + } + } + + canManage, err := handler.userCanManageStacks(securityContext, endpoint) + if err != nil { + return httperror.InternalServerError("Unable to verify user authorizations to validate stack deletion", err) + } + if !canManage { + errMsg := "stack deletion is disabled for non-admin users" + return httperror.Forbidden(errMsg, errors.New(errMsg)) + } + + // stop scheduler updates of the stack before removal + if stack.AutoUpdate != nil { + deployments.StopAutoupdate(stack.ID, stack.AutoUpdate.JobID, handler.Scheduler) + } + + if err := handler.deleteStack(r.Context(), securityContext.UserID, stack, endpoint); err != nil { + return httperror.InternalServerError(err.Error(), err) + } + + if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + if stack.WorkflowID != 0 { + if err := tx.Workflow().Delete(stack.WorkflowID); err != nil { + return err + } + } + return tx.Stack().Delete(portainer.StackID(id)) + }); err != nil { + return httperror.InternalServerError("Unable to remove the stack from the database", err) + } + + if resourceControl != nil { + if err := handler.DataStore.ResourceControl().Delete(resourceControl.ID); err != nil { + return httperror.InternalServerError("Unable to remove the associated resource control from the database", err) + } + } + + if err := handler.FileService.RemoveDirectory(stack.ProjectPath); err != nil { + log.Warn().Err(err).Msg("Unable to remove stack files from disk") + } + + return response.Empty(w) +} + +func (handler *Handler) deleteExternalStack(r *http.Request, w http.ResponseWriter, stackName string, securityContext *security.RestrictedRequestContext) *httperror.HandlerError { + endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", false) + if err != nil { + return httperror.BadRequest("Invalid query parameter: endpointId", err) + } + + if !securityContext.IsAdmin { + return httperror.Unauthorized("Permission denied to delete the stack", httperrors.ErrUnauthorized) + } + + stack, err := handler.DataStore.Stack().StackByName(stackName) + if err != nil && !handler.DataStore.IsErrObjectNotFound(err) { + return httperror.InternalServerError("Unable to check for stack existence inside the database", err) + } + if stack != nil { + return httperror.BadRequest("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.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) + if handler.DataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find the endpoint associated to the stack inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find the endpoint associated to the stack inside the database", err) + } + + if err := handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint); err != nil { + return httperror.Forbidden("Permission denied to access endpoint", err) + } + + stack = &portainer.Stack{ + Name: stackName, + Type: portainer.DockerSwarmStack, + } + + if err := handler.deleteStack(context.TODO(), securityContext.UserID, stack, endpoint); err != nil { + return httperror.InternalServerError("Unable to delete stack", err) + } + + return response.Empty(w) +} + +func (handler *Handler) deleteStack(ctx context.Context, userID portainer.UserID, stack *portainer.Stack, endpoint *portainer.Endpoint) error { + if stack.Type == portainer.DockerSwarmStack { + stack.Name = handler.SwarmStackManager.NormalizeStackName(stack.Name) + + if stackutils.IsRelativePathStack(stack) { + return handler.StackDeployer.UndeployRemoteSwarmStack(ctx, userID, stack, endpoint) + } + + return handler.SwarmStackManager.Remove(ctx, stack, endpoint) + } + + if stack.Type == portainer.DockerComposeStack { + stack.Name = handler.ComposeStackManager.NormalizeStackName(stack.Name) + + if stackutils.IsRelativePathStack(stack) { + return handler.StackDeployer.UndeployRemoteComposeStack(ctx, userID, stack, endpoint) + } + + return handler.StackDeployer.UndeployComposeStack(ctx, stack, endpoint) + } + + if stack.Type == portainer.KubernetesStack { + manifestFiles := stackutils.GetStackFilePaths(stack, true) + + out, err := handler.KubernetesDeployer.Remove(ctx, userID, endpoint, manifestFiles, stack.Namespace) + if err != nil { + for _, manifest := range manifestFiles { + if exists, fileExistsErr := filesystem.FileExists(manifest); fileExistsErr != nil || !exists { + // If removal has failed and one of the manifest files is missing, + // we can consider this stack as removed + log.Warn().Err(fileExistsErr).Msgf("failed to find manifest %s, but stack deletion will continue", manifest) + return nil + } + } + return fmt.Errorf("failed to remove kubernetes resources: %q. Error: %w", out, err) + } + return nil + } + + return fmt.Errorf("unsupported stack type: %v", stack.Type) +} + +// @id StackDeleteKubernetesByName +// @summary Remove Kubernetes stacks by name +// @description Remove a stack. +// @description **Access policy**: restricted +// @tags stacks +// @security ApiKeyAuth +// @security jwt +// @param name path string true "Stack name" +// @param external query boolean false "Set to true to delete an external stack. Only external Swarm stacks are supported" +// @param endpointId query int true "Environment identifier" +// @success 204 "Success" +// @failure 400 "Invalid request" +// @failure 403 "Permission denied" +// @failure 404 "Not found" +// @failure 500 "Server error" +// @router /stacks/name/{name} [delete] +func (handler *Handler) stackDeleteKubernetesByName(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + stackName, err := request.RetrieveRouteVariableValue(r, "name") + if err != nil { + return httperror.BadRequest("Invalid stack identifier route variable", err) + } + + log.Debug().Msgf("Trying to delete Kubernetes stack %q", stackName) + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) + } + + endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", true) + if err != nil { + return httperror.BadRequest("Invalid query parameter: endpointId", err) + } + + namespace, err := request.RetrieveQueryParameter(r, "namespace", false) + if err != nil { + return httperror.BadRequest("Invalid query parameter: namespace", err) + } + + stacks, err := handler.DataStore.Stack().StacksByName(stackName) + if err != nil && !handler.DataStore.IsErrObjectNotFound(err) { + return httperror.InternalServerError("Unable to check for stack existence inside the database", err) + } + if stacks == nil { + return httperror.InternalServerError("Unable to find a stacks with the specified identifier name the database", err) + } + + endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) + if handler.DataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find the endpoint associated to the stack inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find the endpoint associated to the stack inside the database", err) + } + + log.Debug().Msgf("Trying to delete Kubernetes stack %q for endpoint `%d`", stackName, endpointID) + + // check authorizations on all the stacks one by one + stacksToDelete := make([]portainer.Stack, 0) + for _, stack := range stacks { + // only delete stacks for the specified namespace + if stack.Namespace != namespace { + continue + } + + isOrphaned := portainer.EndpointID(endpointID) != stack.EndpointID + if stack.Type != portainer.KubernetesStack { + return httperror.BadRequest("Only Kubernetes stacks can be deleted by name", errors.New("Only Kubernetes stacks can be deleted by name")) + } + + if isOrphaned && !securityContext.IsAdmin { + return httperror.Forbidden("Permission denied to remove orphaned stack", errors.New("Permission denied to remove orphaned stack")) + } + + if !isOrphaned { + err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) + if err != nil { + return httperror.Forbidden("Permission denied to access endpoint", err) + } + } + + canManage, err := handler.userCanManageStacks(securityContext, endpoint) + if err != nil { + return httperror.InternalServerError("Unable to verify user authorizations to validate stack deletion", err) + } + if !canManage { + errMsg := "stack deletion is disabled for non-admin users" + return httperror.Forbidden(errMsg, errors.New(errMsg)) + } + + stacksToDelete = append(stacksToDelete, stack) + } + + log.Debug().Msgf("Trying to delete Kubernetes stacks `%v` for endpoint `%d`", stacksToDelete, endpointID) + + var errs error + // Delete all the stacks one by one + for _, stack := range stacksToDelete { + log.Debug().Msgf("Trying to delete Kubernetes stack id `%d`", stack.ID) + + // stop scheduler updates of the stack before removal + if stack.AutoUpdate != nil { + deployments.StopAutoupdate(stack.ID, stack.AutoUpdate.JobID, handler.Scheduler) + } + + err = handler.deleteStack(context.TODO(), securityContext.UserID, &stack, endpoint) + if err != nil { + log.Err(err).Msgf("Unable to delete Kubernetes stack `%d`", stack.ID) + errs = errors.Join(errs, err) + + continue + } + + if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + if stack.WorkflowID != 0 { + if err := tx.Workflow().Delete(stack.WorkflowID); err != nil { + return err + } + } + return tx.Stack().Delete(stack.ID) + }); err != nil { + errs = errors.Join(errs, err) + log.Err(err).Int("stack_id", int(stack.ID)).Msg("unable to remove the stack from the database") + + continue + } + + if err := handler.FileService.RemoveDirectory(stack.ProjectPath); err != nil { + errs = errors.Join(errs, err) + log.Warn().Err(err).Msg("Unable to remove stack files from disk") + } + + log.Debug().Msgf("Kubernetes stack `%d` deleted", stack.ID) + } + + if errs != nil { + return httperror.InternalServerError("Unable to delete some Kubernetes stack(s). Check Portainer logs for more details", nil) + } + + return response.Empty(w) +} diff --git a/api/http/handler/stacks/stack_file.go b/api/http/handler/stacks/stack_file.go new file mode 100644 index 0000000..4c59481 --- /dev/null +++ b/api/http/handler/stacks/stack_file.go @@ -0,0 +1,136 @@ +package stacks + +import ( + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/dataservices/source" + gittypes "github.com/portainer/portainer/api/git/types" + httperrors "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/stacks/stackutils" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" + + "github.com/pkg/errors" +) + +type stackFileResponse struct { + // Content of the Stack file + StackFileContent string `json:"StackFileContent" example:"version: 3\n services:\n web:\n image:nginx"` +} + +// @id StackFileInspect +// @summary Retrieve the content of the Stack file for the specified stack +// @description Get Stack file content. +// @description **Access policy**: restricted +// @tags stacks +// @security ApiKeyAuth +// @security jwt +// @produce json +// @param id path int true "Stack identifier" +// @success 200 {object} stackFileResponse "Success" +// @failure 400 "Invalid request" +// @failure 403 "Permission denied" +// @failure 404 "Stack not found" +// @failure 409 "Git settings changed, redeploy required" +// @failure 500 "Server error" +// @router /stacks/{id}/file [get] +func (handler *Handler) stackFile(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + stackID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid stack identifier route variable", err) + } + + stack, err := handler.DataStore.Stack().Read(portainer.StackID(stackID)) + if handler.DataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find a stack with the specified identifier inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find a stack with the specified identifier inside the database", err) + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) + } + + endpoint, err := handler.DataStore.Endpoint().Endpoint(stack.EndpointID) + if handler.DataStore.IsErrObjectNotFound(err) { + if !securityContext.IsAdmin { + return httperror.NotFound("Unable to find an environment with the specified identifier inside the database", err) + } + } else if err != nil { + return httperror.InternalServerError("Unable to find an environment with the specified identifier inside the database", err) + } + + canManage, err := handler.userCanManageStacks(securityContext, endpoint) + if err != nil { + return httperror.InternalServerError("Unable to verify user authorizations to validate stack deletion", err) + } + if !canManage { + errMsg := "Stack management is disabled for non-admin users" + return httperror.Forbidden(errMsg, errors.New(errMsg)) + } + + if endpoint != nil { + err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) + if err != nil { + return httperror.Forbidden("Permission denied to access environment", err) + } + + if stack.Type == portainer.DockerSwarmStack || stack.Type == portainer.DockerComposeStack { + resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl) + if err != nil { + return httperror.InternalServerError("Unable to retrieve a resource control associated to the stack", err) + } + + access, err := handler.userCanAccessStack(securityContext, resourceControl) + if err != nil { + return httperror.InternalServerError("Unable to verify user authorizations to validate stack access", err) + } + if !access { + return httperror.Forbidden("Access denied to resource", httperrors.ErrResourceAccessDenied) + } + } + } + + var gitConfig *gittypes.RepoConfig + if stack.WorkflowID != 0 { + if err := handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error { + var err error + userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships) + gitConfig, _, err = loadGitConfigForStack(tx, userContext, stack.WorkflowID, stack.ID) + if err != nil { + return httperror.InternalServerError("Unable to load git config for stack", err) + } + return nil + }); err != nil { + return response.TxErrorResponse(err) + } + } + + if gitStackPendingRedeploy(stack, gitConfig) { + return httperror.Conflict("Stack git settings have changed. Redeploy the stack to apply the new configuration.", errors.New("git settings updated without redeploy")) + } + + stackFileContent, err := handler.FileService.GetFileContent(stack.ProjectPath, stack.EntryPoint) + if err != nil { + return httperror.InternalServerError("Unable to retrieve Compose file from disk", err) + } + + return response.JSON(w, &stackFileResponse{StackFileContent: string(stackFileContent)}) +} + +// gitStackPendingRedeploy returns true when the stack's git settings (URL or config file path) +// have been updated via "save settings" but the stack has not yet been redeployed to apply them. +// In that state the local clone is stale and the stack file cannot be read from disk. +func gitStackPendingRedeploy(stack *portainer.Stack, gitConfig *gittypes.RepoConfig) bool { + if gitConfig == nil || stack.CurrentDeploymentInfo == nil { + return false + } + + return gitConfig.URL != stack.CurrentDeploymentInfo.RepositoryURL || + gitConfig.ConfigFilePath != stack.CurrentDeploymentInfo.ConfigFilePath +} diff --git a/api/http/handler/stacks/stack_file_test.go b/api/http/handler/stacks/stack_file_test.go new file mode 100644 index 0000000..f1b7817 --- /dev/null +++ b/api/http/handler/stacks/stack_file_test.go @@ -0,0 +1,133 @@ +package stacks + +import ( + "net/http" + "net/http/httptest" + "os" + "strconv" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices/source" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/filesystem" + gittypes "github.com/portainer/portainer/api/git/types" + "github.com/portainer/portainer/api/internal/testhelpers" + + "encoding/json" + + "github.com/stretchr/testify/require" +) + +func TestStackFile_GitPendingRedeploy_Returns409(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + _, err := mockCreateUser(store) + require.NoError(t, err) + + endpoint, err := mockCreateEndpoint(store) + require.NoError(t, err) + + tempDir := t.TempDir() + fileService, err := filesystem.NewService(tempDir, "") + require.NoError(t, err) + + handler := NewHandler(testhelpers.NewTestRequestBouncer()) + handler.FileService = fileService + handler.DataStore = store + + const stackID = portainer.StackID(1) + + src := &portainer.Source{ + Type: portainer.SourceTypeGit, + Git: &gittypes.GitSource{ + URL: "https://github.com/portainer/portainer.git", + }, + } + require.NoError(t, store.Source().Create(source.InsecureNewAdminContext(), src)) + + wf := &portainer.Workflow{Artifacts: []portainer.Artifact{{ + StackID: stackID, + Files: []portainer.ArtifactFile{{SourceID: src.ID}}, + }}} + require.NoError(t, store.Workflow().Create(wf)) + + stack := &portainer.Stack{ + ID: stackID, + EndpointID: endpoint.ID, + Type: portainer.DockerComposeStack, + WorkflowID: wf.ID, + CurrentDeploymentInfo: &portainer.StackDeploymentInfo{ + RepositoryURL: "https://github.com/portainer/old-repo.git", + ConfigFilePath: "docker-compose.yml", + }, + } + require.NoError(t, store.Stack().Create(stack)) + + req := mockCreateStackRequestWithSecurityContext( + http.MethodGet, + "/stacks/"+strconv.Itoa(int(stack.ID))+"/file", + nil, + ) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + require.Equal(t, http.StatusConflict, rr.Code) +} + +func TestStackFile_MatchingGitSettings_ReturnsFileContent(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + _, err := mockCreateUser(store) + require.NoError(t, err) + + endpoint, err := mockCreateEndpoint(store) + require.NoError(t, err) + + tempDir := t.TempDir() + fileService, err := filesystem.NewService(tempDir, "") + require.NoError(t, err) + + handler := NewHandler(testhelpers.NewTestRequestBouncer()) + handler.FileService = fileService + handler.DataStore = store + + const repoURL = "https://github.com/portainer/portainer.git" + const configPath = "docker-compose.yml" + const fileContent = "version: '3'\nservices:\n web:\n image: nginx\n" + + require.NoError(t, os.WriteFile(filesystem.JoinPaths(tempDir, configPath), []byte(fileContent), 0o644)) + + stack := &portainer.Stack{ + ID: 2, + EndpointID: endpoint.ID, + Type: portainer.DockerComposeStack, + ProjectPath: tempDir, + EntryPoint: configPath, + CurrentDeploymentInfo: &portainer.StackDeploymentInfo{ + RepositoryURL: repoURL, + ConfigFilePath: configPath, + }, + GitConfig: &gittypes.RepoConfig{ + URL: repoURL, + ConfigFilePath: configPath, + }, + } + require.NoError(t, store.Stack().Create(stack)) + + req := mockCreateStackRequestWithSecurityContext( + http.MethodGet, + "/stacks/"+strconv.Itoa(int(stack.ID))+"/file", + nil, + ) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + require.Equal(t, http.StatusOK, rr.Code) + + var resp stackFileResponse + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp)) + require.Equal(t, fileContent, resp.StackFileContent) +} diff --git a/api/http/handler/stacks/stack_inspect.go b/api/http/handler/stacks/stack_inspect.go new file mode 100644 index 0000000..4217d3e --- /dev/null +++ b/api/http/handler/stacks/stack_inspect.go @@ -0,0 +1,102 @@ +package stacks + +import ( + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices/source" + httperrors "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/stacks/stackutils" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" + + "github.com/pkg/errors" +) + +// @id StackInspect +// @summary Inspect a stack +// @description Retrieve details about a stack. +// @description **Access policy**: restricted +// @tags stacks +// @security ApiKeyAuth +// @security jwt +// @produce json +// @param id path int true "Stack identifier" +// @success 200 {object} stackResponse "Success" +// @failure 400 "Invalid request" +// @failure 403 "Permission denied" +// @failure 404 "Stack not found" +// @failure 500 "Server error" +// @router /stacks/{id} [get] +func (handler *Handler) stackInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + stackID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid stack identifier route variable", err) + } + + stack, err := handler.DataStore.Stack().Read(portainer.StackID(stackID)) + if handler.DataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find a stack with the specified identifier inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find a stack with the specified identifier inside the database", err) + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) + } + + endpoint, err := handler.DataStore.Endpoint().Endpoint(stack.EndpointID) + if handler.DataStore.IsErrObjectNotFound(err) { + if !securityContext.IsAdmin { + return httperror.NotFound("Unable to find an environment with the specified identifier inside the database", err) + } + } else if err != nil { + return httperror.InternalServerError("Unable to find an environment with the specified identifier inside the database", err) + } + + canManage, err := handler.userCanManageStacks(securityContext, endpoint) + if err != nil { + return httperror.InternalServerError("Unable to verify user authorizations to validate stack deletion", err) + } + if !canManage { + errMsg := "Stack management is disabled for non-admin users" + return httperror.Forbidden(errMsg, errors.New(errMsg)) + } + + if endpoint != nil { + err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) + if err != nil { + return httperror.Forbidden("Permission denied to access environment", err) + } + + if stack.Type == portainer.DockerSwarmStack || stack.Type == portainer.DockerComposeStack { + resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl) + if err != nil { + return httperror.InternalServerError("Unable to retrieve a resource control associated to the stack", err) + } + + access, err := handler.userCanAccessStack(securityContext, resourceControl) + if err != nil { + return httperror.InternalServerError("Unable to verify user authorizations to validate stack access", err) + } + if !access { + return httperror.Forbidden("Access denied to resource", httperrors.ErrResourceAccessDenied) + } + + if resourceControl != nil { + stack.ResourceControl = resourceControl + } + } + } + + userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships) + resp, err := newStackResponse(handler.DataStore, userContext, stack) + if err != nil { + return httperror.InternalServerError("Unable to load git config for stack", err) + } + + return response.JSON(w, resp) +} diff --git a/api/http/handler/stacks/stack_list.go b/api/http/handler/stacks/stack_list.go new file mode 100644 index 0000000..0b8ecce --- /dev/null +++ b/api/http/handler/stacks/stack_list.go @@ -0,0 +1,160 @@ +package stacks + +import ( + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/dataservices/source" + httperrors "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/authorization" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +type stackListOperationFilters struct { + SwarmID string `json:"SwarmID"` + EndpointID int `json:"EndpointID"` + IncludeOrphanedStacks bool `json:"IncludeOrphanedStacks"` +} + +// @id StackList +// @summary List stacks +// @description List all stacks based on the current user authorizations. +// @description Will return all stacks if using an administrator account otherwise it +// @description will only return the list of stacks the user have access to. +// @description Limited stacks will not be returned by this endpoint. +// @description **Access policy**: authenticated +// @tags stacks +// @security ApiKeyAuth +// @security jwt +// @param filters query string false "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." +// @success 200 {array} portainer.Stack "Success" +// @success 204 "Success" +// @failure 400 "Invalid request" +// @failure 500 "Server error" +// @router /stacks [get] +func (handler *Handler) stackList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var filters stackListOperationFilters + err := request.RetrieveJSONQueryParameter(r, "filters", &filters, true) + if err != nil { + return httperror.BadRequest("Invalid query parameter: filters", err) + } + + endpoints, err := handler.DataStore.Endpoint().Endpoints() + if err != nil { + return httperror.InternalServerError("Unable to retrieve environments from database", err) + } + + stacks, err := handler.DataStore.Stack().ReadAll() + if err != nil { + return httperror.InternalServerError("Unable to retrieve stacks from the database", err) + } + stacks = filterStacks(stacks, &filters, endpoints) + + resourceControls, err := handler.DataStore.ResourceControl().ReadAll() + if err != nil { + return httperror.InternalServerError("Unable to retrieve resource controls from the database", err) + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) + } + + stacks = authorization.DecorateStacks(stacks, resourceControls) + + if !securityContext.IsAdmin { + if filters.IncludeOrphanedStacks { + return httperror.Forbidden("Permission denied to access orphaned stacks", httperrors.ErrUnauthorized) + } + + user, err := handler.DataStore.User().Read(securityContext.UserID) + if err != nil { + return httperror.InternalServerError("Unable to retrieve user information from the database", err) + } + + userTeamIDs := authorization.TeamIDs(securityContext.UserMemberships) + + stacks = authorization.FilterAuthorizedStacks(stacks, user.ID, userTeamIDs) + } + + err = handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error { + userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships) + for i := range stacks { + if err := fillStackGitConfig(tx, userContext, &stacks[i]); err != nil { + return httperror.InternalServerError("Unable to load git config for stack", err) + } + } + return nil + }) + + return response.TxResponse(w, stacks, err) +} + +// filterStacks refines a collection of Stack instances using specified criteria. +// This function examines the provided filters: EndpointID, SwarmID, and IncludeOrphanedStacks. +// - If both EndpointID is zero and SwarmID is an empty string, the function directly returns the original stack list without any modifications. +// - If either filter is specified, it proceeds to selectively include stacks that match the criteria. + +// Key Points on Business Logic: +// 1. Determining Inclusion of Orphaned Stacks: +// - The decision to include orphaned stacks is influenced by the user's role and usually set by the client (UI). +// - Administrators or environment administrators can include orphaned stacks by setting IncludeOrphanedStacks to true, reflecting their broader access rights. +// - For non-administrative users, this is typically set to false, limiting their visibility to only stacks within their purview. + +// 2. Inclusion Criteria for Orphaned Stacks: +// - When IncludeOrphanedStacks is true and an EndpointID is specified (not zero), the function selects: +// a) Stacks linked to the specified EndpointID. +// b) Orphaned stacks that don't have a naming conflict with any stack associated with the EndpointID. +// - This approach is designed to avoid name conflicts within Docker Compose, which restricts the creation of multiple stacks with the same name. + +// 3. Type Matching for Orphaned Stacks: +// - The function ensures that orphaned stacks are compatible with the environment's stack type (compose or swarm). +// - It filters out orphaned swarm stacks in Docker standalone environments +// - It filters out orphaned standalone stack in Docker swarm environments +// - This ensures that re-association respects the constraints of the environment and stack type. + +// The outcome is a new list of stacks that align with these filtering and business logic criteria. +func filterStacks(stacks []portainer.Stack, filters *stackListOperationFilters, endpoints []portainer.Endpoint) []portainer.Stack { + if filters.EndpointID == 0 && filters.SwarmID == "" { + return stacks + } + + filteredStacks := make([]portainer.Stack, 0, len(stacks)) + uniqueStackNames := make(map[string]struct{}) + for _, stack := range stacks { + if stack.Type == portainer.DockerComposeStack && stack.EndpointID == portainer.EndpointID(filters.EndpointID) { + filteredStacks = append(filteredStacks, stack) + uniqueStackNames[stack.Name] = struct{}{} + } + if stack.Type == portainer.DockerSwarmStack && stack.SwarmID == filters.SwarmID { + filteredStacks = append(filteredStacks, stack) + uniqueStackNames[stack.Name] = struct{}{} + } + } + + for _, stack := range stacks { + if filters.IncludeOrphanedStacks && isOrphanedStack(stack, endpoints) { + if (stack.Type == portainer.DockerComposeStack && filters.SwarmID == "") || (stack.Type == portainer.DockerSwarmStack && filters.SwarmID != "") { + if _, exists := uniqueStackNames[stack.Name]; !exists { + filteredStacks = append(filteredStacks, stack) + } + } + } + } + + return filteredStacks +} + +func isOrphanedStack(stack portainer.Stack, endpoints []portainer.Endpoint) bool { + for _, endpoint := range endpoints { + if stack.EndpointID == endpoint.ID { + return false + } + } + + return true +} diff --git a/api/http/handler/stacks/stack_list_test.go b/api/http/handler/stacks/stack_list_test.go new file mode 100644 index 0000000..86e6a87 --- /dev/null +++ b/api/http/handler/stacks/stack_list_test.go @@ -0,0 +1,75 @@ +package stacks + +import ( + "sort" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/stretchr/testify/assert" +) + +func TestFilterStacks(t *testing.T) { + t.Parallel() + t.Run("filter stacks against particular endpoint and all orphaned stacks", func(t *testing.T) { + stacks := []portainer.Stack{ + {ID: 1, EndpointID: 3, Name: "normal_stack", Type: portainer.DockerComposeStack}, + {ID: 2, EndpointID: 4, Name: "orphaned_stack", Type: portainer.DockerComposeStack}, + {ID: 3, EndpointID: 5, Name: "other_stack", Type: portainer.DockerComposeStack}, + } + filters := &stackListOperationFilters{EndpointID: 3, IncludeOrphanedStacks: true} + endpoints := []portainer.Endpoint{{ID: 3}, {ID: 5}} + + expectStacks := []portainer.Stack{{ID: 1}, {ID: 2}} + actualStacks := filterStacks(stacks, filters, endpoints) + + isEqualStacks(t, expectStacks, actualStacks) + }) + + t.Run("filter unique stacks against particular endpoint and all orphaned stacks and an orphaned stack has the same name with normal stack", func(t *testing.T) { + stacks := []portainer.Stack{ + {ID: 1, EndpointID: 3, Name: "normal_stack", Type: portainer.DockerComposeStack}, + {ID: 2, EndpointID: 4, Name: "orphaned_stack", Type: portainer.DockerComposeStack}, + {ID: 3, EndpointID: 5, Name: "other_stack", Type: portainer.DockerComposeStack}, + {ID: 4, EndpointID: 4, Name: "normal_stack", Type: portainer.DockerComposeStack}, + } + filters := &stackListOperationFilters{EndpointID: 3, IncludeOrphanedStacks: true} + endpoints := []portainer.Endpoint{{ID: 3}, {ID: 5}} + + expectStacks := []portainer.Stack{{ID: 1}, {ID: 2}} + actualStacks := filterStacks(stacks, filters, endpoints) + + isEqualStacks(t, expectStacks, actualStacks) + }) + + t.Run("only filter stacks against particular endpoint and no orphaned stacks", func(t *testing.T) { + stacks := []portainer.Stack{ + {ID: 1, EndpointID: 3, Name: "normal_stack", Type: portainer.DockerComposeStack}, + {ID: 2, EndpointID: 4, Name: "orphaned_stack", Type: portainer.DockerComposeStack}, + {ID: 3, EndpointID: 5, Name: "other_stack", Type: portainer.DockerComposeStack}, + {ID: 4, EndpointID: 4, Name: "normal_stack", Type: portainer.DockerComposeStack}, + } + filters := &stackListOperationFilters{EndpointID: 3, IncludeOrphanedStacks: false} + endpoints := []portainer.Endpoint{{ID: 3}, {ID: 5}} + + expectStacks := []portainer.Stack{{ID: 1}} + actualStacks := filterStacks(stacks, filters, endpoints) + + isEqualStacks(t, expectStacks, actualStacks) + }) +} + +func isEqualStacks(t *testing.T, expectStacks, actualStacks []portainer.Stack) { + expectStackIDs := make([]int, len(expectStacks)) + for i, stack := range expectStacks { + expectStackIDs[i] = int(stack.ID) + } + sort.Ints(expectStackIDs) + + actualStackIDs := make([]int, len(actualStacks)) + for i, stack := range actualStacks { + actualStackIDs[i] = int(stack.ID) + } + sort.Ints(actualStackIDs) + + assert.Equal(t, expectStackIDs, actualStackIDs) +} diff --git a/api/http/handler/stacks/stack_migrate.go b/api/http/handler/stacks/stack_migrate.go new file mode 100644 index 0000000..cccb24f --- /dev/null +++ b/api/http/handler/stacks/stack_migrate.go @@ -0,0 +1,250 @@ +package stacks + +import ( + "context" + "errors" + "fmt" + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/dataservices/source" + httperrors "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/stacks/deployments" + "github.com/portainer/portainer/api/stacks/stackutils" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +type stackMigratePayload struct { + // Environment(Endpoint) identifier of the target environment(endpoint) where the stack will be relocated + EndpointID int `example:"2" validate:"required"` + // Swarm cluster identifier, must match the identifier of the cluster where the stack will be relocated + SwarmID string `example:"jpofkc0i9uo9wtx1zesuk649w"` + // If provided will rename the migrated stack + Name string `example:"new-stack"` +} + +func (payload *stackMigratePayload) Validate(r *http.Request) error { + if payload.EndpointID == 0 { + return errors.New("Invalid environment identifier. Must be a positive number") + } + return nil +} + +// @id StackMigrate +// @summary Migrate a stack to another environment(endpoint) +// @description Migrate a stack from an environment(endpoint) to another environment(endpoint). It will re-create the stack inside the target environment(endpoint) before removing the original stack. +// @description **Access policy**: authenticated +// @tags stacks +// @security ApiKeyAuth +// @security jwt +// @produce json +// @param id path int true "Stack identifier" +// @param endpointId query int false "Stacks created before version 1.18.0 might not have an associated environment(endpoint) identifier. Use this optional parameter to set the environment(endpoint) identifier used by the stack." +// @param body body stackMigratePayload true "Stack migration details" +// @success 200 {object} portainer.Stack "Success" +// @failure 400 "Invalid request" +// @failure 403 "Permission denied" +// @failure 404 "Stack not found" +// @failure 409 "A stack with the same name is already running on the target environment(endpoint)" +// @failure 500 "Server error" +// @router /stacks/{id}/migrate [post] +func (handler *Handler) stackMigrate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + stackID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid stack identifier route variable", err) + } + + var payload stackMigratePayload + if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + stack, err := handler.DataStore.Stack().Read(portainer.StackID(stackID)) + if handler.DataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find a stack with the specified identifier inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find a stack with the specified identifier inside the database", err) + } + + if stack.Type == portainer.KubernetesStack { + return httperror.BadRequest("Migrating a kubernetes stack is not supported", err) + } + + endpoint, err := handler.DataStore.Endpoint().Endpoint(stack.EndpointID) + if handler.DataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find an endpoint with the specified identifier inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find an endpoint with the specified identifier inside the database", err) + } + + if err := handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint); err != nil { + return httperror.Forbidden("Permission denied to access endpoint", err) + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) + } + + canManage, err := handler.userCanManageStacks(securityContext, endpoint) + if err != nil { + return httperror.InternalServerError("Unable to verify user authorizations to validate stack deletion", err) + } + if !canManage { + errMsg := "Stack migration is disabled for non-admin users" + return httperror.Forbidden(errMsg, errors.New(errMsg)) + } + + resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl) + if err != nil { + return httperror.InternalServerError("Unable to retrieve a resource control associated to the stack", err) + } + + access, err := handler.userCanAccessStack(securityContext, resourceControl) + if err != nil { + return httperror.InternalServerError("Unable to verify user authorizations to validate stack access", err) + } + if !access { + return httperror.Forbidden("Access denied to resource", httperrors.ErrResourceAccessDenied) + } + + // TODO: this is a work-around for stacks created with Portainer version >= 1.17.1 + // The EndpointID property is not available for these stacks, this API environment(endpoint) + // can use the optional EndpointID query parameter to associate a valid environment(endpoint) identifier to the stack. + endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", true) + if err != nil { + return httperror.BadRequest("Invalid query parameter: endpointId", err) + } + if endpointID != int(stack.EndpointID) { + stack.EndpointID = portainer.EndpointID(endpointID) + } + + targetEndpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(payload.EndpointID)) + if handler.DataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find an endpoint with the specified identifier inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find an endpoint with the specified identifier inside the database", err) + } + + stack.EndpointID = portainer.EndpointID(payload.EndpointID) + if payload.SwarmID != "" { + stack.SwarmID = payload.SwarmID + } + + oldName := stack.Name + if payload.Name != "" { + stack.Name = payload.Name + } + + isUnique, err := handler.checkUniqueStackNameInDocker(targetEndpoint, stack.Name, stack.ID, stack.SwarmID != "") + if err != nil { + return httperror.InternalServerError("Unable to check for name collision", err) + } + + if !isUnique { + errorMessage := fmt.Sprintf("A stack with the name '%s' is already running on endpoint '%s'", stack.Name, targetEndpoint.Name) + return httperror.Conflict(errorMessage, errors.New(errorMessage)) + } + + migrationError := handler.migrateStack(r, stack, targetEndpoint) + if migrationError != nil { + return migrationError + } + + newName := stack.Name + stack.Name = oldName + if err := handler.deleteStack(context.TODO(), securityContext.UserID, stack, endpoint); err != nil { + return httperror.InternalServerError(err.Error(), err) + } + + stack.Name = newName + if err := handler.DataStore.Stack().Update(stack.ID, stack); err != nil { + return httperror.InternalServerError("Unable to persist the stack changes inside the database", err) + } + + if resourceControl != nil { + resourceControl.ResourceID = stackutils.ResourceControlID(stack.EndpointID, stack.Name) + err := handler.DataStore.ResourceControl().Update(resourceControl.ID, resourceControl) + if err != nil { + return httperror.InternalServerError("Unable to persist the resource control changes", err) + } + } + + err = handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error { + userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships) + + if err := fillStackGitConfig(tx, userContext, stack); err != nil { + return httperror.InternalServerError("Unable to load git config for stack", err) + } + + return nil + }) + + return response.TxResponse(w, stack, err) +} + +func (handler *Handler) migrateStack(r *http.Request, stack *portainer.Stack, next *portainer.Endpoint) *httperror.HandlerError { + if stack.Type == portainer.DockerSwarmStack { + return handler.migrateSwarmStack(r, stack, next) + } + return handler.migrateComposeStack(r, stack, next) +} + +func (handler *Handler) migrateComposeStack(r *http.Request, stack *portainer.Stack, next *portainer.Endpoint) *httperror.HandlerError { + // Create compose deployment config + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) + } + + composeDeploymentConfig, err := deployments.CreateComposeStackDeploymentConfigTx(handler.DataStore, + securityContext, + stack, + next, + handler.FileService, + handler.StackDeployer, + true, + false, + false) + if err != nil { + return httperror.InternalServerError(err.Error(), err) + } + + // Deploy the stack + if err := composeDeploymentConfig.Deploy(context.TODO()); err != nil { + return httperror.InternalServerError(err.Error(), err) + } + + return nil +} + +func (handler *Handler) migrateSwarmStack(r *http.Request, stack *portainer.Stack, next *portainer.Endpoint) *httperror.HandlerError { + // Create swarm deployment config + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) + } + + swarmDeploymentConfig, err := deployments.CreateSwarmStackDeploymentConfigTx(handler.DataStore, + securityContext, + stack, + next, + handler.FileService, + handler.StackDeployer, + true, + true) + if err != nil { + return httperror.InternalServerError(err.Error(), err) + } + + // Deploy the stack + if err := swarmDeploymentConfig.Deploy(context.TODO()); err != nil { + return httperror.InternalServerError(err.Error(), err) + } + + return nil +} diff --git a/api/http/handler/stacks/stack_start.go b/api/http/handler/stacks/stack_start.go new file mode 100644 index 0000000..ba26683 --- /dev/null +++ b/api/http/handler/stacks/stack_start.go @@ -0,0 +1,215 @@ +package stacks + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/dataservices/source" + httperrors "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/stacks/deployments" + "github.com/portainer/portainer/api/stacks/stackutils" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" + + "github.com/rs/zerolog/log" +) + +// @id StackStart +// @summary Starts a stopped Stack +// @description Starts a stopped Stack. +// @description **Access policy**: authenticated +// @tags stacks +// @security ApiKeyAuth +// @security jwt +// @param id path int true "Stack identifier" +// @param endpointId query int true "Environment identifier" +// @success 200 {object} portainer.Stack "Success" +// @failure 400 "Invalid request" +// @failure 403 "Permission denied" +// @failure 404 "Not found" +// @failure 409 "Stack is already active, deploying, or in error state" +// @failure 500 "Server error" +// @router /stacks/{id}/start [post] +func (handler *Handler) stackStart(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + stackID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid stack identifier route variable", err) + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) + } + + stack, err := handler.DataStore.Stack().Read(portainer.StackID(stackID)) + if handler.DataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find a stack with the specified identifier inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find a stack with the specified identifier inside the database", err) + } + + if stack.Type == portainer.KubernetesStack { + return httperror.BadRequest("Starting a kubernetes stack is not supported", err) + } + + // Check stack status before checking endpoint access to avoid unnecessary database + // calls in case of invalid stack status + switch stack.Status { + case portainer.StackStatusActive: + return httperror.Conflict("Unable to start stack", errors.New("Stack is already active")) + case portainer.StackStatusDeploying: + return httperror.Conflict("Unable to start stack", errors.New("Stack deployment is already in progress")) + case portainer.StackStatusError: + errMessage := "Stack is in error state" + if len(stack.DeploymentStatus) > 0 { + lastDeploymentStatus := stack.DeploymentStatus[len(stack.DeploymentStatus)-1] + if lastDeploymentStatus.Status == portainer.StackStatusError && lastDeploymentStatus.Message != "" { + errMessage = lastDeploymentStatus.Message + } + } + + return httperror.Conflict("Unable to start stack", errors.New(errMessage)) + } + + endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", false) + if err != nil { + return httperror.BadRequest("Invalid query parameter: endpointId", err) + } + + endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) + if handler.DataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find an endpoint with the specified identifier inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find an endpoint with the specified identifier inside the database", err) + } + + err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) + if err != nil { + return httperror.Forbidden("Permission denied to access endpoint", err) + } + + canManage, err := handler.userCanManageStacks(securityContext, endpoint) + if err != nil { + return httperror.InternalServerError("Unable to verify user authorizations to validate stack deletion", err) + } + if !canManage { + errMsg := "stack management is disabled for non-admin users" + return httperror.Forbidden(errMsg, errors.New(errMsg)) + } + + isUnique, err := handler.checkUniqueStackNameInDocker(endpoint, stack.Name, stack.ID, stack.SwarmID != "") + if err != nil { + return httperror.InternalServerError("Unable to check for name collision", err) + } + if !isUnique { + errorMessage := fmt.Sprintf("A stack with the name '%s' is already running", stack.Name) + return httperror.Conflict(errorMessage, errors.New(errorMessage)) + } + + resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl) + if err != nil { + return httperror.InternalServerError("Unable to retrieve a resource control associated to the stack", err) + } + + access, err := handler.userCanAccessStack(securityContext, resourceControl) + if err != nil { + return httperror.InternalServerError("Unable to verify user authorizations to validate stack access", err) + } + if !access { + return httperror.Forbidden("Access denied to resource", httperrors.ErrResourceAccessDenied) + } + + if stack.AutoUpdate != nil && stack.AutoUpdate.Interval != "" { + deployments.StopAutoupdate(stack.ID, stack.AutoUpdate.JobID, handler.Scheduler) + + jobID, e := deployments.StartAutoupdate(context.TODO(), stack.ID, stack.AutoUpdate.Interval, handler.Scheduler, handler.StackDeployer, handler.DataStore, handler.GitService) + if e != nil { + return e + } + + stack.AutoUpdate.JobID = jobID + } + + if err := handler.startStack(context.TODO(), securityContext.UserID, stack, endpoint, securityContext); err != nil { + stack.Status = portainer.StackStatusError + stack.DeploymentStatus = append(stack.DeploymentStatus, portainer.StackDeploymentStatus{ + Status: portainer.StackStatusError, + Time: time.Now().Unix(), + Message: err.Error(), + }) + if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + return tx.Stack().Update(stack.ID, stack) + }); err != nil { + log.Warn().Err(err).Str("context", "StackStart").Msg("Unable to update stack status after failed start attempt") + } + + return httperror.InternalServerError("Unable to start stack", err) + } + + stack.Status = portainer.StackStatusActive + stack.DeploymentStatus = []portainer.StackDeploymentStatus{ + {Status: portainer.StackStatusActive, Time: time.Now().Unix()}, + } + err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + if err := tx.Stack().Update(stack.ID, stack); err != nil { + return httperror.InternalServerError("Unable to update stack status", err) + } + + userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships) + + if err := fillStackGitConfig(tx, userContext, stack); err != nil { + return httperror.InternalServerError("Unable to load git config for stack", err) + } + return nil + }) + + return response.TxResponse(w, stack, err) +} + +func (handler *Handler) startStack( + ctx context.Context, + userID portainer.UserID, + stack *portainer.Stack, + endpoint *portainer.Endpoint, + securityContext *security.RestrictedRequestContext, +) error { + user, err := handler.DataStore.User().Read(securityContext.UserID) + if err != nil { + return fmt.Errorf("unable to load user information from the database: %w", err) + } + + registries, err := handler.DataStore.Registry().ReadAll() + if err != nil { + return fmt.Errorf("unable to retrieve registries from the database: %w", err) + } + + filteredRegistries := security.FilterRegistries(registries, user, securityContext.UserMemberships, endpoint.ID) + + switch stack.Type { + case portainer.DockerComposeStack: + stack.Name = handler.ComposeStackManager.NormalizeStackName(stack.Name) + + if stackutils.IsRelativePathStack(stack) { + return handler.StackDeployer.StartRemoteComposeStack(ctx, userID, stack, endpoint, filteredRegistries) + } + + return handler.StackDeployer.DeployComposeStack(ctx, stack, endpoint, filteredRegistries, false, false, false) + case portainer.DockerSwarmStack: + stack.Name = handler.SwarmStackManager.NormalizeStackName(stack.Name) + + if stackutils.IsRelativePathStack(stack) { + return handler.StackDeployer.StartRemoteSwarmStack(ctx, userID, stack, endpoint, filteredRegistries) + } + + return handler.StackDeployer.DeploySwarmStack(ctx, stack, endpoint, filteredRegistries, true, true) + } + + return nil +} diff --git a/api/http/handler/stacks/stack_start_test.go b/api/http/handler/stacks/stack_start_test.go new file mode 100644 index 0000000..ce6cf24 --- /dev/null +++ b/api/http/handler/stacks/stack_start_test.go @@ -0,0 +1,140 @@ +package stacks + +import ( + "context" + "net/http" + "net/http/httptest" + "strconv" + "testing" + + "github.com/pkg/errors" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/internal/testhelpers" + "github.com/portainer/portainer/api/stacks/deployments" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Stubs +type stubComposeStackManager struct { + portainer.ComposeStackManager +} + +func (s *stubComposeStackManager) NormalizeStackName(name string) string { return name } + +type stubStackDeployer struct { + deployments.StackDeployer + deployErr error +} + +func (s *stubStackDeployer) DeployComposeStack(_ context.Context, _ *portainer.Stack, _ *portainer.Endpoint, _ []portainer.Registry, _, _, _ bool) error { + return s.deployErr +} + +func stackStartRequest(stackID portainer.StackID, endpointID portainer.EndpointID) *http.Request { + url := "/stacks/" + strconv.Itoa(int(stackID)) + "/start?endpointId=" + strconv.Itoa(int(endpointID)) + return mockCreateStackRequestWithSecurityContext(http.MethodPost, url, nil) +} + +func newStackStartHandler(t *testing.T) (*Handler, *datastore.Store) { + t.Helper() + _, store := datastore.MustNewTestStore(t, true, false) + h := NewHandler(testhelpers.NewTestRequestBouncer()) + h.DataStore = store + return h, store +} + +func TestStackStart_ActiveStack_ReturnsConflict(t *testing.T) { + t.Parallel() + h, store := newStackStartHandler(t) + stack := &portainer.Stack{ID: 1, Status: portainer.StackStatusActive} + require.NoError(t, store.Stack().Create(stack)) + + w := httptest.NewRecorder() + h.ServeHTTP(w, stackStartRequest(stack.ID, 1)) + + assert.Equal(t, http.StatusConflict, w.Code) +} + +func TestStackStart_DeployingStack_ReturnsConflict(t *testing.T) { + t.Parallel() + h, store := newStackStartHandler(t) + stack := &portainer.Stack{ID: 1, Status: portainer.StackStatusDeploying} + require.NoError(t, store.Stack().Create(stack)) + + w := httptest.NewRecorder() + h.ServeHTTP(w, stackStartRequest(stack.ID, 1)) + + assert.Equal(t, http.StatusConflict, w.Code) +} + +func TestStackStart_ErrorStack_ReturnsConflict(t *testing.T) { + t.Parallel() + h, store := newStackStartHandler(t) + stack := &portainer.Stack{ID: 1, Status: portainer.StackStatusError} + require.NoError(t, store.Stack().Create(stack)) + + w := httptest.NewRecorder() + h.ServeHTTP(w, stackStartRequest(stack.ID, 1)) + + assert.Equal(t, http.StatusConflict, w.Code) +} + +func newStartableStack(endpointID portainer.EndpointID) *portainer.Stack { + return &portainer.Stack{ + ID: 1, + EndpointID: endpointID, + Type: portainer.DockerComposeStack, + Name: "test-stack", + } +} + +func TestStackStart_StartSuccess_StackStatusSetToActive(t *testing.T) { + t.Parallel() + h, store := newStackStartHandler(t) + _, err := mockCreateUser(store) + require.NoError(t, err) + endpoint, err := mockCreateEndpoint(store) + require.NoError(t, err) + stack := newStartableStack(endpoint.ID) + require.NoError(t, store.Stack().Create(stack)) + h.ComposeStackManager = &stubComposeStackManager{} + h.StackDeployer = &stubStackDeployer{} + + w := httptest.NewRecorder() + h.ServeHTTP(w, stackStartRequest(stack.ID, endpoint.ID)) + + require.Equal(t, http.StatusOK, w.Code) + updated, err := store.Stack().Read(stack.ID) + require.NoError(t, err) + assert.Equal(t, portainer.StackStatusActive, updated.Status) + require.Len(t, updated.DeploymentStatus, 1) + assert.Equal(t, portainer.StackStatusActive, updated.DeploymentStatus[0].Status) +} + +func TestStackStart_StartFailure_StackStatusSetToError(t *testing.T) { + t.Parallel() + deployErr := errors.New("failed to pull image nginx:999") + h, store := newStackStartHandler(t) + _, err := mockCreateUser(store) + require.NoError(t, err) + endpoint, err := mockCreateEndpoint(store) + require.NoError(t, err) + stack := newStartableStack(endpoint.ID) + require.NoError(t, store.Stack().Create(stack)) + h.ComposeStackManager = &stubComposeStackManager{} + h.StackDeployer = &stubStackDeployer{deployErr: deployErr} + + w := httptest.NewRecorder() + h.ServeHTTP(w, stackStartRequest(stack.ID, endpoint.ID)) + + require.Equal(t, http.StatusInternalServerError, w.Code) + updated, err := store.Stack().Read(stack.ID) + require.NoError(t, err) + assert.Equal(t, portainer.StackStatusError, updated.Status) + require.Len(t, updated.DeploymentStatus, 1) + lastEntry := updated.DeploymentStatus[0] + assert.Equal(t, portainer.StackStatusError, lastEntry.Status) + assert.Equal(t, deployErr.Error(), lastEntry.Message) +} diff --git a/api/http/handler/stacks/stack_stop.go b/api/http/handler/stacks/stack_stop.go new file mode 100644 index 0000000..2ef1258 --- /dev/null +++ b/api/http/handler/stacks/stack_stop.go @@ -0,0 +1,161 @@ +package stacks + +import ( + "context" + "errors" + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/dataservices/source" + httperrors "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/stacks/deployments" + "github.com/portainer/portainer/api/stacks/stackutils" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" + "github.com/rs/zerolog/log" +) + +// @id StackStop +// @summary Stop a running Stack +// @description Stop a running Stack. +// @description **Access policy**: authenticated +// @tags stacks +// @security ApiKeyAuth +// @security jwt +// @param id path int true "Stack identifier" +// @param endpointId query int true "Environment identifier" +// @success 200 {object} portainer.Stack "Success" +// @failure 400 "Invalid request" +// @failure 403 "Permission denied" +// @failure 404 "Not found" +// @failure 409 "Conflict" +// @failure 500 "Server error" +// @router /stacks/{id}/stop [post] +func (handler *Handler) stackStop(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + stackID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid stack identifier route variable", err) + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) + } + + stack, err := handler.DataStore.Stack().Read(portainer.StackID(stackID)) + if handler.DataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find a stack with the specified identifier inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find a stack with the specified identifier inside the database", err) + } + + if stack.Type == portainer.KubernetesStack { + return httperror.BadRequest("Stopping a kubernetes stack is not supported", err) + } + + endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", false) + if err != nil { + return httperror.BadRequest("Invalid query parameter: endpointId", err) + } + + endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) + if handler.DataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find an environment with the specified identifier inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find an environment with the specified identifier inside the database", err) + } + + err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) + if err != nil { + return httperror.Forbidden("Permission denied to access environment", err) + } + + resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl) + if err != nil { + return httperror.InternalServerError("Unable to retrieve a resource control associated to the stack", err) + } + + access, err := handler.userCanAccessStack(securityContext, resourceControl) + if err != nil { + return httperror.InternalServerError("Unable to verify user authorizations to validate stack access", err) + } + if !access { + return httperror.Forbidden("Access denied to resource", httperrors.ErrResourceAccessDenied) + } + + canManage, err := handler.userCanManageStacks(securityContext, endpoint) + if err != nil { + return httperror.InternalServerError("Unable to verify user authorizations to validate stack deletion", err) + } + if !canManage { + errMsg := "stack management is disabled for non-admin users" + return httperror.Forbidden(errMsg, errors.New(errMsg)) + } + + if stack.Status == portainer.StackStatusInactive { + return httperror.BadRequest("Stack is already inactive", errors.New("Stack is already inactive")) + } + + if stack.Status == portainer.StackStatusDeploying { + return httperror.Conflict("Stack deployment is in progress", errors.New("stack deployment is in progress")) + } + + // stop scheduler updates of the stack before stopping + if stack.AutoUpdate != nil && stack.AutoUpdate.JobID != "" { + deployments.StopAutoupdate(stack.ID, stack.AutoUpdate.JobID, handler.Scheduler) + stack.AutoUpdate.JobID = "" + } + + stopErr := handler.stopStack(r.Context(), securityContext.UserID, stack, endpoint) + if stopErr != nil { + if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + stackutils.UpdateStackStatusFromUndeploymentResult(stack, stopErr) + return tx.Stack().Update(stack.ID, stack) + }); err != nil { + log.Warn().Err(err).Str("context", "StackStop").Msg("Unable to update stack status after failed stop attempt") + } + + return httperror.InternalServerError("Unable to stop stack", stopErr) + } + + err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + stackutils.UpdateStackStatusFromUndeploymentResult(stack, nil) + if err := tx.Stack().Update(stack.ID, stack); err != nil { + return httperror.InternalServerError("Unable to update stack status", err) + } + + userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships) + if err := fillStackGitConfig(tx, userContext, stack); err != nil { + return httperror.InternalServerError("Unable to load git config for stack", err) + } + return nil + }) + + return response.TxResponse(w, stack, err) +} + +func (handler *Handler) stopStack(ctx context.Context, userId portainer.UserID, stack *portainer.Stack, endpoint *portainer.Endpoint) error { + switch stack.Type { + case portainer.DockerComposeStack: + stack.Name = handler.ComposeStackManager.NormalizeStackName(stack.Name) + + if stackutils.IsRelativePathStack(stack) { + return handler.StackDeployer.StopRemoteComposeStack(ctx, userId, stack, endpoint) + } + + return handler.StackDeployer.UndeployComposeStack(ctx, stack, endpoint) + case portainer.DockerSwarmStack: + stack.Name = handler.SwarmStackManager.NormalizeStackName(stack.Name) + + if stackutils.IsRelativePathStack(stack) { + return handler.StackDeployer.StopRemoteSwarmStack(ctx, userId, stack, endpoint) + } + + return handler.SwarmStackManager.Remove(ctx, stack, endpoint) + } + + return nil +} diff --git a/api/http/handler/stacks/stack_test_helper.go b/api/http/handler/stacks/stack_test_helper.go new file mode 100644 index 0000000..4dd3315 --- /dev/null +++ b/api/http/handler/stacks/stack_test_helper.go @@ -0,0 +1,54 @@ +package stacks + +import ( + "io" + "net/http" + "net/http/httptest" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/authorization" +) + +func mockCreateUser(store *datastore.Store) (*portainer.User, error) { + user := &portainer.User{ID: 1, Username: "testUser", Role: portainer.AdministratorRole, PortainerAuthorizations: authorization.DefaultPortainerAuthorizations()} + err := store.User().Create(user) + return user, err +} + +func mockCreateEndpoint(store *datastore.Store) (*portainer.Endpoint, error) { + endpoint := &portainer.Endpoint{ + ID: 1, + Name: "testEndpoint", + SecuritySettings: portainer.EndpointSecuritySettings{ + AllowBindMountsForRegularUsers: true, + AllowPrivilegedModeForRegularUsers: true, + AllowVolumeBrowserForRegularUsers: true, + AllowHostNamespaceForRegularUsers: true, + AllowDeviceMappingForRegularUsers: true, + AllowStackManagementForRegularUsers: true, + AllowContainerCapabilitiesForRegularUsers: true, + AllowSysctlSettingForRegularUsers: true, + EnableHostManagementFeatures: true, + }, + } + + err := store.Endpoint().Create(endpoint) + + return endpoint, err +} + +func mockCreateStackRequestWithSecurityContext(method, target string, body io.Reader) *http.Request { + req := httptest.NewRequest(method, + target, + body) + + ctx := security.StoreRestrictedRequestContext(req, &security.RestrictedRequestContext{ + IsAdmin: true, + UserID: portainer.UserID(1), + User: &portainer.User{ID: 1, Role: portainer.AdministratorRole}, + }) + + return req.WithContext(ctx) +} diff --git a/api/http/handler/stacks/stack_update.go b/api/http/handler/stacks/stack_update.go new file mode 100644 index 0000000..f255a30 --- /dev/null +++ b/api/http/handler/stacks/stack_update.go @@ -0,0 +1,450 @@ +package stacks + +import ( + "context" + "net/http" + "strconv" + "time" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/dataservices/source" + httperrors "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/stacks/deployments" + "github.com/portainer/portainer/api/stacks/stackutils" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" + + "github.com/pkg/errors" + "github.com/rs/zerolog/log" +) + +type postDeployFunc func(ctx context.Context, err error) + +type updateComposeStackPayload struct { + // New content of the Stack file + StackFileContent string `example:"version: 3\n services:\n web:\n image:nginx"` + // A list of environment(endpoint) variables used during stack deployment + Env []portainer.Pair + // RepullImageAndRedeploy indicates whether to force repulling images and redeploying the stack + RepullImageAndRedeploy bool + // Prune services that are no longer referenced + Prune bool `example:"true"` + + // Deprecated(2.36): use RepullImageAndRedeploy instead for cleaner responsibility + // Force a pulling to current image with the original tag though the image is already the latest + PullImage bool `example:"false"` +} + +func (payload *updateComposeStackPayload) Validate(r *http.Request) error { + if len(payload.StackFileContent) == 0 { + return errors.New("Invalid stack file content") + } + + return nil +} + +type updateSwarmStackPayload struct { + // New content of the Stack file + StackFileContent string `example:"version: 3\n services:\n web:\n image:nginx"` + // A list of environment(endpoint) variables used during stack deployment + Env []portainer.Pair + // Prune services that are no longer referenced + Prune bool `example:"true"` + // RepullImageAndRedeploy indicates whether to force repulling images and redeploying the stack + RepullImageAndRedeploy bool + + // Deprecated(2.36): use RepullImageAndRedeploy instead for cleaner responsibility + // Force a pulling to current image with the original tag though the image is already the latest + PullImage bool `example:"false"` +} + +func (payload *updateSwarmStackPayload) Validate(r *http.Request) error { + if len(payload.StackFileContent) == 0 { + return errors.New("Invalid stack file content") + } + + return nil +} + +// @id StackUpdate +// @summary Update a stack +// @description Update a stack, only for file based stacks. +// @description **Access policy**: authenticated +// @tags stacks +// @security ApiKeyAuth +// @security jwt +// @accept json +// @produce json +// @param id path int true "Stack identifier" +// @param endpointId query int true "Environment identifier" +// @param body body updateSwarmStackPayload true "Stack details" +// @success 200 {object} portainer.Stack "Success" +// @failure 400 "Invalid request" +// @failure 403 "Permission denied" +// @failure 404 "Not found" +// @failure 409 "Conflict" +// @failure 500 "Server error" +// @router /stacks/{id} [put] +func (handler *Handler) stackUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + stackID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid stack identifier route variable", err) + } + + // TODO: this is a work-around for stacks created with Portainer version >= 1.17.1 + // The EndpointID property is not available for these stacks, this API endpoint + // can use the optional EndpointID query parameter to associate a valid environment(endpoint) identifier to the stack. + endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", false) + if err != nil { + return httperror.BadRequest("Invalid query parameter: endpointId", err) + } + + var stack *portainer.Stack + err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + var httpErr *httperror.HandlerError + stack, httpErr = handler.updateStackInTx(tx, r, portainer.StackID(stackID), portainer.EndpointID(endpointID)) + if httpErr != nil { + return httpErr + } + return nil + }) + return response.TxResponse(w, stack, err) +} + +func (handler *Handler) updateStackInTx(tx dataservices.DataStoreTx, r *http.Request, stackID portainer.StackID, endpointID portainer.EndpointID) (*portainer.Stack, *httperror.HandlerError) { + stack, err := tx.Stack().Read(stackID) + if tx.IsErrObjectNotFound(err) { + return nil, httperror.NotFound("Unable to find a stack with the specified identifier inside the database", err) + } else if err != nil { + return nil, httperror.InternalServerError("Unable to find a stack with the specified identifier inside the database", err) + } + + if stack.Status == portainer.StackStatusDeploying { + return nil, httperror.Conflict("Unable to update stack", errors.New("Stack deployment is already in progress")) + } + + if endpointID != 0 && endpointID != stack.EndpointID { + stack.EndpointID = endpointID + } + + endpoint, err := tx.Endpoint().Endpoint(stack.EndpointID) + if tx.IsErrObjectNotFound(err) { + return nil, httperror.NotFound("Unable to find the environment associated to the stack inside the database", err) + } else if err != nil { + return nil, httperror.InternalServerError("Unable to find the environment associated to the stack inside the database", err) + } + + if err := handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint); err != nil { + return nil, httperror.Forbidden("Permission denied to access environment", err) + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return nil, httperror.InternalServerError("Unable to retrieve info from request context", err) + } + + //only check resource control when it is a DockerSwarmStack or a DockerComposeStack + if stack.Type == portainer.DockerSwarmStack || stack.Type == portainer.DockerComposeStack { + resourceControl, err := tx.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl) + if err != nil { + return nil, httperror.InternalServerError("Unable to retrieve a resource control associated to the stack", err) + } + + if access, err := handler.userCanAccessStack(securityContext, resourceControl); err != nil { + return nil, httperror.InternalServerError("Unable to verify user authorizations to validate stack access", err) + } else if !access { + return nil, httperror.Forbidden("Access denied to resource", httperrors.ErrResourceAccessDenied) + } + } + + if canManage, err := handler.userCanManageStacks(securityContext, endpoint); err != nil { + return nil, httperror.InternalServerError("Unable to verify user authorizations to validate stack deletion", err) + } else if !canManage { + errMsg := "Stack editing is disabled for non-admin users" + + return nil, httperror.Forbidden(errMsg, errors.New(errMsg)) + } + + deployGate := newDeployGate() + if err := handler.updateAndDeployStack(tx, r, stack, endpoint, deployGate); err != nil { + return nil, err + } + + user, err := tx.User().Read(securityContext.UserID) + if err != nil { + return nil, httperror.BadRequest("Cannot find context user", errors.Wrap(err, "failed to fetch the user")) + } + + stack.UpdatedBy = user.Username + stack.UpdateDate = time.Now().Unix() + stackutils.PrepareStackStatusForDeployment(stack) + + if err := tx.Stack().Update(stack.ID, stack); err != nil { + deployGate.abortDeploy() + return nil, httperror.InternalServerError("Unable to persist the stack changes inside the database", err) + } + + deployGate.startDeploy() + + userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships) + if err := fillStackGitConfig(tx, userContext, stack); err != nil { + return nil, httperror.InternalServerError("Unable to load git config for stack", err) + } + + return stack, nil +} + +func (handler *Handler) updateAndDeployStack(tx dataservices.DataStoreTx, r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint, gate *deployGate) *httperror.HandlerError { + switch stack.Type { + case portainer.DockerSwarmStack: + stack.Name = handler.SwarmStackManager.NormalizeStackName(stack.Name) + + return handler.updateSwarmStack(tx, r, stack, endpoint, gate) + case portainer.DockerComposeStack: + stack.Name = handler.ComposeStackManager.NormalizeStackName(stack.Name) + + return handler.updateComposeStack(tx, r, stack, endpoint, gate) + case portainer.KubernetesStack: + return handler.updateKubernetesStack(tx, r, stack, endpoint, gate) + } + + return httperror.InternalServerError("Unsupported stack", errors.Errorf("unsupported stack type: %v", stack.Type)) +} + +func (handler *Handler) updateComposeStack(tx dataservices.DataStoreTx, r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint, gate *deployGate) *httperror.HandlerError { + // Must not be git based stack. stop the auto update job if there is any + if stack.AutoUpdate != nil { + deployments.StopAutoupdate(stack.ID, stack.AutoUpdate.JobID, handler.Scheduler) + stack.AutoUpdate = nil + } + if stack.WorkflowID != 0 { + stack.FromAppTemplate = true + } + + var payload updateComposeStackPayload + if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + payload.RepullImageAndRedeploy = payload.RepullImageAndRedeploy || payload.PullImage + stack.Env = payload.Env + + if stack.WorkflowID != 0 { + oldWorkflowID := stack.WorkflowID + stack.WorkflowID = 0 + if err := tx.Workflow().Delete(oldWorkflowID); err != nil { + return httperror.InternalServerError("Unable to remove git workflow records from database", err) + } + } + + stackFolder := strconv.Itoa(int(stack.ID)) + if _, err := handler.FileService.UpdateStoreStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent)); err != nil { + if rollbackErr := handler.FileService.RollbackStackFile(stackFolder, stack.EntryPoint); rollbackErr != nil { + log.Warn().Err(rollbackErr).Msg("rollback stack file error") + } + + return httperror.InternalServerError("Unable to persist updated Compose file on disk", err) + } + + // Create compose deployment config + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) + } + + composeDeploymentConfig, err := deployments.CreateComposeStackDeploymentConfigTx(tx, securityContext, + stack, + endpoint, + handler.FileService, + handler.StackDeployer, + payload.Prune, + payload.RepullImageAndRedeploy, + payload.RepullImageAndRedeploy) + if err != nil { + if rollbackErr := handler.FileService.RollbackStackFile(stackFolder, stack.EntryPoint); rollbackErr != nil { + log.Warn().Err(rollbackErr).Msg("rollback stack file error") + } + + return httperror.InternalServerError(err.Error(), err) + } + + if stack.Option != nil { + stack.Option.Prune = payload.Prune + } else { + stack.Option = &portainer.StackOption{ + Prune: payload.Prune, + } + } + + postDeploy := func(ctx context.Context, deployErr error) { + if deployErr != nil { + if rollbackErr := handler.FileService.RollbackStackFile(stackFolder, stack.EntryPoint); rollbackErr != nil { + log.Warn().Err(rollbackErr).Msg("rollback stack file error") + } + return + } + + if err := handler.FileService.RemoveStackFileBackup(stackFolder, stack.EntryPoint); err != nil { + log.Warn().Err(err).Msg("remove stack file backup error") + } + } + + go stackDeploy(handler.DataStore, stack.ID, composeDeploymentConfig, gate, postDeploy) + + return nil +} + +func (handler *Handler) updateSwarmStack(tx dataservices.DataStoreTx, r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint, gate *deployGate) *httperror.HandlerError { + // Must not be git based stack. stop the auto update job if there is any + if stack.AutoUpdate != nil { + deployments.StopAutoupdate(stack.ID, stack.AutoUpdate.JobID, handler.Scheduler) + stack.AutoUpdate = nil + } + if stack.WorkflowID != 0 { + stack.FromAppTemplate = true + } + + var payload updateSwarmStackPayload + if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + payload.RepullImageAndRedeploy = payload.RepullImageAndRedeploy || payload.PullImage + stack.Env = payload.Env + + if stack.WorkflowID != 0 { + oldWorkflowID := stack.WorkflowID + stack.WorkflowID = 0 + if err := tx.Workflow().Delete(oldWorkflowID); err != nil { + return httperror.InternalServerError("Unable to remove git workflow records from database", err) + } + } + + stackFolder := strconv.Itoa(int(stack.ID)) + if _, err := handler.FileService.UpdateStoreStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent)); err != nil { + if rollbackErr := handler.FileService.RollbackStackFile(stackFolder, stack.EntryPoint); rollbackErr != nil { + log.Warn().Err(rollbackErr).Msg("rollback stack file error") + } + + return httperror.InternalServerError("Unable to persist updated Compose file on disk", err) + } + + // Create swarm deployment config + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) + } + + swarmDeploymentConfig, err := deployments.CreateSwarmStackDeploymentConfigTx(tx, securityContext, + stack, + endpoint, + handler.FileService, + handler.StackDeployer, + payload.Prune, + payload.RepullImageAndRedeploy) + if err != nil { + if rollbackErr := handler.FileService.RollbackStackFile(stackFolder, stack.EntryPoint); rollbackErr != nil { + log.Warn().Err(rollbackErr).Msg("rollback stack file error") + } + + return httperror.InternalServerError(err.Error(), err) + } + + if stack.Option != nil { + stack.Option.Prune = payload.Prune + } else { + stack.Option = &portainer.StackOption{ + Prune: payload.Prune, + } + } + + postDeploy := func(ctx context.Context, deployErr error) { + if deployErr != nil { + if rollbackErr := handler.FileService.RollbackStackFile(stackFolder, stack.EntryPoint); rollbackErr != nil { + log.Warn().Err(rollbackErr).Msg("rollback stack file error") + } + return + } + + if err := handler.FileService.RemoveStackFileBackup(stackFolder, stack.EntryPoint); err != nil { + log.Warn().Err(err).Msg("remove stack file backup error") + } + } + + go stackDeploy(handler.DataStore, stack.ID, swarmDeploymentConfig, gate, postDeploy) + + return nil +} + +func stackDeploy(dataStore dataservices.DataStore, stackID portainer.StackID, stackDeploymentConfig deployments.StackDeploymentConfiger, gate *deployGate, postDeploy postDeployFunc) { + // Wait until stack update payload is persisted + if !gate.wait() { + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + shouldUndeploy := false + if err := dataStore.ViewTx(func(tx dataservices.DataStoreTx) error { + stack, err := tx.Stack().Read(stackID) + if err != nil { + return err + } + + shouldUndeploy = stack.Status == portainer.StackStatusDeploying && stack.DeploymentStartStatus == portainer.StackStatusError + return nil + }); err != nil { + log.Error().Err(err). + Int("stack_id", int(stackID)). + Str("context", "stackDeploy"). + Msg("Failed to determine stack status before async deployment") + return + } + + if shouldUndeploy { + if undeployErr := stackDeploymentConfig.Undeploy(ctx); undeployErr != nil { + if err := dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + stack, err := tx.Stack().Read(stackID) + if err != nil { + return err + } + + stackutils.UpdateStackStatusFromUndeploymentResult(stack, undeployErr) + return tx.Stack().Update(stack.ID, stack) + }); err != nil { + log.Error().Err(err). + AnErr("undeploy_error", undeployErr). + Int("stack_id", int(stackID)). + Str("context", "stackDeploy"). + Msg("Failed to update stack status before async deployment") + return + } + } + } + + deployErr := stackDeploymentConfig.Deploy(ctx) + + if err := dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + stack, err := tx.Stack().Read(stackID) + if err != nil { + return err + } + + stackutils.UpdateStackStatusFromDeploymentResult(stack, deployErr) + return tx.Stack().Update(stack.ID, stack) + }); err != nil { + log.Error().Err(err). + AnErr("deploy_error", deployErr). + Int("stack_id", int(stackID)). + Str("context", "stackDeploy"). + Msg("Failed to update stack status after async deployment") + return + } + + if postDeploy != nil { + postDeploy(ctx, deployErr) + } +} diff --git a/api/http/handler/stacks/stack_update_git.go b/api/http/handler/stacks/stack_update_git.go new file mode 100644 index 0000000..eaf74ed --- /dev/null +++ b/api/http/handler/stacks/stack_update_git.go @@ -0,0 +1,278 @@ +package stacks + +import ( + "cmp" + "context" + "net/http" + "time" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/dataservices/source" + gittypes "github.com/portainer/portainer/api/git/types" + "github.com/portainer/portainer/api/git/update" + "github.com/portainer/portainer/api/gitops/sources" + httperrors "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/stacks/deployments" + "github.com/portainer/portainer/api/stacks/stackutils" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" + + "github.com/pkg/errors" +) + +type stackGitUpdatePayload struct { + AutoUpdate *portainer.AutoUpdateSettings + Env []portainer.Pair + Prune bool + ConfigFilePath string + AdditionalFiles []string + RepositoryReferenceName string + // SourceID references an existing Source for git credentials/URL. + // When set, the inline URL and authentication fields are ignored. + SourceID portainer.SourceID + // Deprecated: use SourceID instead. URL of a Git repository hosting the Stack file. + RepositoryURL string + // Deprecated: use SourceID instead. Use basic authentication to clone the Git repository. + RepositoryAuthentication bool + // Deprecated: use SourceID instead. Username used in basic authentication. + RepositoryUsername string + // Deprecated: use SourceID instead. Password used in basic authentication. + RepositoryPassword string + // Deprecated: use SourceID instead. Skip TLS verification when cloning the Git repository. + TLSSkipVerify bool +} + +func (payload *stackGitUpdatePayload) Validate(r *http.Request) error { + return update.ValidateAutoUpdateSettings(payload.AutoUpdate) +} + +// @id StackUpdateGit +// @summary Update a stack's Git configs +// @description Update the Git settings in a stack, e.g., RepositoryReferenceName and AutoUpdate. When SourceID is set, URL/auth/TLS are taken from the referenced Source. +// @description **Access policy**: authenticated +// @tags stacks +// @security ApiKeyAuth +// @security jwt +// @accept json +// @produce json +// @param id path int true "Stack identifier" +// @param endpointId query int false "Stacks created before version 1.18.0 might not have an associated environment(endpoint) identifier. Use this optional parameter to set the environment(endpoint) identifier used by the stack." +// @param body body stackGitUpdatePayload true "Git configs for pull and redeploy a stack" +// @success 200 {object} stackResponse "Success" +// @failure 400 "Invalid request" +// @failure 403 "Permission denied" +// @failure 404 "Not found" +// @failure 500 "Server error" +// @router /stacks/{id}/git [post] +func (handler *Handler) stackUpdateGit(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + stackID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid stack identifier route variable", err) + } + + var payload stackGitUpdatePayload + if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + stack, err := handler.DataStore.Stack().Read(portainer.StackID(stackID)) + if handler.DataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find a stack with the specified identifier inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find a stack with the specified identifier inside the database", err) + } else if stack.WorkflowID == 0 { + msg := "No Git config in the found stack" + return httperror.InternalServerError(msg, errors.New(msg)) + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) + } + + var gitConfig *gittypes.RepoConfig + var sourceID portainer.SourceID + if err := handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error { + userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships) + gitConfig, sourceID, err = loadGitConfigForStack(tx, userContext, stack.WorkflowID, stack.ID) + if err != nil { + return httperror.InternalServerError("Unable to load git config for stack", err) + } + + if gitConfig == nil { + msg := "No Git config in the found stack source" + return httperror.InternalServerError(msg, errors.New(msg)) + } + + return nil + }); err != nil { + return response.TxErrorResponse(err) + } + + if payload.AutoUpdate != nil && payload.AutoUpdate.Webhook != "" && + (stack.AutoUpdate == nil || + (stack.AutoUpdate != nil && stack.AutoUpdate.Webhook != payload.AutoUpdate.Webhook)) { + if isUnique, err := handler.checkUniqueWebhookID(handler.DataStore, payload.AutoUpdate.Webhook); !isUnique || err != nil { + return httperror.Conflict("Webhook ID already exists", errors.New("webhook ID already exists")) + } + } + + // TODO: this is a work-around for stacks created with Portainer version >= 1.17.1 + // The EndpointID property is not available for these stacks, this API environment(endpoint) + // can use the optional EndpointID query parameter to associate a valid environment(endpoint) identifier to the stack. + endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", true) + if err != nil { + return httperror.BadRequest("Invalid query parameter: endpointId", err) + } + if endpointID != int(stack.EndpointID) { + stack.EndpointID = portainer.EndpointID(endpointID) + } + + endpoint, err := handler.DataStore.Endpoint().Endpoint(stack.EndpointID) + if handler.DataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find the environment associated to the stack inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find the environment associated to the stack inside the database", err) + } + + if err := handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint); err != nil { + return httperror.Forbidden("Permission denied to access environment", err) + } + + user, err := handler.DataStore.User().Read(securityContext.UserID) + if err != nil { + return httperror.BadRequest("Cannot find context user", errors.Wrap(err, "failed to fetch the user")) + } + + if stack.Type == portainer.DockerSwarmStack || stack.Type == portainer.DockerComposeStack { + resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl) + if err != nil { + return httperror.InternalServerError("Unable to retrieve a resource control associated to the stack", err) + } + + if access, err := handler.userCanAccessStack(securityContext, resourceControl); err != nil { + return httperror.InternalServerError("Unable to verify user authorizations to validate stack access", err) + } else if !access { + return httperror.Forbidden("Access denied to resource", httperrors.ErrResourceAccessDenied) + } + } + + if canManage, err := handler.userCanManageStacks(securityContext, endpoint); err != nil { + return httperror.InternalServerError("Unable to verify user authorizations to validate stack deletion", err) + } else if !canManage { + errMsg := "Stack editing is disabled for non-admin users" + return httperror.Forbidden(errMsg, errors.New(errMsg)) + } + + //stop the autoupdate job if there is any + if stack.AutoUpdate != nil { + deployments.StopAutoupdate(stack.ID, stack.AutoUpdate.JobID, handler.Scheduler) + } + + // Record the current git config as the deployment baseline if it was never set (legacy stacks). + if stack.CurrentDeploymentInfo == nil { + stack.CurrentDeploymentInfo = &portainer.StackDeploymentInfo{ + RepositoryURL: gitConfig.URL, + ReferenceName: gitConfig.ReferenceName, + ConfigFilePath: gitConfig.ConfigFilePath, + AdditionalFiles: stack.AdditionalFiles, + ConfigHash: gitConfig.ConfigHash, + SourceID: sourceID, + } + } + + // Update gitConfig based on payload; the updated config is saved to Source (not stack.GitConfig). + gitConfig.ReferenceName = payload.RepositoryReferenceName + if payload.ConfigFilePath != "" { + gitConfig.ConfigFilePath = payload.ConfigFilePath + } + if payload.AdditionalFiles != nil { + stack.AdditionalFiles = payload.AdditionalFiles + } + + stack.EntryPoint = cmp.Or(payload.ConfigFilePath, stack.EntryPoint) + + stack.AutoUpdate = payload.AutoUpdate + stack.Env = payload.Env + stack.UpdatedBy = user.Username + stack.UpdateDate = time.Now().Unix() + + if stack.Type == portainer.DockerSwarmStack { + stack.Option = &portainer.StackOption{Prune: payload.Prune} + } + + userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships) + + if payload.SourceID != 0 { + src, httpErr := sources.ValidateGitSourceAccess(handler.DataStore, userContext, payload.SourceID) + if httpErr != nil { + return httpErr + } + + if src.Git == nil { + return httperror.BadRequest("Source has no git configuration", errors.New("source has no git config")) + } + } else { + gitConfig.TLSSkipVerify = payload.TLSSkipVerify + if payload.RepositoryURL != "" { + gitConfig.URL = payload.RepositoryURL + } + + if payload.RepositoryAuthentication { + password := payload.RepositoryPassword + + // When the existing stack is using the custom username/password and the password is not updated, + // the stack should keep using the saved username/password + if password == "" && gitConfig.Authentication != nil { + password = gitConfig.Authentication.Password + } + + gitConfig.Authentication = &gittypes.GitAuthentication{ + Username: payload.RepositoryUsername, + Password: password, + } + + if _, err := handler.GitService.LatestCommitID( + context.TODO(), + gitConfig.URL, + gitConfig.ReferenceName, + gitConfig.Authentication.Username, + gitConfig.Authentication.Password, + gitConfig.TLSSkipVerify, + ); err != nil { + return httperror.InternalServerError("Unable to fetch git repository", err) + } + } else { + gitConfig.Authentication = nil + } + } + + if payload.AutoUpdate != nil && payload.AutoUpdate.Interval != "" { + if jobID, err := deployments.StartAutoupdate(context.TODO(), stack.ID, stack.AutoUpdate.Interval, handler.Scheduler, handler.StackDeployer, handler.DataStore, handler.GitService); err != nil { + return err + } else { + stack.AutoUpdate.JobID = jobID + } + } + + var resp *stackResponse + if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + if err := tx.Stack().Update(stack.ID, stack); err != nil { + return err + } + userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships) + if err := saveStackGitConfig(tx, userContext, stack.WorkflowID, stack.ID, sourceID, payload.SourceID, gitConfig); err != nil { + return err + } + var err error + resp, err = newStackResponse(tx, userContext, stack) + return err + }); err != nil { + return httperror.InternalServerError("Unable to persist the stack changes inside the database", err) + } + + return response.JSON(w, resp) +} diff --git a/api/http/handler/stacks/stack_update_git_redeploy.go b/api/http/handler/stacks/stack_update_git_redeploy.go new file mode 100644 index 0000000..1098d11 --- /dev/null +++ b/api/http/handler/stacks/stack_update_git_redeploy.go @@ -0,0 +1,369 @@ +package stacks + +import ( + "cmp" + "context" + "fmt" + "net/http" + "time" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/dataservices/source" + "github.com/portainer/portainer/api/git" + gittypes "github.com/portainer/portainer/api/git/types" + "github.com/portainer/portainer/api/gitops/workflows" + httperrors "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/api/http/security" + k "github.com/portainer/portainer/api/kubernetes" + "github.com/portainer/portainer/api/stacks/deployments" + "github.com/portainer/portainer/api/stacks/stackutils" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" + + "github.com/pkg/errors" + "github.com/rs/zerolog/log" +) + +type stackGitRedeployPayload struct { + RepositoryReferenceName string + // When true and RepositoryPassword is non-empty, stored credentials are replaced. + RepositoryAuthentication bool + RepositoryUsername string + // Non-empty value (with RepositoryAuthentication=true) replaces stored credentials; leave blank to keep them. + RepositoryPassword string + Env []portainer.Pair + Prune *bool + // RepullImageAndRedeploy indicates whether to force repulling images and redeploying the stack + RepullImageAndRedeploy bool + + StackName string + // Deprecated(2.36): use RepullImageAndRedeploy instead for cleaner responsibility + // Force a pulling to current image with the original tag though the image is already the latest + PullImage bool `example:"false"` +} + +func (payload *stackGitRedeployPayload) Validate(r *http.Request) error { + return nil +} + +// @id StackGitRedeploy +// @summary Redeploy a stack +// @description Pull and redeploy a stack via Git +// @description **Access policy**: authenticated +// @tags stacks +// @security ApiKeyAuth +// @security jwt +// @accept json +// @produce json +// @param id path int true "Stack identifier" +// @param endpointId query int false "Stacks created before version 1.18.0 might not have an associated environment(endpoint) identifier. Use this optional parameter to set the environment(endpoint) identifier used by the stack." +// @param body body stackGitRedeployPayload true "Git configs for pull and redeploy of a stack. **StackName** may only be populated for Kuberenetes stacks, and if specified with a blank string, it will be set to blank" +// @success 200 {object} portainer.Stack "Success" +// @failure 400 "Invalid request" +// @failure 403 "Permission denied" +// @failure 404 "Not found" +// @failure 409 "Conflict" +// @failure 500 "Server error" +// @router /stacks/{id}/git/redeploy [put] +func (handler *Handler) stackGitRedeploy(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + stackID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid stack identifier route variable", err) + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) + } + + var stack *portainer.Stack + var gitConfig *gittypes.RepoConfig + var sourceID portainer.SourceID + if err := handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error { + stack, err = tx.Stack().Read(portainer.StackID(stackID)) + if tx.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find a stack with the specified identifier inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find a stack with the specified identifier inside the database", err) + } + + if stack.WorkflowID == 0 { + return httperror.BadRequest("Stack is not created from git", errors.New("stack has no git workflow")) + } + + userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships) + gitConfig, sourceID, err = loadGitConfigForStack(tx, userContext, stack.WorkflowID, stack.ID) + if err != nil { + return httperror.InternalServerError("Unable to load git config for stack", err) + } + if gitConfig == nil { + return httperror.BadRequest("Stack is not created from git", errors.New("stack source has no git config")) + } + + if stack.Status == portainer.StackStatusDeploying { + return httperror.Conflict("Unable to update stack", errors.New("Stack deployment is already in progress")) + } + return nil + }); err != nil { + return response.TxErrorResponse(err) + } + + // TODO: this is a work-around for stacks created with Portainer version >= 1.17.1 + // The EndpointID property is not available for these stacks, this API environment(endpoint) + // can use the optional EndpointID query parameter to associate a valid environment(endpoint) identifier to the stack. + endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", true) + if err != nil { + return httperror.BadRequest("Invalid query parameter: endpointId", err) + } + if endpointID != int(stack.EndpointID) { + stack.EndpointID = portainer.EndpointID(endpointID) + } + + endpoint, err := handler.DataStore.Endpoint().Endpoint(stack.EndpointID) + if handler.DataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find the environment associated to the stack inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find the environment associated to the stack inside the database", err) + } + + if err := handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint); err != nil { + return httperror.Forbidden("Permission denied to access environment", err) + } + + // Only check resource control when it is a DockerSwarmStack or a DockerComposeStack + if stack.Type == portainer.DockerSwarmStack || stack.Type == portainer.DockerComposeStack { + resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl) + if err != nil { + return httperror.InternalServerError("Unable to retrieve a resource control associated to the stack", err) + } + + if access, err := handler.userCanAccessStack(securityContext, resourceControl); err != nil { + return httperror.InternalServerError("Unable to verify user authorizations to validate stack access", err) + } else if !access { + return httperror.Forbidden("Access denied to resource", httperrors.ErrResourceAccessDenied) + } + } + + if canManage, err := handler.userCanManageStacks(securityContext, endpoint); err != nil { + return httperror.InternalServerError("Unable to verify user authorizations to validate stack deletion", err) + } else if !canManage { + errMsg := "Stack management is disabled for non-admin users" + return httperror.Forbidden(errMsg, errors.New(errMsg)) + } + + var payload stackGitRedeployPayload + if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + payload.RepullImageAndRedeploy = payload.RepullImageAndRedeploy || payload.PullImage + + gitConfig.ReferenceName = cmp.Or(payload.RepositoryReferenceName, gitConfig.ReferenceName) + + if payload.Env != nil { + stack.Env = payload.Env + } + + if payload.Prune != nil { + if stack.Type == portainer.DockerSwarmStack || stack.Type == portainer.DockerComposeStack { + if stack.Option == nil { + stack.Option = &portainer.StackOption{} + } + stack.Option.Prune = *payload.Prune + } + } + + if stack.Type == portainer.KubernetesStack && payload.StackName != "" { + stack.Name = payload.StackName + } + + auth := resolveGitAuthFromRedeployPayload(gitConfig, payload) + + cloneOptions := git.CloneOptions{ + ProjectPath: stack.ProjectPath, + URL: gitConfig.URL, + ReferenceName: gitConfig.ReferenceName, + Username: auth.Username, + Password: auth.Password, + TLSSkipVerify: gitConfig.TLSSkipVerify, + } + + clean, err := git.CloneWithBackup(context.TODO(), handler.GitService, handler.FileService, cloneOptions) + if err != nil { + if persistErr := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + return persistSourceSyncError(tx, securityContext, sourceID, err) + }); persistErr != nil { + return httperror.InternalServerError("Unable to clone git repository directory", fmt.Errorf("%w (and failed to persist status: %w)", err, persistErr)) + } + + return httperror.InternalServerError("Unable to clone git repository directory", err) + } + + defer clean() + + newHash, err := handler.GitService.LatestCommitID(context.TODO(), gitConfig.URL, gitConfig.ReferenceName, auth.Username, auth.Password, gitConfig.TLSSkipVerify) + if err != nil { + if persistErr := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + return persistSourceSyncError(tx, securityContext, sourceID, err) + }); persistErr != nil { + return httperror.InternalServerError("Unable get latest commit id", fmt.Errorf("%w (and failed to persist status: %w)", errors.WithMessagef(err, "failed to fetch latest commit id of the stack %v", stack.ID), persistErr)) + } + + return httperror.InternalServerError("Unable get latest commit id", errors.WithMessagef(err, "failed to fetch latest commit id of the stack %v", stack.ID)) + } + + oldConfigHash := gitConfig.ConfigHash + gitConfig.ConfigHash = newHash + + user, err := handler.DataStore.User().Read(securityContext.UserID) + if err != nil { + return httperror.BadRequest("Cannot find context user", errors.Wrap(err, "failed to fetch the user")) + } + stack.CurrentDeploymentInfo = &portainer.StackDeploymentInfo{ + RepositoryURL: gitConfig.URL, + ReferenceName: gitConfig.ReferenceName, + ConfigFilePath: gitConfig.ConfigFilePath, + AdditionalFiles: stack.AdditionalFiles, + ConfigHash: newHash, + SourceID: sourceID, + } + + stack.UpdatedBy = user.Username + stack.UpdateDate = time.Now().Unix() + stackutils.PrepareStackStatusForDeployment(stack) + + postDeploy := func(ctx context.Context, deployErr error) { + if deployErr == nil { + return + } + + if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + liveStack, err := tx.Stack().Read(stack.ID) + if err != nil { + return err + } + + if liveStack.CurrentDeploymentInfo != nil { + liveStack.CurrentDeploymentInfo.ConfigHash = oldConfigHash + } + + if err := tx.Stack().Update(liveStack.ID, liveStack); err != nil { + return err + } + + return workflows.UpdateArtifactFileForStack(tx, stack.WorkflowID, stack.ID, sourceID, func(a *portainer.ArtifactFile) { + a.Hash = oldConfigHash + }) + }); err != nil { + log.Error().Err(err).Int("stack_id", int(stack.ID)).Msg("failed to revert config hash after failed redeploy") + } + } + + deployGate := newDeployGate() + if err := handler.deployStack(r, stack, payload.RepullImageAndRedeploy, endpoint, deployGate, postDeploy); err != nil { + return err + } + + if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + if err := tx.Stack().Update(stack.ID, stack); err != nil { + return err + } + userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships) + if err := saveStackGitConfig(tx, userContext, stack.WorkflowID, stack.ID, sourceID, 0, gitConfig); err != nil { + return err + } + + if err := workflows.SaveSourceStatus(tx, userContext, sourceID, nil); err != nil { + return err + } + + return fillStackGitConfig(tx, userContext, stack) + }); err != nil { + deployGate.abortDeploy() + + return httperror.InternalServerError("Unable to persist the stack changes inside the database", errors.Wrap(err, "failed to update the stack")) + } + + deployGate.startDeploy() + + return response.JSON(w, stack) +} + +func resolveGitAuthFromRedeployPayload(gitConfig *gittypes.RepoConfig, payload stackGitRedeployPayload) gittypes.GitAuthentication { + auth := gittypes.GitAuthentication{} + if gitConfig.Authentication != nil { + auth = *gitConfig.Authentication + } + + // only overload source auth if provided + if payload.RepositoryAuthentication && payload.RepositoryPassword != "" { + auth.Username = payload.RepositoryUsername + auth.Password = payload.RepositoryPassword + } + return auth +} + +func (handler *Handler) deployStack(r *http.Request, stack *portainer.Stack, pullImage bool, endpoint *portainer.Endpoint, gate *deployGate, postDeploy postDeployFunc) *httperror.HandlerError { + var deploymentConfiger deployments.StackDeploymentConfiger + + switch stack.Type { + case portainer.DockerSwarmStack: + // Create swarm deployment config + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) + } + + prune := stack.Option != nil && stack.Option.Prune + + deploymentConfiger, err = deployments.CreateSwarmStackDeploymentConfigTx(handler.DataStore, securityContext, stack, endpoint, handler.FileService, handler.StackDeployer, prune, pullImage) + if err != nil { + return httperror.InternalServerError(err.Error(), err) + } + + case portainer.DockerComposeStack: + // Create compose deployment config + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) + } + + prune := stack.Option != nil && stack.Option.Prune + + deploymentConfiger, err = deployments.CreateComposeStackDeploymentConfigTx(handler.DataStore, securityContext, stack, endpoint, handler.FileService, handler.StackDeployer, prune, pullImage, true) + if err != nil { + return httperror.InternalServerError(err.Error(), err) + } + + case portainer.KubernetesStack: + handler.stackCreationMutex.Lock() + defer handler.stackCreationMutex.Unlock() + + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + return httperror.BadRequest("Failed to retrieve user token data", err) + } + + user := &portainer.User{ + ID: tokenData.ID, + Username: tokenData.Username, + } + + appLabel := k.KubeAppLabels{ + StackID: int(stack.ID), + StackName: stack.Name, + Owner: tokenData.Username, + Kind: "git", + } + + deploymentConfiger = deployments.CreateKubernetesStackDeploymentConfig(stack, handler.KubernetesDeployer, appLabel, user, endpoint) + + default: + return httperror.InternalServerError("Unsupported stack", errors.Errorf("unsupported stack type: %v", stack.Type)) + } + + go stackDeploy(handler.DataStore, stack.ID, deploymentConfiger, gate, postDeploy) + + return nil +} diff --git a/api/http/handler/stacks/stack_update_git_redeploy_test.go b/api/http/handler/stacks/stack_update_git_redeploy_test.go new file mode 100644 index 0000000..1bcf8a0 --- /dev/null +++ b/api/http/handler/stacks/stack_update_git_redeploy_test.go @@ -0,0 +1,82 @@ +package stacks + +import ( + "testing" + + gittypes "github.com/portainer/portainer/api/git/types" + "github.com/stretchr/testify/require" +) + +func TestResolveGitAuthFromRedeployPayload(t *testing.T) { + t.Parallel() + + existing := &gittypes.GitAuthentication{ + Username: "existing-user", + Password: "existing-pass", + } + + tests := []struct { + name string + auth *gittypes.GitAuthentication + payload stackGitRedeployPayload + want gittypes.GitAuthentication + }{ + { + name: "no existing auth, flag off, no creds", + auth: nil, + payload: stackGitRedeployPayload{RepositoryAuthentication: false}, + want: gittypes.GitAuthentication{}, + }, + { + name: "no existing auth, flag off, creds provided", + auth: nil, + payload: stackGitRedeployPayload{RepositoryAuthentication: false, RepositoryPassword: "pass"}, + want: gittypes.GitAuthentication{}, + }, + { + name: "no existing auth, flag on, empty password", + auth: nil, + payload: stackGitRedeployPayload{RepositoryAuthentication: true, RepositoryUsername: "user"}, + want: gittypes.GitAuthentication{}, + }, + { + name: "no existing auth, flag on, password set", + auth: nil, + payload: stackGitRedeployPayload{RepositoryAuthentication: true, RepositoryUsername: "user", RepositoryPassword: "pass"}, + want: gittypes.GitAuthentication{Username: "user", Password: "pass"}, + }, + { + name: "no existing auth, flag on, password set but no username", + auth: nil, + payload: stackGitRedeployPayload{RepositoryAuthentication: true, RepositoryPassword: "pass"}, + want: gittypes.GitAuthentication{Username: "", Password: "pass"}, + }, + { + name: "existing auth, flag off", + auth: existing, + payload: stackGitRedeployPayload{RepositoryAuthentication: false}, + want: *existing, + }, + { + name: "existing auth, flag on, empty password", + auth: existing, + payload: stackGitRedeployPayload{RepositoryAuthentication: true, RepositoryUsername: "new-user"}, + want: *existing, + }, + { + name: "existing auth, flag on, password set", + auth: existing, + payload: stackGitRedeployPayload{RepositoryAuthentication: true, RepositoryUsername: "new-user", RepositoryPassword: "new-pass"}, + want: gittypes.GitAuthentication{Username: "new-user", Password: "new-pass"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + cfg := &gittypes.RepoConfig{Authentication: tc.auth} + got := resolveGitAuthFromRedeployPayload(cfg, tc.payload) + require.Equal(t, tc.want, got) + }) + } +} diff --git a/api/http/handler/stacks/stack_update_git_test.go b/api/http/handler/stacks/stack_update_git_test.go new file mode 100644 index 0000000..e5c4a2e --- /dev/null +++ b/api/http/handler/stacks/stack_update_git_test.go @@ -0,0 +1,107 @@ +package stacks + +import ( + "bytes" + "net/http" + "net/http/httptest" + "strconv" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices/source" + "github.com/portainer/portainer/api/datastore" + gittypes "github.com/portainer/portainer/api/git/types" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/testhelpers" + + "github.com/google/uuid" + "github.com/segmentio/encoding/json" + "github.com/stretchr/testify/require" +) + +func TestStackUpdateGitWebhookUniqueness(t *testing.T) { + t.Parallel() + webhook, err := uuid.NewRandom() + require.NoError(t, err) + + _, store := datastore.MustNewTestStore(t, false, false) + + endpoint := &portainer.Endpoint{ + ID: 123, + Name: "endpoint1", + Type: portainer.DockerEnvironment, + } + err = store.Endpoint().Create(endpoint) + require.NoError(t, err) + + const stack1ID = portainer.StackID(456) + const stack2ID = portainer.StackID(457) + + sharedSrc := &portainer.Source{ + Type: portainer.SourceTypeGit, + Git: &gittypes.GitSource{URL: "https://github.com/portainer/portainer.git"}, + } + err = store.Source().Create(source.InsecureNewAdminContext(), sharedSrc) + require.NoError(t, err) + + wf1 := &portainer.Workflow{Artifacts: []portainer.Artifact{{ + StackID: stack1ID, + Files: []portainer.ArtifactFile{{SourceID: sharedSrc.ID}}, + }}} + err = store.Workflow().Create(wf1) + require.NoError(t, err) + + wf2 := &portainer.Workflow{Artifacts: []portainer.Artifact{{ + StackID: stack2ID, + Files: []portainer.ArtifactFile{{SourceID: sharedSrc.ID}}, + }}} + err = store.Workflow().Create(wf2) + require.NoError(t, err) + + stack1 := portainer.Stack{ + ID: stack1ID, + EndpointID: endpoint.ID, + WorkflowID: wf1.ID, + AutoUpdate: &portainer.AutoUpdateSettings{ + Webhook: webhook.String(), + }, + } + + err = store.Stack().Create(&stack1) + require.NoError(t, err) + + stack2 := stack1 + stack2.ID = stack2ID + stack2.AutoUpdate = nil + stack2.WorkflowID = wf2.ID + + err = store.Stack().Create(&stack2) + require.NoError(t, err) + + handler := NewHandler(testhelpers.NewTestRequestBouncer()) + handler.DataStore = store + + payload := &stackGitUpdatePayload{ + AutoUpdate: &portainer.AutoUpdateSettings{ + Webhook: webhook.String(), + }, + } + + jsonPayload, err := json.Marshal(payload) + require.NoError(t, err) + + url := "/stacks/" + strconv.Itoa(int(stack2.ID)) + "/git?endpointId=" + strconv.Itoa(int(endpoint.ID)) + req := httptest.NewRequest(http.MethodPost, url, bytes.NewReader(jsonPayload)) + + rrc := &security.RestrictedRequestContext{ + IsAdmin: true, + UserID: 1, + User: &portainer.User{ID: 1, Role: portainer.AdministratorRole}, + } + req = req.WithContext(security.StoreRestrictedRequestContext(req, rrc)) + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + require.Equal(t, http.StatusConflict, rr.Code) +} diff --git a/api/http/handler/stacks/stack_update_test.go b/api/http/handler/stacks/stack_update_test.go new file mode 100644 index 0000000..15e8f04 --- /dev/null +++ b/api/http/handler/stacks/stack_update_test.go @@ -0,0 +1,483 @@ +package stacks + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strconv" + "testing" + "time" + + "github.com/pkg/errors" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/filesystem" + "github.com/portainer/portainer/api/internal/testhelpers" + "github.com/portainer/portainer/api/stacks/stackutils" + "github.com/portainer/portainer/pkg/fips" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_updateStackInTx(t *testing.T) { + t.Parallel() + t.Run("Transaction commits successfully - changes are persisted", func(t *testing.T) { + payload := &updateComposeStackPayload{ + StackFileContent: "version: '3'\nservices:\n web:\n image: nginx:latest", + Env: []portainer.Pair{{Name: "FOO", Value: "BAR"}}, + } + stack := &portainer.Stack{ + ID: 1, + Name: "test-stack-1", + EntryPoint: "docker-compose.yml", + Type: portainer.DockerComposeStack, + } + setup := setupUpdateStackInTxTest(t, stack, payload) + + // Execute updateStackInTx within a successful transaction + err := setup.store.UpdateTx(func(tx dataservices.DataStoreTx) error { + _, handlerErr := setup.handler.updateStackInTx(tx, setup.req, setup.stack.ID, setup.endpoint.ID) + if handlerErr != nil { + return handlerErr + } + return nil + }) + require.NoError(t, err, "transction should succeed") + + // Verify the stack was updated in the database (transaction committed) + stackAfterCommit, err := setup.store.Stack().Read(setup.stack.ID) + require.NoError(t, err, "should be able to read stack after commit") + require.NotNil(t, stackAfterCommit) + require.Equal(t, "BAR", stackAfterCommit.Env[0].Value, "stack env variable should be updated") + }) + + t.Run("Transaction rollback on error - changes not persisted", func(t *testing.T) { + payload := &updateComposeStackPayload{ + StackFileContent: "version: '3'\nservices:\n web:\n image: nginx:latest", + Env: []portainer.Pair{{Name: "FOO", Value: "BAR"}}, + } + stack := &portainer.Stack{ + ID: 1, + Name: "test-stack-1", + EntryPoint: "docker-compose.yml", + Type: portainer.DockerComposeStack, + } + setup := setupUpdateStackInTxTest(t, stack, payload) + + // Execute updateStackInTx within a transaction that we force to fail + err := setup.store.UpdateTx(func(tx dataservices.DataStoreTx) error { + updatedStack, handlerErr := setup.handler.updateStackInTx(tx, setup.req, setup.stack.ID, setup.endpoint.ID) + if handlerErr != nil { + return handlerErr + } + + // Verify changes are visible within the transaction + assert.NotNil(t, updatedStack) + assert.Equal(t, setup.user.Username, updatedStack.UpdatedBy) + assert.NotZero(t, updatedStack.UpdateDate) + + // Force the transaction to fail by returning an error + return errors.New("forced transaction failure") + }) + + // Verify the transaction failed + require.Error(t, err) + assert.Contains(t, err.Error(), "forced transaction failure") + + // Verify the stack was NOT updated in the database (transaction rolled back) + stackAfterRollback, err := setup.store.Stack().Read(setup.stack.ID) + require.NoError(t, err) + require.Zero(t, stackAfterRollback.Env, "stack env variable should remain unchanged after rollback") + }) + + t.Run("Error: Stack not found returns NotFound httperror", func(t *testing.T) { + payload := &updateComposeStackPayload{ + StackFileContent: "version: '3'\nservices:\n web:\n image: nginx:latest", + } + stack := &portainer.Stack{ + ID: 1, + Name: "test-stack-1", + EntryPoint: "docker-compose.yml", + Type: portainer.DockerComposeStack, + } + setup := setupUpdateStackInTxTest(t, stack, payload) + setup.req.URL.Path = "/stacks/9999" // Non-existent stack ID + + var handlerErr *httperror.HandlerError + _ = setup.store.UpdateTx(func(tx dataservices.DataStoreTx) error { + _, handlerErr = setup.handler.updateStackInTx(tx, setup.req, 9999, setup.endpoint.ID) + return handlerErr + }) + + require.NotNil(t, handlerErr, "handler error should be set") + assert.Equal(t, http.StatusNotFound, handlerErr.StatusCode, "should return 404 NotFound") + assert.Contains(t, handlerErr.Message, "Unable to find a stack", "error message should mention stack") + }) + + t.Run("Error: Endpoint not found returns NotFound httperror", func(t *testing.T) { + payload := &updateComposeStackPayload{ + StackFileContent: "version: '3'\nservices:\n web:\n image: nginx:latest", + } + stack := &portainer.Stack{ + ID: 1, + Name: "test-stack-1", + EntryPoint: "docker-compose.yml", + Type: portainer.DockerComposeStack, + } + setup := setupUpdateStackInTxTest(t, stack, payload) + + var handlerErr *httperror.HandlerError + _ = setup.store.UpdateTx(func(tx dataservices.DataStoreTx) error { + _, handlerErr = setup.handler.updateStackInTx(tx, setup.req, stack.ID, 2999) // Non-existent endpoint ID + return nil + }) + + require.NotNil(t, handlerErr, "handler error should be set") + assert.Equal(t, http.StatusNotFound, handlerErr.StatusCode, "should return 404 NotFound") + assert.Contains(t, handlerErr.Message, "Unable to find the environment", "error message should mention environment") + }) + + t.Run("Error: user cannot access the stack", func(t *testing.T) { + payload := &updateComposeStackPayload{ + StackFileContent: "version: '3'\nservices:\n web:\n image: nginx:latest", + } + stack := &portainer.Stack{ + ID: 1, + Name: "test-stack-1", + EntryPoint: "docker-compose.yml", + Type: portainer.DockerComposeStack, + } + setup := setupUpdateStackInTxTest(t, stack, payload) + originalUser, err := setup.store.User().Read(setup.user.ID) + require.NoError(t, err, "error reading user") + + // Modify the user's role to restrict access + originalUser.Role = portainer.StandardUserRole + err = setup.store.User().Update(originalUser.ID, originalUser) + require.NoError(t, err, "error updating user role") + + var handlerErr *httperror.HandlerError + _ = setup.store.UpdateTx(func(tx dataservices.DataStoreTx) error { + _, handlerErr = setup.handler.updateStackInTx(tx, setup.req, stack.ID, stack.EndpointID) + return nil + }) + + require.NotNil(t, handlerErr, "handler error should be set") + assert.Equal(t, http.StatusForbidden, handlerErr.StatusCode, "should return 403 Forbidden") + assert.Contains(t, handlerErr.Message, "Access denied", "error message should mention access") + }) + + t.Run("Error: user not found", func(t *testing.T) { + payload := &updateComposeStackPayload{ + StackFileContent: "version: '3'\nservices:\n web:\n image: nginx:latest", + } + stack := &portainer.Stack{ + ID: 1, + Name: "test-stack-1", + EntryPoint: "docker-compose.yml", + Type: portainer.DockerComposeStack, + } + setup := setupUpdateStackInTxTest(t, stack, payload) + err := setup.store.User().Delete(setup.user.ID) // Delete the user to simulate "user not found" + require.NoError(t, err, "error deleting user") + + var handlerErr *httperror.HandlerError + _ = setup.store.UpdateTx(func(tx dataservices.DataStoreTx) error { + _, handlerErr = setup.handler.updateStackInTx(tx, setup.req, stack.ID, stack.EndpointID) + return nil + }) + + require.NotNil(t, handlerErr, "handler error should be set") + assert.Equal(t, http.StatusInternalServerError, handlerErr.StatusCode, "should return 500 Internal Server Error") + assert.Contains(t, handlerErr.Message, "Unable to verify user authorizations to validate stack access", "error message should mention user authorizations") + }) +} + +func TestStackUpdate(t *testing.T) { + t.Parallel() + t.Helper() + _, store := datastore.MustNewTestStore(t, false, true) + + testDataPath := filesystem.JoinPaths(t.TempDir()) + fileService, err := filesystem.NewService(testDataPath, "") + require.NoError(t, err, "error init file service") + + // Create test user + _, err = mockCreateUser(store) + require.NoError(t, err, "error creating user") + + // Create test endpoint + endpoint, err := mockCreateEndpoint(store) + require.NoError(t, err, "error creating endpoint") + + // Create test stack + stack := &portainer.Stack{ + ID: 1, + Name: "test-stack-1", + EntryPoint: "docker-compose.yml", + EndpointID: endpoint.ID, + ProjectPath: fileService.GetDatastorePath() + fmt.Sprintf("/compose/%d", 1), + Type: portainer.DockerSwarmStack, + } + + err = store.Stack().Create(stack) + require.NoError(t, err, "error creating stack") + + // Create resource control for the stack + resourceControl := &portainer.ResourceControl{ + ID: portainer.ResourceControlID(stack.ID), + ResourceID: stackutils.ResourceControlID(stack.EndpointID, stack.Name), + Type: portainer.StackResourceControl, + AdministratorsOnly: false, + } + err = store.ResourceControl().Create(resourceControl) + require.NoError(t, err, "error creating resource control") + + // Store initial stack file + _, err = fileService.StoreStackFileFromBytes( + strconv.Itoa(int(stack.ID)), + stack.EntryPoint, + []byte("version: '3'\nservices:\n web:\n image: nginx:v1"), + ) + require.NoError(t, err, "error storing stack file") + + // Create handler + handler := NewHandler(testhelpers.NewTestRequestBouncer()) + handler.DataStore = store + handler.FileService = fileService + handler.StackDeployer = testhelpers.NewTestStackDeployer() + handler.ComposeStackManager = testhelpers.NewComposeStackManager() + handler.SwarmStackManager = swarmStackManager{} + + payload := &updateComposeStackPayload{ + StackFileContent: "version: '3'\nservices:\n web:\n image: nginx:latest", + } + // Create mock request with security context + jsonPayload, err := json.Marshal(payload) + require.NoError(t, err) + + t.Run("Endpoint is not provided in query param nor header", func(t *testing.T) { + req := mockCreateStackRequestWithSecurityContext( + http.MethodPut, + fmt.Sprintf("/stacks/%d", stack.ID), + bytes.NewBuffer(jsonPayload), + ) + + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + require.Equal(t, http.StatusBadRequest, rec.Code, "expected status BadRequest when endpoint is not provided") + }) + + t.Run("Stack doesn't exist", func(t *testing.T) { + req := mockCreateStackRequestWithSecurityContext( + http.MethodPut, + fmt.Sprintf("/stacks/test-stack-1?endpointId=%d", endpoint.ID), + bytes.NewBuffer(jsonPayload), + ) + + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + require.Equal(t, http.StatusBadRequest, rec.Code, "expected status NotFound when stack doesn't exist") + }) + + t.Run("Update stack successfully", func(t *testing.T) { + fips.InitFIPS(false) + + req := mockCreateStackRequestWithSecurityContext( + http.MethodPut, + fmt.Sprintf("/stacks/%d?endpointId=%d", stack.ID, endpoint.ID), + bytes.NewBuffer(jsonPayload), + ) + + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code, "expected status OK when stack is updated successfully") + var stackResponse portainer.Stack + err = json.NewDecoder(rec.Body).Decode(&stackResponse) + require.NoError(t, err, "error decoding response body") + require.NotZero(t, stackResponse.UpdateDate, "stack update date should be set") + }) +} + +// setupUpdateStackInTxTest creates a fresh test environment for each subtest +type updateStackInTxTestSetup struct { + store *datastore.Store + fileService portainer.FileService + handler *Handler + user *portainer.User + endpoint *portainer.Endpoint + stack *portainer.Stack + resourceControl *portainer.ResourceControl + jsonPayload []byte + req *http.Request +} + +type testUpdateStackPayload interface { + *updateComposeStackPayload | *updateSwarmStackPayload +} + +func setupUpdateStackInTxTest[T testUpdateStackPayload](t *testing.T, stack *portainer.Stack, payload T) *updateStackInTxTestSetup { + t.Helper() + + _, store := datastore.MustNewTestStore(t, false, true) + + testDataPath := filesystem.JoinPaths(t.TempDir()) + fileService, err := filesystem.NewService(testDataPath, "") + require.NoError(t, err, "error init file service") + + // Create test user + user, err := mockCreateUser(store) + require.NoError(t, err, "error creating user") + + // Create test endpoint + endpoint, err := mockCreateEndpoint(store) + require.NoError(t, err, "error creating endpoint") + + // Create test stack + stack.EndpointID = endpoint.ID + stack.ProjectPath = fileService.GetDatastorePath() + fmt.Sprintf("/compose/%d", stack.ID) + + err = store.Stack().Create(stack) + require.NoError(t, err, "error creating stack") + + // Create resource control for the stack + resourceControl := &portainer.ResourceControl{ + ID: portainer.ResourceControlID(stack.ID), + ResourceID: stackutils.ResourceControlID(stack.EndpointID, stack.Name), + Type: portainer.StackResourceControl, + AdministratorsOnly: false, + } + err = store.ResourceControl().Create(resourceControl) + require.NoError(t, err, "error creating resource control") + + // Store initial stack file + _, err = fileService.StoreStackFileFromBytes( + strconv.Itoa(int(stack.ID)), + stack.EntryPoint, + []byte("version: '3'\nservices:\n web:\n image: nginx:v1"), + ) + require.NoError(t, err, "error storing stack file") + + // Create handler + handler := NewHandler(testhelpers.NewTestRequestBouncer()) + handler.DataStore = store + handler.FileService = fileService + handler.StackDeployer = testhelpers.NewTestStackDeployer() + handler.ComposeStackManager = testhelpers.NewComposeStackManager() + + // Create mock request with security context + jsonPayload, err := json.Marshal(payload) + require.NoError(t, err) + + req := mockCreateStackRequestWithSecurityContext( + http.MethodPut, + fmt.Sprintf("/stacks/%d?endpointId=%d", stack.ID, endpoint.ID), + bytes.NewBuffer(jsonPayload), + ) + + return &updateStackInTxTestSetup{ + store: store, + fileService: fileService, + handler: handler, + user: user, + endpoint: endpoint, + stack: stack, + resourceControl: resourceControl, + jsonPayload: jsonPayload, + req: req, + } +} + +type swarmStackManager struct { + portainer.SwarmStackManager +} + +func (manager swarmStackManager) NormalizeStackName(name string) string { + return name +} + +func Test_updateSwarmStack_Prune(t *testing.T) { + t.Parallel() + fips.InitFIPS(false) + + payload := &updateSwarmStackPayload{ + StackFileContent: "version: '3'\nservices:\n web:\n image: nginx:latest", + Prune: true, + } + stack := &portainer.Stack{ + ID: 1, + Name: "test-stack-prune", + EntryPoint: "docker-compose.yml", + Type: portainer.DockerSwarmStack, + } + setup := setupUpdateStackInTxTest(t, stack, payload) + setup.handler.SwarmStackManager = swarmStackManager{} + deployer := testhelpers.NewTestStackDeployer() + setup.handler.StackDeployer = deployer + + err := setup.store.UpdateTx(func(tx dataservices.DataStoreTx) error { + _, handlerErr := setup.handler.updateStackInTx(tx, setup.req, setup.stack.ID, setup.endpoint.ID) + if handlerErr != nil { + return handlerErr + } + return nil + }) + require.NoError(t, err, "handler should accept Prune=true and succeed") + + stored, err := setup.store.Stack().Read(setup.stack.ID) + require.NoError(t, err) + require.NotNil(t, stored.Option, "stack.Option should not be nil") + assert.True(t, stored.Option.Prune, "stack.Option.Prune should be persisted as true") + + // Deploy runs asynchronously; wait for the goroutine to call the deployer + require.Eventually(t, func() bool { + return deployer.DeploySwarmCallCount == 1 + }, 5*time.Second, 10*time.Millisecond, "DeploySwarmStack should be called exactly once") + assert.True(t, deployer.LastPrune, "deployer should be invoked with prune=true") +} + +func Test_updateComposeStack_Prune(t *testing.T) { + t.Parallel() + fips.InitFIPS(false) + + payload := &updateComposeStackPayload{ + StackFileContent: "version: '3'\nservices:\n web:\n image: nginx:latest", + Prune: true, + } + stack := &portainer.Stack{ + ID: 1, + Name: "test-stack-prune", + EntryPoint: "docker-compose.yml", + Type: portainer.DockerComposeStack, + } + setup := setupUpdateStackInTxTest(t, stack, payload) + deployer := testhelpers.NewTestStackDeployer() + setup.handler.StackDeployer = deployer + + err := setup.store.UpdateTx(func(tx dataservices.DataStoreTx) error { + _, handlerErr := setup.handler.updateStackInTx(tx, setup.req, setup.stack.ID, setup.endpoint.ID) + if handlerErr != nil { + return handlerErr + } + return nil + }) + require.NoError(t, err, "handler should accept Prune=true and succeed") + + stored, err := setup.store.Stack().Read(setup.stack.ID) + require.NoError(t, err) + require.NotNil(t, stored.Option, "stack.Option should not be nil") + assert.True(t, stored.Option.Prune, "stack.Option.Prune should be persisted as true") + + // Deploy runs asynchronously; wait for the goroutine to call the deployer + require.Eventually(t, func() bool { + return deployer.DeployComposeCallCount == 1 + }, 5*time.Second, 10*time.Millisecond, "DeployComposeStack should be called exactly once") + assert.True(t, deployer.LastPrune, "deployer should be invoked with prune=true") +} diff --git a/api/http/handler/stacks/update_kubernetes_stack.go b/api/http/handler/stacks/update_kubernetes_stack.go new file mode 100644 index 0000000..6270eaf --- /dev/null +++ b/api/http/handler/stacks/update_kubernetes_stack.go @@ -0,0 +1,206 @@ +package stacks + +import ( + "context" + "net/http" + "os" + "strconv" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/dataservices/source" + "github.com/portainer/portainer/api/filesystem" + gittypes "github.com/portainer/portainer/api/git/types" + "github.com/portainer/portainer/api/git/update" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/registryutils" + k "github.com/portainer/portainer/api/kubernetes" + "github.com/portainer/portainer/api/stacks/deployments" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + + "github.com/pkg/errors" + "github.com/rs/zerolog/log" +) + +type kubernetesFileStackUpdatePayload struct { + StackFileContent string + // Name of the stack + StackName string +} + +type kubernetesGitStackUpdatePayload struct { + RepositoryReferenceName string + RepositoryAuthentication bool + RepositoryUsername string + RepositoryPassword string + AutoUpdate *portainer.AutoUpdateSettings + TLSSkipVerify bool +} + +func (payload *kubernetesFileStackUpdatePayload) Validate(r *http.Request) error { + if len(payload.StackFileContent) == 0 { + return errors.New("Invalid stack file content") + } + + return nil +} + +func (payload *kubernetesGitStackUpdatePayload) Validate(r *http.Request) error { + if err := update.ValidateAutoUpdateSettings(payload.AutoUpdate); err != nil { + return err + } + + return nil +} + +func (handler *Handler) updateKubernetesStack(tx dataservices.DataStoreTx, r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint, gate *deployGate) *httperror.HandlerError { + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) + } + + userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships) + if stack.WorkflowID != 0 { + gitConfig, sourceID, err := loadGitConfigForStack(tx, userContext, stack.WorkflowID, stack.ID) + if err != nil { + return httperror.InternalServerError("Unable to load git config for stack", err) + } + if gitConfig == nil { + return httperror.InternalServerError("Stack has no git config in source", errors.New("source has no git config")) + } + + // Stop the autoupdate job if there is any + if stack.AutoUpdate != nil { + deployments.StopAutoupdate(stack.ID, stack.AutoUpdate.JobID, handler.Scheduler) + } + + var payload kubernetesGitStackUpdatePayload + + if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + gitConfig.ReferenceName = payload.RepositoryReferenceName + gitConfig.TLSSkipVerify = payload.TLSSkipVerify + stack.AutoUpdate = payload.AutoUpdate + + if payload.RepositoryAuthentication { + password := payload.RepositoryPassword + if password == "" && gitConfig.Authentication != nil { + password = gitConfig.Authentication.Password + } + + gitConfig.Authentication = &gittypes.GitAuthentication{ + Username: payload.RepositoryUsername, + Password: password, + } + + if _, err := handler.GitService.LatestCommitID( + context.TODO(), + gitConfig.URL, + gitConfig.ReferenceName, + gitConfig.Authentication.Username, + gitConfig.Authentication.Password, + gitConfig.TLSSkipVerify, + ); err != nil { + return httperror.InternalServerError("Unable to fetch git repository", err) + } + } else { + gitConfig.Authentication = nil + } + + if payload.AutoUpdate != nil && payload.AutoUpdate.Interval != "" { + jobID, e := deployments.StartAutoupdate(context.TODO(), stack.ID, stack.AutoUpdate.Interval, handler.Scheduler, handler.StackDeployer, handler.DataStore, handler.GitService) + if e != nil { + return e + } + stack.AutoUpdate.JobID = jobID + } + + if err := saveStackGitConfig(tx, userContext, stack.WorkflowID, stack.ID, sourceID, 0, gitConfig); err != nil { + return httperror.InternalServerError("Unable to update source git config", err) + } + + return nil + } + + var payload kubernetesFileStackUpdatePayload + + if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + return httperror.BadRequest("Failed to retrieve user token data", err) + } + + tempFileDir, _ := os.MkdirTemp("", "kub_file_content") + + if err := filesystem.WriteToFile(filesystem.JoinPaths(tempFileDir, stack.EntryPoint), []byte(payload.StackFileContent)); err != nil { + return httperror.InternalServerError("Failed to persist deployment file in a temp directory", err) + } + + if payload.StackName != stack.Name { + stack.Name = payload.StackName + if err := handler.DataStore.Stack().Update(stack.ID, stack); err != nil { + return httperror.InternalServerError("Failed to update stack name", err) + } + } + + // Refresh ECR registry secret if needed + // RefreshEcrSecret method checks if the namespace has any ECR registry + // otherwise return nil + cli, err := handler.KubernetesClientFactory.GetPrivilegedKubeClient(endpoint) + if err == nil { + if err := registryutils.RefreshEcrSecret(cli, endpoint, handler.DataStore, stack.Namespace); err != nil { + log.Warn().Err(err).Msg("failed to refresh ECR registry secret") + } + } + + // Use temp dir as the stack project path for deployment + // so if the deployment failed, the original file won't be over-written + stack.ProjectPath = tempFileDir + + appLabels := k.KubeAppLabels{ + StackID: int(stack.ID), + StackName: stack.Name, + Owner: stack.CreatedBy, + Kind: "content", + } + + copyStack := *stack + user := &portainer.User{ID: tokenData.ID} + k8sDeploymentConfig := deployments.CreateKubernetesStackDeploymentConfig(©Stack, handler.KubernetesDeployer, appLabels, user, endpoint) + + stackFolder := strconv.Itoa(int(stack.ID)) + projectPath, err := handler.FileService.UpdateStoreStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent)) + if err != nil { + if rollbackErr := handler.FileService.RollbackStackFile(stackFolder, stack.EntryPoint); rollbackErr != nil { + log.Warn().Err(rollbackErr).Msg("rollback stack file error") + } + + return httperror.InternalServerError("Unable to persist Kubernetes Manifest file on disk", err) + } + stack.ProjectPath = projectPath + + postDeploy := func(ctx context.Context, deployErr error) { + defer func() { + if err := os.RemoveAll(tempFileDir); err != nil { + log.Warn().Err(err).Msg("failed to remove temporary stack deployment directory") + } + }() + + if deployErr == nil { + if err := handler.FileService.RemoveStackFileBackup(stackFolder, stack.EntryPoint); err != nil { + log.Warn().Err(err).Msg("remove stack file backup error") + } + } + } + + go stackDeploy(handler.DataStore, copyStack.ID, k8sDeploymentConfig, gate, postDeploy) + + return nil +} diff --git a/api/http/handler/stacks/webhook_invoke.go b/api/http/handler/stacks/webhook_invoke.go new file mode 100644 index 0000000..b3ec5d0 --- /dev/null +++ b/api/http/handler/stacks/webhook_invoke.go @@ -0,0 +1,71 @@ +package stacks + +import ( + "context" + "errors" + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/stacks/deployments" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" + + "github.com/google/uuid" +) + +// @id WebhookInvoke +// @summary Webhook for triggering stack updates from git +// @description **Access policy**: public +// @tags stacks +// @param webhookID path string true "Stack identifier" +// @success 200 "Success" +// @failure 400 "Invalid request" +// @failure 409 "Autoupdate for the stack isn't available" or "Stack deployment is already in progress" +// @failure 500 "Server error" +// @router /stacks/webhooks/{webhookID} [post] +func (handler *Handler) webhookInvoke(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + webhookID, err := retrieveUUIDRouteVariableValue(r, "webhookID") + if err != nil { + return httperror.BadRequest("Invalid webhook identifier route variable", err) + } + + stack, err := handler.DataStore.Stack().StackByWebhookID(webhookID.String()) + if err != nil { + statusCode := http.StatusInternalServerError + if handler.DataStore.IsErrObjectNotFound(err) { + statusCode = http.StatusNotFound + } + + return httperror.NewError(statusCode, "Unable to find the stack by webhook ID", err) + } + + if stack.Status == portainer.StackStatusDeploying { + return httperror.Conflict("Unable to update stack", errors.New("Stack deployment is already in progress")) + } + + if err = deployments.RedeployWhenChanged(context.TODO(), stack.ID, handler.StackDeployer, handler.DataStore, handler.GitService); err != nil { + var StackAuthorMissingErr *deployments.StackAuthorMissingErr + if errors.As(err, &StackAuthorMissingErr) { + return httperror.Conflict("Autoupdate for the stack isn't available", err) + } + + return httperror.InternalServerError("Failed to update the stack", err) + } + + return response.Empty(w) +} + +func retrieveUUIDRouteVariableValue(r *http.Request, name string) (uuid.UUID, error) { + webhookID, err := request.RetrieveRouteVariableValue(r, name) + if err != nil { + return uuid.Nil, err + } + + uid, err := uuid.Parse(webhookID) + if err != nil { + return uuid.Nil, err + } + + return uid, nil +} diff --git a/api/http/handler/stacks/webhook_invoke_test.go b/api/http/handler/stacks/webhook_invoke_test.go new file mode 100644 index 0000000..49a8eec --- /dev/null +++ b/api/http/handler/stacks/webhook_invoke_test.go @@ -0,0 +1,64 @@ +package stacks + +import ( + "net/http" + "net/http/httptest" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/internal/testhelpers" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHandler_webhookInvoke(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + webhookID := newGuidString(t) + err := store.StackService.Create(&portainer.Stack{ + ID: 1, + AutoUpdate: &portainer.AutoUpdateSettings{ + Webhook: webhookID, + }, + }) + require.NoError(t, err) + + h := NewHandler(testhelpers.NewTestRequestBouncer()) + h.DataStore = store + + t.Run("invalid uuid results in http.StatusBadRequest", func(t *testing.T) { + w := httptest.NewRecorder() + req := newRequest("notuuid") + h.ServeHTTP(w, req) + assert.Equal(t, http.StatusBadRequest, w.Code) + }) + + t.Run("registered webhook ID in http.StatusNoContent", func(t *testing.T) { + w := httptest.NewRecorder() + req := newRequest(webhookID) + h.ServeHTTP(w, req) + assert.Equal(t, http.StatusNoContent, w.Code) + }) + + t.Run("unregistered webhook ID in http.StatusNotFound", func(t *testing.T) { + w := httptest.NewRecorder() + req := newRequest(newGuidString(t)) + h.ServeHTTP(w, req) + assert.Equal(t, http.StatusNotFound, w.Code) + }) +} + +func newGuidString(t *testing.T) string { + uuid, err := uuid.NewRandom() + require.NoError(t, err) + + return uuid.String() +} + +func newRequest(webhookID string) *http.Request { + return httptest.NewRequest(http.MethodPost, "/stacks/webhooks/"+webhookID, nil) +} diff --git a/api/http/handler/storybook/handler.go b/api/http/handler/storybook/handler.go new file mode 100644 index 0000000..55bd28a --- /dev/null +++ b/api/http/handler/storybook/handler.go @@ -0,0 +1,23 @@ +package storybook + +import ( + "net/http" + "path" +) + +// Handler represents an HTTP API handler for managing static files. +type Handler struct { + http.Handler +} + +// NewHandler creates a handler to serve static files. +func NewHandler(assetsPath string) *Handler { + h := &Handler{ + http.FileServer(http.Dir(path.Join(assetsPath, "storybook"))), + } + return h +} + +func (handler *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + handler.Handler.ServeHTTP(w, r) +} diff --git a/api/http/handler/system/handler.go b/api/http/handler/system/handler.go new file mode 100644 index 0000000..4cab433 --- /dev/null +++ b/api/http/handler/system/handler.go @@ -0,0 +1,64 @@ +package system + +import ( + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/upgrade" + "github.com/portainer/portainer/api/platform" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + + "github.com/gorilla/mux" +) + +// Handler is the HTTP handler used to handle status operations. +type Handler struct { + *mux.Router + status *portainer.Status + dataStore dataservices.DataStore + upgradeService upgrade.Service + platformService platform.Service +} + +// NewHandler creates a handler to manage status operations. +func NewHandler(bouncer security.BouncerService, + status *portainer.Status, + dataStore dataservices.DataStore, + platformService platform.Service, + upgradeService upgrade.Service) *Handler { + + h := &Handler{ + Router: mux.NewRouter(), + dataStore: dataStore, + status: status, + upgradeService: upgradeService, + platformService: platformService, + } + + router := h.PathPrefix("/system").Subrouter() + + adminRouter := router.PathPrefix("/").Subrouter() + adminRouter.Use(bouncer.AdminAccess) + + adminRouter.Handle("/upgrade", httperror.LoggerHandler(h.systemUpgrade)).Methods(http.MethodPost) + + authenticatedRouter := router.PathPrefix("/").Subrouter() + authenticatedRouter.Use(bouncer.AuthenticatedAccess) + + authenticatedRouter.Handle("/version", httperror.LoggerHandler(h.version)).Methods(http.MethodGet) + authenticatedRouter.Handle("/nodes", httperror.LoggerHandler(h.systemNodesCount)).Methods(http.MethodGet) + authenticatedRouter.Handle("/info", httperror.LoggerHandler(h.systemInfo)).Methods(http.MethodGet) + + publicRouter := router.PathPrefix("/").Subrouter() + publicRouter.Use(bouncer.PublicAccess) + + publicRouter.Handle("/status", httperror.LoggerHandler(h.systemStatus)).Methods(http.MethodGet) + + // Deprecated /status endpoint, will be removed in the future. + h.Handle("/status", + bouncer.PublicAccess(httperror.LoggerHandler(h.statusInspectDeprecated))).Methods(http.MethodGet) + + return h +} diff --git a/api/http/handler/system/nodes_count.go b/api/http/handler/system/nodes_count.go new file mode 100644 index 0000000..7d150d9 --- /dev/null +++ b/api/http/handler/system/nodes_count.go @@ -0,0 +1,44 @@ +package system + +import ( + "net/http" + + portainer "github.com/portainer/portainer/api" + statusutil "github.com/portainer/portainer/api/internal/nodes" + "github.com/portainer/portainer/api/internal/snapshot" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +type nodesCountResponse struct { + Nodes int `json:"nodes"` +} + +// @id systemNodesCount +// @summary Retrieve the count of nodes +// @description **Access policy**: authenticated +// @security ApiKeyAuth +// @security jwt +// @tags system +// @produce json +// @success 200 {object} nodesCountResponse "Success" +// @failure 500 "Server error" +// @router /system/nodes [get] +func (handler *Handler) systemNodesCount(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpoints, err := handler.dataStore.Endpoint().Endpoints() + if err != nil { + return httperror.InternalServerError("Failed to get environment list", err) + } + + var nodes int + + for _, endpoint := range endpoints { + if err := snapshot.FillSnapshotData(handler.dataStore, &endpoint, false); err != nil { + return httperror.InternalServerError("Unable to add snapshot data", err) + } + + nodes += statusutil.NodesCount([]portainer.Endpoint{endpoint}) + } + + return response.JSON(w, &nodesCountResponse{Nodes: nodes}) +} diff --git a/api/http/handler/system/status.go b/api/http/handler/system/status.go new file mode 100644 index 0000000..ff42518 --- /dev/null +++ b/api/http/handler/system/status.go @@ -0,0 +1,46 @@ +package system + +import ( + "net/http" + + portainer "github.com/portainer/portainer/api" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/response" + + "github.com/rs/zerolog/log" +) + +type status struct { + *portainer.Status +} + +// @id systemStatus +// @summary Check Portainer status +// @description Retrieve Portainer status +// @description **Access policy**: public +// @tags system +// @produce json +// @success 200 {object} status "Success" +// @router /system/status [get] +func (handler *Handler) systemStatus(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + return response.JSON(w, &status{ + Status: handler.status, + }) +} + +// swagger docs for deprecated route: +// @id StatusInspect +// @summary Check Portainer status +// @deprecated +// @description Deprecated: use the `/system/status` endpoint instead. +// @description Retrieve Portainer status +// @description **Access policy**: public +// @tags status +// @produce json +// @success 200 {object} status "Success" +// @router /status [get] +func (handler *Handler) statusInspectDeprecated(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + log.Warn().Msg("The /status endpoint is deprecated and will be removed in a future version of Portainer. Please use the /system/status endpoint instead.") + + return handler.systemStatus(w, r) +} diff --git a/api/http/handler/system/system_info.go b/api/http/handler/system/system_info.go new file mode 100644 index 0000000..64a9153 --- /dev/null +++ b/api/http/handler/system/system_info.go @@ -0,0 +1,63 @@ +package system + +import ( + "net/http" + + "github.com/pkg/errors" + "github.com/portainer/portainer/api/internal/endpointutils" + plf "github.com/portainer/portainer/api/platform" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +type systemInfoResponse struct { + Platform plf.ContainerPlatform `json:"platform"` + EdgeAgents int `json:"edgeAgents"` + Agents int `json:"agents"` +} + +// @id systemInfo +// @summary Retrieve system info +// @description **Access policy**: authenticated +// @security ApiKeyAuth +// @security jwt +// @tags system +// @produce json +// @success 200 {object} systemInfoResponse "Success" +// @failure 500 "Server error" +// @router /system/info [get] +func (handler *Handler) systemInfo(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + environments, err := handler.dataStore.Endpoint().Endpoints() + if err != nil { + return httperror.InternalServerError("Failed to get environment list", err) + } + + agents := 0 + edgeAgents := 0 + + for _, environment := range environments { + if endpointutils.IsAgentEndpoint(&environment) { + agents++ + } + + if endpointutils.IsEdgeEndpoint(&environment) { + edgeAgents++ + } + } + + platform, err := handler.platformService.GetPlatform() + if err != nil { + if !errors.Is(err, plf.ErrNoLocalEnvironment) { + return httperror.InternalServerError("Failed to get platform", err) + } + // If no local environment is detected, we assume the platform is Docker + // UI will stop showing the upgrade banner + platform = plf.PlatformDocker + } + + return response.JSON(w, &systemInfoResponse{ + EdgeAgents: edgeAgents, + Agents: agents, + Platform: platform, + }) +} diff --git a/api/http/handler/system/system_upgrade.go b/api/http/handler/system/system_upgrade.go new file mode 100644 index 0000000..8eb4141 --- /dev/null +++ b/api/http/handler/system/system_upgrade.go @@ -0,0 +1,65 @@ +package system + +import ( + "net/http" + "regexp" + + ceplf "github.com/portainer/portainer/api/platform" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" + + "github.com/pkg/errors" +) + +type systemUpgradePayload struct { + License string +} + +var re = regexp.MustCompile(`^\d-.+`) + +func (payload *systemUpgradePayload) Validate(r *http.Request) error { + if payload.License == "" { + return errors.New("license is missing") + } + + if !re.MatchString(payload.License) { + return errors.New("license is invalid") + } + + return nil +} + +// @id systemUpgrade +// @summary Upgrade Portainer to BE +// @description Upgrade Portainer to BE +// @description **Access policy**: administrator +// @tags system +// @produce json +// @success 204 {object} status "Success" +// @router /system/upgrade [post] +func (handler *Handler) systemUpgrade(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + payload, err := request.GetPayload[systemUpgradePayload](r) + if err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + environment, err := handler.platformService.GetLocalEnvironment() + if err != nil { + if errors.Is(err, ceplf.ErrNoLocalEnvironment) { + return httperror.NotFound("The system upgrade feature is disabled because no local environment was detected.", err) + } + return httperror.InternalServerError("Failed to get local environment", err) + } + + platform, err := handler.platformService.GetPlatform() + if err != nil { + return httperror.InternalServerError("Failed to get platform", err) + } + + if err := handler.upgradeService.Upgrade(platform, environment, payload.License); err != nil { + return httperror.InternalServerError("Failed to upgrade Portainer", err) + } + + return response.Empty(w) +} diff --git a/api/http/handler/system/version.go b/api/http/handler/system/version.go new file mode 100644 index 0000000..ca9f193 --- /dev/null +++ b/api/http/handler/system/version.go @@ -0,0 +1,113 @@ +package system + +import ( + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/client" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/pkg/build" + libclient "github.com/portainer/portainer/pkg/libhttp/client" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/response" + + "github.com/Masterminds/semver/v3" + "github.com/rs/zerolog/log" + "github.com/segmentio/encoding/json" +) + +type versionResponse struct { + // Whether portainer has an update available + UpdateAvailable bool `json:"UpdateAvailable" example:"false"` + // The latest version available + LatestVersion string `json:"LatestVersion" example:"2.0.0"` + + ServerVersion string + VersionSupport string `json:"VersionSupport" example:"STS/LTS"` + ServerEdition string `json:"ServerEdition" example:"CE/EE"` + DatabaseVersion string + Build build.BuildInfo + Dependencies build.DependenciesInfo + Runtime build.RuntimeInfo +} + +// @id systemVersion +// @summary Check for portainer updates +// @description Check if portainer has an update available +// @description **Access policy**: authenticated +// @security ApiKeyAuth +// @security jwt +// @tags system +// @produce json +// @success 200 {object} versionResponse "Success" +// @router /system/version [get] +func (handler *Handler) version(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + isAdmin, err := security.IsAdmin(r) + if err != nil { + return httperror.Forbidden("Permission denied to access Portainer", err) + } + + result := &versionResponse{ + ServerVersion: portainer.APIVersion, + VersionSupport: portainer.APIVersionSupport, + DatabaseVersion: portainer.APIVersion, + ServerEdition: portainer.Edition.GetEditionLabel(), + Build: build.GetBuildInfo(), + Dependencies: build.GetDependenciesInfo(), + } + + if isAdmin { + result.Runtime = build.GetRuntimeInfo() + } + + latestVersion := GetLatestVersion() + if HasNewerVersion(portainer.APIVersion, latestVersion) { + result.UpdateAvailable = true + result.LatestVersion = latestVersion + } + + return response.JSON(w, &result) +} + +func GetLatestVersion() string { + if err := libclient.ExternalRequestDisabled(portainer.VersionCheckURL); err != nil { + log.Debug().Err(err).Msg("External request disabled: Version check") + return "" + } + + motd, err := client.Get(portainer.VersionCheckURL, 5) + if err != nil { + log.Debug().Err(err).Msg("couldn't fetch latest Portainer release version") + return "" + } + + var data struct { + TagName string `json:"tag_name"` + } + + if err := json.Unmarshal(motd, &data); err != nil { + log.Debug().Err(err).Msg("couldn't parse latest Portainer version") + + return "" + } + + return data.TagName +} + +func HasNewerVersion(currentVersion, latestVersion string) bool { + currentVersionSemver, err := semver.NewVersion(currentVersion) + if err != nil { + log.Debug().Str("version", currentVersion).Msg("current Portainer version isn't a semver") + + return false + } + + latestVersionSemver, err := semver.NewVersion(latestVersion) + if err != nil { + log.Debug().Str("version", latestVersion).Msg("latest Portainer version isn't a semver") + + return false + } + + return currentVersionSemver.LessThan(latestVersionSemver) +} diff --git a/api/http/handler/system/version_test.go b/api/http/handler/system/version_test.go new file mode 100644 index 0000000..383b952 --- /dev/null +++ b/api/http/handler/system/version_test.go @@ -0,0 +1,68 @@ +package system + +import ( + "io" + "net/http" + "net/http/httptest" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/apikey" + "github.com/portainer/portainer/api/database/models" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/testhelpers" + "github.com/portainer/portainer/api/jwt" + + "github.com/segmentio/encoding/json" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_getSystemVersion(t *testing.T) { + t.Parallel() + is := assert.New(t) + + _, store := datastore.MustNewTestStore(t, true, true) + + // create version data + version := &models.Version{SchemaVersion: "2.20.0", Edition: 1} + err := store.Version().UpdateVersion(version) + require.NoError(t, err, "error creating version data") + + // create admin and standard user(s) + adminUser := &portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole} + err = store.User().Create(adminUser) + require.NoError(t, err, "error creating admin user") + + // setup services + jwtService, err := jwt.NewService("1h", store) + require.NoError(t, err, "Error initiating jwt service") + + apiKeyService := apikey.NewAPIKeyService(store.APIKeyRepository(), store.User()) + requestBouncer := security.NewRequestBouncer(t.Context(), store, jwtService, apiKeyService) + + h := NewHandler(requestBouncer, &portainer.Status{}, store, nil, nil) + + // generate standard and admin user tokens + jwt, _, _ := jwtService.GenerateToken(&portainer.TokenData{ID: adminUser.ID, Username: adminUser.Username, Role: adminUser.Role}) + + t.Run("Display Edition", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/system/version", nil) + testhelpers.AddTestSecurityCookie(req, jwt) + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + is.Equal(http.StatusOK, rr.Code) + + body, err := io.ReadAll(rr.Body) + require.NoError(t, err, "ReadAll should not return error") + + var resp versionResponse + err = json.Unmarshal(body, &resp) + require.NoError(t, err, "response should be list json") + + is.Equal("CE", resp.ServerEdition, "Edition is not expected") + }) +} diff --git a/api/http/handler/tags/handler.go b/api/http/handler/tags/handler.go new file mode 100644 index 0000000..3dbac0c --- /dev/null +++ b/api/http/handler/tags/handler.go @@ -0,0 +1,32 @@ +package tags + +import ( + "net/http" + + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/http/security" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + + "github.com/gorilla/mux" +) + +// Handler is the HTTP handler used to handle tag operations. +type Handler struct { + *mux.Router + DataStore dataservices.DataStore +} + +// NewHandler creates a handler to manage tag operations. +func NewHandler(bouncer security.BouncerService) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + } + h.Handle("/tags", + bouncer.AdminAccess(httperror.LoggerHandler(h.tagCreate))).Methods(http.MethodPost) + h.Handle("/tags", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.tagList))).Methods(http.MethodGet) + h.Handle("/tags/{id}", + bouncer.AdminAccess(httperror.LoggerHandler(h.tagDelete))).Methods(http.MethodDelete) + + return h +} diff --git a/api/http/handler/tags/tag_create.go b/api/http/handler/tags/tag_create.go new file mode 100644 index 0000000..60d72fc --- /dev/null +++ b/api/http/handler/tags/tag_create.go @@ -0,0 +1,80 @@ +package tags + +import ( + "errors" + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +type tagCreatePayload struct { + Name string `validate:"required" example:"org/acme"` +} + +func (payload *tagCreatePayload) Validate(r *http.Request) error { + if len(payload.Name) == 0 { + return errors.New("invalid tag name") + } + + return nil +} + +// @id TagCreate +// @summary Create a new tag +// @description Create a new tag. +// @description **Access policy**: administrator +// @tags tags +// @security ApiKeyAuth +// @security jwt +// @accept json +// @produce json +// @param body body tagCreatePayload true "Tag details" +// @success 200 {object} portainer.Tag "Success" +// @failure 409 "This name is already associated to a tag" +// @failure 500 "Server error" +// @router /tags [post] +func (handler *Handler) tagCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload tagCreatePayload + if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + var tag *portainer.Tag + var err error + err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + tag, err = createTag(tx, payload) + return err + }) + + return response.TxResponse(w, tag, err) +} + +func createTag(tx dataservices.DataStoreTx, payload tagCreatePayload) (*portainer.Tag, error) { + tags, err := tx.Tag().ReadAll() + if err != nil { + return nil, httperror.InternalServerError("Unable to retrieve tags from the database", err) + } + + for _, tag := range tags { + if tag.Name == payload.Name { + return nil, httperror.Conflict("This name is already associated to a tag", errors.New("a tag already exists with this name")) + } + } + + tag := &portainer.Tag{ + Name: payload.Name, + EndpointGroups: map[portainer.EndpointGroupID]bool{}, + Endpoints: map[portainer.EndpointID]bool{}, + } + + err = tx.Tag().Create(tag) + if err != nil { + return nil, httperror.InternalServerError("Unable to persist the tag inside the database", err) + } + + return tag, nil +} diff --git a/api/http/handler/tags/tag_delete.go b/api/http/handler/tags/tag_delete.go new file mode 100644 index 0000000..afc6127 --- /dev/null +++ b/api/http/handler/tags/tag_delete.go @@ -0,0 +1,174 @@ +package tags + +import ( + "net/http" + "slices" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/internal/edge" + "github.com/portainer/portainer/api/internal/endpointutils" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +// @id TagDelete +// @summary Remove a tag +// @description Remove a tag. +// @description **Access policy**: administrator +// @tags tags +// @security ApiKeyAuth +// @security jwt +// @param id path int true "Tag identifier" +// @success 204 "Success" +// @failure 400 "Invalid request" +// @failure 403 "Permission denied" +// @failure 404 "Tag not found" +// @failure 500 "Server error" +// @router /tags/{id} [delete] +func (handler *Handler) tagDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + id, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid tag identifier route variable", err) + } + + err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + return deleteTag(tx, portainer.TagID(id)) + }) + + return response.TxEmptyResponse(w, err) +} + +func deleteTag(tx dataservices.DataStoreTx, tagID portainer.TagID) error { + tag, err := tx.Tag().Read(tagID) + if tx.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find a tag with the specified identifier inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find a tag with the specified identifier inside the database", err) + } + + for endpointID := range tag.Endpoints { + endpoint, err := tx.Endpoint().Endpoint(endpointID) + if tx.IsErrObjectNotFound(err) { + continue + } + if err != nil { + return httperror.InternalServerError("Unable to retrieve environment from the database", err) + } + + endpoint.TagIDs = slices.DeleteFunc(endpoint.TagIDs, func(t portainer.TagID) bool { + return t == tagID + }) + + err = tx.Endpoint().UpdateEndpoint(endpoint.ID, endpoint) + if err != nil { + return httperror.InternalServerError("Unable to update environment", err) + } + } + + for endpointGroupID := range tag.EndpointGroups { + endpointGroup, err := tx.EndpointGroup().Read(endpointGroupID) + if err != nil { + return httperror.InternalServerError("Unable to retrieve environment group from the database", err) + } + + endpointGroup.TagIDs = slices.DeleteFunc(endpointGroup.TagIDs, func(t portainer.TagID) bool { + return t == tagID + }) + + err = tx.EndpointGroup().Update(endpointGroup.ID, endpointGroup) + if err != nil { + return httperror.InternalServerError("Unable to update environment group", err) + } + } + + endpoints, err := tx.Endpoint().Endpoints() + if err != nil { + return httperror.InternalServerError("Unable to retrieve environments from the database", err) + } + + edgeGroups, err := tx.EdgeGroup().ReadAll() + if err != nil { + return httperror.InternalServerError("Unable to retrieve edge groups from the database", err) + } + + edgeStacks, err := tx.EdgeStack().EdgeStacks() + if err != nil { + return httperror.InternalServerError("Unable to retrieve edge stacks from the database", err) + } + + edgeJobs, err := tx.EdgeJob().ReadAll() + if err != nil { + return httperror.InternalServerError("Unable to retrieve edge job configurations from the database", err) + } + for _, edgeGroup := range edgeGroups { + edgeGroup.TagIDs = slices.DeleteFunc(edgeGroup.TagIDs, func(t portainer.TagID) bool { + return t == tagID + }) + + err = tx.EdgeGroup().Update(edgeGroup.ID, &edgeGroup) + if err != nil { + return httperror.InternalServerError("Unable to update edge group", err) + } + } + + for _, endpoint := range endpoints { + if (!tag.Endpoints[endpoint.ID] && !tag.EndpointGroups[endpoint.GroupID]) || !endpointutils.IsEdgeEndpoint(&endpoint) { + continue + } + + if err := updateEndpointRelations(tx, endpoint, edgeGroups, edgeStacks, edgeJobs); err != nil { + return httperror.InternalServerError("Unable to update environment relations in the database", err) + } + } + + err = tx.Tag().Delete(tagID) + if err != nil { + return httperror.InternalServerError("Unable to remove the tag from the database", err) + } + + return nil +} + +func updateEndpointRelations(tx dataservices.DataStoreTx, endpoint portainer.Endpoint, edgeGroups []portainer.EdgeGroup, edgeStacks []portainer.EdgeStack, edgeJobs []portainer.EdgeJob) error { + endpointRelation, err := tx.EndpointRelation().EndpointRelation(endpoint.ID) + if err != nil { + return err + } + + endpointGroup, err := tx.EndpointGroup().Read(endpoint.GroupID) + if err != nil { + return err + } + + endpointStacks := edge.EndpointRelatedEdgeStacks(&endpoint, endpointGroup, edgeGroups, edgeStacks) + stacksSet := map[portainer.EdgeStackID]bool{} + for _, edgeStackID := range endpointStacks { + stacksSet[edgeStackID] = true + } + + endpointRelation.EdgeStacks = stacksSet + + if err := tx.EndpointRelation().UpdateEndpointRelation(endpoint.ID, endpointRelation); err != nil { + return err + } + + for _, edgeJob := range edgeJobs { + endpoints, err := edge.GetEndpointsFromEdgeGroups(edgeJob.EdgeGroups, tx) + if err != nil { + return err + } + if slices.Contains(endpoints, endpoint.ID) { + continue + } + + delete(edgeJob.GroupLogsCollection, endpoint.ID) + + if err := tx.EdgeJob().Update(edgeJob.ID, &edgeJob); err != nil { + return err + } + } + + return nil +} diff --git a/api/http/handler/tags/tag_delete_test.go b/api/http/handler/tags/tag_delete_test.go new file mode 100644 index 0000000..bb8fb61 --- /dev/null +++ b/api/http/handler/tags/tag_delete_test.go @@ -0,0 +1,210 @@ +package tags + +import ( + "net/http" + "net/http/httptest" + "strconv" + "sync" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + portainerDsErrors "github.com/portainer/portainer/api/dataservices/errors" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/internal/testhelpers" + "github.com/portainer/portainer/api/roar" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTagDeleteEdgeGroupsConcurrently(t *testing.T) { + t.Parallel() + const tagsCount = 100 + + handler, store := setUpHandler(t) + // Create all the tags and add them to the same edge group + + var tagIDs []portainer.TagID + + for i := range tagsCount { + tagID := portainer.TagID(i) + 1 + + if err := store.Tag().Create(&portainer.Tag{ + ID: tagID, + Name: "tag-" + strconv.Itoa(int(tagID)), + }); err != nil { + t.Fatal("could not create tag:", err) + } + + tagIDs = append(tagIDs, tagID) + } + + if err := store.EdgeGroup().Create(&portainer.EdgeGroup{ + ID: 1, + Name: "edgegroup-1", + TagIDs: tagIDs, + }); err != nil { + t.Fatal("could not create edge group:", err) + } + + // Remove the tags concurrently + + var wg sync.WaitGroup + + wg.Add(len(tagIDs)) + + for _, tagID := range tagIDs { + go func(ID portainer.TagID) { + defer wg.Done() + + req, err := http.NewRequest(http.MethodDelete, "/tags/"+strconv.Itoa(int(ID)), nil) + if err != nil { + t.Fail() + return + } + + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + }(tagID) + } + + wg.Wait() + + // Check that the edge group is consistent + + edgeGroup, err := handler.DataStore.EdgeGroup().Read(1) + if err != nil { + t.Fatal("could not retrieve the edge group:", err) + } + + if len(edgeGroup.TagIDs) > 0 { + t.Fatal("the edge group is not consistent") + } +} + +func TestHandler_tagDelete(t *testing.T) { + t.Parallel() + t.Run("should delete tag and update related endpoints and edge groups", func(t *testing.T) { + handler, store := setUpHandler(t) + + tag := &portainer.Tag{ + ID: 1, + Name: "tag-1", + Endpoints: make(map[portainer.EndpointID]bool), + EndpointGroups: make(map[portainer.EndpointGroupID]bool), + } + require.NoError(t, store.Tag().Create(tag)) + + endpointGroup := &portainer.EndpointGroup{ + ID: 2, + Name: "endpoint-group-1", + TagIDs: []portainer.TagID{tag.ID}, + } + require.NoError(t, store.EndpointGroup().Create(endpointGroup)) + + endpoint1 := &portainer.Endpoint{ + ID: 1, + Name: "endpoint-1", + GroupID: endpointGroup.ID, + } + require.NoError(t, store.Endpoint().Create(endpoint1)) + + endpoint2 := &portainer.Endpoint{ + ID: 2, + Name: "endpoint-2", + TagIDs: []portainer.TagID{tag.ID}, + } + require.NoError(t, store.Endpoint().Create(endpoint2)) + + tag.Endpoints[endpoint2.ID] = true + tag.EndpointGroups[endpointGroup.ID] = true + require.NoError(t, store.Tag().Update(tag.ID, tag)) + + dynamicEdgeGroup := &portainer.EdgeGroup{ + ID: 1, + Name: "edgegroup-1", + TagIDs: []portainer.TagID{tag.ID}, + Dynamic: true, + } + require.NoError(t, store.EdgeGroup().Create(dynamicEdgeGroup)) + + staticEdgeGroup := &portainer.EdgeGroup{ + ID: 2, + Name: "edgegroup-2", + EndpointIDs: roar.FromSlice([]portainer.EndpointID{endpoint2.ID}), + } + require.NoError(t, store.EdgeGroup().Create(staticEdgeGroup)) + + req, err := http.NewRequest(http.MethodDelete, "/tags/"+strconv.Itoa(int(tag.ID)), nil) + if err != nil { + t.Fail() + + return + } + + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + require.Equal(t, http.StatusNoContent, rec.Code) + + // Check that the tag is deleted + _, err = store.Tag().Read(tag.ID) + require.ErrorIs(t, err, portainerDsErrors.ErrObjectNotFound) + + // Check that the endpoints are updated + endpoint1, err = store.Endpoint().Endpoint(endpoint1.ID) + require.NoError(t, err) + assert.Empty(t, endpoint1.TagIDs, "endpoint-1 should not have any tags") + assert.Equal(t, endpoint1.GroupID, endpointGroup.ID, "endpoint-1 should still belong to the endpoint group") + + endpoint2, err = store.Endpoint().Endpoint(endpoint2.ID) + require.NoError(t, err) + assert.Empty(t, endpoint2.TagIDs, "endpoint-2 should not have any tags") + + // Check that the dynamic edge group is updated + dynamicEdgeGroup, err = store.EdgeGroup().Read(dynamicEdgeGroup.ID) + require.NoError(t, err) + assert.Empty(t, dynamicEdgeGroup.TagIDs, "dynamic edge group should not have any tags") + assert.Equal(t, 0, dynamicEdgeGroup.EndpointIDs.Len(), "dynamic edge group should not have any endpoints") + + // Check that the static edge group is not updated + staticEdgeGroup, err = store.EdgeGroup().Read(staticEdgeGroup.ID) + require.NoError(t, err) + assert.Empty(t, staticEdgeGroup.TagIDs, "static edge group should not have any tags") + assert.Equal(t, 1, staticEdgeGroup.EndpointIDs.Len(), "static edge group should have one endpoint") + assert.True(t, staticEdgeGroup.EndpointIDs.Contains(endpoint2.ID), "static edge group should have the endpoint-2") + }) + + // Test the tx.IsErrObjectNotFound logic when endpoint is not found during cleanup + t.Run("should continue gracefully when endpoint not found during cleanup", func(t *testing.T) { + _, store := setUpHandler(t) + // Create a tag with a reference to a non-existent endpoint + tag := &portainer.Tag{ + ID: 1, + Name: "test-tag", + Endpoints: map[portainer.EndpointID]bool{999: true}, // Non-existent endpoint + EndpointGroups: make(map[portainer.EndpointGroupID]bool), + } + + err := store.Tag().Create(tag) + require.NoError(t, err) + + err = deleteTag(store, 1) + require.NoError(t, err) + }) +} + +func setUpHandler(t *testing.T) (*Handler, dataservices.DataStore) { + _, store := datastore.MustNewTestStore(t, true, false) + + user := &portainer.User{ID: 2, Username: "admin", Role: portainer.AdministratorRole} + if err := store.User().Create(user); err != nil { + t.Fatal("could not create admin user:", err) + } + + handler := NewHandler(testhelpers.NewTestRequestBouncer()) + handler.DataStore = store + + return handler, store +} diff --git a/api/http/handler/tags/tag_list.go b/api/http/handler/tags/tag_list.go new file mode 100644 index 0000000..a0d7a8b --- /dev/null +++ b/api/http/handler/tags/tag_list.go @@ -0,0 +1,28 @@ +package tags + +import ( + "net/http" + + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +// @id TagList +// @summary List tags +// @description List tags. +// @description **Access policy**: authenticated +// @tags tags +// @security ApiKeyAuth +// @security jwt +// @produce json +// @success 200 {array} portainer.Tag "Success" +// @failure 500 "Server error" +// @router /tags [get] +func (handler *Handler) tagList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + tags, err := handler.DataStore.Tag().ReadAll() + if err != nil { + return httperror.InternalServerError("Unable to retrieve tags from the database", err) + } + + return response.JSON(w, tags) +} diff --git a/api/http/handler/teammemberships/handler.go b/api/http/handler/teammemberships/handler.go new file mode 100644 index 0000000..aa4a219 --- /dev/null +++ b/api/http/handler/teammemberships/handler.go @@ -0,0 +1,62 @@ +package teammemberships + +import ( + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/endpointutils" + "github.com/portainer/portainer/api/kubernetes/cli" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/rs/zerolog/log" + + "github.com/gorilla/mux" +) + +// Handler is the HTTP handler used to handle team membership operations. +type Handler struct { + *mux.Router + DataStore dataservices.DataStore + K8sClientFactory *cli.ClientFactory +} + +// NewHandler creates a handler to manage team membership operations. +func NewHandler(bouncer security.BouncerService) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + } + + h.Use(bouncer.TeamLeaderAccess) + + h.Handle("/team_memberships", httperror.LoggerHandler(h.teamMembershipCreate)).Methods(http.MethodPost) + h.Handle("/team_memberships", httperror.LoggerHandler(h.teamMembershipList)).Methods(http.MethodGet) + h.Handle("/team_memberships/{id}", httperror.LoggerHandler(h.teamMembershipUpdate)).Methods(http.MethodPut) + h.Handle("/team_memberships/{id}", httperror.LoggerHandler(h.teamMembershipDelete)).Methods(http.MethodDelete) + + return h +} + +func (handler *Handler) updateUserServiceAccounts(membership *portainer.TeamMembership) { + endpoints, err := handler.DataStore.Endpoint().EndpointsByTeamID(membership.TeamID) + if err != nil { + log.Error().Err(err).Msgf("failed fetching environments for team %d", membership.TeamID) + return + } + for _, endpoint := range endpoints { + restrictDefaultNamespace := endpoint.Kubernetes.Configuration.RestrictDefaultNamespace + // update kubernenets service accounts if the team is associated with a kubernetes environment + if endpointutils.IsKubernetesEndpoint(&endpoint) { + kubecli, err := handler.K8sClientFactory.GetPrivilegedKubeClient(&endpoint) + if err != nil { + log.Error().Err(err).Msgf("failed getting kube client for environment %d", endpoint.ID) + continue + } + teamIDs := []int{int(membership.TeamID)} + err = kubecli.SetupUserServiceAccount(int(membership.UserID), teamIDs, restrictDefaultNamespace) + if err != nil { + log.Error().Err(err).Msgf("failed setting-up service account for user %d", membership.UserID) + } + } + } +} diff --git a/api/http/handler/teammemberships/teammembership_create.go b/api/http/handler/teammemberships/teammembership_create.go new file mode 100644 index 0000000..d9acb9b --- /dev/null +++ b/api/http/handler/teammemberships/teammembership_create.go @@ -0,0 +1,96 @@ +package teammemberships + +import ( + "errors" + "net/http" + + portainer "github.com/portainer/portainer/api" + httperrors "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/api/http/security" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +type teamMembershipCreatePayload struct { + // User identifier + UserID int `validate:"required" example:"1"` + // Team identifier + TeamID int `validate:"required" example:"1"` + // Role for the user inside the team (1 for leader and 2 for regular member) + Role int `validate:"required" example:"1" enums:"1,2"` +} + +func (payload *teamMembershipCreatePayload) Validate(r *http.Request) error { + if payload.UserID == 0 { + return errors.New("Invalid UserID") + } + if payload.TeamID == 0 { + return errors.New("Invalid TeamID") + } + if payload.Role != 1 && payload.Role != 2 { + return errors.New("Invalid role value. Value must be one of: 1 (leader) or 2 (member)") + } + return nil +} + +// @id TeamMembershipCreate +// @summary Create a new team membership +// @description Create a new team memberships. Access is only available to administrators leaders of the associated team. +// @description **Access policy**: administrator +// @tags team_memberships +// @security ApiKeyAuth +// @security jwt +// @accept json +// @produce json +// @param body body teamMembershipCreatePayload true "Team membership details" +// @success 200 {object} portainer.TeamMembership "Success" +// @failure 400 "Invalid request" +// @failure 403 "Permission denied to manage memberships" +// @failure 409 "Team membership already registered" +// @failure 500 "Server error" +// @router /team_memberships [post] +func (handler *Handler) teamMembershipCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload teamMembershipCreatePayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) + } + + if !security.AuthorizedTeamManagement(portainer.TeamID(payload.TeamID), securityContext) { + return httperror.Forbidden("Permission denied to manage team memberships", httperrors.ErrResourceAccessDenied) + } + + memberships, err := handler.DataStore.TeamMembership().TeamMembershipsByUserID(portainer.UserID(payload.UserID)) + if err != nil { + return httperror.InternalServerError("Unable to retrieve team memberships from the database", err) + } + + if len(memberships) > 0 { + for _, membership := range memberships { + if membership.UserID == portainer.UserID(payload.UserID) && membership.TeamID == portainer.TeamID(payload.TeamID) { + return httperror.Conflict("Team membership already registered", errors.New("Team membership already exists for this user and team")) + } + } + } + + membership := &portainer.TeamMembership{ + UserID: portainer.UserID(payload.UserID), + TeamID: portainer.TeamID(payload.TeamID), + Role: portainer.MembershipRole(payload.Role), + } + + err = handler.DataStore.TeamMembership().Create(membership) + if err != nil { + return httperror.InternalServerError("Unable to persist team memberships inside the database", err) + } + + defer handler.updateUserServiceAccounts(membership) + + return response.JSON(w, membership) +} diff --git a/api/http/handler/teammemberships/teammembership_delete.go b/api/http/handler/teammemberships/teammembership_delete.go new file mode 100644 index 0000000..e9b8ec4 --- /dev/null +++ b/api/http/handler/teammemberships/teammembership_delete.go @@ -0,0 +1,58 @@ +package teammemberships + +import ( + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/api/http/security" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +// @id TeamMembershipDelete +// @summary Remove a team membership +// @description Remove a team membership. Access is only available to administrators leaders of the associated team. +// @description **Access policy**: administrator +// @tags team_memberships +// @security ApiKeyAuth +// @security jwt +// @param id path int true "TeamMembership identifier" +// @success 204 "Success" +// @failure 400 "Invalid request" +// @failure 403 "Permission denied" +// @failure 404 "TeamMembership not found" +// @failure 500 "Server error" +// @router /team_memberships/{id} [delete] +func (handler *Handler) teamMembershipDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + membershipID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid membership identifier route variable", err) + } + + membership, err := handler.DataStore.TeamMembership().Read(portainer.TeamMembershipID(membershipID)) + if handler.DataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find a team membership with the specified identifier inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find a team membership with the specified identifier inside the database", err) + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) + } + + if !security.AuthorizedTeamManagement(membership.TeamID, securityContext) { + return httperror.Forbidden("Permission denied to delete the membership", errors.ErrResourceAccessDenied) + } + + err = handler.DataStore.TeamMembership().Delete(portainer.TeamMembershipID(membershipID)) + if err != nil { + return httperror.InternalServerError("Unable to remove the team membership from the database", err) + } + + defer handler.updateUserServiceAccounts(membership) + + return response.Empty(w) +} diff --git a/api/http/handler/teammemberships/teammembership_list.go b/api/http/handler/teammemberships/teammembership_list.go new file mode 100644 index 0000000..508f5db --- /dev/null +++ b/api/http/handler/teammemberships/teammembership_list.go @@ -0,0 +1,30 @@ +package teammemberships + +import ( + "net/http" + + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +// @id TeamMembershipList +// @summary List team memberships +// @description List team memberships. Access is only available to administrators and team leaders. +// @description **Access policy**: administrator +// @tags team_memberships +// @security ApiKeyAuth +// @security jwt +// @produce json +// @success 200 {array} portainer.TeamMembership "Success" +// @failure 400 "Invalid request" +// @failure 403 "Permission denied" +// @failure 500 "Server error" +// @router /team_memberships [get] +func (handler *Handler) teamMembershipList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + memberships, err := handler.DataStore.TeamMembership().ReadAll() + if err != nil { + return httperror.InternalServerError("Unable to retrieve team memberships from the database", err) + } + + return response.JSON(w, memberships) +} diff --git a/api/http/handler/teammemberships/teammembership_update.go b/api/http/handler/teammemberships/teammembership_update.go new file mode 100644 index 0000000..0c70759 --- /dev/null +++ b/api/http/handler/teammemberships/teammembership_update.go @@ -0,0 +1,94 @@ +package teammemberships + +import ( + "errors" + "net/http" + + portainer "github.com/portainer/portainer/api" + httperrors "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/api/http/security" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +type teamMembershipUpdatePayload struct { + // User identifier + UserID int `validate:"required" example:"1"` + // Team identifier + TeamID int `validate:"required" example:"1"` + // Role for the user inside the team (1 for leader and 2 for regular member) + Role int `validate:"required" example:"1" enums:"1,2"` +} + +func (payload *teamMembershipUpdatePayload) Validate(r *http.Request) error { + if payload.UserID == 0 { + return errors.New("Invalid UserID") + } + if payload.TeamID == 0 { + return errors.New("Invalid TeamID") + } + if payload.Role != 1 && payload.Role != 2 { + return errors.New("Invalid role value. Value must be one of: 1 (leader) or 2 (member)") + } + return nil +} + +// @id TeamMembershipUpdate +// @summary Update a team membership +// @description Update a team membership. Access is only available to administrators or leaders of the associated team. +// @description **Access policy**: administrator or leaders of the associated team +// @tags team_memberships +// @security ApiKeyAuth +// @security jwt +// @accept json +// @produce json +// @param id path int true "Team membership identifier" +// @param body body teamMembershipUpdatePayload true "Team membership details" +// @success 200 {object} portainer.TeamMembership "Success" +// @failure 400 "Invalid request" +// @failure 403 "Permission denied" +// @failure 404 "TeamMembership not found" +// @failure 500 "Server error" +// @router /team_memberships/{id} [put] +func (handler *Handler) teamMembershipUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + membershipID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid membership identifier route variable", err) + } + + var payload teamMembershipUpdatePayload + if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + membership, err := handler.DataStore.TeamMembership().Read(portainer.TeamMembershipID(membershipID)) + if handler.DataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find a team membership with the specified identifier inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find a team membership with the specified identifier inside the database", err) + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) + } + + isLeadingBothTeam := security.AuthorizedTeamManagement(portainer.TeamID(payload.TeamID), securityContext) && + security.AuthorizedTeamManagement(membership.TeamID, securityContext) + if !securityContext.IsAdmin && !isLeadingBothTeam { + return httperror.Forbidden("Permission denied to update the membership", httperrors.ErrResourceAccessDenied) + } + + membership.UserID = portainer.UserID(payload.UserID) + membership.TeamID = portainer.TeamID(payload.TeamID) + membership.Role = portainer.MembershipRole(payload.Role) + + if err := handler.DataStore.TeamMembership().Update(membership.ID, membership); err != nil { + return httperror.InternalServerError("Unable to persist membership changes inside the database", err) + } + + defer handler.updateUserServiceAccounts(membership) + + return response.JSON(w, membership) +} diff --git a/api/http/handler/teammemberships/teammembership_update_test.go b/api/http/handler/teammemberships/teammembership_update_test.go new file mode 100644 index 0000000..2ad6663 --- /dev/null +++ b/api/http/handler/teammemberships/teammembership_update_test.go @@ -0,0 +1,51 @@ +package teammemberships + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/testhelpers" + + "github.com/segmentio/encoding/json" + "github.com/stretchr/testify/require" +) + +func TestUpdate(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, false) + + err := store.TeamMembershipService.Create(&portainer.TeamMembership{ + UserID: 3, + TeamID: 3, + Role: 1, + }) + require.NoError(t, err) + + h := NewHandler(testhelpers.NewTestRequestBouncer()) + h.DataStore = store + + payload := `{"UserID": 3, "TeamID": 2, "Role": 1}` + + rr := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPut, "/team_memberships/1", strings.NewReader(payload)) + + restrictedCtx := &security.RestrictedRequestContext{IsAdmin: true} + r = r.WithContext(security.StoreRestrictedRequestContext(r, restrictedCtx)) + + h.ServeHTTP(rr, r) + + require.Equal(t, http.StatusOK, rr.Code) + + var updatedMembership portainer.TeamMembership + err = json.Unmarshal(rr.Body.Bytes(), &updatedMembership) + require.NoError(t, err) + + require.Equal(t, portainer.UserID(3), updatedMembership.UserID) + require.Equal(t, portainer.TeamID(2), updatedMembership.TeamID) + require.Equal(t, portainer.MembershipRole(1), updatedMembership.Role) +} diff --git a/api/http/handler/teams/handler.go b/api/http/handler/teams/handler.go new file mode 100644 index 0000000..2718878 --- /dev/null +++ b/api/http/handler/teams/handler.go @@ -0,0 +1,42 @@ +package teams + +import ( + "net/http" + + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/http/security" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + + "github.com/gorilla/mux" +) + +// Handler is the HTTP handler used to handle team operations. +type Handler struct { + *mux.Router + DataStore dataservices.DataStore +} + +// NewHandler creates a handler to manage team operations. +func NewHandler(bouncer security.BouncerService) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + } + + adminRouter := h.NewRoute().Subrouter() + adminRouter.Use(bouncer.AdminAccess) + + restrictedRouter := h.NewRoute().Subrouter() + restrictedRouter.Use(bouncer.RestrictedAccess) + + teamLeaderRouter := h.NewRoute().Subrouter() + teamLeaderRouter.Use(bouncer.TeamLeaderAccess) + + adminRouter.Handle("/teams", httperror.LoggerHandler(h.teamCreate)).Methods(http.MethodPost) + restrictedRouter.Handle("/teams", httperror.LoggerHandler(h.teamList)).Methods(http.MethodGet) + teamLeaderRouter.Handle("/teams/{id}", httperror.LoggerHandler(h.teamInspect)).Methods(http.MethodGet) + adminRouter.Handle("/teams/{id}", httperror.LoggerHandler(h.teamUpdate)).Methods(http.MethodPut) + adminRouter.Handle("/teams/{id}", httperror.LoggerHandler(h.teamDelete)).Methods(http.MethodDelete) + teamLeaderRouter.Handle("/teams/{id}/memberships", httperror.LoggerHandler(h.teamMemberships)).Methods(http.MethodGet) + + return h +} diff --git a/api/http/handler/teams/team_create.go b/api/http/handler/teams/team_create.go new file mode 100644 index 0000000..f073196 --- /dev/null +++ b/api/http/handler/teams/team_create.go @@ -0,0 +1,88 @@ +package teams + +import ( + "errors" + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +type teamCreatePayload struct { + // Name + Name string `example:"developers" validate:"required"` + // TeamLeaders + TeamLeaders []portainer.UserID `example:"3,5"` +} + +func (payload *teamCreatePayload) Validate(r *http.Request) error { + if len(payload.Name) == 0 { + return errors.New("Invalid team name") + } + + return nil +} + +// @id TeamCreate +// @summary Create a new team +// @description Create a new team. +// @description **Access policy**: administrator +// @tags teams +// @security ApiKeyAuth +// @security jwt +// @accept json +// @produce json +// @param body body teamCreatePayload true "details" +// @success 200 {object} portainer.Team "Success" +// @failure 400 "Invalid request" +// @failure 409 "A team with the same name already exists" +// @failure 500 "Server error" +// @router /teams [post] +func (handler *Handler) teamCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload teamCreatePayload + if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + var team *portainer.Team + var err error + err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + team, err = createTeam(tx, payload) + return err + }) + + return response.TxResponse(w, team, err) +} + +func createTeam(tx dataservices.DataStoreTx, payload teamCreatePayload) (*portainer.Team, error) { + team, err := tx.Team().TeamByName(payload.Name) + if err != nil && !tx.IsErrObjectNotFound(err) { + return nil, httperror.InternalServerError("Unable to retrieve teams from the database", err) + } + if team != nil { + return nil, httperror.Conflict("A team with the same name already exists", errors.New("Team already exists")) + } + + team = &portainer.Team{Name: payload.Name} + + if err := tx.Team().Create(team); err != nil { + return nil, httperror.InternalServerError("Unable to persist the team inside the database", err) + } + + for _, teamLeader := range payload.TeamLeaders { + membership := &portainer.TeamMembership{ + UserID: teamLeader, + TeamID: team.ID, + Role: portainer.TeamLeader, + } + + if err := tx.TeamMembership().Create(membership); err != nil { + return nil, httperror.InternalServerError("Unable to persist team leadership inside the database", err) + } + } + + return team, nil +} diff --git a/api/http/handler/teams/team_create_test.go b/api/http/handler/teams/team_create_test.go new file mode 100644 index 0000000..003c22e --- /dev/null +++ b/api/http/handler/teams/team_create_test.go @@ -0,0 +1,66 @@ +package teams + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/portainer/portainer/api/datastore" + + "github.com/stretchr/testify/require" + "golang.org/x/sync/errgroup" +) + +func TestConcurrentTeamCreation(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, true, false) + + h := &Handler{ + DataStore: store, + } + + tcp := teamCreatePayload{ + Name: "portainer", + } + + m, err := json.Marshal(tcp) + require.NoError(t, err) + + errGroup := &errgroup.Group{} + + n := 100 + + for range n { + errGroup.Go(func() error { + req, err := http.NewRequest(http.MethodPost, "/teams", bytes.NewReader(m)) + if err != nil { + return err + } + + if err := h.teamCreate(httptest.NewRecorder(), req); err != nil { + return err + } + + return nil + }) + } + + err = errGroup.Wait() + require.Error(t, err) + + teams, err := store.Team().ReadAll() + require.NotEmpty(t, teams) + require.NoError(t, err) + + teamCreated := false + for _, team := range teams { + if team.Name == tcp.Name { + require.False(t, teamCreated) + teamCreated = true + } + } + + require.True(t, teamCreated) +} diff --git a/api/http/handler/teams/team_delete.go b/api/http/handler/teams/team_delete.go new file mode 100644 index 0000000..93cd43f --- /dev/null +++ b/api/http/handler/teams/team_delete.go @@ -0,0 +1,74 @@ +package teams + +import ( + "net/http" + + portainer "github.com/portainer/portainer/api" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" + + "github.com/pkg/errors" +) + +// @id TeamDelete +// @summary Remove a team +// @description Remove a team. +// @description **Access policy**: administrator +// @tags teams +// @security ApiKeyAuth +// @security jwt +// @param id path int true "Team Id" +// @success 204 "Success" +// @failure 400 "Invalid request" +// @failure 403 "Permission denied" +// @failure 404 "Team not found" +// @failure 500 "Server error" +// @router /teams/{id} [delete] +func (handler *Handler) teamDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + teamID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid team identifier route variable", err) + } + + _, err = handler.DataStore.Team().Read(portainer.TeamID(teamID)) + if handler.DataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find a team with the specified identifier inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find a team with the specified identifier inside the database", err) + } + + err = handler.DataStore.Team().Delete(portainer.TeamID(teamID)) + if err != nil { + return httperror.InternalServerError("Unable to delete the team from the database", err) + } + + err = handler.DataStore.TeamMembership().DeleteTeamMembershipByTeamID(portainer.TeamID(teamID)) + if err != nil { + return httperror.InternalServerError("Unable to delete associated team memberships from the database", err) + } + + // update default team if deleted team was default + err = handler.updateDefaultTeamIfDeleted(portainer.TeamID(teamID)) + if err != nil { + return httperror.InternalServerError("Unable to reset default team", err) + } + + return response.Empty(w) +} + +// updateDefaultTeamIfDeleted resets the default team to nil if default team was the deleted team +func (handler *Handler) updateDefaultTeamIfDeleted(teamID portainer.TeamID) error { + settings, err := handler.DataStore.Settings().Settings() + if err != nil { + return errors.Wrap(err, "failed to fetch settings") + } + + if teamID != settings.OAuthSettings.DefaultTeamID { + return nil + } + + settings.OAuthSettings.DefaultTeamID = 0 + err = handler.DataStore.Settings().UpdateSettings(settings) + return errors.Wrap(err, "failed to update settings") +} diff --git a/api/http/handler/teams/team_inspect.go b/api/http/handler/teams/team_inspect.go new file mode 100644 index 0000000..8576365 --- /dev/null +++ b/api/http/handler/teams/team_inspect.go @@ -0,0 +1,52 @@ +package teams + +import ( + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/api/http/security" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +// @id TeamInspect +// @summary Inspect a team +// @description Retrieve details about a team. Access is only available for administrator and leaders of that team. +// @description **Access policy**: administrator +// @tags teams +// @security ApiKeyAuth +// @security jwt +// @produce json +// @param id path int true "Team identifier" +// @success 200 {object} portainer.Team "Success" +// @failure 400 "Invalid request" +// @failure 403 "Permission denied" +// @failure 404 "Team not found" +// @failure 500 "Server error" +// @router /teams/{id} [get] +func (handler *Handler) teamInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + teamID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid team identifier route variable", err) + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) + } + + if !security.AuthorizedTeamManagement(portainer.TeamID(teamID), securityContext) { + return httperror.Forbidden("Access denied to team", errors.ErrResourceAccessDenied) + } + + team, err := handler.DataStore.Team().Read(portainer.TeamID(teamID)) + if handler.DataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find a team with the specified identifier inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find a team with the specified identifier inside the database", err) + } + + return response.JSON(w, team) +} diff --git a/api/http/handler/teams/team_list.go b/api/http/handler/teams/team_list.go new file mode 100644 index 0000000..35821de --- /dev/null +++ b/api/http/handler/teams/team_list.go @@ -0,0 +1,77 @@ +package teams + +import ( + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/security" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +// @id TeamList +// @summary List teams +// @description List teams. For non-administrator users, will only list the teams they are member of. +// @description **Access policy**: restricted +// @tags teams +// @param onlyLedTeams query boolean false "Only list teams that the user is leader of" +// @param environmentId query int false "Identifier of the environment(endpoint) that will be used to filter the authorized teams" +// @security ApiKeyAuth +// @security jwt +// @produce json +// @success 200 {array} portainer.Team "Success" +// @failure 500 "Server error" +// @router /teams [get] +func (handler *Handler) teamList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + teams, err := handler.DataStore.Team().ReadAll() + if err != nil { + return httperror.InternalServerError("Unable to retrieve teams from the database", err) + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) + } + + onlyLedTeams, _ := request.RetrieveBooleanQueryParameter(r, "onlyLedTeams", true) + + var userTeams []portainer.Team + if onlyLedTeams { + userTeams = security.FilterLeaderTeams(teams, securityContext) + } else { + userTeams = security.FilterUserTeams(teams, securityContext) + } + + endpointID, _ := request.RetrieveNumericQueryParameter(r, "environmentId", true) + if endpointID == 0 { + return response.JSON(w, userTeams) + } + + // filter out teams who do not have access to the specific endpoint + endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) + if err != nil { + return httperror.InternalServerError("Unable to retrieve endpoint from the database", err) + } + + endpointGroup, err := handler.DataStore.EndpointGroup().Read(endpoint.GroupID) + if err != nil { + return httperror.InternalServerError("Unable to retrieve environment groups from the database", err) + } + + allowedTeams := make(map[portainer.TeamID]struct{}) + for teamID := range endpointGroup.TeamAccessPolicies { + allowedTeams[teamID] = struct{}{} + } + for teamID := range endpoint.TeamAccessPolicies { + allowedTeams[teamID] = struct{}{} + } + + listableTeams := make([]portainer.Team, 0) + for _, team := range userTeams { + if _, ok := allowedTeams[team.ID]; ok { + listableTeams = append(listableTeams, team) + } + } + return response.JSON(w, listableTeams) +} diff --git a/api/http/handler/teams/team_list_test.go b/api/http/handler/teams/team_list_test.go new file mode 100644 index 0000000..6dbf1b7 --- /dev/null +++ b/api/http/handler/teams/team_list_test.go @@ -0,0 +1,193 @@ +package teams + +import ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/apikey" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/authorization" + "github.com/portainer/portainer/api/internal/testhelpers" + "github.com/portainer/portainer/api/jwt" + + "github.com/segmentio/encoding/json" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_teamList(t *testing.T) { + t.Parallel() + is := assert.New(t) + + _, store := datastore.MustNewTestStore(t, true, true) + + // create admin + adminUser := &portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole} + err := store.User().Create(adminUser) + require.NoError(t, err, "error creating admin user") + + // setup services + jwtService, err := jwt.NewService("1h", store) + require.NoError(t, err, "Error initiating jwt service") + apiKeyService := apikey.NewAPIKeyService(store.APIKeyRepository(), store.User()) + requestBouncer := security.NewRequestBouncer(t.Context(), store, jwtService, apiKeyService) + + h := NewHandler(requestBouncer) + h.DataStore = store + + // generate admin user tokens + adminJWT, _, _ := jwtService.GenerateToken(&portainer.TokenData{ID: adminUser.ID, Username: adminUser.Username, Role: adminUser.Role}) + + // Case 1: the team is given the endpoint access directly + // create teams + teamWithEndpointAccess := &portainer.Team{ID: 1, Name: "team-with-endpoint-access"} + err = store.Team().Create(teamWithEndpointAccess) + require.NoError(t, err, "error creating team") + + teamWithoutEndpointAccess := &portainer.Team{ID: 2, Name: "team-without-endpoint-access"} + err = store.Team().Create(teamWithoutEndpointAccess) + require.NoError(t, err, "error creating team") + + // create users + userWithEndpointAccessByTeam := &portainer.User{ID: 2, Username: "standard-user-inherit-endpoint-access-from-team", Role: portainer.StandardUserRole, PortainerAuthorizations: authorization.DefaultPortainerAuthorizations()} + err = store.User().Create(userWithEndpointAccessByTeam) + require.NoError(t, err, "error creating user") + + userWithoutEndpointAccess := &portainer.User{ID: 3, Username: "standard-user-without-endpoint-access", Role: portainer.StandardUserRole, PortainerAuthorizations: authorization.DefaultPortainerAuthorizations()} + err = store.User().Create(userWithoutEndpointAccess) + require.NoError(t, err, "error creating user") + + // create team membership + teamMembership := &portainer.TeamMembership{ID: 1, UserID: userWithEndpointAccessByTeam.ID, TeamID: teamWithEndpointAccess.ID} + err = store.TeamMembership().Create(teamMembership) + require.NoError(t, err, "error creating team membership") + + // create endpoint and team access policies + teamAccessPolicies := make(portainer.TeamAccessPolicies, 0) + teamAccessPolicies[teamWithEndpointAccess.ID] = portainer.AccessPolicy{RoleID: portainer.RoleID(userWithEndpointAccessByTeam.Role)} + + endpointGroupOnly := &portainer.EndpointGroup{ID: 5, Name: "endpoint-group"} + err = store.EndpointGroup().Create(endpointGroupOnly) + require.NoError(t, err, "error creating endpoint group") + + endpointWithTeamAccessPolicy := &portainer.Endpoint{ID: 1, GroupID: endpointGroupOnly.ID, TeamAccessPolicies: teamAccessPolicies} + err = store.Endpoint().Create(endpointWithTeamAccessPolicy) + require.NoError(t, err, "error creating endpoint") + + jwt, _, _ := jwtService.GenerateToken(&portainer.TokenData{ID: userWithEndpointAccessByTeam.ID, Username: userWithEndpointAccessByTeam.Username, Role: userWithEndpointAccessByTeam.Role}) + + t.Run("admin user can successfully list all teams", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/teams", nil) + testhelpers.AddTestSecurityCookie(req, adminJWT) + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + is.Equal(http.StatusOK, rr.Code) + + body, err := io.ReadAll(rr.Body) + require.NoError(t, err, "ReadAll should not return error") + + var resp []portainer.Team + err = json.Unmarshal(body, &resp) + require.NoError(t, err, "response should be list json") + + is.Len(resp, 2) + }) + + t.Run("admin user can list team who is given access to the specific endpoint", func(t *testing.T) { + params := url.Values{} + params.Add("environmentId", fmt.Sprintf("%d", endpointWithTeamAccessPolicy.ID)) + req := httptest.NewRequest(http.MethodGet, "/teams?"+params.Encode(), nil) + testhelpers.AddTestSecurityCookie(req, adminJWT) + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + is.Equal(http.StatusOK, rr.Code) + + body, err := io.ReadAll(rr.Body) + require.NoError(t, err, "ReadAll should not return error") + + var resp []portainer.Team + err = json.Unmarshal(body, &resp) + require.NoError(t, err, "response should be list json") + + is.Len(resp, 1) + if len(resp) == 1 { + is.Equal(teamWithEndpointAccess.ID, resp[0].ID) + } + }) + + t.Run("standard user only can list team where he belongs to", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/teams", nil) + testhelpers.AddTestSecurityCookie(req, jwt) + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + is.Equal(http.StatusOK, rr.Code) + + body, err := io.ReadAll(rr.Body) + require.NoError(t, err, "ReadAll should not return error") + + var resp []portainer.Team + err = json.Unmarshal(body, &resp) + require.NoError(t, err, "response should be list json") + + is.Len(resp, 1) + if len(resp) == 1 { + is.Equal(teamWithEndpointAccess.ID, resp[0].ID) + } + }) + + // Case 2: the team is under an environment group and the endpoint group has endpoint access. + // the user inherits the endpoint access from the environment group + // create team + teamUnderGroup := &portainer.Team{ID: 3, Name: "team-under-environment-group"} + err = store.Team().Create(teamUnderGroup) + require.NoError(t, err, "error creating user") + + // create environment group including a team + teamAccessPoliciesUnderGroup := make(portainer.TeamAccessPolicies, 0) + teamAccessPoliciesUnderGroup[teamUnderGroup.ID] = portainer.AccessPolicy{} + + endpointGroupWithTeam := &portainer.EndpointGroup{ID: 2, Name: "endpoint-group-with-team", TeamAccessPolicies: teamAccessPoliciesUnderGroup} + err = store.EndpointGroup().Create(endpointGroupWithTeam) + require.NoError(t, err, "error creating endpoint group") + + // create endpoint + endpointUnderGroupWithTeam := &portainer.Endpoint{ID: 2, GroupID: endpointGroupWithTeam.ID} + err = store.Endpoint().Create(endpointUnderGroupWithTeam) + require.NoError(t, err, "error creating endpoint") + + t.Run("admin user can list teams who inherit endpoint access from an environment group", func(t *testing.T) { + params := url.Values{} + params.Add("environmentId", fmt.Sprintf("%d", endpointUnderGroupWithTeam.ID)) + req := httptest.NewRequest(http.MethodGet, "/teams?"+params.Encode(), nil) + testhelpers.AddTestSecurityCookie(req, adminJWT) + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + is.Equal(http.StatusOK, rr.Code) + + body, err := io.ReadAll(rr.Body) + require.NoError(t, err, "ReadAll should not return error") + + var resp []portainer.Team + err = json.Unmarshal(body, &resp) + require.NoError(t, err, "response should be list json") + + is.Len(resp, 1) + if len(resp) == 1 { + is.Equal(teamUnderGroup.ID, resp[0].ID) + } + }) +} diff --git a/api/http/handler/teams/team_memberships.go b/api/http/handler/teams/team_memberships.go new file mode 100644 index 0000000..5f577e9 --- /dev/null +++ b/api/http/handler/teams/team_memberships.go @@ -0,0 +1,49 @@ +package teams + +import ( + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/api/http/security" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +// @id TeamMemberships +// @summary List team memberships +// @description List team memberships. Access is only available to administrators and team leaders. +// @description **Access policy**: restricted +// @tags team_memberships +// @security ApiKeyAuth +// @security jwt +// @produce json +// @param id path int true "Team Id" +// @success 200 {array} portainer.TeamMembership "Success" +// @failure 400 "Invalid request" +// @failure 403 "Permission denied" +// @failure 500 "Server error" +// @router /teams/{id}/memberships [get] +func (handler *Handler) teamMemberships(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + teamID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid team identifier route variable", err) + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) + } + + if !security.AuthorizedTeamManagement(portainer.TeamID(teamID), securityContext) { + return httperror.Forbidden("Access denied to team", errors.ErrResourceAccessDenied) + } + + memberships, err := handler.DataStore.TeamMembership().TeamMembershipsByTeamID(portainer.TeamID(teamID)) + if err != nil { + return httperror.InternalServerError("Unable to retrieve associated team memberships from the database", err) + } + + return response.JSON(w, memberships) +} diff --git a/api/http/handler/teams/team_update.go b/api/http/handler/teams/team_update.go new file mode 100644 index 0000000..5731f29 --- /dev/null +++ b/api/http/handler/teams/team_update.go @@ -0,0 +1,65 @@ +package teams + +import ( + "net/http" + + portainer "github.com/portainer/portainer/api" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +type teamUpdatePayload struct { + // Name + Name string `example:"developers"` +} + +func (payload *teamUpdatePayload) Validate(r *http.Request) error { + return nil +} + +// @id TeamUpdate +// @summary Update a team +// @description Update a team. +// @description **Access policy**: administrator +// @tags teams +// @security ApiKeyAuth +// @security jwt +// @accept json +// @produce json +// @param id path int true "Team identifier" +// @param body body teamUpdatePayload true "Team details" +// @success 200 {object} portainer.Team "Success" +// @failure 400 "Invalid request" +// @failure 403 "Permission denied" +// @failure 404 "Team not found" +// @failure 500 "Server error" +// @router /teams/{id} [put] +func (handler *Handler) teamUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + teamID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid team identifier route variable", err) + } + + var payload teamUpdatePayload + if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + team, err := handler.DataStore.Team().Read(portainer.TeamID(teamID)) + if handler.DataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find a team with the specified identifier inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find a team with the specified identifier inside the database", err) + } + + if payload.Name != "" { + team.Name = payload.Name + } + + if err := handler.DataStore.Team().Update(team.ID, team); err != nil { + return httperror.NotFound("Unable to persist team changes inside the database", err) + } + + return response.JSON(w, team) +} diff --git a/api/http/handler/templates/handler.go b/api/http/handler/templates/handler.go new file mode 100644 index 0000000..1e7b7f0 --- /dev/null +++ b/api/http/handler/templates/handler.go @@ -0,0 +1,33 @@ +package templates + +import ( + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/http/security" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + + "github.com/gorilla/mux" +) + +// Handler represents an HTTP API handler for managing templates. +type Handler struct { + *mux.Router + DataStore dataservices.DataStore + GitService portainer.GitService + FileService portainer.FileService +} + +// NewHandler returns a new instance of Handler. +func NewHandler(bouncer security.BouncerService) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + } + + h.Handle("/templates", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.templateList))).Methods(http.MethodGet) + h.Handle("/templates/{id}/file", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.templateFile))).Methods(http.MethodPost) + return h +} diff --git a/api/http/handler/templates/template_file.go b/api/http/handler/templates/template_file.go new file mode 100644 index 0000000..ab7c4a3 --- /dev/null +++ b/api/http/handler/templates/template_file.go @@ -0,0 +1,99 @@ +package templates + +import ( + "context" + "net/http" + "slices" + + portainer "github.com/portainer/portainer/api" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" + + "github.com/rs/zerolog/log" +) + +type fileResponse struct { + // The requested file content + FileContent string `example:"version:2"` +} + +// @id TemplateFile +// @summary Get a template's file +// @description Get a template's file +// @description **Access policy**: authenticated +// @tags templates +// @security ApiKeyAuth +// @security jwt +// @accept json +// @produce json +// @param id path int true "Template identifier" +// @success 200 {object} fileResponse "Success" +// @failure 400 "Invalid request" +// @failure 500 "Server error" +// @router /templates/{id}/file [post] +func (handler *Handler) templateFile(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + id, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid template identifier", err) + } + + templatesResponse, httpErr := handler.fetchTemplates() + if httpErr != nil { + return httpErr + } + + templateIdx := slices.IndexFunc(templatesResponse.Templates, func(template portainer.Template) bool { + return template.ID == portainer.TemplateID(id) + }) + + if templateIdx == -1 { + return httperror.NotFound("Unable to find a template with the specified identifier", nil) + } + + template := templatesResponse.Templates[templateIdx] + + if template.Type == portainer.ContainerTemplate { + return httperror.BadRequest("Invalid template type", nil) + } + + if template.StackFile != "" { + return response.JSON(w, fileResponse{FileContent: template.StackFile}) + } + + if template.Repository.StackFile == "" || template.Repository.URL == "" { + return httperror.BadRequest("Invalid template configuration", nil) + } + + projectPath, err := handler.FileService.GetTemporaryPath() + if err != nil { + return httperror.InternalServerError("Unable to create temporary folder", err) + } + + defer handler.cleanUp(projectPath) + + if err := handler.GitService.CloneRepository( + context.TODO(), + projectPath, + template.Repository.URL, + "", + "", + "", + false, + ); err != nil { + return httperror.InternalServerError("Unable to clone git repository", err) + } + + fileContent, err := handler.FileService.GetFileContent(projectPath, template.Repository.StackFile) + if err != nil { + return httperror.InternalServerError("Failed loading file content", err) + } + + return response.JSON(w, fileResponse{FileContent: string(fileContent)}) +} + +func (handler *Handler) cleanUp(projectPath string) { + if err := handler.FileService.RemoveDirectory(projectPath); err != nil { + log.Debug().Err(err).Msg("HTTP error: unable to cleanup stack creation") + } +} diff --git a/api/http/handler/templates/template_list.go b/api/http/handler/templates/template_list.go new file mode 100644 index 0000000..472cb61 --- /dev/null +++ b/api/http/handler/templates/template_list.go @@ -0,0 +1,28 @@ +package templates + +import ( + "net/http" + + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +// @id TemplateList +// @summary List available templates +// @description List available templates. +// @description **Access policy**: authenticated +// @tags templates +// @security ApiKeyAuth +// @security jwt +// @produce json +// @success 200 {object} listResponse "Success" +// @failure 500 "Server error" +// @router /templates [get] +func (handler *Handler) templateList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + templates, httpErr := handler.fetchTemplates() + if httpErr != nil { + return httpErr + } + + return response.JSON(w, templates) +} diff --git a/api/http/handler/templates/utils_fetch_templates.go b/api/http/handler/templates/utils_fetch_templates.go new file mode 100644 index 0000000..e9802d4 --- /dev/null +++ b/api/http/handler/templates/utils_fetch_templates.go @@ -0,0 +1,56 @@ +package templates + +import ( + "net/http" + + portainer "github.com/portainer/portainer/api" + libclient "github.com/portainer/portainer/pkg/libhttp/client" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/rs/zerolog/log" + "github.com/segmentio/encoding/json" +) + +type listResponse struct { + Version string `json:"version"` + Templates []portainer.Template `json:"templates"` +} + +func (handler *Handler) fetchTemplates() (*listResponse, *httperror.HandlerError) { + settings, err := handler.DataStore.Settings().Settings() + if err != nil { + return nil, httperror.InternalServerError("Unable to retrieve settings from the database", err) + } + + templatesURL := settings.TemplatesURL + if templatesURL == "" { + templatesURL = portainer.DefaultTemplatesURL + } + + var body *listResponse + if err := libclient.ExternalRequestDisabled(templatesURL); err != nil { + if templatesURL == portainer.DefaultTemplatesURL { + log.Debug().Err(err).Msg("External request disabled: Default templates") + return body, nil + } + } + + resp, err := http.Get(templatesURL) + if err != nil { + return nil, httperror.InternalServerError("Unable to retrieve templates via the network", err) + } + defer func() { + if err := resp.Body.Close(); err != nil { + log.Warn().Err(err).Msg("Failed to close templates response body") + } + }() + + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return nil, httperror.InternalServerError("Unable to parse template file", err) + } + + for i := range body.Templates { + body.Templates[i].ID = portainer.TemplateID(i + 1) + } + return body, nil + +} diff --git a/api/http/handler/upload/handler.go b/api/http/handler/upload/handler.go new file mode 100644 index 0000000..d23e58a --- /dev/null +++ b/api/http/handler/upload/handler.go @@ -0,0 +1,27 @@ +package upload + +import ( + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/security" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + + "github.com/gorilla/mux" +) + +// Handler is the HTTP handler used to handle upload operations. +type Handler struct { + *mux.Router + FileService portainer.FileService +} + +// NewHandler creates a handler to manage upload operations. +func NewHandler(bouncer security.BouncerService) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + } + h.Handle("/upload/tls/{certificate:(?:ca|cert|key)}", + bouncer.AdminAccess(httperror.LoggerHandler(h.uploadTLS))).Methods(http.MethodPost) + return h +} diff --git a/api/http/handler/upload/upload_tls.go b/api/http/handler/upload/upload_tls.go new file mode 100644 index 0000000..b25f2d5 --- /dev/null +++ b/api/http/handler/upload/upload_tls.go @@ -0,0 +1,63 @@ +package upload + +import ( + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/filesystem" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +// @id UploadTLS +// @summary Upload TLS files +// @description Use this environment(endpoint) to upload TLS files. +// @description **Access policy**: administrator +// @tags upload +// @security ApiKeyAuth +// @security jwt +// @accept multipart/form-data +// @produce json +// @param certificate path string true "TLS file type. Valid values are 'ca', 'cert' or 'key'." Enums(ca,cert,key) +// @param folder formData string true "Folder where the TLS file will be stored. Will be created if not existing" +// @param file formData file true "The file to upload" +// @success 204 "Success" +// @failure 400 "Invalid request" +// @failure 500 "Server error" +// @router /upload/tls/{certificate} [post] +func (handler *Handler) uploadTLS(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + certificate, err := request.RetrieveRouteVariableValue(r, "certificate") + if err != nil { + return httperror.BadRequest("Invalid certificate route variable", err) + } + + folder, err := request.RetrieveMultiPartFormValue(r, "folder", false) + if err != nil { + return httperror.BadRequest("Invalid query parameter: folder", err) + } + + file, _, err := request.RetrieveMultiPartFormFile(r, "file") + if err != nil { + return httperror.BadRequest("Invalid certificate file. Ensure that the certificate file is uploaded correctly", err) + } + + var fileType portainer.TLSFileType + switch certificate { + case "ca": + fileType = portainer.TLSFileCA + case "cert": + fileType = portainer.TLSFileCert + case "key": + fileType = portainer.TLSFileKey + default: + return httperror.BadRequest("Invalid certificate route value. Value must be one of: ca, cert or key", filesystem.ErrUndefinedTLSFileType) + } + + _, err = handler.FileService.StoreTLSFileFromBytes(folder, fileType, file) + if err != nil { + return httperror.InternalServerError("Unable to persist certificate file on disk", err) + } + + return response.Empty(w) +} diff --git a/api/http/handler/users/admin_check.go b/api/http/handler/users/admin_check.go new file mode 100644 index 0000000..01d7b91 --- /dev/null +++ b/api/http/handler/users/admin_check.go @@ -0,0 +1,31 @@ +package users + +import ( + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices/errors" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +// @id UserAdminCheck +// @summary Check administrator account existence +// @description Check if an administrator account exists in the database. +// @description **Access policy**: public +// @tags users +// @success 204 "Success" +// @failure 404 "User not found" +// @router /users/admin/check [get] +func (handler *Handler) adminCheck(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + users, err := handler.DataStore.User().UsersByRole(portainer.AdministratorRole) + if err != nil { + return httperror.InternalServerError("Unable to retrieve users from the database", err) + } + + if len(users) == 0 { + return httperror.NotFound("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 new file mode 100644 index 0000000..dd31f74 --- /dev/null +++ b/api/http/handler/users/admin_init.go @@ -0,0 +1,90 @@ +package users + +import ( + "errors" + "net/http" + "strings" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/security/setuptoken" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +type adminInitPayload struct { + // Username for the admin user + Username string `validate:"required" example:"admin"` + // Password for the admin user + Password string `validate:"required" example:"admin-password"` +} + +func (payload *adminInitPayload) Validate(r *http.Request) error { + if len(payload.Username) == 0 || strings.Contains(payload.Username, " ") { + return errors.New("Invalid username. Must not contain any whitespace") + } + if len(payload.Password) == 0 { + return errors.New("Invalid password") + } + return nil +} + +// @id UserAdminInit +// @summary Initialize administrator account +// @description Initialize the 'admin' user account. +// @description **Access policy**: public (requires the X-Setup-Token header on an uninitialized instance unless --no-setup-token is set) +// @tags users +// @accept json +// @produce json +// @param X-Setup-Token header string false "Setup token (required when instance is uninitialized and --no-setup-token is not set)" +// @param body body adminInitPayload true "User details" +// @success 200 {object} portainer.User "Success" +// @failure 400 "Invalid request" +// @failure 403 "Access denied - invalid or missing setup token" +// @failure 409 "Admin user already initialized" +// @failure 500 "Server error" +// @router /users/admin/init [post] +func (handler *Handler) adminInit(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + if err := setuptoken.Validate(r, handler.SetupToken); err != nil { + return err + } + + var payload adminInitPayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + users, err := handler.DataStore.User().UsersByRole(portainer.AdministratorRole) + if err != nil { + return httperror.InternalServerError("Unable to retrieve users from the database", err) + } + + if len(users) != 0 { + return httperror.Conflict("Unable to create administrator user", errAdminAlreadyInitialized) + } + + if !handler.passwordStrengthChecker.Check(payload.Password) { + return httperror.BadRequest("Password does not meet the requirements", nil) + } + + user := &portainer.User{ + Username: payload.Username, + Role: portainer.AdministratorRole, + } + + user.Password, err = handler.CryptoService.Hash(payload.Password) + if err != nil { + return httperror.InternalServerError("Unable to hash user password", errCryptoHashFailure) + } + + err = handler.DataStore.User().Create(user) + if err != nil { + return httperror.InternalServerError("Unable to persist user inside the database", err) + } + + // After the admin user is created, we can notify the endpoint initialization process + handler.AdminCreationDone <- struct{}{} + + return response.JSON(w, user) +} diff --git a/api/http/handler/users/admin_init_test.go b/api/http/handler/users/admin_init_test.go new file mode 100644 index 0000000..6269149 --- /dev/null +++ b/api/http/handler/users/admin_init_test.go @@ -0,0 +1,65 @@ +package users + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/portainer/portainer/api/apikey" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/http/security/setuptoken" + "github.com/portainer/portainer/api/internal/testhelpers" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newAdminInitHandler(t *testing.T) *Handler { + t.Helper() + _, store := datastore.MustNewTestStore(t, true, false) + rateLimiter := security.NewRateLimiter(10, 1*time.Second, 1*time.Hour) + apiKeyService := apikey.NewAPIKeyService(store.APIKeyRepository(), store.User()) + h := NewHandler(testhelpers.NewTestRequestBouncer(), rateLimiter, apiKeyService, mockPasswordStrengthChecker{}) + h.DataStore = store + h.CryptoService = testhelpers.NewCryptoService() + h.AdminCreationDone = make(chan struct{}, 1) + return h +} + +func Test_adminInit_setupTokenGate(t *testing.T) { + t.Parallel() + + t.Run("403 without token header", func(t *testing.T) { + handler := newAdminInitHandler(t) + handler.SetupToken = "secret-token" + body := strings.NewReader(`{"Username":"admin","Password":"abcdefgh12"}`) + r := httptest.NewRequest(http.MethodPost, "/users/admin/init", body) + err := handler.adminInit(httptest.NewRecorder(), r) + require.NotNil(t, err) + assert.Equal(t, http.StatusForbidden, err.StatusCode) + }) + + t.Run("403 with wrong token", func(t *testing.T) { + handler := newAdminInitHandler(t) + handler.SetupToken = "secret-token" + body := strings.NewReader(`{"Username":"admin","Password":"abcdefgh12"}`) + r := httptest.NewRequest(http.MethodPost, "/users/admin/init", body) + r.Header.Set(setuptoken.HeaderName, "wrong") + err := handler.adminInit(httptest.NewRecorder(), r) + require.NotNil(t, err) + assert.Equal(t, http.StatusForbidden, err.StatusCode) + }) + + t.Run("succeeds with correct token", func(t *testing.T) { + handler := newAdminInitHandler(t) + handler.SetupToken = "secret-token" + body := strings.NewReader(`{"Username":"admin","Password":"abcdefgh12"}`) + r := httptest.NewRequest(http.MethodPost, "/users/admin/init", body) + r.Header.Set(setuptoken.HeaderName, "secret-token") + err := handler.adminInit(httptest.NewRecorder(), r) + assert.Nil(t, err) + }) +} diff --git a/api/http/handler/users/handler.go b/api/http/handler/users/handler.go new file mode 100644 index 0000000..1c2d6eb --- /dev/null +++ b/api/http/handler/users/handler.go @@ -0,0 +1,88 @@ +package users + +import ( + "errors" + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/apikey" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/http/security" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + + "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 = "" +} + +// Handler is the HTTP handler used to handle user operations. +type Handler struct { + *mux.Router + bouncer security.BouncerService + apiKeyService apikey.APIKeyService + DataStore dataservices.DataStore + CryptoService portainer.CryptoService + passwordStrengthChecker security.PasswordStrengthChecker + AdminCreationDone chan<- struct{} + FileService portainer.FileService + SetupToken string +} + +// NewHandler creates a handler to manage user operations. +func NewHandler(bouncer security.BouncerService, rateLimiter *security.RateLimiter, apiKeyService apikey.APIKeyService, passwordStrengthChecker security.PasswordStrengthChecker) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + bouncer: bouncer, + apiKeyService: apiKeyService, + passwordStrengthChecker: passwordStrengthChecker, + } + + adminRouter := h.NewRoute().Subrouter() + adminRouter.Use(bouncer.AdminAccess) + + teamLeaderRouter := h.NewRoute().Subrouter() + teamLeaderRouter.Use(bouncer.TeamLeaderAccess) + + authenticatedRouter := h.NewRoute().Subrouter() + authenticatedRouter.Use(bouncer.AuthenticatedAccess) + + restrictedRouter := h.NewRoute().Subrouter() + restrictedRouter.Use(bouncer.RestrictedAccess) + + publicRouter := h.NewRoute().Subrouter() + publicRouter.Use(bouncer.PublicAccess) + + adminRouter.Handle("/users", httperror.LoggerHandler(h.userCreate)).Methods(http.MethodPost) + restrictedRouter.Handle("/users", httperror.LoggerHandler(h.userList)).Methods(http.MethodGet) + + authenticatedRouter.Handle("/users/me", httperror.LoggerHandler(h.userInspectMe)).Methods(http.MethodGet) + restrictedRouter.Handle("/users/{id}", httperror.LoggerHandler(h.userInspect)).Methods(http.MethodGet) + authenticatedRouter.Handle("/users/{id}", httperror.LoggerHandler(h.userUpdate)).Methods(http.MethodPut) + adminRouter.Handle("/users/{id}", httperror.LoggerHandler(h.userDelete)).Methods(http.MethodDelete) + restrictedRouter.Handle("/users/{id}/tokens", httperror.LoggerHandler(h.userGetAccessTokens)).Methods(http.MethodGet) + restrictedRouter.Handle("/users/{id}/tokens", rateLimiter.LimitAccess(httperror.LoggerHandler(h.userCreateAccessToken))).Methods(http.MethodPost) + restrictedRouter.Handle("/users/{id}/tokens/{keyID}", httperror.LoggerHandler(h.userRemoveAccessToken)).Methods(http.MethodDelete) + restrictedRouter.Handle("/users/{id}/memberships", httperror.LoggerHandler(h.userMemberships)).Methods(http.MethodGet) + restrictedRouter.Handle("/users/{id}/effective-access", httperror.LoggerHandler(h.userEffectiveAccess)).Methods(http.MethodGet) + authenticatedRouter.Handle("/users/{id}/passwd", rateLimiter.LimitAccess(httperror.LoggerHandler(h.userUpdatePassword))).Methods(http.MethodPut) + + publicRouter.Handle("/users/admin/check", httperror.LoggerHandler(h.adminCheck)).Methods(http.MethodGet) + publicRouter.Handle("/users/admin/init", httperror.LoggerHandler(h.adminInit)).Methods(http.MethodPost) + + // Helm repositories + authenticatedRouter.Handle("/users/{id}/helm/repositories", httperror.LoggerHandler(h.userGetHelmRepos)).Methods(http.MethodGet) + authenticatedRouter.Handle("/users/{id}/helm/repositories", httperror.LoggerHandler(h.userCreateHelmRepo)).Methods(http.MethodPost) + authenticatedRouter.Handle("/users/{id}/helm/repositories/{repositoryID}", httperror.LoggerHandler(h.userDeleteHelmRepo)).Methods(http.MethodDelete) + + return h +} diff --git a/api/http/handler/users/user_create.go b/api/http/handler/users/user_create.go new file mode 100644 index 0000000..daa2f6c --- /dev/null +++ b/api/http/handler/users/user_create.go @@ -0,0 +1,111 @@ +package users + +import ( + "errors" + "net/http" + "strings" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +type userCreatePayload struct { + Username string `validate:"required" example:"bob"` + Password string `validate:"required" example:"cg9Wgky3"` + // User role (1 for administrator account and 2 for regular account) + Role int `validate:"required" enums:"1,2" example:"2"` +} + +func (payload *userCreatePayload) Validate(r *http.Request) error { + if len(payload.Username) == 0 || strings.Contains(payload.Username, " ") { + return errors.New("Invalid username. Must not contain any whitespace") + } + + if payload.Role != 1 && payload.Role != 2 { + return errors.New("Invalid role value. Value must be one of: 1 (administrator) or 2 (regular user)") + } + + return nil +} + +// @id UserCreate +// @summary Create a new user +// @description Create a new Portainer user. +// @description Only administrators can create users. +// @description **Access policy**: restricted +// @tags users +// @security ApiKeyAuth +// @security jwt +// @accept json +// @produce json +// @param body body userCreatePayload true "User details" +// @success 200 {object} portainer.User "Success" +// @failure 400 "Invalid request" +// @failure 403 "Permission denied" +// @failure 409 "User already exists" +// @failure 500 "Server error" +// @router /users [post] +func (handler *Handler) userCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload userCreatePayload + if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + var user *portainer.User + var err error + err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + user, err = handler.createUser(tx, payload) + return err + }) + + return response.TxResponse(w, user, err) +} + +func (handler *Handler) createUser(tx dataservices.DataStoreTx, payload userCreatePayload) (*portainer.User, error) { + user, err := tx.User().UserByUsername(payload.Username) + if err != nil && !tx.IsErrObjectNotFound(err) { + return nil, httperror.InternalServerError("Unable to retrieve users from the database", err) + } + + if user != nil { + return nil, httperror.Conflict("Another user with the same username already exists", errUserAlreadyExists) + } + + user = &portainer.User{ + Username: payload.Username, + Role: portainer.UserRole(payload.Role), + } + + settings, err := tx.Settings().Settings() + if err != nil { + return nil, httperror.InternalServerError("Unable to retrieve settings from the database", err) + } + + // When LDAP/OAuth is on, can only add users without password + if (settings.AuthenticationMethod == portainer.AuthenticationLDAP || settings.AuthenticationMethod == portainer.AuthenticationOAuth) && payload.Password != "" { + errMsg := "a user with password can not be created when authentication method is Oauth or LDAP" + return nil, httperror.BadRequest(errMsg, errors.New(errMsg)) + } + + if settings.AuthenticationMethod == portainer.AuthenticationInternal { + if !handler.passwordStrengthChecker.Check(payload.Password) { + return nil, httperror.BadRequest("Password does not meet the requirements", nil) + } + + user.Password, err = handler.CryptoService.Hash(payload.Password) + if err != nil { + return nil, httperror.InternalServerError("Unable to hash user password", errCryptoHashFailure) + } + } + + if err := tx.User().Create(user); err != nil { + return nil, httperror.InternalServerError("Unable to persist user inside the database", err) + } + + hideFields(user) + + return user, nil +} diff --git a/api/http/handler/users/user_create_access_token.go b/api/http/handler/users/user_create_access_token.go new file mode 100644 index 0000000..aa10f6f --- /dev/null +++ b/api/http/handler/users/user_create_access_token.go @@ -0,0 +1,133 @@ +package users + +import ( + "errors" + "fmt" + "net/http" + + portainer "github.com/portainer/portainer/api" + httperrors "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/api/http/security" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" + "github.com/portainer/portainer/pkg/validate" +) + +type userAccessTokenCreatePayload struct { + Password string `validate:"required" example:"password" json:"password"` + Description string `validate:"required" example:"github-api-key" json:"description"` +} + +func (payload *userAccessTokenCreatePayload) Validate(r *http.Request) error { + if len(payload.Description) == 0 { + return errors.New("invalid description: cannot be empty") + } + if validate.HasWhitespaceOnly(payload.Description) { + return errors.New("invalid description: cannot contain only whitespaces") + } + if validate.MinStringLength(payload.Description, 128) { + return errors.New("invalid description: cannot be longer than 128 characters") + } + return nil +} + +type accessTokenResponse struct { + RawAPIKey string `json:"rawAPIKey"` + APIKey portainer.APIKey `json:"apiKey"` +} + +// @id UserGenerateAPIKey +// @summary Generate an API key for a user +// @description Generates an API key for a user. +// @description Only the calling user can generate a token for themselves. +// @description Password is required only for internal authentication. +// @description **Access policy**: restricted +// @tags users +// @security jwt +// @accept json +// @produce json +// @param id path int true "User identifier" +// @param body body userAccessTokenCreatePayload true "details" +// @success 200 {object} accessTokenResponse "Success" +// @failure 400 "Invalid request" +// @failure 401 "Unauthorized" +// @failure 403 "Permission denied" +// @failure 404 "User not found" +// @failure 500 "Server error" +// @router /users/{id}/tokens [post] +func (handler *Handler) userCreateAccessToken(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + // specifically require Cookie auth for this endpoint since API-Key based auth is not supported + jwt, _ := handler.bouncer.CookieAuthLookup(r) + if jwt == nil { + jwt, _ = handler.bouncer.JWTAuthLookup(r) + } + + if jwt == nil { + return httperror.Unauthorized("Auth not supported", errors.New("Authentication required")) + } + + var payload userAccessTokenCreatePayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + userID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid user identifier route variable", err) + } + + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve user authentication token", err) + } + + if tokenData.ID != portainer.UserID(userID) { + return httperror.Forbidden("Permission denied to create user access token", httperrors.ErrUnauthorized) + } + + user, err := handler.DataStore.User().Read(portainer.UserID(userID)) + if err != nil { + return httperror.InternalServerError("Unable to find a user with the specified identifier inside the database", err) + } + + internalAuth, err := handler.usesInternalAuthentication(portainer.UserID(userID)) + if err != nil { + return httperror.InternalServerError("Unable to determine the authentication method", err) + } + + if internalAuth { + // Internal auth requires the password field and must not be empty + if len(payload.Password) == 0 { + return httperror.BadRequest("Invalid request payload", errors.New("invalid password: cannot be empty")) + } + + err = handler.CryptoService.CompareHashAndData(user.Password, payload.Password) + if err != nil { + return httperror.Forbidden("Current password doesn't match", errors.New("Current password does not match the password provided. Please try again")) + } + } + + rawAPIKey, apiKey, err := handler.apiKeyService.GenerateApiKey(*user, payload.Description) + if err != nil { + return httperror.InternalServerError("Internal Server Error", err) + } + + return response.JSONWithStatus(w, accessTokenResponse{rawAPIKey, *apiKey}, http.StatusOK) +} + +func (handler *Handler) usesInternalAuthentication(userid portainer.UserID) (bool, error) { + // userid 1 is the admin user and always uses internal auth + if userid == 1 { + return true, nil + } + + // otherwise determine the auth method from the settings + settings, err := handler.DataStore.Settings().Settings() + if err != nil { + return false, fmt.Errorf("unable to retrieve the settings from the database: %w", err) + } + + return settings.AuthenticationMethod == portainer.AuthenticationInternal, nil +} diff --git a/api/http/handler/users/user_create_access_token_test.go b/api/http/handler/users/user_create_access_token_test.go new file mode 100644 index 0000000..56b1121 --- /dev/null +++ b/api/http/handler/users/user_create_access_token_test.go @@ -0,0 +1,146 @@ +package users + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/internal/testhelpers" + + "github.com/segmentio/encoding/json" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_userCreateAccessToken(t *testing.T) { + t.Parallel() + is := assert.New(t) + + _, store := datastore.MustNewTestStore(t, true, true) + + // create admin and standard user(s) + adminUser := &portainer.User{ID: 1, Password: "password", Username: "admin", Role: portainer.AdministratorRole} + err := store.User().Create(adminUser) + require.NoError(t, err, "error creating admin user") + + user := &portainer.User{ID: 2, Username: "standard", Role: portainer.StandardUserRole} + err = store.User().Create(user) + require.NoError(t, err, "error creating user") + + h, jwtService, apiKeyService := newTestHandler(t, store) + h.CryptoService = testhelpers.NewCryptoService() + + // generate standard and admin user tokens + adminJWT, _, _ := jwtService.GenerateToken(&portainer.TokenData{ID: adminUser.ID, Username: adminUser.Username, Role: adminUser.Role}) + jwt, _, _ := jwtService.GenerateToken(&portainer.TokenData{ID: user.ID, Username: user.Username, Role: user.Role}) + + t.Run("standard user successfully generates API key", func(t *testing.T) { + data := userAccessTokenCreatePayload{Password: "password", Description: "test-token"} + payload, err := json.Marshal(data) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodPost, "/users/2/tokens", bytes.NewBuffer(payload)) + testhelpers.AddTestSecurityCookie(req, jwt) + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + is.Equal(http.StatusOK, rr.Code) + + body, err := io.ReadAll(rr.Body) + require.NoError(t, err, "ReadAll should not return error") + + var resp accessTokenResponse + err = json.Unmarshal(body, &resp) + require.NoError(t, err, "response should be json") + is.Equal(data.Description, resp.APIKey.Description) + is.NotEmpty(resp.RawAPIKey) + }) + + t.Run("admin cannot generate API key for standard user", func(t *testing.T) { + data := userAccessTokenCreatePayload{Password: "password", Description: "test-token-admin"} + payload, err := json.Marshal(data) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodPost, "/users/2/tokens", bytes.NewBuffer(payload)) + testhelpers.AddTestSecurityCookie(req, adminJWT) + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + is.Equal(http.StatusForbidden, rr.Code) + + _, err = io.ReadAll(rr.Body) + require.NoError(t, err, "ReadAll should not return error") + }) + + t.Run("endpoint cannot generate api-key using api-key auth", func(t *testing.T) { + rawAPIKey, _, err := apiKeyService.GenerateApiKey(*user, "test-api-key") + require.NoError(t, err) + + data := userAccessTokenCreatePayload{Password: "password", Description: "test-token-fails"} + payload, err := json.Marshal(data) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodPost, "/users/2/tokens", bytes.NewBuffer(payload)) + req.Header.Add("x-api-key", rawAPIKey) + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + is.Equal(http.StatusUnauthorized, rr.Code) + + body, err := io.ReadAll(rr.Body) + require.NoError(t, err, "ReadAll should not return error") + is.JSONEq(`{"message":"Auth not supported","details":"Authentication required"}`, string(body)) + }) +} + +func Test_userAccessTokenCreatePayload(t *testing.T) { + t.Parallel() + tests := []struct { + payload userAccessTokenCreatePayload + shouldFail bool + }{ + { + payload: userAccessTokenCreatePayload{Password: "password", Description: "test-token"}, + shouldFail: false, + }, + { + payload: userAccessTokenCreatePayload{Password: "password", Description: ""}, + shouldFail: true, + }, + { + payload: userAccessTokenCreatePayload{Password: "password", Description: "test token"}, + shouldFail: false, + }, + { + payload: userAccessTokenCreatePayload{Password: "password", Description: "test-token "}, + shouldFail: false, + }, + { + payload: userAccessTokenCreatePayload{Password: "password", Description: ` +this string is longer than 128 characters and hence this will fail. +this string is longer than 128 characters and hence this will fail. +this string is longer than 128 characters and hence this will fail. +this string is longer than 128 characters and hence this will fail. +this string is longer than 128 characters and hence this will fail. +this string is longer than 128 characters and hence this will fail. +`}, + shouldFail: true, + }, + } + + for _, test := range tests { + err := test.payload.Validate(nil) + if test.shouldFail { + require.Error(t, err) + } else { + require.NoError(t, err) + } + } +} diff --git a/api/http/handler/users/user_create_test.go b/api/http/handler/users/user_create_test.go new file mode 100644 index 0000000..31c1809 --- /dev/null +++ b/api/http/handler/users/user_create_test.go @@ -0,0 +1,99 @@ +package users + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/apikey" + "github.com/portainer/portainer/api/crypto" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/jwt" + + "github.com/stretchr/testify/require" + "golang.org/x/sync/errgroup" +) + +type mockPasswordStrengthChecker struct{} + +func (m mockPasswordStrengthChecker) Check(string) bool { + return true +} + +func newTestHandler(t *testing.T, store *datastore.Store) (*Handler, *jwt.Service, apikey.APIKeyService) { + t.Helper() + + jwtService, err := jwt.NewService("1h", store) + require.NoError(t, err) + + apiKeyService := apikey.NewAPIKeyService(store.APIKeyRepository(), store.User()) + requestBouncer := security.NewRequestBouncer(t.Context(), store, jwtService, apiKeyService) + rateLimiter := security.NewRateLimiter(10, 1*time.Second, 1*time.Hour) + passwordChecker := security.NewPasswordStrengthChecker(store.SettingsService) + + h := NewHandler(requestBouncer, rateLimiter, apiKeyService, passwordChecker) + h.DataStore = store + + return h, jwtService, apiKeyService +} + +func TestConcurrentUserCreation(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, true, false) + + h := &Handler{ + passwordStrengthChecker: mockPasswordStrengthChecker{}, + CryptoService: crypto.Service{}, + DataStore: store, + } + + ucp := userCreatePayload{ + Username: "portainer", + Password: "password", + Role: int(portainer.AdministratorRole), + } + + m, err := json.Marshal(ucp) + require.NoError(t, err) + + errGroup := &errgroup.Group{} + + n := 100 + + for range n { + errGroup.Go(func() error { + req, err := http.NewRequest(http.MethodPost, "/users", bytes.NewReader(m)) + if err != nil { + return err + } + + if err := h.userCreate(httptest.NewRecorder(), req); err != nil { + return err + } + + return nil + }) + } + + err = errGroup.Wait() + require.Error(t, err) + + users, err := store.User().ReadAll() + require.NotEmpty(t, users) + require.NoError(t, err) + + userCreated := false + for _, u := range users { + if u.Username == ucp.Username { + require.False(t, userCreated) + userCreated = true + } + } + + require.True(t, userCreated) +} diff --git a/api/http/handler/users/user_delete.go b/api/http/handler/users/user_delete.go new file mode 100644 index 0000000..25c588d --- /dev/null +++ b/api/http/handler/users/user_delete.go @@ -0,0 +1,110 @@ +package users + +import ( + "errors" + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/security" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +// @id UserDelete +// @summary Remove a user +// @description Remove a user. +// @description **Access policy**: administrator +// @tags users +// @security ApiKeyAuth +// @security jwt +// @produce json +// @param id path int true "User identifier" +// @success 204 "Success" +// @failure 400 "Invalid request" +// @failure 403 "Permission denied" +// @failure 404 "User not found" +// @failure 500 "Server error" +// @router /users/{id} [delete] +func (handler *Handler) userDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + userID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid user identifier route variable", err) + } + + if userID == 1 { + return httperror.Forbidden("Cannot remove the initial admin account", errors.New("Cannot remove the initial admin account")) + } + + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve user authentication token", err) + } + + if tokenData.ID == portainer.UserID(userID) { + return httperror.Forbidden("Cannot remove your own user account. Contact another administrator", errAdminCannotRemoveSelf) + } + + user, err := handler.DataStore.User().Read(portainer.UserID(userID)) + if handler.DataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find a user with the specified identifier inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find a user with the specified identifier inside the database", err) + } + + if user.Role == portainer.AdministratorRole { + return handler.deleteAdminUser(w, user) + } + + return handler.deleteUser(w, user) +} + +func (handler *Handler) deleteAdminUser(w http.ResponseWriter, user *portainer.User) *httperror.HandlerError { + if user.Password == "" { + return handler.deleteUser(w, user) + } + + users, err := handler.DataStore.User().ReadAll() + if err != nil { + return httperror.InternalServerError("Unable to retrieve users from the database", err) + } + + localAdminCount := 0 + for _, u := range users { + if u.Role == portainer.AdministratorRole && u.Password != "" { + localAdminCount++ + } + } + + if localAdminCount < 2 { + return httperror.InternalServerError("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.DataStore.User().Delete(user.ID) + if err != nil { + return httperror.InternalServerError("Unable to remove user from the database", err) + } + + err = handler.DataStore.TeamMembership().DeleteTeamMembershipByUserID(user.ID) + if err != nil { + return httperror.InternalServerError("Unable to remove user memberships from the database", err) + } + + // Remove all of the users persisted API keys + apiKeys, err := handler.apiKeyService.GetAPIKeys(user.ID) + if err != nil { + return httperror.InternalServerError("Unable to retrieve user API keys from the database", err) + } + for _, k := range apiKeys { + err = handler.apiKeyService.DeleteAPIKey(k.ID) + if err != nil { + return httperror.InternalServerError("Unable to remove user API key from the database", err) + } + } + + return response.Empty(w) +} diff --git a/api/http/handler/users/user_delete_test.go b/api/http/handler/users/user_delete_test.go new file mode 100644 index 0000000..28adc11 --- /dev/null +++ b/api/http/handler/users/user_delete_test.go @@ -0,0 +1,47 @@ +package users + +import ( + "net/http" + "net/http/httptest" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/datastore" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_deleteUserRemovesAccessTokens(t *testing.T) { + t.Parallel() + is := assert.New(t) + + _, store := datastore.MustNewTestStore(t, true, true) + + // create standard user + user := &portainer.User{ID: 2, Username: "standard", Role: portainer.StandardUserRole} + err := store.User().Create(user) + require.NoError(t, err, "error creating user") + + h, _, apiKeyService := newTestHandler(t, store) + + t.Run("standard user deletion removes all associated access tokens", func(t *testing.T) { + _, _, err := apiKeyService.GenerateApiKey(*user, "test-user-token") + require.NoError(t, err) + + keys, err := apiKeyService.GetAPIKeys(user.ID) + require.NoError(t, err) + is.Len(keys, 1) + + rr := httptest.NewRecorder() + + handleErr := h.deleteUser(rr, user) + require.Nil(t, handleErr) + + is.Equal(http.StatusNoContent, rr.Code) + + keys, err = apiKeyService.GetAPIKeys(user.ID) + require.NoError(t, err) + is.Empty(keys) + }) +} diff --git a/api/http/handler/users/user_effective_access.go b/api/http/handler/users/user_effective_access.go new file mode 100644 index 0000000..2bf43b5 --- /dev/null +++ b/api/http/handler/users/user_effective_access.go @@ -0,0 +1,169 @@ +package users + +import ( + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/pkg/authorization" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +// AccessLocation describes which part of the access model granted the user +// their effective role on an environment. The frontend maps these enum values +// to display strings. +type AccessLocation string + +const ( + AccessLocationEnvironment AccessLocation = "environment" + AccessLocationEnvironmentGroup AccessLocation = "environmentGroup" +) + +// @id UserEffectiveAccessInspect +// @summary Inspect a user's effective access on every environment +// @description Returns the resolved role for each environment the user can access, +// @description following the policy precedence used by the access viewer +// @description (user-endpoint, user-group, team-endpoint, team-group). +// @description Environments where the user has no role are omitted. +// @description **Access policy**: restricted +// @tags users +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "User identifier" +// @success 200 {array} EffectiveAccessEntry "Success" +// @failure 400 "Invalid request" +// @failure 403 "Permission denied" +// @failure 404 "User not found" +// @failure 500 "Server error" +// @router /users/{id}/effective-access [get] +func (handler *Handler) userEffectiveAccess(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + userID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid user identifier route variable", err) + } + + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve user authentication token", err) + } + if tokenData.Role != portainer.AdministratorRole && tokenData.ID != portainer.UserID(userID) { + return httperror.Forbidden("Permission denied to inspect another user's effective access", errors.ErrUnauthorized) + } + + entries := make([]EffectiveAccessEntry, 0) + if err := handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error { + user, err := tx.User().Read(portainer.UserID(userID)) + if handler.DataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find a user with the specified identifier inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find a user with the specified identifier inside the database", err) + } + + endpoints, err := tx.Endpoint().ReadAll() + if err != nil { + return httperror.InternalServerError("Unable to retrieve environments from the database", err) + } + + groups, err := tx.EndpointGroup().ReadAll() + if err != nil { + return httperror.InternalServerError("Unable to retrieve environment groups from the database", err) + } + + roles, err := tx.Role().ReadAll() + if err != nil { + return httperror.InternalServerError("Unable to retrieve roles from the database", err) + } + + teams, err := tx.Team().ReadAll() + if err != nil { + return httperror.InternalServerError("Unable to retrieve teams from the database", err) + } + + memberships, err := tx.TeamMembership().TeamMembershipsByUserID(user.ID) + if err != nil { + return httperror.InternalServerError("Unable to retrieve team memberships from the database", err) + } + + groupsByID := make(map[portainer.EndpointGroupID]portainer.EndpointGroup, len(groups)) + for _, g := range groups { + groupsByID[g.ID] = g + } + + teamsByID := make(map[portainer.TeamID]portainer.Team, len(teams)) + for _, t := range teams { + teamsByID[t.ID] = t + } + + for i := range endpoints { + endpoint := &endpoints[i] + + access := authorization.ResolveUserEndpointAccess(authorization.ResolverInput{ + User: user, + Endpoint: endpoint, + EndpointGroup: groupsByID[endpoint.GroupID], + Roles: roles, + UserMemberships: memberships, + }) + if access == nil { + continue + } + + entries = append(entries, buildEffectiveAccessEntry(access, endpoint, groupsByID, teamsByID)) + } + return nil + }); err != nil { + return response.TxErrorResponse(err) + } + + return response.JSON(w, entries) +} + +func buildEffectiveAccessEntry( + access *authorization.ResolvedAccess, + endpoint *portainer.Endpoint, + groupsByID map[portainer.EndpointGroupID]portainer.EndpointGroup, + teamsByID map[portainer.TeamID]portainer.Team, +) EffectiveAccessEntry { + entry := EffectiveAccessEntry{ + EndpointID: endpoint.ID, + EndpointName: endpoint.Name, + RoleID: access.Role.ID, + RoleName: access.Role.Name, + RolePriority: access.Role.Priority, + AccessLocation: AccessLocationEnvironment, + } + + if access.Source.GroupID != 0 { + entry.GroupID = access.Source.GroupID + entry.AccessLocation = AccessLocationEnvironmentGroup + if g, ok := groupsByID[access.Source.GroupID]; ok { + entry.GroupName = g.Name + } + } + + if access.Source.TeamID != 0 { + entry.TeamID = access.Source.TeamID + if t, ok := teamsByID[access.Source.TeamID]; ok { + entry.TeamName = t.Name + } + } + + return entry +} + +type EffectiveAccessEntry struct { + EndpointID portainer.EndpointID `json:"endpointId"` + EndpointName string `json:"endpointName"` + RoleID portainer.RoleID `json:"roleId"` + RoleName string `json:"roleName"` + RolePriority int `json:"rolePriority"` + GroupID portainer.EndpointGroupID `json:"groupId,omitempty"` + GroupName string `json:"groupName,omitempty"` + TeamID portainer.TeamID `json:"teamId,omitempty"` + TeamName string `json:"teamName,omitempty"` + AccessLocation AccessLocation `json:"accessLocation"` +} diff --git a/api/http/handler/users/user_effective_access_test.go b/api/http/handler/users/user_effective_access_test.go new file mode 100644 index 0000000..63dc60e --- /dev/null +++ b/api/http/handler/users/user_effective_access_test.go @@ -0,0 +1,105 @@ +package users + +import ( + "io" + "net/http" + "net/http/httptest" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/internal/testhelpers" + + "github.com/segmentio/encoding/json" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_userEffectiveAccess(t *testing.T) { + t.Parallel() + is := assert.New(t) + + _, store := datastore.MustNewTestStore(t, true, true) + + const ( + standardRoleID portainer.RoleID = 1 + helpdeskRoleID portainer.RoleID = 2 + teamID portainer.TeamID = 100 + groupID = portainer.EndpointGroupID(10) + envID = portainer.EndpointID(200) + ) + + require.NoError(t, store.Role().Create(&portainer.Role{ID: standardRoleID, Name: "Standard user", Priority: 1})) + require.NoError(t, store.Role().Create(&portainer.Role{ID: helpdeskRoleID, Name: "Helpdesk", Priority: 5})) + + require.NoError(t, store.EndpointGroup().Create(&portainer.EndpointGroup{ID: groupID, Name: "production"})) + require.NoError(t, store.Team().Create(&portainer.Team{ID: teamID, Name: "devs"})) + + adminUser := &portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole} + require.NoError(t, store.User().Create(adminUser)) + + targetUser := &portainer.User{ID: 2, Username: "alice", Role: portainer.StandardUserRole} + require.NoError(t, store.User().Create(targetUser)) + + otherUser := &portainer.User{ID: 3, Username: "bob", Role: portainer.StandardUserRole} + require.NoError(t, store.User().Create(otherUser)) + + require.NoError(t, store.TeamMembership().Create(&portainer.TeamMembership{ID: 1, UserID: targetUser.ID, TeamID: teamID, Role: portainer.TeamMember})) + + require.NoError(t, store.Endpoint().Create(&portainer.Endpoint{ + ID: envID, + Name: "env-1", + GroupID: groupID, + UserAccessPolicies: portainer.UserAccessPolicies{targetUser.ID: {RoleID: standardRoleID}}, + TeamAccessPolicies: portainer.TeamAccessPolicies{teamID: {RoleID: helpdeskRoleID}}, + })) + + h, jwtService, _ := newTestHandler(t, store) + + adminJWT, _, _ := jwtService.GenerateToken(&portainer.TokenData{ID: adminUser.ID, Username: adminUser.Username, Role: adminUser.Role}) + targetJWT, _, _ := jwtService.GenerateToken(&portainer.TokenData{ID: targetUser.ID, Username: targetUser.Username, Role: targetUser.Role}) + otherJWT, _, _ := jwtService.GenerateToken(&portainer.TokenData{ID: otherUser.ID, Username: otherUser.Username, Role: otherUser.Role}) + + doRequest := func(t *testing.T, jwt string, path string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodGet, path, nil) + testhelpers.AddTestSecurityCookie(req, jwt) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + return rr + } + + t.Run("admin can inspect another user", func(t *testing.T) { + rr := doRequest(t, adminJWT, "/users/2/effective-access") + is.Equal(http.StatusOK, rr.Code) + + body, err := io.ReadAll(rr.Body) + require.NoError(t, err) + + var resp []EffectiveAccessEntry + require.NoError(t, json.Unmarshal(body, &resp)) + is.Len(resp, 1) + is.Equal(envID, resp[0].EndpointID) + is.Equal(standardRoleID, resp[0].RoleID) + }) + + t.Run("user can inspect themselves", func(t *testing.T) { + rr := doRequest(t, targetJWT, "/users/2/effective-access") + is.Equal(http.StatusOK, rr.Code) + }) + + t.Run("non-admin cannot inspect another user", func(t *testing.T) { + rr := doRequest(t, otherJWT, "/users/2/effective-access") + is.Equal(http.StatusForbidden, rr.Code) + }) + + t.Run("returns 404 when target user does not exist", func(t *testing.T) { + rr := doRequest(t, adminJWT, "/users/9999/effective-access") + is.Equal(http.StatusNotFound, rr.Code) + }) + + t.Run("returns 400 when id is not numeric", func(t *testing.T) { + rr := doRequest(t, adminJWT, "/users/not-a-number/effective-access") + is.Equal(http.StatusBadRequest, rr.Code) + }) +} diff --git a/api/http/handler/users/user_get_access_tokens.go b/api/http/handler/users/user_get_access_tokens.go new file mode 100644 index 0000000..45a26a6 --- /dev/null +++ b/api/http/handler/users/user_get_access_tokens.go @@ -0,0 +1,68 @@ +package users + +import ( + "net/http" + + portainer "github.com/portainer/portainer/api" + httperrors "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/api/http/security" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +// @id UserGetAPIKeys +// @summary Get all API keys for a user +// @description Gets all API keys for a user. +// @description Only the calling user or admin can retrieve api-keys. +// @description **Access policy**: authenticated +// @tags users +// @security ApiKeyAuth +// @security jwt +// @produce json +// @param id path int true "User identifier" +// @success 200 {array} portainer.APIKey "Success" +// @failure 400 "Invalid request" +// @failure 403 "Permission denied" +// @failure 404 "User not found" +// @failure 500 "Server error" +// @router /users/{id}/tokens [get] +func (handler *Handler) userGetAccessTokens(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + userID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid user identifier route variable", err) + } + + user, err := handler.DataStore.User().Read(portainer.UserID(userID)) + if err != nil { + if handler.DataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find a user with the specified identifier inside the database", err) + } + return httperror.InternalServerError("Unable to find a user with the specified identifier inside the database", err) + } + + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve user authentication token", err) + } + + if tokenData.ID != portainer.UserID(userID) && (tokenData.Role != portainer.AdministratorRole || user.Role == portainer.AdministratorRole) { + return httperror.Forbidden("Permission denied to get user access tokens", httperrors.ErrUnauthorized) + } + + apiKeys, err := handler.apiKeyService.GetAPIKeys(portainer.UserID(userID)) + if err != nil { + return httperror.InternalServerError("Internal Server Error", err) + } + + for idx := range apiKeys { + hideAPIKeyFields(&apiKeys[idx]) + } + + return response.JSON(w, apiKeys) +} + +// hideAPIKeyFields remove the digest from the API key (it is not needed in the response) +func hideAPIKeyFields(apiKey *portainer.APIKey) { + apiKey.Digest = "" +} diff --git a/api/http/handler/users/user_get_access_tokens_test.go b/api/http/handler/users/user_get_access_tokens_test.go new file mode 100644 index 0000000..6ceb8c7 --- /dev/null +++ b/api/http/handler/users/user_get_access_tokens_test.go @@ -0,0 +1,126 @@ +package users + +import ( + "io" + "net/http" + "net/http/httptest" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/internal/testhelpers" + + "github.com/segmentio/encoding/json" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_userGetAccessTokens(t *testing.T) { + t.Parallel() + is := assert.New(t) + + _, store := datastore.MustNewTestStore(t, true, true) + + // create admin and standard user(s) + adminUser := &portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole} + err := store.User().Create(adminUser) + require.NoError(t, err, "error creating admin user") + + user := &portainer.User{ID: 2, Username: "standard", Role: portainer.StandardUserRole} + err = store.User().Create(user) + require.NoError(t, err, "error creating user") + + h, jwtService, apiKeyService := newTestHandler(t, store) + + // generate standard and admin user tokens + adminJWT, _, _ := jwtService.GenerateToken(&portainer.TokenData{ID: adminUser.ID, Username: adminUser.Username, Role: adminUser.Role}) + jwt, _, _ := jwtService.GenerateToken(&portainer.TokenData{ID: user.ID, Username: user.Username, Role: user.Role}) + + t.Run("standard user can successfully retrieve API key", func(t *testing.T) { + _, apiKey, err := apiKeyService.GenerateApiKey(*user, "test-get-token") + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodGet, "/users/2/tokens", nil) + testhelpers.AddTestSecurityCookie(req, jwt) + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + is.Equal(http.StatusOK, rr.Code) + + body, err := io.ReadAll(rr.Body) + require.NoError(t, err, "ReadAll should not return error") + + var resp []portainer.APIKey + err = json.Unmarshal(body, &resp) + require.NoError(t, err, "response should be list json") + + is.Len(resp, 1) + if len(resp) == 1 { + is.Empty(resp[0].Digest) + is.Equal(apiKey.ID, resp[0].ID) + is.Equal(apiKey.UserID, resp[0].UserID) + is.Equal(apiKey.Prefix, resp[0].Prefix) + is.Equal(apiKey.Description, resp[0].Description) + } + }) + + t.Run("admin can retrieve standard user API Key", func(t *testing.T) { + _, _, err := apiKeyService.GenerateApiKey(*user, "test-get-admin-token") + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodGet, "/users/2/tokens", nil) + testhelpers.AddTestSecurityCookie(req, adminJWT) + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + is.Equal(http.StatusOK, rr.Code) + + body, err := io.ReadAll(rr.Body) + require.NoError(t, err, "ReadAll should not return error") + + var resp []portainer.APIKey + err = json.Unmarshal(body, &resp) + require.NoError(t, err, "response should be list json") + + is.NotEmpty(resp) + }) + + t.Run("user can retrieve API Key using api-key auth", func(t *testing.T) { + rawAPIKey, _, err := apiKeyService.GenerateApiKey(*user, "test-api-key") + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodGet, "/users/2/tokens", nil) + req.Header.Add("x-api-key", rawAPIKey) + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + is.Equal(http.StatusOK, rr.Code) + + body, err := io.ReadAll(rr.Body) + require.NoError(t, err, "ReadAll should not return error") + + var resp []portainer.APIKey + err = json.Unmarshal(body, &resp) + require.NoError(t, err, "response should be list json") + + is.NotEmpty(resp) + }) +} + +func Test_hideAPIKeyFields(t *testing.T) { + t.Parallel() + apiKey := &portainer.APIKey{ + ID: 1, + UserID: 2, + Prefix: "abc", + Description: "test", + Digest: "", + } + + hideAPIKeyFields(apiKey) + + require.Empty(t, apiKey.Digest, "digest should be cleared when hiding api key fields") +} diff --git a/api/http/handler/users/user_helm_repos.go b/api/http/handler/users/user_helm_repos.go new file mode 100644 index 0000000..0bb37d0 --- /dev/null +++ b/api/http/handler/users/user_helm_repos.go @@ -0,0 +1,201 @@ +package users + +import ( + "net/http" + "strings" + + portainer "github.com/portainer/portainer/api" + httperrors "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/pkg/libhelm" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" + "github.com/portainer/portainer/pkg/libhttp/ssrf" + + "github.com/pkg/errors" +) + +type helmUserRepositoryResponse struct { + GlobalRepository string `json:"GlobalRepository"` + UserRepositories []portainer.HelmUserRepository `json:"UserRepositories"` +} + +type addHelmRepoUrlPayload struct { + URL string `json:"url"` +} + +func (p *addHelmRepoUrlPayload) Validate(r *http.Request) error { + if err := ssrf.CheckURL(r.Context(), p.URL); err != nil { + return err + } + + return libhelm.ValidateHelmRepositoryURL(p.URL, nil) +} + +// @id HelmUserRepositoryCreate +// @summary Create a user helm repository +// @description Create a user helm repository. +// @description **Access policy**: authenticated +// @tags helm +// @security ApiKeyAuth +// @security jwt +// @accept json +// @produce json +// @param id path int true "User identifier" +// @param payload body addHelmRepoUrlPayload true "Helm Repository" +// @success 200 {object} portainer.HelmUserRepository "Success" +// @failure 400 "Invalid request" +// @failure 403 "Permission denied" +// @failure 500 "Server error" +// @router /users/{id}/helm/repositories [post] +func (handler *Handler) userCreateHelmRepo(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + userIDEndpoint, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid user identifier route variable", err) + } + + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve user authentication token", err) + } + + userID := portainer.UserID(userIDEndpoint) + if tokenData.ID != userID { + return httperror.Forbidden("Couldn't create Helm repositories for another user", httperrors.ErrUnauthorized) + } + + p := new(addHelmRepoUrlPayload) + err = request.DecodeAndValidateJSONPayload(r, p) + if err != nil { + return httperror.BadRequest("Invalid Helm repository URL", err) + } + + // lowercase, remove trailing slash + p.URL = strings.TrimSuffix(strings.ToLower(p.URL), "/") + + records, err := handler.DataStore.HelmUserRepository().HelmUserRepositoryByUserID(userID) + if err != nil { + return httperror.InternalServerError("Unable to access the DataStore", err) + } + + // check if repo already exists - by doing case insensitive comparison + for _, record := range records { + if strings.EqualFold(record.URL, p.URL) { + errMsg := "Helm repo already registered for user" + return httperror.BadRequest(errMsg, errors.New(errMsg)) + } + } + + record := portainer.HelmUserRepository{ + UserID: userID, + URL: p.URL, + } + + err = handler.DataStore.HelmUserRepository().Create(&record) + if err != nil { + return httperror.InternalServerError("Unable to save a user Helm repository URL", err) + } + + return response.JSON(w, record) +} + +// @id HelmUserRepositoriesList +// @summary List a users helm repositories +// @description Inspect a user helm repositories. +// @description **Access policy**: authenticated +// @tags helm +// @security ApiKeyAuth +// @security jwt +// @produce json +// @param id path int true "User identifier" +// @success 200 {object} helmUserRepositoryResponse "Success" +// @failure 400 "Invalid request" +// @failure 403 "Permission denied" +// @failure 500 "Server error" +// @router /users/{id}/helm/repositories [get] +func (handler *Handler) userGetHelmRepos(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + userIDEndpoint, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid user identifier route variable", err) + } + + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve user authentication token", err) + } + + userID := portainer.UserID(userIDEndpoint) + if tokenData.ID != userID { + return httperror.Forbidden("Couldn't create Helm repositories for another user", httperrors.ErrUnauthorized) + } + + settings, err := handler.DataStore.Settings().Settings() + if err != nil { + return httperror.InternalServerError("Unable to retrieve settings from the database", err) + } + + userRepos, err := handler.DataStore.HelmUserRepository().HelmUserRepositoryByUserID(userID) + if err != nil { + return httperror.InternalServerError("Unable to get user Helm repositories", err) + } + + resp := helmUserRepositoryResponse{ + GlobalRepository: settings.HelmRepositoryURL, + UserRepositories: userRepos, + } + + return response.JSON(w, resp) +} + +// @id HelmUserRepositoryDelete +// @summary Delete a users helm repositoryies +// @description **Access policy**: authenticated +// @tags helm +// @security ApiKeyAuth +// @security jwt +// @produce json +// @param id path int true "User identifier" +// @param repositoryID path int true "Repository identifier" +// @success 204 "Success" +// @failure 400 "Invalid request" +// @failure 403 "Permission denied" +// @failure 500 "Server error" +// @router /users/{id}/helm/repositories/{repositoryID} [delete] +func (handler *Handler) userDeleteHelmRepo(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + userIDEndpoint, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid user identifier route variable", err) + } + + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve user authentication token", err) + } + + userID := portainer.UserID(userIDEndpoint) + if tokenData.ID != userID { + return httperror.Forbidden("Couldn't create Helm repositories for another user", httperrors.ErrUnauthorized) + } + + repositoryID, err := request.RetrieveNumericRouteVariableValue(r, "repositoryID") + if err != nil { + return httperror.BadRequest("Invalid user identifier route variable", err) + } + + userRepos, err := handler.DataStore.HelmUserRepository().HelmUserRepositoryByUserID(userID) + if err != nil { + return httperror.InternalServerError("Unable to get user Helm repositories", err) + } + + for _, repo := range userRepos { + if repo.ID == portainer.HelmUserRepositoryID(repositoryID) && repo.UserID == userID { + err = handler.DataStore.HelmUserRepository().Delete(portainer.HelmUserRepositoryID(repositoryID)) + if err != nil { + return httperror.InternalServerError("Unable to delete user Helm repository", err) + } + } + } + + return response.JSON(w, nil) +} diff --git a/api/http/handler/users/user_inspect.go b/api/http/handler/users/user_inspect.go new file mode 100644 index 0000000..f3308b6 --- /dev/null +++ b/api/http/handler/users/user_inspect.go @@ -0,0 +1,54 @@ +package users + +import ( + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/api/http/security" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +// @id UserInspect +// @summary Inspect a user +// @description Retrieve details about a user. +// @description User passwords are filtered out, and should never be accessible. +// @description **Access policy**: authenticated +// @tags users +// @security ApiKeyAuth +// @security jwt +// @produce json +// @param id path int true "User identifier" +// @success 200 {object} portainer.User "Success" +// @failure 400 "Invalid request" +// @failure 403 "Permission denied" +// @failure 404 "User not found" +// @failure 500 "Server error" +// @router /users/{id} [get] +func (handler *Handler) userInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + userID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid user identifier route variable", err) + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) + } + + if !securityContext.IsAdmin && securityContext.UserID != portainer.UserID(userID) { + return httperror.Forbidden("Permission denied inspect user", errors.ErrResourceAccessDenied) + } + + user, err := handler.DataStore.User().Read(portainer.UserID(userID)) + if handler.DataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find a user with the specified identifier inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find a user with the specified identifier inside the database", err) + } + + hideFields(user) + return response.JSON(w, user) +} diff --git a/api/http/handler/users/user_inspect_me.go b/api/http/handler/users/user_inspect_me.go new file mode 100644 index 0000000..a199fda --- /dev/null +++ b/api/http/handler/users/user_inspect_me.go @@ -0,0 +1,58 @@ +package users + +import ( + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/security" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +type CurrentUserInspectResponse struct { + *portainer.User + ForceChangePassword bool `json:"forceChangePassword"` +} + +// @id CurrentUserInspect +// @summary Inspect the current user user +// @description Retrieve details about the current user. +// @description User passwords are filtered out, and should never be accessible. +// @description **Access policy**: authenticated +// @tags users +// @security ApiKeyAuth +// @security jwt +// @produce json +// @success 200 {object} portainer.User "Success" +// @failure 400 "Invalid request" +// @failure 403 "Permission denied" +// @failure 404 "User not found" +// @failure 500 "Server error" +// @router /users/me [get] +func (handler *Handler) userInspectMe(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve user authentication token", err) + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) + } + + user, err := handler.DataStore.User().Read(securityContext.UserID) + if handler.DataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find a user with the specified identifier inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find a user with the specified identifier inside the database", err) + } + + hideFields(user) + return response.JSON( + w, + &CurrentUserInspectResponse{ + User: user, + ForceChangePassword: tokenData.ForceChangePassword, + }, + ) +} diff --git a/api/http/handler/users/user_list.go b/api/http/handler/users/user_list.go new file mode 100644 index 0000000..696a138 --- /dev/null +++ b/api/http/handler/users/user_list.go @@ -0,0 +1,105 @@ +package users + +import ( + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/security" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +type User struct { + ID portainer.UserID `json:"Id" example:"1"` + Username string `json:"Username" example:"bob"` + // User role (1 for administrator account and 2 for regular account) + Role portainer.UserRole `json:"Role" example:"1"` +} + +// @id UserList +// @summary List users +// @description List Portainer users. +// @description Non-administrator users will only be able to list other non-administrator user accounts. +// @description User passwords are filtered out, and should never be accessible. +// @description **Access policy**: restricted +// @tags users +// @security ApiKeyAuth +// @security jwt +// @produce json +// @param environmentId query int false "Identifier of the environment(endpoint) that will be used to filter the authorized users" +// @success 200 {array} portainer.User "Success" +// @failure 400 "Invalid request" +// @failure 500 "Server error" +// @router /users [get] +func (handler *Handler) userList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) + } + + if !securityContext.IsAdmin && !securityContext.IsTeamLeader { + return httperror.Forbidden("Permission denied to access users list", err) + } + + users, err := handler.DataStore.User().ReadAll() + if err != nil { + return httperror.InternalServerError("Unable to retrieve users from the database", err) + } + + availableUsers := security.FilterUsers(users, securityContext) + + endpointID, _ := request.RetrieveNumericQueryParameter(r, "environmentId", true) + if endpointID == 0 { + users := sanitizeUsers(availableUsers) + return response.JSON(w, users) + } + + // filter out users who do not have access to the specific endpoint + endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) + if err != nil { + return httperror.InternalServerError("Unable to retrieve endpoint from the database", err) + } + + endpointGroup, err := handler.DataStore.EndpointGroup().Read(endpoint.GroupID) + if err != nil { + return httperror.InternalServerError("Unable to retrieve environment groups from the database", err) + } + + canAccessEndpoint := make([]User, 0) + for _, user := range availableUsers { + // the users who have the endpoint authorization + if _, ok := user.EndpointAuthorizations[endpoint.ID]; ok { + canAccessEndpoint = append(canAccessEndpoint, sanitizeUser(user)) + continue + } + + // the user inherits the endpoint access from team or environment group + teamMemberships, err := handler.DataStore.TeamMembership().TeamMembershipsByUserID(user.ID) + if err != nil { + return httperror.InternalServerError("Unable to retrieve team membership from the database", err) + } + + if security.AuthorizedEndpointAccess(endpoint, endpointGroup, user.ID, teamMemberships) { + canAccessEndpoint = append(canAccessEndpoint, sanitizeUser(user)) + } + } + + return response.JSON(w, canAccessEndpoint) +} + +func sanitizeUser(user portainer.User) User { + return User{ + ID: user.ID, + Username: user.Username, + Role: user.Role, + } +} + +func sanitizeUsers(users []portainer.User) []User { + u := make([]User, len(users)) + for i := range users { + u[i] = sanitizeUser(users[i]) + } + return u +} diff --git a/api/http/handler/users/user_list_test.go b/api/http/handler/users/user_list_test.go new file mode 100644 index 0000000..a8e2957 --- /dev/null +++ b/api/http/handler/users/user_list_test.go @@ -0,0 +1,260 @@ +package users + +import ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/internal/authorization" + "github.com/portainer/portainer/api/internal/testhelpers" + + "github.com/segmentio/encoding/json" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_userList(t *testing.T) { + t.Parallel() + is := assert.New(t) + + _, store := datastore.MustNewTestStore(t, true, true) + + // Create admin and standard user(s) + adminUser := &portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole} + err := store.User().Create(adminUser) + require.NoError(t, err, "error creating admin user") + + h, jwtService, _ := newTestHandler(t, store) + + // Generate admin user tokens + adminJWT, _, _ := jwtService.GenerateToken(&portainer.TokenData{ID: adminUser.ID, Username: adminUser.Username, Role: adminUser.Role}) + + // Case 1: the user is given the endpoint access directly + userWithEndpointAccess := &portainer.User{ID: 2, Username: "standard-user-with-endpoint-access", Role: portainer.StandardUserRole, PortainerAuthorizations: authorization.DefaultPortainerAuthorizations()} + err = store.User().Create(userWithEndpointAccess) + require.NoError(t, err, "error creating user") + + userWithoutEndpointAccess := &portainer.User{ID: 3, Username: "standard-user-without-endpoint-access", Role: portainer.StandardUserRole, PortainerAuthorizations: authorization.DefaultPortainerAuthorizations()} + err = store.User().Create(userWithoutEndpointAccess) + require.NoError(t, err, "error creating user") + + // Create environment group + endpointGroup := &portainer.EndpointGroup{ID: 1, Name: "default-endpoint-group"} + err = store.EndpointGroup().Create(endpointGroup) + require.NoError(t, err, "error creating endpoint group") + + // Create endpoint and user access policies + userAccessPolicies := make(portainer.UserAccessPolicies, 0) + userAccessPolicies[userWithEndpointAccess.ID] = portainer.AccessPolicy{RoleID: portainer.RoleID(userWithEndpointAccess.Role)} + + endpointWithUserAccessPolicy := &portainer.Endpoint{ID: 1, UserAccessPolicies: userAccessPolicies, GroupID: endpointGroup.ID} + err = store.Endpoint().Create(endpointWithUserAccessPolicy) + require.NoError(t, err, "error creating endpoint") + + jwt, _, _ := jwtService.GenerateToken(&portainer.TokenData{ID: userWithEndpointAccess.ID, Username: userWithEndpointAccess.Username, Role: userWithEndpointAccess.Role}) + + t.Run("admin user can successfully list all users", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/users", nil) + testhelpers.AddTestSecurityCookie(req, adminJWT) + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + is.Equal(http.StatusOK, rr.Code) + + body, err := io.ReadAll(rr.Body) + require.NoError(t, err, "ReadAll should not return error") + + var resp []portainer.User + err = json.Unmarshal(body, &resp) + require.NoError(t, err, "response should be list json") + + is.Len(resp, 3) + }) + + t.Run("admin user can list users who are given the endpoint access directly", func(t *testing.T) { + params := url.Values{} + params.Add("environmentId", fmt.Sprintf("%d", endpointWithUserAccessPolicy.ID)) + req := httptest.NewRequest(http.MethodGet, "/users?"+params.Encode(), nil) + testhelpers.AddTestSecurityCookie(req, adminJWT) + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + is.Equal(http.StatusOK, rr.Code) + + body, err := io.ReadAll(rr.Body) + require.NoError(t, err, "ReadAll should not return error") + + var resp []portainer.User + err = json.Unmarshal(body, &resp) + require.NoError(t, err, "response should be list json") + + is.Len(resp, 1) + if len(resp) == 1 { + is.Equal(userWithEndpointAccess.ID, resp[0].ID) + } + }) + + t.Run("standard user cannot list users", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/users", nil) + testhelpers.AddTestSecurityCookie(req, jwt) + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + is.Equal(http.StatusForbidden, rr.Code) + }) + + // Case 2: the user is under an environment group and the environment group has endpoint access. + // the user inherits the endpoint access from the environment group + // create user + userUnderGroup := &portainer.User{ID: 4, Username: "standard-user-under-environment-group", Role: portainer.StandardUserRole, PortainerAuthorizations: authorization.DefaultPortainerAuthorizations()} + err = store.User().Create(userUnderGroup) + require.NoError(t, err, "error creating user") + + // Create environment group including a user + userAccessPoliciesUnderGroup := make(portainer.UserAccessPolicies, 0) + userAccessPoliciesUnderGroup[userUnderGroup.ID] = portainer.AccessPolicy{RoleID: portainer.RoleID(userUnderGroup.Role)} + + endpointGroupWithUser := &portainer.EndpointGroup{ID: 2, Name: "endpoint-group-with-user", UserAccessPolicies: userAccessPoliciesUnderGroup} + err = store.EndpointGroup().Create(endpointGroupWithUser) + require.NoError(t, err, "error creating endpoint group") + + // Create endpoint + endpointUnderGroupWithUser := &portainer.Endpoint{ID: 2, GroupID: endpointGroupWithUser.ID} + err = store.Endpoint().Create(endpointUnderGroupWithUser) + require.NoError(t, err, "error creating endpoint") + + t.Run("admin user can list users who inherit endpoint access from an environment group", func(t *testing.T) { + params := url.Values{} + params.Add("environmentId", fmt.Sprintf("%d", endpointUnderGroupWithUser.ID)) + req := httptest.NewRequest(http.MethodGet, "/users?"+params.Encode(), nil) + testhelpers.AddTestSecurityCookie(req, adminJWT) + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + is.Equal(http.StatusOK, rr.Code) + + body, err := io.ReadAll(rr.Body) + require.NoError(t, err, "ReadAll should not return error") + + var resp []portainer.User + err = json.Unmarshal(body, &resp) + require.NoError(t, err, "response should be list json") + + is.Len(resp, 1) + if len(resp) == 1 { + is.Equal(userUnderGroup.ID, resp[0].ID) + } + }) + + // Case 3: the user is under a team and the team is under an environment group. + // the environment group is given the endpoint access. + // both user and team should inherits the endpoint access from the environment group + // create a team including a user + teamUnderGroup := &portainer.Team{ID: 1, Name: "team-under-environment-group"} + err = store.Team().Create(teamUnderGroup) + require.NoError(t, err, "error creating team") + + userUnderTeam := &portainer.User{ID: 4, Username: "standard-user-under-team", Role: portainer.StandardUserRole, PortainerAuthorizations: authorization.DefaultPortainerAuthorizations()} + err = store.User().Create(userUnderTeam) + require.NoError(t, err, "error creating user") + + teamMembership := &portainer.TeamMembership{ID: 1, UserID: userUnderTeam.ID, TeamID: teamUnderGroup.ID} + err = store.TeamMembership().Create(teamMembership) + require.NoError(t, err, "error creating team membership") + + // Create environment group including a team + teamAccessPoliciesUnderGroup := make(portainer.TeamAccessPolicies, 0) + teamAccessPoliciesUnderGroup[teamUnderGroup.ID] = portainer.AccessPolicy{RoleID: portainer.RoleID(userUnderTeam.Role)} + + endpointGroupWithTeam := &portainer.EndpointGroup{ID: 3, Name: "endpoint-group-with-team", TeamAccessPolicies: teamAccessPoliciesUnderGroup} + err = store.EndpointGroup().Create(endpointGroupWithTeam) + require.NoError(t, err, "error creating endpoint group") + + // Create endpoint + endpointUnderGroupWithTeam := &portainer.Endpoint{ID: 3, GroupID: endpointGroupWithTeam.ID} + err = store.Endpoint().Create(endpointUnderGroupWithTeam) + require.NoError(t, err, "error creating endpoint") + t.Run("admin user can list users who inherit endpoint access from a team that inherit from an environment group", func(t *testing.T) { + params := url.Values{} + params.Add("environmentId", fmt.Sprintf("%d", endpointUnderGroupWithTeam.ID)) + req := httptest.NewRequest(http.MethodGet, "/users?"+params.Encode(), nil) + testhelpers.AddTestSecurityCookie(req, adminJWT) + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + is.Equal(http.StatusOK, rr.Code) + + body, err := io.ReadAll(rr.Body) + require.NoError(t, err, "ReadAll should not return error") + + var resp []portainer.User + err = json.Unmarshal(body, &resp) + require.NoError(t, err, "response should be list json") + + is.Len(resp, 1) + if len(resp) == 1 { + is.Equal(userUnderTeam.ID, resp[0].ID) + } + }) + + // Case 4: the user is under a team and the team is given the endpoint access + // the user inherits the endpoint access from the team + // create a team including a user + teamWithEndpointAccess := &portainer.Team{ID: 2, Name: "team-with-endpoint-access"} + err = store.Team().Create(teamWithEndpointAccess) + require.NoError(t, err, "error creating team") + + userUnderTeamWithEndpointAccess := &portainer.User{ID: 5, Username: "standard-user-under-team-with-endpoint-access", Role: portainer.StandardUserRole, PortainerAuthorizations: authorization.DefaultPortainerAuthorizations()} + err = store.User().Create(userUnderTeamWithEndpointAccess) + require.NoError(t, err, "error creating user") + + teamMembershipWithEndpointAccess := &portainer.TeamMembership{ID: 2, UserID: userUnderTeamWithEndpointAccess.ID, TeamID: teamWithEndpointAccess.ID} + err = store.TeamMembership().Create(teamMembershipWithEndpointAccess) + require.NoError(t, err, "error creating team membership") + + // Create environment group + endpointGroupWithoutTeam := &portainer.EndpointGroup{ID: 4, Name: "endpoint-group-without-team"} + err = store.EndpointGroup().Create(endpointGroupWithoutTeam) + require.NoError(t, err, "error creating endpoint group") + + // Create endpoint and team access policies + teamAccessPolicies := make(portainer.TeamAccessPolicies, 0) + teamAccessPolicies[teamWithEndpointAccess.ID] = portainer.AccessPolicy{RoleID: portainer.RoleID(userUnderTeamWithEndpointAccess.Role)} + + endpointWithTeamAccessPolicy := &portainer.Endpoint{ID: 4, TeamAccessPolicies: teamAccessPolicies, GroupID: endpointGroupWithoutTeam.ID} + err = store.Endpoint().Create(endpointWithTeamAccessPolicy) + require.NoError(t, err, "error creating endpoint") + t.Run("admin user can list users who inherit endpoint access from a team", func(t *testing.T) { + params := url.Values{} + params.Add("environmentId", fmt.Sprintf("%d", endpointWithTeamAccessPolicy.ID)) + req := httptest.NewRequest(http.MethodGet, "/users?"+params.Encode(), nil) + testhelpers.AddTestSecurityCookie(req, adminJWT) + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + is.Equal(http.StatusOK, rr.Code) + + body, err := io.ReadAll(rr.Body) + require.NoError(t, err, "ReadAll should not return error") + + var resp []portainer.User + err = json.Unmarshal(body, &resp) + require.NoError(t, err, "response should be list json") + + is.Len(resp, 1) + if len(resp) == 1 { + is.Equal(userUnderTeamWithEndpointAccess.ID, resp[0].ID) + } + }) +} diff --git a/api/http/handler/users/user_memberships.go b/api/http/handler/users/user_memberships.go new file mode 100644 index 0000000..5f77afd --- /dev/null +++ b/api/http/handler/users/user_memberships.go @@ -0,0 +1,49 @@ +package users + +import ( + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/api/http/security" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +// @id UserMembershipsInspect +// @summary Inspect a user memberships +// @description Inspect a user memberships. +// @description **Access policy**: restricted +// @tags users +// @security ApiKeyAuth +// @security jwt +// @produce json +// @param id path int true "User identifier" +// @success 200 {object} portainer.TeamMembership "Success" +// @failure 400 "Invalid request" +// @failure 403 "Permission denied" +// @failure 500 "Server error" +// @router /users/{id}/memberships [get] +func (handler *Handler) userMemberships(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + userID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid user identifier route variable", err) + } + + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve user authentication token", err) + } + + if tokenData.Role != portainer.AdministratorRole && tokenData.ID != portainer.UserID(userID) { + return httperror.Forbidden("Permission denied to update user memberships", errors.ErrUnauthorized) + } + + memberships, err := handler.DataStore.TeamMembership().TeamMembershipsByUserID(portainer.UserID(userID)) + if err != nil { + return httperror.InternalServerError("Unable to persist membership changes inside the database", err) + } + + return response.JSON(w, memberships) +} diff --git a/api/http/handler/users/user_remove_access_token.go b/api/http/handler/users/user_remove_access_token.go new file mode 100644 index 0000000..8ab6ca5 --- /dev/null +++ b/api/http/handler/users/user_remove_access_token.go @@ -0,0 +1,77 @@ +package users + +import ( + "errors" + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/apikey" + httperrors "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/api/http/security" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +// @id UserRemoveAPIKey +// @summary Remove an api-key associated to a user +// @description Remove an api-key associated to a user.. +// @description Only the calling user or admin can remove api-key. +// @description **Access policy**: authenticated +// @tags users +// @security ApiKeyAuth +// @security jwt +// @param id path int true "User identifier" +// @param keyID path int true "Api Key identifier" +// @success 204 "Success" +// @failure 400 "Invalid request" +// @failure 403 "Permission denied" +// @failure 404 "Not found" +// @failure 500 "Server error" +// @router /users/{id}/tokens/{keyID} [delete] +func (handler *Handler) userRemoveAccessToken(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + userID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid user identifier route variable", err) + } + + apiKeyID, err := request.RetrieveNumericRouteVariableValue(r, "keyID") + if err != nil { + return httperror.BadRequest("Invalid api-key identifier route variable", err) + } + + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve user authentication token", err) + } + if tokenData.Role != portainer.AdministratorRole && tokenData.ID != portainer.UserID(userID) { + return httperror.Forbidden("Permission denied to get user access tokens", httperrors.ErrUnauthorized) + } + + _, err = handler.DataStore.User().Read(portainer.UserID(userID)) + if err != nil { + if handler.DataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find a user with the specified identifier inside the database", err) + } + return httperror.InternalServerError("Unable to find a user with the specified identifier inside the database", err) + } + + // check if the key exists and the key belongs to the user + apiKey, err := handler.apiKeyService.GetAPIKey(portainer.APIKeyID(apiKeyID)) + if err != nil { + return httperror.InternalServerError("API Key not found", err) + } + if apiKey.UserID != portainer.UserID(userID) { + return httperror.Forbidden("Permission denied to remove api-key", httperrors.ErrUnauthorized) + } + + err = handler.apiKeyService.DeleteAPIKey(portainer.APIKeyID(apiKeyID)) + if err != nil { + if errors.Is(err, apikey.ErrInvalidAPIKey) { + return httperror.NotFound("Unable to find an api-key with the specified identifier inside the database", err) + } + return httperror.InternalServerError("Unable to remove the api-key from the user", err) + } + + return response.Empty(w) +} diff --git a/api/http/handler/users/user_remove_access_token_test.go b/api/http/handler/users/user_remove_access_token_test.go new file mode 100644 index 0000000..c23ae1c --- /dev/null +++ b/api/http/handler/users/user_remove_access_token_test.go @@ -0,0 +1,115 @@ +package users + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/internal/testhelpers" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_userRemoveAccessToken(t *testing.T) { + t.Parallel() + is := assert.New(t) + + _, store := datastore.MustNewTestStore(t, true, true) + + // create admin and standard user(s) + adminUser := &portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole} + err := store.User().Create(adminUser) + require.NoError(t, err, "error creating admin user") + + user := &portainer.User{ID: 2, Username: "standard", Role: portainer.StandardUserRole} + err = store.User().Create(user) + require.NoError(t, err, "error creating user") + + h, jwtService, apiKeyService := newTestHandler(t, store) + + // generate standard and admin user tokens + adminJWT, _, _ := jwtService.GenerateToken(&portainer.TokenData{ID: adminUser.ID, Username: adminUser.Username, Role: adminUser.Role}) + jwt, _, _ := jwtService.GenerateToken(&portainer.TokenData{ID: user.ID, Username: user.Username, Role: user.Role}) + + t.Run("standard user can successfully delete API key", func(t *testing.T) { + is := assert.New(t) + _, apiKey, err := apiKeyService.GenerateApiKey(*user, "test-delete-token") + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("%s/%d", "/users/2/tokens", apiKey.ID), nil) + testhelpers.AddTestSecurityCookie(req, jwt) + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + is.Equal(http.StatusNoContent, rr.Code) + + keys, err := apiKeyService.GetAPIKeys(user.ID) + require.NoError(t, err) + + require.Empty(t, keys) + }) + + t.Run("admin can delete a standard user API Key", func(t *testing.T) { + is := assert.New(t) + _, apiKey, err := apiKeyService.GenerateApiKey(*user, "test-admin-delete-token") + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("%s/%d", "/users/2/tokens", apiKey.ID), nil) + testhelpers.AddTestSecurityCookie(req, adminJWT) + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + is.Equal(http.StatusNoContent, rr.Code) + + keys, err := apiKeyService.GetAPIKeys(user.ID) + require.NoError(t, err) + + is.Empty(keys) + }) + + t.Run("user can delete API Key using api-key auth", func(t *testing.T) { + is := assert.New(t) + rawAPIKey, apiKey, err := apiKeyService.GenerateApiKey(*user, "test-api-key-auth-deletion") + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("%s/%d", "/users/2/tokens", apiKey.ID), nil) + req.Header.Add("x-api-key", rawAPIKey) + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + is.Equal(http.StatusNoContent, rr.Code) + + keys, err := apiKeyService.GetAPIKeys(user.ID) + require.NoError(t, err) + + is.Empty(keys) + }) + + t.Run("user cannot delete another users API Keys using api-key auth", func(t *testing.T) { + _, adminAPIKey, err := apiKeyService.GenerateApiKey(*adminUser, "admin-key") + require.NoError(t, err) + + rawAPIKey, _, err := apiKeyService.GenerateApiKey(*user, "user-key") + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/users/%d/tokens/%d", user.ID, adminAPIKey.ID), nil) + req.Header.Add("x-api-key", rawAPIKey) + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + is.Equal(http.StatusForbidden, rr.Code) + + adminKeyGot, err := apiKeyService.GetAPIKey(adminAPIKey.ID) + require.NoError(t, err) + + is.Equal(adminAPIKey, adminKeyGot) + }) +} diff --git a/api/http/handler/users/user_update.go b/api/http/handler/users/user_update.go new file mode 100644 index 0000000..cc290dd --- /dev/null +++ b/api/http/handler/users/user_update.go @@ -0,0 +1,159 @@ +package users + +import ( + "cmp" + "errors" + "net/http" + "strings" + "time" + + portainer "github.com/portainer/portainer/api" + httperrors "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/api/http/security" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +type themePayload struct { + // Color represents the color theme of the UI + Color *string `json:"color" example:"dark" enums:"dark,light,highcontrast,auto"` +} + +type userUpdatePayload struct { + Username string `validate:"required" example:"bob"` + Password string `validate:"required" example:"cg9Wgky3"` + NewPassword string `validate:"required" example:"asfj2emv"` + UseCache *bool `validate:"required" example:"true"` + Theme *themePayload + + // User role (1 for administrator account and 2 for regular account) + Role int `validate:"required" enums:"1,2" example:"2"` +} + +func (payload *userUpdatePayload) Validate(r *http.Request) error { + if strings.Contains(payload.Username, " ") { + return errors.New("invalid username. Must not contain any whitespace") + } + + if payload.Role != 0 && payload.Role != 1 && payload.Role != 2 { + return errors.New("invalid role value. Value must be one of: 1 (administrator) or 2 (regular user)") + } + + return nil +} + +// @id UserUpdate +// @summary Update a user +// @description Update user details. A regular user account can only update his details. +// @description A regular user account cannot change their username or role. +// @description **Access policy**: authenticated +// @tags users +// @security ApiKeyAuth +// @security jwt +// @accept json +// @produce json +// @param id path int true "User identifier" +// @param body body userUpdatePayload true "User details" +// @success 200 {object} portainer.User "Success" +// @failure 400 "Invalid request" +// @failure 403 "Permission denied" +// @failure 404 "User not found" +// @failure 409 "Username already exist" +// @failure 500 "Server error" +// @router /users/{id} [put] +func (handler *Handler) userUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + userID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid user identifier route variable", err) + } + + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve user authentication token", err) + } + + if tokenData.Role != portainer.AdministratorRole && tokenData.ID != portainer.UserID(userID) { + return httperror.Forbidden("Permission denied to update user", httperrors.ErrUnauthorized) + } + + var payload userUpdatePayload + if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + if tokenData.Role != portainer.AdministratorRole && payload.Role != 0 { + return httperror.Forbidden("Permission denied to update user to administrator role", httperrors.ErrResourceAccessDenied) + } + + user, err := handler.DataStore.User().Read(portainer.UserID(userID)) + if handler.DataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find a user with the specified identifier inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find a user with the specified identifier inside the database", err) + } + + if payload.Username != "" && payload.Username != user.Username { + if tokenData.Role != portainer.AdministratorRole { + return httperror.Forbidden("Permission denied. Unable to update username", httperrors.ErrResourceAccessDenied) + } + + if sameNameUser, err := handler.DataStore.User().UserByUsername(payload.Username); err != nil && !handler.DataStore.IsErrObjectNotFound(err) { + return httperror.InternalServerError("Unable to retrieve users from the database", err) + } else if sameNameUser != nil && sameNameUser.ID != portainer.UserID(userID) { + return httperror.Conflict("Another user with the same username already exists", errUserAlreadyExists) + } + + user.Username = payload.Username + } + + if payload.Password != "" && payload.NewPassword == "" { + if tokenData.Role == portainer.AdministratorRole { + return httperror.BadRequest("Existing password field specified without new password field.", errors.New("To change the password as an admin, you only need 'newPassword' in your request")) + } + + return httperror.BadRequest("Existing password field specified without new password field.", errors.New("To change the password, you must include both 'password' and 'newPassword' in your request")) + } + + if payload.NewPassword != "" { + // Non-admins need to supply the previous password + if tokenData.Role != portainer.AdministratorRole { + if err := handler.CryptoService.CompareHashAndData(user.Password, payload.Password); err != nil { + return httperror.Forbidden("Current password doesn't match. Password left unchanged", errors.New("Current password does not match the password provided. Please try again")) + } + } + + if !handler.passwordStrengthChecker.Check(payload.NewPassword) { + return httperror.BadRequest("Password does not meet the minimum strength requirements", nil) + } + + user.Password, err = handler.CryptoService.Hash(payload.NewPassword) + if err != nil { + return httperror.InternalServerError("Unable to hash user password", errCryptoHashFailure) + } + user.TokenIssueAt = time.Now().Unix() + } + + if payload.Theme != nil { + user.ThemeSettings.Color = *cmp.Or(payload.Theme.Color, &user.ThemeSettings.Color) + } + + user.UseCache = *cmp.Or(payload.UseCache, &user.UseCache) + + if payload.Role != 0 { + user.Role = portainer.UserRole(payload.Role) + user.TokenIssueAt = time.Now().Unix() + } + + if err := handler.DataStore.User().Update(user.ID, user); err != nil { + return httperror.InternalServerError("Unable to persist user changes inside the database", err) + } + + // remove all of the users persisted API keys + handler.apiKeyService.InvalidateUserKeyCache(user.ID) + + // hide the password field in the response payload + user.Password = "" + + return response.JSON(w, user) +} diff --git a/api/http/handler/users/user_update_password.go b/api/http/handler/users/user_update_password.go new file mode 100644 index 0000000..6ca3905 --- /dev/null +++ b/api/http/handler/users/user_update_password.go @@ -0,0 +1,100 @@ +package users + +import ( + "errors" + "net/http" + "time" + + portainer "github.com/portainer/portainer/api" + httperrors "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/api/http/security" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +type userUpdatePasswordPayload struct { + // Current Password + Password string `example:"passwd" validate:"required"` + // New Password + NewPassword string `example:"new_passwd" validate:"required"` +} + +func (payload *userUpdatePasswordPayload) Validate(r *http.Request) error { + if len(payload.Password) == 0 { + return errors.New("Invalid current password") + } + if len(payload.NewPassword) == 0 { + return errors.New("Invalid new password") + } + return nil +} + +// @id UserUpdatePassword +// @summary Update password for a user +// @description Update password for the specified user. +// @description **Access policy**: authenticated +// @tags users +// @security ApiKeyAuth +// @security jwt +// @accept json +// @produce json +// @param id path int true "identifier" +// @param body body userUpdatePasswordPayload true "details" +// @success 204 "Success" +// @failure 400 "Invalid request" +// @failure 403 "Permission denied" +// @failure 404 "User not found" +// @failure 500 "Server error" +// @router /users/{id}/passwd [put] +func (handler *Handler) userUpdatePassword(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + userID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid user identifier route variable", err) + } + + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve user authentication token", err) + } + + if tokenData.Role != portainer.AdministratorRole && tokenData.ID != portainer.UserID(userID) { + return httperror.Forbidden("Permission denied to update user", httperrors.ErrUnauthorized) + } + + var payload userUpdatePasswordPayload + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + user, err := handler.DataStore.User().Read(portainer.UserID(userID)) + if handler.DataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find a user with the specified identifier inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find a user with the specified identifier inside the database", err) + } + + err = handler.CryptoService.CompareHashAndData(user.Password, payload.Password) + if err != nil { + return httperror.Forbidden("Current password doesn't match", errors.New("Current password does not match the password provided. Please try again")) + } + + if !handler.passwordStrengthChecker.Check(payload.NewPassword) { + return httperror.BadRequest("Password does not meet the minimum strength requirements", nil) + } + + user.Password, err = handler.CryptoService.Hash(payload.NewPassword) + if err != nil { + return httperror.InternalServerError("Unable to hash user password", errCryptoHashFailure) + } + + user.TokenIssueAt = time.Now().Unix() + + err = handler.DataStore.User().Update(user.ID, user) + if err != nil { + return httperror.InternalServerError("Unable to persist user changes inside the database", err) + } + + return response.Empty(w) +} diff --git a/api/http/handler/users/user_update_test.go b/api/http/handler/users/user_update_test.go new file mode 100644 index 0000000..c1bca9c --- /dev/null +++ b/api/http/handler/users/user_update_test.go @@ -0,0 +1,47 @@ +package users + +import ( + "net/http" + "net/http/httptest" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/datastore" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_updateUserRemovesAccessTokens(t *testing.T) { + t.Parallel() + is := assert.New(t) + + _, store := datastore.MustNewTestStore(t, true, true) + + // Create standard user + user := &portainer.User{ID: 2, Username: "standard", Role: portainer.StandardUserRole} + err := store.User().Create(user) + require.NoError(t, err, "error creating user") + + h, _, apiKeyService := newTestHandler(t, store) + + t.Run("standard user deletion removes all associated access tokens", func(t *testing.T) { + _, _, err := apiKeyService.GenerateApiKey(*user, "test-user-token") + require.NoError(t, err) + + keys, err := apiKeyService.GetAPIKeys(user.ID) + require.NoError(t, err) + is.Len(keys, 1) + + rr := httptest.NewRecorder() + + handlerErr := h.deleteUser(rr, user) + require.Nil(t, handlerErr) + + is.Equal(http.StatusNoContent, rr.Code) + + keys, err = apiKeyService.GetAPIKeys(user.ID) + require.NoError(t, err) + is.Empty(keys) + }) +} diff --git a/api/http/handler/webhooks/handler.go b/api/http/handler/webhooks/handler.go new file mode 100644 index 0000000..b981da8 --- /dev/null +++ b/api/http/handler/webhooks/handler.go @@ -0,0 +1,40 @@ +package webhooks + +import ( + "net/http" + + "github.com/portainer/portainer/api/dataservices" + dockerclient "github.com/portainer/portainer/api/docker/client" + "github.com/portainer/portainer/api/http/security" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + + "github.com/gorilla/mux" +) + +// Handler is the HTTP handler used to handle webhook operations. +type Handler struct { + *mux.Router + requestBouncer security.BouncerService + DataStore dataservices.DataStore + DockerClientFactory *dockerclient.ClientFactory +} + +// NewHandler creates a handler to manage webhooks operations. +func NewHandler(bouncer security.BouncerService) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + requestBouncer: bouncer, + } + h.Handle("/webhooks", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.webhookCreate))).Methods(http.MethodPost) + h.Handle("/webhooks/{id}", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.webhookUpdate))).Methods(http.MethodPut) + h.Handle("/webhooks", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.webhookList))).Methods(http.MethodGet) + h.Handle("/webhooks/{id}", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.webhookDelete))).Methods(http.MethodDelete) + h.Handle("/webhooks/{token}", + bouncer.PublicAccess(httperror.LoggerHandler(h.webhookExecute))).Methods(http.MethodPost) + + return h +} diff --git a/api/http/handler/webhooks/webhook_create.go b/api/http/handler/webhooks/webhook_create.go new file mode 100644 index 0000000..86f4884 --- /dev/null +++ b/api/http/handler/webhooks/webhook_create.go @@ -0,0 +1,108 @@ +package webhooks + +import ( + "errors" + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/registryutils/access" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" + + "github.com/google/uuid" +) + +type webhookCreatePayload struct { + ResourceID string + EndpointID portainer.EndpointID + RegistryID portainer.RegistryID + // Type of webhook (1 - service) + WebhookType portainer.WebhookType +} + +func (payload *webhookCreatePayload) Validate(r *http.Request) error { + if len(payload.ResourceID) == 0 { + return errors.New("Invalid ResourceID") + } + if payload.EndpointID == 0 { + return errors.New("Invalid EndpointID") + } + if payload.WebhookType != portainer.ServiceWebhook { + return errors.New("Invalid WebhookType") + } + return nil +} + +// @summary Create a webhook +// @description **Access policy**: authenticated +// @security ApiKeyAuth +// @security jwt +// @tags webhooks +// @accept json +// @produce json +// @param body body webhookCreatePayload true "Webhook data" +// @success 200 {object} portainer.Webhook +// @failure 400 "Invalid request" +// @failure 409 "A webhook for this resource already exists" +// @failure 500 "Server error" +// @router /webhooks [post] +func (handler *Handler) webhookCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload webhookCreatePayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + webhook, err := handler.DataStore.Webhook().WebhookByResourceID(payload.ResourceID) + if err != nil && !handler.DataStore.IsErrObjectNotFound(err) { + return httperror.InternalServerError("An error occurred retrieving webhooks from the database", err) + } + if webhook != nil { + return httperror.Conflict("A webhook for this resource already exists", errors.New("A webhook for this resource already exists")) + } + + endpointID := payload.EndpointID + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve user info from request context", err) + } + + if !securityContext.IsAdmin { + return httperror.Forbidden("Not authorized to create a webhook", errors.New("not authorized to create a webhook")) + } + + if payload.RegistryID != 0 { + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve user authentication token", err) + } + + _, err = access.GetAccessibleRegistry(handler.DataStore, nil, tokenData.ID, endpointID, payload.RegistryID) + if err != nil { + return httperror.Forbidden("Permission deny to access registry", err) + } + } + + token, err := uuid.NewRandom() + if err != nil { + return httperror.InternalServerError("Error creating unique token", err) + } + + webhook = &portainer.Webhook{ + Token: token.String(), + ResourceID: payload.ResourceID, + EndpointID: endpointID, + RegistryID: payload.RegistryID, + WebhookType: payload.WebhookType, + } + + err = handler.DataStore.Webhook().Create(webhook) + if err != nil { + return httperror.InternalServerError("Unable to persist the webhook inside the database", err) + } + + return response.JSON(w, webhook) +} diff --git a/api/http/handler/webhooks/webhook_delete.go b/api/http/handler/webhooks/webhook_delete.go new file mode 100644 index 0000000..a16a886 --- /dev/null +++ b/api/http/handler/webhooks/webhook_delete.go @@ -0,0 +1,45 @@ +package webhooks + +import ( + "errors" + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/security" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +// @summary Delete a webhook +// @description **Access policy**: authenticated +// @security ApiKeyAuth +// @security jwt +// @tags webhooks +// @param id path int true "Webhook id" +// @success 202 "Webhook deleted" +// @failure 400 +// @failure 500 +// @router /webhooks/{id} [delete] +func (handler *Handler) webhookDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + id, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid webhook id", err) + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve user info from request context", err) + } + + if !securityContext.IsAdmin { + return httperror.Forbidden("Not authorized to delete a webhook", errors.New("not authorized to delete a webhook")) + } + + err = handler.DataStore.Webhook().Delete(portainer.WebhookID(id)) + if err != nil { + return httperror.InternalServerError("Unable to remove the webhook from the database", err) + } + + return response.Empty(w) +} diff --git a/api/http/handler/webhooks/webhook_execute.go b/api/http/handler/webhooks/webhook_execute.go new file mode 100644 index 0000000..ab948de --- /dev/null +++ b/api/http/handler/webhooks/webhook_execute.go @@ -0,0 +1,132 @@ +package webhooks + +import ( + "context" + "errors" + "net/http" + "strings" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/internal/registryutils" + "github.com/portainer/portainer/api/logs" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" + "github.com/rs/zerolog/log" + + dockertypes "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/image" +) + +// @summary Execute a webhook +// @description Acts on a passed in token UUID to restart the docker service +// @description **Access policy**: public +// @tags webhooks +// @param id path string true "Webhook token" +// @success 202 "Webhook executed" +// @failure 400 +// @failure 500 +// @router /webhooks/{id} [post] +func (handler *Handler) webhookExecute(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + webhookToken, err := request.RetrieveRouteVariableValue(r, "token") + if err != nil { + return httperror.InternalServerError("Invalid service id parameter", err) + } + + webhook, err := handler.DataStore.Webhook().WebhookByToken(webhookToken) + if handler.DataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find a webhook with this token", err) + } else if err != nil { + return httperror.InternalServerError("Unable to retrieve webhook from the database", err) + } + + resourceID := webhook.ResourceID + endpointID := webhook.EndpointID + registryID := webhook.RegistryID + webhookType := webhook.WebhookType + + endpoint, err := handler.DataStore.Endpoint().Endpoint(endpointID) + if handler.DataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find an environment with the specified identifier inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find an environment with the specified identifier inside the database", err) + } + + imageTag, _ := request.RetrieveQueryParameter(r, "tag", true) + + switch webhookType { + case portainer.ServiceWebhook: + return handler.executeServiceWebhook(w, endpoint, resourceID, registryID, imageTag) + default: + return httperror.InternalServerError("Unsupported webhook type", errors.New("Webhooks for this resource are not currently supported")) + } +} + +func (handler *Handler) executeServiceWebhook( + w http.ResponseWriter, + endpoint *portainer.Endpoint, + resourceID string, + registryID portainer.RegistryID, + imageTag string, +) *httperror.HandlerError { + dockerClient, err := handler.DockerClientFactory.CreateClient(endpoint, "", nil) + if err != nil { + return httperror.InternalServerError("Error creating docker client", err) + } + defer logs.CloseAndLogErr(dockerClient) + + service, _, err := dockerClient.ServiceInspectWithRaw(context.Background(), resourceID, dockertypes.ServiceInspectOptions{InsertDefaults: true}) + if err != nil { + return httperror.InternalServerError("Error looking up service", err) + } + + service.Spec.TaskTemplate.ForceUpdate++ + + imageName := strings.Split(service.Spec.TaskTemplate.ContainerSpec.Image, "@sha")[0] + service.Spec.TaskTemplate.ContainerSpec.Image = imageName + + if imageTag != "" { + tagIndex := strings.LastIndex(imageName, ":") + if tagIndex == -1 { + tagIndex = len(imageName) + } + + service.Spec.TaskTemplate.ContainerSpec.Image = imageName[:tagIndex] + ":" + imageTag + } + + serviceUpdateOptions := dockertypes.ServiceUpdateOptions{ + QueryRegistry: true, + } + + if registryID != 0 { + registry, err := handler.DataStore.Registry().Read(registryID) + if err != nil { + return httperror.InternalServerError("Error getting registry", err) + } + + if registry.Authentication { + if err := registryutils.EnsureRegTokenValid(handler.DataStore, registry); err != nil { + log.Warn().Err(err).Msgf("registry auth token renewal failed for registry %d", registry.ID) + } + + serviceUpdateOptions.EncodedRegistryAuth, err = registryutils.GetRegistryAuthHeader(registry) + if err != nil { + return httperror.InternalServerError("Error getting registry auth header", err) + } + } + } + + if imageTag != "" { + rc, err := dockerClient.ImagePull(context.Background(), service.Spec.TaskTemplate.ContainerSpec.Image, image.PullOptions{RegistryAuth: serviceUpdateOptions.EncodedRegistryAuth}) + if err != nil { + return httperror.NotFound("Error pulling image with the specified tag", err) + } + defer logs.CloseAndLogErr(rc) + } + + if _, err := dockerClient.ServiceUpdate(context.Background(), resourceID, service.Version, service.Spec, serviceUpdateOptions); err != nil { + return httperror.InternalServerError("Error updating service", err) + } + + return response.Empty(w) +} diff --git a/api/http/handler/webhooks/webhook_list.go b/api/http/handler/webhooks/webhook_list.go new file mode 100644 index 0000000..00269a2 --- /dev/null +++ b/api/http/handler/webhooks/webhook_list.go @@ -0,0 +1,68 @@ +package webhooks + +import ( + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/security" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +type webhookListOperationFilters struct { + ResourceID string `json:"ResourceID"` + EndpointID int `json:"EndpointID"` +} + +// @summary List webhooks +// @description **Access policy**: authenticated +// @security ApiKeyAuth +// @security jwt +// @tags webhooks +// @accept json +// @produce json +// @param filters query string false "Filters (json-string)" example({"EndpointID":1,"ResourceID":"abc12345-abcd-2345-ab12-58005b4a0260"}) +// @success 200 {array} portainer.Webhook +// @failure 400 +// @failure 500 +// @router /webhooks [get] +func (handler *Handler) webhookList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var filters webhookListOperationFilters + err := request.RetrieveJSONQueryParameter(r, "filters", &filters, true) + if err != nil { + return httperror.BadRequest("Invalid query parameter: filters", err) + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve user info from request context", err) + } + if !securityContext.IsAdmin { + return response.JSON(w, []portainer.Webhook{}) + } + + webhooks, err := handler.DataStore.Webhook().ReadAll() + if err != nil { + return httperror.InternalServerError("Unable to retrieve webhooks from the database", err) + } + + webhooks = filterWebhooks(webhooks, &filters) + + return response.JSON(w, webhooks) +} + +func filterWebhooks(webhooks []portainer.Webhook, filters *webhookListOperationFilters) []portainer.Webhook { + if filters.EndpointID == 0 && filters.ResourceID == "" { + return webhooks + } + + filteredWebhooks := make([]portainer.Webhook, 0, len(webhooks)) + for _, webhook := range webhooks { + if webhook.EndpointID == portainer.EndpointID(filters.EndpointID) && webhook.ResourceID == filters.ResourceID { + filteredWebhooks = append(filteredWebhooks, webhook) + } + } + + return filteredWebhooks +} diff --git a/api/http/handler/webhooks/webhook_update.go b/api/http/handler/webhooks/webhook_update.go new file mode 100644 index 0000000..94133c4 --- /dev/null +++ b/api/http/handler/webhooks/webhook_update.go @@ -0,0 +1,86 @@ +package webhooks + +import ( + "errors" + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/registryutils/access" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +type webhookUpdatePayload struct { + RegistryID portainer.RegistryID +} + +func (payload *webhookUpdatePayload) Validate(r *http.Request) error { + return nil +} + +// @summary Update a webhook +// @description **Access policy**: authenticated +// @security ApiKeyAuth +// @security jwt +// @tags webhooks +// @accept json +// @produce json +// @param id path int true "Webhook id" +// @param body body webhookUpdatePayload true "Webhook data" +// @success 200 {object} portainer.Webhook +// @failure 400 +// @failure 409 +// @failure 500 +// @router /webhooks/{id} [put] +func (handler *Handler) webhookUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + id, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid webhook id", err) + } + webhookID := portainer.WebhookID(id) + + var payload webhookUpdatePayload + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + webhook, err := handler.DataStore.Webhook().Read(webhookID) + if handler.DataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find a webhooks with the specified identifier inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find a webhooks with the specified identifier inside the database", err) + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve user info from request context", err) + } + + if !securityContext.IsAdmin { + return httperror.Forbidden("Not authorized to update a webhook", errors.New("not authorized to update a webhook")) + } + + if payload.RegistryID != 0 { + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve user authentication token", err) + } + + _, err = access.GetAccessibleRegistry(handler.DataStore, nil, tokenData.ID, webhook.EndpointID, payload.RegistryID) + if err != nil { + return httperror.Forbidden("Permission deny to access registry", err) + } + } + + webhook.RegistryID = payload.RegistryID + + err = handler.DataStore.Webhook().Update(portainer.WebhookID(id), webhook) + if err != nil { + return httperror.InternalServerError("Unable to persist the webhook inside the database", err) + } + + return response.JSON(w, webhook) +} diff --git a/api/http/handler/websocket/attach.go b/api/http/handler/websocket/attach.go new file mode 100644 index 0000000..999dba6 --- /dev/null +++ b/api/http/handler/websocket/attach.go @@ -0,0 +1,139 @@ +package websocket + +import ( + "net" + "net/http" + "time" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/logs" + "github.com/portainer/portainer/api/ws" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/validate" + "github.com/rs/zerolog/log" + + "github.com/gorilla/websocket" +) + +// @summary Attach a websocket +// @description If the nodeName query parameter is present, the request will be proxied to the underlying agent environment(endpoint). +// @description If the nodeName query parameter is not specified, the request will be upgraded to the websocket protocol and +// @description an AttachStart operation HTTP request will be created and hijacked. +// @description **Access policy**: authenticated +// @security ApiKeyAuth +// @security jwt +// @tags websocket +// @accept json +// @produce json +// @param endpointId query int true "environment(endpoint) ID of the environment(endpoint) where the resource is located" +// @param nodeName query string false "node name" +// @success 200 +// @failure 400 +// @failure 403 +// @failure 404 +// @failure 500 +// @router /websocket/attach [get] +func (handler *Handler) websocketAttach(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + attachID, err := request.RetrieveQueryParameter(r, "id", false) + if err != nil { + return httperror.BadRequest("Invalid query parameter: id", err) + } + if !validate.IsHexadecimal(attachID) { + return httperror.BadRequest("Invalid query parameter: id (must be hexadecimal identifier)", err) + } + + endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", false) + if err != nil { + return httperror.BadRequest("Invalid query parameter: endpointId", err) + } + + endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) + if handler.DataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find the environment associated to the stack inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find the environment associated to the stack inside the database", err) + } + + err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) + if err != nil { + return httperror.Forbidden("Permission denied to access environment", err) + } + + params := &webSocketRequestParams{ + endpoint: endpoint, + ID: attachID, + nodeName: r.FormValue("nodeName"), + } + + err = handler.handleAttachRequest(w, r, params) + if err != nil { + return httperror.InternalServerError("An error occurred during websocket attach operation", err) + } + + return nil +} + +func (handler *Handler) handleAttachRequest(w http.ResponseWriter, r *http.Request, params *webSocketRequestParams) error { + r.Header.Del("Origin") + + if params.endpoint.Type == portainer.AgentOnDockerEnvironment { + return handler.proxyAgentWebsocketRequest(w, r, params) + } else if params.endpoint.Type == portainer.EdgeAgentOnDockerEnvironment { + return handler.proxyEdgeAgentWebsocketRequest(w, r, params) + } + + websocketConn, err := handler.connectionUpgrader.Upgrade(w, r, nil) + if err != nil { + return err + } + defer logs.CloseAndLogErr(websocketConn) + + return hijackAttachStartOperation(websocketConn, params.endpoint, params.ID) +} + +func hijackAttachStartOperation( + websocketConn *websocket.Conn, + endpoint *portainer.Endpoint, + attachID string, +) error { + conn, err := initDial(endpoint) + if err != nil { + return err + } + + // When we set up a TCP connection for hijack, there could be long periods + // of inactivity (a long running command with no output) that in certain + // network setups may cause ECONNTIMEOUT, leaving the client in an unknown + // state. Setting TCP KeepAlive on the socket connection will prohibit + // ECONNTIMEOUT unless the socket connection truly is broken + if tcpConn, ok := conn.(*net.TCPConn); ok { + if err := tcpConn.SetKeepAlive(true); err != nil { + log.Warn().Err(err).Msg("failed to set TCP keep-alive on connection") + } + + if err := tcpConn.SetKeepAlivePeriod(30 * time.Second); err != nil { + log.Warn().Err(err).Msg("failed to set TCP keep-alive period on connection") + } + } + + attachStartRequest, err := createAttachStartRequest(attachID) + if err != nil { + return err + } + + return ws.HijackRequest(websocketConn, conn, attachStartRequest) +} + +func createAttachStartRequest(attachID string) (*http.Request, error) { + request, err := http.NewRequest("POST", "/containers/"+attachID+"/attach?stdin=1&stdout=1&stderr=1&stream=1", nil) + if err != nil { + return nil, err + } + + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Connection", "Upgrade") + request.Header.Set("Upgrade", "tcp") + + return request, nil +} diff --git a/api/http/handler/websocket/attach_test.go b/api/http/handler/websocket/attach_test.go new file mode 100644 index 0000000..b9e27cd --- /dev/null +++ b/api/http/handler/websocket/attach_test.go @@ -0,0 +1,34 @@ +package websocket + +import ( + "net/http" + "net/http/httptest" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/http/security" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestWebsocketAttach_deniesUnauthorizedEndpoint asserts a non-admin with no access policy on +// the environment is rejected with 403 — the environment-access (L2) gate (BE-13027). +func TestWebsocketAttach_deniesUnauthorizedEndpoint(t *testing.T) { + handler, _ := newWebsocketTestHandler(t) + + user := &portainer.User{Username: "restricted", Role: portainer.StandardUserRole} + err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + return tx.User().Create(user) + }) + require.NoError(t, err) + + // attach requires a hexadecimal `id` query parameter to reach the authorization check. + req := httptest.NewRequest(http.MethodGet, "/websocket/attach?id=abcdef&endpointId=2", nil) + req = req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ID: user.ID, Role: portainer.StandardUserRole})) + + handlerErr := handler.websocketAttach(httptest.NewRecorder(), req) + + require.NotNil(t, handlerErr, "expected an authorization error for a denied environment") + assert.Equal(t, http.StatusForbidden, handlerErr.StatusCode) +} diff --git a/api/http/handler/websocket/dial.go b/api/http/handler/websocket/dial.go new file mode 100644 index 0000000..cab749a --- /dev/null +++ b/api/http/handler/websocket/dial.go @@ -0,0 +1,11 @@ +//go:build !windows + +package websocket + +import ( + "net" +) + +func createDial(scheme, host string) (net.Conn, error) { + return net.Dial(scheme, host) +} diff --git a/api/http/handler/websocket/dial_windows.go b/api/http/handler/websocket/dial_windows.go new file mode 100644 index 0000000..6e8c5cf --- /dev/null +++ b/api/http/handler/websocket/dial_windows.go @@ -0,0 +1,16 @@ +//go:build windows + +package websocket + +import ( + "net" + + "github.com/Microsoft/go-winio" +) + +func createDial(scheme, host string) (net.Conn, error) { + if scheme == "npipe" { + return winio.DialPipe(host, nil) + } + return net.Dial(scheme, host) +} diff --git a/api/http/handler/websocket/exec.go b/api/http/handler/websocket/exec.go new file mode 100644 index 0000000..2ce09ac --- /dev/null +++ b/api/http/handler/websocket/exec.go @@ -0,0 +1,139 @@ +package websocket + +import ( + "bytes" + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/logs" + "github.com/portainer/portainer/api/ws" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/validate" + + "github.com/gorilla/websocket" + "github.com/segmentio/encoding/json" +) + +type execStartOperationPayload struct { + Tty bool + Detach bool +} + +// @summary Execute a websocket +// @description If the nodeName query parameter is present, the request will be proxied to the underlying agent environment(endpoint). +// @description If the nodeName query parameter is not specified, the request will be upgraded to the websocket protocol and +// @description an ExecStart operation HTTP request will be created and hijacked. +// @**Access policy**: authenticated +// @security ApiKeyAuth +// @security jwt +// @tags websocket +// @accept json +// @produce json +// @param endpointId query int true "environment(endpoint) ID of the environment(endpoint) where the resource is located" +// @param nodeName query string false "node name" +// @success 200 +// @failure 400 +// @failure 409 +// @failure 500 +// @router /websocket/exec [get] +func (handler *Handler) websocketExec(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + execID, err := request.RetrieveQueryParameter(r, "id", false) + if err != nil { + return httperror.BadRequest("Invalid query parameter: id", err) + } + if !validate.IsHexadecimal(execID) { + return httperror.BadRequest("Invalid query parameter: id (must be hexadecimal identifier)", err) + } + + endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", false) + if err != nil { + return httperror.BadRequest("Invalid query parameter: endpointId", err) + } + + endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) + if handler.DataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find the environment associated to the stack inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find the environment associated to the stack inside the database", err) + } + + err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) + if err != nil { + return httperror.Forbidden("Permission denied to access environment", err) + } + + params := &webSocketRequestParams{ + endpoint: endpoint, + ID: execID, + nodeName: r.FormValue("nodeName"), + } + + err = handler.handleExecRequest(w, r, params) + if err != nil { + return httperror.InternalServerError("An error occurred during websocket exec operation", err) + } + + return nil +} + +func (handler *Handler) handleExecRequest(w http.ResponseWriter, r *http.Request, params *webSocketRequestParams) error { + r.Header.Del("Origin") + + if params.endpoint.Type == portainer.AgentOnDockerEnvironment { + return handler.proxyAgentWebsocketRequest(w, r, params) + } else if params.endpoint.Type == portainer.EdgeAgentOnDockerEnvironment { + return handler.proxyEdgeAgentWebsocketRequest(w, r, params) + } + + websocketConn, err := handler.connectionUpgrader.Upgrade(w, r, nil) + if err != nil { + return err + } + + defer logs.CloseAndLogErr(websocketConn) + + return hijackExecStartOperation(websocketConn, params.endpoint, params.ID) +} + +func hijackExecStartOperation( + websocketConn *websocket.Conn, + endpoint *portainer.Endpoint, + execID string, +) error { + conn, err := initDial(endpoint) + if err != nil { + return err + } + + execStartRequest, err := createExecStartRequest(execID) + if err != nil { + return err + } + + return ws.HijackRequest(websocketConn, conn, execStartRequest) +} + +func createExecStartRequest(execID string) (*http.Request, error) { + execStartOperationPayload := &execStartOperationPayload{ + Tty: true, + Detach: false, + } + + encodedBody := bytes.NewBuffer(nil) + err := json.NewEncoder(encodedBody).Encode(execStartOperationPayload) + if err != nil { + return nil, err + } + + request, err := http.NewRequest("POST", "/exec/"+execID+"/start", encodedBody) + if err != nil { + return nil, err + } + + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Connection", "Upgrade") + request.Header.Set("Upgrade", "tcp") + + return request, nil +} diff --git a/api/http/handler/websocket/exec_test.go b/api/http/handler/websocket/exec_test.go new file mode 100644 index 0000000..fedfbb5 --- /dev/null +++ b/api/http/handler/websocket/exec_test.go @@ -0,0 +1,34 @@ +package websocket + +import ( + "net/http" + "net/http/httptest" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/http/security" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestWebsocketExec_deniesUnauthorizedEndpoint asserts a non-admin with no access policy on +// the environment is rejected with 403 — the environment-access (L2) gate (BE-13027). +func TestWebsocketExec_deniesUnauthorizedEndpoint(t *testing.T) { + handler, _ := newWebsocketTestHandler(t) + + user := &portainer.User{Username: "restricted", Role: portainer.StandardUserRole} + err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + return tx.User().Create(user) + }) + require.NoError(t, err) + + // exec requires a hexadecimal `id` query parameter to reach the authorization check. + req := httptest.NewRequest(http.MethodGet, "/websocket/exec?id=abcdef&endpointId=2", nil) + req = req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ID: user.ID, Role: portainer.StandardUserRole})) + + handlerErr := handler.websocketExec(httptest.NewRecorder(), req) + + require.NotNil(t, handlerErr, "expected an authorization error for a denied environment") + assert.Equal(t, http.StatusForbidden, handlerErr.StatusCode) +} diff --git a/api/http/handler/websocket/handler.go b/api/http/handler/websocket/handler.go new file mode 100644 index 0000000..2d15891 --- /dev/null +++ b/api/http/handler/websocket/handler.go @@ -0,0 +1,44 @@ +package websocket + +import ( + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/http/proxy/factory/kubernetes" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/kubernetes/cli" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + + "github.com/gorilla/mux" + "github.com/gorilla/websocket" +) + +// Handler is the HTTP handler used to handle websocket operations. +type Handler struct { + *mux.Router + DataStore dataservices.DataStore + SignatureService portainer.DigitalSignatureService + ReverseTunnelService portainer.ReverseTunnelService + KubernetesClientFactory *cli.ClientFactory + requestBouncer security.BouncerService + connectionUpgrader websocket.Upgrader + kubernetesTokenCacheManager *kubernetes.TokenCacheManager +} + +// NewHandler creates a handler to manage websocket operations. +func NewHandler(kubernetesTokenCacheManager *kubernetes.TokenCacheManager, bouncer security.BouncerService) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + connectionUpgrader: websocket.Upgrader{}, + requestBouncer: bouncer, + kubernetesTokenCacheManager: kubernetesTokenCacheManager, + } + h.PathPrefix("/websocket/exec").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))) + h.PathPrefix("/websocket/kubernetes-shell").Handler( + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.websocketShellPodExec))) + return h +} diff --git a/api/http/handler/websocket/initdial.go b/api/http/handler/websocket/initdial.go new file mode 100644 index 0000000..1be0e49 --- /dev/null +++ b/api/http/handler/websocket/initdial.go @@ -0,0 +1,34 @@ +package websocket + +import ( + "crypto/tls" + "net" + "net/url" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/crypto" +) + +func initDial(endpoint *portainer.Endpoint) (net.Conn, error) { + url, err := url.Parse(endpoint.URL) + if err != nil { + return nil, err + } + + host := url.Host + + if url.Scheme == "unix" || url.Scheme == "npipe" { + host = url.Path + } + + if !endpoint.TLSConfig.TLS { + return createDial(url.Scheme, host) + } + + tlsConfig, err := crypto.CreateTLSConfigurationFromDisk(endpoint.TLSConfig) + if err != nil { + return nil, err + } + + return tls.Dial(url.Scheme, host, tlsConfig) +} diff --git a/api/http/handler/websocket/initdial_test.go b/api/http/handler/websocket/initdial_test.go new file mode 100644 index 0000000..24cee95 --- /dev/null +++ b/api/http/handler/websocket/initdial_test.go @@ -0,0 +1,64 @@ +package websocket + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/pkg/fips" + + "github.com/stretchr/testify/require" +) + +func TestInitDial(t *testing.T) { + t.Parallel() + fips.InitFIPS(false) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + defer srv.Close() + + tlsSrv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + defer tlsSrv.Close() + + f := func(srvURL string) { + u, err := url.Parse(srvURL) + require.NoError(t, err) + + isTLS := u.Scheme == "https" + + u.Scheme = "tcp" + + endpoint := &portainer.Endpoint{ + URL: u.String(), + TLSConfig: portainer.TLSConfiguration{ + TLS: isTLS, + TLSSkipVerify: true, + }, + } + + // Valid configuration + conn, err := initDial(endpoint) + require.NoError(t, err) + require.NotNil(t, conn) + + err = conn.Close() + require.NoError(t, err) + + if !isTLS { + return + } + + // Invalid TLS configuration + endpoint.TLSConfig.TLSCertPath = "/invalid/path/client.crt" + endpoint.TLSConfig.TLSKeyPath = "/invalid/path/client.key" + + conn, err = initDial(endpoint) + require.Error(t, err) + require.Nil(t, conn) + } + + f(srv.URL) + f(tlsSrv.URL) +} diff --git a/api/http/handler/websocket/pod.go b/api/http/handler/websocket/pod.go new file mode 100644 index 0000000..f57abf4 --- /dev/null +++ b/api/http/handler/websocket/pod.go @@ -0,0 +1,214 @@ +package websocket + +import ( + "errors" + "io" + "net/http" + "strings" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/proxy/factory/kubernetes" + "github.com/portainer/portainer/api/http/security" + kubecli "github.com/portainer/portainer/api/kubernetes/cli" + "github.com/portainer/portainer/api/logs" + "github.com/portainer/portainer/api/ws" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + + "github.com/gorilla/websocket" + "github.com/rs/zerolog/log" +) + +// @summary Execute a websocket on pod +// @description The request will be upgraded to the websocket protocol. +// @description **Access policy**: authenticated +// @security ApiKeyAuth +// @security jwt +// @tags websocket +// @accept json +// @produce json +// @param endpointId query int true "environment(endpoint) ID of the environment(endpoint) where the resource is located" +// @param namespace query string true "namespace where the container is located" +// @param podName query string true "name of the pod containing the container" +// @param containerName query string true "name of the container" +// @param command query string true "command to execute in the container" +// @success 200 +// @failure 400 +// @failure 403 +// @failure 404 +// @failure 500 +// @router /websocket/pod [get] +func (handler *Handler) websocketPodExec(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", false) + if err != nil { + return httperror.BadRequest("Invalid query parameter: endpointId", err) + } + + namespace, err := request.RetrieveQueryParameter(r, "namespace", false) + if err != nil { + return httperror.BadRequest("Invalid query parameter: namespace", err) + } + + podName, err := request.RetrieveQueryParameter(r, "podName", false) + if err != nil { + return httperror.BadRequest("Invalid query parameter: podName", err) + } + + containerName, err := request.RetrieveQueryParameter(r, "containerName", false) + if err != nil { + return httperror.BadRequest("Invalid query parameter: containerName", err) + } + + command, err := request.RetrieveQueryParameter(r, "command", false) + if err != nil { + return httperror.BadRequest("Invalid query parameter: command", err) + } + + endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) + if handler.DataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find the environment associated to the stack inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find the environment associated to the stack inside the database", err) + } + + if err := handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint); err != nil { + return httperror.Forbidden("Permission denied to access environment", err) + } + + serviceAccountToken, isAdminToken, err := handler.getToken(r, endpoint, false) + if err != nil { + return httperror.InternalServerError("Unable to get user service account token", err) + } + + params := &webSocketRequestParams{ + endpoint: endpoint, + token: serviceAccountToken, + } + + r.Header.Del("Origin") + + if endpoint.Type == portainer.AgentOnKubernetesEnvironment { + if err := handler.proxyAgentWebsocketRequest(w, r, params); err != nil { + return httperror.InternalServerError("Unable to proxy websocket request to agent", err) + } + + return nil + } else if endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment { + if err := handler.proxyEdgeAgentWebsocketRequest(w, r, params); err != nil { + return httperror.InternalServerError("Unable to proxy websocket request to Edge agent", err) + } + + return nil + } + + cli, err := handler.KubernetesClientFactory.GetPrivilegedKubeClient(endpoint) + if err != nil { + return httperror.InternalServerError("Unable to create Kubernetes client", err) + } + + handlerErr := handler.hijackPodExecStartOperation(w, r, cli, serviceAccountToken, isAdminToken, endpoint, namespace, podName, containerName, command) + if handlerErr != nil { + return handlerErr + } + + return nil +} + +func (handler *Handler) hijackPodExecStartOperation( + w http.ResponseWriter, + r *http.Request, + cli portainer.KubeClient, + serviceAccountToken string, + isAdminToken bool, + endpoint *portainer.Endpoint, + namespace, podName, containerName, command string, +) *httperror.HandlerError { + commandArray := strings.Split(command, " ") + + websocketConn, err := handler.connectionUpgrader.Upgrade(w, r, nil) + if err != nil { + return httperror.InternalServerError("Unable to upgrade the connection", err) + } + defer logs.CloseAndLogErr(websocketConn) + + stdinReader, stdinWriter := io.Pipe() + defer logs.CloseAndLogErr(stdinWriter) + stdoutReader, stdoutWriter := io.Pipe() + defer logs.CloseAndLogErr(stdoutWriter) + + // errorChan is used to propagate errors from the go routines to the caller. + errorChan := make(chan error, 3) + + sizeQueue := kubecli.NewTerminalSizeQueue() + defer sizeQueue.Close() + go ws.StreamFromWebsocketToWriter(websocketConn, stdinWriter, errorChan, ws.ResizeHandler(sizeQueue)) + go ws.StreamFromReaderToWebsocket(websocketConn, stdoutReader, errorChan) + + // StartExecProcess is a blocking operation which streams IO to/from pod; + // this must execute in asynchronously, since the websocketConn could return errors (e.g. client disconnects) before + // the blocking operation is completed. + go cli.StartExecProcess(portainer.KubeExecParams{ + Token: serviceAccountToken, + UseAdminToken: isAdminToken, + Namespace: namespace, + PodName: podName, + ContainerName: containerName, + Command: commandArray, + Stdin: stdinReader, + Stdout: stdoutWriter, + ResizeQueue: sizeQueue, + ErrChan: errorChan, + }) + + err = <-errorChan + + if err == nil || errors.Is(err, io.EOF) { + // exec process ended normally (shell exited) - send a clean close frame to the browser + _ = websocketConn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, "")) + + return nil + } + + // websocket client successfully disconnected + if websocket.IsUnexpectedCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway, websocket.CloseNoStatusReceived) { + log.Debug().Err(err).Msg("websocket error") + + return nil + } + + return httperror.InternalServerError("Unable to start exec process inside container", err) +} + +func (handler *Handler) getToken(request *http.Request, endpoint *portainer.Endpoint, setLocalAdminToken bool) (string, bool, error) { + tokenData, err := security.RetrieveTokenData(request) + if err != nil { + return "", false, err + } + + kubecli, err := handler.KubernetesClientFactory.GetPrivilegedKubeClient(endpoint) + if err != nil { + return "", false, err + } + + tokenCache := handler.kubernetesTokenCacheManager.GetOrCreateTokenCache(endpoint.ID) + + tokenManager, err := kubernetes.NewTokenManager(kubecli, handler.DataStore, tokenCache, setLocalAdminToken) + if err != nil { + return "", false, err + } + + if tokenData.Role == portainer.AdministratorRole { + return tokenManager.GetAdminServiceAccountToken(), true, nil + } + + token, err := tokenManager.GetUserServiceAccountToken(int(tokenData.ID), endpoint.ID) + if err != nil { + return "", false, err + } + + if token == "" { + return "", false, errors.New("can not get a valid user service account token") + } + + return token, false, nil +} diff --git a/api/http/handler/websocket/pod_test.go b/api/http/handler/websocket/pod_test.go new file mode 100644 index 0000000..c8c6c29 --- /dev/null +++ b/api/http/handler/websocket/pod_test.go @@ -0,0 +1,62 @@ +package websocket + +import ( + "net/http" + "net/http/httptest" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/http/security" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// podExecQuery is the minimal set of query parameters required to reach the authorization +// check in websocketPodExec. +const podExecQuery = "/websocket/pod?endpointId=2&namespace=default&podName=p&containerName=c&command=sh" + +// TestWebsocketPodExec_deniesUnauthorizedEndpoint asserts a non-admin with no access policy on +// the environment is rejected with 403 — the environment-access (L2) gate (BE-13027). +func TestWebsocketPodExec_deniesUnauthorizedEndpoint(t *testing.T) { + handler, _ := newWebsocketTestHandler(t) + + user := &portainer.User{Username: "restricted", Role: portainer.StandardUserRole} + err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + return tx.User().Create(user) + }) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodGet, podExecQuery, nil) + req = req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ID: user.ID, Role: portainer.StandardUserRole})) + + handlerErr := handler.websocketPodExec(httptest.NewRecorder(), req) + + require.NotNil(t, handlerErr, "expected an authorization error for a denied environment") + assert.Equal(t, http.StatusForbidden, handlerErr.StatusCode) +} + +// TestWebsocketPodExec_allowsAuthorizedNonAdmin asserts a non-admin granted environment access +// passes authorization (reaching the nil client via getToken and panicking). CE has no +// operation-level (L3) layer, so environment access is the only gate (BE-13027). +func TestWebsocketPodExec_allowsAuthorizedNonAdmin(t *testing.T) { + handler, endpoint := newWebsocketTestHandler(t) + + user := &portainer.User{Username: "standard", Role: portainer.StandardUserRole} + err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + if err := tx.User().Create(user); err != nil { + return err + } + // Access is by membership; the access policy's role is irrelevant to the CE access decision. + endpoint.UserAccessPolicies = portainer.UserAccessPolicies{user.ID: {}} + return tx.Endpoint().UpdateEndpoint(endpoint.ID, endpoint) + }) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodGet, podExecQuery, nil) + req = req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ID: user.ID, Role: portainer.StandardUserRole})) + + assert.Panics(t, func() { + _ = handler.websocketPodExec(httptest.NewRecorder(), req) + }) +} diff --git a/api/http/handler/websocket/proxy.go b/api/http/handler/websocket/proxy.go new file mode 100644 index 0000000..da68489 --- /dev/null +++ b/api/http/handler/websocket/proxy.go @@ -0,0 +1,107 @@ +package websocket + +import ( + "net" + "net/http" + "net/url" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/crypto" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/logoutcontext" + + "github.com/gorilla/websocket" + "github.com/koding/websocketproxy" + "github.com/rs/zerolog/log" +) + +func (handler *Handler) proxyEdgeAgentWebsocketRequest(w http.ResponseWriter, r *http.Request, params *webSocketRequestParams) error { + tunnelAddr, err := handler.ReverseTunnelService.TunnelAddr(params.endpoint) + if err != nil { + return err + } + + agentURL, err := url.Parse("http://" + tunnelAddr) + if err != nil { + return err + } + + return handler.doProxyWebsocketRequest(w, r, params, agentURL, true) +} + +func (handler *Handler) proxyAgentWebsocketRequest(w http.ResponseWriter, r *http.Request, params *webSocketRequestParams) error { + endpointURL := params.endpoint.URL + if params.endpoint.Type == portainer.AgentOnKubernetesEnvironment { + endpointURL = "http://" + params.endpoint.URL + } + + agentURL, err := url.Parse(endpointURL) + if err != nil { + return err + } + + return handler.doProxyWebsocketRequest(w, r, params, agentURL, false) +} + +func (handler *Handler) doProxyWebsocketRequest( + w http.ResponseWriter, + r *http.Request, + params *webSocketRequestParams, + agentURL *url.URL, + isEdge bool, +) error { + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + log. + Warn(). + Err(err). + Msg("unable to retrieve user details from authentication token") + return err + } + + enableTLS := !isEdge && (params.endpoint.TLSConfig.TLS || params.endpoint.TLSConfig.TLSSkipVerify) + + agentURL.Scheme = "ws" + if enableTLS { + agentURL.Scheme = "wss" + } + + proxy := websocketproxy.NewProxy(agentURL) + proxyDialer := *websocket.DefaultDialer + proxy.Dialer = &proxyDialer + + if enableTLS { + proxyDialer.TLSClientConfig = crypto.CreateTLSConfiguration(params.endpoint.TLSConfig.TLSSkipVerify) + } + + signature, err := handler.SignatureService.CreateSignature(portainer.PortainerAgentSignatureMessage) + if err != nil { + return err + } + + proxy.Director = func(incoming *http.Request, out http.Header) { + out.Set(portainer.PortainerAgentPublicKeyHeader, handler.SignatureService.EncodedPublicKey()) + out.Set(portainer.PortainerAgentSignatureHeader, signature) + out.Set(portainer.PortainerAgentTargetHeader, params.nodeName) + out.Set(portainer.PortainerAgentKubernetesSATokenHeader, params.token) + } + + if isEdge { + handler.ReverseTunnelService.UpdateLastActivity(params.endpoint.ID) + handler.ReverseTunnelService.KeepTunnelAlive(params.endpoint.ID, r.Context(), portainer.WebSocketKeepAlive) + } + + proxy.Dialer.NetDial = func(network, addr string) (net.Conn, error) { + netDialer := &net.Dialer{} + + logoutCtx := logoutcontext.GetContext(tokenData.Token) + + conn, err := netDialer.DialContext(logoutCtx, network, addr) + + return conn, err + } + + proxy.ServeHTTP(w, r) + + return nil +} diff --git a/api/http/handler/websocket/shell_pod.go b/api/http/handler/websocket/shell_pod.go new file mode 100644 index 0000000..a5e0093 --- /dev/null +++ b/api/http/handler/websocket/shell_pod.go @@ -0,0 +1,122 @@ +package websocket + +import ( + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/security" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" +) + +// @summary Execute a websocket on kubectl shell pod +// @description The request will be upgraded to the websocket protocol. The request will proxy input from the client to the pod via long-lived websocket connection. +// @description **Access policy**: authenticated +// @security ApiKeyAuth +// @security jwt +// @tags websocket +// @accept json +// @produce json +// @param endpointId query int true "environment(endpoint) ID of the environment(endpoint) where the resource is located" +// @success 200 "Success" +// @failure 400 "Invalid request" +// @failure 403 "Permission denied" +// @failure 404 "Environment not found" +// @failure 500 "Server error" +// @router /websocket/kubernetes-shell [get] +func (handler *Handler) websocketShellPodExec(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", false) + if err != nil { + return httperror.BadRequest("Invalid query parameter: endpointId", err) + } + + endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) + if handler.DataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find the environment associated to the stack inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find the environment associated to the stack inside the database", err) + } + + if err := handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint); err != nil { + return httperror.Forbidden("Permission denied to access environment", err) + } + + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve user authentication token", err) + } + + cli, err := handler.KubernetesClientFactory.GetPrivilegedKubeClient(endpoint) + if err != nil { + return httperror.InternalServerError("Unable to create Kubernetes client", err) + } + + serviceAccount, err := cli.GetPortainerUserServiceAccount(tokenData) + if err != nil { + return httperror.InternalServerError("Unable to find serviceaccount associated with user", err) + } + + settings, err := handler.DataStore.Settings().Settings() + if err != nil { + return httperror.InternalServerError("Unable read settings", err) + } + + shellPod, err := cli.CreateUserShellPod(r.Context(), serviceAccount.Name, settings.KubectlShellImage) + if err != nil { + return httperror.InternalServerError("Unable to create user shell", err) + } + + // Modifying request params mid-flight before forewarding to K8s API server (websocket) + q := r.URL.Query() + + q.Add("namespace", shellPod.Namespace) + q.Add("podName", shellPod.PodName) + q.Add("containerName", shellPod.ContainerName) + q.Add("command", shellPod.ShellExecCommand) + + r.URL.RawQuery = q.Encode() + + // Modify url path mid-flight before forewarding to k8s API server (websocket) + r.URL.Path = "/websocket/pod" + + /* + Note: The following websocket proxying logic is duplicated from `api/http/handler/websocket/pod.go` + */ + params := &webSocketRequestParams{ + endpoint: endpoint, + } + + r.Header.Del("Origin") + + if endpoint.Type == portainer.AgentOnKubernetesEnvironment { + err := handler.proxyAgentWebsocketRequest(w, r, params) + if err != nil { + return httperror.InternalServerError("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.InternalServerError("Unable to proxy websocket request to Edge agent", err) + } + return nil + } + + handlerErr := handler.hijackPodExecStartOperation( + w, + r, + cli, + "", + true, + endpoint, + shellPod.Namespace, + shellPod.PodName, + shellPod.ContainerName, + shellPod.ShellExecCommand, + ) + if handlerErr != nil { + return handlerErr + } + + return nil +} diff --git a/api/http/handler/websocket/shell_pod_test.go b/api/http/handler/websocket/shell_pod_test.go new file mode 100644 index 0000000..d244374 --- /dev/null +++ b/api/http/handler/websocket/shell_pod_test.go @@ -0,0 +1,101 @@ +package websocket + +import ( + "net/http" + "net/http/httptest" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/http/security" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// newWebsocketTestHandler builds a websocket Handler backed by a test store with a single +// Kubernetes environment (ID 2) and a real bouncer, returning both the handler and that +// endpoint so callers can grant access via its UserAccessPolicies. KubernetesClientFactory is +// left nil so any handler that proceeds past authorization trips a clear panic. Shared by the +// exec/attach/pod/kubernetes-shell L2 tests (BE-13027). +func newWebsocketTestHandler(t *testing.T) (*Handler, *portainer.Endpoint) { + t.Helper() + + _, store := datastore.MustNewTestStore(t, true, false) + + endpoint := &portainer.Endpoint{ + ID: 2, + Name: "target-env", + Type: portainer.AgentOnKubernetesEnvironment, + GroupID: 1, + } + require.NoError(t, store.Endpoint().Create(endpoint)) + + bouncer := security.NewRequestBouncer(t.Context(), store, nil, nil) + + handler := &Handler{ + DataStore: store, + requestBouncer: bouncer, + // KubernetesClientFactory intentionally left nil. + } + + return handler, endpoint +} + +// TestWebsocketShellPodExec_deniesUnauthorizedEndpoint asserts a non-admin with no access +// policy on the environment is rejected with 403 — the environment-access (L2) gate (BE-13027). +func TestWebsocketShellPodExec_deniesUnauthorizedEndpoint(t *testing.T) { + handler, _ := newWebsocketTestHandler(t) + + user := &portainer.User{Username: "restricted", Role: portainer.StandardUserRole} + err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + return tx.User().Create(user) + }) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodGet, "/websocket/kubernetes-shell?endpointId=2", nil) + req = req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ID: user.ID, Role: portainer.StandardUserRole})) + + handlerErr := handler.websocketShellPodExec(httptest.NewRecorder(), req) + + require.NotNil(t, handlerErr, "expected an authorization error for a denied environment") + assert.Equal(t, http.StatusForbidden, handlerErr.StatusCode) +} + +// TestWebsocketShellPodExec_allowsAuthorizedEndpoint asserts an admin passes authorization and +// reaches the nil KubernetesClientFactory (panic proves auth did not block the request) (BE-13027). +func TestWebsocketShellPodExec_allowsAuthorizedEndpoint(t *testing.T) { + handler, _ := newWebsocketTestHandler(t) + + req := httptest.NewRequest(http.MethodGet, "/websocket/kubernetes-shell?endpointId=2", nil) + req = req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole})) + + assert.Panics(t, func() { + _ = handler.websocketShellPodExec(httptest.NewRecorder(), req) + }) +} + +// TestWebsocketShellPodExec_allowsAuthorizedNonAdmin asserts a non-admin granted environment +// access passes authorization (reaching the nil client and panicking). CE has no operation-level +// (L3) layer, so environment access is the only gate (BE-13027). +func TestWebsocketShellPodExec_allowsAuthorizedNonAdmin(t *testing.T) { + handler, endpoint := newWebsocketTestHandler(t) + + user := &portainer.User{Username: "standard", Role: portainer.StandardUserRole} + err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + if err := tx.User().Create(user); err != nil { + return err + } + // Access is by membership; the access policy's role is irrelevant to the CE access decision. + endpoint.UserAccessPolicies = portainer.UserAccessPolicies{user.ID: {}} + return tx.Endpoint().UpdateEndpoint(endpoint.ID, endpoint) + }) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodGet, "/websocket/kubernetes-shell?endpointId=2", nil) + req = req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ID: user.ID, Role: portainer.StandardUserRole})) + + assert.Panics(t, func() { + _ = handler.websocketShellPodExec(httptest.NewRecorder(), req) + }) +} diff --git a/api/http/handler/websocket/types.go b/api/http/handler/websocket/types.go new file mode 100644 index 0000000..97b3334 --- /dev/null +++ b/api/http/handler/websocket/types.go @@ -0,0 +1,10 @@ +package websocket + +import portainer "github.com/portainer/portainer/api" + +type webSocketRequestParams struct { + ID string + nodeName string + endpoint *portainer.Endpoint + token string +} diff --git a/api/http/logger.go b/api/http/logger.go new file mode 100644 index 0000000..637d253 --- /dev/null +++ b/api/http/logger.go @@ -0,0 +1,19 @@ +package http + +import ( + "log" + + zlog "github.com/rs/zerolog/log" +) + +type httpLogger struct{} + +func NewHTTPLogger() *log.Logger { + return log.New(httpLogger{}, "", 0) +} + +func (l httpLogger) Write(data []byte) (int, error) { + zlog.Debug().CallerSkipFrame(3).Msg(string(data)) + + return len(data), nil +} diff --git a/api/http/logger_test.go b/api/http/logger_test.go new file mode 100644 index 0000000..7828b48 --- /dev/null +++ b/api/http/logger_test.go @@ -0,0 +1,22 @@ +package http + +import ( + "bytes" + "testing" + + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "github.com/stretchr/testify/require" +) + +func TestLogger(t *testing.T) { + t.Parallel() + msg := "Testing HTTP logger" + buf := &bytes.Buffer{} + + log.Logger = zerolog.New(buf) + + NewHTTPLogger().Print(msg) + + require.Contains(t, buf.String(), msg) +} diff --git a/api/http/middlewares/deprecated.go b/api/http/middlewares/deprecated.go new file mode 100644 index 0000000..945eaa8 --- /dev/null +++ b/api/http/middlewares/deprecated.go @@ -0,0 +1,40 @@ +package middlewares + +import ( + "net/http" + + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/rs/zerolog/log" +) + +// deprecate api route +func Deprecated(router http.Handler, urlBuilder func(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError)) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + newUrl, err := urlBuilder(w, r) + if err != nil { + httperror.WriteError(w, err.StatusCode, err.Error(), err) + return + } + + if newUrl == "" { + log.Warn().Msg("This api is deprecated.") + router.ServeHTTP(w, r) + return + } + + log.Warn().Str("new_url", newUrl).Msg("this API endpoint is deprecated") + + redirectedRequest := r.Clone(r.Context()) + redirectedRequest.URL.Path = newUrl + router.ServeHTTP(w, redirectedRequest) + }) +} + +// DeprecatedSimple is a middleware that marks an API route as deprecated +// +// if needed, use Deprecated with a custom urlBuilder +func DeprecatedSimple(h http.Handler) http.Handler { + return Deprecated(h, func(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) { + return "", nil + }) +} diff --git a/api/http/middlewares/deprecated_test.go b/api/http/middlewares/deprecated_test.go new file mode 100644 index 0000000..b074925 --- /dev/null +++ b/api/http/middlewares/deprecated_test.go @@ -0,0 +1,325 @@ +package middlewares + +import ( + "io" + "net/http" + "net/http/httptest" + "testing" + + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDeprecated(t *testing.T) { + t.Parallel() + tests := []struct { + name string + urlBuilder func(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) + requestPath string + expectedStatusCode int + expectedPath string + expectRedirect bool + }{ + { + name: "empty URL - no redirect", + urlBuilder: func(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) { + return "", nil + }, + requestPath: "/api/old", + expectedStatusCode: http.StatusOK, + expectedPath: "/api/old", + expectRedirect: false, + }, + { + name: "new URL provided - redirects", + urlBuilder: func(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) { + return "/api/new", nil + }, + requestPath: "/api/old", + expectedStatusCode: http.StatusOK, + expectedPath: "/api/new", + expectRedirect: true, + }, + { + name: "urlBuilder returns error - returns error response", + urlBuilder: func(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) { + return "", httperror.BadRequest("invalid request", nil) + }, + requestPath: "/api/old", + expectedStatusCode: http.StatusBadRequest, + expectedPath: "", + expectRedirect: false, + }, + { + name: "urlBuilder returns server error", + urlBuilder: func(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) { + return "", httperror.InternalServerError("server error", nil) + }, + requestPath: "/api/old", + expectedStatusCode: http.StatusInternalServerError, + expectedPath: "", + expectRedirect: false, + }, + { + name: "dynamic URL based on request path", + urlBuilder: func(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) { + return "/v2" + r.URL.Path, nil + }, + requestPath: "/api/resource/123", + expectedStatusCode: http.StatusOK, + expectedPath: "/v2/api/resource/123", + expectRedirect: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a test handler that records the request path + var handledPath string + testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handledPath = r.URL.Path + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("success")) + }) + + // Wrap with Deprecated middleware + wrappedHandler := Deprecated(testHandler, tt.urlBuilder) + + // Create test request + req := httptest.NewRequest(http.MethodGet, tt.requestPath, nil) + rec := httptest.NewRecorder() + + // Execute request + wrappedHandler.ServeHTTP(rec, req) + + // Check status code + assert.Equal(t, tt.expectedStatusCode, rec.Code, "unexpected status code") + + // For error cases, don't check the path + if tt.expectedStatusCode >= 400 { + return + } + + // Check that the correct path was handled + if tt.expectRedirect { + assert.Equal(t, tt.expectedPath, handledPath, "path was not redirected correctly") + } else { + assert.Equal(t, tt.requestPath, handledPath, "original path was not preserved") + } + + // Check response body for success cases + body, err := io.ReadAll(rec.Body) + require.NoError(t, err) + assert.Equal(t, "success", string(body), "unexpected response body") + }) + } +} + +func TestDeprecatedSimple(t *testing.T) { + t.Parallel() + // Create a test handler + testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("test response")) + }) + + // Wrap with DeprecatedSimple middleware + wrappedHandler := DeprecatedSimple(testHandler) + + // Create test request + req := httptest.NewRequest(http.MethodGet, "/api/test", nil) + rec := httptest.NewRecorder() + + // Execute request + wrappedHandler.ServeHTTP(rec, req) + + // Check that request was successful + assert.Equal(t, http.StatusOK, rec.Code, "unexpected status code") + + // Check response body + body, err := io.ReadAll(rec.Body) + require.NoError(t, err) + assert.Equal(t, "test response", string(body), "unexpected response body") +} + +func TestDeprecated_PreservesRequestContext(t *testing.T) { + t.Parallel() + // Test that the middleware preserves request context when redirecting + urlBuilder := func(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) { + return "/new-path", nil + } + + var receivedRequest *http.Request + testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedRequest = r + w.WriteHeader(http.StatusOK) + }) + + wrappedHandler := Deprecated(testHandler, urlBuilder) + + req := httptest.NewRequest(http.MethodGet, "/old-path", nil) + rec := httptest.NewRecorder() + + wrappedHandler.ServeHTTP(rec, req) + + require.NotNil(t, receivedRequest, "request was not passed to handler") + assert.Equal(t, req.Context(), receivedRequest.Context(), "request context was not preserved") +} + +func TestDeprecated_PreservesRequestMethod(t *testing.T) { + t.Parallel() + methods := []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodPatch} + + urlBuilder := func(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) { + return "/new-path", nil + } + + for _, method := range methods { + t.Run(method, func(t *testing.T) { + var receivedMethod string + testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedMethod = r.Method + w.WriteHeader(http.StatusOK) + }) + + wrappedHandler := Deprecated(testHandler, urlBuilder) + + req := httptest.NewRequest(method, "/old-path", nil) + rec := httptest.NewRecorder() + + wrappedHandler.ServeHTTP(rec, req) + + assert.Equal(t, method, receivedMethod, "HTTP method was not preserved") + }) + } +} + +func TestDeprecated_PreservesRequestHeaders(t *testing.T) { + t.Parallel() + urlBuilder := func(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) { + return "/new-path", nil + } + + var receivedHeaders http.Header + testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedHeaders = r.Header + w.WriteHeader(http.StatusOK) + }) + + wrappedHandler := Deprecated(testHandler, urlBuilder) + + req := httptest.NewRequest(http.MethodGet, "/old-path", nil) + req.Header.Set("Authorization", "Bearer token123") + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + wrappedHandler.ServeHTTP(rec, req) + + assert.Equal(t, "Bearer token123", receivedHeaders.Get("Authorization"), "Authorization header was not preserved") + assert.Equal(t, "application/json", receivedHeaders.Get("Content-Type"), "Content-Type header was not preserved") +} + +func TestDeprecated_PreservesRequestBody(t *testing.T) { + t.Parallel() + urlBuilder := func(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) { + return "/new-path", nil + } + + var receivedBody string + testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + receivedBody = string(body) + w.WriteHeader(http.StatusOK) + }) + + wrappedHandler := Deprecated(testHandler, urlBuilder) + + req := httptest.NewRequest(http.MethodPost, "/old-path", http.NoBody) + rec := httptest.NewRecorder() + + wrappedHandler.ServeHTTP(rec, req) + + // Body should be preserved (empty in this case since we used http.NoBody) + assert.Empty(t, receivedBody, "expected empty body") +} + +func TestDeprecated_ErrorResponseFormat(t *testing.T) { + t.Parallel() + urlBuilder := func(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) { + return "", httperror.BadRequest("test error message", nil) + } + + handlerCalled := false + testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handlerCalled = true + w.WriteHeader(http.StatusOK) + }) + + wrappedHandler := Deprecated(testHandler, urlBuilder) + + req := httptest.NewRequest(http.MethodGet, "/api/test", nil) + rec := httptest.NewRecorder() + + wrappedHandler.ServeHTTP(rec, req) + + assert.False(t, handlerCalled, "handler should not be called when urlBuilder returns error") + assert.Equal(t, http.StatusBadRequest, rec.Code, "unexpected status code") + + // The httperror.WriteError function should have written the error response + body, err := io.ReadAll(rec.Body) + require.NoError(t, err) + assert.NotEmpty(t, body, "expected error response body") +} + +func TestDeprecated_WithQueryParameters(t *testing.T) { + t.Parallel() + urlBuilder := func(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) { + return "/api/v2/resource", nil + } + + var receivedQuery string + testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedQuery = r.URL.RawQuery + w.WriteHeader(http.StatusOK) + }) + + wrappedHandler := Deprecated(testHandler, urlBuilder) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/resource?filter=active&sort=name", nil) + rec := httptest.NewRecorder() + + wrappedHandler.ServeHTTP(rec, req) + + assert.Equal(t, "filter=active&sort=name", receivedQuery, "query parameters were not preserved") +} + +func TestDeprecated_WithMultipleRedirects(t *testing.T) { + t.Parallel() + // Test that multiple deprecated middleware can be chained + urlBuilder1 := func(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) { + return "/v2" + r.URL.Path, nil + } + + urlBuilder2 := func(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) { + return "/api" + r.URL.Path, nil + } + + var finalPath string + testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + finalPath = r.URL.Path + w.WriteHeader(http.StatusOK) + }) + + // Chain two deprecated middlewares + wrappedHandler := Deprecated(Deprecated(testHandler, urlBuilder2), urlBuilder1) + + req := httptest.NewRequest(http.MethodGet, "/old", nil) + rec := httptest.NewRecorder() + + wrappedHandler.ServeHTTP(rec, req) + + // First middleware redirects to /v2/old + // Second middleware redirects to /api/v2/old + assert.Equal(t, "/api/v2/old", finalPath, "chained redirects did not work correctly") +} diff --git a/api/http/middlewares/endpoint.go b/api/http/middlewares/endpoint.go new file mode 100644 index 0000000..0050e43 --- /dev/null +++ b/api/http/middlewares/endpoint.go @@ -0,0 +1,84 @@ +package middlewares + +import ( + "context" + "errors" + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/http/security" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + requesthelpers "github.com/portainer/portainer/pkg/libhttp/request" + + "github.com/gorilla/mux" +) + +// Note: context keys must be distinct types to prevent collisions. They are NOT key/value map's internally +// See: https://go.dev/blog/context#TOC_3.2. + +// This avoids staticcheck error: +// SA1029: should not use built-in type string as key for value; define your own type to avoid collisions (staticcheck) +// https://stackoverflow.com/questions/40891345/fix-should-not-use-basic-type-string-as-key-in-context-withvalue-golint +type key int + +const contextEndpoint key = 0 + +func WithEndpoint(endpointService dataservices.EndpointService, endpointIDParam string) mux.MiddlewareFunc { + if endpointIDParam == "" { + endpointIDParam = "id" + } + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, request *http.Request) { + endpointID, err := requesthelpers.RetrieveNumericRouteVariableValue(request, endpointIDParam) + if err != nil { + httperror.WriteError(rw, http.StatusBadRequest, "Invalid environment identifier route variable", err) + return + } + + endpoint, err := endpointService.Endpoint(portainer.EndpointID(endpointID)) + if err != nil { + statusCode := http.StatusInternalServerError + + if dataservices.IsErrObjectNotFound(err) { + statusCode = http.StatusNotFound + } + httperror.WriteError(rw, statusCode, "Unable to find an environment with the specified identifier inside the database", err) + return + } + + ctx := context.WithValue(request.Context(), contextEndpoint, endpoint) + + next.ServeHTTP(rw, request.WithContext(ctx)) + }) + } +} + +func FetchEndpoint(request *http.Request) (*portainer.Endpoint, error) { + contextData := request.Context().Value(contextEndpoint) + if contextData == nil { + return nil, errors.New("Unable to find environment data in request context") + } + + return contextData.(*portainer.Endpoint), nil +} + +func CheckEndpointAuthorization(bouncer security.BouncerService) mux.MiddlewareFunc { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + endpoint, err := FetchEndpoint(r) + if err != nil { + httperror.WriteError(w, http.StatusNotFound, "Unable to find an environment on request context", err) + return + } + + if err = bouncer.AuthorizedEndpointOperation(r, endpoint); err != nil { + httperror.WriteError(w, http.StatusForbidden, "Permission denied to access environment", err) + return + } + + next.ServeHTTP(w, r) + }) + } +} diff --git a/api/http/middlewares/featureflag.go b/api/http/middlewares/featureflag.go new file mode 100644 index 0000000..7498b11 --- /dev/null +++ b/api/http/middlewares/featureflag.go @@ -0,0 +1,26 @@ +package middlewares + +import ( + "net/http" + + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/pkg/featureflags" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + + "github.com/gorilla/mux" +) + +func FeatureFlag(settingsService dataservices.SettingsService, feature featureflags.Feature) mux.MiddlewareFunc { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, request *http.Request) { + enabled := featureflags.IsEnabled(feature) + + if !enabled { + httperror.WriteError(rw, http.StatusForbidden, "This feature is not enabled", nil) + return + } + + next.ServeHTTP(rw, request) + }) + } +} diff --git a/api/http/middlewares/panic_logger.go b/api/http/middlewares/panic_logger.go new file mode 100644 index 0000000..6f3b207 --- /dev/null +++ b/api/http/middlewares/panic_logger.go @@ -0,0 +1,25 @@ +package middlewares + +import ( + "net/http" + "runtime/debug" + + "github.com/rs/zerolog/log" +) + +func WithPanicLogger(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + defer func() { + if err := recover(); err != nil { + log.Error(). + Any("panic", err). + Str("method", req.Method). + Str("url", req.URL.String()). + Str("stack", string(debug.Stack())). + Msg("Panic in request handler") + } + }() + + next.ServeHTTP(w, req) + }) +} diff --git a/api/http/middlewares/proxy_headers.go b/api/http/middlewares/proxy_headers.go new file mode 100644 index 0000000..cd617fa --- /dev/null +++ b/api/http/middlewares/proxy_headers.go @@ -0,0 +1,44 @@ +package middlewares + +import ( + "net/http" + "strings" +) + +// parseForwardedHeaderProto parses the Forwarded header and extracts the protocol. +// The Forwarded header format supports: +// - Single proxy: Forwarded: by=;for=;host=;proto= +// - Multiple proxies: Forwarded: for=192.0.2.43, for=198.51.100.17 +// We take the first (leftmost) entry as it represents the original client +func parseForwardedHeaderProto(forwarded string) string { + if forwarded == "" { + return "" + } + + // Parse the first part (leftmost proxy, closest to original client) + firstPart, _, _ := strings.Cut(forwarded, ",") + firstPart = strings.TrimSpace(firstPart) + + // Split by semicolon to get key-value pairs within this proxy entry + // Format: key=value;key=value;key=value + for pair := range strings.SplitSeq(firstPart, ";") { + // Split by equals sign to separate key and value + key, value, found := strings.Cut(pair, "=") + if !found { + continue + } + + if strings.EqualFold(strings.TrimSpace(key), "proto") { + return strings.Trim(strings.TrimSpace(value), `"'`) + } + } + + return "" +} + +// IsHTTPSRequest checks if the original request was made over HTTPS +// by examining both X-Forwarded-Proto and Forwarded headers +func IsHTTPSRequest(r *http.Request) bool { + return strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") || + strings.EqualFold(parseForwardedHeaderProto(r.Header.Get("Forwarded")), "https") +} diff --git a/api/http/middlewares/proxy_headers_test.go b/api/http/middlewares/proxy_headers_test.go new file mode 100644 index 0000000..58650ff --- /dev/null +++ b/api/http/middlewares/proxy_headers_test.go @@ -0,0 +1,174 @@ +package middlewares + +import ( + "testing" +) + +var tests = []struct { + name string + forwarded string + expected string +}{ + { + name: "empty header", + forwarded: "", + expected: "", + }, + { + name: "single proxy with proto=https", + forwarded: "proto=https", + expected: "https", + }, + { + name: "single proxy with proto=http", + forwarded: "proto=http", + expected: "http", + }, + { + name: "single proxy with multiple directives", + forwarded: "for=192.0.2.60;proto=https;by=203.0.113.43", + expected: "https", + }, + { + name: "single proxy with proto in middle", + forwarded: "for=192.0.2.60;proto=https;host=example.com", + expected: "https", + }, + { + name: "single proxy with proto at end", + forwarded: "for=192.0.2.60;host=example.com;proto=https", + expected: "https", + }, + { + name: "multiple proxies - takes first", + forwarded: "proto=https, proto=http", + expected: "https", + }, + { + name: "multiple proxies with complex format", + forwarded: "for=192.0.2.43;proto=https, for=198.51.100.17;proto=http", + expected: "https", + }, + { + name: "multiple proxies with for directive only", + forwarded: "for=192.0.2.43, for=198.51.100.17", + expected: "", + }, + { + name: "multiple proxies with proto only in second", + forwarded: "for=192.0.2.43, proto=https", + expected: "", + }, + { + name: "multiple proxies with proto only in first", + forwarded: "proto=https, for=198.51.100.17", + expected: "https", + }, + { + name: "quoted protocol value", + forwarded: "proto=\"https\"", + expected: "https", + }, + { + name: "single quoted protocol value", + forwarded: "proto='https'", + expected: "https", + }, + { + name: "mixed case protocol", + forwarded: "proto=HTTPS", + expected: "HTTPS", + }, + { + name: "no proto directive", + forwarded: "for=192.0.2.60;by=203.0.113.43", + expected: "", + }, + { + name: "empty proto value", + forwarded: "proto=", + expected: "", + }, + { + name: "whitespace around values", + forwarded: " proto = https ", + expected: "https", + }, + { + name: "whitespace around semicolons", + forwarded: "for=192.0.2.60 ; proto=https ; by=203.0.113.43", + expected: "https", + }, + { + name: "whitespace around commas", + forwarded: "proto=https , proto=http", + expected: "https", + }, + { + name: "IPv6 address in for directive", + forwarded: "for=\"[2001:db8:cafe::17]:4711\";proto=https", + expected: "https", + }, + { + name: "complex multiple proxies with IPv6", + forwarded: "for=192.0.2.43;proto=https, for=\"[2001:db8:cafe::17]\";proto=http", + expected: "https", + }, + { + name: "obfuscated identifiers", + forwarded: "for=_mdn;proto=https", + expected: "https", + }, + { + name: "unknown identifier", + forwarded: "for=unknown;proto=https", + expected: "https", + }, + { + name: "malformed key-value pair", + forwarded: "proto", + expected: "", + }, + { + name: "malformed key-value pair with equals", + forwarded: "proto=", + expected: "", + }, + { + name: "multiple equals signs", + forwarded: "proto=https=extra", + expected: "https=extra", + }, + { + name: "mixed case directive name", + forwarded: "PROTO=https", + expected: "https", + }, + { + name: "mixed case directive name with spaces", + forwarded: " Proto = https ", + expected: "https", + }, +} + +func TestParseForwardedHeaderProto(t *testing.T) { + t.Parallel() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := parseForwardedHeaderProto(tt.forwarded) + if result != tt.expected { + t.Errorf("parseForwardedHeader(%q) = %q, want %q", tt.forwarded, result, tt.expected) + } + }) + } +} + +func FuzzParseForwardedHeaderProto(f *testing.F) { + for _, t := range tests { + f.Add(t.forwarded) + } + + f.Fuzz(func(t *testing.T, forwarded string) { + parseForwardedHeaderProto(forwarded) + }) +} diff --git a/api/http/middlewares/slow_request_logger.go b/api/http/middlewares/slow_request_logger.go new file mode 100644 index 0000000..4bc2ae3 --- /dev/null +++ b/api/http/middlewares/slow_request_logger.go @@ -0,0 +1,36 @@ +package middlewares + +import ( + "net/http" + "time" + + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" +) + +func WithSlowRequestsLogger(next http.Handler) http.Handler { + if zerolog.GlobalLevel() > zerolog.DebugLevel { + return next + } + + burstSampler := &zerolog.BurstSampler{ + Burst: 1, + Period: time.Minute, + } + + log := log.With().Logger().Sample(burstSampler) + + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + t0 := time.Now() + + next.ServeHTTP(w, req) + + if d := time.Since(t0); d > 100*time.Millisecond { + log.Debug(). + Dur("elapsed_ms", d). + Str("method", req.Method). + Str("url", req.URL.String()). + Msg("slow request") + } + }) +} diff --git a/api/http/middlewares/withitem.go b/api/http/middlewares/withitem.go new file mode 100644 index 0000000..fce95e8 --- /dev/null +++ b/api/http/middlewares/withitem.go @@ -0,0 +1,60 @@ +package middlewares + +import ( + "context" + "errors" + "net/http" + + "github.com/portainer/portainer/api/dataservices" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + + "github.com/gorilla/mux" +) + +type ItemContextKey string + +type ItemGetter[TId ~int, TObject any] func(id TId) (*TObject, error) + +func WithItem[TId ~int, TObject any](getter ItemGetter[TId, TObject], idParam string, contextKey ItemContextKey) mux.MiddlewareFunc { + if idParam == "" { + idParam = "id" + } + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + itemId, err := request.RetrieveNumericRouteVariableValue(req, idParam) + if err != nil { + httperror.WriteError(rw, http.StatusBadRequest, "Invalid identifier route variable", err) + return + } + + item, err := getter(TId(itemId)) + if err != nil { + statusCode := http.StatusInternalServerError + if dataservices.IsErrObjectNotFound(err) { + statusCode = http.StatusNotFound + } + httperror.WriteError(rw, statusCode, "Unable to find a object with the specified identifier inside the database", err) + + return + } + ctx := context.WithValue(req.Context(), contextKey, item) + next.ServeHTTP(rw, req.WithContext(ctx)) + }) + } +} + +func FetchItem[T any](request *http.Request, contextKey ItemContextKey) (*T, error) { + contextData := request.Context().Value(contextKey) + if contextData == nil { + return nil, errors.New("unable to find item in request context") + } + + item, ok := contextData.(*T) + if !ok { + return nil, errors.New("unable to cast context item") + } + + return item, nil +} diff --git a/api/http/models/kubernetes/application.go b/api/http/models/kubernetes/application.go new file mode 100644 index 0000000..231f6b5 --- /dev/null +++ b/api/http/models/kubernetes/application.go @@ -0,0 +1,100 @@ +package kubernetes + +import ( + "time" + + autoscalingv2 "k8s.io/api/autoscaling/v2" + corev1 "k8s.io/api/core/v1" +) + +type K8sApplication struct { + ID string `json:"Id"` + Name string `json:"Name"` + Image string `json:"Image"` + Containers []any `json:"Containers,omitempty"` + Services []corev1.Service `json:"Services" swaggerignore:"true"` + CreationDate time.Time `json:"CreationDate"` + ApplicationOwner string `json:"ApplicationOwner,omitempty"` + StackName string `json:"StackName,omitempty"` + ResourcePool string `json:"ResourcePool"` + ApplicationType string `json:"ApplicationType"` + Metadata *Metadata `json:"Metadata,omitempty"` + Status string `json:"Status"` + TotalPodsCount int `json:"TotalPodsCount"` + RunningPodsCount int `json:"RunningPodsCount"` + DeploymentType string `json:"DeploymentType"` + Pods []Pod `json:"Pods,omitempty"` + Configurations []Configuration `json:"Configurations,omitempty"` + LoadBalancerIPAddress string `json:"LoadBalancerIPAddress,omitempty"` + PublishedPorts []PublishedPort `json:"PublishedPorts,omitempty"` + Namespace string `json:"Namespace,omitempty"` + UID string `json:"Uid,omitempty"` + StackID string `json:"StackId,omitempty"` + ServiceID string `json:"ServiceId,omitempty"` + ServiceName string `json:"ServiceName,omitempty"` + ServiceType string `json:"ServiceType,omitempty"` + Kind string `json:"Kind,omitempty"` + MatchLabels map[string]string `json:"MatchLabels,omitempty"` + Labels map[string]string `json:"Labels,omitempty"` + Annotations map[string]string `json:"Annotations,omitempty"` + Resource K8sApplicationResource `json:"Resource,omitzero"` + HorizontalPodAutoscaler *autoscalingv2.HorizontalPodAutoscaler `json:"HorizontalPodAutoscaler,omitempty" swaggerignore:"true"` + CustomResourceMetadata CustomResourceMetadata `json:"CustomResourceMetadata,omitzero"` + StackKind string `json:"StackKind,omitempty"` +} + +type Metadata struct { + Labels map[string]string `json:"labels"` + Annotations map[string]string `json:"annotations"` +} + +type CustomResourceMetadata struct { + Name string `json:"name"` + Kind string `json:"kind"` + Scope string `json:"scope"` + APIVersion string `json:"apiVersion"` + Plural string `json:"plural"` +} + +type Pod struct { + Name string `json:"Name"` + ContainerName string `json:"ContainerName"` + Image string `json:"Image"` + ImagePullPolicy string `json:"ImagePullPolicy"` + Status string `json:"Status"` + NodeName string `json:"NodeName"` + PodIP string `json:"PodIP"` + UID string `json:"Uid"` + Resource K8sApplicationResource `json:"Resource,omitzero"` + CreationDate time.Time `json:"CreationDate"` +} + +type Configuration struct { + Data map[string]any `json:"Data,omitempty"` + Kind string `json:"Kind"` + ConfigurationOwner string `json:"ConfigurationOwner"` +} + +type PublishedPort struct { + IngressRules []IngressRule `json:"IngressRules"` + Port int `json:"Port"` +} + +type IngressRule struct { + Host string `json:"Host"` + IP string `json:"IP"` + Path string `json:"Path"` + TLS []TLSInfo `json:"TLS"` +} + +type TLSInfo struct { + Hosts []string `json:"hosts"` +} + +// Existing types +type K8sApplicationResource struct { + CPURequest float64 `json:"CpuRequest,omitempty"` + CPULimit float64 `json:"CpuLimit,omitempty"` + MemoryRequest int64 `json:"MemoryRequest,omitempty"` + MemoryLimit int64 `json:"MemoryLimit,omitempty"` +} diff --git a/api/http/models/kubernetes/cluster_role_bindings.go b/api/http/models/kubernetes/cluster_role_bindings.go new file mode 100644 index 0000000..5469739 --- /dev/null +++ b/api/http/models/kubernetes/cluster_role_bindings.go @@ -0,0 +1,33 @@ +package kubernetes + +import ( + "errors" + "net/http" + "time" + + rbacv1 "k8s.io/api/rbac/v1" + "k8s.io/apimachinery/pkg/types" +) + +type ( + K8sClusterRoleBinding struct { + Name string `json:"name"` + UID types.UID `json:"uid"` + Namespace string `json:"namespace"` + RoleRef rbacv1.RoleRef `json:"roleRef"` + Subjects []rbacv1.Subject `json:"subjects"` + CreationDate time.Time `json:"creationDate"` + IsSystem bool `json:"isSystem"` + } + + // K8sRoleBindingDeleteRequests slice of cluster role cluster bindings. + K8sClusterRoleBindingDeleteRequests []string +) + +func (r K8sClusterRoleBindingDeleteRequests) Validate(request *http.Request) error { + if len(r) == 0 { + return errors.New("missing deletion request list in payload") + } + + return nil +} diff --git a/api/http/models/kubernetes/cluster_roles.go b/api/http/models/kubernetes/cluster_roles.go new file mode 100644 index 0000000..a42b194 --- /dev/null +++ b/api/http/models/kubernetes/cluster_roles.go @@ -0,0 +1,28 @@ +package kubernetes + +import ( + "errors" + "net/http" + "time" + + "k8s.io/apimachinery/pkg/types" +) + +type ( + K8sClusterRole struct { + Name string `json:"name"` + UID types.UID `json:"uid"` + CreationDate time.Time `json:"creationDate"` + IsSystem bool `json:"isSystem"` + } + + K8sClusterRoleDeleteRequests []string +) + +func (r K8sClusterRoleDeleteRequests) Validate(request *http.Request) error { + if len(r) == 0 { + return errors.New("missing deletion request list in payload") + } + + return nil +} diff --git a/api/http/models/kubernetes/configuration.go b/api/http/models/kubernetes/configuration.go new file mode 100644 index 0000000..2cdbd8f --- /dev/null +++ b/api/http/models/kubernetes/configuration.go @@ -0,0 +1,32 @@ +package kubernetes + +type ( + K8sConfigMap struct { + K8sConfiguration + } + + K8sSecret struct { + K8sConfiguration + SecretType string `json:"SecretType"` + } + + K8sConfiguration struct { + UID string `json:"UID"` + Name string `json:"Name"` + Namespace string `json:"Namespace"` + CreationDate string `json:"CreationDate"` + Annotations map[string]string `json:"Annotations"` + Data map[string]string `json:"Data"` + IsUsed bool `json:"IsUsed"` + Labels map[string]string `json:"Labels"` + ConfigurationOwnerResources []K8sConfigurationOwnerResource `json:"ConfigurationOwners"` + ConfigurationOwner string `json:"ConfigurationOwner"` + ConfigurationOwnerId string `json:"ConfigurationOwnerId"` + } + + K8sConfigurationOwnerResource struct { + Id string `json:"Id"` + Name string `json:"Name"` + ResourceKind string `json:"ResourceKind"` + } +) diff --git a/api/http/models/kubernetes/cron_jobs.go b/api/http/models/kubernetes/cron_jobs.go new file mode 100644 index 0000000..3923e1a --- /dev/null +++ b/api/http/models/kubernetes/cron_jobs.go @@ -0,0 +1,36 @@ +package kubernetes + +import ( + "errors" + "net/http" +) + +type K8sCronJob struct { + Id string `json:"Id"` + Name string `json:"Name"` + Namespace string `json:"Namespace"` + Command string `json:"Command"` + Schedule string `json:"Schedule"` + Timezone string `json:"Timezone"` + Suspend bool `json:"Suspend"` + Jobs []K8sJob `json:"Jobs"` + IsSystem bool `json:"IsSystem"` +} + +type ( + K8sCronJobDeleteRequests map[string][]string +) + +func (r K8sCronJobDeleteRequests) Validate(request *http.Request) error { + if len(r) == 0 { + return errors.New("missing deletion request list in payload") + } + + for ns := range r { + if len(ns) == 0 { + return errors.New("deletion given with empty namespace") + } + } + + return nil +} diff --git a/api/http/models/kubernetes/dashboard.go b/api/http/models/kubernetes/dashboard.go new file mode 100644 index 0000000..544bac2 --- /dev/null +++ b/api/http/models/kubernetes/dashboard.go @@ -0,0 +1,13 @@ +package kubernetes + +type ( + K8sDashboard struct { + NamespacesCount int64 `json:"namespacesCount"` + ApplicationsCount int64 `json:"applicationsCount"` + ServicesCount int64 `json:"servicesCount"` + IngressesCount int64 `json:"ingressesCount"` + ConfigMapsCount int64 `json:"configMapsCount"` + SecretsCount int64 `json:"secretsCount"` + VolumesCount int64 `json:"volumesCount"` + } +) diff --git a/api/http/models/kubernetes/event.go b/api/http/models/kubernetes/event.go new file mode 100644 index 0000000..be447b5 --- /dev/null +++ b/api/http/models/kubernetes/event.go @@ -0,0 +1,25 @@ +package kubernetes + +import "time" + +type K8sEvent struct { + Type string `json:"type"` + Name string `json:"name"` + Reason string `json:"reason"` + Message string `json:"message"` + Namespace string `json:"namespace"` + EventTime time.Time `json:"eventTime"` + Kind string `json:"kind,omitempty"` + Count int32 `json:"count"` + FirstTimestamp *time.Time `json:"firstTimestamp,omitempty"` + LastTimestamp *time.Time `json:"lastTimestamp,omitempty"` + UID string `json:"uid"` + InvolvedObjectKind K8sEventInvolvedObject `json:"involvedObject"` +} + +type K8sEventInvolvedObject struct { + Kind string `json:"kind,omitempty"` + UID string `json:"uid"` + Name string `json:"name"` + Namespace string `json:"namespace"` +} diff --git a/api/http/models/kubernetes/ingress.go b/api/http/models/kubernetes/ingress.go new file mode 100644 index 0000000..f8e900e --- /dev/null +++ b/api/http/models/kubernetes/ingress.go @@ -0,0 +1,83 @@ +package kubernetes + +import ( + "errors" + "net/http" + "time" +) + +type ( + K8sIngressController struct { + Name string `json:"Name"` + ClassName string `json:"ClassName"` + Type string `json:"Type"` + Availability bool `json:"Availability"` + New bool `json:"New"` + Used bool `json:"Used"` + } + + K8sIngressControllers []K8sIngressController + + K8sIngressInfo struct { + Name string `json:"Name"` + UID string `json:"UID"` + Type string `json:"Type"` + Namespace string `json:"Namespace"` + ClassName string `json:"ClassName"` + Annotations map[string]string `json:"Annotations"` + Hosts []string `json:"Hosts"` + Paths []K8sIngressPath `json:"Paths"` + TLS []K8sIngressTLS `json:"TLS"` + Labels map[string]string `json:"Labels,omitempty"` + CreationDate time.Time `json:"CreationDate"` + } + + K8sIngressTLS struct { + Hosts []string `json:"Hosts"` + SecretName string `json:"SecretName"` + } + + K8sIngressPath struct { + IngressName string `json:"IngressName"` + Host string `json:"Host"` + ServiceName string `json:"ServiceName"` + Port int `json:"Port"` + Path string `json:"Path"` + PathType string `json:"PathType"` + HasService bool `json:"HasService"` + } + + // K8sIngressDeleteRequests is a mapping of namespace names to a slice of + // ingress names. + K8sIngressDeleteRequests map[string][]string +) + +func (r K8sIngressControllers) Validate(request *http.Request) error { + return nil +} + +func (r K8sIngressInfo) Validate(request *http.Request) error { + if r.Name == "" { + return errors.New("missing ingress name from the request payload") + } + + if r.Namespace == "" { + return errors.New("missing ingress Namespace from the request payload") + } + + return nil +} + +func (r K8sIngressDeleteRequests) Validate(request *http.Request) error { + if len(r) == 0 { + return errors.New("missing deletion request list in payload") + } + + for ns := range r { + if len(ns) == 0 { + return errors.New("deletion given with empty namespace") + } + } + + return nil +} diff --git a/api/http/models/kubernetes/jobs.go b/api/http/models/kubernetes/jobs.go new file mode 100644 index 0000000..04bed09 --- /dev/null +++ b/api/http/models/kubernetes/jobs.go @@ -0,0 +1,44 @@ +package kubernetes + +import ( + "errors" + "net/http" + + corev1 "k8s.io/api/core/v1" +) + +// K8sJob struct +type K8sJob struct { + ID string `json:"Id"` + Namespace string `json:"Namespace"` + Name string `json:"Name"` + PodName string `json:"PodName"` + Container corev1.Container `json:"Container,omitzero"` + Command string `json:"Command,omitempty"` + BackoffLimit int32 `json:"BackoffLimit,omitempty"` + Completions int32 `json:"Completions,omitempty"` + StartTime string `json:"StartTime"` + FinishTime string `json:"FinishTime"` + Duration string `json:"Duration"` + Status string `json:"Status"` + FailedReason string `json:"FailedReason"` + IsSystem bool `json:"IsSystem"` +} + +type ( + K8sJobDeleteRequests map[string][]string +) + +func (r K8sJobDeleteRequests) Validate(request *http.Request) error { + if len(r) == 0 { + return errors.New("missing deletion request list in payload") + } + + for ns := range r { + if len(ns) == 0 { + return errors.New("deletion given with empty namespace") + } + } + + return nil +} diff --git a/api/http/models/kubernetes/metrics.go b/api/http/models/kubernetes/metrics.go new file mode 100644 index 0000000..5d095cf --- /dev/null +++ b/api/http/models/kubernetes/metrics.go @@ -0,0 +1,13 @@ +package kubernetes + +type K8sMetrics struct { + Resources []K8sMetricsResources `json:"resources"` +} + +type K8sMetricsResources struct { + Kind string `json:"Kind,omitempty"` + Name string `json:"Name,omitempty"` + Namespaced bool `json:"Namespaced,omitempty"` + SingularName string `json:"SingularName,omitempty"` + Verbs []string `json:"Verbs,omitempty"` +} diff --git a/api/http/models/kubernetes/namespaces.go b/api/http/models/kubernetes/namespaces.go new file mode 100644 index 0000000..355d6f3 --- /dev/null +++ b/api/http/models/kubernetes/namespaces.go @@ -0,0 +1,35 @@ +package kubernetes + +import ( + "fmt" + "net/http" + + "k8s.io/apimachinery/pkg/api/resource" +) + +type K8sNamespaceDetails struct { + Name string `json:"Name"` + Annotations map[string]string `json:"Annotations"` + ResourceQuota *K8sResourceQuota `json:"ResourceQuota"` + Owner string `json:"Owner"` +} + +type K8sResourceQuota struct { + Enabled bool `json:"enabled"` + Memory string `json:"memory"` + CPU string `json:"cpu"` +} + +func (r *K8sNamespaceDetails) Validate(request *http.Request) error { + if r.ResourceQuota != nil && r.ResourceQuota.Enabled { + if _, err := resource.ParseQuantity(r.ResourceQuota.Memory); err != nil { + return fmt.Errorf("error parsing memory quota value: %w", err) + } + + if _, err := resource.ParseQuantity(r.ResourceQuota.CPU); err != nil { + return fmt.Errorf("error parsing cpu quota value: %w", err) + } + } + + return nil +} diff --git a/api/http/models/kubernetes/role_bindings.go b/api/http/models/kubernetes/role_bindings.go new file mode 100644 index 0000000..2078244 --- /dev/null +++ b/api/http/models/kubernetes/role_bindings.go @@ -0,0 +1,38 @@ +package kubernetes + +import ( + "errors" + "net/http" + "time" + + rbacv1 "k8s.io/api/rbac/v1" + "k8s.io/apimachinery/pkg/types" +) + +type ( + K8sRoleBinding struct { + Name string `json:"name"` + UID types.UID `json:"uid"` + Namespace string `json:"namespace"` + RoleRef rbacv1.RoleRef `json:"roleRef"` + Subjects []rbacv1.Subject `json:"subjects"` + CreationDate time.Time `json:"creationDate"` + IsSystem bool `json:"isSystem"` + } + + // K8sRoleBindingDeleteRequests is a mapping of namespace names to a slice of role bindings. + K8sRoleBindingDeleteRequests map[string][]string +) + +func (r K8sRoleBindingDeleteRequests) Validate(request *http.Request) error { + if len(r) == 0 { + return errors.New("missing deletion request list in payload") + } + + for ns := range r { + if len(ns) == 0 { + return errors.New("deletion given with empty namespace") + } + } + return nil +} diff --git a/api/http/models/kubernetes/roles.go b/api/http/models/kubernetes/roles.go new file mode 100644 index 0000000..aba205b --- /dev/null +++ b/api/http/models/kubernetes/roles.go @@ -0,0 +1,36 @@ +package kubernetes + +import ( + "errors" + "net/http" + "time" + + "k8s.io/apimachinery/pkg/types" +) + +type ( + K8sRole struct { + Name string `json:"name"` + UID types.UID `json:"uid"` + Namespace string `json:"namespace"` + CreationDate time.Time `json:"creationDate"` + // isSystem is true if prefixed with "system:" or exists in the kube-system namespace + // or is one of the portainer roles + IsSystem bool `json:"isSystem"` + } + + // K8sRoleDeleteRequests is a mapping of namespace names to a slice of roles. + K8sRoleDeleteRequests map[string][]string +) + +func (r K8sRoleDeleteRequests) Validate(request *http.Request) error { + if len(r) == 0 { + return errors.New("missing deletion request list in payload") + } + for ns := range r { + if len(ns) == 0 { + return errors.New("deletion given with empty namespace") + } + } + return nil +} diff --git a/api/http/models/kubernetes/service_accounts.go b/api/http/models/kubernetes/service_accounts.go new file mode 100644 index 0000000..527abaf --- /dev/null +++ b/api/http/models/kubernetes/service_accounts.go @@ -0,0 +1,47 @@ +package kubernetes + +import ( + "errors" + "net/http" + "time" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" +) + +type ( + K8sServiceAccount struct { + Name string `json:"name"` + UID types.UID `json:"uid"` + Namespace string `json:"namespace"` + CreationDate time.Time `json:"creationDate"` + IsSystem bool `json:"isSystem"` + ImagePullSecrets []corev1.LocalObjectReference `json:"imagePullSecrets,omitempty"` + AutomountServiceAccountToken *bool `json:"automountServiceAccountToken,omitempty"` + Labels map[string]string `json:"labels,omitempty"` + Annotations map[string]string `json:"annotations,omitempty"` + } + + // K8sServiceAcountDeleteRequests is a mapping of namespace names to a slice of service account names. + K8sServiceAccountDeleteRequests map[string][]string +) + +type K8sServiceAccountImagePullSecretsUpdatePayload struct { + SecretNames []string `json:"secretNames"` +} + +func (r *K8sServiceAccountImagePullSecretsUpdatePayload) Validate(_ *http.Request) error { + return nil +} + +func (r K8sServiceAccountDeleteRequests) Validate(request *http.Request) error { + if len(r) == 0 { + return errors.New("missing deletion request list in payload") + } + for ns := range r { + if len(ns) == 0 { + return errors.New("deletion given with empty namespace") + } + } + return nil +} diff --git a/api/http/models/kubernetes/service_accounts_test.go b/api/http/models/kubernetes/service_accounts_test.go new file mode 100644 index 0000000..6e27cca --- /dev/null +++ b/api/http/models/kubernetes/service_accounts_test.go @@ -0,0 +1,125 @@ +package kubernetes + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestK8sServiceAccountDeleteRequests_Validate(t *testing.T) { + t.Parallel() + tests := []struct { + name string + payload K8sServiceAccountDeleteRequests + wantErr bool + errMsg string + }{ + { + name: "empty payload returns error", + payload: K8sServiceAccountDeleteRequests{}, + wantErr: true, + errMsg: "missing deletion request list in payload", + }, + { + name: "valid single namespace with service accounts", + payload: K8sServiceAccountDeleteRequests{ + "default": {"sa-1", "sa-2"}, + }, + wantErr: false, + }, + { + name: "valid multiple namespaces", + payload: K8sServiceAccountDeleteRequests{ + "default": {"sa-1"}, + "kube-system": {"sa-2"}, + "custom-ns": {"sa-3"}, + }, + wantErr: false, + }, + { + name: "empty namespace key returns error", + payload: K8sServiceAccountDeleteRequests{ + "": {"sa-1"}, + }, + wantErr: true, + errMsg: "deletion given with empty namespace", + }, + { + name: "valid with empty service account list", + payload: K8sServiceAccountDeleteRequests{ + "default": {}, + }, + wantErr: false, + }, + { + name: "multiple namespaces with one empty returns error", + payload: K8sServiceAccountDeleteRequests{ + "default": {"sa-1"}, + "": {"sa-2"}, + }, + wantErr: true, + errMsg: "deletion given with empty namespace", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/", nil) + err := tt.payload.Validate(req) + + if tt.wantErr { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errMsg) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestK8sServiceAccount_Structure(t *testing.T) { + t.Parallel() + sa := K8sServiceAccount{ + Name: "test-sa", + Namespace: "default", + IsSystem: false, + } + + assert.Equal(t, "test-sa", sa.Name) + assert.Equal(t, "default", sa.Namespace) + assert.False(t, sa.IsSystem) + assert.Nil(t, sa.AutomountServiceAccountToken) + assert.Empty(t, sa.Labels) + assert.Empty(t, sa.Annotations) +} + +func TestK8sServiceAccount_WithAllFields(t *testing.T) { + t.Parallel() + automountToken := true + sa := K8sServiceAccount{ + Name: "full-sa", + Namespace: "production", + IsSystem: true, + Labels: map[string]string{ + "app": "web", + "env": "prod", + }, + Annotations: map[string]string{ + "description": "service account for web", + }, + AutomountServiceAccountToken: &automountToken, + } + + assert.Equal(t, "full-sa", sa.Name) + assert.Equal(t, "production", sa.Namespace) + assert.True(t, sa.IsSystem) + assert.NotNil(t, sa.AutomountServiceAccountToken) + assert.True(t, *sa.AutomountServiceAccountToken) + assert.Len(t, sa.Labels, 2) + assert.Equal(t, "web", sa.Labels["app"]) + assert.Len(t, sa.Annotations, 1) + assert.Equal(t, "service account for web", sa.Annotations["description"]) +} diff --git a/api/http/models/kubernetes/services.go b/api/http/models/kubernetes/services.go new file mode 100644 index 0000000..bcbddf3 --- /dev/null +++ b/api/http/models/kubernetes/services.go @@ -0,0 +1,75 @@ +package kubernetes + +import ( + "errors" + "net/http" +) + +type ( + K8sServiceInfo struct { + Name string `json:",omitempty"` + UID string `json:",omitempty"` + Type string `json:",omitempty"` + Namespace string `json:",omitempty"` + Annotations map[string]string `json:",omitempty"` + CreationDate string `json:",omitempty"` + Labels map[string]string `json:",omitempty"` + AllocateLoadBalancerNodePorts *bool `json:",omitempty"` + Ports []K8sServicePort `json:",omitempty"` + Selector map[string]string `json:",omitempty"` + IngressStatus []K8sServiceIngress `json:",omitempty"` + + // serviceList screen + Applications []K8sApplication `json:",omitempty"` + ClusterIPs []string `json:",omitempty"` + ExternalName string `json:",omitempty"` + ExternalIPs []string `json:",omitempty"` + } + + K8sServicePort struct { + Name string `json:"Name"` + NodePort int `json:"NodePort"` + Port int `json:"Port"` + Protocol string `json:"Protocol"` + TargetPort string `json:"TargetPort"` + } + + K8sServiceIngress struct { + IP string `json:"IP"` + Hostname string `json:"Hostname"` + } + + // K8sServiceDeleteRequests is a mapping of namespace names to a slice of + // service names. + K8sServiceDeleteRequests map[string][]string +) + +func (s *K8sServiceInfo) Validate(request *http.Request) error { + if s.Name == "" { + return errors.New("missing service name from the request payload") + } + + if s.Namespace == "" { + return errors.New("missing service namespace from the request payload") + } + + if s.Ports == nil { + return errors.New("missing service ports from the request payload") + } + + return nil +} + +func (r K8sServiceDeleteRequests) Validate(request *http.Request) error { + if len(r) == 0 { + return errors.New("missing deletion request list in payload") + } + + for ns := range r { + if len(ns) == 0 { + return errors.New("deletion given with empty namespace") + } + } + + return nil +} diff --git a/api/http/models/kubernetes/volumes.go b/api/http/models/kubernetes/volumes.go new file mode 100644 index 0000000..69e9d1a --- /dev/null +++ b/api/http/models/kubernetes/volumes.go @@ -0,0 +1,145 @@ +package kubernetes + +import ( + "errors" + "net/http" + "time" + + corev1 "k8s.io/api/core/v1" +) + +type ( + K8sVolumeInfo struct { + PersistentVolume K8sPersistentVolume `json:"persistentVolume"` + PersistentVolumeClaim K8sPersistentVolumeClaim `json:"persistentVolumeClaim"` + StorageClass K8sStorageClass `json:"storageClass"` + } + + K8sPersistentVolume struct { + Name string `json:"name,omitempty"` + Annotations map[string]string `json:"annotations,omitempty"` + Labels map[string]string `json:"labels,omitempty"` + AccessModes []string `json:"accessModes,omitempty"` + HumanReadableAccessModes []corev1.PersistentVolumeAccessMode `json:"humanReadableAccessModes,omitempty"` + Capacity corev1.ResourceList `json:"capacity"` + ClaimRef *corev1.ObjectReference `json:"claimRef"` + StorageClassName string `json:"storageClassName,omitempty"` + PersistentVolumeReclaimPolicy corev1.PersistentVolumeReclaimPolicy `json:"persistentVolumeReclaimPolicy"` + VolumeMode *corev1.PersistentVolumeMode `json:"volumeMode"` + CSI *corev1.CSIPersistentVolumeSource `json:"csi,omitempty"` + Status corev1.PersistentVolumePhase `json:"status"` + CreationDate time.Time `json:"creationDate"` + } + + K8sPersistentVolumeClaim struct { + ID string `json:"id"` + Name string `json:"name"` + Namespace string `json:"namespace"` + Storage int64 `json:"storage"` + StorageRequest string `json:"storageRequest"` + CreationDate time.Time `json:"creationDate"` + AccessModes []string `json:"accessModes,omitempty"` + HumanReadableAccessModes []corev1.PersistentVolumeAccessMode `json:"humanReadableAccessModes,omitempty"` + VolumeName string `json:"volumeName"` + ResourcesRequests *corev1.ResourceList `json:"resourcesRequests"` + StorageClass *string `json:"storageClass"` + AllowVolumeExpansion bool `json:"allowVolumeExpansion"` + VolumeMode *corev1.PersistentVolumeMode `json:"volumeMode"` + OwningApplications []K8sApplication `json:"owningApplications,omitempty"` + Phase corev1.PersistentVolumeClaimPhase `json:"phase"` + Labels map[string]string `json:"labels,omitempty"` + } + + K8sStorageClass struct { + Name string `json:"name"` + Provisioner string `json:"provisioner"` + ReclaimPolicy *corev1.PersistentVolumeReclaimPolicy `json:"reclaimPolicy"` + AllowVolumeExpansion *bool `json:"allowVolumeExpansion"` + IsDefault bool `json:"isDefault"` + Annotations map[string]string `json:"annotations,omitempty"` + Labels map[string]string `json:"labels,omitempty"` + CreationDate time.Time `json:"creationDate"` + Parameters map[string]string `json:"parameters,omitempty"` + MountOptions []string `json:"mountOptions,omitempty"` + } + + // K8sVolumeDeleteRequest represents a request to delete a PVC. + K8sVolumeDeleteRequest struct { + Namespace string `json:"namespace"` + Name string `json:"name"` + } + + // K8sVolumeDeleteRequests is a list of PVC delete requests. + K8sVolumeDeleteRequests []K8sVolumeDeleteRequest + + // K8sPVDeleteRequest represents a request to delete PVs. + K8sPVDeleteRequest []string + + // K8sStorageClassDeleteRequest represents a request to delete storage classes. + K8sStorageClassDeleteRequest []string + + // K8sPVCResizeRequest represents a request to resize a PVC. + K8sPVCResizeRequest struct { + Namespace string `json:"namespace"` + Name string `json:"name"` + NewSize string `json:"newSize"` + } + + // K8sPVReclaimPolicyRequest represents a request to change PV reclaim policy. + K8sPVReclaimPolicyRequest struct { + Name string `json:"name"` + ReclaimPolicy corev1.PersistentVolumeReclaimPolicy `json:"reclaimPolicy"` + } +) + +func (r K8sPVDeleteRequest) Validate(_ *http.Request) error { + if len(r) == 0 { + return errors.New("missing persistent volume names from the request payload") + } + + return nil +} + +func (r K8sStorageClassDeleteRequest) Validate(_ *http.Request) error { + if len(r) == 0 { + return errors.New("missing storage class names from the request payload") + } + + return nil +} + +func (r K8sVolumeDeleteRequests) Validate(_ *http.Request) error { + if len(r) == 0 { + return errors.New("missing PVC delete requests from the request payload") + } + + return nil +} + +func (r *K8sPVCResizeRequest) Validate(_ *http.Request) error { + if r.Namespace == "" { + return errors.New("missing namespace from the request payload") + } + + if r.Name == "" { + return errors.New("missing PVC name from the request payload") + } + + if r.NewSize == "" { + return errors.New("missing new size from the request payload") + } + + return nil +} + +func (r *K8sPVReclaimPolicyRequest) Validate(_ *http.Request) error { + if r.Name == "" { + return errors.New("missing persistent volume name from the request payload") + } + + if r.ReclaimPolicy == "" { + return errors.New("missing reclaim policy from the request payload") + } + + return nil +} diff --git a/api/http/offlinegate/offlinegate.go b/api/http/offlinegate/offlinegate.go new file mode 100644 index 0000000..58fb29e --- /dev/null +++ b/api/http/offlinegate/offlinegate.go @@ -0,0 +1,53 @@ +package offlinegate + +import ( + "net/http" + "strings" + "time" + + httperror "github.com/portainer/portainer/pkg/libhttp/error" + + "github.com/rs/zerolog/log" + lock "github.com/viney-shih/go-lock" +) + +// OfflineGate is an entity that works similar to a mutex with signaling +// Only the caller that has Locked a gate can unlock it, otherwise it will be blocked with a call to Lock. +// Gate provides a passthrough http middleware that will wait for a locked gate to be unlocked. +// For safety reasons, the middleware will timeout +type OfflineGate struct { + lock *lock.CASMutex +} + +// NewOfflineGate creates a new gate +func NewOfflineGate() *OfflineGate { + return &OfflineGate{ + lock: lock.NewCASMutex(), + } +} + +// Lock locks readonly gate and returns a function to unlock +func (o *OfflineGate) Lock() func() { + o.lock.Lock() + return o.lock.Unlock +} + +// WaitingMiddleware returns an http handler that waits for the gate to be unlocked before continuing +func (o *OfflineGate) WaitingMiddleware(timeout time.Duration, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" || r.Method == "HEAD" || r.Method == "OPTIONS" || strings.HasPrefix(r.URL.Path, "/api/backup") || strings.HasPrefix(r.URL.Path, "/api/restore") { + next.ServeHTTP(w, r) + return + } + + if !o.lock.RTryLockWithTimeout(timeout) { + log.Error().Str("url", r.URL.Path).Msg("request timed out while waiting for the backup process to finish") + httperror.WriteError(w, http.StatusRequestTimeout, "Request timed out while waiting for the backup process to finish", http.ErrHandlerTimeout) + return + } + + defer o.lock.RUnlock() + + next.ServeHTTP(w, r) + }) +} diff --git a/api/http/offlinegate/offlinegate_test.go b/api/http/offlinegate/offlinegate_test.go new file mode 100644 index 0000000..daf7b05 --- /dev/null +++ b/api/http/offlinegate/offlinegate_test.go @@ -0,0 +1,190 @@ +package offlinegate + +import ( + "io" + "net/http" + "net/http/httptest" + "sync" + "testing" + "testing/synctest" + "time" + + "github.com/rs/zerolog/log" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_canLockAndUnlock(t *testing.T) { + t.Parallel() + o := NewOfflineGate() + + unlock := o.Lock() + unlock() +} + +func Test_hasToBeUnlockedToLockAgain(t *testing.T) { + t.Parallel() + // scenario: + // 1. first routine starts and locks the gate + // 2. first routine starts a second and wait for the second to start + // 3. second start but waits for the gate to be released + // 4. first continues and unlocks the gate, when done + // 5. second be able to continue + // 6. second lock the gate, does the job and unlocks it + + o := NewOfflineGate() + + wg := sync.WaitGroup{} + wg.Add(2) + + result := make([]string, 0, 2) + go func() { + unlock := o.Lock() + defer unlock() + waitForSecondToStart := sync.WaitGroup{} + waitForSecondToStart.Add(1) + go func() { + waitForSecondToStart.Done() + unlock := o.Lock() + defer unlock() + result = append(result, "second") + wg.Done() + }() + waitForSecondToStart.Wait() + result = append(result, "first") + wg.Done() + }() + + wg.Wait() + + if len(result) != 2 || result[0] != "first" || result[1] != "second" { + t.Error("Second call have disresregarded a raised lock") + } + +} + +func Test_waitingMiddleware_executesImmediately_whenNotLocked(t *testing.T) { + t.Parallel() + // scenario: + // 1. create an gate + // 2. kick off a waiting middleware that will release immediately as gate wasn't locked + // 3. middleware shouldn't timeout + + o := NewOfflineGate() + + request := httptest.NewRequest(http.MethodPost, "/", nil) + response := httptest.NewRecorder() + + timeout := 2 * time.Second + start := time.Now() + + o.WaitingMiddleware(timeout, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + elapsed := time.Since(start) + if elapsed >= timeout { + t.Error("WaitingMiddleware had likely timeout, when it shouldn't") + } + _, _ = w.Write([]byte("success")) + })).ServeHTTP(response, request) + + body, _ := io.ReadAll(response.Body) + if string(body) != "success" { + t.Error("Didn't receive expected result from the handler") + } +} + +func Test_waitingMiddleware_waitsForTheLockToBeReleased(t *testing.T) { + t.Parallel() + synctest.Test(t, test_waitingMiddleware_waitsForTheLockToBeReleased) +} + +func test_waitingMiddleware_waitsForTheLockToBeReleased(t *testing.T) { + // scenario: + // 1. create an gate and lock it + // 2. kick off a routing that will unlock the gate after 1 second + // 3. kick off a waiting middleware that will wait for lock to be eventually released + // 4. middleware shouldn't timeout + + o := NewOfflineGate() + unlock := o.Lock() + + request := httptest.NewRequest(http.MethodPost, "/", nil) + response := httptest.NewRecorder() + + go func() { + time.Sleep(1 * time.Second) + unlock() + }() + + timeout := 10 * time.Second + start := time.Now() + + o.WaitingMiddleware(timeout, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + elapsed := time.Since(start) + if elapsed >= timeout { + t.Error("WaitingMiddleware had likely timeout, when it shouldn't") + } + _, _ = w.Write([]byte("success")) + })).ServeHTTP(response, request) + + body, _ := io.ReadAll(response.Body) + if string(body) != "success" { + t.Error("Didn't receive expected result from the handler") + } +} + +func Test_waitingMiddleware_mayTimeout_whenLockedForTooLong(t *testing.T) { + t.Parallel() + /* + scenario: + 1. create an gate and lock it + 2. kick off a waiting middleware that will wait for lock to be eventually released + 3. because we never unlocked the gate, middleware suppose to timeout + */ + o := NewOfflineGate() + o.Lock() + + request := httptest.NewRequest(http.MethodPost, "/", nil) + response := httptest.NewRecorder() + + timeout := 1 * time.Second + start := time.Now() + o.WaitingMiddleware(timeout, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + elapsed := time.Since(start) + if elapsed < timeout { + t.Error("WaitingMiddleware suppose to timeout, but it didnt") + } + _, _ = w.Write([]byte("success")) + })).ServeHTTP(response, request) + + assert.Equal(t, http.StatusRequestTimeout, response.Result().StatusCode, "Request support to timeout waiting for the gate") +} + +func Test_waitingMiddleware_handlerPanics(t *testing.T) { + t.Parallel() + o := NewOfflineGate() + + request := httptest.NewRequest(http.MethodPost, "/", nil) + response := httptest.NewRecorder() + + wg := sync.WaitGroup{} + wg.Add(1) + + go func() { + defer func() { + if r := recover(); r != nil { + log.Warn().Msgf("Recovered in test: %v", r) + } + + wg.Done() + }() + + o.WaitingMiddleware(time.Second, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + panic("panic") + })).ServeHTTP(response, request) + }() + + wg.Wait() + + require.True(t, o.lock.TryLock()) + o.lock.Unlock() +} diff --git a/api/http/proxy/factory/agent.go b/api/http/proxy/factory/agent.go new file mode 100644 index 0000000..46e0033 --- /dev/null +++ b/api/http/proxy/factory/agent.go @@ -0,0 +1,114 @@ +package factory + +import ( + "fmt" + "net" + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/crypto" + "github.com/portainer/portainer/api/http/proxy/factory/agent" + "github.com/portainer/portainer/api/internal/endpointutils" + "github.com/portainer/portainer/api/url" + "github.com/portainer/portainer/pkg/libhttp/ssrf" + + "github.com/pkg/errors" + "github.com/rs/zerolog/log" +) + +// ProxyServer provide an extended proxy with a local server to forward requests +type ProxyServer struct { + server *http.Server + Port int +} + +// NewAgentProxy creates a new instance of ProxyServer that wrap http requests with agent headers +func (factory *ProxyFactory) NewAgentProxy(endpoint *portainer.Endpoint) (*ProxyServer, error) { + urlString := endpoint.URL + + if endpointutils.IsEdgeEndpoint(endpoint) { + tunnelAddr, err := factory.reverseTunnelService.TunnelAddr(endpoint) + if err != nil { + return nil, errors.Wrap(err, "failed starting tunnel") + } + + urlString = "http://" + tunnelAddr + } + + endpointURL, err := url.ParseURL(urlString) + if err != nil { + return nil, errors.Wrapf(err, "failed parsing url %s", endpoint.URL) + } + + endpointURL.Scheme = "http" + + var innerTransport *http.Transport + if endpoint.TLSConfig.TLS || endpoint.TLSConfig.TLSSkipVerify { + tlsConfig, err := crypto.CreateTLSConfigurationFromDisk(endpoint.TLSConfig) + if err != nil { + return nil, errors.WithMessage(err, "failed generating tls configuration") + } + + endpointURL.Scheme = "https" + + if endpointutils.IsEdgeEndpoint(endpoint) { + innerTransport = ssrf.NewInternalTransport(tlsConfig) + } else { + innerTransport = ssrf.NewTransport(tlsConfig) + innerTransport.Protocols = ssrf.HTTP1Only() + } + } else if endpointutils.IsEdgeEndpoint(endpoint) { + innerTransport = ssrf.NewInternalTransport(nil) + } else { + innerTransport = ssrf.NewTransport(nil) + } + + proxy := NewSingleHostReverseProxyWithHostHeader(endpointURL) + + proxy.Transport = agent.NewTransport(factory.signatureService, innerTransport) + + proxyServer := &ProxyServer{ + server: &http.Server{ + Handler: proxy, + }, + Port: 0, + } + + if err := proxyServer.start(); err != nil { + return nil, errors.Wrap(err, "failed starting proxy server") + } + + return proxyServer, nil +} + +func (proxy *ProxyServer) start() error { + listener, err := net.Listen("tcp", ":0") + if err != nil { + return err + } + + proxy.Port = listener.Addr().(*net.TCPAddr).Port + + go func() { + proxyHost := fmt.Sprintf("127.0.0.1:%d", proxy.Port) + log.Debug().Str("host", proxyHost).Msg("starting proxy server") + + err := proxy.server.Serve(listener) + log.Debug().Str("host", proxyHost).Msg("exiting proxy server") + + if err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Debug().Str("host", proxyHost).Err(err).Msg("proxy server exited with an error") + } + }() + + return nil +} + +// Close shuts down the server +func (proxy *ProxyServer) Close() { + if proxy.server != nil { + if err := proxy.server.Close(); err != nil { + log.Warn().Err(err).Msg("failed to close proxy server") + } + } +} diff --git a/api/http/proxy/factory/agent/transport.go b/api/http/proxy/factory/agent/transport.go new file mode 100644 index 0000000..076a6d1 --- /dev/null +++ b/api/http/proxy/factory/agent/transport.go @@ -0,0 +1,36 @@ +package agent + +import ( + "net/http" + + portainer "github.com/portainer/portainer/api" +) + +// Transport is an http.Transport wrapper that adds custom http headers to communicate to an Agent +type Transport struct { + httpTransport *http.Transport + signatureService portainer.DigitalSignatureService +} + +// NewTransport returns a new transport that can be used to send signed requests to a Portainer agent +func NewTransport(signatureService portainer.DigitalSignatureService, httpTransport *http.Transport) *Transport { + transport := &Transport{ + httpTransport: httpTransport, + signatureService: signatureService, + } + + return transport +} + +// RoundTrip is the implementation of the http.RoundTripper interface +func (transport *Transport) RoundTrip(request *http.Request) (*http.Response, error) { + 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) +} diff --git a/api/http/proxy/factory/azure.go b/api/http/proxy/factory/azure.go new file mode 100644 index 0000000..e210dec --- /dev/null +++ b/api/http/proxy/factory/azure.go @@ -0,0 +1,21 @@ +package factory + +import ( + "net/http" + "net/url" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/http/proxy/factory/azure" +) + +func newAzureProxy(endpoint *portainer.Endpoint, dataStore dataservices.DataStore) (http.Handler, error) { + remoteURL, err := url.Parse(azureAPIBaseURL) + if err != nil { + return nil, err + } + + proxy := NewSingleHostReverseProxyWithHostHeader(remoteURL) + proxy.Transport = azure.NewTransport(&endpoint.AzureCredentials, dataStore, endpoint) + return proxy, nil +} diff --git a/api/http/proxy/factory/azure/access_control.go b/api/http/proxy/factory/azure/access_control.go new file mode 100644 index 0000000..be17b60 --- /dev/null +++ b/api/http/proxy/factory/azure/access_control.go @@ -0,0 +1,151 @@ +package azure + +import ( + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/authorization" + + "github.com/rs/zerolog/log" +) + +func (transport *Transport) createAzureRequestContext(request *http.Request) (*azureRequestContext, error) { + var err error + + tokenData, err := security.RetrieveTokenData(request) + if err != nil { + return nil, err + } + + resourceControls, err := transport.dataStore.ResourceControl().ReadAll() + if err != nil { + return nil, err + } + + context := &azureRequestContext{ + isAdmin: true, + userID: tokenData.ID, + resourceControls: resourceControls, + } + + if tokenData.Role != portainer.AdministratorRole { + context.isAdmin = false + + teamMemberships, err := transport.dataStore.TeamMembership().TeamMembershipsByUserID(tokenData.ID) + if err != nil { + return nil, err + } + + context.userTeamIDs = authorization.TeamIDs(teamMemberships) + } + + return context, nil +} + +func decorateObject(object map[string]any, resourceControl *portainer.ResourceControl) map[string]any { + if object["Portainer"] == nil { + object["Portainer"] = make(map[string]any) + } + + portainerMetadata := object["Portainer"].(map[string]any) + portainerMetadata["ResourceControl"] = resourceControl + + return object +} + +func (transport *Transport) createPrivateResourceControl( + resourceIdentifier string, + resourceType portainer.ResourceControlType, + userID portainer.UserID) (*portainer.ResourceControl, error) { + + resourceControl := authorization.NewPrivateResourceControl(resourceIdentifier, resourceType, userID) + + if err := transport.dataStore.ResourceControl().Create(resourceControl); err != nil { + log.Error(). + Str("resource", resourceIdentifier). + Err(err). + Msg("unable to persist resource control") + + return nil, err + } + + return resourceControl, nil +} + +func (transport *Transport) userCanDeleteContainerGroup(request *http.Request, context *azureRequestContext) bool { + if context.isAdmin { + return true + } + + resourceIdentifier := request.URL.Path + resourceControl := transport.findResourceControl(resourceIdentifier, context) + + return authorization.UserCanAccessResource(context.userID, context.userTeamIDs, resourceControl) +} + +func (transport *Transport) decorateContainerGroups(containerGroups []any, context *azureRequestContext) []any { + decoratedContainerGroups := make([]any, 0) + + for _, containerGroup := range containerGroups { + containerGroup = transport.decorateContainerGroup(containerGroup.(map[string]any), context) + decoratedContainerGroups = append(decoratedContainerGroups, containerGroup) + } + + return decoratedContainerGroups +} + +func (transport *Transport) decorateContainerGroup(containerGroup map[string]any, context *azureRequestContext) map[string]any { + containerGroupId, ok := containerGroup["id"].(string) + if ok { + resourceControl := transport.findResourceControl(containerGroupId, context) + if resourceControl != nil { + containerGroup = decorateObject(containerGroup, resourceControl) + } + } else { + log.Warn().Msg("unable to find resource id property in container group") + } + + return containerGroup +} + +func (transport *Transport) filterContainerGroups(containerGroups []any, context *azureRequestContext) []any { + filteredContainerGroups := make([]any, 0) + + for _, containerGroup := range containerGroups { + userCanAccessResource := false + containerGroup := containerGroup.(map[string]any) + portainerObject, ok := containerGroup["Portainer"].(map[string]any) + if ok { + resourceControl, ok := portainerObject["ResourceControl"].(*portainer.ResourceControl) + if ok { + userCanAccessResource = authorization.UserCanAccessResource(context.userID, context.userTeamIDs, resourceControl) + } + } + + if context.isAdmin || userCanAccessResource { + filteredContainerGroups = append(filteredContainerGroups, containerGroup) + } + } + + return filteredContainerGroups +} + +func (transport *Transport) removeResourceControl(containerGroup map[string]any, context *azureRequestContext) error { + containerGroupID, ok := containerGroup["id"].(string) + if !ok { + log.Debug().Msg("missing ID in container group") + + return nil + } + + if resourceControl := transport.findResourceControl(containerGroupID, context); resourceControl != nil { + return transport.dataStore.ResourceControl().Delete(resourceControl.ID) + } + + return nil +} + +func (transport *Transport) findResourceControl(containerGroupId string, context *azureRequestContext) *portainer.ResourceControl { + return authorization.GetResourceControlByResourceIDAndType(containerGroupId, portainer.ContainerGroupResourceControl, context.resourceControls) +} diff --git a/api/http/proxy/factory/azure/containergroup.go b/api/http/proxy/factory/azure/containergroup.go new file mode 100644 index 0000000..c5a8309 --- /dev/null +++ b/api/http/proxy/factory/azure/containergroup.go @@ -0,0 +1,155 @@ +package azure + +import ( + "errors" + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/proxy/factory/utils" + "github.com/portainer/portainer/api/http/security" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/rs/zerolog/log" +) + +// proxy for /subscriptions/*/resourceGroups/*/providers/Microsoft.ContainerInstance/containerGroups/* +func (transport *Transport) proxyContainerGroupRequest(request *http.Request) (*http.Response, error) { + switch request.Method { + case http.MethodPut: + return transport.proxyContainerGroupPutRequest(request) + case http.MethodGet: + return transport.proxyContainerGroupGetRequest(request) + case http.MethodDelete: + return transport.proxyContainerGroupDeleteRequest(request) + default: + return http.DefaultTransport.RoundTrip(request) + } +} + +func (transport *Transport) proxyContainerGroupPutRequest(request *http.Request) (*http.Response, error) { + tokenData, err := security.RetrieveTokenData(request) + if err != nil { + return nil, httperror.Forbidden("Permission denied to access environment", err) + } + + // Add a lock before processing existence check + transport.mutex.Lock() + defer transport.mutex.Unlock() + + // Generate a temp http GET request based on the current PUT request + validationRequest := &http.Request{ + Method: http.MethodGet, + URL: request.URL, + Header: http.Header{ + "Authorization": []string{"Bearer " + tokenData.Token}, + }, + } + + // Fire the request to Azure API to validate if there is an existing container instance with the same name + // positive - reject the request + // negative - continue the process + validationResponse, err := http.DefaultTransport.RoundTrip(validationRequest) + if err != nil { + return validationResponse, err + } + + if validationResponse.StatusCode >= 200 && validationResponse.StatusCode < 300 { + resp := &http.Response{ + Header: http.Header{ + http.CanonicalHeaderKey("content-type"): []string{"application/json"}, + }, + } + errObj := map[string]string{ + "message": "A container instance with the same name already exists inside the selected resource group", + } + err = utils.RewriteResponse(resp, errObj, http.StatusConflict) + + return resp, err + } + + response, err := http.DefaultTransport.RoundTrip(request) + if err != nil { + return response, err + } + + responseObject, err := utils.GetResponseAsJSONObject(response) + if err != nil { + return response, err + } + + containerGroupID, ok := responseObject["id"].(string) + if !ok { + return response, errors.New("Missing container group ID") + } + + context, err := transport.createAzureRequestContext(request) + if err != nil { + return response, err + } + + resourceControl, err := transport.createPrivateResourceControl(containerGroupID, portainer.ContainerGroupResourceControl, context.userID) + if err != nil { + return response, err + } + + responseObject = decorateObject(responseObject, resourceControl) + + err = utils.RewriteResponse(response, responseObject, http.StatusOK) + + return response, err +} + +func (transport *Transport) proxyContainerGroupGetRequest(request *http.Request) (*http.Response, error) { + response, err := http.DefaultTransport.RoundTrip(request) + if err != nil { + return response, err + } + + responseObject, err := utils.GetResponseAsJSONObject(response) + if err != nil { + return nil, err + } + + context, err := transport.createAzureRequestContext(request) + if err != nil { + return nil, err + } + + responseObject = transport.decorateContainerGroup(responseObject, context) + + if err := utils.RewriteResponse(response, responseObject, http.StatusOK); err != nil { + log.Warn().Err(err).Msg("failed to rewrite response") + } + + return response, nil +} + +func (transport *Transport) proxyContainerGroupDeleteRequest(request *http.Request) (*http.Response, error) { + context, err := transport.createAzureRequestContext(request) + if err != nil { + return nil, err + } + + if !transport.userCanDeleteContainerGroup(request, context) { + return utils.WriteAccessDeniedResponse() + } + + response, err := http.DefaultTransport.RoundTrip(request) + if err != nil { + return response, err + } + + responseObject, err := utils.GetResponseAsJSONObject(response) + if err != nil { + return nil, err + } + + if err := transport.removeResourceControl(responseObject, context); err != nil { + log.Warn().Err(err).Msg("failed to remove resource control") + } + + if err := utils.RewriteResponse(response, responseObject, http.StatusOK); err != nil { + log.Warn().Err(err).Msg("failed to rewrite response") + } + + return response, nil +} diff --git a/api/http/proxy/factory/azure/containergroups.go b/api/http/proxy/factory/azure/containergroups.go new file mode 100644 index 0000000..5d4516a --- /dev/null +++ b/api/http/proxy/factory/azure/containergroups.go @@ -0,0 +1,50 @@ +package azure + +import ( + "errors" + "net/http" + + "github.com/portainer/portainer/api/http/proxy/factory/utils" +) + +// proxy for /subscriptions/*/providers/Microsoft.ContainerInstance/containerGroups +func (transport *Transport) proxyContainerGroupsRequest(request *http.Request) (*http.Response, error) { + switch request.Method { + case http.MethodGet: + return transport.proxyContainerGroupsGetRequest(request) + default: + return http.DefaultTransport.RoundTrip(request) + } +} + +func (transport *Transport) proxyContainerGroupsGetRequest(request *http.Request) (*http.Response, error) { + response, err := http.DefaultTransport.RoundTrip(request) + if err != nil { + return nil, err + } + + responseObject, err := utils.GetResponseAsJSONObject(response) + if err != nil { + return nil, err + } + + value, ok := responseObject["value"].([]any) + if ok { + context, err := transport.createAzureRequestContext(request) + if err != nil { + return response, err + } + + decoratedValue := transport.decorateContainerGroups(value, context) + filteredValue := transport.filterContainerGroups(decoratedValue, context) + responseObject["value"] = filteredValue + + if err := utils.RewriteResponse(response, responseObject, http.StatusOK); err != nil { + return nil, err + } + } else { + return nil, errors.New("The container groups response has no value property") + } + + return response, nil +} diff --git a/api/http/proxy/factory/azure/transport.go b/api/http/proxy/factory/azure/transport.go new file mode 100644 index 0000000..37fda30 --- /dev/null +++ b/api/http/proxy/factory/azure/transport.go @@ -0,0 +1,106 @@ +package azure + +import ( + "net/http" + "path" + "strconv" + "sync" + "time" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/http/client" +) + +type ( + azureAPIToken struct { + value string + expirationTime time.Time + } + + Transport struct { + credentials *portainer.AzureCredentials + client *client.HTTPClient + token *azureAPIToken + mutex sync.Mutex + dataStore dataservices.DataStore + endpoint *portainer.Endpoint + } + + azureRequestContext struct { + isAdmin bool + userID portainer.UserID + userTeamIDs []portainer.TeamID + resourceControls []portainer.ResourceControl + } +) + +// NewTransport returns a pointer to a new instance of Transport that implements the HTTP Transport +// interface for proxying requests to the Azure API. +func NewTransport(credentials *portainer.AzureCredentials, dataStore dataservices.DataStore, endpoint *portainer.Endpoint) *Transport { + return &Transport{ + credentials: credentials, + client: client.NewHTTPClient(), + dataStore: dataStore, + endpoint: endpoint, + } +} + +// RoundTrip is the implementation of the http.RoundTripper interface +func (transport *Transport) RoundTrip(request *http.Request) (*http.Response, error) { + return transport.proxyAzureRequest(request) +} + +func (transport *Transport) proxyAzureRequest(request *http.Request) (*http.Response, error) { + requestPath := request.URL.Path + + err := transport.retrieveAuthenticationToken() + if err != nil { + return nil, err + } + + request.Header.Set("Authorization", "Bearer "+transport.token.value) + + if match, _ := path.Match(portainer.AzurePathContainerGroups, requestPath); match { + return transport.proxyContainerGroupsRequest(request) + } else if match, _ := path.Match(portainer.AzurePathContainerGroup, requestPath); match { + return transport.proxyContainerGroupRequest(request) + } + + return http.DefaultTransport.RoundTrip(request) +} + +func (transport *Transport) authenticate() error { + token, err := transport.client.ExecuteAzureAuthenticationRequest(transport.credentials) + if err != nil { + return err + } + + expiresOn, err := strconv.ParseInt(token.ExpiresOn, 10, 64) + if err != nil { + return err + } + + transport.token = &azureAPIToken{ + value: token.AccessToken, + expirationTime: time.Unix(expiresOn, 0), + } + + return nil +} + +func (transport *Transport) retrieveAuthenticationToken() error { + transport.mutex.Lock() + defer transport.mutex.Unlock() + + if transport.token == nil { + return transport.authenticate() + } + + timeLimit := time.Now().Add(-5 * time.Minute) + if timeLimit.After(transport.token.expirationTime) { + return transport.authenticate() + } + + return nil +} diff --git a/api/http/proxy/factory/docker.go b/api/http/proxy/factory/docker.go new file mode 100644 index 0000000..b5d92df --- /dev/null +++ b/api/http/proxy/factory/docker.go @@ -0,0 +1,130 @@ +package factory + +import ( + "io" + "net/http" + "strings" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/crypto" + "github.com/portainer/portainer/api/http/proxy/factory/docker" + "github.com/portainer/portainer/api/internal/endpointutils" + "github.com/portainer/portainer/api/url" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/ssrf" + + "github.com/rs/zerolog/log" +) + +func (factory *ProxyFactory) newDockerProxy(endpoint *portainer.Endpoint) (http.Handler, error) { + if strings.HasPrefix(endpoint.URL, "unix://") || strings.HasPrefix(endpoint.URL, "npipe://") { + return factory.newDockerLocalProxy(endpoint) + } + + return factory.newDockerHTTPProxy(endpoint) +} + +func (factory *ProxyFactory) newDockerLocalProxy(endpoint *portainer.Endpoint) (http.Handler, error) { + endpointURL, err := url.ParseURL(endpoint.URL) + if err != nil { + return nil, err + } + + return factory.newOSBasedLocalProxy(endpointURL.Path, endpoint) +} + +func (factory *ProxyFactory) newDockerHTTPProxy(endpoint *portainer.Endpoint) (http.Handler, error) { + rawURL := endpoint.URL + if endpoint.Type == portainer.EdgeAgentOnDockerEnvironment { + tunnelAddr, err := factory.reverseTunnelService.TunnelAddr(endpoint) + if err != nil { + return nil, err + } + rawURL = "http://" + tunnelAddr + } + + endpointURL, err := url.ParseURL(rawURL) + if err != nil { + return nil, err + } + + endpointURL.Scheme = "http" + + transportParameters := &docker.TransportParameters{ + Endpoint: endpoint, + DataStore: factory.dataStore, + ReverseTunnelService: factory.reverseTunnelService, + SignatureService: factory.signatureService, + DockerClientFactory: factory.dockerClientFactory, + } + + var innerTransport *http.Transport + if endpoint.TLSConfig.TLS || endpoint.TLSConfig.TLSSkipVerify { + tlsConfig, err := crypto.CreateTLSConfigurationFromDisk(endpoint.TLSConfig) + if err != nil { + return nil, err + } + + endpointURL.Scheme = "https" + + if endpointutils.IsEdgeEndpoint(endpoint) { + innerTransport = ssrf.NewInternalTransport(tlsConfig) + } else { + innerTransport = ssrf.NewTransport(tlsConfig) + } + } else if endpointutils.IsEdgeEndpoint(endpoint) { + innerTransport = ssrf.NewInternalTransport(nil) + } else { + innerTransport = ssrf.NewTransport(nil) + } + + dockerTransport, err := docker.NewTransport(transportParameters, innerTransport, factory.gitService, factory.snapshotService) + if err != nil { + return nil, err + } + + proxy := NewSingleHostReverseProxyWithHostHeader(endpointURL) + proxy.Transport = dockerTransport + return proxy, nil +} + +type dockerLocalProxy struct { + transport *docker.Transport +} + +// ServeHTTP is the http.Handler interface implementation +// for a local (Unix socket or Windows named pipe) Docker proxy. +func (proxy *dockerLocalProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // Force URL/domain to http/unixsocket to be able to + // use http.transport RoundTrip to do the requests via the socket + r.URL.Scheme = "http" + r.URL.Host = "unixsocket" + + res, err := proxy.transport.ProxyDockerRequest(r) + if err != nil { + code := http.StatusInternalServerError + if res != nil && res.StatusCode != 0 { + code = res.StatusCode + } + + httperror.WriteError(w, code, "Unable to proxy the request via the Docker socket", err) + return + } + defer func() { + if err := res.Body.Close(); err != nil { + log.Warn().Err(err).Msg("proxy error: failed to close response body") + } + }() + + for k, vv := range res.Header { + for _, v := range vv { + w.Header().Add(k, v) + } + } + + w.WriteHeader(res.StatusCode) + + if _, err := io.Copy(w, res.Body); err != nil { + log.Debug().Err(err).Msg("proxy error") + } +} diff --git a/api/http/proxy/factory/docker/access_control.go b/api/http/proxy/factory/docker/access_control.go new file mode 100644 index 0000000..69dc5c6 --- /dev/null +++ b/api/http/proxy/factory/docker/access_control.go @@ -0,0 +1,348 @@ +package docker + +import ( + "net/http" + "strings" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/docker/consts" + "github.com/portainer/portainer/api/http/proxy/factory/utils" + "github.com/portainer/portainer/api/internal/authorization" + "github.com/portainer/portainer/api/slicesx" + "github.com/portainer/portainer/api/stacks/stackutils" + + "github.com/docker/docker/client" + "github.com/rs/zerolog/log" +) + +const ( + resourceLabelForPortainerTeamResourceControl = "io.portainer.accesscontrol.teams" + resourceLabelForPortainerUserResourceControl = "io.portainer.accesscontrol.users" + resourceLabelForPortainerPublicResourceControl = "io.portainer.accesscontrol.public" +) + +type ( + resourceLabelsObjectSelector func(map[string]any) map[string]any + + resourceOperationParameters struct { + resourceIdentifierAttribute string + resourceType portainer.ResourceControlType + labelsObjectSelector resourceLabelsObjectSelector + } +) + +func getUniqueElements(items string) []string { + xs := strings.Split(items, ",") + xs = slicesx.Map(xs, strings.TrimSpace) + xs = slicesx.FilterInPlace(xs, func(x string) bool { return len(x) > 0 }) + + return slicesx.Unique(xs) +} + +func (transport *Transport) newResourceControlFromPortainerLabels(labelsObject map[string]any, resourceID string, resourceType portainer.ResourceControlType) (*portainer.ResourceControl, error) { + if labelsObject[resourceLabelForPortainerPublicResourceControl] != nil { + resourceControl := authorization.NewPublicResourceControl(resourceID, resourceType) + + if err := transport.dataStore.ResourceControl().Create(resourceControl); err != nil { + return nil, err + } + + return resourceControl, nil + } + + teamNames := make([]string, 0) + userNames := make([]string, 0) + + if labelsObject[resourceLabelForPortainerTeamResourceControl] != nil { + concatenatedTeamNames := labelsObject[resourceLabelForPortainerTeamResourceControl].(string) + teamNames = getUniqueElements(concatenatedTeamNames) + } + + if labelsObject[resourceLabelForPortainerUserResourceControl] != nil { + concatenatedUserNames := labelsObject[resourceLabelForPortainerUserResourceControl].(string) + userNames = getUniqueElements(concatenatedUserNames) + } + + if len(teamNames) == 0 && len(userNames) == 0 { + return nil, nil + } + + teamIDs := make([]portainer.TeamID, 0) + userIDs := make([]portainer.UserID, 0) + + for _, name := range teamNames { + team, err := transport.dataStore.Team().TeamByName(name) + if err != nil { + log.Warn(). + Str("name", name). + Str("resource_id", resourceID). + Msg("unknown team name in access control label, ignoring access control rule for this team") + + continue + } + + teamIDs = append(teamIDs, team.ID) + } + + for _, name := range userNames { + user, err := transport.dataStore.User().UserByUsername(name) + if err != nil { + log.Warn(). + Str("name", name). + Str("resource_id", resourceID). + Msg("unknown user name in access control label, ignoring access control rule for this user") + + continue + } + + userIDs = append(userIDs, user.ID) + } + + resourceControl := authorization.NewRestrictedResourceControl(resourceID, resourceType, userIDs, teamIDs) + + if err := transport.dataStore.ResourceControl().Create(resourceControl); err != nil { + return nil, err + } + + return resourceControl, nil +} + +func (transport *Transport) createPrivateResourceControl(resourceIdentifier string, resourceType portainer.ResourceControlType, userID portainer.UserID) (*portainer.ResourceControl, error) { + resourceControl := authorization.NewPrivateResourceControl(resourceIdentifier, resourceType, userID) + + if err := transport.dataStore.ResourceControl().Create(resourceControl); err != nil { + log.Error(). + Str("resource", resourceIdentifier). + Err(err). + Msg("unable to persist resource control") + + return nil, err + } + + return resourceControl, nil +} + +func (transport *Transport) getInheritedResourceControlFromServiceOrStack(client *client.Client, resourceIdentifier string, resourceType portainer.ResourceControlType, resourceControls []portainer.ResourceControl) (*portainer.ResourceControl, error) { + switch resourceType { + case portainer.ContainerResourceControl: + return getInheritedResourceControlFromContainerLabels(client, transport.endpoint.ID, resourceIdentifier, resourceControls) + case portainer.NetworkResourceControl: + return getInheritedResourceControlFromNetworkLabels(client, transport.endpoint.ID, resourceIdentifier, resourceControls) + case portainer.VolumeResourceControl: + return getInheritedResourceControlFromVolumeLabels(client, transport.endpoint.ID, resourceIdentifier, resourceControls) + case portainer.ServiceResourceControl: + return getInheritedResourceControlFromServiceLabels(client, transport.endpoint.ID, resourceIdentifier, resourceControls) + case portainer.ConfigResourceControl: + return getInheritedResourceControlFromConfigLabels(client, transport.endpoint.ID, resourceIdentifier, resourceControls) + case portainer.SecretResourceControl: + return getInheritedResourceControlFromSecretLabels(client, transport.endpoint.ID, resourceIdentifier, resourceControls) + } + + return nil, nil +} + +func (transport *Transport) applyAccessControlOnResource(parameters *resourceOperationParameters, responseObject map[string]any, response *http.Response, executor *operationExecutor) error { + if responseObject[parameters.resourceIdentifierAttribute] == nil { + log.Warn(). + Str("identifier_attribute", parameters.resourceIdentifierAttribute). + Msg("unable to find resource identifier property in resource object") + + return nil + } + + if parameters.resourceType == portainer.NetworkResourceControl { + systemResourceControl := findSystemNetworkResourceControl(responseObject) + if systemResourceControl != nil { + responseObject = decorateObject(responseObject, systemResourceControl) + + return utils.RewriteResponse(response, responseObject, http.StatusOK) + } + } + + resourceIdentifier := responseObject[parameters.resourceIdentifierAttribute].(string) + resourceLabelsObject := parameters.labelsObjectSelector(responseObject) + + resourceControl, err := transport.findResourceControl(resourceIdentifier, parameters.resourceType, resourceLabelsObject, executor.operationContext.resourceControls) + if err != nil { + return err + } + + if resourceControl == nil && (executor.operationContext.isAdmin) { + return utils.RewriteResponse(response, responseObject, http.StatusOK) + } + + if executor.operationContext.isAdmin || (resourceControl != nil && authorization.UserCanAccessResource(executor.operationContext.userID, executor.operationContext.userTeamIDs, resourceControl)) { + responseObject = decorateObject(responseObject, resourceControl) + + return utils.RewriteResponse(response, responseObject, http.StatusOK) + } + + return utils.RewriteAccessDeniedResponse(response) +} + +func (transport *Transport) applyAccessControlOnResourceList(parameters *resourceOperationParameters, resourceData []any, executor *operationExecutor) ([]any, error) { + if executor.operationContext.isAdmin { + return transport.decorateResourceList(parameters, resourceData, executor.operationContext.resourceControls) + } + + return transport.filterResourceList(parameters, resourceData, executor.operationContext) +} + +func (transport *Transport) decorateResourceList(parameters *resourceOperationParameters, resourceData []any, resourceControls []portainer.ResourceControl) ([]any, error) { + decoratedResourceData := make([]any, 0) + + for _, resource := range resourceData { + resourceObject := resource.(map[string]any) + + if resourceObject[parameters.resourceIdentifierAttribute] == nil { + log.Warn(). + Str("identifier_attribute", parameters.resourceIdentifierAttribute). + Msg("unable to find resource identifier property in resource list element") + + continue + } + + if parameters.resourceType == portainer.NetworkResourceControl { + systemResourceControl := findSystemNetworkResourceControl(resourceObject) + if systemResourceControl != nil { + resourceObject = decorateObject(resourceObject, systemResourceControl) + decoratedResourceData = append(decoratedResourceData, resourceObject) + + continue + } + } + + resourceIdentifier := resourceObject[parameters.resourceIdentifierAttribute].(string) + resourceLabelsObject := parameters.labelsObjectSelector(resourceObject) + + resourceControl, err := transport.findResourceControl(resourceIdentifier, parameters.resourceType, resourceLabelsObject, resourceControls) + if err != nil { + return nil, err + } + + if resourceControl != nil { + resourceObject = decorateObject(resourceObject, resourceControl) + } + + decoratedResourceData = append(decoratedResourceData, resourceObject) + } + + return decoratedResourceData, nil +} + +func (transport *Transport) filterResourceList(parameters *resourceOperationParameters, resourceData []any, context *restrictedDockerOperationContext) ([]any, error) { + filteredResourceData := make([]any, 0) + + for _, resource := range resourceData { + resourceObject := resource.(map[string]any) + if resourceObject[parameters.resourceIdentifierAttribute] == nil { + log.Warn(). + Str("identifier_attribute", parameters.resourceIdentifierAttribute). + Msg("unable to find resource identifier property in resource list element") + + continue + } + + resourceIdentifier := resourceObject[parameters.resourceIdentifierAttribute].(string) + resourceLabelsObject := parameters.labelsObjectSelector(resourceObject) + + if parameters.resourceType == portainer.NetworkResourceControl { + systemResourceControl := findSystemNetworkResourceControl(resourceObject) + if systemResourceControl != nil { + resourceObject = decorateObject(resourceObject, systemResourceControl) + filteredResourceData = append(filteredResourceData, resourceObject) + + continue + } + } + + resourceControl, err := transport.findResourceControl(resourceIdentifier, parameters.resourceType, resourceLabelsObject, context.resourceControls) + if err != nil { + return nil, err + } + + if resourceControl == nil { + if context.isAdmin { + filteredResourceData = append(filteredResourceData, resourceObject) + } + + continue + } + + if context.isAdmin || authorization.UserCanAccessResource(context.userID, context.userTeamIDs, resourceControl) { + resourceObject = decorateObject(resourceObject, resourceControl) + filteredResourceData = append(filteredResourceData, resourceObject) + } + } + + return filteredResourceData, nil +} + +func (transport *Transport) findResourceControl(resourceIdentifier string, resourceType portainer.ResourceControlType, resourceLabelsObject map[string]any, resourceControls []portainer.ResourceControl) (*portainer.ResourceControl, error) { + resourceControl := authorization.GetResourceControlByResourceIDAndType(resourceIdentifier, resourceType, resourceControls) + if resourceControl != nil { + return resourceControl, nil + } + + if resourceLabelsObject == nil { + return nil, nil + } + + if resourceLabelsObject[consts.SwarmServiceIDLabel] != nil { + inheritedServiceIdentifier := resourceLabelsObject[consts.SwarmServiceIDLabel].(string) + resourceControl = authorization.GetResourceControlByResourceIDAndType(inheritedServiceIdentifier, portainer.ServiceResourceControl, resourceControls) + + if resourceControl != nil { + return resourceControl, nil + } + } + + if resourceLabelsObject[consts.SwarmStackNameLabel] != nil { + stackName := resourceLabelsObject[consts.SwarmStackNameLabel].(string) + stackResourceID := stackutils.ResourceControlID(transport.endpoint.ID, stackName) + resourceControl = authorization.GetResourceControlByResourceIDAndType(stackResourceID, portainer.StackResourceControl, resourceControls) + + if resourceControl != nil { + return resourceControl, nil + } + } + + if resourceLabelsObject[consts.ComposeStackNameLabel] != nil { + stackName := resourceLabelsObject[consts.ComposeStackNameLabel].(string) + stackResourceID := stackutils.ResourceControlID(transport.endpoint.ID, stackName) + resourceControl = authorization.GetResourceControlByResourceIDAndType(stackResourceID, portainer.StackResourceControl, resourceControls) + + if resourceControl != nil { + return resourceControl, nil + } + } + + return transport.newResourceControlFromPortainerLabels(resourceLabelsObject, resourceIdentifier, resourceType) +} + +func getStackResourceIDFromLabels(resourceLabelsObject map[string]string, endpointID portainer.EndpointID) string { + if resourceLabelsObject[consts.SwarmStackNameLabel] != "" { + stackName := resourceLabelsObject[consts.SwarmStackNameLabel] + + return stackutils.ResourceControlID(endpointID, stackName) + } + + if resourceLabelsObject[consts.ComposeStackNameLabel] != "" { + stackName := resourceLabelsObject[consts.ComposeStackNameLabel] + + return stackutils.ResourceControlID(endpointID, stackName) + } + + return "" +} + +func decorateObject(object map[string]any, resourceControl *portainer.ResourceControl) map[string]any { + if object["Portainer"] == nil { + object["Portainer"] = make(map[string]any) + } + + portainerMetadata := object["Portainer"].(map[string]any) + portainerMetadata["ResourceControl"] = resourceControl + + return object +} diff --git a/api/http/proxy/factory/docker/access_control_test.go b/api/http/proxy/factory/docker/access_control_test.go new file mode 100644 index 0000000..038ba9a --- /dev/null +++ b/api/http/proxy/factory/docker/access_control_test.go @@ -0,0 +1,64 @@ +package docker + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_getUniqueElements(t *testing.T) { + t.Parallel() + cases := []struct { + description string + input string + expected []string + }{ + { + description: "no items padded", + input: " ", + expected: []string{}, + }, + { + description: "single item", + input: "a", + expected: []string{"a"}, + }, + { + description: "single item padded", + input: " a ", + expected: []string{"a"}, + }, + { + description: "multiple items", + input: "a,b", + expected: []string{"a", "b"}, + }, + { + description: "multiple items padded", + input: " a , b ", + expected: []string{"a", "b"}, + }, + { + description: "multiple items with empty values", + input: " a , ,b ", + expected: []string{"a", "b"}, + }, + { + description: "duplicates", + input: " a , a ", + expected: []string{"a"}, + }, + { + description: "mix with duplicates", + input: " a ,b, a ", + expected: []string{"a", "b"}, + }, + } + + for _, tt := range cases { + t.Run(tt.description, func(t *testing.T) { + result := getUniqueElements(tt.input) + assert.ElementsMatch(t, result, tt.expected) + }) + } +} diff --git a/api/http/proxy/factory/docker/build.go b/api/http/proxy/factory/docker/build.go new file mode 100644 index 0000000..6e85cd3 --- /dev/null +++ b/api/http/proxy/factory/docker/build.go @@ -0,0 +1,129 @@ +package docker + +import ( + "bytes" + "errors" + "io" + "mime" + "net/http" + + "github.com/portainer/portainer/api/archive" + "github.com/portainer/portainer/api/logs" + + "github.com/rs/zerolog/log" + "github.com/segmentio/encoding/json" +) + +const OneMegabyte = 1024768 + +type postDockerfileRequest struct { + Content string +} + +var ErrUploadedFilesNotFound = errors.New("uploaded files not found to build image") + +// buildOperation inspects the "Content-Type" header to determine if it needs to alter the request. +// +// If the value of the header is empty, it means that a Dockerfile is posted via upload, the function +// will extract the file content from the request body, tar it, and rewrite the body. +// !! THIS IS ONLY TRUE WHEN THE UPLOADED DOCKERFILE FILE HAS NO EXTENSION (the generated file.type in the frontend will be empty) +// If the Dockerfile is named like Dockerfile.yaml or has an internal type, a non-empty Content-Type header will be generated +// +// If the value of the header contains "application/json", it means that the content of a Dockerfile is posted +// in the request payload as JSON, the function will create a new file called Dockerfile inside a tar archive and +// rewrite the body of the request. +// +// In any other case, it will leave the request unaltered. +func buildOperation(request *http.Request) error { + contentTypeHeader := request.Header.Get("Content-Type") + + mediaType := "" + if contentTypeHeader != "" { + var err error + mediaType, _, err = mime.ParseMediaType(contentTypeHeader) + if err != nil { + return err + } + } + + var buffer []byte + switch mediaType { + case "": + body, err := io.ReadAll(request.Body) + if err != nil { + return err + } + + buffer, err = archive.TarFileInBuffer(body, "Dockerfile", 0600) + if err != nil { + return err + } + + case "application/json": + var req postDockerfileRequest + err := json.NewDecoder(request.Body).Decode(&req) + if err != nil { + return err + } + + buffer, err = archive.TarFileInBuffer([]byte(req.Content), "Dockerfile", 0600) + if err != nil { + return err + } + + case "multipart/form-data": + if err := request.ParseMultipartForm(32 * OneMegabyte); err != nil { + return err + } + + if request.MultipartForm == nil || request.MultipartForm.File == nil { + return ErrUploadedFilesNotFound + } + + tfb := archive.NewTarFileInBuffer() + defer func() { + if err := tfb.Close(); err != nil { + log.Warn().Err(err).Msg("failed to close tar buffer") + } + }() + + for k := range request.MultipartForm.File { + f, hdr, err := request.FormFile(k) + if err != nil { + return err + } + + defer logs.CloseAndLogErr(f) + + log.Info().Str("filename", hdr.Filename).Int64("size", hdr.Size).Msg("upload the file to build image") + + content, err := io.ReadAll(f) + if err != nil { + return err + } + + filename := hdr.Filename + if hdr.Filename == "blob" { + filename = "Dockerfile" + } + + if err := tfb.Put(content, filename, 0600); err != nil { + return err + } + } + + buffer = tfb.Bytes() + request.Form = nil + request.PostForm = nil + request.MultipartForm = nil + + default: + return nil + } + + request.Body = io.NopCloser(bytes.NewReader(buffer)) + request.ContentLength = int64(len(buffer)) + request.Header.Set("Content-Type", "application/x-tar") + + return nil +} diff --git a/api/http/proxy/factory/docker/configs.go b/api/http/proxy/factory/docker/configs.go new file mode 100644 index 0000000..dc01287 --- /dev/null +++ b/api/http/proxy/factory/docker/configs.go @@ -0,0 +1,84 @@ +package docker + +import ( + "context" + "net/http" + + "github.com/docker/docker/client" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/proxy/factory/utils" + "github.com/portainer/portainer/api/internal/authorization" +) + +const configObjectIdentifier = "ID" + +func getInheritedResourceControlFromConfigLabels(dockerClient *client.Client, endpointID portainer.EndpointID, configID string, resourceControls []portainer.ResourceControl) (*portainer.ResourceControl, error) { + config, _, err := dockerClient.ConfigInspectWithRaw(context.Background(), configID) + if err != nil { + return nil, err + } + + stackResourceID := getStackResourceIDFromLabels(config.Spec.Labels, endpointID) + if stackResourceID != "" { + return authorization.GetResourceControlByResourceIDAndType(stackResourceID, portainer.StackResourceControl, resourceControls), nil + } + + return nil, nil +} + +// configListOperation extracts the response as a JSON object, loop through the configs array +// decorate and/or filter the configs based on resource controls before rewriting the response. +func (transport *Transport) configListOperation(response *http.Response, executor *operationExecutor) error { + // ConfigList response is a JSON array + // https://docs.docker.com/engine/api/v1.30/#operation/ConfigList + responseArray, err := utils.GetResponseAsJSONArray(response) + if err != nil { + return err + } + + resourceOperationParameters := &resourceOperationParameters{ + resourceIdentifierAttribute: configObjectIdentifier, + resourceType: portainer.ConfigResourceControl, + labelsObjectSelector: selectorConfigLabels, + } + + responseArray, err = transport.applyAccessControlOnResourceList(resourceOperationParameters, responseArray, executor) + if err != nil { + return err + } + + return utils.RewriteResponse(response, responseArray, http.StatusOK) +} + +// configInspectOperation extracts the response as a JSON object, verify that the user +// has access to the config based on resource control and either rewrite an access denied response or a decorated config. +func (transport *Transport) configInspectOperation(response *http.Response, executor *operationExecutor) error { + // ConfigInspect response is a JSON object + // https://docs.docker.com/engine/api/v1.30/#operation/ConfigInspect + responseObject, err := utils.GetResponseAsJSONObject(response) + if err != nil { + return err + } + + resourceOperationParameters := &resourceOperationParameters{ + resourceIdentifierAttribute: configObjectIdentifier, + resourceType: portainer.ConfigResourceControl, + labelsObjectSelector: selectorConfigLabels, + } + + return transport.applyAccessControlOnResource(resourceOperationParameters, responseObject, response, executor) +} + +// selectorConfigLabels retrieve the labels object associated to the config object. +// Labels are available under the "Spec.Labels" property. +// API schema references: +// https://docs.docker.com/engine/api/v1.37/#operation/ConfigList +// https://docs.docker.com/engine/api/v1.37/#operation/ConfigInspect +func selectorConfigLabels(responseObject map[string]any) map[string]any { + if secretSpec := utils.GetJSONObject(responseObject, "Spec"); secretSpec != nil { + return utils.GetJSONObject(secretSpec, "Labels") + } + + return nil +} diff --git a/api/http/proxy/factory/docker/containers.go b/api/http/proxy/factory/docker/containers.go new file mode 100644 index 0000000..83166ab --- /dev/null +++ b/api/http/proxy/factory/docker/containers.go @@ -0,0 +1,315 @@ +package docker + +import ( + "bytes" + "context" + "errors" + "io" + "net/http" + "strings" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/docker/consts" + "github.com/portainer/portainer/api/http/proxy/factory/utils" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/authorization" + + "github.com/docker/docker/client" + "github.com/segmentio/encoding/json" +) + +const containerObjectIdentifier = "Id" + +var ( + ErrPrivilegedModeForbidden = errors.New("forbidden to use privileged mode") + ErrPIDHostNamespaceForbidden = errors.New("forbidden to use pid host namespace") + ErrDeviceMappingForbidden = errors.New("forbidden to use device mapping") + ErrSysCtlSettingsForbidden = errors.New("forbidden to use sysctl settings") + ErrSecurityOptSettingsForbidden = errors.New("forbidden to use security-opt settings") + ErrContainerCapabilitiesForbidden = errors.New("forbidden to use container capabilities") + ErrBindMountsForbidden = errors.New("forbidden to use bind mounts") +) + +func getInheritedResourceControlFromContainerLabels(dockerClient *client.Client, endpointID portainer.EndpointID, containerID string, resourceControls []portainer.ResourceControl) (*portainer.ResourceControl, error) { + container, err := dockerClient.ContainerInspect(context.Background(), containerID) + if err != nil { + return nil, err + } + + serviceName := container.Config.Labels[consts.SwarmServiceIDLabel] + if serviceName != "" { + serviceResourceControl := authorization.GetResourceControlByResourceIDAndType(serviceName, portainer.ServiceResourceControl, resourceControls) + if serviceResourceControl != nil { + return serviceResourceControl, nil + } + } + + stackResourceID := getStackResourceIDFromLabels(container.Config.Labels, endpointID) + if stackResourceID != "" { + return authorization.GetResourceControlByResourceIDAndType(stackResourceID, portainer.StackResourceControl, resourceControls), nil + } + + return nil, nil +} + +// containerListOperation extracts the response as a JSON array, loop through the containers array +// decorate and/or filter the containers based on resource controls before rewriting the response. +func (transport *Transport) containerListOperation(response *http.Response, executor *operationExecutor) error { + // ContainerList response is a JSON array + // https://docs.docker.com/engine/api/v1.28/#operation/ContainerList + responseArray, err := utils.GetResponseAsJSONArray(response) + if err != nil { + return err + } + + resourceOperationParameters := &resourceOperationParameters{ + resourceIdentifierAttribute: containerObjectIdentifier, + resourceType: portainer.ContainerResourceControl, + labelsObjectSelector: selectorContainerLabelsFromContainerListOperation, + } + + responseArray, err = transport.applyAccessControlOnResourceList(resourceOperationParameters, responseArray, executor) + if err != nil { + return err + } + + if executor.labelBlackList != nil { + responseArray, err = filterContainersWithBlackListedLabels(responseArray, executor.labelBlackList) + if err != nil { + return err + } + } + + responseArray, err = transport.applyPortainerContainers(responseArray) + if err != nil { + return err + } + + return utils.RewriteResponse(response, responseArray, http.StatusOK) +} + +// containerInspectOperation extracts the response as a JSON object, verify that the user +// has access to the container based on resource control and either rewrite an access denied response or a decorated container. +func (transport *Transport) containerInspectOperation(response *http.Response, executor *operationExecutor) error { + // ContainerInspect response is a JSON object + // https://docs.docker.com/engine/api/v1.28/#operation/ContainerInspect + responseObject, err := utils.GetResponseAsJSONObject(response) + if err != nil { + return err + } + + resourceOperationParameters := &resourceOperationParameters{ + resourceIdentifierAttribute: containerObjectIdentifier, + resourceType: portainer.ContainerResourceControl, + labelsObjectSelector: selectorContainerLabelsFromContainerInspectOperation, + } + + responseObject, _ = transport.applyPortainerContainer(responseObject) + + return transport.applyAccessControlOnResource(resourceOperationParameters, responseObject, response, executor) +} + +// selectorContainerLabelsFromContainerInspectOperation retrieve the labels object associated to the container object. +// This selector is specific to the containerInspect Docker operation. +// Labels are available under the "Config.Labels" property. +// API schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ContainerInspect +func selectorContainerLabelsFromContainerInspectOperation(responseObject map[string]any) map[string]any { + containerConfigObject := utils.GetJSONObject(responseObject, "Config") + if containerConfigObject != nil { + containerLabelsObject := utils.GetJSONObject(containerConfigObject, "Labels") + + return containerLabelsObject + } + + return nil +} + +// selectorContainerLabelsFromContainerListOperation retrieve the labels object associated to the container object. +// This selector is specific to the containerList Docker operation. +// Labels are available under the "Labels" property. +// API schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ContainerList +func selectorContainerLabelsFromContainerListOperation(responseObject map[string]any) map[string]any { + containerLabelsObject := utils.GetJSONObject(responseObject, "Labels") + + return containerLabelsObject +} + +// filterContainersWithLabels loops through a list of containers, and filters containers that do not contains +// any labels in the labels black list. +func filterContainersWithBlackListedLabels(containerData []any, labelBlackList []portainer.Pair) ([]any, error) { + filteredContainerData := make([]any, 0) + + for _, container := range containerData { + containerObject := container.(map[string]any) + + containerLabels := selectorContainerLabelsFromContainerListOperation(containerObject) + + if containerHasBlackListedLabel(containerLabels, labelBlackList) { + continue + } + + filteredContainerData = append(filteredContainerData, containerObject) + } + + return filteredContainerData, nil +} + +func containerHasBlackListedLabel(containerLabels map[string]any, labelBlackList []portainer.Pair) bool { + for key, value := range containerLabels { + labelName := key + labelValue := value.(string) + + for _, blackListedLabel := range labelBlackList { + if blackListedLabel.Name == labelName && blackListedLabel.Value == labelValue { + return true + } + } + } + + 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 []any `json:"Devices"` + Sysctls map[string]any `json:"Sysctls"` + SecurityOpt []string `json:"SecurityOpt"` + CapAdd []string `json:"CapAdd"` + CapDrop []string `json:"CapDrop"` + Binds []string `json:"Binds"` + Mounts []struct { + Type string `json:"Type"` + } `json:"Mounts"` + } `json:"HostConfig"` + } + + forbiddenResponse := &http.Response{ + StatusCode: http.StatusForbidden, + Body: http.NoBody, + } + + tokenData, err := security.RetrieveTokenData(request) + if err != nil { + return nil, err + } + + isAdminOrEndpointAdmin, err := transport.isAdminOrEndpointAdmin(request) + if err != nil { + return nil, err + } + + if !isAdminOrEndpointAdmin { + securitySettings, err := transport.fetchEndpointSecuritySettings() + if err != nil { + return nil, err + } + + body, err := io.ReadAll(request.Body) + if err != nil { + return nil, err + } + + partialContainer := &PartialContainer{} + if err := json.Unmarshal(body, partialContainer); err != nil { + return nil, err + } + + if !securitySettings.AllowPrivilegedModeForRegularUsers && partialContainer.HostConfig.Privileged { + return forbiddenResponse, ErrPrivilegedModeForbidden + } + + if !securitySettings.AllowHostNamespaceForRegularUsers && partialContainer.HostConfig.PidMode == "host" { + return forbiddenResponse, ErrPIDHostNamespaceForbidden + } + + if !securitySettings.AllowDeviceMappingForRegularUsers && len(partialContainer.HostConfig.Devices) > 0 { + return forbiddenResponse, ErrDeviceMappingForbidden + } + + if !securitySettings.AllowSysctlSettingForRegularUsers && len(partialContainer.HostConfig.Sysctls) > 0 { + return forbiddenResponse, ErrSysCtlSettingsForbidden + } + + if !securitySettings.AllowSecurityOptForRegularUsers && len(partialContainer.HostConfig.SecurityOpt) > 0 { + return forbiddenResponse, ErrSecurityOptSettingsForbidden + } + + if !securitySettings.AllowContainerCapabilitiesForRegularUsers && (len(partialContainer.HostConfig.CapAdd) > 0 || len(partialContainer.HostConfig.CapDrop) > 0) { + return nil, ErrContainerCapabilitiesForbidden + } + + if !securitySettings.AllowBindMountsForRegularUsers && len(partialContainer.HostConfig.Binds) > 0 { + for _, bind := range partialContainer.HostConfig.Binds { + if strings.HasPrefix(bind, "/") { + return forbiddenResponse, ErrBindMountsForbidden + } + } + } + + if !securitySettings.AllowBindMountsForRegularUsers && len(partialContainer.HostConfig.Mounts) > 0 { + for _, mount := range partialContainer.HostConfig.Mounts { + if mount.Type == "bind" { + return forbiddenResponse, ErrBindMountsForbidden + } + } + } + + request.Body = io.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 +} + +func (transport *Transport) decorateContainerUpdateOperation(request *http.Request, containerID string) (*http.Response, error) { + type PartialContainerUpdate struct { + Devices []any `json:"Devices"` + } + + forbiddenResponse := &http.Response{ + StatusCode: http.StatusForbidden, + } + + isAdminOrEndpointAdmin, err := transport.isAdminOrEndpointAdmin(request) + if err != nil { + return nil, err + } + + if isAdminOrEndpointAdmin { + return transport.restrictedResourceOperation(request, containerID, containerID, portainer.ContainerResourceControl, false) + } + + securitySettings, err := transport.fetchEndpointSecuritySettings() + if err != nil { + return nil, err + } + + body, err := io.ReadAll(request.Body) + if err != nil { + return nil, err + } + + partialUpdate := &PartialContainerUpdate{} + if err := json.Unmarshal(body, partialUpdate); err != nil { + return nil, err + } + + if !securitySettings.AllowDeviceMappingForRegularUsers && len(partialUpdate.Devices) > 0 { + return forbiddenResponse, ErrDeviceMappingForbidden + } + + request.Body = io.NopCloser(bytes.NewBuffer(body)) + + return transport.restrictedResourceOperation(request, containerID, containerID, portainer.ContainerResourceControl, false) +} diff --git a/api/http/proxy/factory/docker/containers_test.go b/api/http/proxy/factory/docker/containers_test.go new file mode 100644 index 0000000..eba7afc --- /dev/null +++ b/api/http/proxy/factory/docker/containers_test.go @@ -0,0 +1,123 @@ +package docker + +import ( + "bytes" + "net/http" + "net/http/httptest" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/http/security" + + "github.com/segmentio/encoding/json" + "github.com/stretchr/testify/require" +) + +func TestDecorateContainerCreationOperation_BindMounts(t *testing.T) { + t.Parallel() + + admin := portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole} + regularUser := portainer.User{ID: 2, Username: "user", Role: portainer.StandardUserRole} + + _, ds := datastore.MustNewTestStore(t, true, false) + + err := ds.UpdateTx(func(tx dataservices.DataStoreTx) error { + err := tx.User().Create(&admin) + require.NoError(t, err) + + err = tx.User().Create(®ularUser) + require.NoError(t, err) + + err = tx.Endpoint().Create(&portainer.Endpoint{ + ID: 1, + Name: "test", + SecuritySettings: portainer.EndpointSecuritySettings{ + AllowBindMountsForRegularUsers: false, + }, + }) + require.NoError(t, err) + + return nil + }) + require.NoError(t, err) + + srv, version := mockDockerAPIServer(t, RoutesDefinition{ + {http.MethodPost, "/containers/create"}: map[string]any{"Id": "abc123", "Warnings": []any{}}, + }) + defer srv.Close() + + transport := &Transport{ + endpoint: &portainer.Endpoint{ID: 1, URL: srv.URL}, + dataStore: ds, + HTTPTransport: &http.Transport{}, + } + + adminToken := portainer.TokenData{ID: admin.ID, Username: admin.Username, Role: admin.Role} + userToken := portainer.TokenData{ID: regularUser.ID, Username: regularUser.Username, Role: regularUser.Role} + + makeRequest := func(token portainer.TokenData, body any) *http.Request { + bodyBytes, err := json.Marshal(body) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodPost, srv.URL+"/v"+version+"/containers/create", bytes.NewReader(bodyBytes)) + req = req.WithContext(security.StoreTokenData(req, &token)) + + return req + } + + // Admin bypasses security checks + req := makeRequest(adminToken, map[string]any{ + "HostConfig": map[string]any{ + "Mounts": []map[string]any{{"Type": "bind", "Source": "/", "Target": "/host"}}, + }, + }) + resp, err := transport.decorateContainerCreationOperation(req, containerObjectIdentifier, portainer.ContainerResourceControl) + require.NoError(t, err) + require.NotNil(t, resp) + + err = resp.Body.Close() + require.NoError(t, err) + + // HostConfig.Binds with an absolute path is blocked for regular users + req = makeRequest(userToken, map[string]any{ + "HostConfig": map[string]any{ + "Binds": []string{"/:/host:ro"}, + }, + }) + resp, err = transport.decorateContainerCreationOperation(req, containerObjectIdentifier, portainer.ContainerResourceControl) + require.ErrorIs(t, err, ErrBindMountsForbidden) + require.NotNil(t, resp) + require.Equal(t, http.StatusForbidden, resp.StatusCode) + + err = resp.Body.Close() + require.NoError(t, err) + + // HostConfig.Mounts with type bind is blocked for regular users + req = makeRequest(userToken, map[string]any{ + "HostConfig": map[string]any{ + "Mounts": []map[string]any{{"Type": "bind", "Source": "/", "Target": "/host"}}, + }, + }) + resp, err = transport.decorateContainerCreationOperation(req, containerObjectIdentifier, portainer.ContainerResourceControl) + require.ErrorIs(t, err, ErrBindMountsForbidden) + require.NotNil(t, resp) + require.Equal(t, http.StatusForbidden, resp.StatusCode) + + err = resp.Body.Close() + require.NoError(t, err) + + // HostConfig.Mounts with a non-bind type is allowed for regular users + req = makeRequest(userToken, map[string]any{ + "HostConfig": map[string]any{ + "Mounts": []map[string]any{{"Type": "volume", "Source": "myvolume", "Target": "/data"}}, + }, + }) + resp, err = transport.decorateContainerCreationOperation(req, containerObjectIdentifier, portainer.ContainerResourceControl) + require.NoError(t, err) + require.NotNil(t, resp) + + err = resp.Body.Close() + require.NoError(t, err) +} diff --git a/api/http/proxy/factory/docker/networks.go b/api/http/proxy/factory/docker/networks.go new file mode 100644 index 0000000..cd94478 --- /dev/null +++ b/api/http/proxy/factory/docker/networks.go @@ -0,0 +1,103 @@ +package docker + +import ( + "context" + "net/http" + + portainer "github.com/portainer/portainer/api" + + "github.com/docker/docker/api/types/network" + + "github.com/docker/docker/client" + + "github.com/portainer/portainer/api/http/proxy/factory/utils" + "github.com/portainer/portainer/api/internal/authorization" +) + +const ( + networkObjectIdentifier = "Id" + networkObjectName = "Name" +) + +func getInheritedResourceControlFromNetworkLabels(dockerClient *client.Client, endpointID portainer.EndpointID, networkID string, resourceControls []portainer.ResourceControl) (*portainer.ResourceControl, error) { + network, err := dockerClient.NetworkInspect(context.Background(), networkID, network.InspectOptions{}) + if err != nil { + return nil, err + } + + stackResourceID := getStackResourceIDFromLabels(network.Labels, endpointID) + if stackResourceID != "" { + return authorization.GetResourceControlByResourceIDAndType(stackResourceID, portainer.StackResourceControl, resourceControls), nil + } + + return nil, nil +} + +// networkListOperation extracts the response as a JSON object, loop through the networks array +// decorate and/or filter the networks based on resource controls before rewriting the response. +func (transport *Transport) networkListOperation(response *http.Response, executor *operationExecutor) error { + // NetworkList response is a JSON array + // https://docs.docker.com/engine/api/v1.28/#operation/NetworkList + responseArray, err := utils.GetResponseAsJSONArray(response) + if err != nil { + return err + } + + resourceOperationParameters := &resourceOperationParameters{ + resourceIdentifierAttribute: networkObjectIdentifier, + resourceType: portainer.NetworkResourceControl, + labelsObjectSelector: selectorNetworkLabels, + } + + responseArray, err = transport.applyAccessControlOnResourceList(resourceOperationParameters, responseArray, executor) + if err != nil { + return err + } + + return utils.RewriteResponse(response, responseArray, http.StatusOK) +} + +// networkInspectOperation extracts the response as a JSON object, verify that the user +// has access to the network based on resource control and either rewrite an access denied response or a decorated network. +func (transport *Transport) networkInspectOperation(response *http.Response, executor *operationExecutor) error { + // NetworkInspect response is a JSON object + // https://docs.docker.com/engine/api/v1.28/#operation/NetworkInspect + responseObject, err := utils.GetResponseAsJSONObject(response) + if err != nil { + return err + } + + resourceOperationParameters := &resourceOperationParameters{ + resourceIdentifierAttribute: networkObjectIdentifier, + resourceType: portainer.NetworkResourceControl, + labelsObjectSelector: selectorNetworkLabels, + } + + return transport.applyAccessControlOnResource(resourceOperationParameters, responseObject, response, executor) +} + +// findSystemNetworkResourceControl will check if the network object is a system network +// and will return a system resource control if that's the case. +func findSystemNetworkResourceControl(networkObject map[string]any) *portainer.ResourceControl { + if networkObject[networkObjectName] == nil { + return nil + } + + networkID := networkObject[networkObjectIdentifier].(string) + networkName := networkObject[networkObjectName].(string) + + if networkName == "bridge" || networkName == "host" || networkName == "ingress" || networkName == "nat" || networkName == "none" { + return authorization.NewSystemResourceControl(networkID, portainer.NetworkResourceControl) + } + + return nil +} + +// selectorNetworkLabels retrieve the labels object associated to the network object. +// Labels are available under the "Labels" property. +// API schema references: +// https://docs.docker.com/engine/api/v1.28/#operation/NetworkInspect +// https://docs.docker.com/engine/api/v1.28/#operation/NetworkList +func selectorNetworkLabels(responseObject map[string]any) map[string]any { + return utils.GetJSONObject(responseObject, "Labels") +} diff --git a/api/http/proxy/factory/docker/portainer.go b/api/http/proxy/factory/docker/portainer.go new file mode 100644 index 0000000..2868d21 --- /dev/null +++ b/api/http/proxy/factory/docker/portainer.go @@ -0,0 +1,47 @@ +package docker + +import ( + "os" +) + +var portainerContainerId string + +func init() { + // use hostname as the current portainer id + // Reference issue: JIRA EE-917 + // https://social.msdn.microsoft.com/Forums/en-US/5e5bff27-7511-4fb2-9ffa-207520d0ffb8/how-to-gain-windows-container-id-in-windows-container?forum=windowscontainers + // Because Windows container cannot obtain container ID from /proc/self/cgroups like linux container, + // as a workaround, we currently use hostname as container ID. + portainerContainerId, _ = os.Hostname() +} + +func (transport *Transport) applyPortainerContainers(resources []any) ([]any, error) { + decoratedResourceData := make([]any, 0) + + for _, resource := range resources { + responseObject, ok := resource.(map[string]any) + if !ok { + decoratedResourceData = append(decoratedResourceData, resource) + + continue + } + + responseObject, _ = transport.applyPortainerContainer(responseObject) + decoratedResourceData = append(decoratedResourceData, responseObject) + } + + return decoratedResourceData, nil +} + +func (transport *Transport) applyPortainerContainer(resourceObject map[string]any) (map[string]any, error) { + resourceId, ok := resourceObject["Id"].(string) + if !ok { + return resourceObject, nil + } + + if len(resourceId) >= 12 && resourceId[0:12] == portainerContainerId { + resourceObject["IsPortainer"] = true + } + + return resourceObject, nil +} diff --git a/api/http/proxy/factory/docker/registry.go b/api/http/proxy/factory/docker/registry.go new file mode 100644 index 0000000..7036853 --- /dev/null +++ b/api/http/proxy/factory/docker/registry.go @@ -0,0 +1,67 @@ +package docker + +import ( + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/registryutils" +) + +type ( + registryAccessContext struct { + isAdmin bool + user *portainer.User + endpointID portainer.EndpointID + teamMemberships []portainer.TeamMembership + registries []portainer.Registry + } + + registryAuthenticationHeader struct { + Username string `json:"username"` + Password string `json:"password"` + Serveraddress string `json:"serveraddress"` + } + + portainerRegistryAuthenticationHeader struct { + RegistryId *portainer.RegistryID `json:"registryId"` + } +) + +func createRegistryAuthenticationHeader( + dataStore dataservices.DataStore, + registryID portainer.RegistryID, + accessContext *registryAccessContext, +) (authenticationHeader registryAuthenticationHeader, err error) { + if registryID == 0 { // dockerhub (anonymous) + authenticationHeader.Serveraddress = "docker.io" + + return + } + + // Any "custom" registry + var matchingRegistry *portainer.Registry + + for _, registry := range accessContext.registries { + if registry.ID == registryID && + (accessContext.isAdmin || + security.AuthorizedRegistryAccess(®istry, accessContext.user, accessContext.teamMemberships, accessContext.endpointID)) { + matchingRegistry = ®istry + + break + } + } + + if matchingRegistry == nil { + return + } + + if err = registryutils.PrepareRegistryCredentials(dataStore, matchingRegistry); err != nil { + return + } + + authenticationHeader.Serveraddress = matchingRegistry.URL + authenticationHeader.Username = matchingRegistry.Username + authenticationHeader.Password = matchingRegistry.Password + + return +} diff --git a/api/http/proxy/factory/docker/secrets.go b/api/http/proxy/factory/docker/secrets.go new file mode 100644 index 0000000..dc35502 --- /dev/null +++ b/api/http/proxy/factory/docker/secrets.go @@ -0,0 +1,87 @@ +package docker + +import ( + "context" + "net/http" + + "github.com/docker/docker/client" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/proxy/factory/utils" + "github.com/portainer/portainer/api/internal/authorization" +) + +const ( + secretObjectIdentifier = "ID" +) + +func getInheritedResourceControlFromSecretLabels(dockerClient *client.Client, endpointID portainer.EndpointID, secretID string, resourceControls []portainer.ResourceControl) (*portainer.ResourceControl, error) { + secret, _, err := dockerClient.SecretInspectWithRaw(context.Background(), secretID) + if err != nil { + return nil, err + } + + stackResourceID := getStackResourceIDFromLabels(secret.Spec.Labels, endpointID) + if stackResourceID != "" { + return authorization.GetResourceControlByResourceIDAndType(stackResourceID, portainer.StackResourceControl, resourceControls), nil + } + + return nil, nil +} + +// secretListOperation extracts the response as a JSON object, loop through the secrets array +// decorate and/or filter the secrets based on resource controls before rewriting the response. +func (transport *Transport) secretListOperation(response *http.Response, executor *operationExecutor) error { + // SecretList response is a JSON array + // https://docs.docker.com/engine/api/v1.28/#operation/SecretList + responseArray, err := utils.GetResponseAsJSONArray(response) + if err != nil { + return err + } + + resourceOperationParameters := &resourceOperationParameters{ + resourceIdentifierAttribute: secretObjectIdentifier, + resourceType: portainer.SecretResourceControl, + labelsObjectSelector: selectorSecretLabels, + } + + responseArray, err = transport.applyAccessControlOnResourceList(resourceOperationParameters, responseArray, executor) + if err != nil { + return err + } + + return utils.RewriteResponse(response, responseArray, http.StatusOK) +} + +// secretInspectOperation extracts the response as a JSON object, verify that the user +// has access to the secret based on resource control and either rewrite an access denied response or a decorated secret. +func (transport *Transport) secretInspectOperation(response *http.Response, executor *operationExecutor) error { + // SecretInspect response is a JSON object + // https://docs.docker.com/engine/api/v1.28/#operation/SecretInspect + responseObject, err := utils.GetResponseAsJSONObject(response) + if err != nil { + return err + } + + resourceOperationParameters := &resourceOperationParameters{ + resourceIdentifierAttribute: secretObjectIdentifier, + resourceType: portainer.SecretResourceControl, + labelsObjectSelector: selectorSecretLabels, + } + + return transport.applyAccessControlOnResource(resourceOperationParameters, responseObject, response, executor) +} + +// selectorSecretLabels retrieve the labels object associated to the secret object. +// Labels are available under the "Spec.Labels" property. +// API schema references: +// https://docs.docker.com/engine/api/v1.37/#operation/SecretList +// https://docs.docker.com/engine/api/v1.37/#operation/SecretInspect +func selectorSecretLabels(responseObject map[string]any) map[string]any { + secretSpec := utils.GetJSONObject(responseObject, "Spec") + if secretSpec != nil { + secretLabelsObject := utils.GetJSONObject(secretSpec, "Labels") + return secretLabelsObject + } + return nil +} diff --git a/api/http/proxy/factory/docker/services.go b/api/http/proxy/factory/docker/services.go new file mode 100644 index 0000000..5949651 --- /dev/null +++ b/api/http/proxy/factory/docker/services.go @@ -0,0 +1,218 @@ +package docker + +import ( + "bytes" + "context" + "io" + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/proxy/factory/utils" + "github.com/portainer/portainer/api/internal/authorization" + "github.com/portainer/portainer/api/logs" + + "github.com/docker/docker/api/types/swarm" + "github.com/docker/docker/client" + "github.com/segmentio/encoding/json" +) + +const serviceObjectIdentifier = "ID" + +type partialServiceSpec struct { + TaskTemplate struct { + ContainerSpec struct { + CapabilityAdd []string `json:"CapabilityAdd"` + CapabilityDrop []string `json:"CapabilityDrop"` + Sysctls map[string]any `json:"Sysctls"` + Privileges *struct { + Seccomp *struct{ Mode string } `json:"Seccomp"` + AppArmor *struct{ Mode string } `json:"AppArmor"` + } `json:"Privileges"` + Mounts []struct { + Type string `json:"Type"` + VolumeOptions *struct { + DriverConfig *struct { + Options map[string]string `json:"Options"` + } `json:"DriverConfig"` + } `json:"VolumeOptions"` + } `json:"Mounts"` + } `json:"ContainerSpec"` + } `json:"TaskTemplate"` +} + +func CheckServiceBodyRestrictions(request *http.Request, securitySettings *portainer.EndpointSecuritySettings) error { + defer logs.CloseAndLogErr(request.Body) + + body, err := io.ReadAll(request.Body) + if err != nil { + return err + } + + spec := &partialServiceSpec{} + if err := json.Unmarshal(body, spec); err != nil { + return err + } + + containerSpec := spec.TaskTemplate.ContainerSpec + + if !securitySettings.AllowContainerCapabilitiesForRegularUsers && (len(containerSpec.CapabilityAdd) > 0 || len(containerSpec.CapabilityDrop) > 0) { + return ErrContainerCapabilitiesForbidden + } + + if !securitySettings.AllowSysctlSettingForRegularUsers && len(containerSpec.Sysctls) > 0 { + return ErrSysCtlSettingsForbidden + } + + if !securitySettings.AllowSecurityOptForRegularUsers && containerSpec.Privileges != nil { + if containerSpec.Privileges.Seccomp != nil || containerSpec.Privileges.AppArmor != nil { + return ErrSecurityOptSettingsForbidden + } + } + + if !securitySettings.AllowBindMountsForRegularUsers { + for _, mount := range containerSpec.Mounts { + if mount.Type == "bind" { + return ErrBindMountsForbidden + } + + if mount.VolumeOptions != nil && mount.VolumeOptions.DriverConfig != nil { + if mount.VolumeOptions.DriverConfig.Options["type"] == "bind" { + return ErrBindMountsForbidden + } + } + } + } + + request.Body = io.NopCloser(bytes.NewBuffer(body)) + + return nil +} + +func getInheritedResourceControlFromServiceLabels(dockerClient *client.Client, endpointID portainer.EndpointID, serviceID string, resourceControls []portainer.ResourceControl) (*portainer.ResourceControl, error) { + service, _, err := dockerClient.ServiceInspectWithRaw(context.Background(), serviceID, swarm.ServiceInspectOptions{}) + if err != nil { + return nil, err + } + + stackResourceID := getStackResourceIDFromLabels(service.Spec.Labels, endpointID) + if stackResourceID != "" { + return authorization.GetResourceControlByResourceIDAndType(stackResourceID, portainer.StackResourceControl, resourceControls), nil + } + + return nil, nil +} + +// serviceListOperation extracts the response as a JSON array, loop through the service array +// decorate and/or filter the services based on resource controls before rewriting the response. +func (transport *Transport) serviceListOperation(response *http.Response, executor *operationExecutor) error { + // ServiceList response is a JSON array + // https://docs.docker.com/engine/api/v1.28/#operation/ServiceList + responseArray, err := utils.GetResponseAsJSONArray(response) + if err != nil { + return err + } + + resourceOperationParameters := &resourceOperationParameters{ + resourceIdentifierAttribute: serviceObjectIdentifier, + resourceType: portainer.ServiceResourceControl, + labelsObjectSelector: selectorServiceLabels, + } + + responseArray, err = transport.applyAccessControlOnResourceList(resourceOperationParameters, responseArray, executor) + if err != nil { + return err + } + + return utils.RewriteResponse(response, responseArray, http.StatusOK) +} + +// serviceInspectOperation extracts the response as a JSON object, verify that the user +// has access to the service based on resource control and either rewrite an access denied response or a decorated service. +func (transport *Transport) serviceInspectOperation(response *http.Response, executor *operationExecutor) error { + // ServiceInspect response is a JSON object + // https://docs.docker.com/engine/api/v1.28/#operation/ServiceInspect + responseObject, err := utils.GetResponseAsJSONObject(response) + if err != nil { + return err + } + + resourceOperationParameters := &resourceOperationParameters{ + resourceIdentifierAttribute: serviceObjectIdentifier, + resourceType: portainer.ServiceResourceControl, + labelsObjectSelector: selectorServiceLabels, + } + + return transport.applyAccessControlOnResource(resourceOperationParameters, responseObject, response, executor) +} + +// selectorServiceLabels retrieve the labels object associated to the service object. +// Labels are available under the "Spec.Labels" property. +// API schema references: +// https://docs.docker.com/engine/api/v1.28/#operation/ServiceInspect +// https://docs.docker.com/engine/api/v1.28/#operation/ServiceList +func selectorServiceLabels(responseObject map[string]any) map[string]any { + serviceSpecObject := utils.GetJSONObject(responseObject, "Spec") + if serviceSpecObject != nil { + return utils.GetJSONObject(serviceSpecObject, "Labels") + } + + return nil +} + +func (transport *Transport) decorateServiceCreationOperation(request *http.Request) (*http.Response, error) { + isAdminOrEndpointAdmin, err := transport.isAdminOrEndpointAdmin(request) + if err != nil { + return nil, err + } + + if isAdminOrEndpointAdmin { + return transport.replaceRegistryAuthenticationHeader(request) + } + + securitySettings, err := transport.fetchEndpointSecuritySettings() + if err != nil { + return nil, err + } + + if err := CheckServiceBodyRestrictions(request, securitySettings); err != nil { + return &http.Response{ + StatusCode: http.StatusForbidden, + Body: io.NopCloser(bytes.NewBufferString("Access denied: insufficient permissions to create service with specified configuration")), + }, err + } + + return transport.replaceRegistryAuthenticationHeader(request) +} + +func (transport *Transport) decorateServiceUpdateOperation(request *http.Request, serviceID string) (*http.Response, error) { + isAdminOrEndpointAdmin, err := transport.isAdminOrEndpointAdmin(request) + if err != nil { + return nil, err + } + + if isAdminOrEndpointAdmin { + if err := transport.decorateRegistryAuthenticationHeader(request); err != nil { + return nil, err + } + + return transport.executeDockerRequest(request) + } + + securitySettings, err := transport.fetchEndpointSecuritySettings() + if err != nil { + return nil, err + } + + if err := CheckServiceBodyRestrictions(request, securitySettings); err != nil { + return &http.Response{ + StatusCode: http.StatusForbidden, + Body: io.NopCloser(bytes.NewBufferString("Access denied: insufficient permissions to update service with specified configuration")), + }, err + } + + if err := transport.decorateRegistryAuthenticationHeader(request); err != nil { + return nil, err + } + + return transport.restrictedResourceOperation(request, serviceID, serviceID, portainer.ServiceResourceControl, false) +} diff --git a/api/http/proxy/factory/docker/services_test.go b/api/http/proxy/factory/docker/services_test.go new file mode 100644 index 0000000..53ca4f8 --- /dev/null +++ b/api/http/proxy/factory/docker/services_test.go @@ -0,0 +1,644 @@ +package docker + +import ( + "bytes" + "net/http" + "net/http/httptest" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/http/security" + + "github.com/docker/docker/api/types/mount" + "github.com/docker/docker/api/types/swarm" + "github.com/segmentio/encoding/json" + "github.com/stretchr/testify/require" +) + +const serviceCreationAPIVersion = "1.51" + +type serviceCreationFixtures struct { + dockerSrv *httptest.Server + ds dataservices.DataStore + stdUser portainer.User + adminUser portainer.User + endpointID portainer.EndpointID +} + +func newServiceCreationFixtures(t *testing.T) *serviceCreationFixtures { + t.Helper() + + const serviceID = "some-service-id" + + dockerSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodHead && r.URL.Path == "/_ping" { + w.Header().Add("Api-Version", serviceCreationAPIVersion) + _, _ = w.Write([]byte{}) + + return + } + + if r.Method == http.MethodPost { + data, err := json.Marshal(map[string]string{"ID": serviceID}) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write(data) + + return + } + + http.NotFound(w, r) + })) + t.Cleanup(dockerSrv.Close) + + _, store := datastore.MustNewTestStore(t, true, false) + + f := &serviceCreationFixtures{ + dockerSrv: dockerSrv, + ds: store, + stdUser: portainer.User{ID: 1, Username: "std", Role: portainer.StandardUserRole}, + adminUser: portainer.User{ID: 2, Username: "admin", Role: portainer.AdministratorRole}, + endpointID: portainer.EndpointID(1), + } + + err := store.UpdateTx(func(tx dataservices.DataStoreTx) error { + err := tx.User().Create(&f.stdUser) + require.NoError(t, err) + + err = tx.User().Create(&f.adminUser) + require.NoError(t, err) + + err = tx.Endpoint().Create(&portainer.Endpoint{ID: f.endpointID, Name: "test-env"}) + require.NoError(t, err) + + return nil + }) + require.NoError(t, err) + + return f +} + +func (f *serviceCreationFixtures) setSecuritySettings(t *testing.T, settings portainer.EndpointSecuritySettings) { + t.Helper() + + err := f.ds.UpdateTx(func(tx dataservices.DataStoreTx) error { + return tx.Endpoint().UpdateEndpoint(f.endpointID, &portainer.Endpoint{ + ID: f.endpointID, + Name: "test-env", + SecuritySettings: settings, + }) + }) + require.NoError(t, err) +} + +func (f *serviceCreationFixtures) newTransport() *Transport { + return &Transport{ + endpoint: &portainer.Endpoint{ID: f.endpointID}, + dataStore: f.ds, + HTTPTransport: &http.Transport{}, + } +} + +func (f *serviceCreationFixtures) newRequest(t *testing.T, spec swarm.ServiceSpec, user portainer.User) *http.Request { + t.Helper() + + data, err := json.Marshal(spec) + require.NoError(t, err) + + req, err := http.NewRequestWithContext( + t.Context(), + http.MethodPost, + f.dockerSrv.URL+"/v"+serviceCreationAPIVersion+"/services/create", + bytes.NewReader(data), + ) + require.NoError(t, err) + + return req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ + ID: user.ID, + Username: user.Username, + Role: user.Role, + })) +} + +var ( + restrictiveSettings = portainer.EndpointSecuritySettings{ + AllowContainerCapabilitiesForRegularUsers: false, + AllowSysctlSettingForRegularUsers: false, + AllowSecurityOptForRegularUsers: false, + AllowBindMountsForRegularUsers: false, + } + + permissiveSettings = portainer.EndpointSecuritySettings{ + AllowContainerCapabilitiesForRegularUsers: true, + AllowSysctlSettingForRegularUsers: true, + AllowSecurityOptForRegularUsers: true, + AllowBindMountsForRegularUsers: true, + } +) + +func TestDecorateServiceCreationOperation_CapabilityAddForbidden(t *testing.T) { + t.Parallel() + + f := newServiceCreationFixtures(t) + f.setSecuritySettings(t, restrictiveSettings) + + spec := swarm.ServiceSpec{ + TaskTemplate: swarm.TaskSpec{ + ContainerSpec: &swarm.ContainerSpec{ + CapabilityAdd: []string{"NET_ADMIN"}, + }, + }, + } + + resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser)) + require.ErrorIs(t, err, ErrContainerCapabilitiesForbidden) + require.NotNil(t, resp) + require.Equal(t, http.StatusForbidden, resp.StatusCode) + + err = resp.Body.Close() + require.NoError(t, err) +} + +func TestDecorateServiceCreationOperation_CapabilityDropForbidden(t *testing.T) { + t.Parallel() + + f := newServiceCreationFixtures(t) + f.setSecuritySettings(t, restrictiveSettings) + + spec := swarm.ServiceSpec{ + TaskTemplate: swarm.TaskSpec{ + ContainerSpec: &swarm.ContainerSpec{ + CapabilityDrop: []string{"MKNOD"}, + }, + }, + } + + resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser)) + require.ErrorIs(t, err, ErrContainerCapabilitiesForbidden) + require.NotNil(t, resp) + require.Equal(t, http.StatusForbidden, resp.StatusCode) + + err = resp.Body.Close() + require.NoError(t, err) +} + +func TestDecorateServiceCreationOperation_CapabilitiesAllowed(t *testing.T) { + t.Parallel() + + f := newServiceCreationFixtures(t) + f.setSecuritySettings(t, permissiveSettings) + + spec := swarm.ServiceSpec{ + TaskTemplate: swarm.TaskSpec{ + ContainerSpec: &swarm.ContainerSpec{ + CapabilityAdd: []string{"NET_ADMIN"}, + }, + }, + } + + resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser)) + require.NoError(t, err) + require.NotNil(t, resp) + require.NotEqual(t, http.StatusForbidden, resp.StatusCode) + + err = resp.Body.Close() + require.NoError(t, err) +} + +func TestDecorateServiceCreationOperation_NoCapabilitiesAllowed(t *testing.T) { + t.Parallel() + + f := newServiceCreationFixtures(t) + f.setSecuritySettings(t, restrictiveSettings) + + var spec swarm.ServiceSpec + + resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser)) + require.NotErrorIs(t, err, ErrContainerCapabilitiesForbidden) + require.NotNil(t, resp) + require.NotEqual(t, http.StatusForbidden, resp.StatusCode) + + err = resp.Body.Close() + require.NoError(t, err) +} + +func TestDecorateServiceCreationOperation_SysctlForbidden(t *testing.T) { + t.Parallel() + + f := newServiceCreationFixtures(t) + f.setSecuritySettings(t, restrictiveSettings) + + spec := swarm.ServiceSpec{ + TaskTemplate: swarm.TaskSpec{ + ContainerSpec: &swarm.ContainerSpec{ + Sysctls: map[string]string{"net.ipv4.ip_forward": "1"}, + }, + }, + } + + resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser)) + require.ErrorIs(t, err, ErrSysCtlSettingsForbidden) + require.NotNil(t, resp) + require.Equal(t, http.StatusForbidden, resp.StatusCode) + + err = resp.Body.Close() + require.NoError(t, err) +} + +func TestDecorateServiceCreationOperation_SysctlAllowed(t *testing.T) { + t.Parallel() + + f := newServiceCreationFixtures(t) + f.setSecuritySettings(t, permissiveSettings) + + spec := swarm.ServiceSpec{ + TaskTemplate: swarm.TaskSpec{ + ContainerSpec: &swarm.ContainerSpec{ + Sysctls: map[string]string{"net.ipv4.ip_forward": "1"}, + }, + }, + } + + resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser)) + require.NoError(t, err) + require.NotNil(t, resp) + require.NotEqual(t, http.StatusForbidden, resp.StatusCode) + + err = resp.Body.Close() + require.NoError(t, err) +} + +func TestDecorateServiceCreationOperation_SeccompForbidden(t *testing.T) { + t.Parallel() + + f := newServiceCreationFixtures(t) + f.setSecuritySettings(t, restrictiveSettings) + + spec := swarm.ServiceSpec{ + TaskTemplate: swarm.TaskSpec{ + ContainerSpec: &swarm.ContainerSpec{ + Privileges: &swarm.Privileges{ + Seccomp: &swarm.SeccompOpts{Mode: swarm.SeccompModeCustom}, + }, + }, + }, + } + + resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser)) + require.ErrorIs(t, err, ErrSecurityOptSettingsForbidden) + require.NotNil(t, resp) + require.Equal(t, http.StatusForbidden, resp.StatusCode) + + err = resp.Body.Close() + require.NoError(t, err) +} + +func TestDecorateServiceCreationOperation_AppArmorForbidden(t *testing.T) { + t.Parallel() + + f := newServiceCreationFixtures(t) + f.setSecuritySettings(t, restrictiveSettings) + + spec := swarm.ServiceSpec{ + TaskTemplate: swarm.TaskSpec{ + ContainerSpec: &swarm.ContainerSpec{ + Privileges: &swarm.Privileges{ + AppArmor: &swarm.AppArmorOpts{Mode: swarm.AppArmorModeDefault}, + }, + }, + }, + } + + resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser)) + require.ErrorIs(t, err, ErrSecurityOptSettingsForbidden) + require.NotNil(t, resp) + require.Equal(t, http.StatusForbidden, resp.StatusCode) + + err = resp.Body.Close() + require.NoError(t, err) +} + +func TestDecorateServiceCreationOperation_NilPrivilegesNotForbidden(t *testing.T) { + t.Parallel() + + f := newServiceCreationFixtures(t) + f.setSecuritySettings(t, restrictiveSettings) + + var spec swarm.ServiceSpec + + resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser)) + require.NotErrorIs(t, err, ErrSecurityOptSettingsForbidden) + require.NotNil(t, resp) + require.NotEqual(t, http.StatusForbidden, resp.StatusCode) + + err = resp.Body.Close() + require.NoError(t, err) +} + +func TestDecorateServiceCreationOperation_PrivilegesWithNilSeccompAndAppArmorNotForbidden(t *testing.T) { + t.Parallel() + + f := newServiceCreationFixtures(t) + f.setSecuritySettings(t, restrictiveSettings) + + spec := swarm.ServiceSpec{ + TaskTemplate: swarm.TaskSpec{ + ContainerSpec: &swarm.ContainerSpec{ + Privileges: &swarm.Privileges{}, + }, + }, + } + + resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser)) + require.NotErrorIs(t, err, ErrSecurityOptSettingsForbidden) + require.NotNil(t, resp) + require.NotEqual(t, http.StatusForbidden, resp.StatusCode) + + err = resp.Body.Close() + require.NoError(t, err) +} + +func TestDecorateServiceCreationOperation_PrivilegesAllowed(t *testing.T) { + t.Parallel() + + f := newServiceCreationFixtures(t) + f.setSecuritySettings(t, permissiveSettings) + + spec := swarm.ServiceSpec{ + TaskTemplate: swarm.TaskSpec{ + ContainerSpec: &swarm.ContainerSpec{ + Privileges: &swarm.Privileges{ + Seccomp: &swarm.SeccompOpts{Mode: swarm.SeccompModeCustom}, + }, + }, + }, + } + + resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser)) + require.NoError(t, err) + require.NotNil(t, resp) + require.NotEqual(t, http.StatusForbidden, resp.StatusCode) + + err = resp.Body.Close() + require.NoError(t, err) +} + +func TestDecorateServiceCreationOperation_BindMountForbidden(t *testing.T) { + t.Parallel() + + f := newServiceCreationFixtures(t) + f.setSecuritySettings(t, restrictiveSettings) + + spec := swarm.ServiceSpec{ + TaskTemplate: swarm.TaskSpec{ + ContainerSpec: &swarm.ContainerSpec{ + Mounts: []mount.Mount{{Type: mount.TypeBind}}, + }, + }, + } + + resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser)) + require.ErrorIs(t, err, ErrBindMountsForbidden) + require.NotNil(t, resp) + require.Equal(t, http.StatusForbidden, resp.StatusCode) + + err = resp.Body.Close() + require.NoError(t, err) +} + +func TestDecorateServiceCreationOperation_NonBindMountNotForbidden(t *testing.T) { + t.Parallel() + + f := newServiceCreationFixtures(t) + + f.setSecuritySettings(t, portainer.EndpointSecuritySettings{ + AllowContainerCapabilitiesForRegularUsers: true, + AllowSysctlSettingForRegularUsers: true, + AllowSecurityOptForRegularUsers: true, + AllowBindMountsForRegularUsers: false, + }) + + spec := swarm.ServiceSpec{ + TaskTemplate: swarm.TaskSpec{ + ContainerSpec: &swarm.ContainerSpec{ + Mounts: []mount.Mount{{Type: mount.TypeVolume}}, + }, + }, + } + + resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser)) + require.NotErrorIs(t, err, ErrBindMountsForbidden) + require.NotNil(t, resp) + require.NotEqual(t, http.StatusForbidden, resp.StatusCode) + + err = resp.Body.Close() + require.NoError(t, err) +} + +func TestDecorateServiceCreationOperation_BindMountAllowed(t *testing.T) { + t.Parallel() + + f := newServiceCreationFixtures(t) + f.setSecuritySettings(t, permissiveSettings) + + spec := swarm.ServiceSpec{ + TaskTemplate: swarm.TaskSpec{ + ContainerSpec: &swarm.ContainerSpec{ + Mounts: []mount.Mount{{Type: mount.TypeBind}}, + }, + }, + } + + resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser)) + require.NoError(t, err) + require.NotNil(t, resp) + require.NotEqual(t, http.StatusForbidden, resp.StatusCode) + + err = resp.Body.Close() + require.NoError(t, err) +} + +func TestDecorateServiceCreationOperation_AdminBypassesAllSecurityChecks(t *testing.T) { + t.Parallel() + + f := newServiceCreationFixtures(t) + f.setSecuritySettings(t, restrictiveSettings) + + spec := swarm.ServiceSpec{ + TaskTemplate: swarm.TaskSpec{ + ContainerSpec: &swarm.ContainerSpec{ + CapabilityAdd: []string{"NET_ADMIN"}, + CapabilityDrop: []string{"MKNOD"}, + Sysctls: map[string]string{"net.ipv4.ip_forward": "1"}, + Privileges: &swarm.Privileges{ + Seccomp: &swarm.SeccompOpts{Mode: swarm.SeccompModeCustom}, + }, + Mounts: []mount.Mount{{Type: mount.TypeBind}}, + }, + }, + } + + resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.adminUser)) + require.NotErrorIs(t, err, ErrContainerCapabilitiesForbidden) + require.NotErrorIs(t, err, ErrSysCtlSettingsForbidden) + require.NotErrorIs(t, err, ErrSecurityOptSettingsForbidden) + require.NotErrorIs(t, err, ErrBindMountsForbidden) + require.NotNil(t, resp) + require.NotEqual(t, http.StatusForbidden, resp.StatusCode) + + err = resp.Body.Close() + require.NoError(t, err) +} + +func TestDecorateServiceCreationOperation_StandardUserPermissiveSettingsSucceeds(t *testing.T) { + t.Parallel() + + f := newServiceCreationFixtures(t) + f.setSecuritySettings(t, permissiveSettings) + + spec := swarm.ServiceSpec{ + TaskTemplate: swarm.TaskSpec{ + ContainerSpec: &swarm.ContainerSpec{ + CapabilityAdd: []string{"NET_ADMIN"}, + Sysctls: map[string]string{"net.core.somaxconn": "128"}, + Mounts: []mount.Mount{{Type: mount.TypeBind}}, + }, + }, + } + + resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser)) + require.NoError(t, err) + require.NotNil(t, resp) + require.Equal(t, http.StatusCreated, resp.StatusCode) + + err = resp.Body.Close() + require.NoError(t, err) +} + +func TestDecorateServiceCreationOperation_VolumeWithBindDriverOptionForbidden(t *testing.T) { + t.Parallel() + + f := newServiceCreationFixtures(t) + f.setSecuritySettings(t, restrictiveSettings) + + spec := swarm.ServiceSpec{ + TaskTemplate: swarm.TaskSpec{ + ContainerSpec: &swarm.ContainerSpec{ + Mounts: []mount.Mount{{ + Type: mount.TypeVolume, + VolumeOptions: &mount.VolumeOptions{ + DriverConfig: &mount.Driver{ + Options: map[string]string{"type": "bind", "device": "/etc"}, + }, + }, + }}, + }, + }, + } + + resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser)) + require.ErrorIs(t, err, ErrBindMountsForbidden) + require.NotNil(t, resp) + require.Equal(t, http.StatusForbidden, resp.StatusCode) + + err = resp.Body.Close() + require.NoError(t, err) +} + +func TestDecorateServiceCreationOperation_VolumeWithBindDriverOptionAllowed(t *testing.T) { + t.Parallel() + + f := newServiceCreationFixtures(t) + f.setSecuritySettings(t, permissiveSettings) + + spec := swarm.ServiceSpec{ + TaskTemplate: swarm.TaskSpec{ + ContainerSpec: &swarm.ContainerSpec{ + Mounts: []mount.Mount{{ + Type: mount.TypeVolume, + VolumeOptions: &mount.VolumeOptions{ + DriverConfig: &mount.Driver{ + Options: map[string]string{"type": "bind", "device": "/etc"}, + }, + }, + }}, + }, + }, + } + + resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser)) + require.NoError(t, err) + require.NotNil(t, resp) + require.NotEqual(t, http.StatusForbidden, resp.StatusCode) + + err = resp.Body.Close() + require.NoError(t, err) +} + +func TestDecorateServiceCreationOperation_VolumeWithNonBindDriverOptionNotForbidden(t *testing.T) { + t.Parallel() + + f := newServiceCreationFixtures(t) + f.setSecuritySettings(t, restrictiveSettings) + + spec := swarm.ServiceSpec{ + TaskTemplate: swarm.TaskSpec{ + ContainerSpec: &swarm.ContainerSpec{ + Mounts: []mount.Mount{{ + Type: mount.TypeVolume, + VolumeOptions: &mount.VolumeOptions{ + DriverConfig: &mount.Driver{ + Options: map[string]string{"type": "tmpfs"}, + }, + }, + }}, + }, + }, + } + + resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser)) + require.NotErrorIs(t, err, ErrBindMountsForbidden) + require.NotNil(t, resp) + require.NotEqual(t, http.StatusForbidden, resp.StatusCode) + + err = resp.Body.Close() + require.NoError(t, err) +} + +func TestDecorateServiceUpdateOperation_VolumeWithBindDriverOptionForbidden(t *testing.T) { + t.Parallel() + + f := newServiceCreationFixtures(t) + f.setSecuritySettings(t, restrictiveSettings) + + spec := swarm.ServiceSpec{ + TaskTemplate: swarm.TaskSpec{ + ContainerSpec: &swarm.ContainerSpec{ + Mounts: []mount.Mount{{ + Type: mount.TypeVolume, + VolumeOptions: &mount.VolumeOptions{ + DriverConfig: &mount.Driver{ + Options: map[string]string{"type": "bind", "device": "/etc"}, + }, + }, + }}, + }, + }, + } + + resp, err := f.newTransport().decorateServiceUpdateOperation(f.newRequest(t, spec, f.stdUser), "test-service-id") + require.ErrorIs(t, err, ErrBindMountsForbidden) + require.NotNil(t, resp) + require.Equal(t, http.StatusForbidden, resp.StatusCode) + + err = resp.Body.Close() + require.NoError(t, err) +} diff --git a/api/http/proxy/factory/docker/swarm.go b/api/http/proxy/factory/docker/swarm.go new file mode 100644 index 0000000..be39a4b --- /dev/null +++ b/api/http/proxy/factory/docker/swarm.go @@ -0,0 +1,25 @@ +package docker + +import ( + "net/http" + + "github.com/portainer/portainer/api/http/proxy/factory/utils" +) + +// swarmInspectOperation extracts the response as a JSON object and rewrites the response based +// on the current user role. Sensitive fields are deleted from the response for non-administrator users. +func swarmInspectOperation(response *http.Response, executor *operationExecutor) error { + // SwarmInspect response is a JSON object + // https://docs.docker.com/engine/api/v1.30/#operation/SwarmInspect + responseObject, err := utils.GetResponseAsJSONObject(response) + if err != nil { + return err + } + + if !executor.operationContext.isAdmin { + delete(responseObject, "JoinTokens") + delete(responseObject, "TLSInfo") + } + + return utils.RewriteResponse(response, responseObject, http.StatusOK) +} diff --git a/api/http/proxy/factory/docker/tasks.go b/api/http/proxy/factory/docker/tasks.go new file mode 100644 index 0000000..d52dfe9 --- /dev/null +++ b/api/http/proxy/factory/docker/tasks.go @@ -0,0 +1,49 @@ +package docker + +import ( + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/proxy/factory/utils" +) + +const taskServiceObjectIdentifier = "ServiceID" + +// taskListOperation extracts the response as a JSON array, loop through the tasks array +// and filter the containers based on resource controls before rewriting the response. +func (transport *Transport) taskListOperation(response *http.Response, executor *operationExecutor) error { + // TaskList response is a JSON array + // https://docs.docker.com/engine/api/v1.28/#operation/TaskList + responseArray, err := utils.GetResponseAsJSONArray(response) + if err != nil { + return err + } + + resourceOperationParameters := &resourceOperationParameters{ + resourceIdentifierAttribute: taskServiceObjectIdentifier, + resourceType: portainer.ServiceResourceControl, + labelsObjectSelector: selectorTaskLabels, + } + + responseArray, err = transport.applyAccessControlOnResourceList(resourceOperationParameters, responseArray, executor) + if err != nil { + return err + } + + return utils.RewriteResponse(response, responseArray, http.StatusOK) +} + +// selectorServiceLabels retrieve the labels object associated to the task object. +// Labels are available under the "Spec.ContainerSpec.Labels" property. +// API schema reference: https://docs.docker.com/engine/api/v1.28/#operation/TaskList +func selectorTaskLabels(responseObject map[string]any) map[string]any { + taskSpecObject := utils.GetJSONObject(responseObject, "Spec") + if taskSpecObject != nil { + containerSpecObject := utils.GetJSONObject(taskSpecObject, "ContainerSpec") + if containerSpecObject != nil { + return utils.GetJSONObject(containerSpecObject, "Labels") + } + } + + return nil +} diff --git a/api/http/proxy/factory/docker/transport.go b/api/http/proxy/factory/docker/transport.go new file mode 100644 index 0000000..bef0ac7 --- /dev/null +++ b/api/http/proxy/factory/docker/transport.go @@ -0,0 +1,979 @@ +package docker + +import ( + "bytes" + "context" + "encoding/base64" + "errors" + "fmt" + "io" + "net/http" + "path" + "regexp" + "strconv" + "strings" + "sync" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/docker/client" + "github.com/portainer/portainer/api/http/proxy/factory/utils" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/authorization" + "github.com/portainer/portainer/api/logs" + "github.com/portainer/portainer/api/slicesx" + "github.com/portainer/portainer/pkg/libhttp/ssrf" + + "github.com/docker/docker/api/types/network" + "github.com/docker/docker/api/types/swarm" + dockerclient "github.com/docker/docker/client" + "github.com/rs/zerolog/log" + "github.com/segmentio/encoding/json" +) + +var apiVersionRe = regexp.MustCompile(`(/v[0-9]\.[0-9]*)?`) + +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 + dataStore dataservices.DataStore + signatureService portainer.DigitalSignatureService + reverseTunnelService portainer.ReverseTunnelService + dockerClientFactory *client.ClientFactory + gitService portainer.GitService + snapshotService portainer.SnapshotService + dockerID string + mu sync.Mutex + } + + // TransportParameters is used to create a new Transport + TransportParameters struct { + Endpoint *portainer.Endpoint + DataStore dataservices.DataStore + SignatureService portainer.DigitalSignatureService + ReverseTunnelService portainer.ReverseTunnelService + DockerClientFactory *client.ClientFactory + } + + restrictedDockerOperationContext struct { + isAdmin bool + userID portainer.UserID + userTeamIDs []portainer.TeamID + resourceControls []portainer.ResourceControl + } + + operationExecutor struct { + operationContext *restrictedDockerOperationContext + labelBlackList []portainer.Pair + } + + restrictedOperationRequest func(*http.Response, *operationExecutor) error + operationRequest func(*http.Request) error +) + +// NewTransport returns a pointer to a new Transport instance. +func NewTransport(parameters *TransportParameters, httpTransport *http.Transport, gitService portainer.GitService, snapshotService portainer.SnapshotService) (*Transport, error) { + transport := &Transport{ + endpoint: parameters.Endpoint, + dataStore: parameters.DataStore, + signatureService: parameters.SignatureService, + reverseTunnelService: parameters.ReverseTunnelService, + dockerClientFactory: parameters.DockerClientFactory, + HTTPTransport: httpTransport, + gitService: gitService, + snapshotService: snapshotService, + } + + return transport, nil +} + +// RoundTrip is the implementation of the http.RoundTripper interface +func (transport *Transport) RoundTrip(request *http.Request) (*http.Response, error) { + return transport.ProxyDockerRequest(request) +} + +var prefixProxyFuncMap = map[string]func(*Transport, *http.Request, string) (*http.Response, error){ + "build": (*Transport).proxyBuildRequest, + "configs": (*Transport).proxyConfigRequest, + "containers": (*Transport).proxyContainerRequest, + "exec": (*Transport).proxyExecRequest, + "images": (*Transport).proxyImageRequest, + "networks": (*Transport).proxyNetworkRequest, + "nodes": (*Transport).proxyNodeRequest, + "secrets": (*Transport).proxySecretRequest, + "services": (*Transport).proxyServiceRequest, + "swarm": (*Transport).proxySwarmRequest, + "tasks": (*Transport).proxyTaskRequest, + "v2": (*Transport).proxyAgentRequest, + "volumes": (*Transport).proxyVolumeRequest, +} + +type route struct { + method string + pattern *regexp.Regexp +} + +var adminOnlyRoutes = []route{ + {http.MethodPost, regexp.MustCompile(`^/plugins/.+/enable$`)}, + {http.MethodPost, regexp.MustCompile(`^/plugins/.+/disable$`)}, + {http.MethodPost, regexp.MustCompile(`^/plugins/pull$`)}, + {http.MethodPost, regexp.MustCompile(`^/plugins/.+/push$`)}, + {http.MethodPost, regexp.MustCompile(`^/plugins/.+/upgrade$`)}, + {http.MethodPost, regexp.MustCompile(`^/plugins/.+/set$`)}, + {http.MethodPost, regexp.MustCompile(`^/plugins/create$`)}, + {http.MethodDelete, regexp.MustCompile(`^/plugins/.+$`)}, +} + +func isAdminOnlyRoute(method string, path string) bool { + return slicesx.Some(adminOnlyRoutes, func(r route) bool { + return method == r.method && r.pattern.MatchString(path) + }) +} + +// ProxyDockerRequest intercepts a Docker API request and apply logic based +// on the requested operation. +func (transport *Transport) ProxyDockerRequest(request *http.Request) (*http.Response, error) { + // from : /v1.47/containers/{id}/json + // or : /containers/{id}/json + // to : /containers/{id}/json + unversionedPath := apiVersionRe.ReplaceAllString(request.URL.Path, "") + + if transport.endpoint.Type == portainer.AgentOnDockerEnvironment || transport.endpoint.Type == portainer.EdgeAgentOnDockerEnvironment { + 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) + } + + // from : /containers/{id}/json + // trim to : containers/{id}/json + // pick : [ containers, {id}, json ][0] + // prefix : containers + prefix := strings.Split(strings.TrimPrefix(unversionedPath, "/"), "/")[0] + + if proxyFunc := prefixProxyFuncMap[prefix]; proxyFunc != nil { + return proxyFunc(transport, request, unversionedPath) + } + + if isAdminOnlyRoute(request.Method, unversionedPath) { + return transport.administratorOperation(request) + } + + return transport.executeDockerRequest(request) +} + +func (transport *Transport) executeDockerRequest(request *http.Request) (*http.Response, error) { + response, err := transport.HTTPTransport.RoundTrip(request) + + if transport.endpoint.Type != portainer.EdgeAgentOnDockerEnvironment { + return response, err + } + + if err == nil { + transport.reverseTunnelService.UpdateLastActivity(transport.endpoint.ID) + } + + return response, err +} + +func (transport *Transport) proxyAgentRequest(r *http.Request, unversionedPath string) (*http.Response, error) { + requestPath := strings.TrimPrefix(unversionedPath, "/v2") + + switch { + case strings.HasPrefix(requestPath, "/browse"): + // Host file browser request + volumeIDParameter, found := r.URL.Query()["volumeID"] + if !found || len(volumeIDParameter) < 1 { + return transport.administratorOperation(r) + } + + volumeName := volumeIDParameter[0] + + resourceID, err := transport.getVolumeResourceID(volumeName) + if err != nil { + return nil, err + } + + // Volume browser request + return transport.restrictedResourceOperation(r, resourceID, volumeName, portainer.VolumeResourceControl, true) + case strings.HasPrefix(requestPath, "/dockerhub"): + requestPath, registryIdString := path.Split(r.URL.Path) + + registryID, err := strconv.Atoi(registryIdString) + if err != nil { + return nil, fmt.Errorf("missing registry id: %w", err) + } + + r.URL.Path = strings.TrimSuffix(requestPath, "/") + + registry := &portainer.Registry{ + Type: portainer.DockerHubRegistry, + } + + if registryID != 0 { + registry, err = transport.dataStore.Registry().Read(portainer.RegistryID(registryID)) + if err != nil { + return nil, fmt.Errorf("failed fetching registry: %w", err) + } + } + + if registry.Type != portainer.DockerHubRegistry { + return nil, errors.New("Invalid registry type") + } + + newBody, err := json.Marshal(registry) + if err != nil { + return nil, err + } + + r.Method = http.MethodPost + + r.Body = io.NopCloser(bytes.NewReader(newBody)) + r.ContentLength = int64(len(newBody)) + } + + return transport.executeDockerRequest(r) +} + +func (transport *Transport) proxyConfigRequest(request *http.Request, unversionedPath string) (*http.Response, error) { + requestPath := unversionedPath + + switch requestPath { + case "/configs/create": + return transport.decorateGenericResourceCreationOperation(request, configObjectIdentifier, portainer.ConfigResourceControl) + + case "/configs": + return transport.rewriteOperation(request, transport.configListOperation) + + default: + // Assume /configs/{id} + configID := path.Base(requestPath) + + switch request.Method { + case http.MethodGet: + return transport.rewriteOperation(request, transport.configInspectOperation) + case http.MethodDelete: + return transport.executeGenericResourceDeletionOperation(request, configID, configID, portainer.ConfigResourceControl) + } + + return transport.restrictedResourceOperation(request, configID, configID, portainer.ConfigResourceControl, false) + } +} + +func (transport *Transport) proxyContainerRequest(request *http.Request, unversionedPath string) (*http.Response, error) { + requestPath := unversionedPath + + switch requestPath { + case "/containers/create": + return transport.decorateContainerCreationOperation(request, containerObjectIdentifier, portainer.ContainerResourceControl) + + case "/containers/prune": + return transport.administratorOperation(request) + + case "/containers/json": + return transport.rewriteOperationWithLabelFiltering(request, transport.containerListOperation) + + default: + // This section assumes /containers/** + if match, _ := path.Match("/containers/*/*", requestPath); match { + // Handle /containers/{id}/{action} requests + containerID := path.Base(path.Dir(requestPath)) + action := path.Base(requestPath) + + if action == "json" { + return transport.rewriteOperation(request, transport.containerInspectOperation) + } + + if action == "update" { + return transport.decorateContainerUpdateOperation(request, containerID) + } + + return transport.restrictedResourceOperation(request, containerID, containerID, portainer.ContainerResourceControl, false) + } else if match, _ := path.Match("/containers/*", requestPath); match { + // Handle /containers/{id} requests + containerID := path.Base(requestPath) + + if request.Method == http.MethodDelete { + return transport.executeGenericResourceDeletionOperation(request, containerID, containerID, portainer.ContainerResourceControl) + } + + return transport.restrictedResourceOperation(request, containerID, containerID, portainer.ContainerResourceControl, false) + } else if match, _ := path.Match("/containers/*/attach/ws", requestPath); match { + containerID := path.Base(path.Dir(path.Dir(requestPath))) + + return transport.restrictedResourceOperation(request, containerID, containerID, portainer.ContainerResourceControl, false) + } + + return transport.executeDockerRequest(request) + } +} + +func (transport *Transport) proxyExecRequest(request *http.Request, unversionedPath string) (*http.Response, error) { + execID := path.Base(path.Dir(unversionedPath)) + + client, err := transport.dockerClientFactory.CreateClient(transport.endpoint, request.Header.Get(portainer.PortainerAgentTargetHeader), nil) + if err != nil { + return nil, err + } + defer logs.CloseAndLogErr(client) + + execInspect, err := client.ContainerExecInspect(request.Context(), execID) + if err != nil { + return nil, err + } + + return transport.restrictedResourceOperation(request, execInspect.ContainerID, execInspect.ContainerID, portainer.ContainerResourceControl, false) +} + +func (transport *Transport) proxyServiceRequest(request *http.Request, unversionedPath string) (*http.Response, error) { + requestPath := unversionedPath + + switch requestPath { + case "/services/create": + return transport.decorateServiceCreationOperation(request) + + case "/services": + return transport.rewriteOperation(request, transport.serviceListOperation) + + default: + // This section assumes /services/** + if match, _ := path.Match("/services/*/*", requestPath); match { + // Handle /services/{id}/{action} requests + serviceID := path.Base(path.Dir(requestPath)) + action := path.Base(requestPath) + + if action == "update" { + return transport.decorateServiceUpdateOperation(request, serviceID) + } + + if err := transport.decorateRegistryAuthenticationHeader(request); err != nil { + return nil, err + } + + return transport.restrictedResourceOperation(request, serviceID, serviceID, portainer.ServiceResourceControl, false) + } else if match, _ := path.Match("/services/*", requestPath); match { + // Handle /services/{id} requests + serviceID := path.Base(requestPath) + + switch request.Method { + case http.MethodGet: + return transport.rewriteOperation(request, transport.serviceInspectOperation) + case http.MethodDelete: + return transport.executeGenericResourceDeletionOperation(request, serviceID, serviceID, portainer.ServiceResourceControl) + } + + return transport.restrictedResourceOperation(request, serviceID, serviceID, portainer.ServiceResourceControl, false) + } + + return transport.executeDockerRequest(request) + } +} + +func (transport *Transport) proxyVolumeRequest(request *http.Request, unversionedPath string) (*http.Response, error) { + requestPath := unversionedPath + + switch requestPath { + case "/volumes/create": + return transport.decorateVolumeResourceCreationOperation(request, portainer.VolumeResourceControl) + + case "/volumes/prune": + return transport.administratorOperation(request) + + case "/volumes": + return transport.rewriteOperation(request, transport.volumeListOperation) + + default: + // Assume /volumes/{name} + return transport.restrictedVolumeOperation(requestPath, request) + } +} + +func match(requestPath string, pattern string) bool { + ok, err := path.Match(pattern, requestPath) + return err == nil && ok +} + +func (transport *Transport) proxyNetworkRequest(request *http.Request, unversionedPath string) (*http.Response, error) { + requestPath := unversionedPath + + switch { + case requestPath == "/networks/create": + return transport.decorateGenericResourceCreationOperation(request, networkObjectIdentifier, portainer.NetworkResourceControl) + + case requestPath == "/networks": + return transport.rewriteOperation(request, transport.networkListOperation) + + case request.Method == http.MethodPost && match(requestPath, "/networks/*/connect"), + request.Method == http.MethodPost && match(requestPath, "/networks/*/disconnect"): + + networkID := path.Base(path.Dir(requestPath)) + return transport.restrictedResourceOperation(request, networkID, networkID, portainer.NetworkResourceControl, false) + + case request.Method == http.MethodGet && match(requestPath, "/networks/*"): + return transport.rewriteOperation(request, transport.networkInspectOperation) + + case request.Method == http.MethodDelete && match(requestPath, "/networks/*"): + networkID := path.Base(requestPath) + return transport.executeGenericResourceDeletionOperation(request, networkID, networkID, portainer.NetworkResourceControl) + } + + // Assume /networks/{id} + networkID := path.Base(requestPath) + return transport.restrictedResourceOperation(request, networkID, networkID, portainer.NetworkResourceControl, false) +} + +func (transport *Transport) proxySecretRequest(request *http.Request, unversionedPath string) (*http.Response, error) { + requestPath := unversionedPath + + switch requestPath { + case "/secrets/create": + return transport.decorateGenericResourceCreationOperation(request, secretObjectIdentifier, portainer.SecretResourceControl) + + case "/secrets": + return transport.rewriteOperation(request, transport.secretListOperation) + + default: + // Assume /secrets/{id} + secretID := path.Base(requestPath) + + switch request.Method { + case http.MethodGet: + return transport.rewriteOperation(request, transport.secretInspectOperation) + case http.MethodDelete: + return transport.executeGenericResourceDeletionOperation(request, secretID, secretID, portainer.SecretResourceControl) + } + + return transport.restrictedResourceOperation(request, secretID, secretID, portainer.SecretResourceControl, false) + } +} + +func (transport *Transport) proxyNodeRequest(request *http.Request, unversionedPath string) (*http.Response, error) { + requestPath := unversionedPath + + // Assume /nodes/{id} + if path.Base(requestPath) != "nodes" { + return transport.administratorOperation(request) + } + + return transport.executeDockerRequest(request) +} + +func (transport *Transport) proxySwarmRequest(request *http.Request, unversionedPath string) (*http.Response, error) { + requestPath := unversionedPath + + switch requestPath { + case "/swarm": + return transport.rewriteOperation(request, swarmInspectOperation) + default: + // Assume /swarm/{action} + return transport.administratorOperation(request) + } +} + +func (transport *Transport) proxyTaskRequest(request *http.Request, unversionedPath string) (*http.Response, error) { + requestPath := unversionedPath + + switch requestPath { + case "/tasks": + return transport.rewriteOperation(request, transport.taskListOperation) + default: + // Assume /tasks/{id} + return transport.executeDockerRequest(request) + } +} + +func (transport *Transport) proxyBuildRequest(request *http.Request, unversionedPath string) (*http.Response, error) { + if unversionedPath == "/build/prune" { + return transport.administratorOperation(request) + } + + if err := transport.updateDefaultGitBranch(request); err != nil { + return nil, err + } + + return transport.interceptAndRewriteRequest(request, buildOperation) +} + +func (transport *Transport) updateDefaultGitBranch(request *http.Request) error { + remote := request.URL.Query().Get("remote") + if !strings.HasSuffix(remote, ".git") { + return nil + } + + repositoryURL := remote[:len(remote)-4] + + if err := ssrf.CheckURL(request.Context(), repositoryURL); err != nil { + return err + } + + latestCommitID, err := transport.gitService.LatestCommitID( + request.Context(), + repositoryURL, + "", + "", + "", + false, + ) + if err != nil { + return err + } + + newRemote := fmt.Sprintf("%s#%s", remote, latestCommitID) + + q := request.URL.Query() + q.Set("remote", newRemote) + request.URL.RawQuery = q.Encode() + + return nil +} + +func (transport *Transport) proxyImageRequest(request *http.Request, unversionedPath string) (*http.Response, error) { + requestPath := unversionedPath + + switch requestPath { + case "/images/create": + return transport.replaceRegistryAuthenticationHeader(request) + case "/images/prune": + return transport.administratorOperation(request) + default: + if path.Base(requestPath) == "push" && request.Method == http.MethodPost { + return transport.replaceRegistryAuthenticationHeader(request) + } + + return transport.executeDockerRequest(request) + } +} + +func (transport *Transport) replaceRegistryAuthenticationHeader(request *http.Request) (*http.Response, error) { + if err := transport.decorateRegistryAuthenticationHeader(request); err != nil { + return nil, err + } + + return transport.decorateGenericResourceCreationOperation(request, serviceObjectIdentifier, portainer.ServiceResourceControl) +} + +func (transport *Transport) decorateRegistryAuthenticationHeader(request *http.Request) error { + accessContext, err := transport.createRegistryAccessContext(request) + if err != nil { + return err + } + + originalHeader := request.Header.Get("X-Registry-Auth") + + if originalHeader == "" { + return nil + } + + decodedHeaderData, err := base64.StdEncoding.DecodeString(originalHeader) + if err != nil { + return err + } + + var originalHeaderData portainerRegistryAuthenticationHeader + if err := json.Unmarshal(decodedHeaderData, &originalHeaderData); err != nil { + return err + } + + // Delete header and exist function without error if Front End + // passes empty json. This is to restore original behavior which + // never originally passed this header + if string(decodedHeaderData) == "{}" { + request.Header.Del("X-Registry-Auth") + + return nil + } + + // Only set X-Registry-Auth if registryId is defined + if originalHeaderData.RegistryId == nil { + return nil + } + + authenticationHeader, err := createRegistryAuthenticationHeader(transport.dataStore, *originalHeaderData.RegistryId, accessContext) + if err != nil { + return err + } + + headerData, err := json.Marshal(authenticationHeader) + if err != nil { + return err + } + + request.Header.Set("X-Registry-Auth", base64.URLEncoding.EncodeToString(headerData)) + + return nil +} + +func (transport *Transport) restrictedResourceOperation(request *http.Request, resourceID string, dockerResourceID string, resourceType portainer.ResourceControlType, volumeBrowseRestrictionCheck bool) (*http.Response, error) { + tokenData, err := security.RetrieveTokenData(request) + if err != nil { + return nil, err + } + + if tokenData.Role == portainer.AdministratorRole { + return transport.executeDockerRequest(request) + } + + if volumeBrowseRestrictionCheck { + securitySettings, err := transport.fetchEndpointSecuritySettings() + if err != nil { + return nil, err + } + + if !securitySettings.AllowVolumeBrowserForRegularUsers { + return utils.WriteAccessDeniedResponse() + } + } + + teamMemberships, err := transport.dataStore.TeamMembership().TeamMembershipsByUserID(tokenData.ID) + if err != nil { + return nil, err + } + + userTeamIDs := authorization.TeamIDs(teamMemberships) + + resourceControls, err := transport.dataStore.ResourceControl().ReadAll() + if err != nil { + return nil, err + } + + resourceControl := authorization.GetResourceControlByResourceIDAndType(resourceID, resourceType, resourceControls) + if resourceControl != nil { + if !authorization.UserCanAccessResource(tokenData.ID, userTeamIDs, resourceControl) { + return utils.WriteAccessDeniedResponse() + } + return transport.executeDockerRequest(request) + } + + client, err := transport.dockerClientFactory.CreateClient(transport.endpoint, request.Header.Get(portainer.PortainerAgentTargetHeader), nil) + if err != nil { + return nil, err + } + defer logs.CloseAndLogErr(client) + + // the resourceID may be the resource name (as it's a valid proxy call to use the name and not the UUID) + // so get the real resource ID and retry with it + resourceID, err = getDockerResourceUUID(client, resourceType, resourceID) + if err != nil { + return nil, err + } + + resourceControl = authorization.GetResourceControlByResourceIDAndType(resourceID, resourceType, resourceControls) + if resourceControl != nil { + if !authorization.UserCanAccessResource(tokenData.ID, userTeamIDs, resourceControl) { + return utils.WriteAccessDeniedResponse() + } + return transport.executeDockerRequest(request) + } + + // If we still can't find the RC by provided ID or "real" (docker-extracted) ID + // it means this resource was created outside of portainer, + // is part of a Docker service or part of a Docker Swarm/Compose stack. + if dockerResourceID == "" { + dockerResourceID = resourceID + } + inheritedResourceControl, err := transport.getInheritedResourceControlFromServiceOrStack(client, dockerResourceID, resourceType, resourceControls) + if err != nil { + return nil, err + } + + if inheritedResourceControl == nil || !authorization.UserCanAccessResource(tokenData.ID, userTeamIDs, inheritedResourceControl) { + return utils.WriteAccessDeniedResponse() + } + + return transport.executeDockerRequest(request) +} + +func getDockerResourceUUID(client *dockerclient.Client, resourceType portainer.ResourceControlType, resourceId string) (string, error) { + switch resourceType { + case portainer.NetworkResourceControl: + network, err := client.NetworkInspect(context.Background(), resourceId, network.InspectOptions{}) + if err != nil { + return "", err + } + return network.ID, nil + + case portainer.ContainerResourceControl: + container, err := client.ContainerInspect(context.Background(), resourceId) + if err != nil { + return "", err + } + return container.ID, nil + + case portainer.VolumeResourceControl: + // volumes don't have an UUID and their UACresourceID has a particular construct that makes them unique + // e.g. fmt.Sprintf("%s_%s", volumeName, dockerID) + // see transport.getVolumeResourceID() / FetchDockerID() + // FetchDockerID fetches info.Swarm.Cluster.ID if environment(endpoint) is swarm and info.ID otherwise + // So: return empty ID but without error + return "", nil + + case portainer.ServiceResourceControl: + service, _, err := client.ServiceInspectWithRaw(context.Background(), resourceId, swarm.ServiceInspectOptions{}) + if err != nil { + return "", err + } + return service.ID, nil + + case portainer.ConfigResourceControl: + config, _, err := client.ConfigInspectWithRaw(context.Background(), resourceId) + if err != nil { + return "", err + } + return config.ID, nil + + case portainer.SecretResourceControl: + secret, _, err := client.SecretInspectWithRaw(context.Background(), resourceId) + if err != nil { + return "", err + } + return secret.ID, nil + + } + return "", fmt.Errorf("Unknown resource type %v", resourceType) +} + +// rewriteOperationWithLabelFiltering will create a new operation context with data that will be used +// to decorate the original request's response as well as retrieve all the black listed labels +// to filter the resources. +func (transport *Transport) rewriteOperationWithLabelFiltering(request *http.Request, operation restrictedOperationRequest) (*http.Response, error) { + operationContext, err := transport.createOperationContext(request) + if err != nil { + return nil, err + } + + settings, err := transport.dataStore.Settings().Settings() + if err != nil { + return nil, err + } + + executor := &operationExecutor{ + operationContext: operationContext, + labelBlackList: settings.BlackListedLabels, + } + + return transport.executeRequestAndRewriteResponse(request, operation, executor) +} + +// rewriteOperation will create a new operation context with data that will be used +// to decorate the original request's response. +func (transport *Transport) rewriteOperation(request *http.Request, operation restrictedOperationRequest) (*http.Response, error) { + operationContext, err := transport.createOperationContext(request) + if err != nil { + return nil, err + } + + executor := &operationExecutor{ + operationContext: operationContext, + } + + return transport.executeRequestAndRewriteResponse(request, operation, executor) +} + +func (transport *Transport) interceptAndRewriteRequest(request *http.Request, operation operationRequest) (*http.Response, error) { + if err := operation(request); err != nil { + return nil, err + } + + return transport.executeDockerRequest(request) +} + +// decorateGenericResourceCreationResponse extracts the response as a JSON object, extracts the resource identifier from that object based +// on the resourceIdentifierAttribute parameter then generate a new resource control associated to that resource +// with a random token and rewrites the response by decorating the original response with a ResourceControl object. +// The generic Docker API response format is JSON object: +// https://docs.docker.com/engine/api/v1.37/#operation/ContainerCreate +// https://docs.docker.com/engine/api/v1.37/#operation/NetworkCreate +// https://docs.docker.com/engine/api/v1.37/#operation/VolumeCreate +// https://docs.docker.com/engine/api/v1.37/#operation/ServiceCreate +// https://docs.docker.com/engine/api/v1.37/#operation/SecretCreate +// https://docs.docker.com/engine/api/v1.37/#operation/ConfigCreate +func (transport *Transport) decorateGenericResourceCreationResponse(response *http.Response, resourceIdentifierAttribute string, resourceType portainer.ResourceControlType, userID portainer.UserID) error { + responseObject, err := utils.GetResponseAsJSONObject(response) + if err != nil { + return err + } + + if responseObject[resourceIdentifierAttribute] == nil { + log.Error().Msg("missing identifier in Docker resource creation response") + + return errors.New("missing identifier in Docker resource creation response") + } + + resourceID := responseObject[resourceIdentifierAttribute].(string) + + resourceControl, err := transport.createPrivateResourceControl(resourceID, resourceType, userID) + if err != nil { + return err + } + + responseObject = decorateObject(responseObject, resourceControl) + + return utils.RewriteResponse(response, responseObject, response.StatusCode) +} + +func (transport *Transport) decorateGenericResourceCreationOperation(request *http.Request, resourceIdentifierAttribute string, resourceType portainer.ResourceControlType) (*http.Response, error) { + tokenData, err := security.RetrieveTokenData(request) + if err != nil { + return nil, err + } + + 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 +} + +func (transport *Transport) executeGenericResourceDeletionOperation(request *http.Request, resourceIdentifierAttribute string, volumeName string, resourceType portainer.ResourceControlType) (*http.Response, error) { + response, err := transport.restrictedResourceOperation(request, resourceIdentifierAttribute, volumeName, resourceType, false) + if err != nil { + return response, err + } + + if response.StatusCode != http.StatusNoContent && response.StatusCode != http.StatusOK { + return response, nil + } + + resourceControl, err := transport.dataStore.ResourceControl().ResourceControlByResourceIDAndType(resourceIdentifierAttribute, resourceType) + if dataservices.IsErrObjectNotFound(err) { + return response, nil + } else if err != nil { + return response, err + } + + if resourceControl != nil { + err = transport.dataStore.ResourceControl().Delete(resourceControl.ID) + } + + return response, err +} + +func (transport *Transport) executeRequestAndRewriteResponse(request *http.Request, operation restrictedOperationRequest, executor *operationExecutor) (*http.Response, error) { + response, err := transport.executeDockerRequest(request) + if err != nil { + return response, err + } + + if response.StatusCode == http.StatusOK { + err = operation(response, executor) + } + + return response, err +} + +// administratorOperation ensures that the user has administrator privileges +// before executing the original request. +func (transport *Transport) administratorOperation(request *http.Request) (*http.Response, error) { + tokenData, err := security.RetrieveTokenData(request) + if err != nil { + return nil, err + } + + if tokenData.Role != portainer.AdministratorRole { + return utils.WriteAccessDeniedResponse() + } + + return transport.executeDockerRequest(request) +} + +func (transport *Transport) createRegistryAccessContext(request *http.Request) (*registryAccessContext, error) { + tokenData, err := security.RetrieveTokenData(request) + if err != nil { + return nil, err + } + + accessContext := ®istryAccessContext{ + isAdmin: true, + endpointID: transport.endpoint.ID, + } + + user, err := transport.dataStore.User().Read(tokenData.ID) + if err != nil { + return nil, err + } + accessContext.user = user + + registries, err := transport.dataStore.Registry().ReadAll() + if err != nil { + return nil, err + } + accessContext.registries = registries + + if user.Role == portainer.AdministratorRole { + return accessContext, nil + } + + accessContext.isAdmin = false + + teamMemberships, err := transport.dataStore.TeamMembership().TeamMembershipsByUserID(tokenData.ID) + if err != nil { + return nil, err + } + + accessContext.teamMemberships = teamMemberships + + return accessContext, nil +} + +func (transport *Transport) createOperationContext(request *http.Request) (*restrictedDockerOperationContext, error) { + var err error + tokenData, err := security.RetrieveTokenData(request) + if err != nil { + return nil, err + } + + resourceControls, err := transport.dataStore.ResourceControl().ReadAll() + if err != nil { + return nil, err + } + + operationContext := &restrictedDockerOperationContext{ + isAdmin: true, + userID: tokenData.ID, + resourceControls: resourceControls, + } + + if tokenData.Role == portainer.AdministratorRole { + return operationContext, nil + } + + operationContext.isAdmin = false + + teamMemberships, err := transport.dataStore.TeamMembership().TeamMembershipsByUserID(tokenData.ID) + if err != nil { + return nil, err + } + + operationContext.userTeamIDs = authorization.TeamIDs(teamMemberships) + + 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 +} + +func (transport *Transport) fetchEndpointSecuritySettings() (*portainer.EndpointSecuritySettings, error) { + endpoint, err := transport.dataStore.Endpoint().Endpoint(transport.endpoint.ID) + if err != nil { + return nil, err + } + + return &endpoint.SecuritySettings, nil +} diff --git a/api/http/proxy/factory/docker/transport_test.go b/api/http/proxy/factory/docker/transport_test.go new file mode 100644 index 0000000..9475a00 --- /dev/null +++ b/api/http/proxy/factory/docker/transport_test.go @@ -0,0 +1,910 @@ +package docker + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/network" + "github.com/docker/docker/api/types/swarm" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/authorization" + "github.com/portainer/portainer/api/internal/testhelpers" + "github.com/portainer/portainer/pkg/libhttp/response" + "github.com/segmentio/encoding/json" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTransport_updateDefaultGitBranch(t *testing.T) { + t.Parallel() + type fields struct { + gitService portainer.GitService + } + + type args struct { + request *http.Request + } + + commitId := "my-latest-commit-id" + defaultFields := fields{ + gitService: testhelpers.NewGitService(nil, commitId), + } + + tests := []struct { + name string + fields fields + args args + wantErr bool + expectedQuery string + }{ + { + name: "append commit ID", + fields: defaultFields, + args: args{ + request: httptest.NewRequest(http.MethodPost, "http://unixsocket/build?dockerfile=Dockerfile&remote=https://my-host.com/my-user/my-repo.git&t=my-image", nil), + }, + wantErr: false, + expectedQuery: fmt.Sprintf("dockerfile=Dockerfile&remote=https%%3A%%2F%%2Fmy-host.com%%2Fmy-user%%2Fmy-repo.git%%23%s&t=my-image", commitId), + }, + { + name: "not append commit ID", + fields: defaultFields, + args: args{ + request: httptest.NewRequest(http.MethodPost, "http://unixsocket/build?dockerfile=Dockerfile&remote=https://my-host.com/my-user/my-repo/my-file&t=my-image", nil), + }, + wantErr: false, + expectedQuery: "dockerfile=Dockerfile&remote=https://my-host.com/my-user/my-repo/my-file&t=my-image", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + transport := &Transport{ + gitService: tt.fields.gitService, + } + err := transport.updateDefaultGitBranch(tt.args.request) + if (err != nil) != tt.wantErr { + t.Errorf("updateDefaultGitBranch() error = %v, wantErr %v", err, tt.wantErr) + return + } + + assert.Equal(t, tt.expectedQuery, tt.args.request.URL.RawQuery) + }) + } +} + +type RoutesDefinition map[[2]string]any + +func mockDockerAPIServer(t *testing.T, routes RoutesDefinition) (*httptest.Server, string) { + version := "1.51" + + v := func(path string) string { + return "/v" + version + path + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodHead && r.URL.Path == "/_ping" { + w.Header().Add("Api-Version", version) + _, _ = w.Write([]byte{}) + return + } + + for defs, rValue := range routes { + method, path := defs[0], defs[1] + if r.Method == method && r.URL.Path == v(path) { + _ = response.JSON(w, rValue) + return + } + } + + http.NotFound(w, r) + })) + require.NotNil(t, srv) + + return srv, version +} + +func TestTransport_adminProxy(t *testing.T) { + t.Parallel() + admin := portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole} + std1 := portainer.User{ID: 2, Username: "std1", Role: portainer.StandardUserRole} + std2 := portainer.User{ID: 3, Username: "std2", Role: portainer.StandardUserRole} + + _, ds := datastore.MustNewTestStore(t, true, false) + + require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error { + require.NoError(t, tx.User().Create(&admin)) + require.NoError(t, tx.User().Create(&std1)) + require.NoError(t, tx.User().Create(&std2)) + require.NoError(t, tx.Endpoint().Create(&portainer.Endpoint{ID: 1, Name: "env", + UserAccessPolicies: portainer.UserAccessPolicies{std1.ID: portainer.AccessPolicy{RoleID: 1}}, + })) + + return nil + })) + srv, version := mockDockerAPIServer(t, RoutesDefinition{ + // allowed routes + {http.MethodGet, "/plugins"}: nil, + {http.MethodGet, "/plugins/xxx/json"}: nil, + {http.MethodGet, "/plugins/privileges"}: nil, + // admin routes ; see `adminOnlyRoutes` + {http.MethodDelete, "/plugins/xxx"}: nil, + {http.MethodPost, "/plugins/sshfs/enable"}: nil, // simulate plugin "sshfs" + {http.MethodPost, "/plugins/vieux/sshfs/enable"}: nil, // simulate "vieux/sshfs" + {http.MethodPost, "/plugins/xxx/disable"}: nil, + {http.MethodPost, "/plugins/pull"}: nil, + {http.MethodPost, "/plugins/xxx/push"}: nil, + {http.MethodPost, "/plugins/xxx/upgrade"}: nil, + {http.MethodPost, "/plugins/xxx/set"}: nil, + {http.MethodPost, "/plugins/create"}: nil, + }) + defer srv.Close() + + transport := &Transport{ + endpoint: &portainer.Endpoint{URL: srv.URL}, + dataStore: ds, + HTTPTransport: &http.Transport{}, + } + + test := func(method string, url string, token portainer.TokenData) (*http.Response, error) { + req := httptest.NewRequest(method, srv.URL+"/v"+version+url, nil) + req = req.WithContext(security.StoreTokenData(req, &token)) + require.NotNil(t, req) + + return transport.ProxyDockerRequest(req) + } + + adminToken := portainer.TokenData{ID: admin.ID, Username: admin.Username, Role: admin.Role} + std1Token := portainer.TokenData{ID: std1.ID, Username: std1.Username, Role: std1.Role} + std2Token := portainer.TokenData{ID: std2.ID, Username: std2.Username, Role: std2.Role} + + { + r, err := test(http.MethodGet, "/plugins", adminToken) + require.NoError(t, err) + require.NotNil(t, r) + require.Equal(t, http.StatusOK, r.StatusCode) + require.NoError(t, r.Body.Close()) + } + + { + r, err := test(http.MethodGet, "/plugins", std1Token) + require.NoError(t, err) + require.NotNil(t, r) + require.Equal(t, http.StatusOK, r.StatusCode) + require.NoError(t, r.Body.Close()) + } + + { + r, err := test(http.MethodGet, "/plugins", std2Token) + require.NoError(t, err) + require.NotNil(t, r) + require.Equal(t, http.StatusOK, r.StatusCode) + require.NoError(t, r.Body.Close()) + } + + { + r, err := test(http.MethodPost, "/plugins/pull", adminToken) + require.NoError(t, err) + require.NotNil(t, r) + require.Equal(t, http.StatusOK, r.StatusCode) + require.NoError(t, r.Body.Close()) + } + + { + r, err := test(http.MethodPost, "/plugins/pull", std1Token) + require.NoError(t, err) + require.NotNil(t, r) + require.Equal(t, http.StatusForbidden, r.StatusCode) + require.NoError(t, r.Body.Close()) + } + + { + r, err := test(http.MethodPost, "/plugins/pull", std2Token) + require.NoError(t, err) + require.NotNil(t, r) + require.Equal(t, http.StatusForbidden, r.StatusCode) + require.NoError(t, r.Body.Close()) + } + + { + r, err := test(http.MethodPost, "/plugins/sshfs/enable", adminToken) + require.NoError(t, err) + require.NotNil(t, r) + require.Equal(t, http.StatusOK, r.StatusCode) + require.NoError(t, r.Body.Close()) + } + + { + r, err := test(http.MethodPost, "/plugins/sshfs/enable", std2Token) + require.NoError(t, err) + require.NotNil(t, r) + require.Equal(t, http.StatusForbidden, r.StatusCode) + require.NoError(t, r.Body.Close()) + } + + { + r, err := test(http.MethodPost, "/plugins/vieux/sshfs/enable", adminToken) + require.NoError(t, err) + require.NotNil(t, r) + require.Equal(t, http.StatusOK, r.StatusCode) + require.NoError(t, r.Body.Close()) + } + + { + r, err := test(http.MethodPost, "/plugins/vieux/sshfs/enable", std2Token) + require.NoError(t, err) + require.NotNil(t, r) + require.Equal(t, http.StatusForbidden, r.StatusCode) + require.NoError(t, r.Body.Close()) + } +} + +func TestTransport_getRealResourceID(t *testing.T) { + t.Parallel() + srv, _ := mockDockerAPIServer(t, RoutesDefinition{ + {http.MethodGet, "/networks"}: []network.Summary{{ID: "16e37c629e88694663791dc738fd37affb908d7b85ce00a20680675d10554fd4", Name: "mynetwork"}}, + {http.MethodGet, "/networks/mynetwork"}: network.Inspect{ID: "16e37c629e88694663791dc738fd37affb908d7b85ce00a20680675d10554fd4", Name: "mynetwork"}, + {http.MethodGet, "/networks/16e37c629e88694663791dc738fd37affb908d7b85ce00a20680675d10554fd4"}: network.Inspect{ID: "16e37c629e88694663791dc738fd37affb908d7b85ce00a20680675d10554fd4", Name: "mynetwork"}, + {http.MethodGet, "/containers/mycontainer/json"}: container.InspectResponse{ContainerJSONBase: &container.ContainerJSONBase{ID: "545fc03ed1fd5008c3bfa2441209ff024e21e396acbeb58b2355930ad1295aa6", Name: "mycontainer"}}, + {http.MethodGet, "/containers/545fc03ed1fd5008c3bfa2441209ff024e21e396acbeb58b2355930ad1295aa6/json"}: container.InspectResponse{ContainerJSONBase: &container.ContainerJSONBase{ID: "545fc03ed1fd5008c3bfa2441209ff024e21e396acbeb58b2355930ad1295aa6", Name: "mycontainer"}}, + {http.MethodGet, "/services/myservice"}: swarm.Service{ID: "ibt43uf5awhg06bxp8rkd7bhi", Spec: swarm.ServiceSpec{Annotations: swarm.Annotations{Name: "myservice"}}}, + {http.MethodGet, "/services/ibt43uf5awhg06bxp8rkd7bhi"}: swarm.Service{ID: "ibt43uf5awhg06bxp8rkd7bhi", Spec: swarm.ServiceSpec{Annotations: swarm.Annotations{Name: "myservice"}}}, + {http.MethodGet, "/configs/myconfig"}: swarm.Config{ID: "3mlqqza0k413ecebk0mfa11em", Spec: swarm.ConfigSpec{Annotations: swarm.Annotations{Name: "myconfig"}}}, + {http.MethodGet, "/configs/3mlqqza0k413ecebk0mfa11em"}: swarm.Config{ID: "3mlqqza0k413ecebk0mfa11em", Spec: swarm.ConfigSpec{Annotations: swarm.Annotations{Name: "myconfig"}}}, + {http.MethodGet, "/secrets/mysecret"}: swarm.Secret{ID: "v9i7o4ivg33u4z3jfyxto162d", Spec: swarm.SecretSpec{Annotations: swarm.Annotations{Name: "mysecret"}}}, + {http.MethodGet, "/secrets/v9i7o4ivg33u4z3jfyxto162d"}: swarm.Secret{ID: "v9i7o4ivg33u4z3jfyxto162d", Spec: swarm.SecretSpec{Annotations: swarm.Annotations{Name: "mysecret"}}}, + }) + defer srv.Close() + + transport := &Transport{ + endpoint: &portainer.Endpoint{URL: srv.URL}, + } + + client, err := transport.dockerClientFactory.CreateClient(transport.endpoint, "", nil) + require.NoError(t, err) + require.NotNil(t, client) + + test := func(rctype portainer.ResourceControlType, name string, id string, errOnUnknown bool) { + // by id + got, err := getDockerResourceUUID(client, rctype, id) + require.NoError(t, err) + require.Equal(t, id, got) + + // by name + got, err = getDockerResourceUUID(client, rctype, name) + require.NoError(t, err) + require.Equal(t, id, got) + + // unknown for this type + _, err = getDockerResourceUUID(client, rctype, "unknown") + if errOnUnknown { + require.Error(t, err) + } else { + require.NoError(t, err) + } + } + + test(portainer.NetworkResourceControl, "mynetwork", "16e37c629e88694663791dc738fd37affb908d7b85ce00a20680675d10554fd4", true) + test(portainer.ContainerResourceControl, "mycontainer", "545fc03ed1fd5008c3bfa2441209ff024e21e396acbeb58b2355930ad1295aa6", true) + test(portainer.VolumeResourceControl, "anything", "", false) + test(portainer.ServiceResourceControl, "myservice", "ibt43uf5awhg06bxp8rkd7bhi", true) + test(portainer.ConfigResourceControl, "myconfig", "3mlqqza0k413ecebk0mfa11em", true) + test(portainer.SecretResourceControl, "mysecret", "v9i7o4ivg33u4z3jfyxto162d", true) + + // validate that other types are not supported + _, err = getDockerResourceUUID(client, portainer.ContainerGroupResourceControl, "") + require.Error(t, err) +} + +func TestTransport_proxyNetworkRequest(t *testing.T) { + t.Parallel() + admin := portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole} + std1 := portainer.User{ID: 2, Username: "std1", Role: portainer.StandardUserRole} + std2 := portainer.User{ID: 3, Username: "std2", Role: portainer.StandardUserRole} + + _, ds := datastore.MustNewTestStore(t, true, false) + + require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error { + require.NoError(t, tx.User().Create(&admin)) + require.NoError(t, tx.User().Create(&std1)) + require.NoError(t, tx.User().Create(&std2)) + require.NoError(t, tx.Endpoint().Create(&portainer.Endpoint{ID: 1, Name: "env", + UserAccessPolicies: portainer.UserAccessPolicies{std1.ID: portainer.AccessPolicy{RoleID: 1}}, + })) + + require.NoError(t, tx.ResourceControl().Create(authorization.NewPrivateResourceControl("16e37c629e88694663791dc738fd37affb908d7b85ce00a20680675d10554fd4", portainer.NetworkResourceControl, std1.ID))) + + return nil + })) + + srv, version := mockDockerAPIServer(t, RoutesDefinition{ + {http.MethodGet, "/networks"}: []network.Summary{{ID: "16e37c629e88694663791dc738fd37affb908d7b85ce00a20680675d10554fd4", Name: "mynetwork"}}, + {http.MethodGet, "/networks/mynetwork"}: network.Inspect{ID: "16e37c629e88694663791dc738fd37affb908d7b85ce00a20680675d10554fd4", Name: "mynetwork"}, + {http.MethodGet, "/networks/16e37c629e88694663791dc738fd37affb908d7b85ce00a20680675d10554fd4"}: network.Inspect{ID: "16e37c629e88694663791dc738fd37affb908d7b85ce00a20680675d10554fd4", Name: "mynetwork"}, + {http.MethodPost, "/networks/mynetwork/connect"}: struct{}{}, + {http.MethodPost, "/networks/16e37c629e88694663791dc738fd37affb908d7b85ce00a20680675d10554fd4/connect"}: struct{}{}, + {http.MethodPost, "/networks/mynetwork/disconnect"}: struct{}{}, + {http.MethodPost, "/networks/16e37c629e88694663791dc738fd37affb908d7b85ce00a20680675d10554fd4/disconnect"}: struct{}{}, + {http.MethodDelete, "/networks/mynetwork"}: struct{}{}, + {http.MethodDelete, "/networks/16e37c629e88694663791dc738fd37affb908d7b85ce00a20680675d10554fd4"}: struct{}{}, + {http.MethodPost, "/networks/create"}: network.CreateResponse{ID: "16e37c629e88694663791dc738fd37affb908d7b85ce00a20680675d10554fd4"}, + {http.MethodPost, "/networks/prune"}: struct{}{}, + }) + defer srv.Close() + + transport := &Transport{ + endpoint: &portainer.Endpoint{URL: srv.URL}, + dataStore: ds, + HTTPTransport: &http.Transport{}, + } + + test := func(method string, url string, token portainer.TokenData) (*http.Response, error) { + req := httptest.NewRequest(method, srv.URL+"/v"+version+url, nil) + req = req.WithContext(security.StoreTokenData(req, &token)) + require.NotNil(t, req) + + return transport.proxyNetworkRequest(req, url) + } + + adminToken := portainer.TokenData{ID: admin.ID, Username: admin.Username, Role: admin.Role} + std1Token := portainer.TokenData{ID: std1.ID, Username: std1.Username, Role: std1.Role} + std2Token := portainer.TokenData{ID: std2.ID, Username: std2.Username, Role: std2.Role} + + { + r, err := test(http.MethodGet, "/networks", adminToken) + require.NoError(t, err) + require.NotNil(t, r) + require.Equal(t, http.StatusOK, r.StatusCode) + var resp []network.Summary + require.NoError(t, json.NewDecoder(r.Body).Decode(&resp)) + require.Len(t, resp, 1) + require.NoError(t, r.Body.Close()) + } + + { + r, err := test(http.MethodGet, "/networks", std1Token) + require.NoError(t, err) + require.NotNil(t, r) + require.Equal(t, http.StatusOK, r.StatusCode) + var resp []network.Summary + require.NoError(t, json.NewDecoder(r.Body).Decode(&resp)) + require.Len(t, resp, 1) + require.NoError(t, r.Body.Close()) + } + + { + r, err := test(http.MethodGet, "/networks", std2Token) + require.NoError(t, err) + require.NotNil(t, r) + require.Equal(t, http.StatusOK, r.StatusCode) + var resp []network.Summary + require.NoError(t, json.NewDecoder(r.Body).Decode(&resp)) + require.Empty(t, resp) + require.NoError(t, r.Body.Close()) + } + + { + r, err := test(http.MethodGet, "/networks/mynetwork", adminToken) + require.NoError(t, err) + require.NotNil(t, r) + require.Equal(t, http.StatusOK, r.StatusCode) + require.NoError(t, r.Body.Close()) + } + + { + r, err := test(http.MethodGet, "/networks/mynetwork", std1Token) + require.NoError(t, err) + require.NotNil(t, r) + require.Equal(t, http.StatusOK, r.StatusCode) + require.NoError(t, r.Body.Close()) + } + + { + r, err := test(http.MethodGet, "/networks/mynetwork", std2Token) + require.NoError(t, err) + require.NotNil(t, r) + require.Equal(t, http.StatusForbidden, r.StatusCode) + require.NoError(t, r.Body.Close()) + } + + { + r, err := test(http.MethodGet, "/networks/unknown", adminToken) + require.NoError(t, err) + require.NotNil(t, r) + require.Equal(t, http.StatusNotFound, r.StatusCode) + require.NoError(t, r.Body.Close()) + } + + { + r, err := test(http.MethodPost, "/networks/mynetwork/connect", adminToken) + require.NoError(t, err) + require.NotNil(t, r) + require.Equal(t, http.StatusOK, r.StatusCode) + require.NoError(t, r.Body.Close()) + } + + { + r, err := test(http.MethodPost, "/networks/mynetwork/connect", std1Token) + require.NoError(t, err) + require.NotNil(t, r) + require.NoError(t, r.Body.Close()) + require.Equal(t, http.StatusOK, r.StatusCode) + } + + { + r, err := test(http.MethodPost, "/networks/mynetwork/connect", std2Token) + require.NoError(t, err) + require.NotNil(t, r) + require.NoError(t, r.Body.Close()) + require.Equal(t, http.StatusForbidden, r.StatusCode) + } + + { + r, err := test(http.MethodPost, "/networks/mynetwork/disconnect", adminToken) + require.NoError(t, err) + require.NotNil(t, r) + require.Equal(t, http.StatusOK, r.StatusCode) + require.NoError(t, r.Body.Close()) + } + + { + r, err := test(http.MethodPost, "/networks/mynetwork/disconnect", std1Token) + require.NoError(t, err) + require.NotNil(t, r) + require.Equal(t, http.StatusOK, r.StatusCode) + require.NoError(t, r.Body.Close()) + } + + { + r, err := test(http.MethodPost, "/networks/mynetwork/disconnect", std2Token) + require.NoError(t, err) + require.NotNil(t, r) + require.Equal(t, http.StatusForbidden, r.StatusCode) + require.NoError(t, r.Body.Close()) + } + + { + r, err := test(http.MethodDelete, "/networks/mynetwork", adminToken) + require.NoError(t, err) + require.NotNil(t, r) + require.Equal(t, http.StatusOK, r.StatusCode) + require.NoError(t, r.Body.Close()) + } + + { + r, err := test(http.MethodDelete, "/networks/mynetwork", std1Token) + require.NoError(t, err) + require.NotNil(t, r) + require.Equal(t, http.StatusOK, r.StatusCode) + require.NoError(t, r.Body.Close()) + } + + { + r, err := test(http.MethodDelete, "/networks/mynetwork", std2Token) + require.NoError(t, err) + require.NotNil(t, r) + require.Equal(t, http.StatusForbidden, r.StatusCode) + require.NoError(t, r.Body.Close()) + } + + { + r, err := test(http.MethodPost, "/networks/create", adminToken) + require.NoError(t, err) + require.NotNil(t, r) + require.Equal(t, http.StatusOK, r.StatusCode) + require.NoError(t, r.Body.Close()) + } + + { + r, err := test(http.MethodPost, "/networks/create", std1Token) + require.NoError(t, err) + require.NotNil(t, r) + require.Equal(t, http.StatusOK, r.StatusCode) + require.NoError(t, r.Body.Close()) + } + + { + r, err := test(http.MethodPost, "/networks/create", std2Token) + require.NoError(t, err) + require.NotNil(t, r) + require.Equal(t, http.StatusOK, r.StatusCode) + require.NoError(t, r.Body.Close()) + } + + { + r, err := test(http.MethodPost, "/networks/prune", adminToken) + require.NoError(t, err) + require.NotNil(t, r) + require.Equal(t, http.StatusOK, r.StatusCode) + require.NoError(t, r.Body.Close()) + } + + { + r, err := test(http.MethodPost, "/networks/prune", std1Token) + require.Error(t, err) + require.Nil(t, r) + if r != nil { + require.NoError(t, r.Body.Close()) + } + } + + { + r, err := test(http.MethodPost, "/networks/prune", std2Token) + require.Error(t, err) + require.Nil(t, r) + if r != nil { + require.NoError(t, r.Body.Close()) + } + } +} + +func TestTransport_proxyExecRequest_accessControl(t *testing.T) { + t.Parallel() + + admin := portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole} + std1 := portainer.User{ID: 2, Username: "std1", Role: portainer.StandardUserRole} + std2 := portainer.User{ID: 3, Username: "std2", Role: portainer.StandardUserRole} + + containerID := "1111" + execID := "2222" + + _, ds := datastore.MustNewTestStore(t, true, false) + + err := ds.UpdateTx(func(tx dataservices.DataStoreTx) error { + require.NoError(t, tx.User().Create(&admin)) + require.NoError(t, tx.User().Create(&std1)) + require.NoError(t, tx.User().Create(&std2)) + require.NoError(t, tx.Endpoint().Create(&portainer.Endpoint{ + ID: 1, + Name: "env", + UserAccessPolicies: portainer.UserAccessPolicies{ + std1.ID: portainer.AccessPolicy{RoleID: 1}, + std2.ID: portainer.AccessPolicy{RoleID: 1}, + }, + })) + + // Only std1 owns the container, std2 has no resource control for it + require.NoError(t, tx.ResourceControl().Create( + authorization.NewPrivateResourceControl(containerID, portainer.ContainerResourceControl, std1.ID), + )) + + return nil + }) + require.NoError(t, err) + + srv, version := mockDockerAPIServer(t, RoutesDefinition{ + {http.MethodGet, "/exec/" + execID + "/json"}: container.ExecInspect{ExecID: execID, ContainerID: containerID}, + {http.MethodPost, "/exec/" + execID + "/start"}: struct{}{}, + {http.MethodPost, "/exec/" + execID + "/resize"}: struct{}{}, + }) + defer srv.Close() + + transport := &Transport{ + endpoint: &portainer.Endpoint{URL: srv.URL}, + dataStore: ds, + HTTPTransport: &http.Transport{}, + } + + test := func(method, url string, token portainer.TokenData) (*http.Response, error) { + req := httptest.NewRequest(method, srv.URL+"/v"+version+url, nil) + req = req.WithContext(security.StoreTokenData(req, &token)) + return transport.ProxyDockerRequest(req) + } + + adminToken := portainer.TokenData{ID: admin.ID, Username: admin.Username, Role: admin.Role} + std1Token := portainer.TokenData{ID: std1.ID, Username: std1.Username, Role: std1.Role} + std2Token := portainer.TokenData{ID: std2.ID, Username: std2.Username, Role: std2.Role} + + // admin can exec into any container + r, err := test(http.MethodPost, "/exec/"+execID+"/start", adminToken) + require.NoError(t, err) + require.Equal(t, http.StatusOK, r.StatusCode) + require.NoError(t, r.Body.Close()) + + // std1 owns the container, all exec operations allowed + r, err = test(http.MethodPost, "/exec/"+execID+"/start", std1Token) + require.NoError(t, err) + require.Equal(t, http.StatusOK, r.StatusCode) + require.NoError(t, r.Body.Close()) + + r, err = test(http.MethodGet, "/exec/"+execID+"/json", std1Token) + require.NoError(t, err) + require.Equal(t, http.StatusOK, r.StatusCode) + require.NoError(t, r.Body.Close()) + + r, err = test(http.MethodPost, "/exec/"+execID+"/resize", std1Token) + require.NoError(t, err) + require.Equal(t, http.StatusOK, r.StatusCode) + require.NoError(t, r.Body.Close()) + + // std2 does NOT own the container, all exec operations must be blocked + r, err = test(http.MethodPost, "/exec/"+execID+"/start", std2Token) + require.NoError(t, err) + require.Equal(t, http.StatusForbidden, r.StatusCode) + require.NoError(t, r.Body.Close()) + + r, err = test(http.MethodGet, "/exec/"+execID+"/json", std2Token) + require.NoError(t, err) + require.Equal(t, http.StatusForbidden, r.StatusCode) + require.NoError(t, r.Body.Close()) + + r, err = test(http.MethodPost, "/exec/"+execID+"/resize", std2Token) + require.NoError(t, err) + require.Equal(t, http.StatusForbidden, r.StatusCode) + require.NoError(t, r.Body.Close()) +} + +func TestTransport_proxyExecRequest_createClientError(t *testing.T) { + t.Parallel() + + // AzureEnvironment type causes CreateClient to return an error immediately + transport := &Transport{ + endpoint: &portainer.Endpoint{Type: portainer.AzureEnvironment}, + HTTPTransport: &http.Transport{}, + } + + req := httptest.NewRequest(http.MethodPost, "http://localhost/v1.51/exec/2222/start", nil) + resp, err := transport.ProxyDockerRequest(req) + require.Error(t, err) + require.Nil(t, resp) + if resp != nil { + require.NoError(t, resp.Body.Close()) + } +} + +func TestTransport_proxyExecRequest_inspectError(t *testing.T) { + t.Parallel() + + // Mock server that handles /_ping (for Docker client negotiation) but no exec routes + srv, version := mockDockerAPIServer(t, RoutesDefinition{}) + defer srv.Close() + + transport := &Transport{ + endpoint: &portainer.Endpoint{URL: srv.URL}, + HTTPTransport: &http.Transport{}, + } + + req := httptest.NewRequest(http.MethodPost, srv.URL+"/v"+version+"/exec/2222/start", nil) + resp, err := transport.ProxyDockerRequest(req) + require.Error(t, err) + require.Nil(t, resp) + if resp != nil { + require.NoError(t, resp.Body.Close()) + } +} + +func TestTransport_proxyImageRequest_Prune(t *testing.T) { + t.Parallel() + admin := portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole} + std1 := portainer.User{ID: 2, Username: "std1", Role: portainer.StandardUserRole} + + _, ds := datastore.MustNewTestStore(t, true, false) + + require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error { + require.NoError(t, tx.User().Create(&admin)) + require.NoError(t, tx.User().Create(&std1)) + require.NoError(t, tx.Endpoint().Create(&portainer.Endpoint{ID: 1, Name: "env", + UserAccessPolicies: portainer.UserAccessPolicies{std1.ID: portainer.AccessPolicy{RoleID: 1}}, + })) + + return nil + })) + + srv, version := mockDockerAPIServer(t, RoutesDefinition{ + {http.MethodPost, "/images/prune"}: struct { + ImagesDeleted []any `json:"ImagesDeleted"` + SpaceReclaimed int `json:"SpaceReclaimed"` + }{ + ImagesDeleted: []any{}, + SpaceReclaimed: 0, + }, + }) + defer srv.Close() + + transport := &Transport{ + endpoint: &portainer.Endpoint{URL: srv.URL}, + dataStore: ds, + HTTPTransport: &http.Transport{}, + } + + test := func(method string, url string, token portainer.TokenData) (*http.Response, error) { + req := httptest.NewRequest(method, srv.URL+"/v"+version+url, nil) + req = req.WithContext(security.StoreTokenData(req, &token)) + require.NotNil(t, req) + + return transport.proxyImageRequest(req, url) + } + + adminToken := portainer.TokenData{ID: admin.ID, Username: admin.Username, Role: admin.Role} + std1Token := portainer.TokenData{ID: std1.ID, Username: std1.Username, Role: std1.Role} + + // Admin should be able to prune images + { + r, err := test(http.MethodPost, "/images/prune", adminToken) + require.NoError(t, err) + require.NotNil(t, r) + require.Equal(t, http.StatusOK, r.StatusCode) + require.NoError(t, r.Body.Close()) + } + + // Standard user should NOT be able to prune images (administrator operation) + { + r, err := test(http.MethodPost, "/images/prune", std1Token) + require.NoError(t, err) + require.NotNil(t, r) + require.Equal(t, http.StatusForbidden, r.StatusCode) + require.NoError(t, r.Body.Close()) + } +} + +func TestTransport_proxyBuildRequest_Prune(t *testing.T) { + t.Parallel() + admin := portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole} + std1 := portainer.User{ID: 2, Username: "std1", Role: portainer.StandardUserRole} + + _, ds := datastore.MustNewTestStore(t, true, false) + + require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error { + require.NoError(t, tx.User().Create(&admin)) + require.NoError(t, tx.User().Create(&std1)) + require.NoError(t, tx.Endpoint().Create(&portainer.Endpoint{ID: 1, Name: "env", + UserAccessPolicies: portainer.UserAccessPolicies{std1.ID: portainer.AccessPolicy{RoleID: 1}}, + })) + + return nil + })) + + srv, version := mockDockerAPIServer(t, RoutesDefinition{ + {http.MethodPost, "/build/prune"}: struct { + CachesDeleted []string `json:"CachesDeleted"` + SpaceReclaimed int `json:"SpaceReclaimed"` + }{ + CachesDeleted: []string{}, + SpaceReclaimed: 0, + }, + }) + defer srv.Close() + + transport := &Transport{ + endpoint: &portainer.Endpoint{URL: srv.URL}, + dataStore: ds, + HTTPTransport: &http.Transport{}, + } + + test := func(method string, url string, token portainer.TokenData) (*http.Response, error) { + req := httptest.NewRequest(method, srv.URL+"/v"+version+url, nil) + req = req.WithContext(security.StoreTokenData(req, &token)) + require.NotNil(t, req) + + return transport.proxyBuildRequest(req, url) + } + + adminToken := portainer.TokenData{ID: admin.ID, Username: admin.Username, Role: admin.Role} + std1Token := portainer.TokenData{ID: std1.ID, Username: std1.Username, Role: std1.Role} + + // Admin should be able to prune build cache + { + r, err := test(http.MethodPost, "/build/prune", adminToken) + require.NoError(t, err) + require.NotNil(t, r) + require.Equal(t, http.StatusOK, r.StatusCode) + require.NoError(t, r.Body.Close()) + } + + // Standard user should NOT be able to prune build cache (administrator operation) + { + r, err := test(http.MethodPost, "/build/prune", std1Token) + require.NoError(t, err) + require.NotNil(t, r) + require.Equal(t, http.StatusForbidden, r.StatusCode) + require.NoError(t, r.Body.Close()) + } +} + +func TestTransport_proxyContainerRequest(t *testing.T) { + t.Parallel() + + const containerID = "1111" + + admin := portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole} + std1 := portainer.User{ID: 2, Username: "std1", Role: portainer.StandardUserRole} + std2 := portainer.User{ID: 3, Username: "std2", Role: portainer.StandardUserRole} + + _, ds := datastore.MustNewTestStore(t, true, false) + + require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error { + require.NoError(t, tx.User().Create(&admin)) + require.NoError(t, tx.User().Create(&std1)) + require.NoError(t, tx.User().Create(&std2)) + require.NoError(t, tx.Endpoint().Create(&portainer.Endpoint{ID: 1, Name: "env", + UserAccessPolicies: portainer.UserAccessPolicies{std1.ID: portainer.AccessPolicy{RoleID: 1}}, + })) + require.NoError(t, tx.ResourceControl().Create(authorization.NewPrivateResourceControl(containerID, portainer.ContainerResourceControl, std1.ID))) + + return nil + })) + + srv, version := mockDockerAPIServer(t, RoutesDefinition{ + {http.MethodPost, "/containers/" + containerID + "/start"}: struct{}{}, + {http.MethodGet, "/containers/" + containerID + "/attach/ws"}: struct{}{}, + {http.MethodDelete, "/containers/" + containerID}: struct{}{}, + {http.MethodPost, "/containers/prune"}: struct{}{}, + }) + defer srv.Close() + + transport := &Transport{ + endpoint: &portainer.Endpoint{ID: 1, URL: srv.URL}, + dataStore: ds, + HTTPTransport: &http.Transport{}, + } + + test := func(method, url string, token portainer.TokenData) (*http.Response, error) { + req := httptest.NewRequest(method, srv.URL+"/v"+version+url, nil) + req = req.WithContext(security.StoreTokenData(req, &token)) + return transport.proxyContainerRequest(req, url) + } + + adminToken := portainer.TokenData{ID: admin.ID, Username: admin.Username, Role: admin.Role} + std1Token := portainer.TokenData{ID: std1.ID, Username: std1.Username, Role: std1.Role} + std2Token := portainer.TokenData{ID: std2.ID, Username: std2.Username, Role: std2.Role} + + // /containers/{id}/start (2-segment): admin and owner allowed, non-owner denied + r, err := test(http.MethodPost, "/containers/"+containerID+"/start", adminToken) + require.NoError(t, err) + require.Equal(t, http.StatusOK, r.StatusCode) + require.NoError(t, r.Body.Close()) + + r, err = test(http.MethodPost, "/containers/"+containerID+"/start", std1Token) + require.NoError(t, err) + require.Equal(t, http.StatusOK, r.StatusCode) + require.NoError(t, r.Body.Close()) + + r, err = test(http.MethodPost, "/containers/"+containerID+"/start", std2Token) + require.NoError(t, err) + require.Equal(t, http.StatusForbidden, r.StatusCode) + require.NoError(t, r.Body.Close()) + + // /containers/{id}/attach/ws (3-segment): admin and owner allowed, non-owner denied + r, err = test(http.MethodGet, "/containers/"+containerID+"/attach/ws", adminToken) + require.NoError(t, err) + require.Equal(t, http.StatusOK, r.StatusCode) + require.NoError(t, r.Body.Close()) + + r, err = test(http.MethodGet, "/containers/"+containerID+"/attach/ws", std1Token) + require.NoError(t, err) + require.Equal(t, http.StatusOK, r.StatusCode) + require.NoError(t, r.Body.Close()) + + r, err = test(http.MethodGet, "/containers/"+containerID+"/attach/ws", std2Token) + require.NoError(t, err) + require.Equal(t, http.StatusForbidden, r.StatusCode) + require.NoError(t, r.Body.Close()) + + // DELETE /containers/{id}: non-owner denied, admin allowed + // std2 must be tested before admin: a successful delete removes the resource control from the datastore + r, err = test(http.MethodDelete, "/containers/"+containerID, std2Token) + require.NoError(t, err) + require.Equal(t, http.StatusForbidden, r.StatusCode) + require.NoError(t, r.Body.Close()) + + r, err = test(http.MethodDelete, "/containers/"+containerID, adminToken) + require.NoError(t, err) + require.Equal(t, http.StatusOK, r.StatusCode) + require.NoError(t, r.Body.Close()) + + // /containers/prune: admin-only + r, err = test(http.MethodPost, "/containers/prune", adminToken) + require.NoError(t, err) + require.Equal(t, http.StatusOK, r.StatusCode) + require.NoError(t, r.Body.Close()) + + r, err = test(http.MethodPost, "/containers/prune", std1Token) + require.NoError(t, err) + require.Equal(t, http.StatusForbidden, r.StatusCode) + require.NoError(t, r.Body.Close()) +} diff --git a/api/http/proxy/factory/docker/volumes.go b/api/http/proxy/factory/docker/volumes.go new file mode 100644 index 0000000..dc51700 --- /dev/null +++ b/api/http/proxy/factory/docker/volumes.go @@ -0,0 +1,305 @@ +package docker + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + "path" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/proxy/factory/utils" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/authorization" + "github.com/portainer/portainer/api/internal/snapshot" + "github.com/portainer/portainer/api/logs" + + "github.com/docker/docker/client" + "github.com/segmentio/encoding/json" +) + +const volumeObjectIdentifier = "ResourceID" + +func getInheritedResourceControlFromVolumeLabels(dockerClient *client.Client, endpointID portainer.EndpointID, volumeID string, resourceControls []portainer.ResourceControl) (*portainer.ResourceControl, error) { + volume, err := dockerClient.VolumeInspect(context.Background(), volumeID) + if err != nil { + return nil, err + } + + stackResourceID := getStackResourceIDFromLabels(volume.Labels, endpointID) + if stackResourceID != "" { + return authorization.GetResourceControlByResourceIDAndType(stackResourceID, portainer.StackResourceControl, resourceControls), nil + } + + return nil, nil +} + +// volumeListOperation extracts the response as a JSON object, loop through the volume array +// decorate and/or filter the volumes based on resource controls before rewriting the response. +func (transport *Transport) volumeListOperation(response *http.Response, executor *operationExecutor) error { + // VolumeList response is a JSON object + // https://docs.docker.com/engine/api/v1.28/#operation/VolumeList + responseObject, err := utils.GetResponseAsJSONObject(response) + if err != nil { + return err + } + + // The "Volumes" field contains the list of volumes as an array of JSON objects + if responseObject["Volumes"] == nil { + return utils.RewriteResponse(response, responseObject, http.StatusOK) + } + + volumeData := responseObject["Volumes"].([]any) + + for _, volumeObject := range volumeData { + volume := volumeObject.(map[string]any) + + if err := transport.decorateVolumeResponseWithResourceID(volume); err != nil { + return fmt.Errorf("failed decorating volume response: %w", err) + } + } + + resourceOperationParameters := &resourceOperationParameters{ + resourceIdentifierAttribute: volumeObjectIdentifier, + resourceType: portainer.VolumeResourceControl, + labelsObjectSelector: selectorVolumeLabels, + } + + volumeData, err = transport.applyAccessControlOnResourceList(resourceOperationParameters, volumeData, executor) + if err != nil { + return err + } + + // Overwrite the original volume list + responseObject["Volumes"] = volumeData + + return utils.RewriteResponse(response, responseObject, http.StatusOK) +} + +// volumeInspectOperation extracts the response as a JSON object, verify that the user +// has access to the volume based on any existing resource control and either rewrite an access denied response or a decorated volume. +func (transport *Transport) volumeInspectOperation(response *http.Response, executor *operationExecutor) error { + // VolumeInspect response is a JSON object + // https://docs.docker.com/engine/api/v1.28/#operation/VolumeInspect + responseObject, err := utils.GetResponseAsJSONObject(response) + if err != nil { + return err + } + + if err := transport.decorateVolumeResponseWithResourceID(responseObject); err != nil { + return fmt.Errorf("failed decorating volume response: %w", err) + } + + resourceOperationParameters := &resourceOperationParameters{ + resourceIdentifierAttribute: volumeObjectIdentifier, + resourceType: portainer.VolumeResourceControl, + labelsObjectSelector: selectorVolumeLabels, + } + + return transport.applyAccessControlOnResource(resourceOperationParameters, responseObject, response, executor) +} + +func (transport *Transport) decorateVolumeResponseWithResourceID(responseObject map[string]any) error { + if responseObject["Name"] == nil { + return errors.New("missing identifier in Docker resource detail response") + } + + resourceID, err := transport.getVolumeResourceID(responseObject["Name"].(string)) + if err != nil { + return fmt.Errorf("failed fetching resource id: %w", err) + } + + responseObject[volumeObjectIdentifier] = resourceID + + return nil +} + +// selectorVolumeLabels retrieve the labels object associated to the volume object. +// Labels are available under the "Labels" property. +// API schema references: +// https://docs.docker.com/engine/api/v1.28/#operation/VolumeInspect +// https://docs.docker.com/engine/api/v1.28/#operation/VolumeList +func selectorVolumeLabels(responseObject map[string]any) map[string]any { + return utils.GetJSONObject(responseObject, "Labels") +} + +func CheckVolumeBodyRestrictions(request *http.Request) error { + defer logs.CloseAndLogErr(request.Body) + + body, err := io.ReadAll(request.Body) + if err != nil { + return err + } + + var volumeCreateBody struct { + DriverOpts map[string]string `json:"DriverOpts"` + } + + if err := json.Unmarshal(body, &volumeCreateBody); err != nil { + return err + } + + if volumeCreateBody.DriverOpts["type"] == "bind" { + return ErrBindMountsForbidden + } + + request.Body = io.NopCloser(bytes.NewBuffer(body)) + + return nil +} + +func (transport *Transport) decorateVolumeResourceCreationOperation(request *http.Request, resourceType portainer.ResourceControlType) (*http.Response, error) { + tokenData, err := security.RetrieveTokenData(request) + if err != nil { + return nil, err + } + + isAdminOrEndpointAdmin, err := transport.isAdminOrEndpointAdmin(request) + if err != nil { + return nil, err + } + + if !isAdminOrEndpointAdmin { + securitySettings, err := transport.fetchEndpointSecuritySettings() + if err != nil { + return nil, err + } + + if !securitySettings.AllowBindMountsForRegularUsers { + if err := CheckVolumeBodyRestrictions(request); err != nil { + return &http.Response{ + StatusCode: http.StatusForbidden, + Body: io.NopCloser(bytes.NewBufferString("Access denied: insufficient permissions to create volume with specified configuration")), + }, err + } + } + } + + volumeID := request.Header.Get("X-Portainer-VolumeName") + + if volumeID != "" { + agentTargetHeader := request.Header.Get(portainer.PortainerAgentTargetHeader) + cli, err := transport.dockerClientFactory.CreateClient(transport.endpoint, agentTargetHeader, nil) + if err != nil { + return nil, err + } + defer logs.CloseAndLogErr(cli) + + if _, err := cli.VolumeInspect(context.Background(), volumeID); err == nil { + return &http.Response{ + StatusCode: http.StatusConflict, + }, errors.New("a volume with the same name already exists") + } + } + + response, err := transport.executeDockerRequest(request) + if err != nil { + return response, err + } + + if response.StatusCode == http.StatusCreated { + err = transport.decorateVolumeCreationResponse(response, resourceType, tokenData.ID) + } + + return response, err +} + +func (transport *Transport) decorateVolumeCreationResponse(response *http.Response, resourceType portainer.ResourceControlType, userID portainer.UserID) error { + responseObject, err := utils.GetResponseAsJSONObject(response) + if err != nil { + return err + } + + if responseObject["Name"] == nil { + return errors.New("missing identifier in Docker resource creation response") + } + + resourceID, err := transport.getVolumeResourceID(responseObject["Name"].(string)) + if err != nil { + return fmt.Errorf("failed fetching resource id: %w", err) + } + + resourceControl, err := transport.createPrivateResourceControl(resourceID, resourceType, userID) + if err != nil { + return err + } + + responseObject[volumeObjectIdentifier] = resourceID + + responseObject = decorateObject(responseObject, resourceControl) + + return utils.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) + } + + volumeName := path.Base(requestPath) + + resourceID, err := transport.getVolumeResourceID(volumeName) + if err != nil { + return nil, err + } + + if request.Method == http.MethodDelete { + return transport.executeGenericResourceDeletionOperation(request, resourceID, volumeName, portainer.VolumeResourceControl) + } + + return transport.restrictedResourceOperation(request, resourceID, volumeName, portainer.VolumeResourceControl, false) +} + +func (transport *Transport) getVolumeResourceID(volumeName string) (string, error) { + dockerID, err := transport.getDockerID() + if err != nil { + return "", fmt.Errorf("failed fetching docker id: %w", err) + } + + return fmt.Sprintf("%s_%s", volumeName, dockerID), nil +} + +func (transport *Transport) getDockerID() (string, error) { + transport.mu.Lock() + defer transport.mu.Unlock() + + // Local cache + if transport.dockerID != "" { + return transport.dockerID, nil + } + + // Snapshot cache + if transport.snapshotService != nil { + endpoint := portainer.Endpoint{ID: transport.endpoint.ID} + + if err := transport.snapshotService.FillSnapshotData(&endpoint, true); err == nil && len(endpoint.Snapshots) > 0 { + if dockerID, err := snapshot.FetchDockerID(endpoint.Snapshots[0]); err == nil { + transport.dockerID = dockerID + return dockerID, nil + } + } + } + + // Remote value + client, err := transport.dockerClientFactory.CreateClient(transport.endpoint, "", nil) + if err != nil { + return "", err + } + defer logs.CloseAndLogErr(client) + + info, err := client.Info(context.Background()) + if err != nil { + return "", err + } + + if info.Swarm.Cluster != nil { + transport.dockerID = info.Swarm.Cluster.ID + return transport.dockerID, nil + } + + transport.dockerID = info.ID + + return transport.dockerID, nil +} diff --git a/api/http/proxy/factory/docker/volumes_test.go b/api/http/proxy/factory/docker/volumes_test.go new file mode 100644 index 0000000..8e255d2 --- /dev/null +++ b/api/http/proxy/factory/docker/volumes_test.go @@ -0,0 +1,226 @@ +package docker + +import ( + "bytes" + "net/http" + "net/http/httptest" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/http/security" + + "github.com/docker/docker/api/types/volume" + "github.com/segmentio/encoding/json" + "github.com/stretchr/testify/require" +) + +const volumeCreationAPIVersion = "1.51" + +type volumeCreationFixtures struct { + dockerSrv *httptest.Server + ds dataservices.DataStore + stdUser portainer.User + adminUser portainer.User + endpointID portainer.EndpointID +} + +func newVolumeCreationFixtures(t *testing.T) *volumeCreationFixtures { + t.Helper() + + dockerSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodHead && r.URL.Path == "/_ping" { + w.Header().Add("Api-Version", volumeCreationAPIVersion) + _, _ = w.Write([]byte{}) + + return + } + + if r.Method == http.MethodPost { + data, err := json.Marshal(map[string]string{"Name": "test-volume"}) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write(data) + + return + } + + http.NotFound(w, r) + })) + t.Cleanup(dockerSrv.Close) + + _, store := datastore.MustNewTestStore(t, true, false) + + f := &volumeCreationFixtures{ + dockerSrv: dockerSrv, + ds: store, + stdUser: portainer.User{ID: 1, Username: "std", Role: portainer.StandardUserRole}, + adminUser: portainer.User{ID: 2, Username: "admin", Role: portainer.AdministratorRole}, + endpointID: portainer.EndpointID(1), + } + + err := store.UpdateTx(func(tx dataservices.DataStoreTx) error { + err := tx.User().Create(&f.stdUser) + require.NoError(t, err) + + err = tx.User().Create(&f.adminUser) + require.NoError(t, err) + + err = tx.Endpoint().Create(&portainer.Endpoint{ID: f.endpointID, Name: "test-env"}) + require.NoError(t, err) + + return nil + }) + require.NoError(t, err) + + return f +} + +func (f *volumeCreationFixtures) setSecuritySettings(t *testing.T, settings portainer.EndpointSecuritySettings) { + t.Helper() + + err := f.ds.UpdateTx(func(tx dataservices.DataStoreTx) error { + return tx.Endpoint().UpdateEndpoint(f.endpointID, &portainer.Endpoint{ + ID: f.endpointID, + Name: "test-env", + SecuritySettings: settings, + }) + }) + require.NoError(t, err) +} + +func (f *volumeCreationFixtures) newTransport() *Transport { + return &Transport{ + endpoint: &portainer.Endpoint{ID: f.endpointID}, + dataStore: f.ds, + HTTPTransport: &http.Transport{}, + } +} + +func (f *volumeCreationFixtures) newRequest(t *testing.T, body volume.CreateOptions, user portainer.User) *http.Request { + t.Helper() + + data, err := json.Marshal(body) + require.NoError(t, err) + + req, err := http.NewRequestWithContext( + t.Context(), + http.MethodPost, + f.dockerSrv.URL+"/v"+volumeCreationAPIVersion+"/volumes/create", + bytes.NewReader(data), + ) + require.NoError(t, err) + + return req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ + ID: user.ID, + Username: user.Username, + Role: user.Role, + })) +} + +func TestDecorateVolumeResourceCreationOperation_BindDriverOptForbidden(t *testing.T) { + t.Parallel() + + f := newVolumeCreationFixtures(t) + f.setSecuritySettings(t, portainer.EndpointSecuritySettings{ + AllowBindMountsForRegularUsers: false, + }) + + body := volume.CreateOptions{ + Name: "evil-volume", + Driver: "local", + DriverOpts: map[string]string{ + "type": "bind", + "device": "/etc", + "o": "bind", + }, + } + + resp, err := f.newTransport().decorateVolumeResourceCreationOperation(f.newRequest(t, body, f.stdUser), portainer.VolumeResourceControl) + require.ErrorIs(t, err, ErrBindMountsForbidden) + require.NotNil(t, resp) + require.Equal(t, http.StatusForbidden, resp.StatusCode) + err = resp.Body.Close() + require.NoError(t, err) +} + +func TestDecorateVolumeResourceCreationOperation_BindDriverOptAllowedForAdmin(t *testing.T) { + t.Parallel() + + f := newVolumeCreationFixtures(t) + f.setSecuritySettings(t, portainer.EndpointSecuritySettings{ + AllowBindMountsForRegularUsers: false, + }) + + body := volume.CreateOptions{ + Name: "admin-volume", + Driver: "local", + DriverOpts: map[string]string{ + "type": "bind", + "device": "/etc", + }, + } + + resp, err := f.newTransport().decorateVolumeResourceCreationOperation(f.newRequest(t, body, f.adminUser), portainer.VolumeResourceControl) + require.NotErrorIs(t, err, ErrBindMountsForbidden) + require.NotNil(t, resp) + require.NotEqual(t, http.StatusForbidden, resp.StatusCode) + err = resp.Body.Close() + require.NoError(t, err) +} + +func TestDecorateVolumeResourceCreationOperation_BindDriverOptAllowedWhenSettingPermissive(t *testing.T) { + t.Parallel() + + f := newVolumeCreationFixtures(t) + f.setSecuritySettings(t, portainer.EndpointSecuritySettings{ + AllowBindMountsForRegularUsers: true, + }) + + body := volume.CreateOptions{ + Name: "allowed-volume", + Driver: "local", + DriverOpts: map[string]string{ + "type": "bind", + "device": "/data", + }, + } + + resp, err := f.newTransport().decorateVolumeResourceCreationOperation(f.newRequest(t, body, f.stdUser), portainer.VolumeResourceControl) + require.NotErrorIs(t, err, ErrBindMountsForbidden) + require.NotNil(t, resp) + require.NotEqual(t, http.StatusForbidden, resp.StatusCode) + err = resp.Body.Close() + require.NoError(t, err) +} + +func TestDecorateVolumeResourceCreationOperation_NonBindDriverOptNotForbidden(t *testing.T) { + t.Parallel() + + f := newVolumeCreationFixtures(t) + f.setSecuritySettings(t, portainer.EndpointSecuritySettings{ + AllowBindMountsForRegularUsers: false, + }) + + body := volume.CreateOptions{ + Name: "normal-volume", + Driver: "local", + DriverOpts: map[string]string{ + "type": "tmpfs", + }, + } + + resp, err := f.newTransport().decorateVolumeResourceCreationOperation(f.newRequest(t, body, f.stdUser), portainer.VolumeResourceControl) + require.NotErrorIs(t, err, ErrBindMountsForbidden) + require.NotNil(t, resp) + require.NotEqual(t, http.StatusForbidden, resp.StatusCode) + err = resp.Body.Close() + require.NoError(t, err) +} diff --git a/api/http/proxy/factory/docker_unix.go b/api/http/proxy/factory/docker_unix.go new file mode 100644 index 0000000..5cc9621 --- /dev/null +++ b/api/http/proxy/factory/docker_unix.go @@ -0,0 +1,43 @@ +//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris + +package factory + +import ( + "context" + "net" + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/proxy/factory/docker" + "github.com/portainer/portainer/pkg/libhttp/ssrf" +) + +func (factory ProxyFactory) newOSBasedLocalProxy(path string, endpoint *portainer.Endpoint) (http.Handler, error) { + transportParameters := &docker.TransportParameters{ + Endpoint: endpoint, + DataStore: factory.dataStore, + ReverseTunnelService: factory.reverseTunnelService, + SignatureService: factory.signatureService, + DockerClientFactory: factory.dockerClientFactory, + } + + proxy := &dockerLocalProxy{} + + dockerTransport, err := docker.NewTransport(transportParameters, newSocketTransport(path), factory.gitService, factory.snapshotService) + if err != nil { + return nil, err + } + + proxy.transport = dockerTransport + return proxy, nil +} + +func newSocketTransport(socketPath string) *http.Transport { + d := &net.Dialer{} + t := ssrf.NewInternalTransport(nil) + t.DialContext = func(ctx context.Context, _, _ string) (net.Conn, error) { + return d.DialContext(ctx, "unix", socketPath) + } + + return t +} diff --git a/api/http/proxy/factory/docker_windows.go b/api/http/proxy/factory/docker_windows.go new file mode 100644 index 0000000..08feaf2 --- /dev/null +++ b/api/http/proxy/factory/docker_windows.go @@ -0,0 +1,43 @@ +//go:build windows + +package factory + +import ( + "context" + "net" + "net/http" + + "github.com/Microsoft/go-winio" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/proxy/factory/docker" + "github.com/portainer/portainer/pkg/libhttp/ssrf" +) + +func (factory ProxyFactory) newOSBasedLocalProxy(path string, endpoint *portainer.Endpoint) (http.Handler, error) { + transportParameters := &docker.TransportParameters{ + Endpoint: endpoint, + DataStore: factory.dataStore, + ReverseTunnelService: factory.reverseTunnelService, + SignatureService: factory.signatureService, + DockerClientFactory: factory.dockerClientFactory, + } + + proxy := &dockerLocalProxy{} + + dockerTransport, err := docker.NewTransport(transportParameters, newNamedPipeTransport(path), factory.gitService, factory.snapshotService) + if err != nil { + return nil, err + } + + proxy.transport = dockerTransport + return proxy, nil +} + +func newNamedPipeTransport(namedPipePath string) *http.Transport { + t := ssrf.NewInternalTransport(nil) + t.DialContext = func(_ context.Context, _, _ string) (net.Conn, error) { + return winio.DialPipe(namedPipePath, nil) + } + + return t +} diff --git a/api/http/proxy/factory/factory.go b/api/http/proxy/factory/factory.go new file mode 100644 index 0000000..b456296 --- /dev/null +++ b/api/http/proxy/factory/factory.go @@ -0,0 +1,61 @@ +package factory + +import ( + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + dockerclient "github.com/portainer/portainer/api/docker/client" + "github.com/portainer/portainer/api/http/proxy/factory/kubernetes" + + "github.com/portainer/portainer/api/kubernetes/cli" +) + +const azureAPIBaseURL = "https://management.azure.com" + +type ( + // ProxyFactory is a factory to create reverse proxies + ProxyFactory struct { + dataStore dataservices.DataStore + signatureService portainer.DigitalSignatureService + reverseTunnelService portainer.ReverseTunnelService + dockerClientFactory *dockerclient.ClientFactory + kubernetesClientFactory *cli.ClientFactory + kubernetesTokenCacheManager *kubernetes.TokenCacheManager + gitService portainer.GitService + snapshotService portainer.SnapshotService + jwtService portainer.JWTService + } +) + +// NewProxyFactory returns a pointer to a new instance of a ProxyFactory +func NewProxyFactory(dataStore dataservices.DataStore, signatureService portainer.DigitalSignatureService, tunnelService portainer.ReverseTunnelService, clientFactory *dockerclient.ClientFactory, kubernetesClientFactory *cli.ClientFactory, kubernetesTokenCacheManager *kubernetes.TokenCacheManager, gitService portainer.GitService, snapshotService portainer.SnapshotService, jwtService portainer.JWTService) *ProxyFactory { + return &ProxyFactory{ + dataStore: dataStore, + signatureService: signatureService, + reverseTunnelService: tunnelService, + dockerClientFactory: clientFactory, + kubernetesClientFactory: kubernetesClientFactory, + kubernetesTokenCacheManager: kubernetesTokenCacheManager, + gitService: gitService, + snapshotService: snapshotService, + jwtService: jwtService, + } +} + +// NewEndpointProxy returns a new reverse proxy (filesystem based or HTTP) to an environment(endpoint) API server +func (factory *ProxyFactory) NewEndpointProxy(endpoint *portainer.Endpoint) (http.Handler, error) { + switch endpoint.Type { + case portainer.AzureEnvironment: + return newAzureProxy(endpoint, factory.dataStore) + case portainer.EdgeAgentOnKubernetesEnvironment, portainer.AgentOnKubernetesEnvironment, portainer.KubernetesLocalEnvironment: + return factory.newKubernetesProxy(endpoint) + } + + return factory.newDockerProxy(endpoint) +} + +// NewGitlabProxy returns a new HTTP proxy to a Gitlab API server +func (factory *ProxyFactory) NewGitlabProxy(gitlabAPIUri string) (http.Handler, error) { + return newGitlabProxy(gitlabAPIUri) +} diff --git a/api/http/proxy/factory/github/client.go b/api/http/proxy/factory/github/client.go new file mode 100644 index 0000000..84291fb --- /dev/null +++ b/api/http/proxy/factory/github/client.go @@ -0,0 +1,115 @@ +package github + +import ( + "context" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/portainer/portainer/pkg/libhttp/ssrf" + + "github.com/rs/zerolog/log" + "github.com/segmentio/encoding/json" + "oras.land/oras-go/v2/registry/remote/retry" +) + +const GitHubAPIHost = "https://api.github.com" + +// Package represents a GitHub container package +type Package struct { + Name string `json:"name"` + Owner struct { + Login string `json:"login"` + } `json:"owner"` +} + +// Client represents a GitHub API client +type Client struct { + httpClient *http.Client + baseURL string +} + +// NewClient creates a new GitHub API client +func NewClient(token string) *Client { + return &Client{ + httpClient: NewHTTPClient(token), + baseURL: GitHubAPIHost, + } +} + +// GetContainerPackages fetches container packages for the configured namespace +// It's a small http client wrapper instead of using the github client because listing repositories is the only known operation that isn't directly supported by oras +func (c *Client) GetContainerPackages(ctx context.Context, useOrganisation bool, organisationName string) ([]string, error) { + // Determine the namespace (user or organisation) for the request + namespace := "user" + if useOrganisation { + namespace = "orgs/" + organisationName + } + + // Build the full URL for listing container packages + url := fmt.Sprintf("%s/%s/packages?package_type=container", c.baseURL, namespace) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to execute request: %w", err) + } + defer func() { + if err := resp.Body.Close(); err != nil { + log.Warn().Err(err).Msg("failed to close response body") + } + }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("GitHub API returned status %d: %s", resp.StatusCode, resp.Status) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + var packages []Package + if err := json.Unmarshal(body, &packages); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + // Extract repository names in the form "owner/name" + repositories := make([]string, len(packages)) + for i, pkg := range packages { + repositories[i] = fmt.Sprintf("%s/%s", strings.ToLower(pkg.Owner.Login), strings.ToLower(pkg.Name)) + } + + return repositories, nil +} + +// NewHTTPClient creates a new HTTP client configured for GitHub API requests +func NewHTTPClient(token string) *http.Client { + return &http.Client{ + Transport: &tokenTransport{ + token: token, + transport: retry.NewTransport(ssrf.NewTransport(nil)), // Use ORAS retry transport for consistent rate limiting and error handling + }, + Timeout: 1 * time.Minute, + } +} + +// tokenTransport automatically adds the Bearer token header to requests +type tokenTransport struct { + token string + transport http.RoundTripper +} + +func (t *tokenTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if t.token != "" { + req.Header.Set("Authorization", "Bearer "+t.token) + req.Header.Set("Accept", "application/vnd.github+json") + } + return t.transport.RoundTrip(req) +} diff --git a/api/http/proxy/factory/gitlab.go b/api/http/proxy/factory/gitlab.go new file mode 100644 index 0000000..7c1ae96 --- /dev/null +++ b/api/http/proxy/factory/gitlab.go @@ -0,0 +1,19 @@ +package factory + +import ( + "net/http" + "net/url" + + "github.com/portainer/portainer/api/http/proxy/factory/gitlab" +) + +func newGitlabProxy(uri string) (http.Handler, error) { + url, err := url.Parse(uri) + if err != nil { + return nil, err + } + + proxy := NewSingleHostReverseProxyWithHostHeader(url) + proxy.Transport = gitlab.NewTransport() + return proxy, nil +} diff --git a/api/http/proxy/factory/gitlab/client.go b/api/http/proxy/factory/gitlab/client.go new file mode 100644 index 0000000..e3b5b2e --- /dev/null +++ b/api/http/proxy/factory/gitlab/client.go @@ -0,0 +1,137 @@ +package gitlab + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "time" + + "github.com/portainer/portainer/pkg/libhttp/ssrf" + + "github.com/rs/zerolog/log" + "github.com/segmentio/encoding/json" + "oras.land/oras-go/v2/registry/remote/retry" +) + +// Repository represents a GitLab registry repository +type Repository struct { + ID int `json:"id"` + Name string `json:"name"` + Path string `json:"path"` + ProjectID int `json:"project_id"` + Location string `json:"location"` + CreatedAt string `json:"created_at"` + Status string `json:"status"` +} + +// Client represents a GitLab API client +type Client struct { + httpClient *http.Client + baseURL string +} + +// NewClient creates a new GitLab API client +// it currently is an http client because only GetRegistryRepositoryNames is needed (oras supports other commands). +// if we need to support other commands, consider using the gitlab client library. +func NewClient(baseURL, token string) *Client { + return &Client{ + httpClient: NewHTTPClient(token), + baseURL: baseURL, + } +} + +// GetRegistryRepositoryNames fetches registry repository names for a given project. +// It's a small http client wrapper instead of using the gitlab client library because listing repositories is the only known operation that isn't directly supported by oras +func (c *Client) GetRegistryRepositoryNames(ctx context.Context, projectID int) ([]string, error) { + url := fmt.Sprintf("%s/api/v4/projects/%d/registry/repositories", c.baseURL, projectID) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to execute request: %w", err) + } + defer func() { + if err := resp.Body.Close(); err != nil { + log.Warn().Err(err).Msg("failed to close response body") + } + }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("GitLab API returned status %d: %s", resp.StatusCode, resp.Status) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + var repositories []Repository + if err := json.Unmarshal(body, &repositories); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + // Extract repository names + names := make([]string, len(repositories)) + for i, repo := range repositories { + // the full path is required for further repo operations + names[i] = repo.Path + } + + return names, nil +} + +type Transport struct { + httpTransport *http.Transport +} + +// NewTransport returns a pointer to a new instance of Transport that implements the HTTP Transport +// interface for proxying requests to the Gitlab API. +func NewTransport() *Transport { + return &Transport{ + httpTransport: ssrf.NewTransport(nil), + } +} + +// RoundTrip is the implementation of the http.RoundTripper interface +func (transport *Transport) RoundTrip(request *http.Request) (*http.Response, error) { + token := request.Header.Get("Private-Token") + if token == "" { + return nil, errors.New("no gitlab token provided") + } + + r, err := http.NewRequest(request.Method, request.URL.String(), request.Body) + if err != nil { + return nil, err + } + + r.Header.Set("Private-Token", token) + return transport.httpTransport.RoundTrip(r) +} + +// NewHTTPClient creates a new HTTP client configured for GitLab API requests +func NewHTTPClient(token string) *http.Client { + return &http.Client{ + Transport: &tokenTransport{ + token: token, + transport: retry.NewTransport(ssrf.NewTransport(nil)), // Use ORAS retry transport for consistent rate limiting and error handling + }, + Timeout: 1 * time.Minute, + } +} + +// tokenTransport automatically adds the Private-Token header to requests +type tokenTransport struct { + token string + transport http.RoundTripper +} + +func (t *tokenTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req.Header.Set("Private-Token", t.token) + return t.transport.RoundTrip(req) +} diff --git a/api/http/proxy/factory/kubernetes.go b/api/http/proxy/factory/kubernetes.go new file mode 100644 index 0000000..43ed7d5 --- /dev/null +++ b/api/http/proxy/factory/kubernetes.go @@ -0,0 +1,110 @@ +package factory + +import ( + "net/http" + "net/url" + + "github.com/portainer/portainer/api/http/proxy/factory/kubernetes" + + portainer "github.com/portainer/portainer/api" +) + +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.GetPrivilegedKubeClient(endpoint) + if err != nil { + return nil, err + } + + tokenCache := factory.kubernetesTokenCacheManager.GetOrCreateTokenCache(endpoint.ID) + tokenManager, err := kubernetes.NewTokenManager(kubecli, factory.dataStore, tokenCache, true) + if err != nil { + return nil, err + } + + transport, err := kubernetes.NewLocalTransport(tokenManager, endpoint, factory.kubernetesClientFactory, factory.dataStore, factory.jwtService) + 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) { + tunnelAddr, err := factory.reverseTunnelService.TunnelAddr(endpoint) + if err != nil { + return nil, err + } + rawURL := "http://" + tunnelAddr + + endpointURL, err := url.Parse(rawURL) + if err != nil { + return nil, err + } + + kubecli, err := factory.kubernetesClientFactory.GetPrivilegedKubeClient(endpoint) + if err != nil { + return nil, err + } + + tokenCache := factory.kubernetesTokenCacheManager.GetOrCreateTokenCache(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.dataStore, factory.signatureService, factory.reverseTunnelService, endpoint, tokenManager, factory.kubernetesClientFactory, factory.jwtService) + + return proxy, nil +} + +func (factory *ProxyFactory) newKubernetesAgentHTTPSProxy(endpoint *portainer.Endpoint) (http.Handler, error) { + endpointURL := "https://" + endpoint.URL + remoteURL, err := url.Parse(endpointURL) + if err != nil { + return nil, err + } + + remoteURL.Scheme = "https" + + kubecli, err := factory.kubernetesClientFactory.GetPrivilegedKubeClient(endpoint) + if err != nil { + return nil, err + } + + tokenCache := factory.kubernetesTokenCacheManager.GetOrCreateTokenCache(endpoint.ID) + tokenManager, err := kubernetes.NewTokenManager(kubecli, factory.dataStore, tokenCache, false) + if err != nil { + return nil, err + } + + transport, err := kubernetes.NewAgentTransport(factory.signatureService, tokenManager, endpoint, factory.kubernetesClientFactory, factory.dataStore, factory.jwtService) + if err != nil { + return nil, err + } + + proxy := NewSingleHostReverseProxyWithHostHeader(remoteURL) + proxy.Transport = transport + + return proxy, nil +} diff --git a/api/http/proxy/factory/kubernetes/agent_transport.go b/api/http/proxy/factory/kubernetes/agent_transport.go new file mode 100644 index 0000000..42266c0 --- /dev/null +++ b/api/http/proxy/factory/kubernetes/agent_transport.go @@ -0,0 +1,75 @@ +package kubernetes + +import ( + "net/http" + "strings" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/crypto" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/kubernetes/cli" + "github.com/portainer/portainer/pkg/libhttp/ssrf" +) + +type agentTransport struct { + *baseTransport + signatureService portainer.DigitalSignatureService +} + +// NewAgentTransport returns a new transport that can be used to send signed requests to a Portainer agent +func NewAgentTransport(signatureService portainer.DigitalSignatureService, tokenManager *tokenManager, endpoint *portainer.Endpoint, k8sClientFactory *cli.ClientFactory, dataStore dataservices.DataStore, jwtService portainer.JWTService) (*agentTransport, error) { + tlsConfig, err := crypto.CreateTLSConfigurationFromDisk(endpoint.TLSConfig) + if err != nil { + return nil, err + } + + httpTransport := ssrf.NewTransport(tlsConfig) + httpTransport.Protocols = ssrf.HTTP1Only() + + transport := &agentTransport{ + baseTransport: newBaseTransport( + httpTransport, + tokenManager, + endpoint, + k8sClientFactory, + dataStore, + jwtService, + ), + signatureService: signatureService, + } + + return transport, nil +} + +// RoundTrip is the implementation of the the http.RoundTripper interface +func (transport *agentTransport) RoundTrip(request *http.Request) (*http.Response, error) { + token, err := transport.getRoundTripToken(request, transport.tokenManager) + if err != nil { + return nil, err + } + + request.Header.Set(portainer.PortainerAgentKubernetesSATokenHeader, token) + + if strings.HasPrefix(request.URL.Path, "/v2") { + err := decorateAgentRequest(request, transport.dataStore) + if err != nil { + return nil, err + } + } + + 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) + + response, err := transport.baseTransport.RoundTrip(request) + if err != nil { + return response, err + } + response.Header.Set(portainer.PortainerCacheHeader, "true") + + return response, err +} diff --git a/api/http/proxy/factory/kubernetes/deployments.go b/api/http/proxy/factory/kubernetes/deployments.go new file mode 100644 index 0000000..a5bcc72 --- /dev/null +++ b/api/http/proxy/factory/kubernetes/deployments.go @@ -0,0 +1,18 @@ +package kubernetes + +import ( + "net/http" + + "github.com/rs/zerolog/log" +) + +func (transport *baseTransport) proxyDeploymentsRequest(request *http.Request, namespace, requestPath string) (*http.Response, error) { + switch request.Method { + case http.MethodPost, http.MethodPatch, http.MethodPut: + if err := transport.refreshRegistry(request, namespace); err != nil { + log.Warn().Err(err).Msg("failed to refresh registry credentials") + } + } + + return transport.executeKubernetesRequest(request) +} diff --git a/api/http/proxy/factory/kubernetes/edge_transport.go b/api/http/proxy/factory/kubernetes/edge_transport.go new file mode 100644 index 0000000..3d07df6 --- /dev/null +++ b/api/http/proxy/factory/kubernetes/edge_transport.go @@ -0,0 +1,68 @@ +package kubernetes + +import ( + "net/http" + "strings" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/kubernetes/cli" + "github.com/portainer/portainer/pkg/libhttp/ssrf" +) + +type edgeTransport struct { + *baseTransport + signatureService portainer.DigitalSignatureService + reverseTunnelService portainer.ReverseTunnelService +} + +// NewAgentTransport returns a new transport that can be used to send signed requests to a Portainer Edge agent +func NewEdgeTransport(dataStore dataservices.DataStore, signatureService portainer.DigitalSignatureService, reverseTunnelService portainer.ReverseTunnelService, endpoint *portainer.Endpoint, tokenManager *tokenManager, k8sClientFactory *cli.ClientFactory, jwtService portainer.JWTService) *edgeTransport { + transport := &edgeTransport{ + reverseTunnelService: reverseTunnelService, + signatureService: signatureService, + baseTransport: newBaseTransport( + ssrf.NewInternalTransport(nil), + tokenManager, + endpoint, + k8sClientFactory, + dataStore, + jwtService, + ), + } + + return transport +} + +// RoundTrip is the implementation of the the http.RoundTripper interface +func (transport *edgeTransport) RoundTrip(request *http.Request) (*http.Response, error) { + token, err := transport.getRoundTripToken(request, transport.tokenManager) + if err != nil { + return nil, err + } + + request.Header.Set(portainer.PortainerAgentKubernetesSATokenHeader, token) + + if strings.HasPrefix(request.URL.Path, "/v2") { + err := decorateAgentRequest(request, transport.dataStore) + if err != nil { + return nil, err + } + } + + 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) + + response, err := transport.baseTransport.RoundTrip(request) + + if err == nil { + transport.reverseTunnelService.UpdateLastActivity(transport.endpoint.ID) + } + + return response, err +} diff --git a/api/http/proxy/factory/kubernetes/edge_transport_test.go b/api/http/proxy/factory/kubernetes/edge_transport_test.go new file mode 100644 index 0000000..ddf40d8 --- /dev/null +++ b/api/http/proxy/factory/kubernetes/edge_transport_test.go @@ -0,0 +1,14 @@ +package kubernetes + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNewEdgeTransport(t *testing.T) { + t.Parallel() + + transport := NewEdgeTransport(nil, nil, nil, nil, nil, nil, nil) + require.NotNil(t, transport) +} diff --git a/api/http/proxy/factory/kubernetes/local_transport.go b/api/http/proxy/factory/kubernetes/local_transport.go new file mode 100644 index 0000000..bd177df --- /dev/null +++ b/api/http/proxy/factory/kubernetes/local_transport.go @@ -0,0 +1,46 @@ +package kubernetes + +import ( + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/crypto" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/kubernetes/cli" + "github.com/portainer/portainer/pkg/libhttp/ssrf" +) + +type localTransport struct { + *baseTransport +} + +// NewLocalTransport returns a new transport that can be used to send requests to the local Kubernetes API +func NewLocalTransport(tokenManager *tokenManager, endpoint *portainer.Endpoint, k8sClientFactory *cli.ClientFactory, dataStore dataservices.DataStore, jwtService portainer.JWTService) (*localTransport, error) { + config, err := crypto.CreateTLSConfigurationFromBytes(true, nil, nil, nil, true, true) + if err != nil { + return nil, err + } + + transport := &localTransport{ + baseTransport: newBaseTransport( + ssrf.NewInternalTransport(config), + tokenManager, + endpoint, + k8sClientFactory, + dataStore, + jwtService, + ), + } + + return transport, nil +} + +// RoundTrip is the implementation of the the http.RoundTripper interface +func (transport *localTransport) RoundTrip(request *http.Request) (*http.Response, error) { + _, err := transport.prepareRoundTrip(request) + if err != nil { + return nil, err + } + + return transport.baseTransport.RoundTrip(request) +} diff --git a/api/http/proxy/factory/kubernetes/local_transport_test.go b/api/http/proxy/factory/kubernetes/local_transport_test.go new file mode 100644 index 0000000..88726dd --- /dev/null +++ b/api/http/proxy/factory/kubernetes/local_transport_test.go @@ -0,0 +1,18 @@ +package kubernetes + +import ( + "testing" + + "github.com/portainer/portainer/pkg/fips" + + "github.com/stretchr/testify/require" +) + +func TestNewLocalTransport(t *testing.T) { + t.Parallel() + fips.InitFIPS(false) + + transport, err := NewLocalTransport(nil, nil, nil, nil, nil) + require.NoError(t, err) + require.True(t, transport.httpTransport.TLSClientConfig.InsecureSkipVerify) //nolint:forbidigo +} diff --git a/api/http/proxy/factory/kubernetes/namespaces.go b/api/http/proxy/factory/kubernetes/namespaces.go new file mode 100644 index 0000000..586bc8f --- /dev/null +++ b/api/http/proxy/factory/kubernetes/namespaces.go @@ -0,0 +1,64 @@ +package kubernetes + +import ( + "net/http" + + "github.com/pkg/errors" + portainer "github.com/portainer/portainer/api" +) + +func (transport *baseTransport) proxyNamespaceDeleteOperation(request *http.Request, namespace string) (*http.Response, error) { + if err := transport.tokenManager.kubecli.NamespaceAccessPoliciesDeleteNamespace(namespace); err != nil { + return nil, errors.WithMessagef(err, "failed to delete a namespace [%s] from portainer config", namespace) + } + + registries, err := transport.dataStore.Registry().ReadAll() + if err != nil { + return nil, err + } + + for _, registry := range registries { + for endpointID, registryAccessPolicies := range registry.RegistryAccesses { + if endpointID != transport.endpoint.ID { + continue + } + + namespaces := []string{} + for _, ns := range registryAccessPolicies.Namespaces { + if ns == namespace { + continue + } + namespaces = append(namespaces, ns) + } + + if len(namespaces) != len(registryAccessPolicies.Namespaces) { + updatedAccessPolicies := portainer.RegistryAccessPolicies{ + Namespaces: namespaces, + UserAccessPolicies: registryAccessPolicies.UserAccessPolicies, + TeamAccessPolicies: registryAccessPolicies.TeamAccessPolicies, + } + + registry.RegistryAccesses[endpointID] = updatedAccessPolicies + err := transport.dataStore.Registry().Update(registry.ID, ®istry) + if err != nil { + return nil, err + } + } + } + } + + stacks, err := transport.dataStore.Stack().ReadAll() + if err != nil { + return nil, err + } + + for _, s := range stacks { + if s.Namespace == namespace && s.EndpointID == transport.endpoint.ID { + if err := transport.dataStore.Stack().Delete(s.ID); err != nil { + return nil, err + } + } + } + + return transport.executeKubernetesRequest(request) +} diff --git a/api/http/proxy/factory/kubernetes/pods.go b/api/http/proxy/factory/kubernetes/pods.go new file mode 100644 index 0000000..e10f1fc --- /dev/null +++ b/api/http/proxy/factory/kubernetes/pods.go @@ -0,0 +1,23 @@ +package kubernetes + +import ( + "net/http" + "strings" + + "github.com/rs/zerolog/log" +) + +func (transport *baseTransport) proxyPodsRequest(request *http.Request, namespace string) (*http.Response, error) { + if request.Method == http.MethodDelete { + if err := transport.refreshRegistry(request, namespace); err != nil { + log.Warn().Err(err).Msg("failed to refresh registry credentials") + } + } + + if request.Method == http.MethodPost && strings.Contains(request.URL.Path, "/exec") { + if err := transport.addTokenForExec(request); err != nil { + return nil, err + } + } + return transport.executeKubernetesRequest(request) +} diff --git a/api/http/proxy/factory/kubernetes/refresh_registry.go b/api/http/proxy/factory/kubernetes/refresh_registry.go new file mode 100644 index 0000000..f70a908 --- /dev/null +++ b/api/http/proxy/factory/kubernetes/refresh_registry.go @@ -0,0 +1,18 @@ +package kubernetes + +import ( + "net/http" + + "github.com/portainer/portainer/api/internal/registryutils" +) + +func (transport *baseTransport) refreshRegistry(request *http.Request, namespace string) (err error) { + cli, err := transport.k8sClientFactory.GetPrivilegedKubeClient(transport.endpoint) + if err != nil { + return + } + + err = registryutils.RefreshEcrSecret(cli, transport.endpoint, transport.dataStore, namespace) + + return +} diff --git a/api/http/proxy/factory/kubernetes/token.go b/api/http/proxy/factory/kubernetes/token.go new file mode 100644 index 0000000..e8c09c4 --- /dev/null +++ b/api/http/proxy/factory/kubernetes/token.go @@ -0,0 +1,110 @@ +package kubernetes + +import ( + "fmt" + "os" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/rs/zerolog/log" +) + +const defaultServiceAccountTokenFile = "/var/run/secrets/kubernetes.io/serviceaccount/token" + +type tokenManager struct { + tokenCache *tokenCache + kubecli portainer.KubeClient + dataStore dataservices.DataStore + 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 dataservices.DataStore, cache *tokenCache, setLocalAdminToken bool) (*tokenManager, error) { + tokenManager := &tokenManager{ + tokenCache: cache, + kubecli: kubecli, + dataStore: dataStore, + adminToken: "", + } + + if setLocalAdminToken { + token, err := os.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) setupUserServiceAccounts(userID portainer.UserID, endpoint *portainer.Endpoint) error { + memberships, err := manager.dataStore.TeamMembership().TeamMembershipsByUserID(userID) + if err != nil { + return err + } + + teamIds := make([]int, 0, len(memberships)) + for _, membership := range memberships { + teamIds = append(teamIds, int(membership.TeamID)) + } + + restrictDefaultNamespace := endpoint.Kubernetes.Configuration.RestrictDefaultNamespace + err = manager.kubecli.SetupUserServiceAccount(int(userID), teamIds, restrictDefaultNamespace) + if err != nil { + return err + } + + return nil +} + +func (manager *tokenManager) UpdateUserServiceAccountsForEndpoint(endpointID portainer.EndpointID) { + endpoint, err := manager.dataStore.Endpoint().Endpoint(endpointID) + if err != nil { + log.Error().Err(err).Msgf("failed fetching environments %d", endpointID) + return + } + + userIDs := make([]portainer.UserID, 0) + for u := range endpoint.UserAccessPolicies { + userIDs = append(userIDs, u) + } + for t := range endpoint.TeamAccessPolicies { + memberships, _ := manager.dataStore.TeamMembership().TeamMembershipsByTeamID(t) + for _, membership := range memberships { + userIDs = append(userIDs, membership.UserID) + } + } + + for _, userID := range userIDs { + if err := manager.setupUserServiceAccounts(userID, endpoint); err != nil { + log.Error().Err(err).Msgf("failed setting-up service account for user %d", userID) + } + } +} + +// GetUserServiceAccountToken setup a user's service account if it does not exist, then retrieve its token +func (manager *tokenManager) GetUserServiceAccountToken(userID int, endpointID portainer.EndpointID) (string, error) { + tokenFunc := func() (string, error) { + endpoint, err := manager.dataStore.Endpoint().Endpoint(endpointID) + if err != nil { + log.Error().Err(err).Msgf("failed fetching environment %d", endpointID) + return "", err + } + + if err := manager.setupUserServiceAccounts(portainer.UserID(userID), endpoint); err != nil { + return "", fmt.Errorf("failed setting-up service account for user %d: %w", userID, err) + } + + return manager.kubecli.GetServiceAccountBearerToken(userID) + } + + return manager.tokenCache.getOrAddToken(portainer.UserID(userID), tokenFunc) +} 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 0000000..f00d5be --- /dev/null +++ b/api/http/proxy/factory/kubernetes/token_cache.go @@ -0,0 +1,78 @@ +package kubernetes + +import ( + "sync" + + portainer "github.com/portainer/portainer/api" +) + +// TokenCacheManager represents a service used to manage multiple tokenCache objects. +type TokenCacheManager struct { + tokenCaches map[portainer.EndpointID]*tokenCache + mu sync.Mutex +} + +type tokenCache struct { + userTokenCache map[portainer.UserID]string + mu sync.Mutex +} + +// NewTokenCacheManager returns a pointer to a new instance of TokenCacheManager +func NewTokenCacheManager() *TokenCacheManager { + return &TokenCacheManager{ + tokenCaches: make(map[portainer.EndpointID]*tokenCache), + } +} + +// GetOrCreateTokenCache will get the tokenCache from the manager map of caches if it exists, +// otherwise it 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) GetOrCreateTokenCache(endpointID portainer.EndpointID) *tokenCache { + manager.mu.Lock() + defer manager.mu.Unlock() + + if tc, ok := manager.tokenCaches[endpointID]; ok { + return tc + } + + tc := &tokenCache{ + userTokenCache: make(map[portainer.UserID]string), + } + + manager.tokenCaches[endpointID] = tc + + return tc +} + +// RemoveUserFromCache will ensure that the specific userID is removed from all registered caches. +func (manager *TokenCacheManager) RemoveUserFromCache(userID portainer.UserID) { + manager.mu.Lock() + for _, tc := range manager.tokenCaches { + tc.removeToken(userID) + } + manager.mu.Unlock() +} + +func (cache *tokenCache) getOrAddToken(userID portainer.UserID, tokenGetFunc func() (string, error)) (string, error) { + cache.mu.Lock() + defer cache.mu.Unlock() + + if tok, ok := cache.userTokenCache[userID]; ok { + return tok, nil + } + + tok, err := tokenGetFunc() + if err != nil { + return "", err + } + + cache.userTokenCache[userID] = tok + + return tok, nil +} + +func (cache *tokenCache) removeToken(userID portainer.UserID) { + cache.mu.Lock() + delete(cache.userTokenCache, userID) + cache.mu.Unlock() +} diff --git a/api/http/proxy/factory/kubernetes/token_cache_test.go b/api/http/proxy/factory/kubernetes/token_cache_test.go new file mode 100644 index 0000000..23231b2 --- /dev/null +++ b/api/http/proxy/factory/kubernetes/token_cache_test.go @@ -0,0 +1,104 @@ +package kubernetes + +import ( + "errors" + "testing" + + portainer "github.com/portainer/portainer/api" +) + +func noTokFunc() (string, error) { + return "", errors.New("no token found") +} + +func stringTok(tok string) func() (string, error) { + return func() (string, error) { + return tok, nil + } +} + +func failFunc(t *testing.T) func() (string, error) { + return func() (string, error) { + t.FailNow() + return noTokFunc() + } +} + +func TestTokenCacheDataRace(t *testing.T) { + t.Parallel() + ch := make(chan struct{}) + + for range 1000 { + var tokenCache1, tokenCache2 *tokenCache + + mgr := NewTokenCacheManager() + + go func() { + tokenCache1 = mgr.GetOrCreateTokenCache(1) + ch <- struct{}{} + }() + + go func() { + tokenCache2 = mgr.GetOrCreateTokenCache(1) + ch <- struct{}{} + }() + + <-ch + <-ch + + if tokenCache1 != tokenCache2 { + t.FailNow() + } + } +} + +func TestTokenCache(t *testing.T) { + t.Parallel() + mgr := NewTokenCacheManager() + tc1 := mgr.GetOrCreateTokenCache(1) + tc2 := mgr.GetOrCreateTokenCache(2) + tc3 := mgr.GetOrCreateTokenCache(3) + + uid := portainer.UserID(2) + tokenString1 := "token-string-1" + tokenString2 := "token-string-2" + + tok, err := tc1.getOrAddToken(uid, stringTok(tokenString1)) + if err != nil || tok != tokenString1 { + t.FailNow() + } + + tok, err = tc1.getOrAddToken(uid, failFunc(t)) + if err != nil || tok != tokenString1 { + t.FailNow() + } + + tok, err = tc2.getOrAddToken(uid, stringTok(tokenString2)) + if err != nil || tok != tokenString2 { + t.FailNow() + } + + _, err = tc3.getOrAddToken(uid, noTokFunc) + if err == nil { + t.FailNow() + } + + // Remove one user from all the caches + + mgr.RemoveUserFromCache(uid) + + _, err = tc1.getOrAddToken(uid, noTokFunc) + if err == nil { + t.FailNow() + } + + _, err = tc2.getOrAddToken(uid, noTokFunc) + if err == nil { + t.FailNow() + } + + _, err = tc3.getOrAddToken(uid, noTokFunc) + if err == nil { + t.FailNow() + } +} diff --git a/api/http/proxy/factory/kubernetes/transport.go b/api/http/proxy/factory/kubernetes/transport.go new file mode 100644 index 0000000..b4d06bc --- /dev/null +++ b/api/http/proxy/factory/kubernetes/transport.go @@ -0,0 +1,227 @@ +package kubernetes + +import ( + "bytes" + "fmt" + "io" + "net/http" + "path" + "regexp" + "strconv" + "strings" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/kubernetes/cli" + + "github.com/pkg/errors" + "github.com/rs/zerolog/log" + "github.com/segmentio/encoding/json" +) + +type baseTransport struct { + httpTransport *http.Transport + tokenManager *tokenManager + endpoint *portainer.Endpoint + k8sClientFactory *cli.ClientFactory + dataStore dataservices.DataStore + jwtService portainer.JWTService +} + +func newBaseTransport(httpTransport *http.Transport, tokenManager *tokenManager, endpoint *portainer.Endpoint, k8sClientFactory *cli.ClientFactory, dataStore dataservices.DataStore, jwtService portainer.JWTService) *baseTransport { + return &baseTransport{ + httpTransport: httpTransport, + tokenManager: tokenManager, + endpoint: endpoint, + k8sClientFactory: k8sClientFactory, + dataStore: dataStore, + jwtService: jwtService, + } +} + +// #region KUBERNETES PROXY + +// proxyKubernetesRequest intercepts a Kubernetes API request and apply logic based +// on the requested operation. +func (transport *baseTransport) proxyKubernetesRequest(request *http.Request) (*http.Response, error) { + // URL path examples: + // http://localhost:9000/api/endpoints/3/kubernetes/api/v1/namespaces + // http://localhost:9000/api/endpoints/3/kubernetes/apis/apps/v1/namespaces/default/deployments + apiVersionRe := regexp.MustCompile(`^(/kubernetes)?/(api|apis/apps)/v[0-9](\.[0-9])?`) + requestPath := apiVersionRe.ReplaceAllString(request.URL.Path, "") + + endpointRe := regexp.MustCompile(`([0-9]+)`) + endpointIDMatch := endpointRe.FindAllString(request.RequestURI, 1) + endpointID := 0 + if len(endpointIDMatch) > 0 { + endpointID, _ = strconv.Atoi(endpointIDMatch[0]) + } + + switch { + case strings.EqualFold(requestPath, "/namespaces/portainer/configmaps/portainer-config") && (request.Method == "PUT" || request.Method == "POST"): + transport.k8sClientFactory.ClearClientCache() + defer transport.tokenManager.UpdateUserServiceAccountsForEndpoint(portainer.EndpointID(endpointID)) + return transport.executeKubernetesRequest(request) + case strings.EqualFold(requestPath, "/namespaces"): + return transport.executeKubernetesRequest(request) + case strings.HasPrefix(requestPath, "/namespaces"): + return transport.proxyNamespacedRequest(request, requestPath) + default: + return transport.executeKubernetesRequest(request) + } +} + +func (transport *baseTransport) proxyNamespacedRequest(request *http.Request, fullRequestPath string) (*http.Response, error) { + requestPath := strings.TrimPrefix(fullRequestPath, "/namespaces/") + split := strings.SplitN(requestPath, "/", 2) + namespace := split[0] + + requestPath = "" + if len(split) > 1 { + requestPath = split[1] + } + + switch { + case strings.HasPrefix(requestPath, "pods"): + return transport.proxyPodsRequest(request, namespace) + case strings.HasPrefix(requestPath, "deployments"): + return transport.proxyDeploymentsRequest(request, namespace, requestPath) + case requestPath == "" && request.Method == "DELETE": + return transport.proxyNamespaceDeleteOperation(request, namespace) + default: + return transport.executeKubernetesRequest(request) + } +} + +// addTokenForExec injects a kubeconfig token into the request header +// this is only used with kubeconfig for kubectl exec requests +func (transport *baseTransport) addTokenForExec(request *http.Request) error { + tokenData, err := security.RetrieveTokenData(request) + if err != nil { + return err + } + + token, err := transport.jwtService.GenerateTokenForKubeconfig(tokenData) + if err != nil { + return err + } + + request.Header.Set("Authorization", "Bearer "+token) + return nil +} + +func (transport *baseTransport) executeKubernetesRequest(request *http.Request) (*http.Response, error) { + + resp, err := transport.httpTransport.RoundTrip(request) + + // This fix was made to resolve a k8s e2e test, more detailed investigation should be done later. + if err == nil && resp.StatusCode == http.StatusMovedPermanently { + oldLocation := resp.Header.Get("Location") + if oldLocation != "" { + stripedPrefix := strings.TrimSuffix(request.RequestURI, request.URL.Path) + // local proxy strips "/kubernetes" but agent proxy and edge agent proxy do not + stripedPrefix = strings.TrimSuffix(stripedPrefix, "/kubernetes") + newLocation := stripedPrefix + "/kubernetes" + oldLocation + resp.Header.Set("Location", newLocation) + } + } + + return resp, err +} + +// #endregion + +// #region ROUND TRIP + +func (transport *baseTransport) prepareRoundTrip(request *http.Request) (string, error) { + token, err := transport.getRoundTripToken(request, transport.tokenManager) + if err != nil { + return "", err + } + + request.Header.Set("Authorization", "Bearer "+token) + + return token, nil +} + +// RoundTrip is the implementation of the the http.RoundTripper interface +func (transport *baseTransport) RoundTrip(request *http.Request) (*http.Response, error) { + return transport.proxyKubernetesRequest(request) +} + +func (transport *baseTransport) getRoundTripToken(request *http.Request, tokenManager *tokenManager) (string, error) { + tokenData, err := security.RetrieveTokenData(request) + if err != nil { + return "", err + } + + var token string + if tokenData.Role == portainer.AdministratorRole { + token = tokenManager.GetAdminServiceAccountToken() + } else { + token, err = tokenManager.GetUserServiceAccountToken(int(tokenData.ID), transport.endpoint.ID) + if err != nil { + log.Error(). + Err(err). + Msg("failed retrieving service account token") + + return "", err + } + } + + return token, nil +} + +// #endregion + +// #region DECORATE FUNCTIONS + +func decorateAgentRequest(r *http.Request, dataStore dataservices.DataStore) error { + requestPath := strings.TrimPrefix(r.URL.Path, "/v2") + + if strings.HasPrefix(requestPath, "/dockerhub") { + return decorateAgentDockerHubRequest(r, dataStore) + } + + return nil +} + +func decorateAgentDockerHubRequest(r *http.Request, dataStore dataservices.DataStore) error { + requestPath, registryIdString := path.Split(r.URL.Path) + + registryID, err := strconv.Atoi(registryIdString) + if err != nil { + return fmt.Errorf("missing registry id: %w", err) + } + + r.URL.Path = strings.TrimSuffix(requestPath, "/") + + registry := &portainer.Registry{ + Type: portainer.DockerHubRegistry, + } + + if registryID != 0 { + registry, err = dataStore.Registry().Read(portainer.RegistryID(registryID)) + if err != nil { + return fmt.Errorf("failed fetching registry: %w", err) + } + } + + if registry.Type != portainer.DockerHubRegistry { + return errors.New("invalid registry type") + } + + newBody, err := json.Marshal(registry) + if err != nil { + return fmt.Errorf("failed marshaling registry: %w", err) + } + + r.Method = http.MethodPost + r.Body = io.NopCloser(bytes.NewReader(newBody)) + r.ContentLength = int64(len(newBody)) + + return nil +} + +// #endregion diff --git a/api/http/proxy/factory/kubernetes/transport_test.go b/api/http/proxy/factory/kubernetes/transport_test.go new file mode 100644 index 0000000..9eb32f8 --- /dev/null +++ b/api/http/proxy/factory/kubernetes/transport_test.go @@ -0,0 +1,367 @@ +package kubernetes + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/jwt" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// MockJWTService implements portainer.JWTService for testing +type MockJWTService struct { + generateTokenFunc func(data *portainer.TokenData) (string, error) +} + +func (m *MockJWTService) GenerateToken(data *portainer.TokenData) (string, time.Time, error) { + if m.generateTokenFunc != nil { + token, err := m.generateTokenFunc(data) + + return token, time.Now().Add(24 * time.Hour), err + } + + return "mock-token", time.Now().Add(24 * time.Hour), nil +} + +func (m *MockJWTService) GenerateTokenForKubeconfig(data *portainer.TokenData) (string, error) { + if m.generateTokenFunc != nil { + return m.generateTokenFunc(data) + } + + return "mock-kubeconfig-token", nil +} + +func (m *MockJWTService) ParseAndVerifyToken(token string) (*portainer.TokenData, string, time.Time, error) { + return &portainer.TokenData{ID: 1, Username: "mock", Role: portainer.AdministratorRole}, "mock-id", time.Now().Add(24 * time.Hour), nil +} + +func (m *MockJWTService) SetUserSessionDuration(userSessionDuration time.Duration) { + // Mock implementation - not used in tests +} + +func TestBaseTransport_AddTokenForExec(t *testing.T) { + t.Parallel() + // Setup test store and JWT service + _, store := datastore.MustNewTestStore(t, true, false) + + // Create test users + adminUser := &portainer.User{ + ID: 1, + Username: "admin", + Role: portainer.AdministratorRole, + } + err := store.User().Create(adminUser) + require.NoError(t, err) + + standardUser := &portainer.User{ + ID: 2, + Username: "standard", + Role: portainer.StandardUserRole, + } + err = store.User().Create(standardUser) + require.NoError(t, err) + + // Create JWT service + jwtService, err := jwt.NewService("24h", store) + require.NoError(t, err) + + // Create base transport + transport := &baseTransport{ + jwtService: jwtService, + } + + tests := []struct { + name string + tokenData *portainer.TokenData + setupRequest func(*http.Request) *http.Request + expectError bool + errorMsg string + expectPanic bool + verifyResponse func(*testing.T, *http.Request, *portainer.TokenData) + }{ + { + name: "admin user - successful token generation", + tokenData: &portainer.TokenData{ + ID: adminUser.ID, + Username: adminUser.Username, + Role: adminUser.Role, + }, + setupRequest: func(req *http.Request) *http.Request { + return req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ + ID: adminUser.ID, + Username: adminUser.Username, + Role: adminUser.Role, + })) + }, + expectError: false, + verifyResponse: func(t *testing.T, req *http.Request, tokenData *portainer.TokenData) { + authHeader := req.Header.Get("Authorization") + assert.NotEmpty(t, authHeader) + assert.True(t, strings.HasPrefix(authHeader, "Bearer ")) + + token := authHeader[7:] // Remove "Bearer " prefix + parsedTokenData, _, _, err := jwtService.ParseAndVerifyToken(token) + require.NoError(t, err) + assert.Equal(t, tokenData.ID, parsedTokenData.ID) + assert.Equal(t, tokenData.Username, parsedTokenData.Username) + assert.Equal(t, tokenData.Role, parsedTokenData.Role) + }, + }, + { + name: "standard user - successful token generation", + tokenData: &portainer.TokenData{ + ID: standardUser.ID, + Username: standardUser.Username, + Role: standardUser.Role, + }, + setupRequest: func(req *http.Request) *http.Request { + return req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ + ID: standardUser.ID, + Username: standardUser.Username, + Role: standardUser.Role, + })) + }, + expectError: false, + verifyResponse: func(t *testing.T, req *http.Request, tokenData *portainer.TokenData) { + authHeader := req.Header.Get("Authorization") + assert.NotEmpty(t, authHeader) + assert.True(t, strings.HasPrefix(authHeader, "Bearer ")) + + token := authHeader[7:] // Remove "Bearer " prefix + parsedTokenData, _, _, err := jwtService.ParseAndVerifyToken(token) + require.NoError(t, err) + assert.Equal(t, tokenData.ID, parsedTokenData.ID) + assert.Equal(t, tokenData.Username, parsedTokenData.Username) + assert.Equal(t, tokenData.Role, parsedTokenData.Role) + }, + }, + { + name: "request without token data in context", + tokenData: nil, + setupRequest: func(req *http.Request) *http.Request { + return req // Don't add token data to context + }, + expectError: true, + errorMsg: "Unable to find JWT data in request context", + }, + { + name: "request with nil token data", + tokenData: nil, + setupRequest: func(req *http.Request) *http.Request { + return req.WithContext(security.StoreTokenData(req, nil)) + }, + expectPanic: true, + }, + { + name: "JWT service failure", + tokenData: &portainer.TokenData{ + ID: 1, + Username: "test", + Role: portainer.AdministratorRole, + }, + setupRequest: func(req *http.Request) *http.Request { + return req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ + ID: 1, + Username: "test", + Role: portainer.AdministratorRole, + })) + }, + expectPanic: true, + }, + { + name: "verify authorization header format", + tokenData: &portainer.TokenData{ + ID: adminUser.ID, + Username: adminUser.Username, + Role: adminUser.Role, + }, + setupRequest: func(req *http.Request) *http.Request { + return req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ + ID: adminUser.ID, + Username: adminUser.Username, + Role: adminUser.Role, + })) + }, + expectError: false, + verifyResponse: func(t *testing.T, req *http.Request, tokenData *portainer.TokenData) { + authHeader := req.Header.Get("Authorization") + assert.NotEmpty(t, authHeader) + assert.True(t, strings.HasPrefix(authHeader, "Bearer ")) + + token := authHeader[7:] // Remove "Bearer " prefix + assert.NotEmpty(t, token) + }, + }, + { + name: "verify header is overwritten on subsequent calls", + tokenData: &portainer.TokenData{ + ID: adminUser.ID, + Username: adminUser.Username, + Role: adminUser.Role, + }, + setupRequest: func(req *http.Request) *http.Request { + req = req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ + ID: adminUser.ID, + Username: adminUser.Username, + Role: adminUser.Role, + })) + // Set an existing Authorization header + req.Header.Set("Authorization", "Bearer old-token") + + return req + }, + expectError: false, + verifyResponse: func(t *testing.T, req *http.Request, tokenData *portainer.TokenData) { + authHeader := req.Header.Get("Authorization") + assert.NotEqual(t, "Bearer old-token", authHeader) + assert.True(t, strings.HasPrefix(authHeader, "Bearer ")) + + token := authHeader[7:] // Remove "Bearer " prefix + parsedTokenData, _, _, err := jwtService.ParseAndVerifyToken(token) + require.NoError(t, err) + assert.Equal(t, tokenData.ID, parsedTokenData.ID) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create request + request := httptest.NewRequest("GET", "/", nil) + request = tt.setupRequest(request) + + // Determine which transport to use based on test case + var testTransport *baseTransport + if tt.name == "JWT service failure" { + testTransport = &baseTransport{ + jwtService: nil, + } + } else { + testTransport = transport + } + + // Call the function + if tt.expectPanic { + assert.Panics(t, func() { + _ = testTransport.addTokenForExec(request) + }) + return + } + + err := testTransport.addTokenForExec(request) + + // Check results + if tt.expectError { + require.Error(t, err) + if tt.errorMsg != "" { + assert.Contains(t, err.Error(), tt.errorMsg) + } + } else { + require.NoError(t, err) + if tt.verifyResponse != nil { + tt.verifyResponse(t, request, tt.tokenData) + } + } + }) + } +} + +func TestBaseTransport_AddTokenForExec_Integration(t *testing.T) { + t.Parallel() + // Create a test HTTP server to capture requests + var capturedRequest *http.Request + var capturedHeaders http.Header + + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedRequest = r + capturedHeaders = r.Header.Clone() + _, _ = w.Write([]byte("success")) + })) + defer testServer.Close() + + // Create mock JWT service + mockJWTService := &MockJWTService{ + generateTokenFunc: func(data *portainer.TokenData) (string, error) { + return "mock-token-" + data.Username, nil + }, + } + + // Create base transport + transport := &baseTransport{ + httpTransport: &http.Transport{}, + jwtService: mockJWTService, + } + + tests := []struct { + name string + tokenData *portainer.TokenData + requestPath string + expectedToken string + }{ + { + name: "admin user exec request", + tokenData: &portainer.TokenData{ + ID: 1, + Username: "admin", + Role: portainer.AdministratorRole, + }, + requestPath: "/api/endpoints/1/kubernetes/api/v1/namespaces/default/pods/test-pod/exec", + expectedToken: "mock-token-admin", + }, + { + name: "standard user exec request", + tokenData: &portainer.TokenData{ + ID: 2, + Username: "standard", + Role: portainer.StandardUserRole, + }, + requestPath: "/api/endpoints/1/kubernetes/api/v1/namespaces/default/pods/test-pod/exec", + expectedToken: "mock-token-standard", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Reset captured data + capturedRequest = nil + capturedHeaders = nil + + // Create request to the test server + request, err := http.NewRequest("POST", testServer.URL+tt.requestPath, strings.NewReader("")) + require.NoError(t, err) + + // Add token data to request context + request = request.WithContext(security.StoreTokenData(request, tt.tokenData)) + + // Call proxyPodsRequest which triggers addTokenForExec for POST /exec requests + resp, err := transport.proxyPodsRequest(request, "default") + require.NoError(t, err) + defer func() { + err := resp.Body.Close() + require.NoError(t, err) + }() + + // Verify the response + assert.Equal(t, http.StatusOK, resp.StatusCode) + + // Verify the request was captured + assert.NotNil(t, capturedRequest) + assert.Equal(t, "POST", capturedRequest.Method) + assert.Equal(t, tt.requestPath, capturedRequest.URL.Path) + + // Verify the authorization header was set correctly + capturedAuthHeader := capturedHeaders.Get("Authorization") + assert.NotEmpty(t, capturedAuthHeader) + assert.True(t, strings.HasPrefix(capturedAuthHeader, "Bearer ")) + assert.Equal(t, "Bearer "+tt.expectedToken, capturedAuthHeader) + }) + } +} diff --git a/api/http/proxy/factory/reverse_proxy.go b/api/http/proxy/factory/reverse_proxy.go new file mode 100644 index 0000000..3c4340b --- /dev/null +++ b/api/http/proxy/factory/reverse_proxy.go @@ -0,0 +1,88 @@ +package factory + +import ( + "net/http" + "net/http/httputil" + "net/url" + "strings" + + httperror "github.com/portainer/portainer/pkg/libhttp/error" +) + +// Note that we discard any non-canonical headers by design +var allowedHeaders = map[string]struct{}{ + "Accept": {}, + "Accept-Encoding": {}, + "Accept-Language": {}, + "Cache-Control": {}, + "Connection": {}, + "Content-Length": {}, + "Content-Type": {}, + "Private-Token": {}, + "Upgrade": {}, + "User-Agent": {}, + "X-Portaineragent-Target": {}, + "X-Portainer-Volumename": {}, + "X-Registry-Auth": {}, + "X-Stream-Protocol-Version": {}, + // WebSocket headers those are required for kubectl exec/attach/port-forward operations + "Sec-Websocket-Key": {}, + "Sec-Websocket-Version": {}, + "Sec-Websocket-Protocol": {}, + "Sec-Websocket-Extensions": {}, +} + +// newSingleHostReverseProxyWithHostHeader is based on NewSingleHostReverseProxy +// from golang.org/src/net/http/httputil/reverseproxy.go and merely sets the Host +// HTTP header, which NewSingleHostReverseProxy deliberately preserves. +func NewSingleHostReverseProxyWithHostHeader(target *url.URL) *httputil.ReverseProxy { + proxy := &httputil.ReverseProxy{Rewrite: createRewriteFn(target)} + + proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) { + httperror.WriteError(w, http.StatusBadGateway, "Proxy failure", err) + } + + return proxy +} + +func createRewriteFn(target *url.URL) func(*httputil.ProxyRequest) { + targetQuery := target.RawQuery + return func(proxyReq *httputil.ProxyRequest) { + proxyReq.Out.URL.Scheme = target.Scheme + proxyReq.Out.URL.Host = target.Host + proxyReq.Out.URL.Path = singleJoiningSlash(target.Path, proxyReq.In.URL.Path) + proxyReq.Out.URL.RawPath = "" + proxyReq.Out.Host = proxyReq.Out.URL.Host + if targetQuery == "" || proxyReq.Out.URL.RawQuery == "" { + proxyReq.Out.URL.RawQuery = targetQuery + proxyReq.Out.URL.RawQuery + } else { + proxyReq.Out.URL.RawQuery = targetQuery + "&" + proxyReq.Out.URL.RawQuery + } + if _, ok := proxyReq.Out.Header["User-Agent"]; !ok { + // explicitly disable User-Agent so it's not set to default value + proxyReq.Out.Header.Set("User-Agent", "") + } + + for k := range proxyReq.Out.Header { + if _, ok := allowedHeaders[k]; !ok { + // We use delete here instead of req.Header.Del because we want to delete non canonical headers. + delete(proxyReq.Out.Header, k) + } + } + } +} + +// singleJoiningSlash from golang.org/src/net/http/httputil/reverseproxy.go +// included here for use in NewSingleHostReverseProxyWithHostHeader +// because its used in NewSingleHostReverseProxy from golang.org/src/net/http/httputil/reverseproxy.go +func singleJoiningSlash(a, b string) string { + aslash := strings.HasSuffix(a, "/") + bslash := strings.HasPrefix(b, "/") + switch { + case aslash && bslash: + return a + b[1:] + case !aslash && !bslash: + return a + "/" + b + } + return a + b +} diff --git a/api/http/proxy/factory/reverse_proxy_test.go b/api/http/proxy/factory/reverse_proxy_test.go new file mode 100644 index 0000000..7fc9825 --- /dev/null +++ b/api/http/proxy/factory/reverse_proxy_test.go @@ -0,0 +1,227 @@ +package factory + +import ( + "net/http" + "net/http/httptest" + "net/http/httputil" + "net/url" + "testing" + + portainer "github.com/portainer/portainer/api" + + "github.com/google/go-cmp/cmp" + "github.com/stretchr/testify/require" +) + +func Test_createRewriteFn(t *testing.T) { + t.Parallel() + testCases := []struct { + name string + target *url.URL + req *http.Request + expectedReq *http.Request + }{ + { + name: "base case", + target: createURL(t, "https://portainer.io/api/docker?a=5&b=6"), + req: createRequest( + t, + "GET", + "https://agent-portainer.io/test?c=7", + map[string]string{"Accept-Encoding": "gzip", "Accept": "application/json", "User-Agent": "something"}, + true, + ), + expectedReq: createRequest( + t, + "GET", + "https://portainer.io/api/docker/test?a=5&b=6&c=7", + map[string]string{"Accept-Encoding": "gzip", "Accept": "application/json", "User-Agent": "something"}, + true, + ), + }, + { + name: "no User-Agent", + target: createURL(t, "https://portainer.io/api/docker?a=5&b=6"), + req: createRequest( + t, + "GET", + "https://agent-portainer.io/test?c=7", + map[string]string{"Accept-Encoding": "gzip", "Accept": "application/json"}, + true, + ), + expectedReq: createRequest( + t, + "GET", + "https://portainer.io/api/docker/test?a=5&b=6&c=7", + map[string]string{"Accept-Encoding": "gzip", "Accept": "application/json", "User-Agent": ""}, + true, + ), + }, + { + name: "Sensitive Headers", + target: createURL(t, "https://portainer.io/api/docker?a=5&b=6"), + req: createRequest( + t, + "GET", + "https://agent-portainer.io/test?c=7", + map[string]string{ + "Authorization": "secret", + "Proxy-Authorization": "secret", + "Cookie": "secret", + "X-Csrf-Token": "secret", + "X-Api-Key": "secret", + "Accept": "application/json", + "Accept-Encoding": "gzip", + "Accept-Language": "en-GB", + "Cache-Control": "None", + "Content-Length": "100", + "Content-Type": "application/json", + "Private-Token": "test-private-token", + "User-Agent": "test-user-agent", + "X-Portaineragent-Target": "test-agent-1", + "X-Portainer-Volumename": "test-volume-1", + "X-Registry-Auth": "test-registry-auth", + }, + true, + ), + expectedReq: createRequest( + t, + "GET", + "https://portainer.io/api/docker/test?a=5&b=6&c=7", + map[string]string{ + "Accept": "application/json", + "Accept-Encoding": "gzip", + "Accept-Language": "en-GB", + "Cache-Control": "None", + "Content-Length": "100", + "Content-Type": "application/json", + "Private-Token": "test-private-token", + "User-Agent": "test-user-agent", + "X-Portaineragent-Target": "test-agent-1", + "X-Portainer-Volumename": "test-volume-1", + "X-Registry-Auth": "test-registry-auth", + }, + true, + ), + }, + { + name: "Non canonical Headers", + target: createURL(t, "https://portainer.io/api/docker?a=5&b=6"), + req: createRequest( + t, + "GET", + "https://agent-portainer.io/test?c=7", + map[string]string{ + "Accept": "application/json", + "Accept-Encoding": "gzip", + "Accept-Language": "en-GB", + "Cache-Control": "None", + "Content-Length": "100", + "Content-Type": "application/json", + "Private-Token": "test-private-token", + "User-Agent": "test-user-agent", + portainer.PortainerAgentTargetHeader: "test-agent-1", + "X-Portainer-VolumeName": "test-volume-1", + "X-Registry-Auth": "test-registry-auth", + }, + false, + ), + expectedReq: createRequest( + t, + "GET", + "https://portainer.io/api/docker/test?a=5&b=6&c=7", + map[string]string{ + "Accept": "application/json", + "Accept-Encoding": "gzip", + "Accept-Language": "en-GB", + "Cache-Control": "None", + "Content-Length": "100", + "Content-Type": "application/json", + "Private-Token": "test-private-token", + "User-Agent": "test-user-agent", + "X-Registry-Auth": "test-registry-auth", + }, + true, + ), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + rewriteFn := createRewriteFn(tc.target) + proxyRequest := httputil.ProxyRequest{ + In: tc.req.Clone(t.Context()), + Out: tc.req.Clone(t.Context()), + } + rewriteFn(&proxyRequest) + + if diff := cmp.Diff(proxyRequest.In, tc.req, cmp.Comparer(compareRequests)); diff != "" { + t.Fatalf("rewriteFn modified in request: \n%s", diff) + } + + if diff := cmp.Diff(proxyRequest.Out, tc.expectedReq, cmp.Comparer(compareRequests)); diff != "" { + t.Fatalf("requests are different: \n%s", diff) + } + }) + } +} + +func createURL(t *testing.T, urlString string) *url.URL { + parsedURL, err := url.Parse(urlString) + if err != nil { + t.Fatalf("Failed to create url: %s", err) + } + + return parsedURL +} + +func createRequest(t *testing.T, method, url string, headers map[string]string, canonicalHeaders bool) *http.Request { + req, err := http.NewRequest(method, url, nil) + if err != nil { + t.Fatalf("Failed to create http request: %s", err) + } else { + for k, v := range headers { + if canonicalHeaders { + req.Header.Add(k, v) + } else { + req.Header[k] = []string{v} + } + } + } + + return req +} + +func compareRequests(a, b *http.Request) bool { + methodEqual := a.Method == b.Method + urlEqual := cmp.Diff(a.URL, b.URL) == "" + hostEqual := a.Host == b.Host + protoEqual := a.Proto == b.Proto && a.ProtoMajor == b.ProtoMajor && a.ProtoMinor == b.ProtoMinor + headersEqual := cmp.Diff(a.Header, b.Header) == "" + + return methodEqual && urlEqual && hostEqual && protoEqual && headersEqual +} + +func Test_createRewriteFn_staleRawPath(t *testing.T) { + t.Parallel() + + var receivedURI string + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedURI = r.RequestURI + w.WriteHeader(http.StatusOK) + })) + defer backend.Close() + + target, err := url.Parse(backend.URL) + require.NoError(t, err) + + proxy := &httputil.ReverseProxy{Rewrite: createRewriteFn(target)} + frontend := httptest.NewServer(proxy) + defer frontend.Close() + + resp, err := http.Get(frontend.URL + "/%63ontainers/json") + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + + require.Equal(t, "/containers/json", receivedURI) +} diff --git a/api/http/proxy/factory/transport_test.go b/api/http/proxy/factory/transport_test.go new file mode 100644 index 0000000..ff5b38b --- /dev/null +++ b/api/http/proxy/factory/transport_test.go @@ -0,0 +1,224 @@ +package factory + +import ( + "context" + "net/http" + "net/http/httptest" + "net/http/httputil" + "testing" + "time" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/proxy/factory/docker" + "github.com/portainer/portainer/pkg/fips" + "github.com/portainer/portainer/pkg/libhttp/ssrf" + + "github.com/stretchr/testify/require" +) + +func init() { + fips.InitFIPS(false) +} + +type stubTunnelService struct{} + +func (s *stubTunnelService) StartTunnelServer(addr, port string, snapshotService portainer.SnapshotService) error { + return nil +} + +func (s *stubTunnelService) StopTunnelServer() error { return nil } + +func (s *stubTunnelService) GenerateEdgeKey(apiURL, tunnelAddr string, endpointIdentifier int) string { + return "" +} + +func (s *stubTunnelService) Open(endpoint *portainer.Endpoint) error { return nil } + +func (s *stubTunnelService) Config(endpointID portainer.EndpointID) portainer.TunnelDetails { + return portainer.TunnelDetails{} +} + +func (s *stubTunnelService) TunnelAddr(endpoint *portainer.Endpoint) (string, error) { + return "127.0.0.1:9999", nil +} + +func (s *stubTunnelService) UpdateLastActivity(endpointID portainer.EndpointID) {} + +func (s *stubTunnelService) KeepTunnelAlive(endpointID portainer.EndpointID, ctx context.Context, maxKeepAlive time.Duration) { +} + +type staticAllowListService struct { + parsed portainer.ParsedAllowList +} + +func (s *staticAllowListService) ReadParsed(id portainer.AllowListKey) (*portainer.ParsedAllowList, error) { + return &s.parsed, nil +} + +func enableSSRF(t *testing.T) { + t.Helper() + parsed := ssrf.ParseAllowedHosts([]string{"example.com"}) + parsed.Mode = portainer.SSRFModeEnforce + err := ssrf.Configure(&staticAllowListService{parsed: parsed}) + require.NoError(t, err) + t.Cleanup(func() { + err := ssrf.Configure(&staticAllowListService{}) + require.NoError(t, err) + }) +} + +func TestNewDockerHTTPProxy_NonEdgeNoTLS(t *testing.T) { + enableSSRF(t) + + f := &ProxyFactory{reverseTunnelService: &stubTunnelService{}} + endpoint := &portainer.Endpoint{ + Type: portainer.DockerEnvironment, + URL: "tcp://192.168.1.100:2376", + } + + handler, err := f.newDockerHTTPProxy(endpoint) + require.NoError(t, err) + + proxy := handler.(*httputil.ReverseProxy) + dt := proxy.Transport.(*docker.Transport) + require.NotNil(t, dt.HTTPTransport.DialContext) +} + +func TestNewDockerHTTPProxy_NonEdgeTLS(t *testing.T) { + enableSSRF(t) + + f := &ProxyFactory{reverseTunnelService: &stubTunnelService{}} + endpoint := &portainer.Endpoint{ + Type: portainer.DockerEnvironment, + URL: "tcp://192.168.1.100:2376", + TLSConfig: portainer.TLSConfiguration{ + TLS: true, + TLSSkipVerify: true, + }, + } + + handler, err := f.newDockerHTTPProxy(endpoint) + require.NoError(t, err) + + proxy := handler.(*httputil.ReverseProxy) + dt := proxy.Transport.(*docker.Transport) + require.NotNil(t, dt.HTTPTransport.DialContext) +} + +func TestNewDockerHTTPProxy_EdgeNoTLS(t *testing.T) { + enableSSRF(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + f := &ProxyFactory{reverseTunnelService: &stubTunnelService{}} + endpoint := &portainer.Endpoint{ + Type: portainer.EdgeAgentOnDockerEnvironment, + URL: "tcp://192.168.1.100:2376", + } + + handler, err := f.newDockerHTTPProxy(endpoint) + require.NoError(t, err) + + proxy := handler.(*httputil.ReverseProxy) + dt := proxy.Transport.(*docker.Transport) + + resp, err := (&http.Client{Transport: dt.HTTPTransport}).Get(srv.URL) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) +} + +func TestNewDockerHTTPProxy_EdgeTLS(t *testing.T) { + enableSSRF(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + f := &ProxyFactory{reverseTunnelService: &stubTunnelService{}} + endpoint := &portainer.Endpoint{ + Type: portainer.EdgeAgentOnDockerEnvironment, + URL: "tcp://192.168.1.100:2376", + TLSConfig: portainer.TLSConfiguration{ + TLS: true, + TLSSkipVerify: true, + }, + } + + handler, err := f.newDockerHTTPProxy(endpoint) + require.NoError(t, err) + + proxy := handler.(*httputil.ReverseProxy) + dt := proxy.Transport.(*docker.Transport) + + resp, err := (&http.Client{Transport: dt.HTTPTransport}).Get(srv.URL) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) +} + +func TestNewAgentProxy_NonEdgeNoTLS(t *testing.T) { + f := &ProxyFactory{reverseTunnelService: &stubTunnelService{}} + endpoint := &portainer.Endpoint{ + Type: portainer.AgentOnDockerEnvironment, + URL: "tcp://192.168.1.100:9001", + } + + proxyServer, err := f.NewAgentProxy(endpoint) + require.NoError(t, err) + defer proxyServer.Close() + + require.Positive(t, proxyServer.Port) +} + +func TestNewAgentProxy_NonEdgeTLS(t *testing.T) { + f := &ProxyFactory{reverseTunnelService: &stubTunnelService{}} + endpoint := &portainer.Endpoint{ + Type: portainer.AgentOnDockerEnvironment, + URL: "tcp://192.168.1.100:9001", + TLSConfig: portainer.TLSConfiguration{ + TLS: true, + TLSSkipVerify: true, + }, + } + + proxyServer, err := f.NewAgentProxy(endpoint) + require.NoError(t, err) + defer proxyServer.Close() + + require.Positive(t, proxyServer.Port) +} + +func TestNewAgentProxy_EdgeNoTLS(t *testing.T) { + f := &ProxyFactory{reverseTunnelService: &stubTunnelService{}} + endpoint := &portainer.Endpoint{ + Type: portainer.EdgeAgentOnDockerEnvironment, + URL: "tcp://192.168.1.100:9001", + } + + proxyServer, err := f.NewAgentProxy(endpoint) + require.NoError(t, err) + defer proxyServer.Close() + + require.Positive(t, proxyServer.Port) +} + +func TestNewAgentProxy_EdgeTLS(t *testing.T) { + f := &ProxyFactory{reverseTunnelService: &stubTunnelService{}} + endpoint := &portainer.Endpoint{ + Type: portainer.EdgeAgentOnDockerEnvironment, + URL: "tcp://192.168.1.100:9001", + TLSConfig: portainer.TLSConfiguration{ + TLS: true, + TLSSkipVerify: true, + }, + } + + proxyServer, err := f.NewAgentProxy(endpoint) + require.NoError(t, err) + defer proxyServer.Close() + + require.Positive(t, proxyServer.Port) +} diff --git a/api/http/proxy/factory/utils/json.go b/api/http/proxy/factory/utils/json.go new file mode 100644 index 0000000..9aa37fd --- /dev/null +++ b/api/http/proxy/factory/utils/json.go @@ -0,0 +1,94 @@ +package utils + +import ( + "compress/gzip" + "errors" + "fmt" + "io" + "mime" + + "github.com/portainer/portainer/api/logs" + "github.com/segmentio/encoding/json" + "go.yaml.in/yaml/v3" +) + +// GetJSONObject will extract an object from a specific property of another JSON object. +// Returns nil if nothing is associated to the specified key. +func GetJSONObject(jsonObject map[string]any, property string) map[string]any { + object := jsonObject[property] + if object != nil { + return object.(map[string]any) + } + return nil +} + +// GetArrayObject will extract an array from a specific property of another JSON object. +// Returns nil if nothing is associated to the specified key. +func GetArrayObject(jsonObject map[string]any, property string) []any { + object := jsonObject[property] + if object != nil { + return object.([]any) + } + return nil +} + +func getBody(body io.ReadCloser, contentType string, isGzip bool) (any, error) { + if body == nil { + return nil, errors.New("unable to parse response: empty response body") + } + + reader := body + + if isGzip { + gzipReader, err := gzip.NewReader(reader) + if err != nil { + return nil, err + } + + reader = gzipReader + } + + defer logs.CloseAndLogErr(reader) + + var data any + err := unmarshal(contentType, reader, &data) + if err != nil { + return nil, err + } + + return data, nil +} + +func marshal(contentType string, data any) ([]byte, error) { + // Note: contentType can look like: "application/json" or "application/json; charset=utf-8" + mediaType, _, err := mime.ParseMediaType(contentType) + if err != nil { + return nil, err + } + + switch mediaType { + case "application/yaml": + return yaml.Marshal(data) + case "application/json", "": + return json.Marshal(data) + } + + return nil, fmt.Errorf("content type is not supported for marshaling: %s", contentType) +} + +func unmarshal(contentType string, body io.Reader, returnBody any) error { + // Note: contentType can look like: "application/json" or "application/json; charset=utf-8" + mediaType, _, err := mime.ParseMediaType(contentType) + if err != nil { + return err + } + + switch mediaType { + case "application/yaml": + return yaml.NewDecoder(body).Decode(returnBody) + case "application/json", "": + return json.NewDecoder(body).Decode(returnBody) + } + + return fmt.Errorf("content type is not supported for unmarshaling: %s", contentType) +} diff --git a/api/http/proxy/factory/utils/response.go b/api/http/proxy/factory/utils/response.go new file mode 100644 index 0000000..4e8aac8 --- /dev/null +++ b/api/http/proxy/factory/utils/response.go @@ -0,0 +1,113 @@ +package utils + +import ( + "bytes" + "fmt" + "io" + "net/http" + "strconv" + + "github.com/pkg/errors" + "github.com/rs/zerolog/log" +) + +// GetResponseAsJSONObject returns the response content as a generic JSON object +func GetResponseAsJSONObject(response *http.Response) (map[string]any, error) { + responseData, err := getResponseBody(response) + if err != nil { + return nil, err + } + + responseObject, ok := responseData.(map[string]any) + if !ok { + return nil, nil + } + return responseObject, nil +} + +// GetResponseAsJSONArray returns the response content as an array of generic JSON object +func GetResponseAsJSONArray(response *http.Response) ([]any, error) { + responseData, err := getResponseBody(response) + if err != nil { + return nil, err + } + if responseData == nil { + return nil, nil + } + + switch responseObject := responseData.(type) { + case []any: + return responseObject, nil + case map[string]any: + if responseObject["message"] != nil { + return nil, errors.New(responseObject["message"].(string)) + } + + log.Error(). + Str("response", fmt.Sprintf("%+v", responseObject)). + Msg("invalid response format, expecting JSON array") + + return nil, errors.New("unable to parse response: expected JSON array, got JSON object") + default: + log.Error(). + Str("response", fmt.Sprintf("%+v", responseObject)). + Msg("invalid response format, expecting JSON array") + + return nil, errors.New("unable to parse response: expected JSON array") + } +} + +type errorResponse struct { + Message string `json:"message,omitempty"` +} + +// WriteAccessDeniedResponse will create a new access denied response +func WriteAccessDeniedResponse() (*http.Response, error) { + header := http.Header{} + header.Add("Content-Type", "application/json") + + response := &http.Response{Header: header} + err := RewriteResponse(response, errorResponse{Message: "access denied to resource"}, http.StatusForbidden) + + return response, err +} + +// RewriteAccessDeniedResponse will overwrite the existing response with an access denied response +func RewriteAccessDeniedResponse(response *http.Response) error { + return RewriteResponse(response, errorResponse{Message: "access denied to resource"}, http.StatusForbidden) +} + +// RewriteResponse will replace the existing response body and status code with the one specified +// in parameters +func RewriteResponse(response *http.Response, newResponseData any, statusCode int) error { + data, err := marshal(getContentType(response), newResponseData) + if err != nil { + return err + } + + body := io.NopCloser(bytes.NewReader(data)) + + response.StatusCode = statusCode + response.Body = body + response.ContentLength = int64(len(data)) + + if response.Header == nil { + response.Header = make(http.Header) + } + response.Header.Set("Content-Length", strconv.Itoa(len(data))) + + return nil +} + +func getResponseBody(response *http.Response) (any, error) { + isGzip := response.Header.Get("Content-Encoding") == "gzip" + if isGzip { + response.Header.Del("Content-Encoding") + } + + return getBody(response.Body, getContentType(response), isGzip) +} + +func getContentType(response *http.Response) string { + return response.Header.Get("Content-type") +} diff --git a/api/http/proxy/factory/utils/response_test.go b/api/http/proxy/factory/utils/response_test.go new file mode 100644 index 0000000..6b82b1f --- /dev/null +++ b/api/http/proxy/factory/utils/response_test.go @@ -0,0 +1,22 @@ +package utils + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestWriteAccessDeniedResponse(t *testing.T) { + t.Parallel() + r, err := WriteAccessDeniedResponse() + require.NoError(t, err) + defer func() { + err = r.Body.Close() + require.NoError(t, err) + }() + + require.NotNil(t, r) + require.Equal(t, "application/json", r.Header.Get("content-type")) + require.Equal(t, http.StatusForbidden, r.StatusCode) +} diff --git a/api/http/proxy/manager.go b/api/http/proxy/manager.go new file mode 100644 index 0000000..477bc54 --- /dev/null +++ b/api/http/proxy/manager.go @@ -0,0 +1,94 @@ +package proxy + +import ( + "errors" + "fmt" + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + dockerclient "github.com/portainer/portainer/api/docker/client" + "github.com/portainer/portainer/api/http/proxy/factory" + "github.com/portainer/portainer/api/http/proxy/factory/kubernetes" + "github.com/portainer/portainer/api/kubernetes/cli" + + cmap "github.com/orcaman/concurrent-map" +) + +var ErrProxyFactoryNotInitialized = errors.New("proxy factory not initialized") + +// Manager represents a service used to manage proxies to environments (endpoints) and extensions. +type Manager struct { + proxyFactory *factory.ProxyFactory + endpointProxies cmap.ConcurrentMap + k8sClientFactory *cli.ClientFactory +} + +// NewManager initializes a new proxy Service +func NewManager(kubernetesClientFactory *cli.ClientFactory) *Manager { + return &Manager{ + endpointProxies: cmap.New(), + k8sClientFactory: kubernetesClientFactory, + } +} + +func (manager *Manager) NewProxyFactory(dataStore dataservices.DataStore, signatureService portainer.DigitalSignatureService, tunnelService portainer.ReverseTunnelService, clientFactory *dockerclient.ClientFactory, kubernetesClientFactory *cli.ClientFactory, kubernetesTokenCacheManager *kubernetes.TokenCacheManager, gitService portainer.GitService, snapshotService portainer.SnapshotService, jwtService portainer.JWTService) { + manager.proxyFactory = factory.NewProxyFactory(dataStore, signatureService, tunnelService, clientFactory, kubernetesClientFactory, kubernetesTokenCacheManager, gitService, snapshotService, jwtService) +} + +// CreateAndRegisterEndpointProxy creates a new HTTP reverse proxy based on environment(endpoint) properties and adds it to the registered proxies. +// It can also be used to create a new HTTP reverse proxy and replace an already registered proxy. +func (manager *Manager) CreateAndRegisterEndpointProxy(endpoint *portainer.Endpoint) (http.Handler, error) { + if manager.proxyFactory == nil { + return nil, ErrProxyFactoryNotInitialized + } + + proxy, err := manager.proxyFactory.NewEndpointProxy(endpoint) + if err != nil { + return nil, err + } + + manager.endpointProxies.Set(fmt.Sprint(endpoint.ID), proxy) + + return proxy, nil +} + +// CreateAgentProxyServer creates a new HTTP reverse proxy based on environment(endpoint) properties and and adds it to the registered proxies. +// It can also be used to create a new HTTP reverse proxy and replace an already registered proxy. +func (manager *Manager) CreateAgentProxyServer(endpoint *portainer.Endpoint) (*factory.ProxyServer, error) { + if manager.proxyFactory == nil { + return nil, ErrProxyFactoryNotInitialized + } + + return manager.proxyFactory.NewAgentProxy(endpoint) +} + +// GetEndpointProxy returns the proxy associated to a key +func (manager *Manager) GetEndpointProxy(endpoint *portainer.Endpoint) http.Handler { + proxy, ok := manager.endpointProxies.Get(fmt.Sprint(endpoint.ID)) + if !ok { + return nil + } + + return proxy.(http.Handler) +} + +// DeleteEndpointProxy deletes the proxy associated to a key +// and cleans the k8s environment(endpoint) client cache. DeleteEndpointProxy +// is currently only called for edge connection clean up and when endpoint is updated +func (manager *Manager) DeleteEndpointProxy(endpointID portainer.EndpointID) { + manager.endpointProxies.Remove(fmt.Sprint(endpointID)) + + if manager.k8sClientFactory != nil { + manager.k8sClientFactory.RemoveKubeClient(endpointID) + } +} + +// CreateGitlabProxy creates a new HTTP reverse proxy that can be used to send requests to the Gitlab API +func (manager *Manager) CreateGitlabProxy(url string) (http.Handler, error) { + if manager.proxyFactory == nil { + return nil, ErrProxyFactoryNotInitialized + } + + return manager.proxyFactory.NewGitlabProxy(url) +} diff --git a/api/http/security/authorization.go b/api/http/security/authorization.go new file mode 100644 index 0000000..4f106b7 --- /dev/null +++ b/api/http/security/authorization.go @@ -0,0 +1,169 @@ +package security + +import ( + "net/http" + "slices" + + portainer "github.com/portainer/portainer/api" +) + +// IsAdmin returns true if the logged-in user is an admin +func IsAdmin(request *http.Request) (bool, error) { + tokenData, err := RetrieveTokenData(request) + if err != nil { + return false, err + } + + return IsAdminRole(tokenData.Role), nil +} + +func IsAdminRole(role portainer.UserRole) bool { + return role == portainer.AdministratorRole +} + +// AuthorizedResourceControlAccess checks whether the user can alter an existing resource control. +func AuthorizedResourceControlAccess(resourceControl *portainer.ResourceControl, context *RestrictedRequestContext) bool { + if context.IsAdmin || resourceControl.Public { + return true + } + + for _, access := range resourceControl.TeamAccesses { + for _, membership := range context.UserMemberships { + if membership.TeamID == access.TeamID { + return true + } + } + } + + for _, access := range resourceControl.UserAccesses { + if context.UserID == access.UserID { + return true + } + } + + return false +} + +// AuthorizedResourceControlUpdate ensure that the user can update a resource control object. +// A non-administrator user cannot create a resource control where: +// * the Public flag is set false +// * the AdministratorsOnly flag is set to true +// * he wants to create a resource control without any user/team accesses +// * he wants to add more than one user in the user accesses +// * he wants to add a user in the user accesses that is not corresponding to its id +// * he wants to add a team he is not a member of +func AuthorizedResourceControlUpdate(resourceControl *portainer.ResourceControl, context *RestrictedRequestContext) bool { + if context.IsAdmin || resourceControl.Public { + return true + } + + if resourceControl.AdministratorsOnly { + return false + } + + userAccessesCount := len(resourceControl.UserAccesses) + teamAccessesCount := len(resourceControl.TeamAccesses) + + if userAccessesCount == 0 && teamAccessesCount == 0 { + return false + } + + if userAccessesCount > 1 || (userAccessesCount == 1 && teamAccessesCount == 1) { + return false + } + + if userAccessesCount == 1 { + access := resourceControl.UserAccesses[0] + if access.UserID == context.UserID { + return true + } + } + + if teamAccessesCount > 0 { + for _, access := range resourceControl.TeamAccesses { + if !slices.ContainsFunc(context.UserMemberships, func(m portainer.TeamMembership) bool { + return m.TeamID == access.TeamID + }) { + return false + } + } + + return true + } + + return false +} + +// AuthorizedTeamManagement ensure that access to the management of the specified team is granted. +// It will check if the user is either administrator or leader of that team. +func AuthorizedTeamManagement(teamID portainer.TeamID, context *RestrictedRequestContext) bool { + if context.IsAdmin { + return true + } + + for _, membership := range context.UserMemberships { + if membership.TeamID == teamID && membership.Role == portainer.TeamLeader { + return true + } + } + + return false +} + +// AuthorizedIsTeamLeader ensure that the user is an admin or a team leader +func AuthorizedIsTeamLeader(context *RestrictedRequestContext) bool { + return context.IsAdmin || context.IsTeamLeader +} + +// AuthorizedIsAdmin ensure that the user is an admin +func AuthorizedIsAdmin(context *RestrictedRequestContext) bool { + return context.IsAdmin +} + +// AuthorizedEndpointAccess ensure that the user can access the specified environment(endpoint). +// It will check if the user is part of the authorized users or part of a team that is +// listed in the authorized teams of the environment(endpoint) and the associated group. +func AuthorizedEndpointAccess(endpoint *portainer.Endpoint, endpointGroup *portainer.EndpointGroup, userID portainer.UserID, memberships []portainer.TeamMembership) bool { + groupAccess := AuthorizedAccess(userID, memberships, endpointGroup.UserAccessPolicies, endpointGroup.TeamAccessPolicies) + if !groupAccess { + return AuthorizedAccess(userID, memberships, endpoint.UserAccessPolicies, endpoint.TeamAccessPolicies) + } + return true +} + +// authorizedEndpointGroupAccess ensure that the user can access the specified environment(endpoint) group. +// It will check if the user is part of the authorized users or part of a team that is +// listed in the authorized teams. +func authorizedEndpointGroupAccess(endpointGroup *portainer.EndpointGroup, userID portainer.UserID, memberships []portainer.TeamMembership) bool { + return AuthorizedAccess(userID, memberships, endpointGroup.UserAccessPolicies, endpointGroup.TeamAccessPolicies) +} + +// AuthorizedRegistryAccess ensure that the user can access the specified registry. +// It will check if the user is part of the authorized users or part of a team that is +// listed in the authorized teams for a specified environment(endpoint), +func AuthorizedRegistryAccess(registry *portainer.Registry, user *portainer.User, teamMemberships []portainer.TeamMembership, endpointID portainer.EndpointID) bool { + if user.Role == portainer.AdministratorRole { + return true + } + + registryEndpointAccesses := registry.RegistryAccesses[endpointID] + + return AuthorizedAccess(user.ID, teamMemberships, registryEndpointAccesses.UserAccessPolicies, registryEndpointAccesses.TeamAccessPolicies) +} + +// AuthorizedAccess verifies the userID or memberships are authorized to use an object per the supplied access policies +func AuthorizedAccess(userID portainer.UserID, memberships []portainer.TeamMembership, userAccessPolicies portainer.UserAccessPolicies, teamAccessPolicies portainer.TeamAccessPolicies) bool { + _, userAccess := userAccessPolicies[userID] + if userAccess { + return true + } + + for _, membership := range memberships { + _, teamAccess := teamAccessPolicies[membership.TeamID] + if teamAccess { + return true + } + } + + return false +} diff --git a/api/http/security/authorization_test.go b/api/http/security/authorization_test.go new file mode 100644 index 0000000..1b0dd9f --- /dev/null +++ b/api/http/security/authorization_test.go @@ -0,0 +1,173 @@ +package security + +import ( + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/stretchr/testify/require" +) + +func TestAuthorizedResourceControlUpdate_AdminAlwaysAllowed(t *testing.T) { + t.Parallel() + + rc := &portainer.ResourceControl{ + AdministratorsOnly: true, + } + ctx := &RestrictedRequestContext{IsAdmin: true} + + require.True(t, AuthorizedResourceControlUpdate(rc, ctx)) +} + +func TestAuthorizedResourceControlUpdate_PublicAlwaysAllowed(t *testing.T) { + t.Parallel() + + rc := &portainer.ResourceControl{ + Public: true, + } + ctx := &RestrictedRequestContext{IsAdmin: false} + + require.True(t, AuthorizedResourceControlUpdate(rc, ctx)) +} + +func TestAuthorizedResourceControlUpdate_AdministratorsOnlyDenied(t *testing.T) { + t.Parallel() + + rc := &portainer.ResourceControl{ + AdministratorsOnly: true, + UserAccesses: []portainer.UserResourceAccess{ + {UserID: 1, AccessLevel: portainer.ReadWriteAccessLevel}, + }, + } + ctx := &RestrictedRequestContext{IsAdmin: false, UserID: 1} + + require.False(t, AuthorizedResourceControlUpdate(rc, ctx)) +} + +func TestAuthorizedResourceControlUpdate_EmptyAccessesDenied(t *testing.T) { + t.Parallel() + + rc := &portainer.ResourceControl{} + ctx := &RestrictedRequestContext{IsAdmin: false, UserID: 1} + + require.False(t, AuthorizedResourceControlUpdate(rc, ctx)) +} + +func TestAuthorizedResourceControlUpdate_UserAccessMatchingCurrentUser(t *testing.T) { + t.Parallel() + + rc := &portainer.ResourceControl{ + UserAccesses: []portainer.UserResourceAccess{ + {UserID: 1, AccessLevel: portainer.ReadWriteAccessLevel}, + }, + } + ctx := &RestrictedRequestContext{IsAdmin: false, UserID: 1} + + require.True(t, AuthorizedResourceControlUpdate(rc, ctx)) +} + +func TestAuthorizedResourceControlUpdate_UserAccessNotMatchingCurrentUser(t *testing.T) { + t.Parallel() + + rc := &portainer.ResourceControl{ + UserAccesses: []portainer.UserResourceAccess{ + {UserID: 2, AccessLevel: portainer.ReadWriteAccessLevel}, + }, + } + ctx := &RestrictedRequestContext{IsAdmin: false, UserID: 1} + + require.False(t, AuthorizedResourceControlUpdate(rc, ctx)) +} + +func TestAuthorizedResourceControlUpdate_TeamAccessUserMemberOfAllTeams(t *testing.T) { + t.Parallel() + + rc := &portainer.ResourceControl{ + TeamAccesses: []portainer.TeamResourceAccess{ + {TeamID: 1, AccessLevel: portainer.ReadWriteAccessLevel}, + {TeamID: 2, AccessLevel: portainer.ReadWriteAccessLevel}, + }, + } + ctx := &RestrictedRequestContext{ + IsAdmin: false, + UserMemberships: []portainer.TeamMembership{ + {TeamID: 1}, + {TeamID: 2}, + }, + } + + require.True(t, AuthorizedResourceControlUpdate(rc, ctx)) +} + +func TestAuthorizedResourceControlUpdate_TeamAccessUserNotMemberOfAllTeams(t *testing.T) { + t.Parallel() + + rc := &portainer.ResourceControl{ + TeamAccesses: []portainer.TeamResourceAccess{ + {TeamID: 1, AccessLevel: portainer.ReadWriteAccessLevel}, + {TeamID: 3, AccessLevel: portainer.ReadWriteAccessLevel}, + }, + } + ctx := &RestrictedRequestContext{ + IsAdmin: false, + UserMemberships: []portainer.TeamMembership{ + {TeamID: 1}, + {TeamID: 2}, + }, + } + + require.False(t, AuthorizedResourceControlUpdate(rc, ctx)) +} + +func TestAuthorizedResourceControlUpdate_TeamAccessUserNotMemberOfAnyTeam(t *testing.T) { + t.Parallel() + + rc := &portainer.ResourceControl{ + TeamAccesses: []portainer.TeamResourceAccess{ + {TeamID: 5, AccessLevel: portainer.ReadWriteAccessLevel}, + }, + } + ctx := &RestrictedRequestContext{ + IsAdmin: false, + UserMemberships: []portainer.TeamMembership{ + {TeamID: 1}, + }, + } + + require.False(t, AuthorizedResourceControlUpdate(rc, ctx)) +} + +func TestAuthorizedResourceControlUpdate_MultipleUserAccessesDenied(t *testing.T) { + t.Parallel() + + rc := &portainer.ResourceControl{ + UserAccesses: []portainer.UserResourceAccess{ + {UserID: 1, AccessLevel: portainer.ReadWriteAccessLevel}, + {UserID: 2, AccessLevel: portainer.ReadWriteAccessLevel}, + }, + } + ctx := &RestrictedRequestContext{IsAdmin: false, UserID: 1} + + require.False(t, AuthorizedResourceControlUpdate(rc, ctx)) +} + +func TestAuthorizedResourceControlUpdate_UserAndTeamAccessCombinationDenied(t *testing.T) { + t.Parallel() + + rc := &portainer.ResourceControl{ + UserAccesses: []portainer.UserResourceAccess{ + {UserID: 1, AccessLevel: portainer.ReadWriteAccessLevel}, + }, + TeamAccesses: []portainer.TeamResourceAccess{ + {TeamID: 1, AccessLevel: portainer.ReadWriteAccessLevel}, + }, + } + ctx := &RestrictedRequestContext{ + IsAdmin: false, + UserID: 1, + UserMemberships: []portainer.TeamMembership{ + {TeamID: 1}, + }, + } + + require.False(t, AuthorizedResourceControlUpdate(rc, ctx)) +} diff --git a/api/http/security/bouncer.go b/api/http/security/bouncer.go new file mode 100644 index 0000000..6def9a9 --- /dev/null +++ b/api/http/security/bouncer.go @@ -0,0 +1,580 @@ +package security + +import ( + "context" + "net/http" + "slices" + "strings" + "sync" + "time" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/apikey" + "github.com/portainer/portainer/api/dataservices" + httperrors "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/pkg/featureflags" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/schedule" + + "github.com/pkg/errors" + "github.com/rs/zerolog/log" +) + +const apiKeyHeader = "X-API-KEY" +const jwtTokenHeader = "Authorization" + +type ( + BouncerService interface { + PublicAccess(http.Handler) http.Handler + AdminAccess(http.Handler) http.Handler + RestrictedAccess(http.Handler) http.Handler + TeamLeaderAccess(http.Handler) http.Handler + AuthenticatedAccess(http.Handler) http.Handler + EdgeComputeOperation(http.Handler) http.Handler + + AuthorizedEndpointOperation(*http.Request, *portainer.Endpoint) error + AuthorizedEdgeEndpointOperation(*http.Request, *portainer.Endpoint) error + CookieAuthLookup(*http.Request) (*portainer.TokenData, error) + JWTAuthLookup(*http.Request) (*portainer.TokenData, error) + TrustedEdgeEnvironmentAccess(dataservices.DataStoreTx, *portainer.Endpoint) error + RevokeJWT(string) + DisableCSP() + } + + // RequestBouncer represents an entity that manages API request accesses + RequestBouncer struct { + dataStore dataservices.DataStore + jwtService portainer.JWTService + apiKeyService apikey.APIKeyService + revokedJWT sync.Map + hsts bool + csp bool + } + + // RestrictedRequestContext is a data structure containing information + // used in AuthenticatedAccess + RestrictedRequestContext struct { + IsAdmin bool + IsTeamLeader bool + UserID portainer.UserID + UserMemberships []portainer.TeamMembership + User *portainer.User + } + + // tokenLookup looks up a token in the request + tokenLookup func(*http.Request) (*portainer.TokenData, error) +) + +var ( + ErrInvalidKey = errors.New("Invalid API key") + ErrRevokedJWT = errors.New("the JWT has been revoked") +) + +// NewRequestBouncer initializes a new RequestBouncer +func NewRequestBouncer(ctx context.Context, dataStore dataservices.DataStore, jwtService portainer.JWTService, apiKeyService apikey.APIKeyService) *RequestBouncer { + b := &RequestBouncer{ + dataStore: dataStore, + jwtService: jwtService, + apiKeyService: apiKeyService, + hsts: featureflags.IsEnabled("hsts"), + csp: true, + } + + go schedule.RunOnInterval(ctx, time.Hour, b.cleanUpExpiredJWTPass, nil) + + return b +} + +// DisableCSP disables Content Security Policy +func (bouncer *RequestBouncer) DisableCSP() { + bouncer.csp = false +} + +// PublicAccess defines a security check for public API endpoints. +// No authentication is required to access these endpoints. +func (bouncer *RequestBouncer) PublicAccess(h http.Handler) http.Handler { + return MWSecureHeaders(h, bouncer.hsts, bouncer.csp) +} + +// AdminAccess defines a security check for API endpoints that require an authorization check. +// Authentication is required to access 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. +func (bouncer *RequestBouncer) AdminAccess(h http.Handler) http.Handler { + h = bouncer.mwUpgradeToRestrictedRequest(h) + h = bouncer.mwCheckPortainerAuthorizations(h, true) + h = bouncer.mwAuthenticatedUser(h) + + return h +} + +// RestrictedAccess defines a security check for restricted API endpoints. +// Authentication is required to access 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. +func (bouncer *RequestBouncer) RestrictedAccess(h http.Handler) http.Handler { + h = bouncer.mwUpgradeToRestrictedRequest(h) + h = bouncer.mwCheckPortainerAuthorizations(h, false) + h = bouncer.mwAuthenticatedUser(h) + + return h +} + +// TeamLeaderAccess defines a security check for APIs require team leader privilege +// +// Bouncer operations are applied backwards: +// - Parse the JWT from the request and stored in context, user has to be authenticated +// - Upgrade to the restricted request +// - User is admin or team leader +func (bouncer *RequestBouncer) TeamLeaderAccess(h http.Handler) http.Handler { + h = bouncer.mwIsTeamLeader(h) + h = bouncer.mwUpgradeToRestrictedRequest(h) + h = bouncer.mwAuthenticatedUser(h) + + return h +} + +// AuthenticatedAccess defines a security check for restricted API endpoints. +// Authentication is required to access 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. +func (bouncer *RequestBouncer) AuthenticatedAccess(h http.Handler) http.Handler { + h = bouncer.mwUpgradeToRestrictedRequest(h) + h = bouncer.mwAuthenticatedUser(h) + + return h +} + +// AuthorizedEndpointOperation retrieves the JWT token from the request context and verifies +// that the user can access the specified environment(endpoint). +// An error is returned when access to the environments(endpoints) 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) error { + tokenData, err := RetrieveTokenData(r) + if err != nil { + return err + } + + if tokenData.Role == portainer.AdministratorRole { + return nil + } + + memberships, err := bouncer.dataStore.TeamMembership().TeamMembershipsByUserID(tokenData.ID) + if err != nil { + return err + } + + group, err := bouncer.dataStore.EndpointGroup().Read(endpoint.GroupID) + if err != nil { + return err + } + + if !AuthorizedEndpointAccess(endpoint, group, tokenData.ID, memberships) { + return httperrors.ErrEndpointAccessDenied + } + + return nil +} + +// AuthorizedEdgeEndpointOperation verifies that the request was received from a valid Edge environment(endpoint) +func (bouncer *RequestBouncer) AuthorizedEdgeEndpointOperation(r *http.Request, endpoint *portainer.Endpoint) error { + if endpoint.Type != portainer.EdgeAgentOnKubernetesEnvironment && endpoint.Type != portainer.EdgeAgentOnDockerEnvironment { + return errors.New("Invalid environment type") + } + + edgeIdentifier := r.Header.Get(portainer.PortainerAgentEdgeIDHeader) + if edgeIdentifier == "" { + return errors.New("missing Edge identifier") + } + + if endpoint.EdgeID != "" && endpoint.EdgeID != edgeIdentifier { + return errors.New("invalid Edge identifier") + } + + return nil +} + +// TrustedEdgeEnvironmentAccess defines a security check for Edge environments, checks if +// the request is coming from a trusted Edge environment +func (bouncer *RequestBouncer) TrustedEdgeEnvironmentAccess(tx dataservices.DataStoreTx, endpoint *portainer.Endpoint) error { + if endpoint.UserTrusted { + return nil + } + + settings, err := tx.Settings().Settings() + if err != nil { + return errors.WithMessage(err, "could not retrieve the settings") + } + + if !settings.TrustOnFirstConnect { + return errors.New("the device has not been trusted yet") + } + + return nil +} + +// mwAuthenticatedUser authenticates a request by +// - adding a secure handlers to the response +// - authenticating the request with a valid token +func (bouncer *RequestBouncer) mwAuthenticatedUser(h http.Handler) http.Handler { + h = bouncer.mwAuthenticateFirst([]tokenLookup{ + bouncer.apiKeyLookup, + bouncer.CookieAuthLookup, + bouncer.JWTAuthLookup, + }, h) + h = MWSecureHeaders(h, bouncer.hsts, bouncer.csp) + + return h +} + +// mwCheckPortainerAuthorizations will verify that the user has the required authorization to access +// a specific API environment(endpoint). +// If the administratorOnly flag is specified, this will prevent non-admin +// users from accessing the environment(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", httperrors.ErrUnauthorized) + return + } + + if tokenData.Role == portainer.AdministratorRole { + next.ServeHTTP(w, r) + return + } + + if administratorOnly { + httperror.WriteError(w, http.StatusForbidden, "Access denied", httperrors.ErrUnauthorized) + return + } + + if ok, err := bouncer.dataStore.User().Exists(tokenData.ID); !ok { + 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 + } + + next.ServeHTTP(w, r) + }) +} + +// mwUpgradeToRestrictedRequest will enhance the current request with +// a new RestrictedRequestContext object. +func (bouncer *RequestBouncer) mwUpgradeToRestrictedRequest(next http.Handler) 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", httperrors.ErrResourceAccessDenied) + return + } + + requestContext, err := newRestrictedContextRequest(bouncer.dataStore, tokenData.ID, tokenData.Role) + if err != nil { + httperror.WriteError(w, http.StatusInternalServerError, "Unable to create restricted request context ", err) + return + } + + ctx := StoreRestrictedRequestContext(r, requestContext) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// mwIsTeamLeader will verify that the user is an admin or a team leader +func (bouncer *RequestBouncer) mwIsTeamLeader(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + securityContext, err := RetrieveRestrictedRequestContext(r) + if err != nil { + httperror.WriteError(w, http.StatusInternalServerError, "Unable to retrieve restricted request context ", err) + return + } + + if !securityContext.IsAdmin && !securityContext.IsTeamLeader { + httperror.WriteError(w, http.StatusForbidden, "Access denied", httperrors.ErrUnauthorized) + return + } + + next.ServeHTTP(w, r) + }) +} + +// mwAuthenticateFirst authenticates a request an auth token. +// A result of a first succeeded token lookup would be used for the authentication. +func (bouncer *RequestBouncer) mwAuthenticateFirst(tokenLookups []tokenLookup, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, hasAPIKey := extractAPIKey(r) + _, hasBearerToken := extractBearerToken(r) + if hasAPIKey && hasBearerToken { + httperror.WriteError(w, http.StatusUnauthorized, "API key and auth header are not allowed at the same time", httperrors.ErrUnauthorized) + + return + } + + var token *portainer.TokenData + + for _, lookup := range tokenLookups { + resultToken, err := lookup(r) + if err != nil { + httperror.WriteError(w, http.StatusUnauthorized, "Invalid JWT token", httperrors.ErrUnauthorized) + + return + } + + if resultToken != nil { + token = resultToken + + break + } + } + + if token == nil { + httperror.WriteError(w, http.StatusUnauthorized, "A valid authorization token is missing", httperrors.ErrUnauthorized) + + return + } + + if ok, _ := bouncer.dataStore.User().Exists(token.ID); !ok { + httperror.WriteError(w, http.StatusUnauthorized, "The authorization token is invalid", httperrors.ErrUnauthorized) + + return + } + + ctx := StoreTokenData(r, token) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// JWTAuthLookup looks up a valid bearer in the request. +func (bouncer *RequestBouncer) CookieAuthLookup(r *http.Request) (*portainer.TokenData, error) { + // get token from the Authorization header or query parameter + token, err := extractKeyFromCookie(r) + if err != nil { + return nil, nil + } + + tokenData, jti, _, err := bouncer.jwtService.ParseAndVerifyToken(token) + if err != nil { + return nil, err + } + + if _, ok := bouncer.revokedJWT.Load(jti); ok { + return nil, ErrRevokedJWT + } + + return tokenData, nil +} + +// JWTAuthLookup looks up a valid bearer in the request. +func (bouncer *RequestBouncer) JWTAuthLookup(r *http.Request) (*portainer.TokenData, error) { + // get token from the Authorization header or query parameter + token, ok := extractBearerToken(r) + if !ok { + return nil, nil + } + + tokenData, jti, _, err := bouncer.jwtService.ParseAndVerifyToken(token) + if err != nil { + return nil, err + } + + if _, ok := bouncer.revokedJWT.Load(jti); ok { + return nil, ErrRevokedJWT + } + + return tokenData, nil +} + +func (bouncer *RequestBouncer) RevokeJWT(token string) { + _, jti, exp, err := bouncer.jwtService.ParseAndVerifyToken(token) + if err != nil { + return + } + + bouncer.revokedJWT.Store(jti, exp) +} + +func (bouncer *RequestBouncer) cleanUpExpiredJWTPass() { + bouncer.revokedJWT.Range(func(key, value any) bool { + if t := value.(time.Time); t.IsZero() { + return true + } else if time.Now().After(t) { + bouncer.revokedJWT.Delete(key) + } + + return true + }) +} + +// apiKeyLookup looks up an verifies an api-key by: +// - computing the digest of the raw api-key +// - verifying it exists in cache/database +// - matching the key to a user (ID, Role) +// If the key is valid/verified, the last updated time of the key is updated. +// Successful verification of the key will return a TokenData object - since the downstream handlers +// utilise the token injected in the request context. +func (bouncer *RequestBouncer) apiKeyLookup(r *http.Request) (*portainer.TokenData, error) { + rawAPIKey, ok := extractAPIKey(r) + if !ok { + return nil, nil + } + + digest := bouncer.apiKeyService.HashRaw(rawAPIKey) + + user, apiKey, err := bouncer.apiKeyService.GetDigestUserAndKey(digest) + if err != nil { + return nil, ErrInvalidKey + } + + tokenData := &portainer.TokenData{ + ID: user.ID, + Username: user.Username, + Role: user.Role, + } + if _, _, err := bouncer.jwtService.GenerateToken(tokenData); err != nil { + log.Debug().Err(err).Msg("Failed to generate token") + return nil, errors.New("failed to generate token") + } + + if now := time.Now().UTC().Unix(); now-apiKey.LastUsed > 60 { // [seconds] + // update the last used time of the key + apiKey.LastUsed = now + _ = bouncer.apiKeyService.UpdateAPIKey(&apiKey) + } + + return tokenData, nil +} + +// extractBearerToken extracts the Bearer token from the Authorization header and returns the token. +func extractBearerToken(r *http.Request) (string, bool) { + tokens, ok := r.Header[jwtTokenHeader] + if !ok || len(tokens) == 0 { + return "", false + } + + token := tokens[0] + token = strings.TrimPrefix(token, "Bearer ") + + return token, true +} + +// AddAuthCookie adds the jwt token to the response cookie. +func AddAuthCookie(w http.ResponseWriter, token string, expirationTime time.Time, secure bool) { + http.SetCookie(w, &http.Cookie{ + Name: portainer.AuthCookieKey, + Value: token, + Path: "/", + Expires: expirationTime, + HttpOnly: true, + Secure: secure, + SameSite: http.SameSiteStrictMode, + }) +} + +// RemoveAuthCookie removes the jwt token from the response cookie. +func RemoveAuthCookie(w http.ResponseWriter, secure bool) { + http.SetCookie(w, &http.Cookie{ + Name: portainer.AuthCookieKey, + Value: "", + Path: "/", + Expires: time.Unix(0, 0), + HttpOnly: true, + MaxAge: -1, + Secure: secure, + SameSite: http.SameSiteStrictMode, + }) +} + +// extractKeyFromCookie extracts the jwt token from the cookie. +func extractKeyFromCookie(r *http.Request) (string, error) { + cookie, err := r.Cookie(portainer.AuthCookieKey) + if err != nil { + return "", err + } + + return cookie.Value, nil +} + +// extractAPIKey extracts the api key from the X-API-KEY request header. +func extractAPIKey(r *http.Request) (string, bool) { + apiKey := r.Header.Get(apiKeyHeader) + if apiKey != "" { + return apiKey, true + } + + return "", false +} + +// MWSecureHeaders provides secure headers middleware for handlers. +func MWSecureHeaders(next http.Handler, hsts, csp bool) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if hsts { + w.Header().Set("Strict-Transport-Security", "max-age=31536000") // 365 days + } + + if csp { + w.Header().Set("Content-Security-Policy", "script-src 'self' https://js.hsforms.net https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/; object-src 'none'; frame-ancestors 'none'; frame-src https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/") + } + + w.Header().Set("X-Content-Type-Options", "nosniff") + next.ServeHTTP(w, r) + }) +} + +func newRestrictedContextRequest(tx dataservices.DataStoreTx, userID portainer.UserID, userRole portainer.UserRole) (*RestrictedRequestContext, error) { + user, err := tx.User().Read(userID) + if err != nil { + return nil, err + } + + if userRole == portainer.AdministratorRole { + return &RestrictedRequestContext{ + IsAdmin: true, + UserID: userID, + User: user, + }, nil + } + + memberships, err := tx.TeamMembership().TeamMembershipsByUserID(userID) + if err != nil { + return nil, err + } + + isTeamLeader := slices.ContainsFunc(memberships, func(m portainer.TeamMembership) bool { + return m.Role == portainer.TeamLeader + }) + + return &RestrictedRequestContext{ + IsAdmin: false, + UserID: userID, + IsTeamLeader: isTeamLeader, + UserMemberships: memberships, + User: user, + }, nil +} + +// EdgeComputeOperation defines a restricted 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/bouncer_test.go b/api/http/security/bouncer_test.go new file mode 100644 index 0000000..6f9b69a --- /dev/null +++ b/api/http/security/bouncer_test.go @@ -0,0 +1,477 @@ +package security + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/apikey" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/internal/testhelpers" + "github.com/portainer/portainer/api/jwt" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// testHandler200 is a simple handler which returns HTTP status 200 OK +var testHandler200 = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) + +func tokenLookupSucceed(dataStore dataservices.DataStore, jwtService portainer.JWTService) tokenLookup { + return func(r *http.Request) (*portainer.TokenData, error) { + uid := portainer.UserID(1) + if err := dataStore.User().Create(&portainer.User{ID: uid}); err != nil { + return nil, err + } + + _, _, err := jwtService.GenerateToken(&portainer.TokenData{ID: uid}) + return &portainer.TokenData{ID: 1}, err + } +} + +func tokenLookupFail(r *http.Request) (*portainer.TokenData, error) { + return nil, ErrInvalidKey +} + +func tokenLookupEmpty(r *http.Request) (*portainer.TokenData, error) { + return nil, nil +} + +func Test_mwAuthenticateFirst(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, true, true) + + jwtService, err := jwt.NewService("1h", store) + require.NoError(t, err, "failed to create a copy of service") + + apiKeyService := apikey.NewAPIKeyService(nil, nil) + + bouncer := NewRequestBouncer(t.Context(), store, jwtService, apiKeyService) + + tests := []struct { + name string + verificationMiddlwares []tokenLookup + wantStatusCode int + }{ + { + name: "mwAuthenticateFirst middleware passes with no middleware", + verificationMiddlwares: nil, + wantStatusCode: http.StatusUnauthorized, + }, + { + name: "mwAuthenticateFirst middleware succeeds with passing middleware", + verificationMiddlwares: []tokenLookup{ + tokenLookupSucceed(store, jwtService), + }, + wantStatusCode: http.StatusOK, + }, + { + name: "mwAuthenticateFirst fails with failing middleware", + verificationMiddlwares: []tokenLookup{ + tokenLookupFail, + }, + wantStatusCode: http.StatusUnauthorized, + }, + { + name: "mwAuthenticateFirst succeeds if first middleware successfully handles request", + verificationMiddlwares: []tokenLookup{ + tokenLookupSucceed(store, jwtService), + tokenLookupFail, + }, + wantStatusCode: http.StatusOK, + }, + { + name: "mwAuthenticateFirst fails if first middleware fails", + verificationMiddlwares: []tokenLookup{ + tokenLookupFail, + tokenLookupSucceed(store, jwtService), + }, + wantStatusCode: http.StatusUnauthorized, + }, + { + name: "mwAuthenticateFirst fails if first middleware has no token, but second middleware fails", + verificationMiddlwares: []tokenLookup{ + tokenLookupEmpty, + tokenLookupFail, + + tokenLookupSucceed(store, jwtService), + }, + wantStatusCode: http.StatusUnauthorized, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + is := assert.New(t) + req := httptest.NewRequest(http.MethodGet, "/", nil) + rr := httptest.NewRecorder() + + h := bouncer.mwAuthenticateFirst(tt.verificationMiddlwares, testHandler200) + h.ServeHTTP(rr, req) + + is.Equal(tt.wantStatusCode, rr.Code, "Status should be %d", tt.wantStatusCode) + }) + } +} + +func Test_extractKeyFromCookie(t *testing.T) { + t.Parallel() + is := assert.New(t) + + tt := []struct { + name string + token string + succeeds bool + }{ + { + name: "missing cookie", + token: "", + succeeds: false, + }, + + { + name: "valid cookie", + token: "abc", + succeeds: true, + }, + } + + for _, test := range tt { + req := httptest.NewRequest(http.MethodGet, "/", nil) + if test.token != "" { + testhelpers.AddTestSecurityCookie(req, test.token) + } + + apiKey, err := extractKeyFromCookie(req) + is.Equal(test.token, apiKey) + if !test.succeeds { + require.Error(t, err, "Should return error") + is.ErrorIs(err, http.ErrNoCookie) + } else { + require.NoError(t, err) + } + } +} + +func Test_extractBearerToken(t *testing.T) { + t.Parallel() + tt := []struct { + name string + requestHeader string + requestHeaderValue string + wantToken string + succeeds bool + }{ + { + name: "missing request header", + requestHeader: "", + requestHeaderValue: "", + wantToken: "", + succeeds: false, + }, + { + name: "invalid authorization request header", + requestHeader: "authorisation", // note: `s` + requestHeaderValue: "abc", + wantToken: "", + succeeds: false, + }, + { + name: "valid authorization request header", + requestHeader: "AUTHORIZATION", + requestHeaderValue: "abc", + wantToken: "abc", + succeeds: true, + }, + { + name: "valid authorization request header case-insensitive canonical check", + requestHeader: "authorization", + requestHeaderValue: "def", + wantToken: "def", + succeeds: true, + }, + } + + for _, test := range tt { + t.Run(test.name, func(t *testing.T) { + is := assert.New(t) + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set(test.requestHeader, test.requestHeaderValue) + apiKey, ok := extractBearerToken(req) + is.Equal(test.wantToken, apiKey) + is.Equal(test.succeeds, ok) + }) + } +} + +func Test_extractAPIKeyHeader(t *testing.T) { + t.Parallel() + is := assert.New(t) + + tt := []struct { + name string + requestHeader string + requestHeaderValue string + wantApiKey string + succeeds bool + }{ + { + name: "missing request header", + requestHeader: "", + requestHeaderValue: "", + wantApiKey: "", + succeeds: false, + }, + { + name: "invalid api-key request header", + requestHeader: "api-key", + requestHeaderValue: "abc", + wantApiKey: "", + succeeds: false, + }, + { + name: "valid api-key request header", + requestHeader: apiKeyHeader, + requestHeaderValue: "abc", + wantApiKey: "abc", + succeeds: true, + }, + { + name: "valid api-key request header case-insensitive canonical check", + requestHeader: "x-api-key", + requestHeaderValue: "def", + wantApiKey: "def", + succeeds: true, + }, + } + + for _, test := range tt { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set(test.requestHeader, test.requestHeaderValue) + apiKey, ok := extractAPIKey(req) + is.Equal(test.wantApiKey, apiKey) + is.Equal(test.succeeds, ok) + } +} + +func Test_apiKeyLookup(t *testing.T) { + t.Parallel() + is := assert.New(t) + + _, store := datastore.MustNewTestStore(t, true, true) + + // create standard user + user := &portainer.User{ID: 2, Username: "standard", Role: portainer.StandardUserRole} + err := store.User().Create(user) + require.NoError(t, err, "error creating user") + + // setup services + jwtService, err := jwt.NewService("1h", store) + require.NoError(t, err, "Error initiating jwt service") + apiKeyService := apikey.NewAPIKeyService(store.APIKeyRepository(), store.User()) + bouncer := NewRequestBouncer(t.Context(), store, jwtService, apiKeyService) + + t.Run("missing x-api-key header fails api-key lookup", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + // testhelpers.AddTestSecurityCookie(req, jwt) + token, err := bouncer.apiKeyLookup(req) + require.NoError(t, err) + is.Nil(token) + }) + + t.Run("invalid x-api-key header fails api-key lookup", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Add("x-api-key", "random-failing-api-key") + token, err := bouncer.apiKeyLookup(req) + is.Nil(token) + require.Error(t, err) + }) + + t.Run("valid x-api-key header succeeds api-key lookup", func(t *testing.T) { + rawAPIKey, _, err := apiKeyService.GenerateApiKey(*user, "test") + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Add("x-api-key", rawAPIKey) + + token, err := bouncer.apiKeyLookup(req) + require.NoError(t, err) + + expectedToken := &portainer.TokenData{ID: user.ID, Username: user.Username, Role: portainer.StandardUserRole} + is.Equal(expectedToken, token) + }) + + t.Run("valid x-api-key header succeeds api-key lookup", func(t *testing.T) { + rawAPIKey, apiKey, err := apiKeyService.GenerateApiKey(*user, "test") + require.NoError(t, err) + defer func() { + err := apiKeyService.DeleteAPIKey(apiKey.ID) + require.NoError(t, err) + }() + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Add("x-api-key", rawAPIKey) + + token, err := bouncer.apiKeyLookup(req) + require.NoError(t, err) + + expectedToken := &portainer.TokenData{ID: user.ID, Username: user.Username, Role: portainer.StandardUserRole} + is.Equal(expectedToken, token) + }) + + t.Run("successful api-key lookup updates token last used time", func(t *testing.T) { + rawAPIKey, apiKey, err := apiKeyService.GenerateApiKey(*user, "test") + require.NoError(t, err) + defer func() { + err := apiKeyService.DeleteAPIKey(apiKey.ID) + require.NoError(t, err) + }() + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Add("x-api-key", rawAPIKey) + + token, err := bouncer.apiKeyLookup(req) + require.NoError(t, err) + + expectedToken := &portainer.TokenData{ID: user.ID, Username: user.Username, Role: portainer.StandardUserRole} + is.Equal(expectedToken, token) + + _, apiKeyUpdated, err := apiKeyService.GetDigestUserAndKey(apiKey.Digest) + require.NoError(t, err) + + is.Greater(apiKeyUpdated.LastUsed, apiKey.LastUsed) + }) +} + +func Test_mwAuthenticateFirst_rejectsBothAPIKeyAndBearerToken(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, true, true) + + jwtService, err := jwt.NewService("1h", store) + require.NoError(t, err) + + apiKeyService := apikey.NewAPIKeyService(nil, nil) + bouncer := NewRequestBouncer(t.Context(), store, jwtService, apiKeyService) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set(apiKeyHeader, "test-api-key") + req.Header.Set(jwtTokenHeader, "Bearer test-token") + + rr := httptest.NewRecorder() + h := bouncer.mwAuthenticateFirst(nil, testHandler200) + h.ServeHTTP(rr, req) + + require.Equal(t, http.StatusUnauthorized, rr.Code) +} + +func TestJWTRevocation(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, true, true) + + jwtService, err := jwt.NewService("1h", store) + require.NoError(t, err) + + err = store.User().Create(&portainer.User{ID: 1}) + require.NoError(t, err) + + jwtService.SetUserSessionDuration(time.Second) + + token, _, err := jwtService.GenerateToken(&portainer.TokenData{ID: 1}) + require.NoError(t, err) + + settings, err := store.Settings().Settings() + require.NoError(t, err) + + settings.KubeconfigExpiry = "0" + + err = store.Settings().UpdateSettings(settings) + require.NoError(t, err) + + kubeToken, err := jwtService.GenerateTokenForKubeconfig(&portainer.TokenData{ID: 1}) + require.NoError(t, err) + + apiKeyService := apikey.NewAPIKeyService(nil, nil) + + bouncer := NewRequestBouncer(t.Context(), store, jwtService, apiKeyService) + + r, err := http.NewRequest(http.MethodGet, "url", nil) + require.NoError(t, err) + + r.Header.Add(jwtTokenHeader, "Bearer "+token) + + r.AddCookie(&http.Cookie{Name: portainer.AuthCookieKey, Value: token}) + + _, err = bouncer.JWTAuthLookup(r) + require.NoError(t, err) + + _, err = bouncer.CookieAuthLookup(r) + require.NoError(t, err) + + bouncer.RevokeJWT(token) + bouncer.RevokeJWT(kubeToken) + + revokeLen := func() (l int) { + bouncer.revokedJWT.Range(func(key, value any) bool { + l++ + + return true + }) + + return l + } + require.Equal(t, 2, revokeLen()) + + _, err = bouncer.JWTAuthLookup(r) + require.Error(t, err) + + _, err = bouncer.CookieAuthLookup(r) + require.Error(t, err) + + time.Sleep(time.Second) + + bouncer.cleanUpExpiredJWTPass() + + require.Equal(t, 1, revokeLen()) +} + +func TestCSPHeaderDefault(t *testing.T) { + t.Parallel() + b := NewRequestBouncer(t.Context(), nil, nil, nil) + + srv := httptest.NewServer( + b.PublicAccess(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})), + ) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/") + require.NoError(t, err) + defer func() { + err := resp.Body.Close() + require.NoError(t, err) + }() + + require.Contains(t, resp.Header, "Content-Security-Policy") +} + +func TestCSPHeaderDisabled(t *testing.T) { + t.Parallel() + b := NewRequestBouncer(t.Context(), nil, nil, nil) + b.DisableCSP() + + srv := httptest.NewServer( + b.PublicAccess(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})), + ) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/") + require.NoError(t, err) + defer func() { + err := resp.Body.Close() + require.NoError(t, err) + }() + + require.NotContains(t, resp.Header, "Content-Security-Policy") +} diff --git a/api/http/security/context.go b/api/http/security/context.go new file mode 100644 index 0000000..2cf6cb0 --- /dev/null +++ b/api/http/security/context.go @@ -0,0 +1,64 @@ +package security + +import ( + "context" + "errors" + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" +) + +type contextKey int + +const ( + contextAuthenticationKey contextKey = iota + contextRestrictedRequest +) + +// StoreTokenData stores a TokenData object inside the request context and returns the enhanced context. +func StoreTokenData(request *http.Request, tokenData *portainer.TokenData) context.Context { + return context.WithValue(request.Context(), contextAuthenticationKey, tokenData) +} + +// RetrieveTokenData returns the TokenData object stored in the request context. +func RetrieveTokenData(request *http.Request) (*portainer.TokenData, error) { + contextData := request.Context().Value(contextAuthenticationKey) + if contextData == nil { + return nil, errors.New("Unable to find JWT data in request context") + } + + tokenData := contextData.(*portainer.TokenData) + return tokenData, nil +} + +// StoreRestrictedRequestContext stores a RestrictedRequestContext object inside the request context +// and returns the enhanced context. +func StoreRestrictedRequestContext(request *http.Request, requestContext *RestrictedRequestContext) context.Context { + return context.WithValue(request.Context(), contextRestrictedRequest, requestContext) +} + +// RetrieveRestrictedRequestContext returns the RestrictedRequestContext object stored in the request context. +func RetrieveRestrictedRequestContext(request *http.Request) (*RestrictedRequestContext, error) { + contextData := request.Context().Value(contextRestrictedRequest) + if contextData == nil { + return nil, errors.New("Unable to find security details in request context") + } + + requestContext := contextData.(*RestrictedRequestContext) + return requestContext, nil +} + +func RetrieveUserFromRequest(r *http.Request, tx dataservices.DataStoreTx) (*portainer.User, error) { + rrc, err := RetrieveRestrictedRequestContext(r) + if err != nil { + return nil, err + } + + user, err := tx.User().Read(rrc.UserID) + if err != nil { + return nil, err + } + + return user, nil +} diff --git a/api/http/security/errors.go b/api/http/security/errors.go new file mode 100644 index 0000000..40193b3 --- /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 new file mode 100644 index 0000000..d01f934 --- /dev/null +++ b/api/http/security/filter.go @@ -0,0 +1,112 @@ +package security + +import ( + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/slicesx" +) + +// FilterUserTeams filters teams based on user role. +// non-administrator users only have access to team they are member of. +func FilterUserTeams(teams []portainer.Team, context *RestrictedRequestContext) []portainer.Team { + if context.IsAdmin { + return teams + } + + memberOf := map[portainer.TeamID]bool{} + for _, membership := range context.UserMemberships { + memberOf[membership.TeamID] = true + } + + return slicesx.FilterInPlace(teams, func(team portainer.Team) bool { + return memberOf[team.ID] + }) +} + +// FilterLeaderTeams filters teams based on user role. +// Team leaders only have access to team they lead. +func FilterLeaderTeams(teams []portainer.Team, context *RestrictedRequestContext) []portainer.Team { + if !context.IsTeamLeader { + return teams[:0] + } + + leaderSet := map[portainer.TeamID]bool{} + for _, membership := range context.UserMemberships { + if membership.Role == portainer.TeamLeader && membership.UserID == context.UserID { + leaderSet[membership.TeamID] = true + } + } + + return slicesx.FilterInPlace(teams, func(team portainer.Team) bool { + return leaderSet[team.ID] + }) +} + +// FilterUsers filters users based on user role. +// Non-administrator users only have access to non-administrator users. +func FilterUsers(users []portainer.User, context *RestrictedRequestContext) []portainer.User { + if context.IsAdmin { + return users + } + + return slicesx.FilterInPlace(users, func(u portainer.User) bool { + return u.Role != portainer.AdministratorRole + }) +} + +// FilterRegistries filters registries based on user role and team memberships. +// Non administrator users only have access to authorized registries. +func FilterRegistries(registries []portainer.Registry, user *portainer.User, teamMemberships []portainer.TeamMembership, endpointID portainer.EndpointID) []portainer.Registry { + if user.Role == portainer.AdministratorRole { + return registries + } + + return slicesx.FilterInPlace(registries, func(r portainer.Registry) bool { + return AuthorizedRegistryAccess(&r, user, teamMemberships, endpointID) + }) +} + +// FilterEndpoints filters environments(endpoints) based on user role and team memberships. +// Non administrator only have access to authorized environments(endpoints) (can be inherited via endpoint groups). +func FilterEndpoints(endpoints []portainer.Endpoint, groups []portainer.EndpointGroup, context *RestrictedRequestContext) []portainer.Endpoint { + if context.IsAdmin { + return endpoints + } + + n := 0 + for _, endpoint := range endpoints { + endpointGroup := getAssociatedGroup(&endpoint, groups) + if endpointGroup == nil { + continue + } + + if AuthorizedEndpointAccess(&endpoint, endpointGroup, context.UserID, context.UserMemberships) { + endpoint.UserAccessPolicies = nil + endpoints[n] = endpoint + n++ + } + } + + return endpoints[:n] +} + +// FilterEndpointGroups filters environment(endpoint) groups based on user role and team memberships. +// Non administrator users only have access to authorized environment(endpoint) groups. +func FilterEndpointGroups(endpointGroups []portainer.EndpointGroup, context *RestrictedRequestContext) []portainer.EndpointGroup { + if context.IsAdmin { + return endpointGroups + } + + return slicesx.FilterInPlace(endpointGroups, func(group portainer.EndpointGroup) bool { + return authorizedEndpointGroupAccess(&group, context.UserID, context.UserMemberships) + }) +} + +func getAssociatedGroup(endpoint *portainer.Endpoint, groups []portainer.EndpointGroup) *portainer.EndpointGroup { + for _, group := range groups { + if group.ID == endpoint.GroupID { + return &group + } + } + + return nil +} diff --git a/api/http/security/filter_test.go b/api/http/security/filter_test.go new file mode 100644 index 0000000..7501b83 --- /dev/null +++ b/api/http/security/filter_test.go @@ -0,0 +1,36 @@ +package security + +import ( + "testing" + + portainer "github.com/portainer/portainer/api" + + "github.com/stretchr/testify/require" +) + +func TestFilterEndpointsPanic(t *testing.T) { + t.Parallel() + endpoints := []portainer.Endpoint{{ID: 1}} + groups := []portainer.EndpointGroup{} + context := &RestrictedRequestContext{} + + FilterEndpoints(endpoints, groups, context) +} + +func TestFilterUserTeams_MembershipOrderDiffersFromTeamOrder(t *testing.T) { + t.Parallel() + + teams := []portainer.Team{{ID: 1, Name: "team1"}, {ID: 5, Name: "team5"}, {ID: 26, Name: "team26"}} + context := &RestrictedRequestContext{ + UserID: 22, + UserMemberships: []portainer.TeamMembership{ + {ID: 60, UserID: 22, TeamID: 26}, + {ID: 1, UserID: 22, TeamID: 1}, + }, + } + + filtered := FilterUserTeams(teams, context) + + require.Len(t, filtered, 2) + require.ElementsMatch(t, []portainer.TeamID{1, 26}, []portainer.TeamID{filtered[0].ID, filtered[1].ID}) +} diff --git a/api/http/security/passwordStrengthCheck.go b/api/http/security/passwordStrengthCheck.go new file mode 100644 index 0000000..d216688 --- /dev/null +++ b/api/http/security/passwordStrengthCheck.go @@ -0,0 +1,37 @@ +package security + +import ( + portainer "github.com/portainer/portainer/api" + + "github.com/rs/zerolog/log" +) + +type PasswordStrengthChecker interface { + Check(password string) bool +} + +type passwordStrengthChecker struct { + settings settingsService +} + +func NewPasswordStrengthChecker(settings settingsService) *passwordStrengthChecker { + return &passwordStrengthChecker{ + settings: settings, + } +} + +// Check returns true if the password is strong enough +func (c *passwordStrengthChecker) Check(password string) bool { + s, err := c.settings.Settings() + if err != nil { + log.Warn().Err(err).Msg("failed to fetch Portainer settings to validate user password") + + return true + } + + return len(password) >= s.InternalAuthSettings.RequiredPasswordLength +} + +type settingsService interface { + Settings() (*portainer.Settings, error) +} diff --git a/api/http/security/passwordStrengthCheck_test.go b/api/http/security/passwordStrengthCheck_test.go new file mode 100644 index 0000000..53e7356 --- /dev/null +++ b/api/http/security/passwordStrengthCheck_test.go @@ -0,0 +1,50 @@ +package security + +import ( + "testing" + + portainer "github.com/portainer/portainer/api" +) + +func TestStrengthCheck(t *testing.T) { + t.Parallel() + checker := NewPasswordStrengthChecker(settingsStub{minLength: 12}) + + type args struct { + password string + } + tests := []struct { + name string + args args + wantStrong bool + }{ + {"Empty password", args{""}, false}, + {"Short password", args{"portainer"}, false}, + {"Short password", args{"portaienr!@#"}, true}, + {"Week password", args{"12345678!@#"}, false}, + {"Week password", args{"portaienr123"}, true}, + {"Good password", args{"Portainer123"}, true}, + {"Good password", args{"Portainer___"}, true}, + {"Good password", args{"^portainer12"}, true}, + {"Good password", args{"12%PORTAINER"}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if gotStrong := checker.Check(tt.args.password); gotStrong != tt.wantStrong { + t.Errorf("StrengthCheck() = %v, want %v", gotStrong, tt.wantStrong) + } + }) + } +} + +type settingsStub struct { + minLength int +} + +func (s settingsStub) Settings() (*portainer.Settings, error) { + return &portainer.Settings{ + InternalAuthSettings: portainer.InternalAuthSettings{ + RequiredPasswordLength: s.minLength, + }, + }, nil +} diff --git a/api/http/security/rate_limiter.go b/api/http/security/rate_limiter.go new file mode 100644 index 0000000..5b0e60f --- /dev/null +++ b/api/http/security/rate_limiter.go @@ -0,0 +1,48 @@ +package security + +import ( + "net/http" + "strings" + "time" + + "github.com/portainer/portainer/api/http/errors" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + + "github.com/g07cha/defender" +) + +// RateLimiter represents an entity that manages request rate limiting +type RateLimiter struct { + *defender.Defender +} + +// NewRateLimiter initializes a new RateLimiter +func NewRateLimiter(maxRequests int, duration time.Duration, banDuration time.Duration) *RateLimiter { + messages := make(chan struct{}) + limiter := defender.New(maxRequests, duration, banDuration) + go limiter.CleanupTask(messages) + return &RateLimiter{ + limiter, + } +} + +// LimitAccess wraps current request with check if remote address does not goes above the defined limits +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 { + httperror.WriteError(w, http.StatusForbidden, "Access denied", errors.ErrResourceAccessDenied) + return + } + next.ServeHTTP(w, r) + }) +} + +// StripAddrPort removes port from IP address +func StripAddrPort(addr string) string { + portIndex := strings.LastIndex(addr, ":") + if portIndex != -1 { + addr = addr[:portIndex] + } + return addr +} diff --git a/api/http/security/rate_limiter_test.go b/api/http/security/rate_limiter_test.go new file mode 100644 index 0000000..9a2817f --- /dev/null +++ b/api/http/security/rate_limiter_test.go @@ -0,0 +1,82 @@ +package security + +import ( + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestLimitAccess(t *testing.T) { + t.Parallel() + testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) + + t.Run("Request below the limit", func(t *testing.T) { + req := httptest.NewRequest("GET", "/", nil) + rr := httptest.NewRecorder() + rateLimiter := NewRateLimiter(10, 1*time.Second, 1*time.Hour) + handler := rateLimiter.LimitAccess(testHandler) + + handler.ServeHTTP(rr, req) + + if status := rr.Code; status != http.StatusOK { + t.Errorf("handler returned wrong status code: got %v want %v", + status, http.StatusOK) + } + }) + + t.Run("Request above the limit", func(t *testing.T) { + rateLimiter := NewRateLimiter(1, 1*time.Second, 1*time.Hour) + handler := rateLimiter.LimitAccess(testHandler) + + ts := httptest.NewServer(handler) + defer ts.Close() + + resp, err := http.Get(ts.URL) + if err == nil { + err = resp.Body.Close() + require.NoError(t, err) + } + + resp, err = http.Get(ts.URL) + if err != nil { + t.Fatal(err) + } + + _, _ = io.Copy(io.Discard, resp.Body) + err = resp.Body.Close() + require.NoError(t, err) + + if status := resp.StatusCode; status != http.StatusForbidden { + t.Errorf("handler returned wrong status code: got %v want %v", + status, http.StatusForbidden) + } + }) +} + +func TestStripAddrPort(t *testing.T) { + t.Parallel() + t.Run("IP with port", func(t *testing.T) { + result := StripAddrPort("127.0.0.1:1000") + if result != "127.0.0.1" { + t.Errorf("Expected IP with address to be '127.0.0.1', but it was %s instead", result) + } + }) + + t.Run("IP without port", func(t *testing.T) { + result := StripAddrPort("127.0.0.1") + if result != "127.0.0.1" { + t.Errorf("Expected IP with address to be '127.0.0.1', but it was %s instead", result) + } + }) + + t.Run("Local IP", func(t *testing.T) { + result := StripAddrPort("[::1]:1000") + if result != "[::1]" { + t.Errorf("Expected IP with address to be '[::1]', but it was %s instead", result) + } + }) +} diff --git a/api/http/security/setuptoken/setuptoken.go b/api/http/security/setuptoken/setuptoken.go new file mode 100644 index 0000000..ec1af99 --- /dev/null +++ b/api/http/security/setuptoken/setuptoken.go @@ -0,0 +1,48 @@ +// Package setuptoken provides a one-time setup token used to protect the +// public initialization endpoints (admin account creation and backup restore) +// on an uninitialized Portainer instance. +package setuptoken + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/hex" + "errors" + "net/http" + + httperror "github.com/portainer/portainer/pkg/libhttp/error" +) + +// HeaderName is the HTTP header that carries the setup token. +const HeaderName = "X-Setup-Token" + +// tokenByteLength is the number of random bytes before hex-encoding (256 bits). +const tokenByteLength = 32 + +var errInvalidSetupToken = errors.New("invalid or missing setup token") + +// Generate returns a cryptographically random, hex-encoded setup token. +func Generate() (string, error) { + b := make([]byte, tokenByteLength) + if _, err := rand.Read(b); err != nil { + return "", err + } + + return hex.EncodeToString(b), nil +} + +// Validate checks that the request carries the expected setup token in the +// X-Setup-Token header. When expected is empty the gate is disabled and the +// request is always allowed. Comparison is constant-time. +func Validate(r *http.Request, expected string) *httperror.HandlerError { + if expected == "" { + return nil + } + + provided := r.Header.Get(HeaderName) + if subtle.ConstantTimeCompare([]byte(provided), []byte(expected)) != 1 { + return httperror.Forbidden("Invalid or missing setup token. Provide the X-Setup-Token header with the token printed in the server logs at startup.", errInvalidSetupToken) + } + + return nil +} diff --git a/api/http/security/setuptoken/setuptoken_test.go b/api/http/security/setuptoken/setuptoken_test.go new file mode 100644 index 0000000..589fa32 --- /dev/null +++ b/api/http/security/setuptoken/setuptoken_test.go @@ -0,0 +1,46 @@ +package setuptoken + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_Generate(t *testing.T) { + token, err := Generate() + require.NoError(t, err) + assert.Len(t, token, 64, "hex-encoded 32 bytes should be 64 characters") + + token2, err := Generate() + require.NoError(t, err) + assert.NotEqual(t, token, token2, "two generated tokens should differ") +} + +func Test_Validate_alwaysPassesWhenExpectedEmpty(t *testing.T) { + r := httptest.NewRequest(http.MethodPost, "/", nil) + assert.Nil(t, Validate(r, "")) +} + +func Test_Validate_missingHeader(t *testing.T) { + r := httptest.NewRequest(http.MethodPost, "/", nil) + herr := Validate(r, "secret") + require.NotNil(t, herr) + assert.Equal(t, http.StatusForbidden, herr.StatusCode) +} + +func Test_Validate_wrongToken(t *testing.T) { + r := httptest.NewRequest(http.MethodPost, "/", nil) + r.Header.Set(HeaderName, "wrong") + herr := Validate(r, "secret") + require.NotNil(t, herr) + assert.Equal(t, http.StatusForbidden, herr.StatusCode) +} + +func Test_Validate_correctToken(t *testing.T) { + r := httptest.NewRequest(http.MethodPost, "/", nil) + r.Header.Set(HeaderName, "secret") + assert.Nil(t, Validate(r, "secret")) +} diff --git a/api/http/server.go b/api/http/server.go new file mode 100644 index 0000000..c56d1cd --- /dev/null +++ b/api/http/server.go @@ -0,0 +1,398 @@ +package http + +import ( + "context" + "crypto/tls" + "net/http" + "path/filepath" + "time" + + "github.com/pkg/errors" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/adminmonitor" + "github.com/portainer/portainer/api/apikey" + "github.com/portainer/portainer/api/crypto" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/docker" + dockerclient "github.com/portainer/portainer/api/docker/client" + "github.com/portainer/portainer/api/http/csrf" + "github.com/portainer/portainer/api/http/handler" + "github.com/portainer/portainer/api/http/handler/auth" + "github.com/portainer/portainer/api/http/handler/backup" + "github.com/portainer/portainer/api/http/handler/customtemplates" + dockerhandler "github.com/portainer/portainer/api/http/handler/docker" + "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/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/file" + "github.com/portainer/portainer/api/http/handler/gitops" + "github.com/portainer/portainer/api/http/handler/helm" + kubehandler "github.com/portainer/portainer/api/http/handler/kubernetes" + "github.com/portainer/portainer/api/http/handler/ldap" + "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" + sslhandler "github.com/portainer/portainer/api/http/handler/ssl" + "github.com/portainer/portainer/api/http/handler/stacks" + "github.com/portainer/portainer/api/http/handler/storybook" + "github.com/portainer/portainer/api/http/handler/system" + "github.com/portainer/portainer/api/http/handler/tags" + "github.com/portainer/portainer/api/http/handler/teammemberships" + "github.com/portainer/portainer/api/http/handler/teams" + "github.com/portainer/portainer/api/http/handler/templates" + "github.com/portainer/portainer/api/http/handler/upload" + "github.com/portainer/portainer/api/http/handler/users" + "github.com/portainer/portainer/api/http/handler/webhooks" + "github.com/portainer/portainer/api/http/handler/websocket" + "github.com/portainer/portainer/api/http/middlewares" + "github.com/portainer/portainer/api/http/offlinegate" + "github.com/portainer/portainer/api/http/proxy" + "github.com/portainer/portainer/api/http/proxy/factory/kubernetes" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/authorization" + edgestackservice "github.com/portainer/portainer/api/internal/edge/edgestacks" + "github.com/portainer/portainer/api/internal/snapshot" + "github.com/portainer/portainer/api/internal/ssl" + "github.com/portainer/portainer/api/internal/upgrade" + k8s "github.com/portainer/portainer/api/kubernetes" + "github.com/portainer/portainer/api/kubernetes/cli" + motdservice "github.com/portainer/portainer/api/motd" + "github.com/portainer/portainer/api/pendingactions" + "github.com/portainer/portainer/api/platform" + "github.com/portainer/portainer/api/scheduler" + "github.com/portainer/portainer/api/stacks/deployments" + libhelmtypes "github.com/portainer/portainer/pkg/libhelm/types" + + "github.com/rs/zerolog/log" +) + +// Server implements the portainer.Server interface +type Server struct { + AuthorizationService *authorization.Service + BindAddress string + BindAddressHTTPS string + CSP bool + HTTPEnabled bool + AssetsPath string + Status *portainer.Status + ReverseTunnelService portainer.ReverseTunnelService + ComposeStackManager portainer.ComposeStackManager + CryptoService portainer.CryptoService + EdgeStacksService *edgestackservice.Service + SignatureService portainer.DigitalSignatureService + SnapshotService portainer.SnapshotService + FileService portainer.FileService + DataStore dataservices.DataStore + GitService portainer.GitService + APIKeyService apikey.APIKeyService + JWTService portainer.JWTService + LDAPService portainer.LDAPService + OAuthService portainer.OAuthService + SwarmStackManager portainer.SwarmStackManager + ProxyManager *proxy.Manager + KubernetesTokenCacheManager *kubernetes.TokenCacheManager + KubeClusterAccessService k8s.KubeClusterAccessService + Handler *handler.Handler + SSLService *ssl.Service + DockerClientFactory *dockerclient.ClientFactory + KubernetesClientFactory *cli.ClientFactory + KubernetesDeployer portainer.KubernetesDeployer + HelmPackageManager libhelmtypes.HelmPackageManager + Scheduler *scheduler.Scheduler + ShutdownTrigger context.CancelFunc + StackDeployer deployments.StackDeployer + UpgradeService upgrade.Service + AdminCreationDone chan struct{} + PendingActionsService *pendingactions.PendingActionsService + PlatformService platform.Service + PullLimitCheckDisabled bool + TrustedOrigins []string + SetupToken string +} + +// Start starts the HTTP server +func (server *Server) Start(ctx context.Context) error { + kubernetesTokenCacheManager := server.KubernetesTokenCacheManager + + requestBouncer := security.NewRequestBouncer(ctx, server.DataStore, server.JWTService, server.APIKeyService) + if !server.CSP { + requestBouncer.DisableCSP() + } + + rateLimiter := security.NewRateLimiter(10, 1*time.Second, 1*time.Hour) + offlineGate := offlinegate.NewOfflineGate() + + passwordStrengthChecker := security.NewPasswordStrengthChecker(server.DataStore.Settings()) + + var authHandler = auth.NewHandler(requestBouncer, rateLimiter, passwordStrengthChecker, server.KubernetesClientFactory) + authHandler.DataStore = server.DataStore + authHandler.CryptoService = server.CryptoService + authHandler.JWTService = server.JWTService + authHandler.LDAPService = server.LDAPService + authHandler.ProxyManager = server.ProxyManager + authHandler.KubernetesTokenCacheManager = kubernetesTokenCacheManager + authHandler.OAuthService = server.OAuthService + + adminMonitor := adminmonitor.New(5*time.Minute, server.DataStore) + adminMonitor.Start(ctx) + + var backupHandler = backup.NewHandler( + requestBouncer, + server.DataStore, + offlineGate, + server.FileService.GetDatastorePath(), + server.ShutdownTrigger, + adminMonitor, + ) + backupHandler.SetupToken = server.SetupToken + + var roleHandler = roles.NewHandler(requestBouncer) + roleHandler.DataStore = server.DataStore + + var customTemplatesHandler = customtemplates.NewHandler(requestBouncer, server.DataStore, server.FileService, server.GitService) + + var edgeGroupsHandler = edgegroups.NewHandler(requestBouncer) + edgeGroupsHandler.DataStore = server.DataStore + edgeGroupsHandler.ReverseTunnelService = server.ReverseTunnelService + + var edgeJobsHandler = edgejobs.NewHandler(requestBouncer) + edgeJobsHandler.DataStore = server.DataStore + edgeJobsHandler.FileService = server.FileService + edgeJobsHandler.ReverseTunnelService = server.ReverseTunnelService + + var edgeStacksHandler = edgestacks.NewHandler(requestBouncer, server.DataStore, server.EdgeStacksService) + edgeStacksHandler.FileService = server.FileService + edgeStacksHandler.GitService = server.GitService + edgeStacksHandler.KubernetesDeployer = server.KubernetesDeployer + + var endpointHandler = endpoints.NewHandler(requestBouncer) + endpointHandler.DataStore = server.DataStore + endpointHandler.FileService = server.FileService + endpointHandler.ProxyManager = server.ProxyManager + endpointHandler.SnapshotService = server.SnapshotService + endpointHandler.K8sClientFactory = server.KubernetesClientFactory + endpointHandler.DockerClientFactory = server.DockerClientFactory + endpointHandler.ReverseTunnelService = server.ReverseTunnelService + endpointHandler.ComposeStackManager = server.ComposeStackManager + endpointHandler.AuthorizationService = server.AuthorizationService + endpointHandler.BindAddress = server.BindAddress + endpointHandler.BindAddressHTTPS = server.BindAddressHTTPS + endpointHandler.PendingActionsService = server.PendingActionsService + endpointHandler.PullLimitCheckDisabled = server.PullLimitCheckDisabled + + var endpointEdgeHandler = endpointedge.NewHandler(requestBouncer, server.DataStore, server.FileService, server.ReverseTunnelService) + + var endpointGroupHandler = endpointgroups.NewHandler(requestBouncer) + endpointGroupHandler.AuthorizationService = server.AuthorizationService + endpointGroupHandler.DataStore = server.DataStore + endpointGroupHandler.PendingActionsService = server.PendingActionsService + + var endpointProxyHandler = endpointproxy.NewHandler(requestBouncer) + endpointProxyHandler.DataStore = server.DataStore + endpointProxyHandler.ProxyManager = server.ProxyManager + endpointProxyHandler.ReverseTunnelService = server.ReverseTunnelService + + var kubernetesHandler = kubehandler.NewHandler(requestBouncer, server.AuthorizationService, server.DataStore, server.JWTService, server.KubeClusterAccessService, server.KubernetesClientFactory, nil) + + containerService := docker.NewContainerService(server.DockerClientFactory, server.DataStore) + + var dockerHandler = dockerhandler.NewHandler(requestBouncer, server.AuthorizationService, server.DataStore, server.DockerClientFactory, containerService) + + var fileHandler = file.NewHandler(filepath.Join(server.AssetsPath, "public"), server.CSP, adminMonitor.WasInstanceDisabled) + + var endpointHelmHandler = helm.NewHandler(requestBouncer, server.DataStore, server.JWTService, server.KubernetesDeployer, server.HelmPackageManager, server.KubeClusterAccessService) + + var gitOperationHandler = gitops.NewHandler(requestBouncer, server.DataStore, server.GitService, server.FileService, server.KubernetesClientFactory) + + var helmTemplatesHandler = helm.NewTemplateHandler(requestBouncer, server.HelmPackageManager) + + var ldapHandler = ldap.NewHandler(requestBouncer) + ldapHandler.DataStore = server.DataStore + ldapHandler.FileService = server.FileService + ldapHandler.LDAPService = server.LDAPService + + motdSvc := motdservice.NewService(portainer.MessageOfTheDayURL) + motdSvc.Start(ctx) + + var motdHandler = motd.NewHandler(requestBouncer, motdSvc) + + var registryHandler = registries.NewHandler(requestBouncer) + registryHandler.DataStore = server.DataStore + registryHandler.FileService = server.FileService + registryHandler.ProxyManager = server.ProxyManager + registryHandler.K8sClientFactory = server.KubernetesClientFactory + + var resourceControlHandler = resourcecontrols.NewHandler(requestBouncer) + resourceControlHandler.DataStore = server.DataStore + + var settingsHandler = settings.NewHandler(requestBouncer) + settingsHandler.DataStore = server.DataStore + settingsHandler.FileService = server.FileService + settingsHandler.JWTService = server.JWTService + settingsHandler.LDAPService = server.LDAPService + settingsHandler.SnapshotService = server.SnapshotService + settingsHandler.SetupTokenRequired = server.SetupToken != "" + + var sslHandler = sslhandler.NewHandler(requestBouncer) + sslHandler.SSLService = server.SSLService + + var stackHandler = stacks.NewHandler(requestBouncer) + stackHandler.DataStore = server.DataStore + stackHandler.DockerClientFactory = server.DockerClientFactory + stackHandler.FileService = server.FileService + stackHandler.KubernetesClientFactory = server.KubernetesClientFactory + stackHandler.KubernetesDeployer = server.KubernetesDeployer + stackHandler.GitService = server.GitService + stackHandler.Scheduler = server.Scheduler + stackHandler.SwarmStackManager = server.SwarmStackManager + stackHandler.ComposeStackManager = server.ComposeStackManager + stackHandler.StackDeployer = server.StackDeployer + + var storybookHandler = storybook.NewHandler(server.AssetsPath) + + var tagHandler = tags.NewHandler(requestBouncer) + tagHandler.DataStore = server.DataStore + + var teamHandler = teams.NewHandler(requestBouncer) + teamHandler.DataStore = server.DataStore + + var teamMembershipHandler = teammemberships.NewHandler(requestBouncer) + teamMembershipHandler.DataStore = server.DataStore + teamMembershipHandler.K8sClientFactory = server.KubernetesClientFactory + + var systemHandler = system.NewHandler(requestBouncer, + server.Status, + server.DataStore, + server.PlatformService, + server.UpgradeService) + + var templatesHandler = templates.NewHandler(requestBouncer) + 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, server.APIKeyService, passwordStrengthChecker) + userHandler.DataStore = server.DataStore + userHandler.CryptoService = server.CryptoService + userHandler.AdminCreationDone = server.AdminCreationDone + userHandler.FileService = server.FileService + userHandler.SetupToken = server.SetupToken + + var websocketHandler = websocket.NewHandler(server.KubernetesTokenCacheManager, requestBouncer) + websocketHandler.DataStore = server.DataStore + websocketHandler.SignatureService = server.SignatureService + websocketHandler.ReverseTunnelService = server.ReverseTunnelService + websocketHandler.KubernetesClientFactory = server.KubernetesClientFactory + + var webhookHandler = webhooks.NewHandler(requestBouncer) + webhookHandler.DataStore = server.DataStore + webhookHandler.DockerClientFactory = server.DockerClientFactory + + server.Handler = &handler.Handler{ + RoleHandler: roleHandler, + AuthHandler: authHandler, + BackupHandler: backupHandler, + CustomTemplatesHandler: customTemplatesHandler, + DockerHandler: dockerHandler, + EdgeGroupsHandler: edgeGroupsHandler, + EdgeJobsHandler: edgeJobsHandler, + EdgeStacksHandler: edgeStacksHandler, + EndpointGroupHandler: endpointGroupHandler, + EndpointHandler: endpointHandler, + EndpointHelmHandler: endpointHelmHandler, + EndpointEdgeHandler: endpointEdgeHandler, + EndpointProxyHandler: endpointProxyHandler, + GitOperationHandler: gitOperationHandler, + FileHandler: fileHandler, + LDAPHandler: ldapHandler, + HelmTemplatesHandler: helmTemplatesHandler, + KubernetesHandler: kubernetesHandler, + MOTDHandler: motdHandler, + RegistryHandler: registryHandler, + ResourceControlHandler: resourceControlHandler, + SettingsHandler: settingsHandler, + SSLHandler: sslHandler, + StackHandler: stackHandler, + StorybookHandler: storybookHandler, + SystemHandler: systemHandler, + TagHandler: tagHandler, + TeamHandler: teamHandler, + TeamMembershipHandler: teamMembershipHandler, + TemplatesHandler: templatesHandler, + UploadHandler: uploadHandler, + UserHandler: userHandler, + WebSocketHandler: websocketHandler, + WebhookHandler: webhookHandler, + } + + errorLogger := NewHTTPLogger() + + handler := adminMonitor.WithRedirect(offlineGate.WaitingMiddleware(time.Minute, server.Handler)) + + handler = middlewares.WithPanicLogger(middlewares.WithSlowRequestsLogger(handler)) + + handler, err := csrf.WithProtect(handler, server.TrustedOrigins) + if err != nil { + return errors.Wrap(err, "failed to create CSRF middleware") + } + + if server.HTTPEnabled { + go func() { + log.Info().Str("bind_address", server.BindAddress).Msg("starting HTTP server") + httpServer := &http.Server{ + Addr: server.BindAddress, + Handler: handler, + ErrorLog: errorLogger, + } + + go shutdown(ctx, httpServer) + + err := httpServer.ListenAndServe() + if err != nil && err != http.ErrServerClosed { + log.Error().Err(err).Msg("HTTP server failed to start") + } + }() + } + + log.Info().Str("bind_address", server.BindAddressHTTPS).Msg("starting HTTPS server") + httpsServer := &http.Server{ + Addr: server.BindAddressHTTPS, + Handler: handler, + ErrorLog: errorLogger, + TLSNextProto: make(map[string]func(*http.Server, *tls.Conn, http.Handler)), // Disable HTTP/2 + } + + httpsServer.TLSConfig = crypto.CreateTLSConfiguration(false) + httpsServer.TLSConfig.GetCertificate = func(*tls.ClientHelloInfo) (*tls.Certificate, error) { + return server.SSLService.GetRawCertificate(), nil + } + + go shutdown(ctx, httpsServer) + go snapshot.NewBackgroundSnapshotter(server.DataStore, server.ReverseTunnelService) + + return httpsServer.ListenAndServeTLS("", "") +} + +func shutdown(shutdownCtx context.Context, httpServer *http.Server) { + <-shutdownCtx.Done() + + log.Debug().Msg("shutting down the HTTP server") + shutdownTimeout, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + err := httpServer.Shutdown(shutdownTimeout) + if err != nil { + log.Error(). + Err(err). + Msg("failed to shut down the HTTP server") + } +} diff --git a/api/http/utils/filters/filters.go b/api/http/utils/filters/filters.go new file mode 100644 index 0000000..2e22d19 --- /dev/null +++ b/api/http/utils/filters/filters.go @@ -0,0 +1,38 @@ +package filters + +import ( + "net/http" + "strconv" +) + +type FilterResult[T any] struct { + Items []T + TotalCount int + TotalAvailable int +} + +type Config[T any] struct { + SearchAccessors []SearchAccessor[T] + SortBindings []SortBinding[T] +} + +func SearchOrderAndPaginate[T any](items []T, params QueryParams, searchConfig Config[T]) FilterResult[T] { + totalAvailable := len(items) + + items = searchFn(items, params.SearchQueryParams, searchConfig.SearchAccessors) + items = sortFn(items, params.SortQueryParams, searchConfig.SortBindings) + + totalCount := len(items) + items = paginateFn(items, params.PaginationQueryParams) + + return FilterResult[T]{ + Items: items, + TotalCount: totalCount, + TotalAvailable: totalAvailable, + } +} + +func ApplyFilterResultsHeaders[T any](w *http.ResponseWriter, result FilterResult[T]) { + (*w).Header().Set("X-Total-Count", strconv.Itoa(result.TotalCount)) + (*w).Header().Set("X-Total-Available", strconv.Itoa(result.TotalAvailable)) +} diff --git a/api/http/utils/filters/filters_test.go b/api/http/utils/filters/filters_test.go new file mode 100644 index 0000000..5f74582 --- /dev/null +++ b/api/http/utils/filters/filters_test.go @@ -0,0 +1,468 @@ +package filters + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// Helper functions for creating test data +func createUsers() []User { + return []User{ + {ID: 1, Name: "Alice Johnson", Email: "alice@example.com", Age: 25}, + {ID: 2, Name: "Bob Smith", Email: "bob@example.com", Age: 30}, + {ID: 3, Name: "Charlie Brown", Email: "charlie@example.com", Age: 35}, + {ID: 4, Name: "Diana Prince", Email: "diana@example.com", Age: 28}, + {ID: 5, Name: "Eve Adams", Email: "eve@example.com", Age: 22}, + } +} + +func createProducts() []Product { + return []Product{ + {ID: 1, Name: "Laptop", Description: "High-performance laptop", Price: 999, Category: "Electronics"}, + {ID: 2, Name: "Mouse", Description: "Wireless mouse", Price: 29, Category: "Electronics"}, + {ID: 3, Name: "Book", Description: "Programming book", Price: 49, Category: "Books"}, + {ID: 4, Name: "Keyboard", Description: "Mechanical keyboard", Price: 129, Category: "Electronics"}, + {ID: 5, Name: "Chair", Description: "Office chair", Price: 199, Category: "Furniture"}, + } +} + +// Sort functions +func userNameSort(a, b User) int { + return strings.Compare(a.Name, b.Name) +} + +func userAgeSort(a, b User) int { + return a.Age - b.Age +} + +func productPriceSort(a, b Product) int { + if a.Price < b.Price { + return -1 + } + if a.Price > b.Price { + return 1 + } + return 0 +} + +func productNameSort(a, b Product) int { + return strings.Compare(a.Name, b.Name) +} + +func TestSearchOrderAndPaginate(t *testing.T) { + t.Parallel() + users := createUsers() + products := createProducts() + + userConfig := Config[User]{ + SearchAccessors: []SearchAccessor[User]{userNameAccessor, userEmailAccessor}, + SortBindings: []SortBinding[User]{ + {Key: "name", Fn: userNameSort}, + {Key: "age", Fn: userAgeSort}, + }, + } + + productConfig := Config[Product]{ + SearchAccessors: []SearchAccessor[Product]{productNameAccessor, productDescriptionAccessor, productCategoryAccessor}, + SortBindings: []SortBinding[Product]{ + {Key: "price", Fn: productPriceSort}, + {Key: "name", Fn: productNameSort}, + }, + } + + t.Run("no filters applied", func(t *testing.T) { + params := QueryParams{ + SearchQueryParams: SearchQueryParams{search: ""}, + SortQueryParams: SortQueryParams{sort: "", order: ""}, + PaginationQueryParams: PaginationQueryParams{start: 0, limit: 0}, + } + + result := SearchOrderAndPaginate(users, params, userConfig) + + require.Len(t, result.Items, 5, "Should return all items when no filters applied") + require.Equal(t, 5, result.TotalCount, "TotalCount should equal filtered items") + require.Equal(t, 5, result.TotalAvailable, "TotalAvailable should equal original items") + require.Equal(t, users, result.Items, "Items should be unchanged") + }) + + t.Run("search only", func(t *testing.T) { + params := QueryParams{ + SearchQueryParams: SearchQueryParams{search: "alice"}, + SortQueryParams: SortQueryParams{sort: "", order: ""}, + PaginationQueryParams: PaginationQueryParams{start: 0, limit: 0}, + } + + result := SearchOrderAndPaginate(users, params, userConfig) + + require.Len(t, result.Items, 1, "Should find one user matching 'alice'") + require.Equal(t, 1, result.TotalCount, "TotalCount should reflect filtered items") + require.Equal(t, 5, result.TotalAvailable, "TotalAvailable should be original count") + require.Equal(t, "Alice Johnson", result.Items[0].Name, "Should return Alice") + }) + + t.Run("search case insensitive", func(t *testing.T) { + params := QueryParams{ + SearchQueryParams: SearchQueryParams{search: "ALICE"}, + SortQueryParams: SortQueryParams{sort: "", order: ""}, + PaginationQueryParams: PaginationQueryParams{start: 0, limit: 0}, + } + + result := SearchOrderAndPaginate(users, params, userConfig) + + require.Len(t, result.Items, 1, "Search should be case insensitive") + require.Equal(t, "Alice Johnson", result.Items[0].Name, "Should return Alice") + }) + + t.Run("search by email", func(t *testing.T) { + params := QueryParams{ + SearchQueryParams: SearchQueryParams{search: "bob@example"}, + SortQueryParams: SortQueryParams{sort: "", order: ""}, + PaginationQueryParams: PaginationQueryParams{start: 0, limit: 0}, + } + + result := SearchOrderAndPaginate(users, params, userConfig) + + require.Len(t, result.Items, 1, "Should find user by email") + require.Equal(t, "Bob Smith", result.Items[0].Name, "Should return Bob") + }) + + t.Run("search no matches", func(t *testing.T) { + params := QueryParams{ + SearchQueryParams: SearchQueryParams{search: "nonexistent"}, + SortQueryParams: SortQueryParams{sort: "", order: ""}, + PaginationQueryParams: PaginationQueryParams{start: 0, limit: 0}, + } + + result := SearchOrderAndPaginate(users, params, userConfig) + + require.Empty(t, result.Items, "Should return empty when no matches") + require.Equal(t, 0, result.TotalCount, "TotalCount should be 0") + require.Equal(t, 5, result.TotalAvailable, "TotalAvailable should remain original count") + }) + + t.Run("search with whitespace", func(t *testing.T) { + params := QueryParams{ + SearchQueryParams: SearchQueryParams{search: " alice "}, + SortQueryParams: SortQueryParams{sort: "", order: ""}, + PaginationQueryParams: PaginationQueryParams{start: 0, limit: 0}, + } + + result := SearchOrderAndPaginate(users, params, userConfig) + + require.Len(t, result.Items, 1, "Should trim whitespace from search") + require.Equal(t, "Alice Johnson", result.Items[0].Name, "Should return Alice") + }) + + t.Run("sort ascending", func(t *testing.T) { + params := QueryParams{ + SearchQueryParams: SearchQueryParams{search: ""}, + SortQueryParams: SortQueryParams{sort: "name", order: SortAsc}, + PaginationQueryParams: PaginationQueryParams{start: 0, limit: 0}, + } + + result := SearchOrderAndPaginate(users, params, userConfig) + + require.Len(t, result.Items, 5, "Should return all items") + require.Equal(t, "Alice Johnson", result.Items[0].Name, "First should be Alice") + require.Equal(t, "Eve Adams", result.Items[4].Name, "Last should be Eve") + }) + + t.Run("sort descending", func(t *testing.T) { + params := QueryParams{ + SearchQueryParams: SearchQueryParams{search: ""}, + SortQueryParams: SortQueryParams{sort: "name", order: SortDesc}, + PaginationQueryParams: PaginationQueryParams{start: 0, limit: 0}, + } + + result := SearchOrderAndPaginate(users, params, userConfig) + + require.Len(t, result.Items, 5, "Should return all items") + require.Equal(t, "Eve Adams", result.Items[0].Name, "First should be Eve (desc order)") + require.Equal(t, "Alice Johnson", result.Items[4].Name, "Last should be Alice (desc order)") + }) + + t.Run("sort by age", func(t *testing.T) { + params := QueryParams{ + SearchQueryParams: SearchQueryParams{search: ""}, + SortQueryParams: SortQueryParams{sort: "age", order: SortAsc}, + PaginationQueryParams: PaginationQueryParams{start: 0, limit: 0}, + } + + result := SearchOrderAndPaginate(users, params, userConfig) + + require.Len(t, result.Items, 5, "Should return all items") + require.Equal(t, 22, result.Items[0].Age, "First should be youngest (22)") + require.Equal(t, 35, result.Items[4].Age, "Last should be oldest (35)") + }) + + t.Run("sort invalid key", func(t *testing.T) { + params := QueryParams{ + SearchQueryParams: SearchQueryParams{search: ""}, + SortQueryParams: SortQueryParams{sort: "invalid", order: SortAsc}, + PaginationQueryParams: PaginationQueryParams{start: 0, limit: 0}, + } + + result := SearchOrderAndPaginate(users, params, userConfig) + + require.Len(t, result.Items, 5, "Should return all items") + // Items should remain in original order since no valid sort key + require.Equal(t, users, result.Items, "Should maintain original order with invalid sort key") + }) + + t.Run("pagination basic", func(t *testing.T) { + params := QueryParams{ + SearchQueryParams: SearchQueryParams{search: ""}, + SortQueryParams: SortQueryParams{sort: "", order: ""}, + PaginationQueryParams: PaginationQueryParams{start: 1, limit: 2}, + } + + result := SearchOrderAndPaginate(users, params, userConfig) + + require.Len(t, result.Items, 2, "Should return 2 items") + require.Equal(t, 5, result.TotalCount, "TotalCount should be all items") + require.Equal(t, 5, result.TotalAvailable, "TotalAvailable should be original count") + require.Equal(t, users[1], result.Items[0], "Should start from index 1") + require.Equal(t, users[2], result.Items[1], "Should include index 2") + }) + + t.Run("pagination zero limit", func(t *testing.T) { + params := QueryParams{ + SearchQueryParams: SearchQueryParams{search: ""}, + SortQueryParams: SortQueryParams{sort: "", order: ""}, + PaginationQueryParams: PaginationQueryParams{start: 1, limit: 0}, + } + + result := SearchOrderAndPaginate(users, params, userConfig) + + require.Len(t, result.Items, 5, "Should return all items when limit is 0") + require.Equal(t, users, result.Items, "Should return all original items") + }) + + t.Run("pagination negative limit", func(t *testing.T) { + params := QueryParams{ + SearchQueryParams: SearchQueryParams{search: ""}, + SortQueryParams: SortQueryParams{sort: "", order: ""}, + PaginationQueryParams: PaginationQueryParams{start: 1, limit: -1}, + } + + result := SearchOrderAndPaginate(users, params, userConfig) + + require.Len(t, result.Items, 5, "Should return all items when limit is negative") + }) + + t.Run("pagination start beyond length", func(t *testing.T) { + params := QueryParams{ + SearchQueryParams: SearchQueryParams{search: ""}, + SortQueryParams: SortQueryParams{sort: "", order: ""}, + PaginationQueryParams: PaginationQueryParams{start: 10, limit: 2}, + } + + result := SearchOrderAndPaginate(users, params, userConfig) + + require.Empty(t, result.Items, "Should return empty slice when start is beyond length") + require.Equal(t, 5, result.TotalCount, "TotalCount should still be original count") + }) + + t.Run("pagination negative start", func(t *testing.T) { + params := QueryParams{ + SearchQueryParams: SearchQueryParams{search: ""}, + SortQueryParams: SortQueryParams{sort: "", order: ""}, + PaginationQueryParams: PaginationQueryParams{start: -1, limit: 2}, + } + + result := SearchOrderAndPaginate(users, params, userConfig) + + require.Len(t, result.Items, 2, "Should return 2 items starting from 0") + require.Equal(t, users[0], result.Items[0], "Should start from index 0") + require.Equal(t, users[1], result.Items[1], "Should include index 1") + }) + + t.Run("combined search sort and pagination", func(t *testing.T) { + params := QueryParams{ + SearchQueryParams: SearchQueryParams{search: "example.com"}, + SortQueryParams: SortQueryParams{sort: "age", order: SortAsc}, + PaginationQueryParams: PaginationQueryParams{start: 1, limit: 2}, + } + + result := SearchOrderAndPaginate(users, params, userConfig) + + // All users have "example.com" in email, so all 5 should match search + // Then sorted by age: Eve(22), Alice(25), Diana(28), Bob(30), Charlie(35) + // Then paginated: start=1, limit=2 should give Alice(25), Diana(28) + require.Len(t, result.Items, 2, "Should return 2 items after pagination") + require.Equal(t, 5, result.TotalCount, "TotalCount should be all filtered items") + require.Equal(t, 5, result.TotalAvailable, "TotalAvailable should be original count") + require.Equal(t, 25, result.Items[0].Age, "First item should be Alice (age 25)") + require.Equal(t, 28, result.Items[1].Age, "Second item should be Diana (age 28)") + }) + + t.Run("products test", func(t *testing.T) { + params := QueryParams{ + SearchQueryParams: SearchQueryParams{search: "electronics"}, + SortQueryParams: SortQueryParams{sort: "price", order: SortAsc}, + PaginationQueryParams: PaginationQueryParams{start: 0, limit: 2}, + } + + result := SearchOrderAndPaginate(products, params, productConfig) + + // Should find 3 electronics, sorted by price: Mouse(29.99), Keyboard(129.99), Laptop(999.99) + // Paginated to first 2: Mouse, Keyboard + require.Len(t, result.Items, 2, "Should return 2 items") + require.Equal(t, 3, result.TotalCount, "Should find 3 electronics items") + require.Equal(t, 5, result.TotalAvailable, "Should have 5 total products") + require.Equal(t, "Mouse", result.Items[0].Name, "First should be Mouse (cheapest)") + require.Equal(t, "Keyboard", result.Items[1].Name, "Second should be Keyboard") + }) + + t.Run("empty input slice", func(t *testing.T) { + emptyUsers := []User{} + params := QueryParams{ + SearchQueryParams: SearchQueryParams{search: "test"}, + SortQueryParams: SortQueryParams{sort: "name", order: SortAsc}, + PaginationQueryParams: PaginationQueryParams{start: 0, limit: 10}, + } + + result := SearchOrderAndPaginate(emptyUsers, params, userConfig) + + require.Empty(t, result.Items, "Should return empty slice") + require.Equal(t, 0, result.TotalCount, "TotalCount should be 0") + require.Equal(t, 0, result.TotalAvailable, "TotalAvailable should be 0") + }) +} + +func TestSearchOrderAndPaginateWithErrors(t *testing.T) { + t.Parallel() + users := createUsers() + + // Config with error-prone accessor + errorConfig := Config[User]{ + SearchAccessors: []SearchAccessor[User]{errorAccessor[User], userNameAccessor}, + SortBindings: []SortBinding[User]{ + {Key: "name", Fn: userNameSort}, + }, + } + + t.Run("search with accessor errors", func(t *testing.T) { + params := QueryParams{ + SearchQueryParams: SearchQueryParams{search: "alice"}, + SortQueryParams: SortQueryParams{sort: "", order: ""}, + PaginationQueryParams: PaginationQueryParams{start: 0, limit: 0}, + } + + result := SearchOrderAndPaginate(users, params, errorConfig) + + // Should still find Alice through the working accessor + require.Len(t, result.Items, 1, "Should find user despite error in first accessor") + require.Equal(t, "Alice Johnson", result.Items[0].Name, "Should return Alice") + }) +} + +func TestApplyFilterResultsHeaders(t *testing.T) { + t.Parallel() + t.Run("sets headers correctly", func(t *testing.T) { + w := httptest.NewRecorder() + var responseWriter http.ResponseWriter = w + result := FilterResult[User]{ + Items: createUsers()[:3], + TotalCount: 10, + TotalAvailable: 25, + } + + ApplyFilterResultsHeaders(&responseWriter, result) + + require.Equal(t, "10", w.Header().Get("X-Total-Count"), "Should set X-Total-Count header") + require.Equal(t, "25", w.Header().Get("X-Total-Available"), "Should set X-Total-Available header") + }) + + t.Run("sets headers with zero values", func(t *testing.T) { + w := httptest.NewRecorder() + var responseWriter http.ResponseWriter = w + result := FilterResult[User]{ + Items: []User{}, + TotalCount: 0, + TotalAvailable: 0, + } + + ApplyFilterResultsHeaders(&responseWriter, result) + + require.Equal(t, "0", w.Header().Get("X-Total-Count"), "Should set X-Total-Count to 0") + require.Equal(t, "0", w.Header().Get("X-Total-Available"), "Should set X-Total-Available to 0") + }) + + t.Run("overwrites existing headers", func(t *testing.T) { + w := httptest.NewRecorder() + var responseWriter http.ResponseWriter = w + w.Header().Set("X-Total-Count", "999") + w.Header().Set("X-Total-Available", "999") + + result := FilterResult[User]{ + Items: createUsers()[:2], + TotalCount: 5, + TotalAvailable: 15, + } + + ApplyFilterResultsHeaders(&responseWriter, result) + + require.Equal(t, "5", w.Header().Get("X-Total-Count"), "Should overwrite existing X-Total-Count") + require.Equal(t, "15", w.Header().Get("X-Total-Available"), "Should overwrite existing X-Total-Available") + }) + + t.Run("simulates real handler usage", func(t *testing.T) { + // Simulate how it's actually used in handlers + handler := func(w http.ResponseWriter, r *http.Request) { + result := FilterResult[Product]{ + Items: createProducts(), + TotalCount: 5, + TotalAvailable: 10, + } + ApplyFilterResultsHeaders(&w, result) + } + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/test", nil) + + handler(w, req) + + require.Equal(t, "5", w.Header().Get("X-Total-Count"), "Should work in handler context") + require.Equal(t, "10", w.Header().Get("X-Total-Available"), "Should work in handler context") + }) +} + +// Benchmark tests +func BenchmarkSearchOrderAndPaginate(b *testing.B) { + users := createUsers() + config := Config[User]{ + SearchAccessors: []SearchAccessor[User]{userNameAccessor, userEmailAccessor}, + SortBindings: []SortBinding[User]{ + {Key: "name", Fn: userNameSort}, + {Key: "age", Fn: userAgeSort}, + }, + } + params := QueryParams{ + SearchQueryParams: SearchQueryParams{search: "example"}, + SortQueryParams: SortQueryParams{sort: "name", order: SortAsc}, + PaginationQueryParams: PaginationQueryParams{start: 0, limit: 10}, + } + + for b.Loop() { + SearchOrderAndPaginate(users, params, config) + } +} + +func BenchmarkApplyFilterResultsHeaders(b *testing.B) { + w := httptest.NewRecorder() + var responseWriter http.ResponseWriter = w + result := FilterResult[User]{ + Items: createUsers(), + TotalCount: 100, + TotalAvailable: 500, + } + + for b.Loop() { + ApplyFilterResultsHeaders(&responseWriter, result) + } +} diff --git a/api/http/utils/filters/pagination.go b/api/http/utils/filters/pagination.go new file mode 100644 index 0000000..fd01eb8 --- /dev/null +++ b/api/http/utils/filters/pagination.go @@ -0,0 +1,22 @@ +package filters + +type PaginationQueryParams struct { + start int + limit int +} + +func paginateFn[T any](items []T, params PaginationQueryParams) []T { + if params.limit <= 0 { + return items + } + + itemsCount := len(items) + + // enforce start in [0, len(items)] + start := min(max(params.start, 0), itemsCount) + + // enforce end <= len(items) (max is unnecessary since limit > 0 and start >= 0) + end := min(start+params.limit, itemsCount) + + return items[start:end] +} diff --git a/api/http/utils/filters/pagination_test.go b/api/http/utils/filters/pagination_test.go new file mode 100644 index 0000000..e8cb2e7 --- /dev/null +++ b/api/http/utils/filters/pagination_test.go @@ -0,0 +1,261 @@ +package filters + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestPaginateFn_BasicPagination(t *testing.T) { + t.Parallel() + items := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10} + + // First page + params := PaginationQueryParams{start: 0, limit: 3} + result := paginateFn(items, params) + require.Equal(t, []int{1, 2, 3}, result) + + // Second page + params = PaginationQueryParams{start: 3, limit: 3} + result = paginateFn(items, params) + require.Equal(t, []int{4, 5, 6}, result) + + // Third page + params = PaginationQueryParams{start: 6, limit: 3} + result = paginateFn(items, params) + require.Equal(t, []int{7, 8, 9}, result) + + // Last partial page + params = PaginationQueryParams{start: 9, limit: 3} + result = paginateFn(items, params) + require.Equal(t, []int{10}, result) +} + +func TestPaginateFn_ZeroLimit(t *testing.T) { + t.Parallel() + items := []int{1, 2, 3, 4, 5} + + params := PaginationQueryParams{start: 2, limit: 0} + result := paginateFn(items, params) + + // Should return all items when limit is 0 + require.Equal(t, items, result) +} + +func TestPaginateFn_NegativeLimit(t *testing.T) { + t.Parallel() + items := []int{1, 2, 3, 4, 5} + + params := PaginationQueryParams{start: 2, limit: -5} + result := paginateFn(items, params) + + // Should return all items when limit is negative + require.Equal(t, items, result) +} + +func TestPaginateFn_NegativeStart(t *testing.T) { + t.Parallel() + items := []int{1, 2, 3, 4, 5} + + params := PaginationQueryParams{start: -3, limit: 2} + result := paginateFn(items, params) + + // Should start from index 0 when start is negative + require.Equal(t, []int{1, 2}, result) +} + +func TestPaginateFn_StartBeyondLength(t *testing.T) { + t.Parallel() + items := []int{1, 2, 3, 4, 5} + + params := PaginationQueryParams{start: 10, limit: 3} + result := paginateFn(items, params) + + // Should return empty slice when start is beyond length + require.Empty(t, result) +} + +func TestPaginateFn_StartAtLength(t *testing.T) { + t.Parallel() + items := []int{1, 2, 3, 4, 5} + + params := PaginationQueryParams{start: 5, limit: 3} + result := paginateFn(items, params) + + // Should return empty slice when start equals length + require.Empty(t, result) +} + +func TestPaginateFn_LimitLargerThanRemaining(t *testing.T) { + t.Parallel() + items := []int{1, 2, 3, 4, 5} + + params := PaginationQueryParams{start: 3, limit: 10} + result := paginateFn(items, params) + + // Should return remaining items when limit exceeds available items + require.Equal(t, []int{4, 5}, result) +} + +func TestPaginateFn_EmptySlice(t *testing.T) { + t.Parallel() + items := []int{} + + params := PaginationQueryParams{start: 0, limit: 5} + result := paginateFn(items, params) + + // Should return empty slice + require.Empty(t, result) +} + +func TestPaginateFn_EmptySliceWithNegativeStart(t *testing.T) { + t.Parallel() + items := []int{} + + params := PaginationQueryParams{start: -5, limit: 3} + result := paginateFn(items, params) + + // Should return empty slice + require.Empty(t, result) +} + +func TestPaginateFn_SingleElement(t *testing.T) { + t.Parallel() + items := []int{42} + + // Take the single element + params := PaginationQueryParams{start: 0, limit: 1} + result := paginateFn(items, params) + require.Equal(t, []int{42}, result) + + // Start beyond the single element + params = PaginationQueryParams{start: 1, limit: 1} + result = paginateFn(items, params) + require.Empty(t, result) +} + +func TestPaginateFn_LimitOfOne(t *testing.T) { + t.Parallel() + items := []int{1, 2, 3, 4, 5} + + results := [][]int{} + for i := range items { + params := PaginationQueryParams{start: i, limit: 1} + result := paginateFn(items, params) + results = append(results, result) + } + + expected := [][]int{ + {1}, {2}, {3}, {4}, {5}, + } + require.Equal(t, expected, results) +} + +func TestPaginateFn_StringSlice(t *testing.T) { + t.Parallel() + items := []string{"apple", "banana", "cherry", "date", "elderberry"} + + params := PaginationQueryParams{start: 1, limit: 3} + result := paginateFn(items, params) + + require.Equal(t, []string{"banana", "cherry", "date"}, result) +} + +func TestPaginateFn_StructSlice(t *testing.T) { + t.Parallel() + type User struct { + ID int + Name string + } + + users := []User{ + {ID: 1, Name: "Alice"}, + {ID: 2, Name: "Bob"}, + {ID: 3, Name: "Charlie"}, + {ID: 4, Name: "David"}, + } + + params := PaginationQueryParams{start: 1, limit: 2} + result := paginateFn(users, params) + + expected := []User{ + {ID: 2, Name: "Bob"}, + {ID: 3, Name: "Charlie"}, + } + require.Equal(t, expected, result) +} + +func TestPaginateFn_BoundaryConditions(t *testing.T) { + t.Parallel() + items := []int{1, 2, 3, 4, 5} + + testCases := []struct { + name string + start int + limit int + expected []int + }{ + {"start=0, limit=0", 0, 0, []int{1, 2, 3, 4, 5}}, + {"start=0, limit=5", 0, 5, []int{1, 2, 3, 4, 5}}, + {"start=0, limit=6", 0, 6, []int{1, 2, 3, 4, 5}}, + {"start=4, limit=1", 4, 1, []int{5}}, + {"start=4, limit=2", 4, 2, []int{5}}, + {"start=5, limit=1", 5, 1, []int{}}, + {"start=-1, limit=1", -1, 1, []int{1}}, + {"start=-10, limit=3", -10, 3, []int{1, 2, 3}}, + {"start=100, limit=1", 100, 1, []int{}}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + params := PaginationQueryParams{start: tc.start, limit: tc.limit} + result := paginateFn(items, params) + require.Equal(t, tc.expected, result) + }) + } +} + +func TestPaginateFn_ReturnsSliceView(t *testing.T) { + t.Parallel() + items := []int{1, 2, 3, 4, 5} + + params := PaginationQueryParams{start: 1, limit: 3} + result := paginateFn(items, params) + + // Result should be a slice view of the original + require.Equal(t, []int{2, 3, 4}, result) + + // Modifying result WILL affect the original slice (shares underlying array) + if len(result) > 0 { + result[0] = 999 + require.Equal(t, 999, items[1]) // Original is modified because they share memory + } +} + +func TestPaginateFn_TypicalAPIUseCases(t *testing.T) { + t.Parallel() + // Simulate API responses with different page sizes + items := make([]int, 100) + for i := range items { + items[i] = i + 1 + } + + // Page size 10 + params := PaginationQueryParams{start: 0, limit: 10} + page1 := paginateFn(items, params) + require.Len(t, page1, 10) + require.Equal(t, []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, page1) + + // Page size 20, offset 20 + params = PaginationQueryParams{start: 20, limit: 20} + page2 := paginateFn(items, params) + require.Len(t, page2, 20) + require.Equal(t, 21, page2[0]) + require.Equal(t, 40, page2[19]) + + // Last page (partial) + params = PaginationQueryParams{start: 95, limit: 10} + lastPage := paginateFn(items, params) + require.Len(t, lastPage, 5) + require.Equal(t, []int{96, 97, 98, 99, 100}, lastPage) +} diff --git a/api/http/utils/filters/query_params.go b/api/http/utils/filters/query_params.go new file mode 100644 index 0000000..7dec803 --- /dev/null +++ b/api/http/utils/filters/query_params.go @@ -0,0 +1,38 @@ +package filters + +import ( + "net/http" + + "github.com/portainer/portainer/pkg/libhttp/request" +) + +type QueryParams struct { + SearchQueryParams + SortQueryParams + PaginationQueryParams +} + +func ExtractListModifiersQueryParams(r *http.Request) QueryParams { + // search + search, _ := request.RetrieveQueryParameter(r, "search", true) + // sorting + sortField, _ := request.RetrieveQueryParameter(r, "sort", true) + sortOrder, _ := request.RetrieveQueryParameter(r, "order", true) + // pagination + start, _ := request.RetrieveNumericQueryParameter(r, "start", true) + limit, _ := request.RetrieveNumericQueryParameter(r, "limit", true) + + return QueryParams{ + SearchQueryParams{ + search: search, + }, + SortQueryParams{ + sort: sortField, + order: SortOrder(sortOrder), + }, + PaginationQueryParams{ + start: start, + limit: limit, + }, + } +} diff --git a/api/http/utils/filters/query_params_test.go b/api/http/utils/filters/query_params_test.go new file mode 100644 index 0000000..c152862 --- /dev/null +++ b/api/http/utils/filters/query_params_test.go @@ -0,0 +1,300 @@ +package filters + +import ( + "net/http" + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestExtractListModifiersQueryParams(t *testing.T) { + t.Parallel() + tests := []struct { + name string + queryParams map[string]string + expectedResult QueryParams + description string + }{ + { + name: "all parameters provided", + queryParams: map[string]string{ + "search": "test query", + "sort": "name", + "order": "asc", + "start": "10", + "limit": "25", + }, + expectedResult: QueryParams{ + SearchQueryParams: SearchQueryParams{ + search: "test query", + }, + SortQueryParams: SortQueryParams{ + sort: "name", + order: SortAsc, + }, + PaginationQueryParams: PaginationQueryParams{ + start: 10, + limit: 25, + }, + }, + description: "Should correctly parse all query parameters when provided", + }, + { + name: "descending sort order", + queryParams: map[string]string{ + "search": "another test", + "sort": "date", + "order": "desc", + "start": "0", + "limit": "50", + }, + expectedResult: QueryParams{ + SearchQueryParams: SearchQueryParams{ + search: "another test", + }, + SortQueryParams: SortQueryParams{ + sort: "date", + order: SortDesc, + }, + PaginationQueryParams: PaginationQueryParams{ + start: 0, + limit: 50, + }, + }, + description: "Should correctly handle descending sort order", + }, + { + name: "no parameters provided", + queryParams: map[string]string{}, + expectedResult: QueryParams{ + SearchQueryParams: SearchQueryParams{ + search: "", + }, + SortQueryParams: SortQueryParams{ + sort: "", + order: SortOrder(""), + }, + PaginationQueryParams: PaginationQueryParams{ + start: 0, + limit: 0, + }, + }, + description: "Should return zero values when no parameters are provided", + }, + { + name: "partial parameters - search only", + queryParams: map[string]string{ + "search": "partial test", + }, + expectedResult: QueryParams{ + SearchQueryParams: SearchQueryParams{ + search: "partial test", + }, + SortQueryParams: SortQueryParams{ + sort: "", + order: SortOrder(""), + }, + PaginationQueryParams: PaginationQueryParams{ + start: 0, + limit: 0, + }, + }, + description: "Should handle partial parameters correctly", + }, + { + name: "partial parameters - pagination only", + queryParams: map[string]string{ + "start": "5", + "limit": "15", + }, + expectedResult: QueryParams{ + SearchQueryParams: SearchQueryParams{ + search: "", + }, + SortQueryParams: SortQueryParams{ + sort: "", + order: SortOrder(""), + }, + PaginationQueryParams: PaginationQueryParams{ + start: 5, + limit: 15, + }, + }, + description: "Should handle pagination parameters when other params are missing", + }, + { + name: "invalid sort order", + queryParams: map[string]string{ + "search": "test", + "sort": "name", + "order": "invalid", + "start": "0", + "limit": "10", + }, + expectedResult: QueryParams{ + SearchQueryParams: SearchQueryParams{ + search: "test", + }, + SortQueryParams: SortQueryParams{ + sort: "name", + order: SortOrder("invalid"), + }, + PaginationQueryParams: PaginationQueryParams{ + start: 0, + limit: 10, + }, + }, + description: "Should accept invalid sort order as SortOrder type", + }, + { + name: "empty string values", + queryParams: map[string]string{ + "search": "", + "sort": "", + "order": "", + "start": "0", + "limit": "0", + }, + expectedResult: QueryParams{ + SearchQueryParams: SearchQueryParams{ + search: "", + }, + SortQueryParams: SortQueryParams{ + sort: "", + order: SortOrder(""), + }, + PaginationQueryParams: PaginationQueryParams{ + start: 0, + limit: 0, + }, + }, + description: "Should handle empty string values correctly", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create HTTP request with query parameters + req := createRequestWithParams(tt.queryParams) + + // Execute the function + result := ExtractListModifiersQueryParams(req) + + // Assertions + require.Equal(t, tt.expectedResult.search, result.search, + "Search parameter should match expected value") + require.Equal(t, tt.expectedResult.sort, result.sort, + "Sort parameter should match expected value") + require.Equal(t, tt.expectedResult.order, result.order, + "Order parameter should match expected value") + require.Equal(t, tt.expectedResult.start, result.start, + "Start parameter should match expected value") + require.Equal(t, tt.expectedResult.limit, result.limit, + "Limit parameter should match expected value") + + // Verify the complete struct + require.Equal(t, tt.expectedResult, result, tt.description) + }) + } +} + +func TestSortOrderConstants(t *testing.T) { + t.Parallel() + require.Equal(t, SortAsc, SortOrder("asc"), "SortAsc constant should equal 'asc'") + require.Equal(t, SortDesc, SortOrder("desc"), "SortDesc constant should equal 'desc'") +} + +func TestQueryParamsStructEmbedding(t *testing.T) { + t.Parallel() + qp := QueryParams{ + SearchQueryParams: SearchQueryParams{search: "test"}, + SortQueryParams: SortQueryParams{sort: "name", order: SortAsc}, + PaginationQueryParams: PaginationQueryParams{start: 10, limit: 20}, + } + + // Test that embedded fields are accessible + require.Equal(t, "test", qp.search, "Embedded search field should be accessible") + require.Equal(t, "name", qp.sort, "Embedded sort field should be accessible") + require.Equal(t, SortAsc, qp.order, "Embedded order field should be accessible") + require.Equal(t, 10, qp.start, "Embedded start field should be accessible") + require.Equal(t, 20, qp.limit, "Embedded limit field should be accessible") +} + +func TestExtractListModifiersQueryParamsEdgeCases(t *testing.T) { + t.Parallel() + t.Run("special characters in search", func(t *testing.T) { + req := createRequestWithParams(map[string]string{ + "search": "test & special chars %20", + }) + + result := ExtractListModifiersQueryParams(req) + require.Equal(t, "test & special chars %20", result.search, + "Should handle special characters in search parameter") + }) + + t.Run("unicode characters", func(t *testing.T) { + req := createRequestWithParams(map[string]string{ + "search": "test 测试 🔍", + "sort": "título", + }) + + result := ExtractListModifiersQueryParams(req) + require.Equal(t, "test 测试 🔍", result.search, "Should handle unicode in search") + require.Equal(t, "título", result.sort, "Should handle unicode in sort field") + }) + + t.Run("very long values", func(t *testing.T) { + longSearch := "a very long search query that contains many words and goes on for quite some time to test handling of long strings" + req := createRequestWithParams(map[string]string{ + "search": longSearch, + }) + + result := ExtractListModifiersQueryParams(req) + require.Equal(t, longSearch, result.search, "Should handle long search strings") + }) +} + +// Helper function to create HTTP request with query parameters +func createRequestWithParams(params map[string]string) *http.Request { + // Create URL with query parameters + u := &url.URL{ + Scheme: "https", + Host: "example.com", + Path: "/test", + } + + // Add query parameters + q := u.Query() + for key, value := range params { + q.Set(key, value) + } + u.RawQuery = q.Encode() + + // Create request + req, _ := http.NewRequest("GET", u.String(), nil) + return req +} + +// Benchmark tests +func BenchmarkExtractListModifiersQueryParams(b *testing.B) { + req := createRequestWithParams(map[string]string{ + "search": "benchmark test", + "sort": "name", + "order": "asc", + "start": "10", + "limit": "25", + }) + + for b.Loop() { + ExtractListModifiersQueryParams(req) + } +} + +func BenchmarkExtractListModifiersQueryParamsEmpty(b *testing.B) { + req := createRequestWithParams(map[string]string{}) + + for b.Loop() { + ExtractListModifiersQueryParams(req) + } +} diff --git a/api/http/utils/filters/search.go b/api/http/utils/filters/search.go new file mode 100644 index 0000000..b88422d --- /dev/null +++ b/api/http/utils/filters/search.go @@ -0,0 +1,36 @@ +package filters + +import ( + "strings" +) + +// Return any error to skip the field (for when matching an unknown state on an enum) +// +// Note: returning ("", nil) will match! +type SearchAccessor[T any] = func(T) (string, error) + +type SearchQueryParams struct { + search string +} + +func searchFn[T any](items []T, params SearchQueryParams, accessors []SearchAccessor[T]) []T { + search := strings.TrimSpace(params.search) + + if search == "" { + return items + } + + results := []T{} + + for iIdx := range items { + for aIdx := range accessors { + value, err := accessors[aIdx](items[iIdx]) + if err == nil && strings.Contains(strings.ToLower(value), strings.ToLower(search)) { + results = append(results, items[iIdx]) + break + } + } + } + + return results +} diff --git a/api/http/utils/filters/search_test.go b/api/http/utils/filters/search_test.go new file mode 100644 index 0000000..695a091 --- /dev/null +++ b/api/http/utils/filters/search_test.go @@ -0,0 +1,299 @@ +package filters + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSearchFn_BasicSearch(t *testing.T) { + t.Parallel() + users := []User{ + {ID: 1, Name: "Alice Smith", Email: "alice@example.com", Age: 25}, + {ID: 2, Name: "Bob Johnson", Email: "bob@company.com", Age: 30}, + {ID: 3, Name: "Charlie Brown", Email: "charlie@test.org", Age: 35}, + } + + accessors := []SearchAccessor[User]{userNameAccessor, userEmailAccessor} + params := SearchQueryParams{search: "Alice"} + + result := searchFn(users, params, accessors) + + require.Len(t, result, 1) + require.Equal(t, "Alice Smith", result[0].Name) +} + +func TestSearchFn_EmptySearch(t *testing.T) { + t.Parallel() + users := []User{ + {ID: 1, Name: "Alice Smith", Email: "alice@example.com", Age: 25}, + {ID: 2, Name: "Bob Johnson", Email: "bob@company.com", Age: 30}, + } + + accessors := []SearchAccessor[User]{userNameAccessor, userEmailAccessor} + params := SearchQueryParams{search: ""} + + result := searchFn(users, params, accessors) + + // Should return all items when search is empty + require.Equal(t, users, result) +} + +func TestSearchFn_NoMatches(t *testing.T) { + t.Parallel() + users := []User{ + {ID: 1, Name: "Alice Smith", Email: "alice@example.com", Age: 25}, + {ID: 2, Name: "Bob Johnson", Email: "bob@company.com", Age: 30}, + } + + accessors := []SearchAccessor[User]{userNameAccessor, userEmailAccessor} + params := SearchQueryParams{search: "nonexistent"} + + result := searchFn(users, params, accessors) + + require.Empty(t, result) +} + +func TestSearchFn_MultipleMatches(t *testing.T) { + t.Parallel() + users := []User{ + {ID: 1, Name: "Alice Smith", Email: "alice@example.com", Age: 25}, + {ID: 2, Name: "Bob Smith", Email: "bob@company.com", Age: 30}, + {ID: 3, Name: "Charlie Brown", Email: "charlie@smith.org", Age: 35}, + } + + accessors := []SearchAccessor[User]{userNameAccessor, userEmailAccessor} + params := SearchQueryParams{search: "Smith"} + + result := searchFn(users, params, accessors) + + require.Len(t, result, 3) + require.Equal(t, "Alice Smith", result[0].Name) + require.Equal(t, "Bob Smith", result[1].Name) + require.Equal(t, "Charlie Brown", result[2].Name) // Matches via email +} + +func TestSearchFn_MultipleAccessors(t *testing.T) { + t.Parallel() + users := []User{ + {ID: 1, Name: "Alice Smith", Email: "alice@example.com", Age: 25}, + {ID: 2, Name: "Bob Johnson", Email: "bob@company.com", Age: 30}, + {ID: 3, Name: "Charlie Brown", Email: "charlie@test.org", Age: 35}, + } + + // Search across name, email, and ID + accessors := []SearchAccessor[User]{userNameAccessor, userEmailAccessor, userIDAccessor} + + // Search by ID + params := SearchQueryParams{search: "2"} + result := searchFn(users, params, accessors) + require.Len(t, result, 1) + require.Equal(t, 2, result[0].ID) + + // Search by email domain + params = SearchQueryParams{search: "company.com"} + result = searchFn(users, params, accessors) + require.Len(t, result, 1) + require.Equal(t, "Bob Johnson", result[0].Name) +} + +func TestSearchFn_CaseSensitive(t *testing.T) { + t.Parallel() + users := []User{ + {ID: 1, Name: "Alice Smith", Email: "alice@example.com", Age: 25}, + {ID: 2, Name: "Bob Johnson", Email: "bob@company.com", Age: 30}, + } + + accessors := []SearchAccessor[User]{userNameAccessor, userEmailAccessor} + + // Case sensitive search - should not match + params := SearchQueryParams{search: "alice"} + result := searchFn(users, params, accessors) + require.Len(t, result, 1) // Matches email which is lowercase + + // Exact case match + params = SearchQueryParams{search: "Alice"} + result = searchFn(users, params, accessors) + require.Len(t, result, 1) + require.Equal(t, "Alice Smith", result[0].Name) +} + +func TestSearchFn_PartialMatches(t *testing.T) { + t.Parallel() + products := []Product{ + {ID: 1, Name: "Wireless Mouse", Description: "Ergonomic wireless mouse", Price: 25, Category: "Electronics"}, + {ID: 2, Name: "Mechanical Keyboard", Description: "RGB gaming keyboard", Price: 150, Category: "Electronics"}, + {ID: 3, Name: "Coffee Mug", Description: "Ceramic coffee mug", Price: 15, Category: "Kitchen"}, + } + + accessors := []SearchAccessor[Product]{productNameAccessor, productDescriptionAccessor} + + // Partial word match + params := SearchQueryParams{search: "wire"} + result := searchFn(products, params, accessors) + require.Len(t, result, 1) + require.Equal(t, "Wireless Mouse", result[0].Name) + + // Match in description + params = SearchQueryParams{search: "RGB"} + result = searchFn(products, params, accessors) + require.Len(t, result, 1) + require.Equal(t, "Mechanical Keyboard", result[0].Name) +} + +func TestSearchFn_EmptySlice(t *testing.T) { + t.Parallel() + users := []User{} + accessors := []SearchAccessor[User]{userNameAccessor, userEmailAccessor} + params := SearchQueryParams{search: "anything"} + + result := searchFn(users, params, accessors) + + require.Empty(t, result) +} + +func TestSearchFn_EmptyAccessors(t *testing.T) { + t.Parallel() + users := []User{ + {ID: 1, Name: "Alice Smith", Email: "alice@example.com", Age: 25}, + } + + accessors := []SearchAccessor[User]{} // No accessors + params := SearchQueryParams{search: "Alice"} + + result := searchFn(users, params, accessors) + + // Should return empty since no accessors to search through + require.Empty(t, result) +} + +func TestSearchFn_SingleAccessor(t *testing.T) { + t.Parallel() + users := []User{ + {ID: 1, Name: "Alice Smith", Email: "alice@example.com", Age: 25}, + {ID: 2, Name: "Bob Johnson", Email: "bob@company.com", Age: 30}, + } + + // Only search by name + accessors := []SearchAccessor[User]{userNameAccessor} + params := SearchQueryParams{search: "company.com"} + + result := searchFn(users, params, accessors) + + // Should not match since we're only searching names, not emails + require.Empty(t, result) +} + +func TestSearchFn_NumericSearch(t *testing.T) { + t.Parallel() + users := []User{ + {ID: 1, Name: "Alice Smith", Email: "alice@example.com", Age: 25}, + {ID: 2, Name: "Bob Johnson", Email: "bob@company.com", Age: 30}, + {ID: 3, Name: "Charlie Brown", Email: "charlie@test.org", Age: 35}, + } + + // Search by age (converted to string) + accessors := []SearchAccessor[User]{userAgeAccessor} + params := SearchQueryParams{search: "30"} + + result := searchFn(users, params, accessors) + + require.Len(t, result, 1) + require.Equal(t, 30, result[0].Age) +} + +func TestSearchFn_FormattedAccessor(t *testing.T) { + t.Parallel() + products := []Product{ + {ID: 1, Name: "Mouse", Description: "Wireless mouse", Price: 25, Category: "Electronics"}, + {ID: 2, Name: "Keyboard", Description: "Gaming keyboard", Price: 150, Category: "Electronics"}, + } + + // Search by formatted price (e.g., "$25") + accessors := []SearchAccessor[Product]{productPriceAccessor} + params := SearchQueryParams{search: "$25"} + + result := searchFn(products, params, accessors) + + require.Len(t, result, 1) + require.Equal(t, "Mouse", result[0].Name) +} + +func TestSearchFn_FirstMatchOnly(t *testing.T) { + t.Parallel() + users := []User{ + {ID: 1, Name: "test user", Email: "test@example.com", Age: 25}, + } + + // Both accessors would match the search term + accessors := []SearchAccessor[User]{userNameAccessor, userEmailAccessor} + params := SearchQueryParams{search: "test"} + + result := searchFn(users, params, accessors) + + // Should only include the item once, even though multiple accessors match + require.Len(t, result, 1) + require.Equal(t, "test user", result[0].Name) +} + +func TestSearchFn_PreservesOrder(t *testing.T) { + t.Parallel() + users := []User{ + {ID: 1, Name: "Alice Test", Email: "alice@example.com", Age: 25}, + {ID: 2, Name: "Bob Johnson", Email: "bob@test.com", Age: 30}, + {ID: 3, Name: "Charlie Test", Email: "charlie@example.com", Age: 35}, + } + + accessors := []SearchAccessor[User]{userNameAccessor, userEmailAccessor} + params := SearchQueryParams{search: "Test"} + + result := searchFn(users, params, accessors) + + require.Len(t, result, 3) + // Should preserve original order + require.Equal(t, 1, result[0].ID) + require.Equal(t, 2, result[1].ID) + require.Equal(t, 3, result[2].ID) +} + +func TestSearchFn_ComplexSearch(t *testing.T) { + t.Parallel() + products := []Product{ + {ID: 1, Name: "Gaming Mouse", Description: "High-DPI gaming mouse", Price: 75, Category: "Gaming"}, + {ID: 2, Name: "Office Mouse", Description: "Ergonomic office mouse", Price: 25, Category: "Office"}, + {ID: 3, Name: "Gaming Keyboard", Description: "Mechanical gaming keyboard", Price: 150, Category: "Gaming"}, + {ID: 4, Name: "Wireless Headset", Description: "Gaming headset with mic", Price: 100, Category: "Gaming"}, + } + + // Search across multiple fields + accessors := []SearchAccessor[Product]{ + productNameAccessor, + productDescriptionAccessor, + productCategoryAccessor, + } + + params := SearchQueryParams{search: "Gaming"} + + result := searchFn(products, params, accessors) + + require.Len(t, result, 3) + require.Equal(t, "Gaming Mouse", result[0].Name) + require.Equal(t, "Gaming Keyboard", result[1].Name) + require.Equal(t, "Wireless Headset", result[2].Name) +} + +func TestSearchFn_WhitespaceSearch(t *testing.T) { + t.Parallel() + users := []User{ + {ID: 1, Name: "Alice Smith", Email: "alice@example.com", Age: 25}, + {ID: 2, Name: "Bob Johnson", Email: "bob@company.com", Age: 30}, + } + + accessors := []SearchAccessor[User]{userNameAccessor, userEmailAccessor} + + // Search with just whitespace should be treated as empty + params := SearchQueryParams{search: " "} + result := searchFn(users, params, accessors) + + require.Len(t, result, 2) +} diff --git a/api/http/utils/filters/sort.go b/api/http/utils/filters/sort.go new file mode 100644 index 0000000..354a4a6 --- /dev/null +++ b/api/http/utils/filters/sort.go @@ -0,0 +1,62 @@ +package filters + +import "slices" + +type SortOrder string + +const ( + SortAsc SortOrder = "asc" + SortDesc SortOrder = "desc" +) + +type SortQueryParams struct { + sort string + order SortOrder +} + +type SortOption[T any] func(a, b T) int +type SortBinding[T any] struct { + Key string + Fn SortOption[T] + NullsLast func(T) bool // if set, items where this returns true always sort after all others +} + +func sortFn[T any](items []T, params SortQueryParams, sorts []SortBinding[T]) []T { + for _, sort := range sorts { + if sort.Key == params.sort { + fn := sort.Fn + if params.order == SortDesc { + fn = reverSortFn(fn) + } + if sort.NullsLast != nil { + fn = nullsLastWrap(fn, sort.NullsLast) + } + slices.SortStableFunc(items, fn) + } + } + return items +} + +func reverSortFn[T any](fn SortOption[T]) SortOption[T] { + return func(a, b T) int { + return -1 * fn(a, b) + } +} + +// nullsLastWrap wraps a comparator so that items where isNull returns true +// always sort after all others, regardless of sort direction. +func nullsLastWrap[T any](fn SortOption[T], isNull func(T) bool) SortOption[T] { + return func(a, b T) int { + aN, bN := isNull(a), isNull(b) + if aN && bN { + return 0 + } + if aN { + return 1 + } + if bN { + return -1 + } + return fn(a, b) + } +} diff --git a/api/http/utils/filters/sort_test.go b/api/http/utils/filters/sort_test.go new file mode 100644 index 0000000..97bf865 --- /dev/null +++ b/api/http/utils/filters/sort_test.go @@ -0,0 +1,299 @@ +package filters + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// Helper sort functions +func compareUserByName(a, b User) int { + return strings.Compare(a.Name, b.Name) +} + +func compareUserByAge(a, b User) int { + return a.Age - b.Age +} + +func compareProductByName(a, b Product) int { + return strings.Compare(a.Name, b.Name) +} + +func compareProductByPrice(a, b Product) int { + return a.Price - b.Price +} + +func TestSortFn_BasicAscending(t *testing.T) { + t.Parallel() + users := []User{ + {Name: "Charlie", Age: 25}, + {Name: "Alice", Age: 30}, + {Name: "Bob", Age: 20}, + } + + sorts := []SortBinding[User]{ + {Key: "name", Fn: compareUserByName}, + {Key: "age", Fn: compareUserByAge}, + } + + params := SortQueryParams{sort: "name", order: SortAsc} + result := sortFn(users, params, sorts) + + require.Equal(t, []User{ + {Name: "Alice", Age: 30}, + {Name: "Bob", Age: 20}, + {Name: "Charlie", Age: 25}, + }, result) +} + +func TestSortFn_BasicDescending(t *testing.T) { + t.Parallel() + users := []User{ + {Name: "Charlie", Age: 25}, + {Name: "Alice", Age: 30}, + {Name: "Bob", Age: 20}, + } + + sorts := []SortBinding[User]{ + {Key: "name", Fn: compareUserByName}, + {Key: "age", Fn: compareUserByAge}, + } + + params := SortQueryParams{sort: "name", order: SortDesc} + result := sortFn(users, params, sorts) + + require.Equal(t, []User{ + {Name: "Charlie", Age: 25}, + {Name: "Bob", Age: 20}, + {Name: "Alice", Age: 30}, + }, result) +} + +func TestSortFn_SortByAge(t *testing.T) { + t.Parallel() + users := []User{ + {Name: "Charlie", Age: 25}, + {Name: "Alice", Age: 30}, + {Name: "Bob", Age: 20}, + } + + sorts := []SortBinding[User]{ + {Key: "name", Fn: compareUserByName}, + {Key: "age", Fn: compareUserByAge}, + } + + // Test ascending by age + params := SortQueryParams{sort: "age", order: SortAsc} + result := sortFn(users, params, sorts) + + require.Equal(t, []User{ + {Name: "Bob", Age: 20}, + {Name: "Charlie", Age: 25}, + {Name: "Alice", Age: 30}, + }, result) + + // Test descending by age + params = SortQueryParams{sort: "age", order: SortDesc} + result = sortFn(users, params, sorts) + + require.Equal(t, []User{ + {Name: "Alice", Age: 30}, + {Name: "Charlie", Age: 25}, + {Name: "Bob", Age: 20}, + }, result) +} + +func TestSortFn_UnknownSortKey(t *testing.T) { + t.Parallel() + users := []User{ + {Name: "Charlie", Age: 25}, + {Name: "Alice", Age: 30}, + {Name: "Bob", Age: 20}, + } + + sorts := []SortBinding[User]{ + {Key: "name", Fn: compareUserByName}, + } + + params := SortQueryParams{sort: "unknown", order: SortAsc} + result := sortFn(users, params, sorts) + + // Should return original slice unchanged + require.Equal(t, []User{ + {Name: "Charlie", Age: 25}, + {Name: "Alice", Age: 30}, + {Name: "Bob", Age: 20}, + }, result) +} + +func TestSortFn_EmptySlice(t *testing.T) { + t.Parallel() + users := []User{} + + sorts := []SortBinding[User]{ + {Key: "name", Fn: compareUserByName}, + } + + params := SortQueryParams{sort: "name", order: SortAsc} + result := sortFn(users, params, sorts) + + require.Empty(t, result) +} + +func TestSortFn_SingleElement(t *testing.T) { + t.Parallel() + users := []User{{Name: "Alice", Age: 30}} + + sorts := []SortBinding[User]{ + {Key: "name", Fn: compareUserByName}, + } + + params := SortQueryParams{sort: "name", order: SortAsc} + result := sortFn(users, params, sorts) + + require.Equal(t, []User{{Name: "Alice", Age: 30}}, result) +} + +func TestSortFn_EmptySortBindings(t *testing.T) { + t.Parallel() + users := []User{ + {Name: "Charlie", Age: 25}, + {Name: "Alice", Age: 30}, + } + + sorts := []SortBinding[User]{} // Empty sorts + + params := SortQueryParams{sort: "name", order: SortAsc} + result := sortFn(users, params, sorts) + + // Should return original slice unchanged + require.Equal(t, []User{ + {Name: "Charlie", Age: 25}, + {Name: "Alice", Age: 30}, + }, result) +} + +func TestSortFn_DifferentType(t *testing.T) { + t.Parallel() + products := []Product{ + {Name: "Laptop", Price: 1000}, + {Name: "Mouse", Price: 25}, + {Name: "Keyboard", Price: 100}, + } + + sorts := []SortBinding[Product]{ + {Key: "name", Fn: compareProductByName}, + {Key: "price", Fn: compareProductByPrice}, + } + + // Test by price ascending + params := SortQueryParams{sort: "price", order: SortAsc} + result := sortFn(products, params, sorts) + + require.Equal(t, []Product{ + {Name: "Mouse", Price: 25}, + {Name: "Keyboard", Price: 100}, + {Name: "Laptop", Price: 1000}, + }, result) + + // Test by name descending + params = SortQueryParams{sort: "name", order: SortDesc} + result = sortFn(products, params, sorts) + + require.Equal(t, []Product{ + {Name: "Mouse", Price: 25}, + {Name: "Laptop", Price: 1000}, + {Name: "Keyboard", Price: 100}, + }, result) +} + +func TestSortFn_StableSort(t *testing.T) { + t.Parallel() + // Test that sorting is stable (maintains relative order of equal elements) + users := []User{ + {Name: "Alice", Age: 25}, + {Name: "Bob", Age: 25}, + {Name: "Charlie", Age: 25}, + {Name: "David", Age: 30}, + } + + sorts := []SortBinding[User]{ + {Key: "age", Fn: compareUserByAge}, + } + + params := SortQueryParams{sort: "age", order: SortAsc} + result := sortFn(users, params, sorts) + + // All users with age 25 should maintain their original relative order + require.Equal(t, []User{ + {Name: "Alice", Age: 25}, + {Name: "Bob", Age: 25}, + {Name: "Charlie", Age: 25}, + {Name: "David", Age: 30}, + }, result) +} + +func TestReverseSortFn(t *testing.T) { + t.Parallel() + originalFn := compareUserByAge + reversedFn := reverSortFn(originalFn) + + userA := User{Name: "Alice", Age: 20} + userB := User{Name: "Bob", Age: 30} + + // Original function: A < B (returns negative) + require.Negative(t, originalFn(userA, userB)) + + // Reversed function: A > B (returns positive) + require.Positive(t, reversedFn(userA, userB)) + + // Test symmetry + require.Equal(t, -originalFn(userA, userB), reversedFn(userA, userB)) + require.Equal(t, -originalFn(userB, userA), reversedFn(userB, userA)) +} + +func TestSortFn_CaseSensitive(t *testing.T) { + t.Parallel() + users := []User{ + {Name: "alice", Age: 25}, + {Name: "Bob", Age: 30}, + {Name: "Charlie", Age: 20}, + } + + sorts := []SortBinding[User]{ + {Key: "name", Fn: compareUserByName}, + } + + params := SortQueryParams{sort: "name", order: SortAsc} + result := sortFn(users, params, sorts) + + // strings.Compare is case-sensitive, uppercase comes before lowercase + require.Equal(t, []User{ + {Name: "Bob", Age: 30}, + {Name: "Charlie", Age: 20}, + {Name: "alice", Age: 25}, + }, result) +} + +func TestSortFn_ModifiesOriginalSlice(t *testing.T) { + t.Parallel() + users := []User{ + {Name: "Charlie", Age: 25}, + {Name: "Alice", Age: 30}, + {Name: "Bob", Age: 20}, + } + original := make([]User, len(users)) + copy(original, users) + + sorts := []SortBinding[User]{ + {Key: "name", Fn: compareUserByName}, + } + + params := SortQueryParams{sort: "name", order: SortAsc} + result := sortFn(users, params, sorts) + + // The function modifies the original slice + require.Equal(t, result, users) + require.NotEqual(t, original, users) +} diff --git a/api/http/utils/filters/types_test.go b/api/http/utils/filters/types_test.go new file mode 100644 index 0000000..2dc9b9a --- /dev/null +++ b/api/http/utils/filters/types_test.go @@ -0,0 +1,63 @@ +package filters + +import ( + "errors" + "fmt" + "strconv" +) + +// Test data structures +type User struct { + ID int + Name string + Email string + Age int +} + +type Product struct { + ID int + Name string + Description string + Price int + Category string +} + +// User accessors +func userIDAccessor(u User) (string, error) { + return strconv.Itoa(u.ID), nil +} + +func userNameAccessor(u User) (string, error) { + return u.Name, nil +} + +func userEmailAccessor(u User) (string, error) { + return u.Email, nil +} + +func userAgeAccessor(u User) (string, error) { + return strconv.Itoa(u.Age), nil +} + +// Product accessors + +func productNameAccessor(p Product) (string, error) { + return p.Name, nil +} + +func productDescriptionAccessor(p Product) (string, error) { + return p.Description, nil +} + +func productPriceAccessor(p Product) (string, error) { + return fmt.Sprintf("$%d", p.Price), nil +} + +func productCategoryAccessor(p Product) (string, error) { + return p.Category, nil +} + +// Other accessors +func errorAccessor[T any](t T) (string, error) { + return "", errors.New("accessor error") +} diff --git a/api/internal/authorization/access_control.go b/api/internal/authorization/access_control.go new file mode 100644 index 0000000..0ff3a2e --- /dev/null +++ b/api/internal/authorization/access_control.go @@ -0,0 +1,76 @@ +package authorization + +import ( + "strconv" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/stacks/stackutils" + "github.com/portainer/portainer/pkg/authorization" +) + +var ( + NewAdministratorsOnlyResourceControl = authorization.NewAdministratorsOnlyResourceControl + NewPrivateResourceControl = authorization.NewPrivateResourceControl + NewSystemResourceControl = authorization.NewSystemResourceControl + NewPublicResourceControl = authorization.NewPublicResourceControl + NewRestrictedResourceControl = authorization.NewRestrictedResourceControl + UserCanAccessResource = authorization.UserCanAccessResource + GetResourceControlByResourceIDAndType = authorization.GetResourceControlByResourceIDAndType + TeamIDs = authorization.TeamIDs +) + +func NewEmptyRestrictedResourceControl(resourceIdentifier string, resourceType portainer.ResourceControlType) *portainer.ResourceControl { + return NewRestrictedResourceControl(resourceIdentifier, resourceType, []portainer.UserID{}, []portainer.TeamID{}) +} + +// 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 []portainer.Stack, resourceControls []portainer.ResourceControl) []portainer.Stack { + for idx, stack := range stacks { + resourceControl := GetResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl, resourceControls) + if resourceControl != nil { + stacks[idx].ResourceControl = resourceControl + } + } + + 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 []portainer.Stack, userID portainer.UserID, userTeamIDs []portainer.TeamID) []portainer.Stack { + authorizedStacks := make([]portainer.Stack, 0) + + for _, stack := range stacks { + if stack.ResourceControl != nil && UserCanAccessResource(userID, userTeamIDs, stack.ResourceControl) { + authorizedStacks = append(authorizedStacks, stack) + } + } + + 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 +} diff --git a/api/internal/authorization/authorizations.go b/api/internal/authorization/authorizations.go new file mode 100644 index 0000000..590bc75 --- /dev/null +++ b/api/internal/authorization/authorizations.go @@ -0,0 +1,598 @@ +package authorization + +import ( + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/kubernetes/cli" +) + +// Service represents a service used to +// update authorizations associated to a user or team. +type Service struct { + dataStore dataservices.DataStoreTx + K8sClientFactory *cli.ClientFactory +} + +// NewService returns a point to a new Service instance. +func NewService(dataStore dataservices.DataStoreTx) *Service { + return &Service{ + dataStore: dataStore, + } +} + +// DefaultEndpointAuthorizationsForEndpointAdministratorRole returns the default environment(endpoint) authorizations +// associated to the environment(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.OperationPortainerRegistryUpdateAccess: 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.EndpointResourcesAccess: true, + } +} + +// DefaultEndpointAuthorizationsForHelpDeskRole returns the default environment(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 environment(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 environment(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.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, + portainer.OperationPortainerUserListToken: true, + portainer.OperationPortainerUserCreateToken: true, + portainer.OperationPortainerUserRevokeToken: true, + } +} + +// RemoveTeamAccessPolicies will remove all existing access policies associated to the specified team +func (service *Service) RemoveTeamAccessPolicies(tx dataservices.DataStoreTx, teamID portainer.TeamID) error { + endpoints, err := tx.Endpoint().Endpoints() + if err != nil { + return err + } + for _, endpoint := range endpoints { + for policyTeamID := range endpoint.TeamAccessPolicies { + if policyTeamID == teamID { + delete(endpoint.TeamAccessPolicies, policyTeamID) + + err := tx.Endpoint().UpdateEndpoint(endpoint.ID, &endpoint) + if err != nil { + return err + } + + break + } + } + } + + endpointGroups, err := tx.EndpointGroup().ReadAll() + if err != nil { + return err + } + + for _, endpointGroup := range endpointGroups { + for policyTeamID := range endpointGroup.TeamAccessPolicies { + if policyTeamID == teamID { + delete(endpointGroup.TeamAccessPolicies, policyTeamID) + + err := tx.EndpointGroup().Update(endpointGroup.ID, &endpointGroup) + if err != nil { + return err + } + + break + } + } + } + + registries, err := tx.Registry().ReadAll() + if err != nil { + return err + } + + // iterate over all environments for all registries + // and evict all direct accesses to the registries the team had + // we could have built a range of the teams's environments accesses while removing them above + // but ranging over all environments (registryAccessPolicy is indexed by environmentId) + // makes sure we cleanup all resources in case an access was not removed when a team was removed from an env + for _, registry := range registries { + updateRegistry := false + for _, registryAccessPolicy := range registry.RegistryAccesses { + if _, ok := registryAccessPolicy.TeamAccessPolicies[teamID]; ok { + delete(registryAccessPolicy.TeamAccessPolicies, teamID) + updateRegistry = true + } + } + if updateRegistry { + if err := tx.Registry().Update(registry.ID, ®istry); err != nil { + return err + } + } + } + + return nil +} + +// RemoveUserAccessPolicies will remove all existing access policies associated to the specified user +func (service *Service) RemoveUserAccessPolicies(tx dataservices.DataStoreTx, userID portainer.UserID) error { + endpoints, err := tx.Endpoint().Endpoints() + if err != nil { + return err + } + + for _, endpoint := range endpoints { + for policyUserID := range endpoint.UserAccessPolicies { + if policyUserID == userID { + delete(endpoint.UserAccessPolicies, policyUserID) + + err := tx.Endpoint().UpdateEndpoint(endpoint.ID, &endpoint) + if err != nil { + return err + } + + break + } + } + } + + endpointGroups, err := tx.EndpointGroup().ReadAll() + if err != nil { + return err + } + + for _, endpointGroup := range endpointGroups { + for policyUserID := range endpointGroup.UserAccessPolicies { + if policyUserID == userID { + delete(endpointGroup.UserAccessPolicies, policyUserID) + + err := tx.EndpointGroup().Update(endpointGroup.ID, &endpointGroup) + if err != nil { + return err + } + + break + } + } + } + + registries, err := tx.Registry().ReadAll() + if err != nil { + return err + } + + // iterate over all environments for all registries + // and evict all direct accesses to the registries the user had + // we could have built a range of the user's environments accesses while removing them above + // but ranging over all environments (registryAccessPolicy is indexed by environmentId) + // makes sure we cleanup all resources in case an access was not removed when a user was removed from an env + for _, registry := range registries { + updateRegistry := false + for _, registryAccessPolicy := range registry.RegistryAccesses { + if _, ok := registryAccessPolicy.UserAccessPolicies[userID]; ok { + delete(registryAccessPolicy.UserAccessPolicies, userID) + updateRegistry = true + } + } + if updateRegistry { + if err := tx.Registry().Update(registry.ID, ®istry); err != nil { + return err + } + } + } + + return nil +} + +// UpdateUsersAuthorizations is a no-op kept for backward compatibility with database migrations. +// +// Deprecated: This function previously populated the User.EndpointAuthorizations field which is +// no longer used. Authorization is now computed dynamically via ResolveUserEndpointAccess. +func (service *Service) UpdateUsersAuthorizations() error { + return nil +} + +func (service *Service) UserIsAdminOrAuthorized(tx dataservices.DataStoreTx, userID portainer.UserID, endpointID portainer.EndpointID, authorizations []portainer.Authorization) (bool, error) { + user, err := tx.User().Read(userID) + if err != nil { + return false, err + } + + if user.Role == portainer.AdministratorRole { + return true, nil + } + + for _, authorization := range authorizations { + _, authorized := user.EndpointAuthorizations[endpointID][authorization] + if authorized { + return true, nil + } + } + + return false, nil +} diff --git a/api/internal/authorization/endpoint_role_with_override.go b/api/internal/authorization/endpoint_role_with_override.go new file mode 100644 index 0000000..750c5f4 --- /dev/null +++ b/api/internal/authorization/endpoint_role_with_override.go @@ -0,0 +1,131 @@ +package authorization + +import ( + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" +) + +// CleanNAPWithOverridePolicies Clean Namespace Access Policies with override policies +func (service *Service) CleanNAPWithOverridePolicies( + tx dataservices.DataStoreTx, + endpoint *portainer.Endpoint, + endpointGroup *portainer.EndpointGroup, +) error { + kubecli, err := service.K8sClientFactory.GetPrivilegedKubeClient(endpoint) + if err != nil { + return err + } + + accessPolicies, err := kubecli.GetNamespaceAccessPolicies() + if err != nil { + return err + } + + hasChange := false + + for namespace, policy := range accessPolicies { + for teamID := range policy.TeamAccessPolicies { + access, err := service.getTeamEndpointAccessWithPolicies(tx, teamID, endpoint, endpointGroup) + if err != nil { + return err + } + + if !access { + delete(accessPolicies[namespace].TeamAccessPolicies, teamID) + hasChange = true + } + } + + for userID := range policy.UserAccessPolicies { + access, err := service.getUserEndpointAccessWithPolicies(tx, userID, endpoint, endpointGroup) + if err != nil { + return err + } + + if !access { + delete(accessPolicies[namespace].UserAccessPolicies, userID) + hasChange = true + } + } + } + + if hasChange { + return kubecli.UpdateNamespaceAccessPolicies(accessPolicies) + } + + return nil +} + +func (service *Service) getUserEndpointAccessWithPolicies( + tx dataservices.DataStoreTx, + userID portainer.UserID, + endpoint *portainer.Endpoint, + endpointGroup *portainer.EndpointGroup, +) (bool, error) { + memberships, err := tx.TeamMembership().TeamMembershipsByUserID(userID) + if err != nil { + return false, err + } + + if endpointGroup == nil { + endpointGroup, err = tx.EndpointGroup().Read(endpoint.GroupID) + if err != nil { + return false, err + } + } + + return userAccess(userID, endpoint.UserAccessPolicies, endpoint.TeamAccessPolicies, memberships) || + userAccess(userID, endpointGroup.UserAccessPolicies, endpointGroup.TeamAccessPolicies, memberships), nil +} + +func userAccess( + userID portainer.UserID, + userAccessPolicies portainer.UserAccessPolicies, + teamAccessPolicies portainer.TeamAccessPolicies, + memberships []portainer.TeamMembership, +) bool { + if _, ok := userAccessPolicies[userID]; ok { + return true + } + + for _, membership := range memberships { + if _, ok := teamAccessPolicies[membership.TeamID]; ok { + return true + } + } + + return false +} + +func (service *Service) getTeamEndpointAccessWithPolicies( + tx dataservices.DataStoreTx, + teamID portainer.TeamID, + endpoint *portainer.Endpoint, + endpointGroup *portainer.EndpointGroup, +) (bool, error) { + if endpointGroup == nil { + var err error + endpointGroup, err = tx.EndpointGroup().Read(endpoint.GroupID) + if err != nil { + return false, err + } + } + + if teamAccess(teamID, endpoint.TeamAccessPolicies) { + return true, nil + } + + if teamAccess(teamID, endpointGroup.TeamAccessPolicies) { + return true, nil + } + + return false, nil +} + +func teamAccess( + teamID portainer.TeamID, + teamAccessPolicies portainer.TeamAccessPolicies, +) bool { + _, ok := teamAccessPolicies[teamID] + return ok +} diff --git a/api/internal/edge/cache/cache.go b/api/internal/edge/cache/cache.go new file mode 100644 index 0000000..26b99eb --- /dev/null +++ b/api/internal/edge/cache/cache.go @@ -0,0 +1,26 @@ +package cache + +import ( + "strconv" + + "github.com/VictoriaMetrics/fastcache" + portainer "github.com/portainer/portainer/api" +) + +var c = fastcache.New(1) + +func key(k portainer.EndpointID) []byte { + return []byte(strconv.Itoa(int(k))) +} + +func Set(k portainer.EndpointID, v []byte) { + c.Set(key(k), v) +} + +func Get(k portainer.EndpointID) ([]byte, bool) { + return c.HasGet(nil, key(k)) +} + +func Del(k portainer.EndpointID) { + c.Del(key(k)) +} diff --git a/api/internal/edge/edgegroup.go b/api/internal/edge/edgegroup.go new file mode 100644 index 0000000..b9a1b89 --- /dev/null +++ b/api/internal/edge/edgegroup.go @@ -0,0 +1,95 @@ +package edge + +import ( + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/internal/endpointutils" + "github.com/portainer/portainer/api/roar" + "github.com/portainer/portainer/api/tag" +) + +// EdgeGroupRelatedEndpoints returns a list of environments(endpoints) related to this Edge group +func EdgeGroupRelatedEndpoints(edgeGroup *portainer.EdgeGroup, endpoints []portainer.Endpoint, endpointGroups []portainer.EndpointGroup) []portainer.EndpointID { + if !edgeGroup.Dynamic { + var r roar.Roar[portainer.EndpointID] + + for _, endpoint := range endpoints { + if edgeGroup.EndpointIDs.Contains(endpoint.ID) { + r.Add(endpoint.ID) + } + } + + return r.ToSlice() + } + + endpointGroupsMap := map[portainer.EndpointGroupID]*portainer.EndpointGroup{} + for i, group := range endpointGroups { + endpointGroupsMap[group.ID] = &endpointGroups[i] + } + + endpointIDs := []portainer.EndpointID{} + for _, endpoint := range endpoints { + if !endpointutils.IsEdgeEndpoint(&endpoint) { + continue + } + + endpointGroup := endpointGroupsMap[endpoint.GroupID] + if edgeGroupRelatedToEndpoint(edgeGroup, &endpoint, endpointGroup) { + endpointIDs = append(endpointIDs, endpoint.ID) + } + } + + return endpointIDs +} + +func EdgeGroupSet(edgeGroupIDs []portainer.EdgeGroupID) map[portainer.EdgeGroupID]bool { + set := map[portainer.EdgeGroupID]bool{} + + for _, edgeGroupID := range edgeGroupIDs { + set[edgeGroupID] = true + } + + return set +} + +func GetEndpointsFromEdgeGroups(edgeGroupIDs []portainer.EdgeGroupID, datastore dataservices.DataStoreTx) ([]portainer.EndpointID, error) { + endpoints, err := datastore.Endpoint().Endpoints() + if err != nil { + return nil, err + } + + endpointGroups, err := datastore.EndpointGroup().ReadAll() + if err != nil { + return nil, err + } + + var response []portainer.EndpointID + for _, edgeGroupID := range edgeGroupIDs { + edgeGroup, err := datastore.EdgeGroup().Read(edgeGroupID) + if err != nil { + return nil, err + } + + response = append(response, EdgeGroupRelatedEndpoints(edgeGroup, endpoints, endpointGroups)...) + } + + return response, nil +} + +// edgeGroupRelatedToEndpoint returns true if edgeGroup is associated with environment(endpoint) +func edgeGroupRelatedToEndpoint(edgeGroup *portainer.EdgeGroup, endpoint *portainer.Endpoint, endpointGroup *portainer.EndpointGroup) bool { + if !edgeGroup.Dynamic { + return edgeGroup.EndpointIDs.Contains(endpoint.ID) + } + + endpointTags := tag.Set(endpoint.TagIDs) + if endpointGroup != nil && endpointGroup.TagIDs != nil { + endpointTags = tag.Union(endpointTags, tag.Set(endpointGroup.TagIDs)) + } + + if edgeGroup.PartialMatch { + return tag.PartialMatch(edgeGroup.TagIDs, endpointTags) + } + + return tag.FullMatch(edgeGroup.TagIDs, endpointTags) +} diff --git a/api/internal/edge/edgegroup_benchmark_test.go b/api/internal/edge/edgegroup_benchmark_test.go new file mode 100644 index 0000000..861db09 --- /dev/null +++ b/api/internal/edge/edgegroup_benchmark_test.go @@ -0,0 +1,104 @@ +package edge + +import ( + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/roar" + + "github.com/rs/zerolog" + "github.com/stretchr/testify/require" +) + +const n = 1_000_000 + +func BenchmarkWriteEdgeGroupOld(b *testing.B) { + zerolog.SetGlobalLevel(zerolog.ErrorLevel) + + _, store := datastore.MustNewTestStore(b, false, false) + + var endpointIDs []portainer.EndpointID + + for i := range n { + endpointIDs = append(endpointIDs, portainer.EndpointID(i+1)) + } + + for b.Loop() { + err := store.EdgeGroup().Create(&portainer.EdgeGroup{ + Name: "Test Edge Group", + Endpoints: endpointIDs, + }) + require.NoError(b, err) + } +} + +func BenchmarkWriteEdgeGroupNew(b *testing.B) { + zerolog.SetGlobalLevel(zerolog.ErrorLevel) + + _, store := datastore.MustNewTestStore(b, false, false) + + var ts []portainer.EndpointID + + for i := range n { + ts = append(ts, portainer.EndpointID(i+1)) + } + + endpointIDs := roar.FromSlice(ts) + + for b.Loop() { + err := store.EdgeGroup().Create(&portainer.EdgeGroup{ + Name: "Test Edge Group", + EndpointIDs: endpointIDs, + }) + require.NoError(b, err) + } +} + +func BenchmarkReadEdgeGroupOld(b *testing.B) { + zerolog.SetGlobalLevel(zerolog.ErrorLevel) + + _, store := datastore.MustNewTestStore(b, false, false) + + var endpointIDs []portainer.EndpointID + + for i := range n { + endpointIDs = append(endpointIDs, portainer.EndpointID(i+1)) + } + + err := store.EdgeGroup().Create(&portainer.EdgeGroup{ + Name: "Test Edge Group", + Endpoints: endpointIDs, + }) + require.NoError(b, err) + + for b.Loop() { + _, err := store.EdgeGroup().ReadAll() + require.NoError(b, err) + } +} + +func BenchmarkReadEdgeGroupNew(b *testing.B) { + zerolog.SetGlobalLevel(zerolog.ErrorLevel) + + _, store := datastore.MustNewTestStore(b, false, false) + + var ts []portainer.EndpointID + + for i := range n { + ts = append(ts, portainer.EndpointID(i+1)) + } + + endpointIDs := roar.FromSlice(ts) + + err := store.EdgeGroup().Create(&portainer.EdgeGroup{ + Name: "Test Edge Group", + EndpointIDs: endpointIDs, + }) + require.NoError(b, err) + + for b.Loop() { + _, err := store.EdgeGroup().ReadAll() + require.NoError(b, err) + } +} diff --git a/api/internal/edge/edgestack.go b/api/internal/edge/edgestack.go new file mode 100644 index 0000000..2e97066 --- /dev/null +++ b/api/internal/edge/edgestack.go @@ -0,0 +1,67 @@ +package edge + +import ( + "errors" + "fmt" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/slicesx" +) + +var ErrEdgeGroupNotFound = errors.New("edge group was not found") + +// EdgeStackRelatedEndpoints returns a list of environments(endpoints) related to this Edge stack +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 *portainer.EdgeGroup + + for _, group := range edgeGroups { + if group.ID == edgeGroupID { + edgeGroup = &group + + break + } + } + + if edgeGroup == nil { + return nil, ErrEdgeGroupNotFound + } + + edgeStackEndpoints = append(edgeStackEndpoints, EdgeGroupRelatedEndpoints(edgeGroup, endpoints, endpointGroups)...) + } + + return slicesx.Unique(edgeStackEndpoints), nil +} + +type EndpointRelationsConfig struct { + Endpoints []portainer.Endpoint + EndpointGroups []portainer.EndpointGroup + EdgeGroups []portainer.EdgeGroup +} + +// FetchEndpointRelationsConfig fetches config needed for Edge Stack related endpoints +func FetchEndpointRelationsConfig(tx dataservices.DataStoreTx) (*EndpointRelationsConfig, error) { + endpoints, err := tx.Endpoint().Endpoints() + if err != nil { + return nil, fmt.Errorf("unable to retrieve environments from database: %w", err) + } + + endpointGroups, err := tx.EndpointGroup().ReadAll() + if err != nil { + return nil, fmt.Errorf("unable to retrieve environment groups from database: %w", err) + } + + edgeGroups, err := tx.EdgeGroup().ReadAll() + if err != nil { + return nil, fmt.Errorf("unable to retrieve edge groups from database: %w", err) + } + + return &EndpointRelationsConfig{ + Endpoints: endpoints, + EndpointGroups: endpointGroups, + EdgeGroups: edgeGroups, + }, nil +} diff --git a/api/internal/edge/edgestacks/service.go b/api/internal/edge/edgestacks/service.go new file mode 100644 index 0000000..35215aa --- /dev/null +++ b/api/internal/edge/edgestacks/service.go @@ -0,0 +1,170 @@ +package edgestacks + +import ( + "fmt" + "strconv" + "strings" + "time" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + httperrors "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/api/internal/edge" + edgetypes "github.com/portainer/portainer/api/internal/edge/types" + + "github.com/pkg/errors" +) + +// Service represents a service for managing edge stacks. +type Service struct { + dataStore dataservices.DataStore +} + +// NewService returns a new instance of a service. +func NewService(dataStore dataservices.DataStore) *Service { + return &Service{ + dataStore: dataStore, + } +} + +// BuildEdgeStack builds the initial edge stack object +// PersistEdgeStack is required to be called after this to persist the edge stack +func (service *Service) BuildEdgeStack( + tx dataservices.DataStoreTx, + name string, + deploymentType portainer.EdgeStackDeploymentType, + edgeGroups []portainer.EdgeGroupID, + registries []portainer.RegistryID, + useManifestNamespaces bool, +) (*portainer.EdgeStack, error) { + if err := validateUniqueName(tx.EdgeStack().EdgeStacks, name); err != nil { + return nil, err + } + + stackID := tx.EdgeStack().GetNextIdentifier() + + return &portainer.EdgeStack{ + ID: portainer.EdgeStackID(stackID), + Name: name, + DeploymentType: deploymentType, + CreationDate: time.Now().Unix(), + EdgeGroups: edgeGroups, + Version: 1, + UseManifestNamespaces: useManifestNamespaces, + }, nil +} + +func validateUniqueName(edgeStacksGetter func() ([]portainer.EdgeStack, error), name string) error { + edgeStacks, err := edgeStacksGetter() + if err != nil { + return err + } + + for _, stack := range edgeStacks { + if strings.EqualFold(stack.Name, name) { + return httperrors.NewConflictError("Edge stack name must be unique") + } + } + + return nil +} + +// PersistEdgeStack persists the edge stack in the database and its relations +func (service *Service) PersistEdgeStack( + tx dataservices.DataStoreTx, + stack *portainer.EdgeStack, + storeManifest edgetypes.StoreManifestFunc) (*portainer.EdgeStack, error) { + + relationConfig, err := edge.FetchEndpointRelationsConfig(tx) + if err != nil { + return nil, fmt.Errorf("unable to find environment relations in database: %w", err) + } + + relatedEndpointIds, err := edge.EdgeStackRelatedEndpoints(stack.EdgeGroups, relationConfig.Endpoints, relationConfig.EndpointGroups, relationConfig.EdgeGroups) + if err != nil { + if errors.Is(err, edge.ErrEdgeGroupNotFound) { + return nil, httperrors.NewInvalidPayloadError(err.Error()) + } + + return nil, fmt.Errorf("unable to persist environment relation in database: %w", err) + } + + stackFolder := strconv.Itoa(int(stack.ID)) + composePath, manifestPath, projectPath, err := storeManifest(stackFolder, relatedEndpointIds) + if err != nil { + return nil, fmt.Errorf("unable to store manifest: %w", err) + } + + stack.ManifestPath = manifestPath + stack.ProjectPath = projectPath + stack.EntryPoint = composePath + + if err := tx.EdgeStack().Create(stack.ID, stack); err != nil { + return nil, err + } + + for _, endpointID := range relatedEndpointIds { + status := &portainer.EdgeStackStatusForEnv{EndpointID: endpointID} + + if err := tx.EdgeStackStatus().Create(stack.ID, endpointID, status); err != nil { + return nil, err + } + } + + if err := tx.EndpointRelation().AddEndpointRelationsForEdgeStack(relatedEndpointIds, stack); err != nil { + return nil, fmt.Errorf("unable to add endpoint relations: %w", err) + } + + if err := service.updateEndpointRelations(tx, stack.ID, relatedEndpointIds); err != nil { + return nil, fmt.Errorf("unable to update endpoint relations: %w", err) + } + + return stack, nil +} + +// updateEndpointRelations adds a relation between the Edge Stack to the related environments(endpoints) +func (service *Service) updateEndpointRelations(tx dataservices.DataStoreTx, edgeStackID portainer.EdgeStackID, relatedEndpointIds []portainer.EndpointID) error { + endpointRelationService := tx.EndpointRelation() + + for _, endpointID := range relatedEndpointIds { + relation, err := endpointRelationService.EndpointRelation(endpointID) + if err != nil { + return fmt.Errorf("unable to find endpoint relation in database: %w", err) + } + + relation.EdgeStacks[edgeStackID] = true + + if err := endpointRelationService.UpdateEndpointRelation(endpointID, relation); err != nil { + return fmt.Errorf("unable to persist endpoint relation in database: %w", err) + } + } + + return nil +} + +// DeleteEdgeStack deletes the edge stack from the database and its relations +func (service *Service) DeleteEdgeStack(tx dataservices.DataStoreTx, edgeStackID portainer.EdgeStackID, relatedEdgeGroupsIds []portainer.EdgeGroupID) error { + relationConfig, err := edge.FetchEndpointRelationsConfig(tx) + if err != nil { + return errors.WithMessage(err, "Unable to retrieve environments relations config from database") + } + + relatedEndpointIds, err := edge.EdgeStackRelatedEndpoints(relatedEdgeGroupsIds, relationConfig.Endpoints, relationConfig.EndpointGroups, relationConfig.EdgeGroups) + if err != nil { + return errors.WithMessage(err, "Unable to retrieve edge stack related environments from database") + } + + if err := tx.EndpointRelation().RemoveEndpointRelationsForEdgeStack(relatedEndpointIds, edgeStackID); err != nil { + return errors.WithMessage(err, "unable to remove environment relation in database") + } + + if err := tx.EdgeStack().DeleteEdgeStack(edgeStackID); err != nil { + return errors.WithMessage(err, "Unable to remove the edge stack from the database") + } + + if err := tx.EdgeStackStatus().DeleteAll(edgeStackID); err != nil { + return errors.WithMessage(err, "unable to remove edge stack statuses from the database") + } + + return nil +} diff --git a/api/internal/edge/edgestacks/service_test.go b/api/internal/edge/edgestacks/service_test.go new file mode 100644 index 0000000..8978c54 --- /dev/null +++ b/api/internal/edge/edgestacks/service_test.go @@ -0,0 +1,42 @@ +package edgestacks + +import ( + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/internal/testhelpers" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_updateEndpointRelation_successfulRuns(t *testing.T) { + t.Parallel() + edgeStackID := portainer.EdgeStackID(5) + endpointRelations := []portainer.EndpointRelation{ + {EndpointID: 1, EdgeStacks: map[portainer.EdgeStackID]bool{}}, + {EndpointID: 2, EdgeStacks: map[portainer.EdgeStackID]bool{}}, + {EndpointID: 3, EdgeStacks: map[portainer.EdgeStackID]bool{}}, + {EndpointID: 4, EdgeStacks: map[portainer.EdgeStackID]bool{}}, + {EndpointID: 5, EdgeStacks: map[portainer.EdgeStackID]bool{}}, + } + + relatedIds := []portainer.EndpointID{2, 3} + + dataStore := testhelpers.NewDatastore(testhelpers.WithEndpointRelations(endpointRelations)) + + service := NewService(dataStore) + + err := service.updateEndpointRelations(dataStore, edgeStackID, relatedIds) + require.NoError(t, err, "updateEndpointRelations should not fail") + + relatedSet := map[portainer.EndpointID]bool{} + for _, relationID := range relatedIds { + relatedSet[relationID] = true + } + + for _, relation := range endpointRelations { + shouldBeRelated := relatedSet[relation.EndpointID] + assert.Equal(t, shouldBeRelated, relation.EdgeStacks[edgeStackID]) + } +} diff --git a/api/internal/edge/endpoint.go b/api/internal/edge/endpoint.go new file mode 100644 index 0000000..7901b0e --- /dev/null +++ b/api/internal/edge/endpoint.go @@ -0,0 +1,67 @@ +package edge + +import ( + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/internal/endpointutils" +) + +// EndpointRelatedEdgeStacks returns a list of Edge stacks related to this Environment(Endpoint) +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) { + relatedEdgeGroupsSet[edgeGroup.ID] = true + } + } + + relatedEdgeStacks := []portainer.EdgeStackID{} + for _, edgeStack := range edgeStacks { + for _, edgeGroupID := range edgeStack.EdgeGroups { + if relatedEdgeGroupsSet[edgeGroupID] { + relatedEdgeStacks = append(relatedEdgeStacks, edgeStack.ID) + break + } + } + } + + return relatedEdgeStacks +} + +func EffectiveCheckinInterval(tx dataservices.DataStoreTx, endpoint *portainer.Endpoint) int { + if endpoint.EdgeCheckinInterval != 0 { + return endpoint.EdgeCheckinInterval + } + + if settings, err := tx.Settings().Settings(); err == nil { + return settings.EdgeAgentCheckinInterval + } + + return portainer.DefaultEdgeAgentCheckinIntervalInSeconds +} + +// EndpointInEdgeGroup returns true and the edge group name if the endpoint is in the edge group +func EndpointInEdgeGroup( + tx dataservices.DataStoreTx, + endpoint *portainer.Endpoint, + edgeGroupID portainer.EdgeGroupID, + endpointGroups []portainer.EndpointGroup, +) (bool, string, error) { + if !endpointutils.IsEdgeEndpoint(endpoint) || !endpoint.UserTrusted { + return false, "", nil + } + + edgeGroup, err := tx.EdgeGroup().Read(edgeGroupID) + if err != nil { + return false, "", err + } + + r := EdgeGroupRelatedEndpoints(edgeGroup, []portainer.Endpoint{*endpoint}, endpointGroups) + + if len(r) > 0 { + return true, edgeGroup.Name, nil + } + + return false, "", nil +} diff --git a/api/internal/edge/endpoint_test.go b/api/internal/edge/endpoint_test.go new file mode 100644 index 0000000..3891c58 --- /dev/null +++ b/api/internal/edge/endpoint_test.go @@ -0,0 +1,83 @@ +package edge + +import ( + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/roar" + + "github.com/stretchr/testify/require" +) + +func TestEndpointInEdgeGroup(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, true, false) + + endpointGroups := []portainer.EndpointGroup{{ID: 1, Name: "test-group"}} + + endpoint := &portainer.Endpoint{ + ID: 1, + Name: "test-endpoint", + Type: portainer.EdgeAgentOnDockerEnvironment, + UserTrusted: true, + GroupID: endpointGroups[0].ID, + } + edgeGroupID := portainer.EdgeGroupID(1) + + untrustedEndpoint := &portainer.Endpoint{ + ID: 2, + Name: "untrusted-endpoint", + Type: portainer.EdgeAgentOnDockerEnvironment, + UserTrusted: false, + GroupID: endpointGroups[0].ID, + } + + nonEdgeEndpoint := &portainer.Endpoint{ + ID: 2, + Name: "untrusted-endpoint", + Type: portainer.AgentOnDockerEnvironment, + UserTrusted: true, + GroupID: endpointGroups[0].ID, + } + + err := store.EdgeGroup().Create(&portainer.EdgeGroup{ + ID: edgeGroupID, + Name: "test-edge-group", + Dynamic: false, + EndpointIDs: roar.FromSlice([]portainer.EndpointID{endpoint.ID, untrustedEndpoint.ID}), + }) + require.NoError(t, err) + + // Related endpoint in a static edge group + + inEdgeGroup, _, err := EndpointInEdgeGroup(store, endpoint, edgeGroupID, endpointGroups) + require.NoError(t, err) + require.True(t, inEdgeGroup) + + // Unrelated endpoint in a static edge group + + unrelatedEndpoint := &portainer.Endpoint{ + ID: 3, + Name: "unrelated-endpoint", + Type: portainer.EdgeAgentOnDockerEnvironment, + UserTrusted: true, + GroupID: 0, + } + + inEdgeGroup, _, err = EndpointInEdgeGroup(store, unrelatedEndpoint, edgeGroupID, endpointGroups) + require.NoError(t, err) + require.False(t, inEdgeGroup) + + // Untrusted endpoint + + inEdgeGroup, _, err = EndpointInEdgeGroup(store, untrustedEndpoint, edgeGroupID, endpointGroups) + require.NoError(t, err) + require.False(t, inEdgeGroup) + + // Non-edge endpoint + + inEdgeGroup, _, err = EndpointInEdgeGroup(store, nonEdgeEndpoint, edgeGroupID, endpointGroups) + require.NoError(t, err) + require.False(t, inEdgeGroup) +} diff --git a/api/internal/edge/types/stacks.go b/api/internal/edge/types/stacks.go new file mode 100644 index 0000000..e4b50ef --- /dev/null +++ b/api/internal/edge/types/stacks.go @@ -0,0 +1,5 @@ +package types + +import portainer "github.com/portainer/portainer/api" + +type StoreManifestFunc func(stackFolder string, relatedEndpointIds []portainer.EndpointID) (composePath, manifestPath, projectPath string, err error) diff --git a/api/internal/edge/url.go b/api/internal/edge/url.go new file mode 100644 index 0000000..50ff44f --- /dev/null +++ b/api/internal/edge/url.go @@ -0,0 +1,32 @@ +package edge + +import ( + "net" + "net/url" + + "github.com/pkg/errors" +) + +// ParseHostForEdge returns the hostname of the given URL, will fail if host is localhost +func ParseHostForEdge(portainerURL string) (string, error) { + parsedURL, err := url.Parse(portainerURL) + if err != nil { + return "", errors.Wrap(err, "Unable to parse Portainer URL") + } + + portainerHost, _, err := net.SplitHostPort(parsedURL.Host) + if err != nil { + portainerHost = parsedURL.Host + } + + if portainerHost == "" { + return "", errors.New("hostname cannot be empty") + } + + if portainerHost == "localhost" { + return "", errors.New("cannot use localhost as environment URL") + } + + return portainerHost, nil + +} diff --git a/api/internal/endpointutils/endpoint_setup.go b/api/internal/endpointutils/endpoint_setup.go new file mode 100644 index 0000000..13a3413 --- /dev/null +++ b/api/internal/endpointutils/endpoint_setup.go @@ -0,0 +1,151 @@ +package endpointutils + +import ( + "context" + "strings" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/http/client" + + "github.com/rs/zerolog/log" +) + +// InitEndpoint controls the workflow to initialize the primary endpoint. +// When installing Portainer in a Docker Cluster using the yaml file provided in the official +// documentation (https://docs.portainer.io/start/install/server/swarm/linux), the "primary" +// endpoint is initialized before the admin user is created. This triggers the creation of a +// snapshot of the environment in the background, which includes the agent saving the signature +// from the first request made by the server. However, if a user restores Portainer from a backup +// instead of creating a new admin user, the server will not be able to connect to the agent because +// the saved signature will not match. To solve this issue, this solution proposes to wait for +// the admin user to be created before initializing the primary endpoint. This way, the agent +// will save the signature from the first request after the admin user is created, ensuring that +// it matches in the event of a backup restoration. +func InitEndpoint(shutdownCtx context.Context, adminCreationDone <-chan struct{}, flags *portainer.CLIFlags, dataStore dataservices.DataStore, snapshotService portainer.SnapshotService) { + select { + case <-shutdownCtx.Done(): + log.Debug().Msg("shutdown endpoint initialization") + + case <-adminCreationDone: + // Wait for the admin user to be created before initializing the primary endpoint + // The admin user can be created in two ways: + // 1. Using the CLI with the --admin-password flag + // 2. Using the API with the /api/users/admin/init endpoint + log.Debug().Msg("init primary endpoint") + + if err := initEndpoint(flags, dataStore, snapshotService); err != nil { + log.Fatal().Err(err).Msg("failed initializing environment") + } + } +} + +func initEndpoint(flags *portainer.CLIFlags, dataStore dataservices.DataStore, snapshotService portainer.SnapshotService) error { + if *flags.EndpointURL == "" { + return nil + } + + endpoints, err := dataStore.Endpoint().Endpoints() + if err != nil { + return err + } + + if len(endpoints) > 0 { + log.Info().Msg("instance already has defined environments, skipping the environment defined via CLI") + + return nil + } + + if *flags.TLS || *flags.TLSSkipVerify { + return createTLSSecuredEndpoint(flags, dataStore, snapshotService) + } + + return createUnsecuredEndpoint(*flags.EndpointURL, dataStore, snapshotService) +} + +func createTLSSecuredEndpoint(flags *portainer.CLIFlags, dataStore dataservices.DataStore, snapshotService portainer.SnapshotService) error { + tlsConfiguration := portainer.TLSConfiguration{ + TLS: *flags.TLS, + TLSSkipVerify: *flags.TLSSkipVerify, + } + + if *flags.TLS { + tlsConfiguration.TLSCACertPath = *flags.TLSCacert + tlsConfiguration.TLSCertPath = *flags.TLSCert + tlsConfiguration.TLSKeyPath = *flags.TLSKey + } else if !*flags.TLS && *flags.TLSSkipVerify { + tlsConfiguration.TLS = true + } + + endpointID := dataStore.Endpoint().GetNextIdentifier() + endpoint := &portainer.Endpoint{ + ID: portainer.EndpointID(endpointID), + Name: "primary", + URL: *flags.EndpointURL, + GroupID: portainer.EndpointGroupID(1), + Type: portainer.DockerEnvironment, + TLSConfig: tlsConfiguration, + UserAccessPolicies: portainer.UserAccessPolicies{}, + TeamAccessPolicies: portainer.TeamAccessPolicies{}, + TagIDs: []portainer.TagID{}, + Status: portainer.EndpointStatusUp, + Snapshots: []portainer.DockerSnapshot{}, + Kubernetes: portainer.KubernetesDefault(), + SecuritySettings: portainer.DefaultEndpointSecuritySettings(), + } + + if strings.HasPrefix(endpoint.URL, "tcp://") { + agentOnDockerEnvironment, err := client.ExecutePingOperation(endpoint.URL, tlsConfiguration) + if err != nil { + return err + } + + if agentOnDockerEnvironment { + endpoint.Type = portainer.AgentOnDockerEnvironment + } + } + + if err := snapshotService.SnapshotEndpoint(endpoint); err != nil { + log.Error(). + Str("endpoint", endpoint.Name). + Str("URL", endpoint.URL). + Err(err). + Msg("environment snapshot error") + } + + return dataStore.Endpoint().Create(endpoint) +} + +func createUnsecuredEndpoint(endpointURL string, dataStore dataservices.DataStore, snapshotService portainer.SnapshotService) error { + if strings.HasPrefix(endpointURL, "tcp://") { + if _, err := client.ExecutePingOperation(endpointURL, portainer.TLSConfiguration{}); err != nil { + return err + } + } + + endpointID := dataStore.Endpoint().GetNextIdentifier() + endpoint := &portainer.Endpoint{ + ID: portainer.EndpointID(endpointID), + Name: "primary", + URL: endpointURL, + GroupID: portainer.EndpointGroupID(1), + Type: portainer.DockerEnvironment, + TLSConfig: portainer.TLSConfiguration{}, + UserAccessPolicies: portainer.UserAccessPolicies{}, + TeamAccessPolicies: portainer.TeamAccessPolicies{}, + TagIDs: []portainer.TagID{}, + Status: portainer.EndpointStatusUp, + Snapshots: []portainer.DockerSnapshot{}, + Kubernetes: portainer.KubernetesDefault(), + SecuritySettings: portainer.DefaultEndpointSecuritySettings(), + } + + if err := snapshotService.SnapshotEndpoint(endpoint); err != nil { + log.Error(). + Str("endpoint", endpoint.Name). + Str("URL", endpoint.URL).Err(err). + Msg("environment snapshot error") + } + + return dataStore.Endpoint().Create(endpoint) +} diff --git a/api/internal/endpointutils/endpoint_setup_test.go b/api/internal/endpointutils/endpoint_setup_test.go new file mode 100644 index 0000000..5848a34 --- /dev/null +++ b/api/internal/endpointutils/endpoint_setup_test.go @@ -0,0 +1,13 @@ +package endpointutils + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCreateOfflineUnsecuredEndpoint(t *testing.T) { + t.Parallel() + err := createUnsecuredEndpoint("tcp://localhost:1", nil, nil) + require.Error(t, err) +} diff --git a/api/internal/endpointutils/endpoint_tags.go b/api/internal/endpointutils/endpoint_tags.go new file mode 100644 index 0000000..ab7e860 --- /dev/null +++ b/api/internal/endpointutils/endpoint_tags.go @@ -0,0 +1,83 @@ +package endpointutils + +import ( + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/set" +) + +// GetEndpointsByTags returns the trusted edge endpoints matching tagIDs, unioned if partialMatch +// is set or intersected otherwise. +func GetEndpointsByTags(tx dataservices.DataStoreTx, tagIDs []portainer.TagID, partialMatch bool) ([]portainer.EndpointID, error) { + if len(tagIDs) == 0 { + return []portainer.EndpointID{}, nil + } + + endpoints, err := tx.Endpoint().Endpoints() + if err != nil { + return nil, err + } + + groupEndpoints := mapEndpointGroupToEndpoints(endpoints) + + tags := []portainer.Tag{} + for _, tagID := range tagIDs { + tag, err := tx.Tag().Read(tagID) + if err != nil { + return nil, err + } + + tags = append(tags, *tag) + } + + setsOfEndpoints := mapTagsToEndpoints(tags, groupEndpoints) + + var endpointSet set.Set[portainer.EndpointID] + if partialMatch { + endpointSet = set.Union(setsOfEndpoints...) + } else { + endpointSet = set.Intersection(setsOfEndpoints...) + } + + results := []portainer.EndpointID{} + for _, endpoint := range endpoints { + if endpointSet.Contains(endpoint.ID) && IsEdgeEndpoint(&endpoint) && endpoint.UserTrusted { + results = append(results, endpoint.ID) + } + } + + return results, nil +} + +func mapEndpointGroupToEndpoints(endpoints []portainer.Endpoint) map[portainer.EndpointGroupID]set.Set[portainer.EndpointID] { + groupEndpoints := map[portainer.EndpointGroupID]set.Set[portainer.EndpointID]{} + + for _, endpoint := range endpoints { + groupID := endpoint.GroupID + if groupEndpoints[groupID] == nil { + groupEndpoints[groupID] = set.Set[portainer.EndpointID]{} + } + + groupEndpoints[groupID].Add(endpoint.ID) + } + + return groupEndpoints +} + +func mapTagsToEndpoints(tags []portainer.Tag, groupEndpoints map[portainer.EndpointGroupID]set.Set[portainer.EndpointID]) []set.Set[portainer.EndpointID] { + sets := make([]set.Set[portainer.EndpointID], 0, len(tags)) + + for _, tag := range tags { + s := set.Set[portainer.EndpointID](tag.Endpoints) + + for groupID := range tag.EndpointGroups { + for endpointID := range groupEndpoints[groupID] { + s.Add(endpointID) + } + } + + sets = append(sets, s) + } + + return sets +} diff --git a/api/internal/endpointutils/endpoint_test.go b/api/internal/endpointutils/endpoint_test.go new file mode 100644 index 0000000..53ddc54 --- /dev/null +++ b/api/internal/endpointutils/endpoint_test.go @@ -0,0 +1,144 @@ +package endpointutils + +import ( + "testing" + "time" + + portainer "github.com/portainer/portainer/api" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type isEndpointTypeTest struct { + endpointType portainer.EndpointType + expected bool +} + +func Test_IsDockerEndpoint(t *testing.T) { + t.Parallel() + tests := []isEndpointTypeTest{ + {endpointType: portainer.DockerEnvironment, expected: true}, + {endpointType: portainer.AgentOnDockerEnvironment, expected: true}, + {endpointType: portainer.AzureEnvironment, expected: false}, + {endpointType: portainer.EdgeAgentOnDockerEnvironment, expected: true}, + {endpointType: portainer.KubernetesLocalEnvironment, expected: false}, + {endpointType: portainer.AgentOnKubernetesEnvironment, expected: false}, + {endpointType: portainer.EdgeAgentOnKubernetesEnvironment, expected: false}, + } + + for _, test := range tests { + ans := IsDockerEndpoint(&portainer.Endpoint{Type: test.endpointType}) + assert.Equal(t, test.expected, ans) + } +} + +func Test_IsKubernetesEndpoint(t *testing.T) { + t.Parallel() + tests := []isEndpointTypeTest{ + {endpointType: portainer.DockerEnvironment, expected: false}, + {endpointType: portainer.AgentOnDockerEnvironment, expected: false}, + {endpointType: portainer.AzureEnvironment, expected: false}, + {endpointType: portainer.EdgeAgentOnDockerEnvironment, expected: false}, + {endpointType: portainer.KubernetesLocalEnvironment, expected: true}, + {endpointType: portainer.AgentOnKubernetesEnvironment, expected: true}, + {endpointType: portainer.EdgeAgentOnKubernetesEnvironment, expected: true}, + } + + for _, test := range tests { + ans := IsKubernetesEndpoint(&portainer.Endpoint{Type: test.endpointType}) + assert.Equal(t, test.expected, ans) + } +} + +func Test_IsAgentEndpoint(t *testing.T) { + t.Parallel() + tests := []isEndpointTypeTest{ + {endpointType: portainer.DockerEnvironment, expected: false}, + {endpointType: portainer.AgentOnDockerEnvironment, expected: true}, + {endpointType: portainer.AzureEnvironment, expected: false}, + {endpointType: portainer.EdgeAgentOnDockerEnvironment, expected: true}, + {endpointType: portainer.KubernetesLocalEnvironment, expected: false}, + {endpointType: portainer.AgentOnKubernetesEnvironment, expected: true}, + {endpointType: portainer.EdgeAgentOnKubernetesEnvironment, expected: true}, + } + + for _, test := range tests { + ans := IsAgentEndpoint(&portainer.Endpoint{Type: test.endpointType}) + assert.Equal(t, test.expected, ans) + } +} + +func Test_FilterByExcludeIDs(t *testing.T) { + t.Parallel() + tests := []struct { + name string + inputArray []portainer.Endpoint + inputExcludeIDs []portainer.EndpointID + asserts func(*testing.T, []portainer.Endpoint) + }{ + { + name: "filter endpoints", + inputArray: []portainer.Endpoint{ + {ID: portainer.EndpointID(1)}, + {ID: portainer.EndpointID(2)}, + {ID: portainer.EndpointID(3)}, + {ID: portainer.EndpointID(4)}, + }, + inputExcludeIDs: []portainer.EndpointID{ + portainer.EndpointID(2), + portainer.EndpointID(3), + }, + asserts: func(t *testing.T, output []portainer.Endpoint) { + assert.Contains(t, output, portainer.Endpoint{ID: portainer.EndpointID(1)}) + assert.NotContains(t, output, portainer.Endpoint{ID: portainer.EndpointID(2)}) + assert.NotContains(t, output, portainer.Endpoint{ID: portainer.EndpointID(3)}) + assert.Contains(t, output, portainer.Endpoint{ID: portainer.EndpointID(4)}) + }, + }, + { + name: "empty input", + inputArray: []portainer.Endpoint{}, + inputExcludeIDs: []portainer.EndpointID{ + portainer.EndpointID(2), + }, + asserts: func(t *testing.T, output []portainer.Endpoint) { + assert.Empty(t, output) + }, + }, + { + name: "no filter", + inputArray: []portainer.Endpoint{ + {ID: portainer.EndpointID(1)}, + {ID: portainer.EndpointID(2)}, + }, + inputExcludeIDs: []portainer.EndpointID{}, + asserts: func(t *testing.T, output []portainer.Endpoint) { + assert.Len(t, output, 2) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + output := FilterByExcludeIDs(tt.inputArray, tt.inputExcludeIDs) + tt.asserts(t, output) + }) + } +} + +func TestUpdateEdgeEndpointHeartbeat(t *testing.T) { + t.Parallel() + endpoint := &portainer.Endpoint{ + Type: portainer.EdgeAgentOnDockerEnvironment, + LastCheckInDate: time.Now().Unix(), + EdgeCheckinInterval: 5, + } + + UpdateEdgeEndpointHeartbeat(endpoint, &portainer.Settings{}) + require.True(t, endpoint.Heartbeat) + + endpoint.LastCheckInDate = time.Now().Add(-time.Minute).Unix() + UpdateEdgeEndpointHeartbeat(endpoint, &portainer.Settings{}) + require.False(t, endpoint.Heartbeat) +} diff --git a/api/internal/endpointutils/endpointutils.go b/api/internal/endpointutils/endpointutils.go new file mode 100644 index 0000000..1fa1aa1 --- /dev/null +++ b/api/internal/endpointutils/endpointutils.go @@ -0,0 +1,243 @@ +package endpointutils + +import ( + "errors" + "time" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/kubernetes/cli" + "github.com/portainer/portainer/pkg/endpoints" + + log "github.com/rs/zerolog/log" +) + +var ( + IsLocalEndpoint = endpoints.IsLocalEndpoint + IsKubernetesEndpoint = endpoints.IsKubernetesEndpoint + IsDockerEndpoint = endpoints.IsDockerEndpoint + IsEdgeEndpoint = endpoints.IsEdgeEndpoint + IsAgentEndpoint = endpoints.IsAgentEndpoint + EndpointSet = endpoints.EndpointSet +) + +// EndpointPlatformType returns the type of the endpoint based on the environment and container engine +func EndpointPlatformType(endpoint *portainer.Endpoint) portainer.PlatformType { + switch endpoint.Type { + case portainer.DockerEnvironment, portainer.AgentOnDockerEnvironment, portainer.EdgeAgentOnDockerEnvironment: + if endpoint.ContainerEngine == portainer.ContainerEnginePodman { + return portainer.PodmanPlatformType + } + return portainer.DockerPlatformType + case portainer.KubernetesLocalEnvironment, portainer.AgentOnKubernetesEnvironment, portainer.EdgeAgentOnKubernetesEnvironment: + return portainer.KubernetesPlatformType + case portainer.AzureEnvironment: + return portainer.AzurePlatformType + } + return portainer.UnknownPlatformType +} + +// FilterByExcludeIDs receives an environment(endpoint) array and returns a filtered array using an excludeIds param +func FilterByExcludeIDs(endpoints []portainer.Endpoint, excludeIds []portainer.EndpointID) []portainer.Endpoint { + if len(excludeIds) == 0 { + return endpoints + } + + filteredEndpoints := make([]portainer.Endpoint, 0) + + idsSet := make(map[portainer.EndpointID]bool) + for _, id := range excludeIds { + idsSet[id] = true + } + + for _, endpoint := range endpoints { + if !idsSet[endpoint.ID] { + filteredEndpoints = append(filteredEndpoints, endpoint) + } + } + + return filteredEndpoints +} + +func InitialIngressClassDetection(tx dataservices.DataStoreTx, endpoint *portainer.Endpoint, factory *cli.ClientFactory) { + if endpoint.Kubernetes.Flags.IsServerIngressClassDetected { + return + } + + defer func() { + endpoint.Kubernetes.Flags.IsServerIngressClassDetected = true + + if err := tx.Endpoint().UpdateEndpoint(endpoint.ID, endpoint); err != nil { + log.Debug().Err(err).Msg("unable to store found IngressClasses inside the database") + } + }() + + cli, err := factory.GetPrivilegedKubeClient(endpoint) + if err != nil { + log.Debug().Err(err).Msg("unable to create kubernetes client for ingress class detection") + + return + } + + controllers, err := cli.GetIngressControllers() + if err != nil { + log.Debug().Err(err).Msg("failed to fetch ingressclasses") + + return + } + + var updatedClasses []portainer.KubernetesIngressClassConfig + for i := range controllers { + var updatedClass portainer.KubernetesIngressClassConfig + updatedClass.Name = controllers[i].ClassName + updatedClass.Type = controllers[i].Type + updatedClasses = append(updatedClasses, updatedClass) + } + + endpoint.Kubernetes.Configuration.IngressClasses = updatedClasses +} + +func InitialMetricsDetection(tx dataservices.DataStoreTx, endpoint *portainer.Endpoint, factory *cli.ClientFactory) { + if endpoint.Kubernetes.Flags.IsServerMetricsDetected { + return + } + + defer func() { + endpoint.Kubernetes.Flags.IsServerMetricsDetected = true + if err := tx.Endpoint().UpdateEndpoint(endpoint.ID, endpoint); err != nil { + log.Debug().Err(err).Msg("unable to enable UseServerMetrics inside the database") + } + }() + + cli, err := factory.GetPrivilegedKubeClient(endpoint) + if err != nil { + log.Debug().Err(err).Msg("unable to create kubernetes client for initial metrics detection") + + return + } + + if _, err := cli.GetMetrics(); err != nil { + log.Debug().Err(err).Msg("unable to fetch metrics: leaving metrics collection disabled.") + + return + } + + endpoint.Kubernetes.Configuration.UseServerMetrics = true +} + +func storageDetect(tx dataservices.DataStoreTx, endpoint *portainer.Endpoint, factory *cli.ClientFactory) error { + if endpoint.Kubernetes.Flags.IsServerStorageDetected { + return nil + } + + defer func() { + endpoint.Kubernetes.Flags.IsServerStorageDetected = true + if err := tx.Endpoint().UpdateEndpoint(endpoint.ID, endpoint); err != nil { + log.Info().Err(err).Msg("unable to enable storage class inside the database") + } + }() + + cli, err := factory.GetPrivilegedKubeClient(endpoint) + if err != nil { + log.Debug().Err(err).Msg("unable to create Kubernetes client for initial storage detection") + + return err + } + + storage, err := cli.GetStorage() + if err != nil { + log.Debug().Err(err).Msg("unable to fetch storage classes: leaving storage classes disabled") + + return err + } else if len(storage) == 0 { + log.Info().Err(err).Msg("zero storage classes found: they may be still building, retrying in 30 seconds") + + return errors.New("zero storage classes found: they may be still building, retrying in 30 seconds") + } + + endpoint.Kubernetes.Configuration.StorageClasses = storage + + return nil +} + +func InitialStorageDetection(tx dataservices.DataStoreTx, dataStore dataservices.DataStore, endpoint *portainer.Endpoint, factory *cli.ClientFactory) { + log.Info().Msg("attempting to detect storage classes in the cluster") + err := storageDetect(tx, endpoint, factory) + if err == nil { + return + } + log.Err(err).Msg("error while detecting storage classes") + + endpointID := endpoint.ID + go func() { + // Retry after 30 seconds if the initial detection failed. + log.Info().Msg("retrying storage detection in 30 seconds") + time.Sleep(30 * time.Second) + + err := dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + endpoint, err := tx.Endpoint().Endpoint(endpointID) + if err != nil { + return err + } + + return storageDetect(tx, endpoint, factory) + }) + log.Err(err).Msg("final error while detecting storage classes") + }() +} + +func UpdateEdgeEndpointHeartbeat(endpoint *portainer.Endpoint, settings *portainer.Settings) { + if !IsEdgeEndpoint(endpoint) { + return + } + + endpoint.Heartbeat = GetHeartbeatStatus(endpoint, settings) +} + +func GetHeartbeatStatus(endpoint *portainer.Endpoint, settings *portainer.Settings) bool { + checkInInterval := getEndpointCheckinInterval(endpoint, settings) + return time.Now().Unix()-endpoint.LastCheckInDate <= int64(checkInInterval*2+20) +} + +func getEndpointCheckinInterval(endpoint *portainer.Endpoint, settings *portainer.Settings) int { + if !endpoint.Edge.AsyncMode { + if endpoint.EdgeCheckinInterval > 0 { + return endpoint.EdgeCheckinInterval + } + + return settings.EdgeAgentCheckinInterval + } + + defaultInterval := 60 + intervals := [][]int{ + {endpoint.Edge.PingInterval, settings.Edge.PingInterval}, + {endpoint.Edge.CommandInterval, settings.Edge.CommandInterval}, + {endpoint.Edge.SnapshotInterval, settings.Edge.SnapshotInterval}, + } + + for i := range intervals { + effectiveInterval := intervals[i][0] + if effectiveInterval <= 0 { + effectiveInterval = intervals[i][1] + } + + if effectiveInterval > 0 && effectiveInterval < defaultInterval { + defaultInterval = effectiveInterval + } + } + + return defaultInterval +} + +func InitializeEdgeEndpointRelation(endpoint *portainer.Endpoint, tx dataservices.DataStoreTx) error { + if !IsEdgeEndpoint(endpoint) { + return nil + } + + relation := &portainer.EndpointRelation{ + EndpointID: endpoint.ID, + EdgeStacks: make(map[portainer.EdgeStackID]bool), + } + + return tx.EndpointRelation().Create(relation) +} diff --git a/api/internal/nodes/nodes.go b/api/internal/nodes/nodes.go new file mode 100644 index 0000000..9e8168c --- /dev/null +++ b/api/internal/nodes/nodes.go @@ -0,0 +1,31 @@ +package status + +import ( + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/internal/endpointutils" +) + +// NodesCount returns the total node number of all environments +func NodesCount(endpoints []portainer.Endpoint) int { + nodes := 0 + + for _, env := range endpoints { + if !endpointutils.IsEdgeEndpoint(&env) || env.UserTrusted { + nodes += countNodes(&env) + } + } + + return nodes +} + +func countNodes(endpoint *portainer.Endpoint) int { + if len(endpoint.Snapshots) == 1 { + return max(endpoint.Snapshots[0].NodeCount, 1) + } + + if len(endpoint.Kubernetes.Snapshots) == 1 { + return max(endpoint.Kubernetes.Snapshots[0].NodeCount, 1) + } + + return 1 +} diff --git a/api/internal/randomstring/random_string.go b/api/internal/randomstring/random_string.go new file mode 100644 index 0000000..44d4336 --- /dev/null +++ b/api/internal/randomstring/random_string.go @@ -0,0 +1,14 @@ +package randomstring + +import "github.com/portainer/portainer/pkg/librand" + +const letterBytes = "abcdefghijklmnopqrstuvwxyz0123456789" + +// RandomString returns a random lowercase alphanumeric string of length n +func RandomString(n int) string { + b := make([]byte, n) + for i := range b { + b[i] = letterBytes[librand.Intn(len(letterBytes))] + } + return string(b) +} diff --git a/api/internal/randomstring/random_string_test.go b/api/internal/randomstring/random_string_test.go new file mode 100644 index 0000000..6837070 --- /dev/null +++ b/api/internal/randomstring/random_string_test.go @@ -0,0 +1,67 @@ +package randomstring + +import ( + "testing" + + "github.com/portainer/portainer/pkg/fips" + "github.com/stretchr/testify/require" +) + +func init() { + fips.InitFIPS(false) +} + +func TestRandomString(t *testing.T) { + t.Parallel() + testCases := []struct { + name string + length int + expected int + }{ + { + name: "zero length", + length: 0, + expected: 0, + }, + { + name: "short string", + length: 5, + expected: 5, + }, + { + name: "longer string", + length: 20, + expected: 20, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := RandomString(tc.length) + require.Len(t, result, tc.expected) + + // Verify all characters are from the expected alphabet + for _, char := range result { + require.Contains(t, letterBytes, string(char)) + } + }) + } +} + +func TestRandomStringUniqueness(t *testing.T) { + t.Parallel() + // Generate multiple random strings and verify they are different + const numStrings = 100 + const stringLength = 10 + + generated := make(map[string]bool) + + for range numStrings { + str := RandomString(stringLength) + require.Len(t, str, stringLength) + + // Check if we've seen this string before (very unlikely for random strings) + require.False(t, generated[str], "Generated duplicate random string: %s", str) + generated[str] = true + } +} diff --git a/api/internal/registryutils/access/access.go b/api/internal/registryutils/access/access.go new file mode 100644 index 0000000..bfa5181 --- /dev/null +++ b/api/internal/registryutils/access/access.go @@ -0,0 +1,102 @@ +package access + +import ( + "errors" + "fmt" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/endpointutils" + "github.com/portainer/portainer/api/kubernetes" + "github.com/portainer/portainer/api/kubernetes/cli" +) + +func hasPermission( + dataStore dataservices.DataStore, + k8sClientFactory *cli.ClientFactory, + userID portainer.UserID, + endpointID portainer.EndpointID, + registry *portainer.Registry, +) (hasPermission bool, err error) { + user, err := dataStore.User().Read(userID) + if err != nil { + return false, err + } + + if user.Role == portainer.AdministratorRole { + return true, nil + } + + endpoint, err := dataStore.Endpoint().Endpoint(endpointID) + if err != nil { + return false, err + } + + teamMemberships, err := dataStore.TeamMembership().TeamMembershipsByUserID(userID) + if err != nil { + return false, err + } + + // validate access for kubernetes namespaces (leverage registry.RegistryAccesses[endpointId].Namespaces) + if endpointutils.IsKubernetesEndpoint(endpoint) && k8sClientFactory != nil { + kcl, err := k8sClientFactory.GetPrivilegedKubeClient(endpoint) + if err != nil { + return false, fmt.Errorf("unable to retrieve kubernetes client to validate registry access: %w", err) + } + accessPolicies, err := kcl.GetNamespaceAccessPolicies() + if err != nil { + return false, fmt.Errorf("unable to retrieve environment's namespaces policies to validate registry access: %w", err) + } + + authorizedNamespaces := registry.RegistryAccesses[endpointID].Namespaces + + for _, namespace := range authorizedNamespaces { + // when the default namespace is authorized to use a registry, all users have the ability to use it + // unless the default namespace is restricted: in this case continue to search for other potential accesses authorizations + if namespace == kubernetes.DefaultNamespace && !endpoint.Kubernetes.Configuration.RestrictDefaultNamespace { + return true, nil + } + + namespacePolicy := accessPolicies[namespace] + if security.AuthorizedAccess(user.ID, teamMemberships, namespacePolicy.UserAccessPolicies, namespacePolicy.TeamAccessPolicies) { + return true, nil + } + } + return false, nil + } + + // validate access for docker environments + // leverage registry.RegistryAccesses[endpointId].UserAccessPolicies (direct access) + // and registry.RegistryAccesses[endpointId].TeamAccessPolicies (indirect access via his teams) + hasPermission = security.AuthorizedRegistryAccess(registry, user, teamMemberships, endpointID) + + return hasPermission, nil +} + +// GetAccessibleRegistry get the registry if the user has permission +func GetAccessibleRegistry( + dataStore dataservices.DataStore, + k8sClientFactory *cli.ClientFactory, + userID portainer.UserID, + endpointID portainer.EndpointID, + registryID portainer.RegistryID, +) (registry *portainer.Registry, err error) { + + registry, err = dataStore.Registry().Read(registryID) + if err != nil { + return + } + + hasPermission, err := hasPermission(dataStore, k8sClientFactory, userID, endpointID, registry) + if err != nil { + return + } + + if !hasPermission { + err = errors.New("user does not has permission to get the registry") + return nil, err + } + + return +} diff --git a/api/internal/registryutils/auth_header.go b/api/internal/registryutils/auth_header.go new file mode 100644 index 0000000..ce09f1e --- /dev/null +++ b/api/internal/registryutils/auth_header.go @@ -0,0 +1,36 @@ +package registryutils + +import ( + "encoding/base64" + + portainer "github.com/portainer/portainer/api" + + "github.com/segmentio/encoding/json" +) + +type authHeader struct { + Username string `json:"username"` + Password string `json:"password"` + ServerAddress string `json:"serveraddress"` +} + +// GetRegistryAuthHeader generate the X-Registry-Auth header from registry +func GetRegistryAuthHeader(registry *portainer.Registry) (header string, err error) { + authHeader := authHeader{ + ServerAddress: registry.URL, + } + + authHeader.Username, authHeader.Password, err = GetRegEffectiveCredential(registry) + if err != nil { + return + } + + headerData, err := json.Marshal(authHeader) + if err != nil { + return + } + + header = base64.StdEncoding.EncodeToString(headerData) + + return +} diff --git a/api/internal/registryutils/ecr_kube_secret.go b/api/internal/registryutils/ecr_kube_secret.go new file mode 100644 index 0000000..a82ea97 --- /dev/null +++ b/api/internal/registryutils/ecr_kube_secret.go @@ -0,0 +1,43 @@ +package registryutils + +import ( + "slices" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" +) + +func isRegistryAssignedToNamespace(registry portainer.Registry, endpointID portainer.EndpointID, namespace string) bool { + return slices.Contains(registry.RegistryAccesses[endpointID].Namespaces, namespace) +} + +func RefreshEcrSecret(cli portainer.KubeClient, endpoint *portainer.Endpoint, dataStore dataservices.DataStore, namespace string) error { + registries, err := dataStore.Registry().ReadAll() + if err != nil { + return err + } + + for _, registry := range registries { + if registry.Type != portainer.EcrRegistry { + continue + } + + if !isRegistryAssignedToNamespace(registry, endpoint.ID, namespace) { + continue + } + + if err := EnsureRegTokenValid(dataStore, ®istry); err != nil { + return err + } + + if err := cli.DeleteRegistrySecret(registry.ID, namespace); err != nil { + return err + } + + if err := cli.CreateRegistrySecret(®istry, namespace); err != nil { + return err + } + } + + return nil +} diff --git a/api/internal/registryutils/ecr_reg_token.go b/api/internal/registryutils/ecr_reg_token.go new file mode 100644 index 0000000..a4387a8 --- /dev/null +++ b/api/internal/registryutils/ecr_reg_token.go @@ -0,0 +1,121 @@ +package registryutils + +import ( + "context" + "fmt" + "time" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/aws/ecr" + "github.com/portainer/portainer/api/dataservices" + + "github.com/rs/zerolog/log" +) + +func isRegTokenValid(registry *portainer.Registry) (valid bool) { + return registry.AccessToken != "" && registry.AccessTokenExpiry > time.Now().Unix() +} + +func fetchRegToken(registry *portainer.Registry) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + ecrClient := ecr.NewService(registry.Username, registry.Password, registry.Ecr.Region) + accessToken, expiryAt, err := ecrClient.GetAuthorizationToken(ctx) + if err != nil { + return err + } + registry.AccessToken = *accessToken + registry.AccessTokenExpiry = expiryAt.Unix() + + return nil +} + +func doGetRegToken(tx dataservices.DataStoreTx, registry *portainer.Registry) error { + if err := fetchRegToken(registry); err != nil { + return err + } + + return tx.Registry().Update(registry.ID, registry) +} + +// RefreshAndPersistECRTokens refreshes and persists ECR tokens for all registries that need it. +// Must be called with a real DataStoreTx (not a top-level DataStore) to avoid write-lock contention. +func RefreshAndPersistECRTokens(tx dataservices.DataStoreTx, registries []portainer.Registry) { + for i := range registries { + reg := ®istries[i] + if reg.Type != portainer.EcrRegistry { + continue + } + if isRegTokenValid(reg) { + continue + } + if err := doGetRegToken(tx, reg); err != nil { + log.Warn(). + Err(err). + Str("RegistryName", reg.Name). + Msg("Failed to get valid ECR registry token. Skip logging with this registry.") + } + } +} + +func EnsureRegTokenValid(tx dataservices.DataStoreTx, registry *portainer.Registry) error { + if registry.Type != portainer.EcrRegistry { + return nil + } + + if isRegTokenValid(registry) { + log.Debug().Msg("current ECR token is still valid") + + return nil + } + + if err := doGetRegToken(tx, registry); err != nil { + log.Debug().Msg("refresh ECR token") + + return err + } + + return nil +} + +func GetRegEffectiveCredential(registry *portainer.Registry) (username, password string, err error) { + username = registry.Username + password = registry.Password + + if registry.Type == portainer.EcrRegistry { + // Fallback token refresh in case the upstream caller did not pre-validate the token. + if !isRegTokenValid(registry) { + if err := fetchRegToken(registry); err != nil { + return "", "", fmt.Errorf("ECR registry %q credentials are invalid or expired. Error: %w", registry.Name, err) + } + } + + username, password, err = ecr.NewService(registry.Username, registry.Password, registry.Ecr.Region). + ParseAuthorizationToken(registry.AccessToken) + } + + return +} + +// PrepareRegistryCredentials consolidates the common pattern of ensuring valid ECR token +// and setting effective credentials on the registry when authentication is enabled. +// This function modifies the registry in-place by setting Username and Password to the effective values. +func PrepareRegistryCredentials(tx dataservices.DataStoreTx, registry *portainer.Registry) error { + if !registry.Authentication { + return nil + } + + if err := EnsureRegTokenValid(tx, registry); err != nil { + return err + } + + username, password, err := GetRegEffectiveCredential(registry) + if err != nil { + return err + } + + registry.Username = username + registry.Password = password + + return nil +} diff --git a/api/internal/registryutils/ecr_reg_token_test.go b/api/internal/registryutils/ecr_reg_token_test.go new file mode 100644 index 0000000..2c9b9a3 --- /dev/null +++ b/api/internal/registryutils/ecr_reg_token_test.go @@ -0,0 +1,99 @@ +package registryutils_test + +import ( + "io" + "strings" + "testing" + "time" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/internal/registryutils" + zerolog "github.com/rs/zerolog/log" + "github.com/stretchr/testify/require" +) + +func newECRRegistry(id portainer.RegistryID, accessToken string, expiry int64) portainer.Registry { + return portainer.Registry{ + ID: id, + Type: portainer.EcrRegistry, + Name: "test-ecr", + Username: "AKIAIOSFODNN7EXAMPLE", + Password: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + Ecr: portainer.EcrData{Region: "us-east-1"}, + AccessToken: accessToken, + AccessTokenExpiry: expiry, + } +} + +func TestRefreshAndPersistECRTokens(t *testing.T) { + t.Run("does not modify token for ECR registry with valid token", func(t *testing.T) { + _, ds := datastore.MustNewTestStore(t, true, false) + reg := newECRRegistry(1, "valid-token", time.Now().Add(time.Hour).Unix()) + require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error { + registryutils.RefreshAndPersistECRTokens(tx, []portainer.Registry{reg}) + return nil + })) + require.Equal(t, "valid-token", reg.AccessToken) + }) + + t.Run("does not block and leaves token empty when ECR token refresh fails", func(t *testing.T) { + var logOutput strings.Builder + setupLogOutput(t, &logOutput) + + _, ds := datastore.MustNewTestStore(t, true, false) + reg := newECRRegistry(1, "", 0) + require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error { + registryutils.RefreshAndPersistECRTokens(tx, []portainer.Registry{reg}) + return nil + })) + require.Empty(t, reg.AccessToken) + require.Contains(t, logOutput.String(), "test-ecr") + require.Contains(t, logOutput.String(), "Failed to get valid ECR registry token") + }) +} + +func setupLogOutput(t *testing.T, w io.Writer) { + t.Helper() + + oldLogger := zerolog.Logger + zerolog.Logger = zerolog.Output(w) + t.Cleanup(func() { + zerolog.Logger = oldLogger + }) +} + +func TestGetRegEffectiveCredential(t *testing.T) { + t.Parallel() + + t.Run("returns username and password directly for non-ECR registry", func(t *testing.T) { + t.Parallel() + reg := &portainer.Registry{ + Type: portainer.DockerHubRegistry, + Username: "user", + Password: "pass", + } + username, password, err := registryutils.GetRegEffectiveCredential(reg) + require.NoError(t, err) + require.Equal(t, "user", username) + require.Equal(t, "pass", password) + }) + + t.Run("parses ECR access token when token is valid", func(t *testing.T) { + t.Parallel() + reg := newECRRegistry(1, "AWS:ecr-password", time.Now().Add(time.Hour).Unix()) + username, password, err := registryutils.GetRegEffectiveCredential(®) + require.NoError(t, err) + require.Equal(t, "AWS", username) + require.Equal(t, "ecr-password", password) + }) + + t.Run("returns error for ECR registry with missing token and invalid credentials", func(t *testing.T) { + t.Parallel() + reg := newECRRegistry(1, "", 0) + _, _, err := registryutils.GetRegEffectiveCredential(®) + require.Error(t, err) + require.Contains(t, err.Error(), "test-ecr") + }) +} diff --git a/api/internal/registryutils/get_registry_name.go b/api/internal/registryutils/get_registry_name.go new file mode 100644 index 0000000..fb0c27d --- /dev/null +++ b/api/internal/registryutils/get_registry_name.go @@ -0,0 +1,11 @@ +package registryutils + +import ( + "strconv" + + portainer "github.com/portainer/portainer/api" +) + +func RegistrySecretName(registryID portainer.RegistryID) string { + return "registry-" + strconv.Itoa(int(registryID)) +} diff --git a/api/internal/snapshot/snapshot.go b/api/internal/snapshot/snapshot.go new file mode 100644 index 0000000..686ed58 --- /dev/null +++ b/api/internal/snapshot/snapshot.go @@ -0,0 +1,346 @@ +package snapshot + +import ( + "context" + "errors" + "time" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/agent" + "github.com/portainer/portainer/api/crypto" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/pendingactions" + endpointsutils "github.com/portainer/portainer/pkg/endpoints" + + "github.com/rs/zerolog/log" +) + +// Service represents a service to manage environment(endpoint) snapshots. +// It provides an interface to start background snapshots as well as +// specific Docker/Kubernetes environment(endpoint) snapshot methods. +type Service struct { + dataStore dataservices.DataStore + snapshotIntervalCh chan time.Duration + snapshotIntervalInSeconds float64 + dockerSnapshotter portainer.DockerSnapshotter + kubernetesSnapshotter portainer.KubernetesSnapshotter + pendingActionsService *pendingactions.PendingActionsService +} + +// NewService creates a new instance of a service +func NewService( + snapshotIntervalFromFlag string, + dataStore dataservices.DataStore, + dockerSnapshotter portainer.DockerSnapshotter, + kubernetesSnapshotter portainer.KubernetesSnapshotter, + pendingActionsService *pendingactions.PendingActionsService, +) (*Service, error) { + interval, err := parseSnapshotFrequency(snapshotIntervalFromFlag, dataStore) + if err != nil { + return nil, err + } + + return &Service{ + dataStore: dataStore, + snapshotIntervalCh: make(chan time.Duration), + snapshotIntervalInSeconds: interval, + dockerSnapshotter: dockerSnapshotter, + kubernetesSnapshotter: kubernetesSnapshotter, + pendingActionsService: pendingActionsService, + }, nil +} + +// NewBackgroundSnapshotter queues snapshots of existing edge environments that +// do not have one already +func NewBackgroundSnapshotter(dataStore dataservices.DataStore, tunnelService portainer.ReverseTunnelService) { + if err := dataStore.ViewTx(func(tx dataservices.DataStoreTx) error { + endpoints, err := tx.Endpoint().Endpoints() + if err != nil { + return err + } + + for _, e := range endpoints { + if !endpointsutils.HasDirectConnectivity(&e) { + continue + } + + s, err := tx.Snapshot().Read(e.ID) + if dataservices.IsErrObjectNotFound(err) || + (err == nil && s.Docker == nil && s.Kubernetes == nil) { + if err := tunnelService.Open(&e); err != nil { + log.Error().Err(err).Msg("could not open the tunnel") + } + } + } + + return nil + }); err != nil { + log.Error().Err(err).Msg("background snapshotter failure") + + return + } +} + +func parseSnapshotFrequency(snapshotInterval string, dataStore dataservices.DataStore) (float64, error) { + if snapshotInterval == "" { + settings, err := dataStore.Settings().Settings() + if err != nil { + return 0, err + } + + snapshotInterval = settings.SnapshotInterval + if snapshotInterval == "" { + snapshotInterval = portainer.DefaultSnapshotInterval + } + } + + snapshotFrequency, err := time.ParseDuration(snapshotInterval) + if err != nil { + return 0, err + } + + return snapshotFrequency.Seconds(), nil +} + +// Start will start a background routine to execute periodic snapshots of environments(endpoints) +func (service *Service) Start(ctx context.Context) { + go service.startSnapshotLoop(ctx) +} + +// SetSnapshotInterval sets the snapshot interval and resets the service +func (service *Service) SetSnapshotInterval(snapshotInterval string) error { + interval, err := time.ParseDuration(snapshotInterval) + if err != nil { + return err + } + + service.snapshotIntervalCh <- interval + + return nil +} + +// SupportDirectSnapshot checks whether an environment(endpoint) can be used to trigger a direct a snapshot. +// It is mostly true for all environments(endpoints) except Edge and Azure environments(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 environment(endpoint) based on the environment(endpoint) type. +// If the snapshot is a success, it will be associated to the environment(endpoint). +func (service *Service) SnapshotEndpoint(endpoint *portainer.Endpoint) error { + if endpoint.Type == portainer.AgentOnDockerEnvironment || endpoint.Type == portainer.AgentOnKubernetesEnvironment { + tlsConfig, err := crypto.CreateTLSConfigurationFromDisk(endpoint.TLSConfig) + if err != nil { + return err + } + + _, version, err := agent.GetAgentVersionAndPlatform(endpoint.URL, tlsConfig) + if err != nil { + return err + } + + endpoint.Agent.Version = version + } + + 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) Create(snapshot portainer.Snapshot) error { + return service.dataStore.Snapshot().Create(&snapshot) +} + +func (service *Service) FillSnapshotData(endpoint *portainer.Endpoint, includeRaw bool) error { + return FillSnapshotData(service.dataStore, endpoint, includeRaw) +} + +func (service *Service) snapshotKubernetesEndpoint(endpoint *portainer.Endpoint) error { + kubernetesSnapshot, err := service.kubernetesSnapshotter.CreateSnapshot(endpoint) + if err != nil { + return err + } + + if kubernetesSnapshot != nil { + snapshot := &portainer.Snapshot{EndpointID: endpoint.ID, Kubernetes: kubernetesSnapshot} + + return service.dataStore.Snapshot().Create(snapshot) + } + + return nil +} + +func (service *Service) snapshotDockerEndpoint(endpoint *portainer.Endpoint) error { + dockerSnapshot, err := service.dockerSnapshotter.CreateSnapshot(endpoint) + if err != nil { + return err + } + + if err := validateContainerEngineCompatibility(endpoint, dockerSnapshot); err != nil { + return err + } + + if dockerSnapshot != nil { + snapshot := &portainer.Snapshot{EndpointID: endpoint.ID, Docker: dockerSnapshot} + + return service.dataStore.Snapshot().Create(snapshot) + } + + return nil +} + +func validateContainerEngineCompatibility(endpoint *portainer.Endpoint, dockerSnapshot *portainer.DockerSnapshot) error { + if endpoint.ContainerEngine == portainer.ContainerEngineDocker && dockerSnapshot.IsPodman { + return errors.New("the Docker environment option doesn't support Podman environments. Please select the Podman option instead.") + } + if endpoint.ContainerEngine == portainer.ContainerEnginePodman && !dockerSnapshot.IsPodman { + return errors.New("the Podman environment option doesn't support Docker environments. Please select the Docker option instead.") + } + return nil +} + +func (service *Service) startSnapshotLoop(ctx context.Context) { + ticker := time.NewTicker(time.Duration(service.snapshotIntervalInSeconds) * time.Second) + + err := service.snapshotEndpoints() + if err != nil { + log.Error().Err(err).Msg("background schedule error (environment snapshot)") + } + + for { + select { + case <-ticker.C: + err := service.snapshotEndpoints() + if err != nil { + log.Error().Err(err).Msg("background schedule error (environment snapshot)") + } + case <-ctx.Done(): + log.Debug().Msg("shutting down snapshotting") + ticker.Stop() + + return + case interval := <-service.snapshotIntervalCh: + ticker.Reset(interval) + } + } +} + +func (service *Service) snapshotEndpoints() error { + endpoints, err := service.dataStore.Endpoint().Endpoints() + if err != nil { + return err + } + + for _, endpoint := range endpoints { + if !SupportDirectSnapshot(&endpoint) || endpoint.URL == "" { + continue + } + + snapshotError := service.SnapshotEndpoint(&endpoint) + + if err := service.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + updateEndpointStatus(tx, &endpoint, snapshotError, service.pendingActionsService) + + return nil + }); err != nil { + log.Error(). + Err(err). + Int("endpoint_id", int(endpoint.ID)). + Msg("unable to update environment status") + } + } + + return nil +} + +func updateEndpointStatus(tx dataservices.DataStoreTx, endpoint *portainer.Endpoint, snapshotError error, pendingActionsService *pendingactions.PendingActionsService) { + latestEndpointReference, err := tx.Endpoint().Endpoint(endpoint.ID) + if latestEndpointReference == nil { + log.Debug(). + Str("endpoint", endpoint.Name). + Str("URL", endpoint.URL).Err(err). + Msg("background schedule error (environment snapshot), environment not found inside the database anymore") + + return + } + + latestEndpointReference.Status = portainer.EndpointStatusUp + + if snapshotError != nil { + log.Debug(). + Str("endpoint", endpoint.Name). + Str("URL", endpoint.URL).Err(err). + Msg("background schedule error (environment snapshot), unable to create snapshot") + + latestEndpointReference.Status = portainer.EndpointStatusDown + } + + latestEndpointReference.Agent.Version = endpoint.Agent.Version + + if err := tx.Endpoint().UpdateEndpoint(latestEndpointReference.ID, latestEndpointReference); err != nil { + log.Debug(). + Str("endpoint", endpoint.Name). + Str("URL", endpoint.URL).Err(err). + Msg("background schedule error (environment snapshot), unable to update environment") + } + + // Run the pending actions + if latestEndpointReference.Status == portainer.EndpointStatusUp { + pendingActionsService.Execute(endpoint.ID) + } +} + +// FetchDockerID fetches info.Swarm.Cluster.ID if environment(endpoint) is swarm and info.ID otherwise +func FetchDockerID(snapshot portainer.DockerSnapshot) (string, error) { + info := snapshot.SnapshotRaw.Info + + if !snapshot.Swarm { + return info.ID, nil + } + + if info.Swarm.Cluster == nil { + return "", errors.New("swarm environment is missing cluster info snapshot") + } + + return info.Swarm.Cluster.ID, nil +} + +func FillSnapshotData(tx dataservices.DataStoreTx, endpoint *portainer.Endpoint, includeRaw bool) error { + var snapshot *portainer.Snapshot + var err error + + if includeRaw { + snapshot, err = tx.Snapshot().Read(endpoint.ID) + } else { + snapshot, err = tx.Snapshot().ReadWithoutSnapshotRaw(endpoint.ID) + } + + if tx.IsErrObjectNotFound(err) { + endpoint.Snapshots = []portainer.DockerSnapshot{} + endpoint.Kubernetes.Snapshots = []portainer.KubernetesSnapshot{} + + return nil + } else if err != nil { + return err + } + + if snapshot.Docker != nil { + endpoint.Snapshots = []portainer.DockerSnapshot{*snapshot.Docker} + } + + if snapshot.Kubernetes != nil { + endpoint.Kubernetes.Snapshots = []portainer.KubernetesSnapshot{*snapshot.Kubernetes} + } + + return nil +} diff --git a/api/internal/ssl/ssl.go b/api/internal/ssl/ssl.go new file mode 100644 index 0000000..d6e348c --- /dev/null +++ b/api/internal/ssl/ssl.go @@ -0,0 +1,165 @@ +package ssl + +import ( + "context" + "crypto/tls" + "os" + "time" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/pkg/libcrypto" + + "github.com/pkg/errors" + "github.com/rs/zerolog/log" +) + +// Service represents a service to manage SSL certificates +type Service struct { + fileService portainer.FileService + dataStore dataservices.DataStore + rawCert *tls.Certificate + shutdownTrigger context.CancelFunc +} + +// NewService returns a pointer to a new Service +func NewService(fileService portainer.FileService, dataStore dataservices.DataStore, shutdownTrigger context.CancelFunc) *Service { + return &Service{ + fileService: fileService, + dataStore: dataStore, + shutdownTrigger: shutdownTrigger, + } +} + +// Init initializes the service +func (service *Service) Init(host, certPath, keyPath string) error { + certSupplied := certPath != "" && keyPath != "" + if certSupplied { + newCertPath, newKeyPath, err := service.fileService.CopySSLCertPair(certPath, keyPath) + if err != nil { + return errors.Wrap(err, "failed copying supplied certs") + } + + return service.cacheInfo(newCertPath, newKeyPath, false) + } + + settings, err := service.GetSSLSettings() + if err != nil { + return errors.Wrap(err, "failed fetching SSL settings") + } + + // certificates already exist + if settings.CertPath != "" && settings.KeyPath != "" { + err := service.cacheCertificate(settings.CertPath, settings.KeyPath) + if err != nil && !os.IsNotExist(err) { + return err + } + + // continue if certs don't exist + if err == nil { + return nil + } + } + + // path not supplied and certificates doesn't exist - generate self-signed + certPath, keyPath = service.fileService.GetDefaultSSLCertsPath() + + if err := generateSelfSignedCertificates(host, certPath, keyPath); err != nil { + return errors.Wrap(err, "failed generating self signed certs") + } + + return service.cacheInfo(certPath, keyPath, true) +} + +func generateSelfSignedCertificates(ip, certPath, keyPath string) error { + if ip == "" { + return errors.New("host can't be empty") + } + + log.Info().Msg("no cert files found, generating self signed SSL certificates") + + return libcrypto.GenerateCertsForHost("localhost", ip, certPath, keyPath, time.Now().AddDate(5, 0, 0)) +} + +// GetRawCertificate gets the raw certificate +func (service *Service) GetRawCertificate() *tls.Certificate { + return service.rawCert +} + +// GetSSLSettings gets the certificate info +func (service *Service) GetSSLSettings() (*portainer.SSLSettings, error) { + return service.dataStore.SSLSettings().Settings() +} + +// SetCertificates sets the certificates +func (service *Service) SetCertificates(certData, keyData []byte) error { + if len(certData) == 0 || len(keyData) == 0 { + return errors.New("missing certificate files") + } + + if _, err := tls.X509KeyPair(certData, keyData); err != nil { + return err + } + + certPath, keyPath, err := service.fileService.StoreSSLCertPair(certData, keyData) + if err != nil { + return err + } + + if err := service.cacheInfo(certPath, keyPath, false); err != nil { + return err + } + + service.shutdownTrigger() + + return nil +} + +func (service *Service) SetHTTPEnabled(httpEnabled bool) error { + settings, err := service.dataStore.SSLSettings().Settings() + if err != nil { + return err + } + + if settings.HTTPEnabled == httpEnabled { + return nil + } + + settings.HTTPEnabled = httpEnabled + + if err := service.dataStore.SSLSettings().UpdateSettings(settings); err != nil { + return err + } + + service.shutdownTrigger() + + return nil +} + +func (service *Service) cacheCertificate(certPath, keyPath string) error { + rawCert, err := tls.LoadX509KeyPair(certPath, keyPath) + if err != nil { + return err + } + + service.rawCert = &rawCert + + return nil +} + +func (service *Service) cacheInfo(certPath string, keyPath string, selfSigned bool) error { + if err := service.cacheCertificate(certPath, keyPath); err != nil { + return err + } + + settings, err := service.dataStore.SSLSettings().Settings() + if err != nil { + return err + } + + settings.CertPath = certPath + settings.KeyPath = keyPath + settings.SelfSigned = selfSigned + + return service.dataStore.SSLSettings().UpdateSettings(settings) +} diff --git a/api/internal/testhelpers/compose_stack_manager.go b/api/internal/testhelpers/compose_stack_manager.go new file mode 100644 index 0000000..e33a945 --- /dev/null +++ b/api/internal/testhelpers/compose_stack_manager.go @@ -0,0 +1,38 @@ +package testhelpers + +import ( + "context" + + portainer "github.com/portainer/portainer/api" +) + +var _ portainer.ComposeStackManager = composeStackManager{} + +type composeStackManager struct{} + +func NewComposeStackManager() composeStackManager { + return composeStackManager{} +} + +func (manager composeStackManager) ComposeSyntaxMaxVersion() string { + return "" +} + +func (manager composeStackManager) NormalizeStackName(name string) string { + return name +} +func (manager composeStackManager) Run(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint, serviceName string, options portainer.ComposeRunOptions) error { + return nil +} + +func (manager composeStackManager) Up(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint, options portainer.ComposeUpOptions) error { + return nil +} + +func (manager composeStackManager) Down(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint) error { + return nil +} + +func (manager composeStackManager) Pull(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint, options portainer.ComposeOptions) error { + return nil +} diff --git a/api/internal/testhelpers/crypto_service.go b/api/internal/testhelpers/crypto_service.go new file mode 100644 index 0000000..012144f --- /dev/null +++ b/api/internal/testhelpers/crypto_service.go @@ -0,0 +1,16 @@ +package testhelpers + +// Service represents a service for encrypting/hashing data. +type cryptoService struct{} + +func NewCryptoService() cryptoService { + return cryptoService{} +} + +func (cryptoService) Hash(data string) (string, error) { + return "", nil +} + +func (cryptoService) CompareHashAndData(hash string, data string) error { + return nil +} diff --git a/api/internal/testhelpers/datastore.go b/api/internal/testhelpers/datastore.go new file mode 100644 index 0000000..80b7e19 --- /dev/null +++ b/api/internal/testhelpers/datastore.go @@ -0,0 +1,527 @@ +package testhelpers + +import ( + "time" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/database" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/dataservices/errors" + "github.com/portainer/portainer/api/slicesx" +) + +var _ dataservices.DataStore = &testDatastore{} + +type testDatastore struct { + allowList dataservices.AllowListService + customTemplate dataservices.CustomTemplateService + edgeGroup dataservices.EdgeGroupService + edgeJob dataservices.EdgeJobService + edgeStack dataservices.EdgeStackService + edgeStackStatus dataservices.EdgeStackStatusService + endpoint dataservices.EndpointService + endpointGroup dataservices.EndpointGroupService + endpointRelation dataservices.EndpointRelationService + helmUserRepository dataservices.HelmUserRepositoryService + registry dataservices.RegistryService + resourceControl dataservices.ResourceControlService + source dataservices.SourceService + apiKeyRepositoryService dataservices.APIKeyRepository + role dataservices.RoleService + sslSettings dataservices.SSLSettingsService + settings dataservices.SettingsService + snapshot dataservices.SnapshotService + stack dataservices.StackService + tag dataservices.TagService + teamMembership dataservices.TeamMembershipService + team dataservices.TeamService + tunnelServer dataservices.TunnelServerService + user dataservices.UserService + version dataservices.VersionService + webhook dataservices.WebhookService + pendingActionsService dataservices.PendingActionsService + workflow dataservices.WorkflowService + connection portainer.Connection +} + +func (d *testDatastore) Backup(path string) (string, error) { return "", nil } +func (d *testDatastore) Open() (bool, error) { return false, nil } +func (d *testDatastore) Init() error { return nil } +func (d *testDatastore) Close() error { return nil } +func (d *testDatastore) UpdateTx(func(dataservices.DataStoreTx) error) error { return nil } +func (d *testDatastore) ViewTx(func(dataservices.DataStoreTx) error) error { return nil } + +func (d *testDatastore) CheckCurrentEdition() error { return nil } +func (d *testDatastore) MigrateData() error { return nil } +func (d *testDatastore) Rollback(force bool) error { return nil } +func (d *testDatastore) AllowList() dataservices.AllowListService { return d.allowList } +func (d *testDatastore) CustomTemplate() dataservices.CustomTemplateService { return d.customTemplate } +func (d *testDatastore) EdgeGroup() dataservices.EdgeGroupService { return d.edgeGroup } +func (d *testDatastore) EdgeJob() dataservices.EdgeJobService { return d.edgeJob } +func (d *testDatastore) EdgeStack() dataservices.EdgeStackService { return d.edgeStack } +func (d *testDatastore) EdgeStackStatus() dataservices.EdgeStackStatusService { + return d.edgeStackStatus +} +func (d *testDatastore) Endpoint() dataservices.EndpointService { return d.endpoint } +func (d *testDatastore) EndpointGroup() dataservices.EndpointGroupService { return d.endpointGroup } + +func (d *testDatastore) EndpointRelation() dataservices.EndpointRelationService { + return d.endpointRelation +} + +func (d *testDatastore) HelmUserRepository() dataservices.HelmUserRepositoryService { + return d.helmUserRepository +} +func (d *testDatastore) Registry() dataservices.RegistryService { return d.registry } +func (d *testDatastore) ResourceControl() dataservices.ResourceControlService { + return d.resourceControl +} +func (d *testDatastore) Source() dataservices.SourceService { return d.source } +func (d *testDatastore) Role() dataservices.RoleService { return d.role } +func (d *testDatastore) APIKeyRepository() dataservices.APIKeyRepository { + return d.apiKeyRepositoryService +} +func (d *testDatastore) Settings() dataservices.SettingsService { return d.settings } +func (d *testDatastore) Snapshot() dataservices.SnapshotService { return d.snapshot } +func (d *testDatastore) SSLSettings() dataservices.SSLSettingsService { return d.sslSettings } +func (d *testDatastore) Stack() dataservices.StackService { return d.stack } +func (d *testDatastore) Tag() dataservices.TagService { return d.tag } +func (d *testDatastore) TeamMembership() dataservices.TeamMembershipService { return d.teamMembership } +func (d *testDatastore) Team() dataservices.TeamService { return d.team } +func (d *testDatastore) TunnelServer() dataservices.TunnelServerService { return d.tunnelServer } +func (d *testDatastore) User() dataservices.UserService { return d.user } +func (d *testDatastore) Version() dataservices.VersionService { return d.version } +func (d *testDatastore) Webhook() dataservices.WebhookService { return d.webhook } +func (d *testDatastore) Workflow() dataservices.WorkflowService { return d.workflow } + +func (d *testDatastore) PendingActions() dataservices.PendingActionsService { + return d.pendingActionsService +} + +func (d *testDatastore) Connection() portainer.Connection { + return d.connection +} + +func (d *testDatastore) IsErrObjectNotFound(e error) bool { + return false +} + +func (d *testDatastore) Export(filename string) (err error) { + return nil +} + +func (d *testDatastore) Import(filename string) (err error) { + return nil +} + +type datastoreOption = func(d *testDatastore) + +// NewDatastore creates new instance of testDatastore. +// Will apply options before returning, opts will be applied from left to right. +func NewDatastore(options ...datastoreOption) *testDatastore { + conn, _ := database.NewDatabase("boltdb", "", nil, false) + d := testDatastore{connection: conn} + + for _, o := range options { + o(&d) + } + + return &d +} + +type stubSettingsService struct { + settings *portainer.Settings +} + +func (s *stubSettingsService) BucketName() string { return "settings" } + +func (s *stubSettingsService) Settings() (*portainer.Settings, error) { + return s.settings, nil +} + +func (s *stubSettingsService) UpdateSettings(settings *portainer.Settings) error { + s.settings = settings + + return nil +} + +func WithSettingsService(settings *portainer.Settings) datastoreOption { + return func(d *testDatastore) { + d.settings = &stubSettingsService{ + settings: settings, + } + } +} + +type stubSSLSettingsService struct { + settings *portainer.SSLSettings +} + +func (s *stubSSLSettingsService) BucketName() string { return "ssl" } + +func (s *stubSSLSettingsService) Settings() (*portainer.SSLSettings, error) { + return s.settings, nil +} + +func (s *stubSSLSettingsService) UpdateSettings(settings *portainer.SSLSettings) error { + s.settings = settings + return nil +} + +func WithSSLSettingsService(settings *portainer.SSLSettings) datastoreOption { + return func(d *testDatastore) { + d.sslSettings = &stubSSLSettingsService{settings: settings} + } +} + +type stubUserService struct { + dataservices.UserService + + users []portainer.User +} + +func (s *stubUserService) BucketName() string { return "users" } +func (s *stubUserService) ReadAll(predicates ...func(portainer.User) bool) ([]portainer.User, error) { + filtered := s.users + + for _, p := range predicates { + filtered = slicesx.Filter(filtered, p) + } + + return filtered, nil +} + +func (s *stubUserService) UsersByRole(role portainer.UserRole) ([]portainer.User, error) { + return s.users, nil +} + +// WithUsers testDatastore option that will instruct testDatastore to return provided users +func WithUsers(us []portainer.User) datastoreOption { + return func(d *testDatastore) { + d.user = &stubUserService{users: us} + } +} + +type stubEdgeJobService struct { + dataservices.EdgeJobService + + jobs []portainer.EdgeJob +} + +func (s *stubEdgeJobService) BucketName() string { return "edgejobs" } +func (s *stubEdgeJobService) ReadAll(predicates ...func(portainer.EdgeJob) bool) ([]portainer.EdgeJob, error) { + filtered := s.jobs + + for _, p := range predicates { + filtered = slicesx.Filter(filtered, p) + } + + return filtered, nil +} + +// WithEdgeJobs option will instruct testDatastore to return provided jobs +func WithEdgeJobs(js []portainer.EdgeJob) datastoreOption { + return func(d *testDatastore) { + d.edgeJob = &stubEdgeJobService{jobs: js} + } +} + +type stubEndpointRelationService struct { + dataservices.EndpointRelationService + + relations []portainer.EndpointRelation +} + +func (s *stubEndpointRelationService) BucketName() string { return "endpoint_relation" } +func (s *stubEndpointRelationService) EndpointRelations() ([]portainer.EndpointRelation, error) { + return s.relations, nil +} + +func (s *stubEndpointRelationService) EndpointRelation(ID portainer.EndpointID) (*portainer.EndpointRelation, error) { + for _, relation := range s.relations { + if relation.EndpointID == ID { + return &relation, nil + } + } + + return nil, errors.ErrObjectNotFound +} + +func (s *stubEndpointRelationService) UpdateEndpointRelation(ID portainer.EndpointID, relation *portainer.EndpointRelation) error { + for i, r := range s.relations { + if r.EndpointID == ID { + s.relations[i] = *relation + } + } + + return nil +} + +func (s *stubEndpointRelationService) AddEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStack *portainer.EdgeStack) error { + for _, endpointID := range endpointIDs { + for i, r := range s.relations { + if r.EndpointID == endpointID { + s.relations[i].EdgeStacks[edgeStack.ID] = true + } + } + } + + return nil +} + +func (s *stubEndpointRelationService) RemoveEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error { + for _, endpointID := range endpointIDs { + for i, r := range s.relations { + if r.EndpointID == endpointID { + delete(s.relations[i].EdgeStacks, edgeStackID) + } + } + } + + return nil +} + +// WithEndpointRelations option will instruct testDatastore to return provided jobs +func WithEndpointRelations(relations []portainer.EndpointRelation) datastoreOption { + return func(d *testDatastore) { + d.endpointRelation = &stubEndpointRelationService{relations: relations} + } +} + +type stubEndpointService struct { + dataservices.EndpointService + + endpoints []portainer.Endpoint +} + +func (s *stubEndpointService) BucketName() string { return "endpoint" } +func (s *stubEndpointService) Endpoint(ID portainer.EndpointID) (*portainer.Endpoint, error) { + for _, endpoint := range s.endpoints { + if endpoint.ID == ID { + return &endpoint, nil + } + } + + return nil, errors.ErrObjectNotFound +} + +func (s *stubEndpointService) EndpointIDByEdgeID(edgeID string) (portainer.EndpointID, bool) { + for _, endpoint := range s.endpoints { + if endpoint.EdgeID == edgeID { + return endpoint.ID, true + } + } + + return 0, false +} + +func (s *stubEndpointService) Heartbeat(endpointID portainer.EndpointID) (int64, bool) { + for i, endpoint := range s.endpoints { + if endpoint.ID == endpointID { + return s.endpoints[i].LastCheckInDate, true + } + } + + return 0, false +} + +func (s *stubEndpointService) UpdateHeartbeat(endpointID portainer.EndpointID) { + for i, endpoint := range s.endpoints { + if endpoint.ID == endpointID { + s.endpoints[i].LastCheckInDate = time.Now().Unix() + } + } +} + +func (s *stubEndpointService) Endpoints() ([]portainer.Endpoint, error) { + return s.endpoints, nil +} + +func (s *stubEndpointService) Create(endpoint *portainer.Endpoint) error { + s.endpoints = append(s.endpoints, *endpoint) + + return nil +} + +func (s *stubEndpointService) UpdateEndpoint(ID portainer.EndpointID, endpoint *portainer.Endpoint) error { + for i, e := range s.endpoints { + if e.ID == ID { + s.endpoints[i] = *endpoint + } + } + + return nil +} + +func (s *stubEndpointService) DeleteEndpoint(ID portainer.EndpointID) error { + endpoints := []portainer.Endpoint{} + + for _, endpoint := range s.endpoints { + if endpoint.ID != ID { + endpoints = append(endpoints, endpoint) + } + } + + s.endpoints = endpoints + + return nil +} + +func (s *stubEndpointService) GetNextIdentifier() int { + return len(s.endpoints) +} + +func (s *stubEndpointService) EndpointsByTeamID(teamID portainer.TeamID) ([]portainer.Endpoint, error) { + endpoints := make([]portainer.Endpoint, 0) + + for _, e := range s.endpoints { + for t := range e.TeamAccessPolicies { + if t == teamID { + endpoints = append(endpoints, e) + } + } + } + + return endpoints, nil +} + +// WithEndpoints option will instruct testDatastore to return provided environments(endpoints) +func WithEndpoints(endpoints []portainer.Endpoint) datastoreOption { + return func(d *testDatastore) { + d.endpoint = &stubEndpointService{endpoints: endpoints} + } +} + +type stubStacksService struct { + dataservices.StackService + stacks []portainer.Stack +} + +func (s *stubStacksService) BucketName() string { return "stacks" } + +func (s *stubStacksService) Read(ID portainer.StackID) (*portainer.Stack, error) { + for _, stack := range s.stacks { + if stack.ID == ID { + return &stack, nil + } + } + + return nil, errors.ErrObjectNotFound +} + +func (s *stubStacksService) ReadAll(predicates ...func(portainer.Stack) bool) ([]portainer.Stack, error) { + filtered := s.stacks + + for _, p := range predicates { + filtered = slicesx.Filter(filtered, p) + } + + return filtered, nil +} + +func (s *stubStacksService) StacksByEndpointID(endpointID portainer.EndpointID) ([]portainer.Stack, error) { + result := make([]portainer.Stack, 0) + + for _, stack := range s.stacks { + if stack.EndpointID == endpointID { + result = append(result, stack) + } + } + + return result, nil +} + +func (s *stubStacksService) RefreshableStacks() ([]portainer.Stack, error) { + result := make([]portainer.Stack, 0) + + for _, stack := range s.stacks { + if stack.AutoUpdate != nil { + result = append(result, stack) + } + } + + return result, nil +} + +func (s *stubStacksService) StackByName(name string) (*portainer.Stack, error) { + for _, stack := range s.stacks { + if stack.Name == name { + return &stack, nil + } + } + + return nil, errors.ErrObjectNotFound +} + +func (s *stubStacksService) StacksByName(name string) ([]portainer.Stack, error) { + result := make([]portainer.Stack, 0) + + for _, stack := range s.stacks { + if stack.Name == name { + result = append(result, stack) + } + } + + return result, nil +} + +func (s *stubStacksService) StackByWebhookID(webhookID string) (*portainer.Stack, error) { + for _, stack := range s.stacks { + if stack.AutoUpdate != nil && stack.AutoUpdate.Webhook == webhookID { + return &stack, nil + } + } + + return nil, errors.ErrObjectNotFound +} + +func (s *stubStacksService) GetNextIdentifier() int { + return len(s.stacks) +} + +func (s *stubStacksService) Exists(ID portainer.StackID) (bool, error) { + return false, nil +} + +// WithStacks option will instruct testDatastore to return provided stacks +func WithStacks(stacks []portainer.Stack) datastoreOption { + return func(d *testDatastore) { + d.stack = &stubStacksService{stacks: stacks} + } +} + +type stubPendingActionService struct { + actions []portainer.PendingAction + dataservices.PendingActionsService +} + +func WithPendingActions(pendingActions []portainer.PendingAction) datastoreOption { + return func(d *testDatastore) { + d.pendingActionsService = &stubPendingActionService{ + actions: pendingActions, + } + } +} + +func (s *stubPendingActionService) ReadAll(predicates ...func(portainer.PendingAction) bool) ([]portainer.PendingAction, error) { + filtered := s.actions + + for _, predicate := range predicates { + filtered = slicesx.Filter(filtered, predicate) + } + + return filtered, nil +} + +func (s *stubPendingActionService) Delete(ID portainer.PendingActionID) error { + actions := []portainer.PendingAction{} + + for _, action := range s.actions { + if action.ID != ID { + actions = append(actions, action) + } + } + s.actions = actions + + return nil +} diff --git a/api/internal/testhelpers/git_service.go b/api/internal/testhelpers/git_service.go new file mode 100644 index 0000000..1024a4b --- /dev/null +++ b/api/internal/testhelpers/git_service.go @@ -0,0 +1,68 @@ +package testhelpers + +import ( + "context" + + portainer "github.com/portainer/portainer/api" +) + +type gitService struct { + cloneErr error + id string +} + +// NewGitService creates new mock for portainer.GitService. +func NewGitService(cloneErr error, id string) portainer.GitService { + return &gitService{ + cloneErr: cloneErr, + id: id, + } +} + +func (g *gitService) CloneRepository( + _ context.Context, + destination, + repositoryURL, + referenceName, + username, + password string, + tlsSkipVerify bool, +) error { + return g.cloneErr +} + +func (g *gitService) LatestCommitID( + _ context.Context, + repositoryURL, + referenceName, + username, + password string, + tlsSkipVerify bool, +) (string, error) { + return g.id, nil +} + +func (g *gitService) ListRefs( + _ context.Context, + repositoryURL, + username, + password string, + hardRefresh bool, + tlsSkipVerify bool, +) ([]string, error) { + return nil, nil +} + +func (g *gitService) ListFiles( + _ context.Context, + repositoryURL, + referenceName, + username, + password string, + dirOnly, + hardRefresh bool, + includedExts []string, + tlsSkipVerify bool, +) ([]string, error) { + return nil, nil +} diff --git a/api/internal/testhelpers/kube_client.go b/api/internal/testhelpers/kube_client.go new file mode 100644 index 0000000..9c1bc76 --- /dev/null +++ b/api/internal/testhelpers/kube_client.go @@ -0,0 +1,26 @@ +package testhelpers + +import ( + "context" + + portainer "github.com/portainer/portainer/api" + models "github.com/portainer/portainer/api/http/models/kubernetes" +) + +type testKubeClient struct { + portainer.KubeClient +} + +func NewKubernetesClient() portainer.KubeClient { + return &testKubeClient{} +} + +// Event +func (kcl *testKubeClient) GetEvents(namespace string, resourceId string) ([]models.K8sEvent, error) { + return nil, nil +} + +// Pod +func (kcl *testKubeClient) DeletePod(namespace, name string) error { return nil } +func (kcl *testKubeClient) RestartPod(namespace, name string) error { return nil } +func (kcl *testKubeClient) SupportsPodRestart(_ context.Context) (bool, error) { return false, nil } diff --git a/api/internal/testhelpers/request_bouncer.go b/api/internal/testhelpers/request_bouncer.go new file mode 100644 index 0000000..c5720df --- /dev/null +++ b/api/internal/testhelpers/request_bouncer.go @@ -0,0 +1,71 @@ +package testhelpers + +import ( + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" +) + +type testRequestBouncer struct{} + +// NewTestRequestBouncer creates new mock for requestBouncer +func NewTestRequestBouncer() testRequestBouncer { + return testRequestBouncer{} +} + +func (testRequestBouncer) AuthenticatedAccess(h http.Handler) http.Handler { + return h +} + +func (testRequestBouncer) AdminAccess(h http.Handler) http.Handler { + return h +} + +func (testRequestBouncer) RestrictedAccess(h http.Handler) http.Handler { + return h +} + +func (testRequestBouncer) PublicAccess(h http.Handler) http.Handler { + return h +} + +func (testRequestBouncer) TeamLeaderAccess(h http.Handler) http.Handler { + return h +} + +func (testRequestBouncer) EdgeComputeOperation(h http.Handler) http.Handler { + return h +} + +func (testRequestBouncer) AuthorizedEndpointOperation(r *http.Request, endpoint *portainer.Endpoint) error { + return nil +} + +func (testRequestBouncer) AuthorizedEdgeEndpointOperation(r *http.Request, endpoint *portainer.Endpoint) error { + return nil +} + +func (testRequestBouncer) TrustedEdgeEnvironmentAccess(tx dataservices.DataStoreTx, endpoint *portainer.Endpoint) error { + return nil +} + +func (testRequestBouncer) CookieAuthLookup(r *http.Request) (*portainer.TokenData, error) { + return nil, nil +} + +func (testRequestBouncer) JWTAuthLookup(r *http.Request) (*portainer.TokenData, error) { + return nil, nil +} + +func (testRequestBouncer) RevokeJWT(jti string) {} + +func (testRequestBouncer) DisableCSP() {} + +// AddTestSecurityCookie adds a security cookie to the request +func AddTestSecurityCookie(r *http.Request, jwt string) { + r.AddCookie(&http.Cookie{ + Name: portainer.AuthCookieKey, + Value: jwt, + }) +} diff --git a/api/internal/testhelpers/stack_deployer.go b/api/internal/testhelpers/stack_deployer.go new file mode 100644 index 0000000..9d39223 --- /dev/null +++ b/api/internal/testhelpers/stack_deployer.go @@ -0,0 +1,69 @@ +package testhelpers + +import ( + "context" + + portainer "github.com/portainer/portainer/api" +) + +type TestStackDeployer struct { + DeployComposeCallCount int + DeploySwarmCallCount int + LastPrune bool +} + +func NewTestStackDeployer() *TestStackDeployer { + return &TestStackDeployer{} +} + +func (d *TestStackDeployer) DeployComposeStack(_ context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune, forcePullImage, forceRecreate bool) error { + d.DeployComposeCallCount++ + d.LastPrune = prune + return nil +} + +func (d *TestStackDeployer) UndeployComposeStack(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint) error { + return nil +} + +func (d *TestStackDeployer) DeploySwarmStack(_ context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune, pullImage bool) error { + d.DeploySwarmCallCount++ + d.LastPrune = prune + return nil +} + +func (d *TestStackDeployer) DeployKubernetesStack(_ context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint, user *portainer.User) error { + return nil +} + +func (d *TestStackDeployer) DeployRemoteComposeStack(_ context.Context, userId portainer.UserID, stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune, forcePullImage, forceRecreate bool) error { + return nil +} + +func (d *TestStackDeployer) UndeployRemoteComposeStack(_ context.Context, userId portainer.UserID, stack *portainer.Stack, endpoint *portainer.Endpoint) error { + return nil +} + +func (d *TestStackDeployer) StartRemoteComposeStack(_ context.Context, userId portainer.UserID, stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry) error { + return nil +} + +func (d *TestStackDeployer) StopRemoteComposeStack(_ context.Context, userId portainer.UserID, stack *portainer.Stack, endpoint *portainer.Endpoint) error { + return nil +} + +func (d *TestStackDeployer) DeployRemoteSwarmStack(_ context.Context, userId portainer.UserID, stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune, pullImage bool) error { + return nil +} + +func (d *TestStackDeployer) UndeployRemoteSwarmStack(_ context.Context, userId portainer.UserID, stack *portainer.Stack, endpoint *portainer.Endpoint) error { + return nil +} + +func (d *TestStackDeployer) StartRemoteSwarmStack(_ context.Context, userId portainer.UserID, stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry) error { + return nil +} + +func (d *TestStackDeployer) StopRemoteSwarmStack(_ context.Context, userId portainer.UserID, stack *portainer.Stack, endpoint *portainer.Endpoint) error { + return nil +} diff --git a/api/internal/testhelpers/user_activity_service.go b/api/internal/testhelpers/user_activity_service.go new file mode 100644 index 0000000..4e5828c --- /dev/null +++ b/api/internal/testhelpers/user_activity_service.go @@ -0,0 +1,12 @@ +package testhelpers + +type userActivityService struct { +} + +func NewUserActivityService() *userActivityService { + return &userActivityService{} +} + +func (service *userActivityService) LogUserActivity(username string, context string, action string, payload []byte) error { + return nil +} diff --git a/api/internal/upgrade/upgrade.go b/api/internal/upgrade/upgrade.go new file mode 100644 index 0000000..472875d --- /dev/null +++ b/api/internal/upgrade/upgrade.go @@ -0,0 +1,81 @@ +package upgrade + +import ( + "fmt" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + dockerclient "github.com/portainer/portainer/api/docker/client" + kubecli "github.com/portainer/portainer/api/kubernetes/cli" + plf "github.com/portainer/portainer/api/platform" + "github.com/portainer/portainer/api/stacks/deployments" + "github.com/rs/zerolog/log" +) + +const ( + // mustacheUpgradeDockerTemplateFile represents the name of the template file for the docker upgrade + mustacheUpgradeDockerTemplateFile = "upgrade-docker.yml.mustache" + + // portainerImagePrefixEnvVar represents the name of the environment variable used to define the image prefix for portainer-updater + // useful if there's a need to test PR images + portainerImagePrefixEnvVar = "UPGRADE_PORTAINER_IMAGE_PREFIX" + // skipPullImageEnvVar represents the name of the environment variable used to define if the image pull should be skipped + // useful if there's a need to test local images + skipPullImageEnvVar = "UPGRADE_SKIP_PULL_PORTAINER_IMAGE" + // updaterImageEnvVar represents the name of the environment variable used to define the updater image + // useful if there's a need to test a different updater + updaterImageEnvVar = "UPGRADE_UPDATER_IMAGE" +) + +type Service interface { + Upgrade(platform plf.ContainerPlatform, environment *portainer.Endpoint, licenseKey string) error +} + +type service struct { + kubernetesClientFactory *kubecli.ClientFactory + dockerClientFactory *dockerclient.ClientFactory + dockerComposeStackManager portainer.ComposeStackManager + fileService portainer.FileService + + isUpdating bool + + assetsPath string +} + +func NewService( + assetsPath string, + kubernetesClientFactory *kubecli.ClientFactory, + dockerClientFactory *dockerclient.ClientFactory, + dockerComposeStackManager portainer.ComposeStackManager, + dataStore dataservices.DataStore, + fileService portainer.FileService, + stackDeployer deployments.StackDeployer, +) (Service, error) { + + return &service{ + assetsPath: assetsPath, + kubernetesClientFactory: kubernetesClientFactory, + dockerClientFactory: dockerClientFactory, + dockerComposeStackManager: dockerComposeStackManager, + fileService: fileService, + }, nil +} + +func (service *service) Upgrade(platform plf.ContainerPlatform, environment *portainer.Endpoint, licenseKey string) error { + service.isUpdating = true + log.Debug(). + Str("platform", string(platform)). + Msg("Starting upgrade process") + + switch platform { + case plf.PlatformDockerStandalone: + return service.upgradeDocker(environment, licenseKey, portainer.APIVersion, "standalone") + case plf.PlatformDockerSwarm: + return service.upgradeDocker(environment, licenseKey, portainer.APIVersion, "swarm") + case plf.PlatformKubernetes: + return service.upgradeKubernetes(environment, licenseKey, portainer.APIVersion) + } + + service.isUpdating = false + return fmt.Errorf("unsupported platform %s", platform) +} diff --git a/api/internal/upgrade/upgrade_docker.go b/api/internal/upgrade/upgrade_docker.go new file mode 100644 index 0000000..7f5a245 --- /dev/null +++ b/api/internal/upgrade/upgrade_docker.go @@ -0,0 +1,127 @@ +package upgrade + +import ( + "context" + "fmt" + "os" + "strings" + "time" + + "github.com/docker/docker/api/types/filters" + "github.com/docker/docker/api/types/image" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/filesystem" + + "github.com/cbroglie/mustache" + "github.com/pkg/errors" + "github.com/rs/zerolog/log" +) + +func (service *service) upgradeDocker(environment *portainer.Endpoint, licenseKey, version string, envType string) error { + ctx := context.TODO() + + templateName := filesystem.JoinPaths(service.assetsPath, "mustache-templates", mustacheUpgradeDockerTemplateFile) + + portainerImagePrefix := os.Getenv(portainerImagePrefixEnvVar) + if portainerImagePrefix == "" { + portainerImagePrefix = "portainer/portainer-ee" + } + + image := fmt.Sprintf("%s:%s", portainerImagePrefix, version) + + skipPullImageEnv := os.Getenv(skipPullImageEnvVar) + skipPullImage := skipPullImageEnv != "" + + if err := service.checkImageForDocker(ctx, environment, image, skipPullImage); err != nil { + return err + } + + updaterImage := getUpdaterImage() + + composeFile, err := mustache.RenderFile(templateName, map[string]string{ + "image": image, + "skip_pull_image": skipPullImageEnv, + "updater_image": updaterImage, + "license": licenseKey, + "envType": envType, + }) + + log.Debug(). + Str("composeFile", composeFile). + Msg("Compose file for upgrade") + + if err != nil { + return errors.Wrap(err, "failed to render upgrade template") + } + + timeId := time.Now().Unix() + fileName := fmt.Sprintf("upgrade-%d.yml", timeId) + + filePath, err := service.fileService.StoreStackFileFromBytes("upgrade", fileName, []byte(composeFile)) + if err != nil { + return errors.Wrap(err, "failed to create upgrade compose file") + } + + projectName := fmt.Sprintf( + "portainer-upgrade-%d-%s", + timeId, + strings.ReplaceAll(version, ".", "-"), + ) + + tempStack := &portainer.Stack{ + Name: projectName, + ProjectPath: filePath, + EntryPoint: fileName, + } + + err = service.dockerComposeStackManager.Run(ctx, tempStack, environment, "updater", portainer.ComposeRunOptions{ + Remove: true, + Detached: true, + }) + + if err != nil { + return errors.Wrap(err, "failed to deploy upgrade stack") + } + + return nil +} + +func (service *service) checkImageForDocker(ctx context.Context, environment *portainer.Endpoint, imageName string, skipPullImage bool) error { + cli, err := service.dockerClientFactory.CreateClient(environment, "", nil) + if err != nil { + return errors.Wrap(err, "failed to create docker client") + } + + if skipPullImage { + filters := filters.NewArgs() + filters.Add("reference", imageName) + images, err := cli.ImageList(ctx, image.ListOptions{ + Filters: filters, + }) + if err != nil { + return errors.Wrap(err, "failed to list images") + } + + if len(images) == 0 { + return errors.Errorf("image %s not found locally", imageName) + } + + return nil + } else { + // check if available on registry + _, err := cli.DistributionInspect(ctx, imageName, "") + if err != nil { + return errors.Errorf("image %s not found on registry", imageName) + } + + return nil + } +} + +func getUpdaterImage() string { + updaterImage := os.Getenv(updaterImageEnvVar) + if updaterImage == "" { + updaterImage = "portainer/portainer-updater:" + portainer.APIVersion + } + return updaterImage +} diff --git a/api/internal/upgrade/upgrade_docker_test.go b/api/internal/upgrade/upgrade_docker_test.go new file mode 100644 index 0000000..b994f6a --- /dev/null +++ b/api/internal/upgrade/upgrade_docker_test.go @@ -0,0 +1,32 @@ +package upgrade + +import ( + "os" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/stretchr/testify/require" +) + +func TestGetUpdaterImage(t *testing.T) { + t.Run("updater image Environment Variable is set", func(t *testing.T) { + t.Setenv(updaterImageEnvVar, "portainer/portainer-updater:pr111") + + expect := "portainer/portainer-updater:pr111" + updaterImage := getUpdaterImage() + if updaterImage != expect { + t.Fatalf("expected %v, got %v", expect, updaterImage) + } + }) + + t.Run("updater image Environment Variable not set", func(t *testing.T) { + err := os.Unsetenv(updaterImageEnvVar) + require.NoError(t, err) + + expect := "portainer/portainer-updater:" + portainer.APIVersion + updaterImage := getUpdaterImage() + if updaterImage != expect { + t.Fatalf("expected %v, got %v", expect, updaterImage) + } + }) +} diff --git a/api/internal/upgrade/upgrade_kubernetes.go b/api/internal/upgrade/upgrade_kubernetes.go new file mode 100644 index 0000000..d4a49a4 --- /dev/null +++ b/api/internal/upgrade/upgrade_kubernetes.go @@ -0,0 +1,199 @@ +package upgrade + +import ( + "context" + "fmt" + "os" + "time" + + "github.com/pkg/errors" + portainer "github.com/portainer/portainer/api" + "github.com/rs/zerolog/log" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" +) + +func (service *service) upgradeKubernetes(environment *portainer.Endpoint, licenseKey, version string) error { + ctx := context.TODO() + + kubeCLI, _, err := service.kubernetesClientFactory.CreateClient(environment) + if err != nil { + return errors.WithMessage(err, "failed to get kubernetes client") + } + + namespace := "portainer" + taskName := fmt.Sprintf("portainer-upgrade-%d", time.Now().Unix()) + + jobsCli := kubeCLI.BatchV1().Jobs(namespace) + + updaterImage := os.Getenv(updaterImageEnvVar) + if updaterImage == "" { + updaterImage = "portainer/portainer-updater:latest" + } + + portainerImagePrefix := os.Getenv(portainerImagePrefixEnvVar) + if portainerImagePrefix == "" { + portainerImagePrefix = "portainer/portainer-ee" + } + + image := fmt.Sprintf("%s:%s", portainerImagePrefix, version) + + if err := service.checkImageForKubernetes(ctx, kubeCLI, namespace, image); err != nil { + return err + } + + job, err := jobsCli.Create(ctx, &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: taskName, + Namespace: namespace, + }, + + Spec: batchv1.JobSpec{ + TTLSecondsAfterFinished: new(int32(5 * 60)), // cleanup after 5 minutes + BackoffLimit: new(int32(0)), + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + + RestartPolicy: "Never", + ServiceAccountName: "portainer-sa-clusteradmin", + Containers: []corev1.Container{ + { + Name: taskName, + Image: updaterImage, + Args: []string{ + "--pretty-log", + "--log-level", "DEBUG", + "portainer", + "--env-type", "kubernetes", + "--image", image, + "--license", licenseKey, + }, + }, + }, + }, + }, + }, + }, metav1.CreateOptions{}) + + if err != nil { + return errors.WithMessage(err, "failed to create upgrade job") + } + + watcher, err := jobsCli.Watch(ctx, metav1.ListOptions{ + FieldSelector: "metadata.name=" + taskName, + TimeoutSeconds: new(int64(60)), + }) + if err != nil { + return errors.WithMessage(err, "failed to watch upgrade job") + } + + for event := range watcher.ResultChan() { + job, ok := event.Object.(*batchv1.Job) + if !ok { + continue + } + + for _, c := range job.Status.Conditions { + if c.Type == batchv1.JobComplete { + log.Debug(). + Str("job", job.Name). + Msg("Upgrade job completed") + return nil + } + + if c.Type == batchv1.JobFailed { + return fmt.Errorf("upgrade failed: %s", c.Message) + } + } + } + + log.Debug(). + Str("job", job.Name). + Msg("Upgrade job created") + + return errors.New("upgrade failed: server should have been restarted by the updater") + +} + +func (service *service) checkImageForKubernetes(ctx context.Context, kubeCLI *kubernetes.Clientset, namespace, image string) error { + podsCli := kubeCLI.CoreV1().Pods(namespace) + + log.Debug(). + Str("image", image). + Msg("Checking image") + + podName := fmt.Sprintf("portainer-image-check-%d", time.Now().Unix()) + _, err := podsCli.Create(ctx, &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: podName, + }, + Spec: corev1.PodSpec{ + RestartPolicy: "Never", + + Containers: []corev1.Container{ + { + Name: fmt.Sprint(podName, "-container"), + Image: image, + }, + }, + }, + }, metav1.CreateOptions{}) + + if err != nil { + log.Warn().Err(err).Msg("failed to create image check pod") + return errors.WithMessage(err, "failed to create image check pod") + } + + defer func() { + log.Debug(). + Str("pod", podName). + Msg("Deleting image check pod") + + if err := podsCli.Delete(ctx, podName, metav1.DeleteOptions{}); err != nil { + log.Warn().Err(err).Msg("failed to delete image check pod") + } + }() + + i := 0 + for { + time.Sleep(2 * time.Second) + + log.Debug(). + Str("image", image). + Int("try", i). + Msg("Checking image") + + i++ + + pod, err := podsCli.Get(ctx, podName, metav1.GetOptions{}) + if err != nil { + return errors.WithMessage(err, "failed to get image check pod") + } + + for _, containerStatus := range pod.Status.ContainerStatuses { + if containerStatus.Ready { + log.Debug(). + Str("image", image). + Str("pod", podName). + Msg("Image check container ready, assuming image is available") + + return nil + } + + if containerStatus.State.Waiting != nil { + if containerStatus.State.Waiting.Reason == "ErrImagePull" || containerStatus.State.Waiting.Reason == "ImagePullBackOff" { + log.Debug(). + Str("image", image). + Str("pod", podName). + Str("reason", containerStatus.State.Waiting.Reason). + Str("message", containerStatus.State.Waiting.Message). + Str("container", containerStatus.Name). + Msg("Image check container failed because of missing image") + return fmt.Errorf("image %s not found", image) + } + } + } + } +} diff --git a/api/jwt/jwt.go b/api/jwt/jwt.go new file mode 100644 index 0000000..f461f48 --- /dev/null +++ b/api/jwt/jwt.go @@ -0,0 +1,214 @@ +package jwt + +import ( + "errors" + "fmt" + "time" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/apikey" + "github.com/portainer/portainer/api/dataservices" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" + "github.com/rs/zerolog/log" +) + +const ( + year = time.Hour * 24 * 365 + + keyLen = 32 + + defaultScope = scope("default") + kubeConfigScope = scope("kubeconfig") +) + +// scope represents JWT scopes that are supported in JWT claims. +type scope string + +// Service represents a service for managing JWT tokens. +type Service struct { + secrets map[scope][]byte + userSessionTimeout time.Duration + dataStore dataservices.DataStore +} + +type claims struct { + UserID int `json:"id"` + Username string `json:"username"` + Role int `json:"role"` + Scope scope `json:"scope"` + ForceChangePassword bool `json:"forceChangePassword"` + jwt.RegisteredClaims +} + +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(userSessionDuration string, dataStore dataservices.DataStore) (*Service, error) { + userSessionTimeout, err := time.ParseDuration(userSessionDuration) + if err != nil { + return nil, err + } + + secret := apikey.GenerateRandomKey(keyLen) + if secret == nil { + return nil, errSecretGeneration + } + + kubeSecret, err := getOrCreateKubeSecret(dataStore) + if err != nil { + return nil, err + } + + return &Service{ + map[scope][]byte{ + defaultScope: secret, + kubeConfigScope: kubeSecret, + }, + userSessionTimeout, + dataStore, + }, nil +} + +func getOrCreateKubeSecret(dataStore dataservices.DataStore) ([]byte, error) { + settings, err := dataStore.Settings().Settings() + if err != nil { + return nil, err + } + + kubeSecret := settings.OAuthSettings.KubeSecretKey + if kubeSecret != nil { + return kubeSecret, nil + } + + kubeSecret = apikey.GenerateRandomKey(keyLen) + if kubeSecret == nil { + return nil, errSecretGeneration + } + + settings.OAuthSettings.KubeSecretKey = kubeSecret + + if err := dataStore.Settings().UpdateSettings(settings); err != nil { + return nil, err + } + + return kubeSecret, nil +} + +func (service *Service) defaultExpireAt() time.Time { + return time.Now().Add(service.userSessionTimeout) +} + +// GenerateToken generates a new JWT token. +func (service *Service) GenerateToken(data *portainer.TokenData) (string, time.Time, error) { + expiryTime := service.defaultExpireAt() + token, err := service.generateSignedToken(data, expiryTime, defaultScope) + + return token, expiryTime, err +} + +// ParseAndVerifyToken parses a JWT token and verify its validity. It returns an error if token is invalid. +func (service *Service) ParseAndVerifyToken(token string) (*portainer.TokenData, string, time.Time, error) { + scope := parseScope(token) + secret := service.secrets[scope] + + parsedToken, err := jwt.ParseWithClaims(token, &claims{}, func(token *jwt.Token) (any, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + + return secret, nil + }) + if err != nil || parsedToken == nil { + return nil, "", time.Time{}, errInvalidJWTToken + } + + cl, ok := parsedToken.Claims.(*claims) + if !ok || !parsedToken.Valid { + return nil, "", time.Time{}, errInvalidJWTToken + } + + user, err := service.dataStore.User().Read(portainer.UserID(cl.UserID)) + if err != nil || user.TokenIssueAt > cl.IssuedAt.Unix() { + return nil, "", time.Time{}, errInvalidJWTToken + } + + if cl.ExpiresAt == nil { + cl.ExpiresAt = &jwt.NumericDate{} + } + + return &portainer.TokenData{ + ID: portainer.UserID(cl.UserID), + Username: cl.Username, + Role: portainer.UserRole(cl.Role), + Token: token, + ForceChangePassword: cl.ForceChangePassword, + }, cl.ID, cl.ExpiresAt.Time, nil +} + +// Parse a JWT token, fallback to defaultScope if no scope is present in the JWT +func parseScope(token string) scope { + unverifiedToken, _, _ := new(jwt.Parser).ParseUnverified(token, &claims{}) + if unverifiedToken == nil { + return defaultScope + } + + if cl, ok := unverifiedToken.Claims.(*claims); ok && cl.Scope == kubeConfigScope { + return kubeConfigScope + } + + return defaultScope +} + +// SetUserSessionDuration sets the user session duration +func (service *Service) SetUserSessionDuration(userSessionDuration time.Duration) { + service.userSessionTimeout = userSessionDuration +} + +func (service *Service) generateSignedToken(data *portainer.TokenData, expiresAt time.Time, scope scope) (string, error) { + secret, found := service.secrets[scope] + if !found { + return "", fmt.Errorf("invalid scope: %v", scope) + } + + settings, err := service.dataStore.Settings().Settings() + if err != nil { + return "", fmt.Errorf("failed fetching settings from db: %w", err) + } + + if settings.IsDockerDesktopExtension { + log.Info().Msg("detected docker desktop extension mode") + expiresAt = time.Now().Add(99 * year) + } + + uuid, err := uuid.NewRandom() + if err != nil { + return "", fmt.Errorf("unable to generate the JWT ID: %w", err) + } + + cl := claims{ + UserID: int(data.ID), + Username: data.Username, + Role: int(data.Role), + Scope: scope, + ForceChangePassword: data.ForceChangePassword, + RegisteredClaims: jwt.RegisteredClaims{ + ID: uuid.String(), + IssuedAt: jwt.NewNumericDate(time.Now()), + ExpiresAt: jwt.NewNumericDate(expiresAt), + }, + } + + // If expiresAt is set to a zero value, the token should never expire + if expiresAt.IsZero() { + cl.ExpiresAt = nil + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, cl) + + return token.SignedString(secret) +} diff --git a/api/jwt/jwt_kubeconfig.go b/api/jwt/jwt_kubeconfig.go new file mode 100644 index 0000000..7913d12 --- /dev/null +++ b/api/jwt/jwt_kubeconfig.go @@ -0,0 +1,28 @@ +package jwt + +import ( + "time" + + portainer "github.com/portainer/portainer/api" +) + +// GenerateTokenForKubeconfig generates a new JWT token for Kubeconfig +func (service *Service) GenerateTokenForKubeconfig(data *portainer.TokenData) (string, error) { + settings, err := service.dataStore.Settings().Settings() + if err != nil { + return "", err + } + + expiryDuration, err := time.ParseDuration(settings.KubeconfigExpiry) + if err != nil { + return "", err + } + + // https://go.dev/play/p/bOrt6cQpA0I time.Time defaults to a zero value which is 0001-01-01 00:00:00 +0000 UTC + var expiryAt time.Time + if expiryDuration > time.Duration(0) { + expiryAt = time.Now().Add(expiryDuration) + } + + return service.generateSignedToken(data, expiryAt, kubeConfigScope) +} diff --git a/api/jwt/jwt_kubeconfig_test.go b/api/jwt/jwt_kubeconfig_test.go new file mode 100644 index 0000000..cf88843 --- /dev/null +++ b/api/jwt/jwt_kubeconfig_test.go @@ -0,0 +1,98 @@ +package jwt + +import ( + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/datastore" + + "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestService_GenerateTokenForKubeconfig(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, true, false) + + err := store.User().Create(&portainer.User{ID: 1}) + require.NoError(t, err) + + type fields struct { + userSessionTimeout string + dataStore dataservices.DataStore + } + + type args struct { + data *portainer.TokenData + } + + settings, err := store.Settings().Settings() + require.NoError(t, err) + + settings.KubeconfigExpiry = "0" + + err = store.Settings().UpdateSettings(settings) + require.NoError(t, err) + + myFields := fields{ + userSessionTimeout: "24h", + dataStore: store, + } + + myTokenData := &portainer.TokenData{ + Username: "Joe", + ID: 1, + Role: 1, + } + + myArgs := args{ + data: myTokenData, + } + + tests := []struct { + name string + fields fields + args args + wantExpiresAt *jwt.NumericDate + wantErr bool + }{ + { + name: "kubeconfig no expiry", + fields: myFields, + args: myArgs, + wantExpiresAt: nil, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + service, err := NewService(tt.fields.userSessionTimeout, tt.fields.dataStore) + require.NoError(t, err, "failed to create a copy of service") + + got, err := service.GenerateTokenForKubeconfig(tt.args.data) + if (err != nil) != tt.wantErr { + t.Errorf("GenerateTokenForKubeconfig() error = %v, wantErr %v", err, tt.wantErr) + return + } + + _, _, _, err = service.ParseAndVerifyToken(got) + require.NoError(t, err) + + parsedToken, err := jwt.ParseWithClaims(got, &claims{}, func(token *jwt.Token) (any, error) { + return service.secrets[kubeConfigScope], nil + }) + require.NoError(t, err, "failed to parse generated token") + + tokenClaims, ok := parsedToken.Claims.(*claims) + assert.True(t, ok, "failed to claims out of generated ticket") + + assert.Equal(t, myTokenData.Username, tokenClaims.Username) + assert.Equal(t, int(myTokenData.ID), tokenClaims.UserID) + assert.Equal(t, int(myTokenData.Role), tokenClaims.Role) + assert.Equal(t, tt.wantExpiresAt, tokenClaims.ExpiresAt) + }) + } +} diff --git a/api/jwt/jwt_test.go b/api/jwt/jwt_test.go new file mode 100644 index 0000000..52c7d58 --- /dev/null +++ b/api/jwt/jwt_test.go @@ -0,0 +1,122 @@ +package jwt + +import ( + "testing" + "testing/synctest" + "time" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/internal/testhelpers" + + "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGenerateSignedToken(t *testing.T) { + t.Parallel() + dataStore := testhelpers.NewDatastore(testhelpers.WithSettingsService(&portainer.Settings{})) + svc, err := NewService("24h", dataStore) + require.NoError(t, err, "failed to create a copy of service") + + token := &portainer.TokenData{ + Username: "Joe", + ID: 1, + Role: 1, + } + expiresAt := time.Now().Add(1 * time.Hour) + + generatedToken, err := svc.generateSignedToken(token, expiresAt, defaultScope) + require.NoError(t, err, "failed to generate a signed token") + + parsedToken, err := jwt.ParseWithClaims(generatedToken, &claims{}, func(token *jwt.Token) (any, error) { + return svc.secrets[defaultScope], nil + }) + require.NoError(t, err, "failed to parse generated token") + + tokenClaims, ok := parsedToken.Claims.(*claims) + assert.True(t, ok, "failed to claims out of generated ticket") + + assert.Equal(t, token.Username, tokenClaims.Username) + assert.Equal(t, int(token.ID), tokenClaims.UserID) + assert.Equal(t, int(token.Role), tokenClaims.Role) + assert.Equal(t, jwt.NewNumericDate(expiresAt), tokenClaims.ExpiresAt) +} + +func TestGenerateSignedToken_InvalidScope(t *testing.T) { + t.Parallel() + dataStore := testhelpers.NewDatastore(testhelpers.WithSettingsService(&portainer.Settings{})) + svc, err := NewService("24h", dataStore) + require.NoError(t, err, "failed to create a copy of service") + + token := &portainer.TokenData{ + Username: "Joe", + ID: 1, + Role: 1, + } + expiresAt := time.Now().Add(1 * time.Hour) + + _, err = svc.generateSignedToken(token, expiresAt, "testing") + require.Error(t, err) + assert.Equal(t, "invalid scope: testing", err.Error()) +} + +func TestGenerationAndParsing(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, true, false) + + err := store.User().Create(&portainer.User{ID: 1}) + require.NoError(t, err) + + service, err := NewService("1h", store) + require.NoError(t, err) + + expectedToken := &portainer.TokenData{ + Username: "User", + ID: 1, + Role: 1, + } + + tokenString, _, err := service.GenerateToken(expectedToken) + require.NoError(t, err) + + expectedToken.Token = tokenString + + token, _, _, err := service.ParseAndVerifyToken(tokenString) + require.NoError(t, err) + require.Equal(t, expectedToken, token) +} + +func TestExpiration(t *testing.T) { + t.Parallel() + synctest.Test(t, testExpiration) +} + +func testExpiration(t *testing.T) { + _, store := datastore.MustNewTestStore(t, true, false) + + err := store.User().Create(&portainer.User{ID: 1}) + require.NoError(t, err) + + service, err := NewService("1h", store) + require.NoError(t, err) + + expectedToken := &portainer.TokenData{ + Username: "User", + ID: 1, + Role: 1, + } + + service.SetUserSessionDuration(time.Second) + + tokenString, _, err := service.GenerateToken(expectedToken) + require.NoError(t, err) + + expectedToken.Token = tokenString + + time.Sleep(2 * time.Second) + + _, _, _, err = service.ParseAndVerifyToken(tokenString) + require.Error(t, err) +} diff --git a/api/kubernetes.go b/api/kubernetes.go new file mode 100644 index 0000000..b6bf967 --- /dev/null +++ b/api/kubernetes.go @@ -0,0 +1,14 @@ +package portainer + +func KubernetesDefault() KubernetesData { + return KubernetesData{ + Configuration: KubernetesConfiguration{ + UseLoadBalancer: false, + UseServerMetrics: false, + EnableResourceOverCommit: true, + 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 0000000..30ef194 --- /dev/null +++ b/api/kubernetes/cli/access.go @@ -0,0 +1,177 @@ +package cli + +import ( + "context" + "fmt" + + portainer "github.com/portainer/portainer/api" + + "github.com/pkg/errors" + "github.com/segmentio/encoding/json" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// NamespaceAccessPoliciesDeleteNamespace removes stored policies associated with a given namespace +func (kcl *KubeClient) NamespaceAccessPoliciesDeleteNamespace(ns string) error { + kcl.mu.Lock() + defer kcl.mu.Unlock() + + policies, err := kcl.GetNamespaceAccessPolicies() + if err != nil { + return errors.WithMessage(err, "failed to fetch access policies") + } + + if policies != nil { + delete(policies, ns) + return kcl.UpdateNamespaceAccessPolicies(policies) + } + + return nil +} + +// GetNamespaceAccessPolicies gets the namespace access policies +// from config maps in the portainer namespace +func (kcl *KubeClient) GetNamespaceAccessPolicies() (map[string]portainer.K8sNamespaceAccessPolicy, error) { + configMap, err := kcl.cli.CoreV1().ConfigMaps(portainerNamespace).Get(context.TODO(), portainerConfigMapName, metav1.GetOptions{}) + if err != nil { + if k8serrors.IsNotFound(err) { + return nil, nil + } + return nil, err + } + + accessData := configMap.Data[portainerConfigMapAccessPoliciesKey] + policies := map[string]portainer.K8sNamespaceAccessPolicy{} + err = json.Unmarshal([]byte(accessData), &policies) + if err != nil { + return nil, err + } + + return policies, nil +} + +func (kcl *KubeClient) setupNamespaceAccesses(userID int, teamIDs []int, serviceAccountName string, restrictDefaultNamespace bool) error { + accessPolicies, err := kcl.GetNamespaceAccessPolicies() + if err != nil { + return err + } + + namespaces, err := kcl.cli.CoreV1().Namespaces().List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return err + } + + for _, namespace := range namespaces.Items { + if namespace.Name == defaultNamespace && !restrictDefaultNamespace { + err = kcl.ensureNamespaceAccessForServiceAccount(serviceAccountName, defaultNamespace) + if err != nil { + return err + } + continue + } + + policies, ok := accessPolicies[namespace.Name] + if !ok || !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 portainer.K8sNamespaceAccessPolicy) 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 +} + +// UpdateNamespaceAccessPolicies updates the namespace access policies +func (kcl *KubeClient) UpdateNamespaceAccessPolicies(accessPolicies map[string]portainer.K8sNamespaceAccessPolicy) error { + data, err := json.Marshal(accessPolicies) + if err != nil { + return err + } + + configMap, err := kcl.cli.CoreV1().ConfigMaps(portainerNamespace).Get(context.TODO(), portainerConfigMapName, metav1.GetOptions{}) + if err != nil { + return err + } + + configMap.Data[portainerConfigMapAccessPoliciesKey] = string(data) + _, err = kcl.cli.CoreV1().ConfigMaps(portainerNamespace).Update(context.TODO(), configMap, metav1.UpdateOptions{}) + + return err +} + +// GetNonAdminNamespaces retrieves namespaces for a non-admin user, excluding the default namespace if restricted. +func (kcl *KubeClient) GetNonAdminNamespaces(userID int, teamIDs []int, isRestrictDefaultNamespace bool) ([]string, error) { + accessPolicies, err := kcl.GetNamespaceAccessPolicies() + if err != nil { + return nil, fmt.Errorf("an error occurred during the getNonAdminNamespaces operation, unable to get namespace access policies via portainer-config. check if portainer-config configMap exists in the Kubernetes cluster: %w", err) + } + + nonAdminNamespaces := []string{} + if !isRestrictDefaultNamespace { + nonAdminNamespaces = append(nonAdminNamespaces, defaultNamespace) + } + + for namespace, accessPolicy := range accessPolicies { + if hasUserAccessToNamespace(userID, teamIDs, accessPolicy) { + nonAdminNamespaces = append(nonAdminNamespaces, namespace) + } + } + + return nonAdminNamespaces, nil +} + +// GetIsKubeAdmin retrieves true if client is admin +func (kcl *KubeClient) GetIsKubeAdmin() bool { + kcl.mu.Lock() + defer kcl.mu.Unlock() + + return kcl.isKubeAdmin +} + +// UpdateIsKubeAdmin sets whether the kube client is admin +func (kcl *KubeClient) SetIsKubeAdmin(isKubeAdmin bool) { + kcl.mu.Lock() + defer kcl.mu.Unlock() + + kcl.isKubeAdmin = isKubeAdmin +} + +// GetClientNonAdminNamespaces retrieves non-admin namespaces +func (kcl *KubeClient) GetClientNonAdminNamespaces() []string { + kcl.mu.Lock() + defer kcl.mu.Unlock() + + return kcl.nonAdminNamespaces +} + +// UpdateClientNonAdminNamespaces sets the client non admin namespace list +func (kcl *KubeClient) SetClientNonAdminNamespaces(nonAdminNamespaces []string) { + kcl.mu.Lock() + defer kcl.mu.Unlock() + + kcl.nonAdminNamespaces = nonAdminNamespaces +} diff --git a/api/kubernetes/cli/access_test.go b/api/kubernetes/cli/access_test.go new file mode 100644 index 0000000..c6f1ddc --- /dev/null +++ b/api/kubernetes/cli/access_test.go @@ -0,0 +1,96 @@ +package cli + +import ( + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + ktypes "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + kfake "k8s.io/client-go/kubernetes/fake" +) + +func Test_NamespaceAccessPoliciesDeleteNamespace_updatesPortainerConfig_whenConfigExists(t *testing.T) { + t.Parallel() + testcases := []struct { + name string + namespaceToDelete string + expectedConfig map[string]portainer.K8sNamespaceAccessPolicy + }{ + { + name: "doesn't change config, when designated namespace absent", + namespaceToDelete: "missing-namespace", + expectedConfig: map[string]portainer.K8sNamespaceAccessPolicy{ + "ns1": {UserAccessPolicies: portainer.UserAccessPolicies{2: {RoleID: 0}}}, + "ns2": {UserAccessPolicies: portainer.UserAccessPolicies{2: {RoleID: 0}}}, + }, + }, + { + name: "removes designated namespace from config, when namespace is present", + namespaceToDelete: "ns2", + expectedConfig: map[string]portainer.K8sNamespaceAccessPolicy{ + "ns1": {UserAccessPolicies: portainer.UserAccessPolicies{2: {RoleID: 0}}}, + }, + }, + } + + for _, test := range testcases { + t.Run(test.name, func(t *testing.T) { + k := &KubeClient{ + cli: kfake.NewSimpleClientset(), + instanceID: "instance", + } + + config := &ktypes.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: portainerConfigMapName, + Namespace: portainerNamespace, + }, + Data: map[string]string{ + "NamespaceAccessPolicies": `{"ns1":{"UserAccessPolicies":{"2":{"RoleId":0}}}, "ns2":{"UserAccessPolicies":{"2":{"RoleId":0}}}}`, + }, + } + + _, err := k.cli.CoreV1().ConfigMaps(portainerNamespace).Create(t.Context(), config, metav1.CreateOptions{}) + require.NoError(t, err, "failed to create a portainer config") + defer func() { + err := k.cli.CoreV1().ConfigMaps(portainerNamespace).Delete(t.Context(), portainerConfigMapName, metav1.DeleteOptions{}) + require.NoError(t, err) + }() + + err = k.NamespaceAccessPoliciesDeleteNamespace(test.namespaceToDelete) + require.NoError(t, err, "failed to delete namespace") + + policies, err := k.GetNamespaceAccessPolicies() + require.NoError(t, err, "failed to fetch policies") + assert.Equal(t, test.expectedConfig, policies) + }) + } +} + +func TestKubeAdmin(t *testing.T) { + t.Parallel() + kcl := &KubeClient{} + require.False(t, kcl.GetIsKubeAdmin()) + + kcl.SetIsKubeAdmin(true) + require.True(t, kcl.GetIsKubeAdmin()) + + kcl.SetIsKubeAdmin(false) + require.False(t, kcl.GetIsKubeAdmin()) +} + +func TestClientNonAdminNamespaces(t *testing.T) { + t.Parallel() + kcl := &KubeClient{} + + require.Empty(t, kcl.GetClientNonAdminNamespaces()) + + nss := []string{"ns1", "ns2"} + kcl.SetClientNonAdminNamespaces(nss) + require.Equal(t, nss, kcl.GetClientNonAdminNamespaces()) + + kcl.SetClientNonAdminNamespaces([]string{}) + require.Empty(t, kcl.GetClientNonAdminNamespaces()) +} diff --git a/api/kubernetes/cli/applications.go b/api/kubernetes/cli/applications.go new file mode 100644 index 0000000..3a15925 --- /dev/null +++ b/api/kubernetes/cli/applications.go @@ -0,0 +1,585 @@ +package cli + +import ( + "context" + + models "github.com/portainer/portainer/api/http/models/kubernetes" + "github.com/rs/zerolog/log" + appsv1 "k8s.io/api/apps/v1" + autoscalingv2 "k8s.io/api/autoscaling/v2" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + labels "k8s.io/apimachinery/pkg/labels" +) + +// PortainerApplicationResources contains collections of various Kubernetes resources +// associated with a Portainer application. +type PortainerApplicationResources struct { + Pods []corev1.Pod + ReplicaSets []appsv1.ReplicaSet + Deployments []appsv1.Deployment + StatefulSets []appsv1.StatefulSet + DaemonSets []appsv1.DaemonSet + Services []corev1.Service + HorizontalPodAutoscalers []autoscalingv2.HorizontalPodAutoscaler +} + +// GetAllKubernetesApplications gets a list of kubernetes workloads (or applications) across all namespaces in the cluster +// if the user is an admin, all namespaces in the current k8s environment(endpoint) are fetched using the fetchApplications function. +// otherwise, namespaces the non-admin user has access to will be used to filter the applications based on the allowed namespaces. +func (kcl *KubeClient) GetApplications(namespace, nodeName string) ([]models.K8sApplication, error) { + if kcl.GetIsKubeAdmin() { + return kcl.fetchApplications(namespace, nodeName) + } + + return kcl.fetchApplicationsForNonAdmin(namespace, nodeName) +} + +// fetchApplications fetches the applications in the namespaces the user has access to. +// This function is called when the user is an admin. +func (kcl *KubeClient) fetchApplications(namespace, nodeName string) ([]models.K8sApplication, error) { + podListOptions := metav1.ListOptions{} + if nodeName != "" { + podListOptions.FieldSelector = "spec.nodeName=" + nodeName + } + + portainerApplicationResources, err := kcl.fetchAllApplicationsListResources(namespace, podListOptions) + if err != nil { + return nil, err + } + + applications, err := kcl.convertPodsToApplications(portainerApplicationResources) + if err != nil { + return nil, err + } + + unhealthyApplications, err := fetchUnhealthyApplications(portainerApplicationResources) + if err != nil { + return nil, err + } + + return append(applications, unhealthyApplications...), nil +} + +// fetchApplicationsForNonAdmin fetches the applications in the namespaces the user has access to. +// This function is called when the user is not an admin. +func (kcl *KubeClient) fetchApplicationsForNonAdmin(namespace, nodeName string) ([]models.K8sApplication, error) { + nonAdminNamespaces := kcl.GetClientNonAdminNamespaces() + + log.Debug(). + Strs("non_admin_namespaces", nonAdminNamespaces). + Msg("fetching applications for non-admin user") + + if len(nonAdminNamespaces) == 0 { + return nil, nil + } + + podListOptions := metav1.ListOptions{} + if nodeName != "" { + podListOptions.FieldSelector = "spec.nodeName=" + nodeName + } + + portainerApplicationResources, err := kcl.fetchAllApplicationsListResources(namespace, podListOptions) + if err != nil { + return nil, err + } + + applications, err := kcl.convertPodsToApplications(portainerApplicationResources) + if err != nil { + return nil, err + } + + unhealthyApplications, err := fetchUnhealthyApplications(portainerApplicationResources) + if err != nil { + return nil, err + } + + nonAdminNamespaceSet := kcl.buildNonAdminNamespacesMap() + results := make([]models.K8sApplication, 0) + for _, application := range append(applications, unhealthyApplications...) { + if _, ok := nonAdminNamespaceSet[application.ResourcePool]; ok { + results = append(results, application) + } + } + + return results, nil +} + +// convertPodsToApplications processes pods and converts them to applications, ensuring uniqueness by owner reference. +func (kcl *KubeClient) convertPodsToApplications(portainerApplicationResources PortainerApplicationResources) ([]models.K8sApplication, error) { + applications := []models.K8sApplication{} + processedOwners := make(map[string]struct{}) + + for _, pod := range portainerApplicationResources.Pods { + if len(pod.OwnerReferences) > 0 { + ownerUID := string(pod.OwnerReferences[0].UID) + if _, exists := processedOwners[ownerUID]; exists { + continue + } + processedOwners[ownerUID] = struct{}{} + } + + application, err := kcl.ConvertPodToApplication(pod, portainerApplicationResources, true) + if err != nil { + return nil, err + } + + if application != nil { + applications = append(applications, *application) + } + } + + return applications, nil +} + +// GetClusterApplicationsResource returns the total resource requests and limits for all applications in a namespace +// for a cluster level resource, set the namespace to "" +func (kcl *KubeClient) GetApplicationsResource(namespace, node string) (models.K8sApplicationResource, error) { + resource := models.K8sApplicationResource{} + podListOptions := metav1.ListOptions{} + if node != "" { + podListOptions.FieldSelector = "spec.nodeName=" + node + } + + pods, err := kcl.cli.CoreV1().Pods(namespace).List(context.Background(), podListOptions) + if err != nil { + return resource, err + } + + for _, pod := range pods.Items { + podResources := calculatePodResourceUsage(pod) + resource.CPURequest += podResources.CPURequest + resource.CPULimit += podResources.CPULimit + resource.MemoryRequest += podResources.MemoryRequest + resource.MemoryLimit += podResources.MemoryLimit + } + + return resource, nil +} + +// ConvertPodToApplication converts a pod to an application, updating owner references if necessary +func (kcl *KubeClient) ConvertPodToApplication(pod corev1.Pod, portainerApplicationResources PortainerApplicationResources, withResource bool) (*models.K8sApplication, error) { + if isReplicaSetOwner(pod) { + updateOwnerReferenceToDeployment(&pod, portainerApplicationResources.ReplicaSets) + } + + application := createApplicationFromPod(&pod, portainerApplicationResources) + if application.ID == "" && application.Name == "" { + return nil, nil + } + + if withResource { + podResources := calculatePodResourceUsage(pod) + // multiply by the number of requested pods in the application (not the running count) + application.Resource.CPURequest = podResources.CPURequest * float64(application.TotalPodsCount) + application.Resource.CPULimit = podResources.CPULimit * float64(application.TotalPodsCount) + application.Resource.MemoryRequest = podResources.MemoryRequest * int64(application.TotalPodsCount) + application.Resource.MemoryLimit = podResources.MemoryLimit * int64(application.TotalPodsCount) + } + + return &application, nil +} + +// createApplicationFromPod creates a K8sApplication object from a pod +// it sets the application name, namespace, kind, image, stack id, stack name, and labels +func createApplicationFromPod(pod *corev1.Pod, portainerApplicationResources PortainerApplicationResources) models.K8sApplication { + kind := "Pod" + name := pod.Name + + if len(pod.OwnerReferences) > 0 { + kind = pod.OwnerReferences[0].Kind + name = pod.OwnerReferences[0].Name + } + + application := models.K8sApplication{ + Services: []corev1.Service{}, + Metadata: &models.Metadata{}, + } + + switch kind { + case "Deployment": + for _, deployment := range portainerApplicationResources.Deployments { + if deployment.Name == name && deployment.Namespace == pod.Namespace { + populateApplicationFromDeployment(&application, deployment) + break + } + } + case "StatefulSet": + for _, statefulSet := range portainerApplicationResources.StatefulSets { + if statefulSet.Name == name && statefulSet.Namespace == pod.Namespace { + populateApplicationFromStatefulSet(&application, statefulSet) + break + } + } + case "DaemonSet": + for _, daemonSet := range portainerApplicationResources.DaemonSets { + if daemonSet.Name == name && daemonSet.Namespace == pod.Namespace { + populateApplicationFromDaemonSet(&application, daemonSet) + break + } + } + case "Pod": + populateApplicationFromPod(&application, *pod) + } + + if application.ID != "" && application.Name != "" && len(portainerApplicationResources.Services) > 0 { + updateApplicationWithService(&application, portainerApplicationResources.Services) + } + + if application.ID != "" && application.Name != "" && len(portainerApplicationResources.HorizontalPodAutoscalers) > 0 { + updateApplicationWithHorizontalPodAutoscaler(&application, portainerApplicationResources.HorizontalPodAutoscalers) + } + + return application +} + +// createApplicationFromDeployment creates a K8sApplication from a Deployment +func createApplicationFromDeployment(deployment appsv1.Deployment) models.K8sApplication { + var app models.K8sApplication + populateApplicationFromDeployment(&app, deployment) + return app +} + +// createApplicationFromStatefulSet creates a K8sApplication from a StatefulSet +func createApplicationFromStatefulSet(statefulSet appsv1.StatefulSet) models.K8sApplication { + var app models.K8sApplication + populateApplicationFromStatefulSet(&app, statefulSet) + return app +} + +// createApplicationFromDaemonSet creates a K8sApplication from a DaemonSet +func createApplicationFromDaemonSet(daemonSet appsv1.DaemonSet) models.K8sApplication { + var app models.K8sApplication + populateApplicationFromDaemonSet(&app, daemonSet) + return app +} + +func populateApplicationFromDeployment(application *models.K8sApplication, deployment appsv1.Deployment) { + application.ApplicationType = "Deployment" + application.Kind = "Deployment" + application.ID = string(deployment.UID) + application.ResourcePool = deployment.Namespace + application.Name = deployment.Name + application.ApplicationOwner = deployment.Labels["io.portainer.kubernetes.application.owner"] + application.StackID = deployment.Labels["io.portainer.kubernetes.application.stackid"] + application.StackName = deployment.Labels["io.portainer.kubernetes.application.stack"] + application.StackKind = deployment.Labels["io.portainer.kubernetes.application.stackKind"] + application.Labels = deployment.Labels + application.MatchLabels = deployment.Spec.Selector.MatchLabels + application.CreationDate = deployment.CreationTimestamp.Time + application.TotalPodsCount = 0 + if deployment.Spec.Replicas != nil { + application.TotalPodsCount = int(*deployment.Spec.Replicas) + } + application.RunningPodsCount = int(deployment.Status.ReadyReplicas) + application.DeploymentType = "Replicated" + application.Metadata = &models.Metadata{ + Labels: deployment.Labels, + Annotations: deployment.Annotations, + } + + // If the deployment has containers, use the first container's image + if len(deployment.Spec.Template.Spec.Containers) > 0 { + application.Image = deployment.Spec.Template.Spec.Containers[0].Image + } +} + +func populateApplicationFromStatefulSet(application *models.K8sApplication, statefulSet appsv1.StatefulSet) { + application.Kind = "StatefulSet" + application.ApplicationType = "StatefulSet" + application.ID = string(statefulSet.UID) + application.ResourcePool = statefulSet.Namespace + application.Name = statefulSet.Name + application.ApplicationOwner = statefulSet.Labels["io.portainer.kubernetes.application.owner"] + application.StackID = statefulSet.Labels["io.portainer.kubernetes.application.stackid"] + application.StackName = statefulSet.Labels["io.portainer.kubernetes.application.stack"] + application.StackKind = statefulSet.Labels["io.portainer.kubernetes.application.stackKind"] + application.Labels = statefulSet.Labels + application.MatchLabels = statefulSet.Spec.Selector.MatchLabels + application.CreationDate = statefulSet.CreationTimestamp.Time + application.TotalPodsCount = 0 + if statefulSet.Spec.Replicas != nil { + application.TotalPodsCount = int(*statefulSet.Spec.Replicas) + } + application.RunningPodsCount = int(statefulSet.Status.ReadyReplicas) + application.DeploymentType = "Replicated" + application.Metadata = &models.Metadata{ + Labels: statefulSet.Labels, + Annotations: statefulSet.Annotations, + } + + // If the statefulSet has containers, use the first container's image + if len(statefulSet.Spec.Template.Spec.Containers) > 0 { + application.Image = statefulSet.Spec.Template.Spec.Containers[0].Image + } +} + +func populateApplicationFromDaemonSet(application *models.K8sApplication, daemonSet appsv1.DaemonSet) { + application.Kind = "DaemonSet" + application.ApplicationType = "DaemonSet" + application.ID = string(daemonSet.UID) + application.ResourcePool = daemonSet.Namespace + application.Name = daemonSet.Name + application.ApplicationOwner = daemonSet.Labels["io.portainer.kubernetes.application.owner"] + application.StackID = daemonSet.Labels["io.portainer.kubernetes.application.stackid"] + application.StackName = daemonSet.Labels["io.portainer.kubernetes.application.stack"] + application.StackKind = daemonSet.Labels["io.portainer.kubernetes.application.stackKind"] + application.Labels = daemonSet.Labels + application.MatchLabels = daemonSet.Spec.Selector.MatchLabels + application.CreationDate = daemonSet.CreationTimestamp.Time + application.TotalPodsCount = int(daemonSet.Status.DesiredNumberScheduled) + application.RunningPodsCount = int(daemonSet.Status.NumberReady) + application.DeploymentType = "Global" + application.Metadata = &models.Metadata{ + Labels: daemonSet.Labels, + Annotations: daemonSet.Annotations, + } + + if len(daemonSet.Spec.Template.Spec.Containers) > 0 { + application.Image = daemonSet.Spec.Template.Spec.Containers[0].Image + } +} + +func populateApplicationFromPod(application *models.K8sApplication, pod corev1.Pod) { + runningPodsCount := 1 + if pod.Status.Phase != corev1.PodRunning { + runningPodsCount = 0 + } + + application.ApplicationType = "Pod" + application.Kind = "Pod" + application.ID = string(pod.UID) + application.ResourcePool = pod.Namespace + application.Name = pod.Name + application.ApplicationOwner = pod.Labels["io.portainer.kubernetes.application.owner"] + application.StackID = pod.Labels["io.portainer.kubernetes.application.stackid"] + application.StackName = pod.Labels["io.portainer.kubernetes.application.stack"] + application.StackKind = pod.Labels["io.portainer.kubernetes.application.stackKind"] + application.Labels = pod.Labels + application.MatchLabels = pod.Labels + application.CreationDate = pod.CreationTimestamp.Time + application.TotalPodsCount = 1 + application.RunningPodsCount = runningPodsCount + application.DeploymentType = string(pod.Status.Phase) + application.Metadata = &models.Metadata{ + Labels: pod.Labels, + Annotations: pod.Annotations, + } + + // If the pod has containers, use the first container's image + if len(pod.Spec.Containers) > 0 { + application.Image = pod.Spec.Containers[0].Image + } +} + +// updateApplicationWithService updates the application with the services that match the application's selector match labels +// and are in the same namespace as the application +func updateApplicationWithService(application *models.K8sApplication, services []corev1.Service) { + for _, service := range services { + serviceSelector := labels.SelectorFromSet(service.Spec.Selector) + + if service.Namespace == application.ResourcePool && !serviceSelector.Empty() && serviceSelector.Matches(labels.Set(application.MatchLabels)) { + application.ServiceType = string(service.Spec.Type) + application.Services = append(application.Services, service) + } + } +} + +func updateApplicationWithHorizontalPodAutoscaler(application *models.K8sApplication, hpas []autoscalingv2.HorizontalPodAutoscaler) { + for _, hpa := range hpas { + // Check if HPA is in the same namespace as the application + if hpa.Namespace != application.ResourcePool { + continue + } + + // Check if the scale target ref matches the application + scaleTargetRef := hpa.Spec.ScaleTargetRef + if scaleTargetRef.Name == application.Name && scaleTargetRef.Kind == application.Kind { + hpaCopy := hpa // Create a local copy + application.HorizontalPodAutoscaler = &hpaCopy + break + } + } +} + +// calculatePodResourceUsage calculates the resource usage for a pod in CPU cores and Bytes +func calculatePodResourceUsage(pod corev1.Pod) models.K8sApplicationResource { + resource := models.K8sApplicationResource{} + for _, container := range pod.Spec.Containers { + // CPU cores as a decimal + resource.CPURequest += float64(container.Resources.Requests.Cpu().MilliValue()) / 1000 + resource.CPULimit += float64(container.Resources.Limits.Cpu().MilliValue()) / 1000 + // Bytes + resource.MemoryRequest += container.Resources.Requests.Memory().Value() + resource.MemoryLimit += container.Resources.Limits.Memory().Value() + } + return resource +} + +// GetApplicationFromServiceSelector gets applications based on service selectors +// it matches the service selector with the pod labels +func (kcl *KubeClient) GetApplicationFromServiceSelector(pods []corev1.Pod, service models.K8sServiceInfo, replicaSets []appsv1.ReplicaSet) (*models.K8sApplication, error) { + servicesSelector := labels.SelectorFromSet(service.Selector) + if servicesSelector.Empty() { + return nil, nil + } + + for _, pod := range pods { + if servicesSelector.Matches(labels.Set(pod.Labels)) { + if isReplicaSetOwner(pod) { + updateOwnerReferenceToDeployment(&pod, replicaSets) + } + + if len(pod.OwnerReferences) == 0 { + return &models.K8sApplication{ + Name: pod.Name, + Kind: "Pod", + }, nil + } + + return &models.K8sApplication{ + Name: pod.OwnerReferences[0].Name, + Kind: pod.OwnerReferences[0].Kind, + }, nil + } + } + + return nil, nil +} + +// GetApplicationConfigurationOwnersFromConfigMap gets a list of applications that use a specific ConfigMap +// by checking all pods in the same namespace as the ConfigMap +func (kcl *KubeClient) GetApplicationConfigurationOwnersFromConfigMap(configMap models.K8sConfigMap, pods []corev1.Pod, replicaSets []appsv1.ReplicaSet) ([]models.K8sConfigurationOwnerResource, error) { + configurationOwners := []models.K8sConfigurationOwnerResource{} + for _, pod := range pods { + if isPodUsingConfigMap(&pod, configMap) { + kind := "Pod" + name := pod.Name + + if len(pod.OwnerReferences) > 0 { + kind = pod.OwnerReferences[0].Kind + name = pod.OwnerReferences[0].Name + } + + if isReplicaSetOwner(pod) { + updateOwnerReferenceToDeployment(&pod, replicaSets) + } + + configurationOwners = append(configurationOwners, models.K8sConfigurationOwnerResource{ + Name: name, + ResourceKind: kind, + }) + } + } + + return configurationOwners, nil +} + +// GetApplicationConfigurationOwnersFromSecret gets a list of applications that use a specific Secret +// by checking all pods in the same namespace as the Secret +func (kcl *KubeClient) GetApplicationConfigurationOwnersFromSecret(secret models.K8sSecret, pods []corev1.Pod, replicaSets []appsv1.ReplicaSet) ([]models.K8sConfigurationOwnerResource, error) { + configurationOwners := []models.K8sConfigurationOwnerResource{} + for _, pod := range pods { + if isPodUsingSecret(&pod, secret) { + kind := "Pod" + name := pod.Name + + if len(pod.OwnerReferences) > 0 { + kind = pod.OwnerReferences[0].Kind + name = pod.OwnerReferences[0].Name + } + + if isReplicaSetOwner(pod) { + updateOwnerReferenceToDeployment(&pod, replicaSets) + } + + configurationOwners = append(configurationOwners, models.K8sConfigurationOwnerResource{ + Name: name, + ResourceKind: kind, + }) + } + } + + return configurationOwners, nil +} + +// fetchUnhealthyApplications fetches applications that failed to schedule any pods +// due to issues like missing resource limits or other scheduling constraints +func fetchUnhealthyApplications(resources PortainerApplicationResources) ([]models.K8sApplication, error) { + var unhealthyApplications []models.K8sApplication + + // Process Deployments + for _, deployment := range resources.Deployments { + if hasNoScheduledPods(deployment) { + app := createApplicationFromDeployment(deployment) + addRelatedResourcesToApplication(&app, resources) + unhealthyApplications = append(unhealthyApplications, app) + } + } + + // Process StatefulSets + for _, statefulSet := range resources.StatefulSets { + if hasNoScheduledPods(statefulSet) { + app := createApplicationFromStatefulSet(statefulSet) + addRelatedResourcesToApplication(&app, resources) + unhealthyApplications = append(unhealthyApplications, app) + } + } + + // Process DaemonSets + for _, daemonSet := range resources.DaemonSets { + if hasNoScheduledPods(daemonSet) { + app := createApplicationFromDaemonSet(daemonSet) + addRelatedResourcesToApplication(&app, resources) + unhealthyApplications = append(unhealthyApplications, app) + } + } + + return unhealthyApplications, nil +} + +// addRelatedResourcesToApplication adds Services and HPA information to the application +func addRelatedResourcesToApplication(app *models.K8sApplication, resources PortainerApplicationResources) { + if app.ID == "" || app.Name == "" { + return + } + + if len(resources.Services) > 0 { + updateApplicationWithService(app, resources.Services) + } + + if len(resources.HorizontalPodAutoscalers) > 0 { + updateApplicationWithHorizontalPodAutoscaler(app, resources.HorizontalPodAutoscalers) + } +} + +// hasNoScheduledPods checks if a workload has completely failed to schedule any pods +// it checks for no replicas desired, i.e. nothing to schedule and see if any pods are running +// if any pods exist at all (even if not ready), it returns false +func hasNoScheduledPods(obj any) bool { + switch resource := obj.(type) { + case appsv1.Deployment: + if resource.Status.Replicas > 0 { + return false + } + + return resource.Status.ReadyReplicas == 0 && resource.Status.AvailableReplicas == 0 + + case appsv1.StatefulSet: + if resource.Status.Replicas > 0 { + return false + } + + return resource.Status.ReadyReplicas == 0 && resource.Status.CurrentReplicas == 0 + + case appsv1.DaemonSet: + if resource.Status.CurrentNumberScheduled > 0 || resource.Status.NumberMisscheduled > 0 { + return false + } + + return resource.Status.NumberReady == 0 && resource.Status.DesiredNumberScheduled > 0 + + default: + return false + } +} diff --git a/api/kubernetes/cli/applications_test.go b/api/kubernetes/cli/applications_test.go new file mode 100644 index 0000000..dffb62f --- /dev/null +++ b/api/kubernetes/cli/applications_test.go @@ -0,0 +1,463 @@ +package cli + +import ( + "testing" + + models "github.com/portainer/portainer/api/http/models/kubernetes" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/kubernetes/fake" +) + +// Helper functions to create test resources +func createTestDeployment(name, namespace string, replicas int32) *appsv1.Deployment { + return &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + UID: types.UID("deploy-" + name), + Labels: map[string]string{ + "app": name, + }, + }, + Spec: appsv1.DeploymentSpec{ + Replicas: &replicas, + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "app": name, + }, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + "app": name, + }, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: name, + Image: "nginx:latest", + Resources: corev1.ResourceRequirements{ + Limits: corev1.ResourceList{}, + Requests: corev1.ResourceList{}, + }, + }, + }, + }, + }, + }, + Status: appsv1.DeploymentStatus{ + Replicas: replicas, + ReadyReplicas: replicas, + }, + } +} + +func createTestReplicaSet(name, namespace, deploymentName string) *appsv1.ReplicaSet { + return &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + UID: types.UID("rs-" + name), + OwnerReferences: []metav1.OwnerReference{ + { + Kind: "Deployment", + Name: deploymentName, + UID: types.UID("deploy-" + deploymentName), + }, + }, + }, + Spec: appsv1.ReplicaSetSpec{ + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "app": deploymentName, + }, + }, + }, + } +} + +func createTestStatefulSet(name, namespace string, replicas int32) *appsv1.StatefulSet { + return &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + UID: types.UID("sts-" + name), + Labels: map[string]string{ + "app": name, + }, + }, + Spec: appsv1.StatefulSetSpec{ + Replicas: &replicas, + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "app": name, + }, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + "app": name, + }, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: name, + Image: "redis:latest", + Resources: corev1.ResourceRequirements{ + Limits: corev1.ResourceList{}, + Requests: corev1.ResourceList{}, + }, + }, + }, + }, + }, + }, + Status: appsv1.StatefulSetStatus{ + Replicas: replicas, + ReadyReplicas: replicas, + }, + } +} + +func createTestDaemonSet(name, namespace string) *appsv1.DaemonSet { + return &appsv1.DaemonSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + UID: types.UID("ds-" + name), + Labels: map[string]string{ + "app": name, + }, + }, + Spec: appsv1.DaemonSetSpec{ + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "app": name, + }, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + "app": name, + }, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: name, + Image: "fluentd:latest", + Resources: corev1.ResourceRequirements{ + Limits: corev1.ResourceList{}, + Requests: corev1.ResourceList{}, + }, + }, + }, + }, + }, + }, + Status: appsv1.DaemonSetStatus{ + DesiredNumberScheduled: 2, + NumberReady: 2, + }, + } +} + +func createTestPod(name, namespace, ownerKind, ownerName string, isRunning bool) *corev1.Pod { + phase := corev1.PodPending + if isRunning { + phase = corev1.PodRunning + } + + var ownerReferences []metav1.OwnerReference + if ownerKind != "" && ownerName != "" { + ownerReferences = []metav1.OwnerReference{ + { + Kind: ownerKind, + Name: ownerName, + UID: types.UID(ownerKind + "-" + ownerName), + }, + } + } + + return &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + UID: types.UID("pod-" + name), + OwnerReferences: ownerReferences, + Labels: map[string]string{ + "app": ownerName, + }, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "container-" + name, + Image: "busybox:latest", + Resources: corev1.ResourceRequirements{ + Limits: corev1.ResourceList{}, + Requests: corev1.ResourceList{}, + }, + }, + }, + }, + Status: corev1.PodStatus{ + Phase: phase, + }, + } +} + +func createTestService(name, namespace string, selector map[string]string) *corev1.Service { + return &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + UID: types.UID("svc-" + name), + }, + Spec: corev1.ServiceSpec{ + Selector: selector, + Type: corev1.ServiceTypeClusterIP, + }, + } +} + +func TestGetApplications(t *testing.T) { + t.Parallel() + t.Run("Admin user - Mix of deployments, statefulsets and daemonsets with and without pods", func(t *testing.T) { + // Create a fake K8s client + fakeClient := fake.NewSimpleClientset() + + // Setup the test namespace + namespace := "test-namespace" + defaultNamespace := "default" + + // Create resources in the test namespace + // 1. Deployment with pods + deployWithPods := createTestDeployment("deploy-with-pods", namespace, 2) + _, err := fakeClient.AppsV1().Deployments(namespace).Create(t.Context(), deployWithPods, metav1.CreateOptions{}) + require.NoError(t, err) + + replicaSet := createTestReplicaSet("rs-deploy-with-pods", namespace, "deploy-with-pods") + _, err = fakeClient.AppsV1().ReplicaSets(namespace).Create(t.Context(), replicaSet, metav1.CreateOptions{}) + require.NoError(t, err) + + pod1 := createTestPod("pod1-deploy", namespace, "ReplicaSet", "rs-deploy-with-pods", true) + _, err = fakeClient.CoreV1().Pods(namespace).Create(t.Context(), pod1, metav1.CreateOptions{}) + require.NoError(t, err) + + pod2 := createTestPod("pod2-deploy", namespace, "ReplicaSet", "rs-deploy-with-pods", true) + _, err = fakeClient.CoreV1().Pods(namespace).Create(t.Context(), pod2, metav1.CreateOptions{}) + require.NoError(t, err) + + // 2. Deployment without pods (scaled to 0) + deployNoPods := createTestDeployment("deploy-no-pods", namespace, 0) + _, err = fakeClient.AppsV1().Deployments(namespace).Create(t.Context(), deployNoPods, metav1.CreateOptions{}) + require.NoError(t, err) + + // 3. StatefulSet with pods + stsWithPods := createTestStatefulSet("sts-with-pods", namespace, 1) + _, err = fakeClient.AppsV1().StatefulSets(namespace).Create(t.Context(), stsWithPods, metav1.CreateOptions{}) + require.NoError(t, err) + + pod3 := createTestPod("pod1-sts", namespace, "StatefulSet", "sts-with-pods", true) + _, err = fakeClient.CoreV1().Pods(namespace).Create(t.Context(), pod3, metav1.CreateOptions{}) + require.NoError(t, err) + + // 4. StatefulSet without pods + stsNoPods := createTestStatefulSet("sts-no-pods", namespace, 0) + _, err = fakeClient.AppsV1().StatefulSets(namespace).Create(t.Context(), stsNoPods, metav1.CreateOptions{}) + require.NoError(t, err) + + // 5. DaemonSet with pods + dsWithPods := createTestDaemonSet("ds-with-pods", namespace) + _, err = fakeClient.AppsV1().DaemonSets(namespace).Create(t.Context(), dsWithPods, metav1.CreateOptions{}) + require.NoError(t, err) + + pod4 := createTestPod("pod1-ds", namespace, "DaemonSet", "ds-with-pods", true) + _, err = fakeClient.CoreV1().Pods(namespace).Create(t.Context(), pod4, metav1.CreateOptions{}) + require.NoError(t, err) + + pod5 := createTestPod("pod2-ds", namespace, "DaemonSet", "ds-with-pods", true) + _, err = fakeClient.CoreV1().Pods(namespace).Create(t.Context(), pod5, metav1.CreateOptions{}) + require.NoError(t, err) + + // 6. Naked Pod (no owner reference) + nakedPod := createTestPod("naked-pod", namespace, "", "", true) + _, err = fakeClient.CoreV1().Pods(namespace).Create(t.Context(), nakedPod, metav1.CreateOptions{}) + require.NoError(t, err) + + // 7. Resources in another namespace + deployOtherNs := createTestDeployment("deploy-other-ns", defaultNamespace, 1) + _, err = fakeClient.AppsV1().Deployments(defaultNamespace).Create(t.Context(), deployOtherNs, metav1.CreateOptions{}) + require.NoError(t, err) + + podOtherNs := createTestPod("pod-other-ns", defaultNamespace, "Deployment", "deploy-other-ns", true) + _, err = fakeClient.CoreV1().Pods(defaultNamespace).Create(t.Context(), podOtherNs, metav1.CreateOptions{}) + require.NoError(t, err) + + // 8. Add a service (dependency) + service := createTestService("svc-deploy", namespace, map[string]string{"app": "deploy-with-pods"}) + _, err = fakeClient.CoreV1().Services(namespace).Create(t.Context(), service, metav1.CreateOptions{}) + require.NoError(t, err) + + // Create the KubeClient with admin privileges + kubeClient := &KubeClient{ + cli: fakeClient, + instanceID: "test-instance", + isKubeAdmin: true, + } + + // Test cases + + // 1. All resources, no filtering + t.Run("All resources with dependencies", func(t *testing.T) { + apps, err := kubeClient.GetApplications("", "") + require.NoError(t, err) + + // We expect 7 resources: 2 deployments + 2 statefulsets + 1 daemonset + 1 naked pod + 1 deployment in other namespace + // Note: Each controller with pods should count once, not per pod + assert.Len(t, apps, 7) + + // Verify one of the deployments has services attached + appsWithServices := []models.K8sApplication{} + for _, app := range apps { + if len(app.Services) > 0 { + appsWithServices = append(appsWithServices, app) + } + } + assert.Len(t, appsWithServices, 1) + assert.Equal(t, "deploy-with-pods", appsWithServices[0].Name) + }) + + // 2. Filter by namespace + t.Run("Filter by namespace", func(t *testing.T) { + apps, err := kubeClient.GetApplications(namespace, "") + require.NoError(t, err) + + // We expect 6 resources in the test namespace + assert.Len(t, apps, 6) + + // Verify resources from other namespaces are not included + for _, app := range apps { + assert.Equal(t, namespace, app.ResourcePool) + } + }) + }) + + t.Run("Non-admin user - Resources filtered by accessible namespaces", func(t *testing.T) { + // Create a fake K8s client + fakeClient := fake.NewSimpleClientset() + + // Setup the test namespaces + namespace1 := "allowed-ns" + namespace2 := "restricted-ns" + + // Create resources in the allowed namespace + sts1 := createTestStatefulSet("sts-allowed", namespace1, 1) + _, err := fakeClient.AppsV1().StatefulSets(namespace1).Create(t.Context(), sts1, metav1.CreateOptions{}) + require.NoError(t, err) + + pod1 := createTestPod("pod-allowed", namespace1, "StatefulSet", "sts-allowed", true) + _, err = fakeClient.CoreV1().Pods(namespace1).Create(t.Context(), pod1, metav1.CreateOptions{}) + require.NoError(t, err) + + // Add a StatefulSet without pods in the allowed namespace + stsNoPods := createTestStatefulSet("sts-no-pods-allowed", namespace1, 0) + _, err = fakeClient.AppsV1().StatefulSets(namespace1).Create(t.Context(), stsNoPods, metav1.CreateOptions{}) + require.NoError(t, err) + + // Create resources in the restricted namespace + sts2 := createTestStatefulSet("sts-restricted", namespace2, 1) + _, err = fakeClient.AppsV1().StatefulSets(namespace2).Create(t.Context(), sts2, metav1.CreateOptions{}) + require.NoError(t, err) + + pod2 := createTestPod("pod-restricted", namespace2, "StatefulSet", "sts-restricted", true) + _, err = fakeClient.CoreV1().Pods(namespace2).Create(t.Context(), pod2, metav1.CreateOptions{}) + require.NoError(t, err) + + // Create the KubeClient with non-admin privileges (only allowed namespace1) + kubeClient := &KubeClient{ + cli: fakeClient, + instanceID: "test-instance", + isKubeAdmin: false, + nonAdminNamespaces: []string{namespace1}, + } + + // Test that only resources from allowed namespace are returned + apps, err := kubeClient.GetApplications("", "") + require.NoError(t, err) + + // We expect 2 resources from the allowed namespace (1 sts with pod + 1 sts without pod) + assert.Len(t, apps, 2) + + // Verify resources are from the allowed namespace + for _, app := range apps { + assert.Equal(t, namespace1, app.ResourcePool) + assert.Equal(t, "StatefulSet", app.Kind) + } + + // Verify names of returned resources + stsNames := make(map[string]bool) + for _, app := range apps { + stsNames[app.Name] = true + } + + assert.True(t, stsNames["sts-allowed"], "Expected StatefulSet 'sts-allowed' was not found") + assert.True(t, stsNames["sts-no-pods-allowed"], "Expected StatefulSet 'sts-no-pods-allowed' was not found") + }) + + t.Run("Filter by node name", func(t *testing.T) { + // Create a fake K8s client + fakeClient := fake.NewSimpleClientset() + + // Setup test namespace + namespace := "node-filter-ns" + nodeName := "worker-node-1" + + // Create a deployment with pods on specific node + deploy := createTestDeployment("node-deploy", namespace, 2) + _, err := fakeClient.AppsV1().Deployments(namespace).Create(t.Context(), deploy, metav1.CreateOptions{}) + require.NoError(t, err) + + // Create ReplicaSet for the deployment + rs := createTestReplicaSet("rs-node-deploy", namespace, "node-deploy") + _, err = fakeClient.AppsV1().ReplicaSets(namespace).Create(t.Context(), rs, metav1.CreateOptions{}) + require.NoError(t, err) + + // Create 2 pods, one on the specified node, one on a different node + pod1 := createTestPod("pod-on-node", namespace, "ReplicaSet", "rs-node-deploy", true) + pod1.Spec.NodeName = nodeName + _, err = fakeClient.CoreV1().Pods(namespace).Create(t.Context(), pod1, metav1.CreateOptions{}) + require.NoError(t, err) + + pod2 := createTestPod("pod-other-node", namespace, "ReplicaSet", "rs-node-deploy", true) + pod2.Spec.NodeName = "worker-node-2" + _, err = fakeClient.CoreV1().Pods(namespace).Create(t.Context(), pod2, metav1.CreateOptions{}) + require.NoError(t, err) + + // Create the KubeClient + kubeClient := &KubeClient{ + cli: fakeClient, + instanceID: "test-instance", + isKubeAdmin: true, + } + + // Test filtering by node name + apps, err := kubeClient.GetApplications(namespace, nodeName) + require.NoError(t, err) + + // We expect to find only the pod on the specified node + assert.Len(t, apps, 1) + if len(apps) > 0 { + assert.Equal(t, "node-deploy", apps[0].Name) + } + }) +} diff --git a/api/kubernetes/cli/client.go b/api/kubernetes/cli/client.go new file mode 100644 index 0000000..5e6c15a --- /dev/null +++ b/api/kubernetes/cli/client.go @@ -0,0 +1,495 @@ +package cli + +import ( + "errors" + "fmt" + "net" + "net/http" + "strconv" + "strings" + "sync" + "time" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/rs/zerolog/log" + + "github.com/patrickmn/go-cache" + pkgerrors "github.com/pkg/errors" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" + metricsv "k8s.io/metrics/pkg/client/clientset/versioned" + gatewaycliv1 "sigs.k8s.io/gateway-api/pkg/client/clientset/versioned/typed/apis/v1" +) + +const ( + defaultKubeClientQPS = 30 + defaultKubeClientBurst = 100 + maxConcurrency = 30 +) + +type ( + // ClientFactory is used to create Kubernetes clients + ClientFactory struct { + dataStore dataservices.DataStore + reverseTunnelService portainer.ReverseTunnelService + signatureService portainer.DigitalSignatureService + instanceID string + endpointProxyClients *cache.Cache + AddrHTTPS string + } + + // KubeClient represent a service used to execute Kubernetes operations + KubeClient struct { + cli kubernetes.Interface + gatewayCLI gatewaycliv1.GatewayV1Interface + instanceID string + mu sync.Mutex + isKubeAdmin bool + nonAdminNamespaces []string + } +) + +// NewClientFactory returns a new instance of a ClientFactory +func NewClientFactory(signatureService portainer.DigitalSignatureService, reverseTunnelService portainer.ReverseTunnelService, dataStore dataservices.DataStore, instanceID, addrHTTPS, userSessionTimeout string) (*ClientFactory, error) { + if userSessionTimeout == "" { + userSessionTimeout = portainer.DefaultUserSessionTimeout + } + timeout, err := time.ParseDuration(userSessionTimeout) + if err != nil { + return nil, err + } + + return &ClientFactory{ + dataStore: dataStore, + signatureService: signatureService, + reverseTunnelService: reverseTunnelService, + instanceID: instanceID, + endpointProxyClients: cache.New(timeout, timeout), + AddrHTTPS: addrHTTPS, + }, nil +} + +func (factory *ClientFactory) GetInstanceID() (instanceID string) { + return factory.instanceID +} + +// Clear removes all cached kube clients +func (factory *ClientFactory) ClearClientCache() { + log.Debug().Msgf("kubernetes namespace permissions have changed, clearing the client cache") + factory.endpointProxyClients.Flush() +} + +// ClearClientCache removes all cached kube clients for a userId +func (factory *ClientFactory) ClearUserClientCache(userID string) { + for key := range factory.endpointProxyClients.Items() { + if strings.HasSuffix(key, "."+userID) { + factory.endpointProxyClients.Delete(key) + } + } +} + +// Remove the cached kube client so a new one can be created +func (factory *ClientFactory) RemoveKubeClient(endpointID portainer.EndpointID) { + factory.endpointProxyClients.Delete(strconv.Itoa(int(endpointID))) + + endpointPrefix := strconv.Itoa(int(endpointID)) + "." + + for key := range factory.endpointProxyClients.Items() { + if strings.HasPrefix(key, endpointPrefix) { + factory.endpointProxyClients.Delete(key) + } + } +} + +func (factory *ClientFactory) GetAddrHTTPS() string { + return factory.AddrHTTPS +} + +// GetPrivilegedKubeClient checks if an existing client is already registered +// for the environment(endpoint) and returns it if one is found. +// +// If no client is registered, it will create a new client, register it, and return it. +func (factory *ClientFactory) GetPrivilegedKubeClient(endpoint *portainer.Endpoint) (*KubeClient, error) { + key := strconv.Itoa(int(endpoint.ID)) + pcl, ok := factory.endpointProxyClients.Get(key) + if ok { + return pcl.(*KubeClient), nil + } + + kcl, err := factory.createCachedPrivilegedKubeClient(endpoint) + if err != nil { + return nil, err + } + + factory.endpointProxyClients.Set(key, kcl, cache.DefaultExpiration) + return kcl, nil +} + +// GetPrivilegedUserKubeClient checks if an existing admin client is already +// registered for the environment(endpoint) and user and returns it if one is +// found. +// +// If no client is registered, it will create a new client, register it, and return it. +func (factory *ClientFactory) GetPrivilegedUserKubeClient(endpoint *portainer.Endpoint, userID portainer.UserID) (*KubeClient, error) { + key := strconv.Itoa(int(endpoint.ID)) + ".admin." + strconv.Itoa(int(userID)) + pcl, ok := factory.endpointProxyClients.Get(key) + if ok { + return pcl.(*KubeClient), nil + } + + kcl, err := factory.createCachedPrivilegedKubeClient(endpoint) + if err != nil { + return nil, err + } + + factory.endpointProxyClients.Set(key, kcl, cache.DefaultExpiration) + return kcl, nil +} + +// GetProxyKubeClient retrieves a KubeClient from the cache. You should be +// calling SetProxyKubeClient before first. It is normally, called the +// kubernetes middleware. +func (factory *ClientFactory) GetProxyKubeClient(endpointID, userID string) (*KubeClient, bool) { + client, ok := factory.endpointProxyClients.Get(endpointID + "." + userID) + if ok { + return client.(*KubeClient), true + } + + return nil, false +} + +// SetProxyKubeClient stores a kubeclient in the cache. +func (factory *ClientFactory) SetProxyKubeClient(endpointID, userID string, cli *KubeClient) { + factory.endpointProxyClients.Set(endpointID+"."+userID, cli, cache.DefaultExpiration) +} + +// CreateKubeClientFromKubeConfig creates a KubeClient from a clusterID, and +// Kubernetes config. +func (factory *ClientFactory) CreateKubeClientFromKubeConfig(clusterID string, kubeConfig []byte, IsKubeAdmin bool, NonAdminNamespaces []string) (*KubeClient, error) { + config, err := clientcmd.NewClientConfigFromBytes(kubeConfig) + if err != nil { + return nil, fmt.Errorf("failed to create a client config from kubeconfig: %w", err) + } + + clientConfig, err := config.ClientConfig() + if err != nil { + return nil, fmt.Errorf("failed to get the complete client config from kubeconfig: %w", err) + } + + clientConfig.QPS = defaultKubeClientQPS + clientConfig.Burst = defaultKubeClientBurst + + httpClient, err := rest.HTTPClientFor(clientConfig) + if err != nil { + return nil, fmt.Errorf("failed to create http client for the given config: %w", err) + } + + cli, err := kubernetes.NewForConfigAndClient(clientConfig, httpClient) + if err != nil { + return nil, fmt.Errorf("failed to create clientset for the given config: %w", err) + } + + gatewayCLI, err := gatewaycliv1.NewForConfigAndClient(clientConfig, httpClient) + if err != nil { + return nil, fmt.Errorf("failed to create gateway clientset for the given config: %w", err) + } + + return &KubeClient{ + cli: cli, + gatewayCLI: gatewayCLI, + instanceID: factory.instanceID, + isKubeAdmin: IsKubeAdmin, + nonAdminNamespaces: NonAdminNamespaces, + }, nil +} + +func (factory *ClientFactory) createCachedPrivilegedKubeClient(endpoint *portainer.Endpoint) (*KubeClient, error) { + cli, gatewayCLI, err := factory.CreateClient(endpoint) + if err != nil { + return nil, err + } + + return &KubeClient{ + cli: cli, + gatewayCLI: gatewayCLI, + instanceID: factory.instanceID, + isKubeAdmin: true, + }, nil +} + +// CreateClient returns a pointer to a new Kubernetes Core Clientset instance. +func (factory *ClientFactory) CreateClient(endpoint *portainer.Endpoint) (*kubernetes.Clientset, *gatewaycliv1.GatewayV1Client, error) { + switch endpoint.Type { + case portainer.KubernetesLocalEnvironment, portainer.AgentOnKubernetesEnvironment, portainer.EdgeAgentOnKubernetesEnvironment: + c, err := factory.CreateConfig(endpoint) + if err != nil { + return nil, nil, err + } + + httpClient, err := rest.HTTPClientFor(c) + if err != nil { + return nil, nil, fmt.Errorf("failed to create http client for the given config: %w", err) + } + + cli, err := kubernetes.NewForConfigAndClient(c, httpClient) + if err != nil { + return nil, nil, fmt.Errorf("failed to create clientset for the given config: %w", err) + } + + gatewayCLI, err := gatewaycliv1.NewForConfigAndClient(c, httpClient) + if err != nil { + return nil, nil, fmt.Errorf("failed to create gateway clientset for the given config: %w", err) + } + return cli, gatewayCLI, nil + } + return nil, nil, errors.New("unsupported environment type") +} + +// CreateConfig returns a pointer to a new kubeconfig ready to create a client. +func (factory *ClientFactory) CreateConfig(endpoint *portainer.Endpoint) (*rest.Config, error) { + switch endpoint.Type { + case portainer.KubernetesLocalEnvironment: + return buildLocalConfig() + case portainer.AgentOnKubernetesEnvironment: + return factory.buildAgentConfig(endpoint) + case portainer.EdgeAgentOnKubernetesEnvironment: + return factory.buildEdgeConfig(endpoint) + } + return nil, errors.New("unsupported environment type") +} + +// AgentHeaderRoundTripper decorates HTTP requests with Portainer agent +// authentication headers and retries once on stale-connection errors +type AgentHeaderRoundTripper struct { + signatureHeader string + publicKeyHeader string + + roundTripper http.RoundTripper +} + +// NewAgentHeaderRoundTripper creates a round tripper that attaches the agent +// authentication headers to every request +func NewAgentHeaderRoundTripper(signatureHeader, publicKeyHeader string, rt http.RoundTripper) *AgentHeaderRoundTripper { + return &AgentHeaderRoundTripper{ + signatureHeader: signatureHeader, + publicKeyHeader: publicKeyHeader, + roundTripper: rt, + } +} + +func (rt *AgentHeaderRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + req.Header.Add(portainer.PortainerAgentPublicKeyHeader, rt.publicKeyHeader) + req.Header.Add(portainer.PortainerAgentSignatureHeader, rt.signatureHeader) + + resp, err := rt.roundTripper.RoundTrip(req) + if err == nil { + return resp, nil + } + + // Retry once if a stale pooled connection was reset (non-timeout transport error) + var opErr *net.OpError + if !errors.As(err, &opErr) || opErr.Timeout() { + return nil, err + } + + if req.GetBody != nil { + body, bodyErr := req.GetBody() + if bodyErr != nil { + return nil, err + } + req.Body = body + } else if req.Body != nil { + return nil, err + } + + return rt.roundTripper.RoundTrip(req) +} + +func (factory *ClientFactory) buildAgentConfig(endpoint *portainer.Endpoint) (*rest.Config, error) { + var clientURL strings.Builder + if !strings.HasPrefix(endpoint.URL, "http") { + clientURL.WriteString("https://") + } + clientURL.WriteString(endpoint.URL) + clientURL.WriteString("/kubernetes") + + signature, err := factory.signatureService.CreateSignature(portainer.PortainerAgentSignatureMessage) + if err != nil { + return nil, err + } + + config, err := clientcmd.BuildConfigFromFlags(clientURL.String(), "") + if err != nil { + return nil, err + } + + config.Insecure = true + config.QPS = defaultKubeClientQPS + config.Burst = defaultKubeClientBurst + + config.Wrap(func(rt http.RoundTripper) http.RoundTripper { + return NewAgentHeaderRoundTripper(signature, factory.signatureService.EncodedPublicKey(), rt) + }) + + return config, nil +} + +func (factory *ClientFactory) buildEdgeConfig(endpoint *portainer.Endpoint) (*rest.Config, error) { + tunnelAddr, err := factory.reverseTunnelService.TunnelAddr(endpoint) + if err != nil { + return nil, pkgerrors.Wrap(err, "failed to activate the chisel reverse tunnel. check if the tunnel port is open at the portainer instance") + } + endpointURL := fmt.Sprintf("http://%s/kubernetes", tunnelAddr) + + config, err := clientcmd.BuildConfigFromFlags(endpointURL, "") + if err != nil { + return nil, err + } + + signature, err := factory.signatureService.CreateSignature(portainer.PortainerAgentSignatureMessage) + if err != nil { + return nil, err + } + + config.Insecure = true + config.QPS = defaultKubeClientQPS + config.Burst = defaultKubeClientBurst + + config.Wrap(func(rt http.RoundTripper) http.RoundTripper { + return NewAgentHeaderRoundTripper(signature, factory.signatureService.EncodedPublicKey(), rt) + }) + + return config, nil +} + +func (factory *ClientFactory) CreateRemoteMetricsClient(endpoint *portainer.Endpoint) (*metricsv.Clientset, error) { + config, err := factory.CreateConfig(endpoint) + if err != nil { + return nil, errors.New("failed to create metrics KubeConfig") + } + return metricsv.NewForConfig(config) +} + +func buildLocalConfig() (*rest.Config, error) { + config, err := rest.InClusterConfig() + if err != nil { + return nil, err + } + + config.QPS = defaultKubeClientQPS + config.Burst = defaultKubeClientBurst + + return config, nil +} + +func (factory *ClientFactory) MigrateEndpointIngresses(e *portainer.Endpoint, datastore dataservices.DataStore, cli *KubeClient) error { + return datastore.UpdateTx(func(tx dataservices.DataStoreTx) error { + environment, err := tx.Endpoint().Endpoint(e.ID) + if err != nil { + log.Error().Err(err).Msgf("Error retrieving environment %d", e.ID) + return err + } + + // classes is a list of controllers which have been manually added to the + // cluster setup view. These need to all be allowed globally, but then + // blocked in specific namespaces which they were not previously allowed in. + classes := environment.Kubernetes.Configuration.IngressClasses + + // In pre-2.16 versions of portainer, the namespace level permissions were stored by + // creating an actual ingress rule in the cluster with a particular + // annotation indicating that it's name (the class name) should be allowed. + detected, err := cli.GetIngressControllers() + if err != nil { + log.Error().Err(err).Msgf("Error getting ingress controllers in environment %d", environment.ID) + return err + } + + // newControllers is a set of all currently detected controllers. + newControllers := make(map[string]struct{}) + for _, controller := range detected { + newControllers[controller.ClassName] = struct{}{} + } + + namespaces, err := cli.GetNamespaces() + if err != nil { + log.Error().Err(err).Msgf("Error getting namespaces in environment %d", environment.ID) + return err + } + + // Set of namespaces, if any, in which "allow none" should be true. + allow := make(map[string]map[string]struct{}) + for _, c := range classes { + allow[c.Name] = make(map[string]struct{}) + } + allow["none"] = make(map[string]struct{}) + + for namespace := range namespaces { + // Compare old annotations with currently detected controllers. + ingresses, err := cli.GetIngresses(namespace) + if err != nil { + log.Error().Err(err).Msgf("Error getting ingresses in environment %d", environment.ID) + return err + } + + for _, ingress := range ingresses { + oldController, ok := ingress.Annotations["ingress.portainer.io/ingress-type"] + if !ok { + // Skip rules without our old annotation. + continue + } + + if _, ok := newControllers[oldController]; ok { + // Skip rules which match a detected controller. + // TODO: Allow this particular controller. + allow[oldController][ingress.Namespace] = struct{}{} + continue + } + + allow["none"][ingress.Namespace] = struct{}{} + } + } + + // Locally, disable "allow none" for namespaces not inside shouldAllowNone. + var newClasses []portainer.KubernetesIngressClassConfig + for _, c := range classes { + var blocked []string + for namespace := range namespaces { + if _, ok := allow[c.Name][namespace]; ok { + continue + } + blocked = append(blocked, namespace) + } + + newClasses = append(newClasses, portainer.KubernetesIngressClassConfig{ + Name: c.Name, + Type: c.Type, + GloballyBlocked: false, + BlockedNamespaces: blocked, + }) + } + + // Handle "none". + if len(allow["none"]) != 0 { + environment.Kubernetes.Configuration.AllowNoneIngressClass = true + var disallowNone []string + for namespace := range namespaces { + if _, ok := allow["none"][namespace]; ok { + continue + } + disallowNone = append(disallowNone, namespace) + } + newClasses = append(newClasses, portainer.KubernetesIngressClassConfig{ + Name: "none", + Type: "custom", + GloballyBlocked: false, + BlockedNamespaces: disallowNone, + }) + } + + environment.Kubernetes.Configuration.IngressClasses = newClasses + environment.PostInitMigrations.MigrateIngresses = false + return tx.Endpoint().UpdateEndpoint(environment.ID, environment) + }) +} diff --git a/api/kubernetes/cli/client_test.go b/api/kubernetes/cli/client_test.go new file mode 100644 index 0000000..c8e91e2 --- /dev/null +++ b/api/kubernetes/cli/client_test.go @@ -0,0 +1,23 @@ +package cli + +import ( + "testing" +) + +func TestClearUserClientCache(t *testing.T) { + t.Parallel() + factory, _ := NewClientFactory(nil, nil, nil, "", "", "") + kcl := &KubeClient{} + factory.endpointProxyClients.Set("12.1", kcl, 0) + factory.endpointProxyClients.Set("12.12", kcl, 0) + factory.endpointProxyClients.Set("12", kcl, 0) + + factory.ClearUserClientCache("12") + + if len(factory.endpointProxyClients.Items()) != 2 { + t.Errorf("Incorrect clients cached after clearUserClientCache;\ngot=\n%d\nwant=\n%d", len(factory.endpointProxyClients.Items()), 2) + } + if _, ok := factory.GetProxyKubeClient("12", "12"); ok { + t.Errorf("Expected not to find client cache for user after clear") + } +} diff --git a/api/kubernetes/cli/cluster_role.go b/api/kubernetes/cli/cluster_role.go new file mode 100644 index 0000000..1296aa4 --- /dev/null +++ b/api/kubernetes/cli/cluster_role.go @@ -0,0 +1,97 @@ +package cli + +import ( + "context" + "errors" + "slices" + "strings" + + models "github.com/portainer/portainer/api/http/models/kubernetes" + "github.com/rs/zerolog/log" + rbacv1 "k8s.io/api/rbac/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + meta "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// GetClusterRoles gets all the clusterRoles for at the cluster level in a k8s endpoint. +// It returns a list of K8sClusterRole objects. +func (kcl *KubeClient) GetClusterRoles() ([]models.K8sClusterRole, error) { + if kcl.GetIsKubeAdmin() { + return kcl.fetchClusterRoles() + } + + return []models.K8sClusterRole{}, errors.New("non-admin users are not allowed to access cluster roles") +} + +// fetchClusterRoles returns a list of all Roles in the specified namespace. +func (kcl *KubeClient) fetchClusterRoles() ([]models.K8sClusterRole, error) { + clusterRoles, err := kcl.cli.RbacV1().ClusterRoles().List(context.TODO(), meta.ListOptions{}) + if err != nil { + return nil, err + } + + results := make([]models.K8sClusterRole, 0) + for _, clusterRole := range clusterRoles.Items { + results = append(results, parseClusterRole(clusterRole)) + } + + return results, nil +} + +// parseClusterRole converts a rbacv1.ClusterRole object to a models.K8sClusterRole object. +func parseClusterRole(clusterRole rbacv1.ClusterRole) models.K8sClusterRole { + return models.K8sClusterRole{ + Name: clusterRole.Name, + CreationDate: clusterRole.CreationTimestamp.Time, + UID: clusterRole.UID, + IsSystem: isSystemClusterRole(&clusterRole), + } +} + +func (kcl *KubeClient) DeleteClusterRoles(req models.K8sClusterRoleDeleteRequests) error { + var errs error + for _, name := range req { + client := kcl.cli.RbacV1().ClusterRoles() + + clusterRole, err := client.Get(context.Background(), name, meta.GetOptions{}) + if err != nil { + if k8serrors.IsNotFound(err) { + continue + } + // this is a more serious error to do with the client so we return right away + return err + } + + if isSystemClusterRole(clusterRole) { + log.Warn().Str("role_name", name).Msg("ignoring delete of 'system' cluster role, not allowed") + } + + err = client.Delete(context.Background(), name, meta.DeleteOptions{}) + if err != nil { + log.Err(err).Str("role_name", name).Msg("unable to delete the cluster role") + errs = errors.Join(errs, err) + } + } + + return errs +} + +func isSystemClusterRole(role *rbacv1.ClusterRole) bool { + if role.Namespace == "kube-system" || role.Namespace == "kube-public" || + role.Namespace == "kube-node-lease" || role.Namespace == "portainer" { + return true + } + + if strings.HasPrefix(role.Name, "system:") { + return true + } + + if role.Labels != nil { + if role.Labels["kubernetes.io/bootstrapping"] == "rbac-defaults" { + return true + } + } + + roles := getPortainerDefaultK8sRoleNames() + return slices.Contains(roles, role.Name) +} diff --git a/api/kubernetes/cli/cluster_role_binding.go b/api/kubernetes/cli/cluster_role_binding.go new file mode 100644 index 0000000..bf39800 --- /dev/null +++ b/api/kubernetes/cli/cluster_role_binding.go @@ -0,0 +1,110 @@ +package cli + +import ( + "context" + "errors" + "strings" + + models "github.com/portainer/portainer/api/http/models/kubernetes" + "github.com/rs/zerolog/log" + rbacv1 "k8s.io/api/rbac/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// GetClusterRoleBindings gets all the clusterRoleBindings for at the cluster level in a k8s endpoint. +// It returns a list of K8sClusterRoleBinding objects. +func (kcl *KubeClient) GetClusterRoleBindings() ([]models.K8sClusterRoleBinding, error) { + if kcl.GetIsKubeAdmin() { + return kcl.fetchClusterRoleBindings() + } + + return []models.K8sClusterRoleBinding{}, errors.New("non-admin users are not allowed to access cluster role bindings") +} + +// fetchClusterRoleBindings returns a list of all cluster roles in the cluster. +func (kcl *KubeClient) fetchClusterRoleBindings() ([]models.K8sClusterRoleBinding, error) { + clusterRoleBindings, err := kcl.cli.RbacV1().ClusterRoleBindings().List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return nil, err + } + + results := make([]models.K8sClusterRoleBinding, 0) + for _, clusterRoleBinding := range clusterRoleBindings.Items { + results = append(results, parseClusterRoleBinding(clusterRoleBinding)) + } + + return results, nil +} + +// parseClusterRoleBinding converts a rbacv1.ClusterRoleBinding object to a models.K8sClusterRoleBinding object. +func parseClusterRoleBinding(clusterRoleBinding rbacv1.ClusterRoleBinding) models.K8sClusterRoleBinding { + return models.K8sClusterRoleBinding{ + Name: clusterRoleBinding.Name, + UID: clusterRoleBinding.UID, + Namespace: clusterRoleBinding.Namespace, + RoleRef: clusterRoleBinding.RoleRef, + Subjects: clusterRoleBinding.Subjects, + CreationDate: clusterRoleBinding.CreationTimestamp.Time, + IsSystem: isSystemClusterRoleBinding(&clusterRoleBinding), + } +} + +// DeleteClusterRoleBindings processes a K8sClusterRoleBindingDeleteRequest +// by deleting each cluster role binding in its given namespace. If deleting a specific cluster role binding +// fails, the error is logged and we continue to delete the remaining cluster role bindings. +func (kcl *KubeClient) DeleteClusterRoleBindings(reqs models.K8sClusterRoleBindingDeleteRequests) error { + var errs error + + for _, name := range reqs { + client := kcl.cli.RbacV1().ClusterRoleBindings() + + clusterRoleBinding, err := client.Get(context.Background(), name, metav1.GetOptions{}) + if err != nil { + if k8serrors.IsNotFound(err) { + continue + } + + // This is a more serious error to do with the client so we return right away + return err + } + + if isSystemClusterRoleBinding(clusterRoleBinding) { + log.Warn().Str("role_name", name).Msg("ignoring delete of 'system' cluster role binding, not allowed") + } + + if err := client.Delete(context.Background(), name, metav1.DeleteOptions{}); err != nil { + log.Err(err).Str("role_name", name).Msg("unable to delete the cluster role binding") + errs = errors.Join(errs, err) + } + } + + return errs +} + +func isSystemClusterRoleBinding(binding *rbacv1.ClusterRoleBinding) bool { + if strings.HasPrefix(binding.Name, "system:") { + return true + } + + if binding.Labels != nil { + if binding.Labels["kubernetes.io/bootstrapping"] == "rbac-defaults" { + return true + } + } + + for _, sub := range binding.Subjects { + if strings.HasPrefix(sub.Name, "system:") { + return true + } + + if sub.Namespace == "kube-system" || + sub.Namespace == "kube-public" || + sub.Namespace == "kube-node-lease" || + sub.Namespace == "portainer" { + return true + } + } + + return false +} diff --git a/api/kubernetes/cli/configmap.go b/api/kubernetes/cli/configmap.go new file mode 100644 index 0000000..af6cdc2 --- /dev/null +++ b/api/kubernetes/cli/configmap.go @@ -0,0 +1,157 @@ +package cli + +import ( + "context" + "fmt" + "time" + + models "github.com/portainer/portainer/api/http/models/kubernetes" + "github.com/rs/zerolog/log" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// GetConfigMaps gets all the ConfigMaps for a given namespace in a k8s endpoint. +// if the user is an admin, all configMaps in the current k8s environment(endpoint) are fetched using the fetchConfigMaps function. +// otherwise, namespaces the non-admin user has access to will be used to filter the configMaps based on the allowed namespaces. +func (kcl *KubeClient) GetConfigMaps(namespace string) ([]models.K8sConfigMap, error) { + if kcl.GetIsKubeAdmin() { + return kcl.fetchConfigMaps(namespace) + } + + return kcl.fetchConfigMapsForNonAdmin(namespace) +} + +// fetchConfigMapsForNonAdmin fetches the configMaps in the namespaces the user has access to. +// This function is called when the user is not an admin. +func (kcl *KubeClient) fetchConfigMapsForNonAdmin(namespace string) ([]models.K8sConfigMap, error) { + nonAdminNamespaces := kcl.GetClientNonAdminNamespaces() + + log.Debug(). + Strs("non_admin_namespaces", nonAdminNamespaces). + Msg("fetching configMaps for non-admin user") + + if len(nonAdminNamespaces) == 0 { + return nil, nil + } + + configMaps, err := kcl.fetchConfigMaps(namespace) + if err != nil { + return nil, err + } + + nonAdminNamespaceSet := kcl.buildNonAdminNamespacesMap() + results := make([]models.K8sConfigMap, 0) + for _, configMap := range configMaps { + if _, ok := nonAdminNamespaceSet[configMap.Namespace]; ok { + results = append(results, configMap) + } + } + + return results, nil +} + +// fetchConfigMaps gets all the ConfigMaps for a given namespace in a k8s endpoint. +// the result is a list of config maps parsed into a K8sConfigMap struct. +func (kcl *KubeClient) fetchConfigMaps(namespace string) ([]models.K8sConfigMap, error) { + configMaps, err := kcl.cli.CoreV1().ConfigMaps(namespace).List(context.Background(), metav1.ListOptions{}) + if err != nil { + return nil, err + } + + results := []models.K8sConfigMap{} + for _, configMap := range configMaps.Items { + results = append(results, parseConfigMap(&configMap, false)) + } + + return results, nil +} + +func (kcl *KubeClient) GetConfigMap(namespace, configMapName string) (models.K8sConfigMap, error) { + configMap, err := kcl.cli.CoreV1().ConfigMaps(namespace).Get(context.Background(), configMapName, metav1.GetOptions{}) + if err != nil { + return models.K8sConfigMap{}, err + } + + return parseConfigMap(configMap, true), nil +} + +// parseConfigMap parses a k8s ConfigMap object into a K8sConfigMap struct. +// for get operation, withData will be set to true. +// otherwise, only metadata will be parsed. +func parseConfigMap(configMap *corev1.ConfigMap, withData bool) models.K8sConfigMap { + result := models.K8sConfigMap{ + K8sConfiguration: models.K8sConfiguration{ + UID: string(configMap.UID), + Name: configMap.Name, + Namespace: configMap.Namespace, + CreationDate: configMap.CreationTimestamp.Time.UTC().Format(time.RFC3339), + Annotations: configMap.Annotations, + Labels: configMap.Labels, + ConfigurationOwner: configMap.Labels[labelPortainerKubeConfigOwner], + ConfigurationOwnerId: configMap.Labels[labelPortainerKubeConfigOwnerId], + }, + } + + if withData { + result.Data = configMap.Data + } + + return result +} + +// SetConfigMapsIsUsed combines the config maps with the applications that use them. +// the function fetches all the pods and replica sets in the cluster and checks if the config map is used by any of the pods. +// if the config map is used by a pod, the application that uses the pod is added to the config map. +// otherwise, the config map is returned as is. +func (kcl *KubeClient) SetConfigMapsIsUsed(configMaps *[]models.K8sConfigMap) error { + portainerApplicationResources, err := kcl.fetchAllApplicationsListResources("", metav1.ListOptions{}) + if err != nil { + return fmt.Errorf("an error occurred during the SetConfigMapsIsUsed operation, unable to fetch Portainer application resources. Error: %w", err) + } + + for i := range *configMaps { + configMap := &(*configMaps)[i] + + for _, pod := range portainerApplicationResources.Pods { + if isPodUsingConfigMap(&pod, *configMap) { + configMap.IsUsed = true + break + } + } + } + + return nil +} + +// CombineConfigMapWithApplications combines the config map with the applications that use it. +// the function fetches all the pods in the cluster and checks if the config map is used by any of the pods. +// it needs to check if the pods are owned by a replica set to determine if the pod is part of a deployment. +func (kcl *KubeClient) CombineConfigMapWithApplications(configMap models.K8sConfigMap) (models.K8sConfigMap, error) { + pods, err := kcl.cli.CoreV1().Pods(configMap.Namespace).List(context.Background(), metav1.ListOptions{}) + if err != nil { + return models.K8sConfigMap{}, fmt.Errorf("an error occurred during the CombineConfigMapWithApplications operation, unable to get pods. Error: %w", err) + } + + replicaSetsItems := []appsv1.ReplicaSet{} + if containsReplicaSetOwnerReference(pods) { + replicaSets, err := kcl.cli.AppsV1().ReplicaSets(configMap.Namespace).List(context.Background(), metav1.ListOptions{}) + if err != nil { + return models.K8sConfigMap{}, fmt.Errorf("an error occurred during the CombineConfigMapWithApplications operation, unable to get replica sets. Error: %w", err) + } + replicaSetsItems = replicaSets.Items + } + + applicationConfigurationOwners, err := kcl.GetApplicationConfigurationOwnersFromConfigMap(configMap, pods.Items, replicaSetsItems) + if err != nil { + return models.K8sConfigMap{}, fmt.Errorf("an error occurred during the CombineConfigMapWithApplications operation, unable to get applications from config map. Error: %w", err) + } + + if len(applicationConfigurationOwners) > 0 { + configMap.ConfigurationOwnerResources = applicationConfigurationOwners + configMap.IsUsed = true + } + + return configMap, nil +} diff --git a/api/kubernetes/cli/cronjob.go b/api/kubernetes/cli/cronjob.go new file mode 100644 index 0000000..6dbc6a4 --- /dev/null +++ b/api/kubernetes/cli/cronjob.go @@ -0,0 +1,128 @@ +package cli + +import ( + "context" + "errors" + "strings" + + models "github.com/portainer/portainer/api/http/models/kubernetes" + batchv1 "k8s.io/api/batch/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// GetCronJobs returns all cronjobs in the given namespace +// If the user is a kube admin, it returns all cronjobs in the namespace +// Otherwise, it returns only the cronjobs in the non-admin namespaces +func (kcl *KubeClient) GetCronJobs(namespace string) ([]models.K8sCronJob, error) { + if kcl.GetIsKubeAdmin() { + return kcl.fetchCronJobs(namespace) + } + + return kcl.fetchCronJobsForNonAdmin(namespace) +} + +// fetchCronJobsForNonAdmin returns all cronjobs in the given namespace +// It returns only the cronjobs in the non-admin namespaces +func (kcl *KubeClient) fetchCronJobsForNonAdmin(namespace string) ([]models.K8sCronJob, error) { + cronJobs, err := kcl.fetchCronJobs(namespace) + if err != nil { + return nil, err + } + + nonAdminNamespaceSet := kcl.buildNonAdminNamespacesMap() + results := make([]models.K8sCronJob, 0) + for _, cronJob := range cronJobs { + if _, ok := nonAdminNamespaceSet[cronJob.Namespace]; ok { + results = append(results, cronJob) + } + } + + return results, nil +} + +// fetchCronJobs returns all cronjobs in the given namespace +// It returns all cronjobs in the namespace +func (kcl *KubeClient) fetchCronJobs(namespace string) ([]models.K8sCronJob, error) { + cronJobs, err := kcl.cli.BatchV1().CronJobs(namespace).List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return nil, err + } + + jobs, err := kcl.cli.BatchV1().Jobs(namespace).List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return nil, err + } + + results := make([]models.K8sCronJob, 0) + for _, cronJob := range cronJobs.Items { + results = append(results, kcl.parseCronJob(cronJob, jobs)) + } + + return results, nil +} + +// parseCronJob converts a batchv1.CronJob object to a models.K8sCronJob object. +func (kcl *KubeClient) parseCronJob(cronJob batchv1.CronJob, jobsList *batchv1.JobList) models.K8sCronJob { + jobs, err := kcl.getCronJobExecutions(cronJob.Name, cronJob.Namespace, jobsList) + if err != nil { + return models.K8sCronJob{} + } + + timezone := "" + if cronJob.Spec.TimeZone != nil { + timezone = *cronJob.Spec.TimeZone + } + + suspend := false + if cronJob.Spec.Suspend != nil { + suspend = *cronJob.Spec.Suspend + } + + var command string + if len(cronJob.Spec.JobTemplate.Spec.Template.Spec.Containers) > 0 { + command = strings.Join(cronJob.Spec.JobTemplate.Spec.Template.Spec.Containers[0].Command, " ") + } + + return models.K8sCronJob{ + Id: string(cronJob.UID), + Name: cronJob.Name, + Namespace: cronJob.Namespace, + Command: command, + Schedule: cronJob.Spec.Schedule, + Timezone: timezone, + Suspend: suspend, + Jobs: jobs, + IsSystem: kcl.isSystemCronJob(cronJob.Namespace), + } +} + +func (kcl *KubeClient) isSystemCronJob(namespace string) bool { + return kcl.isSystemNamespace(namespace) +} + +// DeleteCronJobs deletes the provided list of cronjobs in its namespace +// it returns an error if any of the cronjobs are not found or if there is an error deleting the cronjobs +func (kcl *KubeClient) DeleteCronJobs(payload models.K8sCronJobDeleteRequests) error { + var errs error + for namespace := range payload { + for _, cronJobName := range payload[namespace] { + client := kcl.cli.BatchV1().CronJobs(namespace) + + _, err := client.Get(context.Background(), cronJobName, metav1.GetOptions{}) + if err != nil { + if k8serrors.IsNotFound(err) { + continue + } + + errs = errors.Join(errs, err) + } + + if err := client.Delete(context.Background(), cronJobName, metav1.DeleteOptions{}); err != nil { + errs = errors.Join(errs, err) + } + } + } + + return errs +} diff --git a/api/kubernetes/cli/cronjob_test.go b/api/kubernetes/cli/cronjob_test.go new file mode 100644 index 0000000..b60147e --- /dev/null +++ b/api/kubernetes/cli/cronjob_test.go @@ -0,0 +1,128 @@ +package cli + +import ( + "testing" + + models "github.com/portainer/portainer/api/http/models/kubernetes" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + kfake "k8s.io/client-go/kubernetes/fake" +) + +// TestFetchCronJobs tests the fetchCronJobs method for both admin and non-admin clients +// It creates a fake Kubernetes client and passes it to the fetchCronJobs method +// It then logs the fetched Cron Jobs +// non-admin client will have access to the default namespace only +func (kcl *KubeClient) TestFetchCronJobs(t *testing.T) { + t.Run("admin client can fetch Cron Jobs from all namespaces", func(t *testing.T) { + kcl.cli = kfake.NewSimpleClientset() + kcl.instanceID = "test" + kcl.isKubeAdmin = true + + cronJobs, err := kcl.GetCronJobs("") + if err != nil { + t.Fatalf("Failed to fetch Cron Jobs: %v", err) + } + + t.Logf("Fetched Cron Jobs: %v", cronJobs) + }) + + t.Run("non-admin client can fetch Cron Jobs from the default namespace only", func(t *testing.T) { + kcl.cli = kfake.NewSimpleClientset() + kcl.instanceID = "test" + kcl.isKubeAdmin = false + kcl.SetClientNonAdminNamespaces([]string{"default"}) + + cronJobs, err := kcl.GetCronJobs("") + if err != nil { + t.Fatalf("Failed to fetch Cron Jobs: %v", err) + } + + t.Logf("Fetched Cron Jobs: %v", cronJobs) + }) + + t.Run("delete Cron Jobs", func(t *testing.T) { + kcl.cli = kfake.NewSimpleClientset() + kcl.instanceID = "test" + + _, err := kcl.cli.BatchV1().CronJobs("default").Create(t.Context(), &batchv1.CronJob{ + ObjectMeta: metav1.ObjectMeta{Name: "test-cronjob"}, + }, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("Failed to create cron job: %v", err) + } + + err = kcl.DeleteCronJobs(models.K8sCronJobDeleteRequests{ + "default": []string{"test-cronjob"}, + }) + + if err != nil { + t.Fatalf("Failed to delete Cron Jobs: %v", err) + } + + t.Logf("Deleted Cron Jobs") + }) +} + +// TestGetCronJobExecutionsNamespaceFilter verifies that getCronJobExecutions only returns +// executions belonging to the CronJob's own namespace, even when same-named CronJobs +// exist across multiple namespaces. +func TestGetCronJobExecutionsNamespaceFilter(t *testing.T) { + t.Parallel() + backoffLimit := int32(3) + completions := int32(1) + + makeJob := func(name, namespace, cronJobName string) batchv1.Job { + return batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + OwnerReferences: []metav1.OwnerReference{ + {Kind: "CronJob", Name: cronJobName}, + }, + }, + Spec: batchv1.JobSpec{ + BackoffLimit: &backoffLimit, + Completions: &completions, + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "worker", Image: "busybox"}}, + }, + }, + }, + } + } + + // Simulate the cross-namespace job list returned when fetchCronJobs is called with namespace="" + allJobs := &batchv1.JobList{ + Items: []batchv1.Job{ + makeJob("backup-prod-28001440", "ns-prod", "backup"), + makeJob("backup-test-28001441", "ns-test", "backup"), + }, + } + + kcl := &KubeClient{ + cli: kfake.NewSimpleClientset(), + instanceID: "test", + isKubeAdmin: true, + } + + t.Run("returns only executions from the matching namespace", func(t *testing.T) { + result, err := kcl.getCronJobExecutions("backup", "ns-prod", allJobs) + require.NoError(t, err) + require.Len(t, result, 1) + assert.Equal(t, "ns-prod", result[0].Namespace) + assert.Equal(t, "backup-prod-28001440", result[0].Name) + }) + + t.Run("returns only executions from the other matching namespace", func(t *testing.T) { + result, err := kcl.getCronJobExecutions("backup", "ns-test", allJobs) + require.NoError(t, err) + require.Len(t, result, 1) + assert.Equal(t, "ns-test", result[0].Namespace) + assert.Equal(t, "backup-test-28001441", result[0].Name) + }) +} diff --git a/api/kubernetes/cli/dashboard.go b/api/kubernetes/cli/dashboard.go new file mode 100644 index 0000000..7a7951c --- /dev/null +++ b/api/kubernetes/cli/dashboard.go @@ -0,0 +1,262 @@ +package cli + +import ( + "context" + + "github.com/portainer/portainer/api/concurrent" + models "github.com/portainer/portainer/api/http/models/kubernetes" + + "k8s.io/apimachinery/pkg/api/errors" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func (kcl *KubeClient) GetDashboard() (models.K8sDashboard, error) { + dashboardData := models.K8sDashboard{} + + // Get a list of all the namespaces first + namespaces, err := kcl.cli.CoreV1().Namespaces().List(context.TODO(), v1.ListOptions{}) + if err != nil { + return dashboardData, err + } + + getNamespaceCounts := func(namespace string) concurrent.Func { + return func(ctx context.Context) (any, error) { + data := models.K8sDashboard{} + + // apps (deployments, statefulsets, daemonsets) + applicationCount, err := getApplicationsCount(ctx, kcl, namespace) + if err != nil { + // skip namespaces we're not allowed access to. But don't return an error so that we + // can still count the other namespaces. Returning an error here will stop concurrent.Run + if errors.IsForbidden(err) { + return nil, nil + } + return nil, err + } + data.ApplicationsCount = applicationCount + + // services + serviceCount, err := getServicesCount(ctx, kcl, namespace) + if err != nil { + return nil, err + } + data.ServicesCount = serviceCount + + // ingresses + ingressesCount, err := getIngressesCount(ctx, kcl, namespace) + if err != nil { + return nil, err + } + data.IngressesCount = ingressesCount + + // configmaps + configMapCount, err := getConfigMapsCount(ctx, kcl, namespace) + if err != nil { + return nil, err + } + data.ConfigMapsCount = configMapCount + + // secrets + secretsCount, err := getSecretsCount(ctx, kcl, namespace) + if err != nil { + return nil, err + } + data.SecretsCount = secretsCount + + // volumes + volumesCount, err := getVolumesCount(ctx, kcl, namespace) + if err != nil { + return nil, err + } + data.VolumesCount = volumesCount + + // count this namespace for the user + data.NamespacesCount = 1 + + return data, nil + } + } + + dashboardTasks := make([]concurrent.Func, 0) + for _, ns := range namespaces.Items { + dashboardTasks = append(dashboardTasks, getNamespaceCounts(ns.Name)) + } + + // Fetch all the data for each namespace concurrently + results, err := concurrent.Run(context.TODO(), maxConcurrency, dashboardTasks...) + if err != nil { + return dashboardData, err + } + + // Sum up the results + for i := range results { + data, _ := results[i].Result.(models.K8sDashboard) + dashboardData.NamespacesCount += data.NamespacesCount + dashboardData.ApplicationsCount += data.ApplicationsCount + dashboardData.ServicesCount += data.ServicesCount + dashboardData.IngressesCount += data.IngressesCount + dashboardData.ConfigMapsCount += data.ConfigMapsCount + dashboardData.SecretsCount += data.SecretsCount + dashboardData.VolumesCount += data.VolumesCount + } + + return dashboardData, nil +} + +// Get applications excluding nakedpods +func getApplicationsCount(ctx context.Context, kcl *KubeClient, namespace string) (int64, error) { + options := v1.ListOptions{Limit: 1} + count := int64(0) + + // deployments + deployments, err := kcl.cli.AppsV1().Deployments(namespace).List(ctx, options) + if err != nil { + return 0, err + } + + if len(deployments.Items) > 0 { + count = 1 // first deployment + remainingItemsCount := deployments.GetRemainingItemCount() + if remainingItemsCount != nil { + count += *remainingItemsCount // add the remaining deployments if any + } + } + + // StatefulSets + statefulSets, err := kcl.cli.AppsV1().StatefulSets(namespace).List(ctx, options) + if err != nil { + return 0, err + } + + if len(statefulSets.Items) > 0 { + count += 1 // + first statefulset + remainingItemsCount := statefulSets.GetRemainingItemCount() + if remainingItemsCount != nil { + count += *remainingItemsCount // add the remaining statefulsets if any + } + } + + // Daemonsets + daemonsets, err := kcl.cli.AppsV1().DaemonSets(namespace).List(ctx, options) + if err != nil { + return 0, err + } + + if len(daemonsets.Items) > 0 { + count += 1 // + first daemonset + remainingItemsCount := daemonsets.GetRemainingItemCount() + if remainingItemsCount != nil { + count += *remainingItemsCount // add the remaining daemonsets if any + } + } + + // + (naked pods) + // TODO: Implement fetching of naked pods + // This is to be reworked as part of the dashboard refactor + + // nakedPods, err := kcl.GetApplications(namespace, "nakedpods") + // if err != nil { + // return 0, err + // } + // For now, we're not including naked pods in the count + + return count, nil +} + +// Get the total count of services for the given namespace +func getServicesCount(ctx context.Context, kcl *KubeClient, namespace string) (int64, error) { + options := v1.ListOptions{ + Limit: 1, + } + var count int64 = 0 + services, err := kcl.cli.CoreV1().Services(namespace).List(ctx, options) + if err != nil { + return 0, err + } + + if len(services.Items) > 0 { + count = 1 // first service + remainingItemsCount := services.GetRemainingItemCount() + if remainingItemsCount != nil { + count += *remainingItemsCount // add the remaining services if any + } + } + + return count, nil +} + +// Get the total count of ingresses for the given namespace +func getIngressesCount(ctx context.Context, kcl *KubeClient, namespace string) (int64, error) { + ingresses, err := kcl.cli.NetworkingV1().Ingresses(namespace).List(ctx, v1.ListOptions{Limit: 1}) + if err != nil { + return 0, err + } + + count := int64(0) + if len(ingresses.Items) > 0 { + count = 1 // first ingress + remainingItemsCount := ingresses.GetRemainingItemCount() + if remainingItemsCount != nil { + count += *remainingItemsCount // add the remaining ingresses if any + } + } + + return count, nil +} + +// Get the total count of configMaps for the given namespace +func getConfigMapsCount(ctx context.Context, kcl *KubeClient, namespace string) (int64, error) { + configMaps, err := kcl.cli.CoreV1().ConfigMaps(namespace).List(ctx, v1.ListOptions{Limit: 1}) + if err != nil { + return 0, err + } + + count := int64(0) + if len(configMaps.Items) > 0 { + count = 1 // first configmap + remainingItemsCount := configMaps.GetRemainingItemCount() + if remainingItemsCount != nil { + count += *remainingItemsCount // add the remaining configmaps if any + } + } + + return count, nil +} + +// Get the total count of secrets for the given namespace +func getSecretsCount(ctx context.Context, kcl *KubeClient, namespace string) (int64, error) { + secrets, err := kcl.cli.CoreV1().Secrets(namespace).List(ctx, v1.ListOptions{Limit: 1}) + if err != nil { + return 0, err + } + + count := int64(0) + if len(secrets.Items) > 0 { + count = 1 // first secret + remainingItemsCount := secrets.GetRemainingItemCount() + if remainingItemsCount != nil { + count += *remainingItemsCount // add the remaining secrets if any + } + } + + return count, nil +} + +// Get the total count of volumes for the given namespace +func getVolumesCount(ctx context.Context, kcl *KubeClient, namespace string) (int64, error) { + volumes, err := kcl.cli.CoreV1().PersistentVolumeClaims(namespace).List(ctx, v1.ListOptions{Limit: 1}) + if err != nil { + return 0, err + } + + count := int64(0) + if len(volumes.Items) > 0 { + count = 1 // first volume + remainingItemsCount := volumes.GetRemainingItemCount() + if remainingItemsCount != nil { + count += *remainingItemsCount // add the remaining volumes if any + } + } + + return count, nil +} diff --git a/api/kubernetes/cli/deployment.go b/api/kubernetes/cli/deployment.go new file mode 100644 index 0000000..ce0b72c --- /dev/null +++ b/api/kubernetes/cli/deployment.go @@ -0,0 +1,22 @@ +package cli + +import ( + "context" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + labels "k8s.io/apimachinery/pkg/labels" +) + +// HasStackName checks whether the given name is used in the given namespace. +func (kcl *KubeClient) HasStackName(namespace string, stackName string) (bool, error) { + querySet := labels.Set{"io.portainer.kubernetes.application.stack": stackName} + listOpts := metav1.ListOptions{LabelSelector: labels.SelectorFromSet(querySet).String()} + list, err := kcl.cli.AppsV1().Deployments(namespace).List(context.TODO(), listOpts) + if err != nil { + return false, err + } + if len(list.Items) > 0 { + return false, nil + } + return true, nil +} diff --git a/api/kubernetes/cli/errors.go b/api/kubernetes/cli/errors.go new file mode 100644 index 0000000..7cd9b50 --- /dev/null +++ b/api/kubernetes/cli/errors.go @@ -0,0 +1,7 @@ +package cli + +import "errors" + +// ErrUnauthorized is returned when a non-admin user attempts to access a resource +// outside their permitted namespace scope. +var ErrUnauthorized = errors.New("unauthorized") diff --git a/api/kubernetes/cli/event.go b/api/kubernetes/cli/event.go new file mode 100644 index 0000000..2c250fc --- /dev/null +++ b/api/kubernetes/cli/event.go @@ -0,0 +1,93 @@ +package cli + +import ( + "context" + + models "github.com/portainer/portainer/api/http/models/kubernetes" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// GetEvents gets all the Events for a given namespace and resource +// If the user is a kube admin, it returns all events in the namespace +// Otherwise, it returns only the events in the non-admin namespaces +func (kcl *KubeClient) GetEvents(namespace string, resourceId string) ([]models.K8sEvent, error) { + if kcl.GetIsKubeAdmin() { + return kcl.fetchAllEvents(namespace, resourceId) + } + + return kcl.fetchEventsForNonAdmin(namespace, resourceId) +} + +// fetchEventsForNonAdmin returns all events in the given namespace and resource +// It returns only the events in the non-admin namespaces +func (kcl *KubeClient) fetchEventsForNonAdmin(namespace string, resourceId string) ([]models.K8sEvent, error) { + if len(kcl.GetClientNonAdminNamespaces()) == 0 { + return nil, nil + } + + events, err := kcl.fetchAllEvents(namespace, resourceId) + if err != nil { + return nil, err + } + + nonAdminNamespaceSet := kcl.buildNonAdminNamespacesMap() + results := make([]models.K8sEvent, 0) + for _, event := range events { + if _, ok := nonAdminNamespaceSet[event.Namespace]; ok { + results = append(results, event) + } + } + + return results, nil +} + +// fetchEventsForNonAdmin returns all events in the given namespace and resource +// It returns all events in the namespace and resource +func (kcl *KubeClient) fetchAllEvents(namespace string, resourceId string) ([]models.K8sEvent, error) { + options := metav1.ListOptions{} + if resourceId != "" { + options.FieldSelector = "involvedObject.uid=" + resourceId + } + + list, err := kcl.cli.CoreV1().Events(namespace).List(context.TODO(), options) + if err != nil { + return nil, err + } + + results := make([]models.K8sEvent, 0) + for _, event := range list.Items { + results = append(results, parseEvent(&event)) + } + + return results, nil +} + +func parseEvent(event *corev1.Event) models.K8sEvent { + result := models.K8sEvent{ + Type: event.Type, + Name: event.Name, + Message: event.Message, + Reason: event.Reason, + Namespace: event.Namespace, + EventTime: event.EventTime.UTC(), + Kind: event.Kind, + Count: event.Count, + UID: string(event.GetUID()), + InvolvedObjectKind: models.K8sEventInvolvedObject{ + Kind: event.InvolvedObject.Kind, + UID: string(event.InvolvedObject.UID), + Name: event.InvolvedObject.Name, + Namespace: event.InvolvedObject.Namespace, + }, + } + + if !event.LastTimestamp.Time.IsZero() { + result.LastTimestamp = &event.LastTimestamp.Time + } + if !event.FirstTimestamp.Time.IsZero() { + result.FirstTimestamp = &event.FirstTimestamp.Time + } + + return result +} diff --git a/api/kubernetes/cli/event_test.go b/api/kubernetes/cli/event_test.go new file mode 100644 index 0000000..684f086 --- /dev/null +++ b/api/kubernetes/cli/event_test.go @@ -0,0 +1,101 @@ +package cli + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + kfake "k8s.io/client-go/kubernetes/fake" +) + +// TestGetEvents tests the GetEvents method +// It creates a fake Kubernetes client and passes it to the GetEvents method +// It then logs the fetched events and validated the data returned +func TestGetEvents(t *testing.T) { + t.Parallel() + t.Run("can get events for resource id when admin", func(t *testing.T) { + kcl := &KubeClient{ + cli: kfake.NewSimpleClientset(), + instanceID: "instance", + isKubeAdmin: true, + } + + event := corev1.Event{ + InvolvedObject: corev1.ObjectReference{UID: "resourceId"}, + Action: "something", + ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "myEvent"}, + EventTime: metav1.NowMicro(), + Type: "warning", + Message: "This event has a very serious warning", + } + + _, err := kcl.cli.CoreV1().Events("default").Create(t.Context(), &event, metav1.CreateOptions{}) + require.NoError(t, err, "Failed to create Event") + + events, err := kcl.GetEvents("default", "resourceId") + require.NoError(t, err, "Failed to fetch Events") + + t.Logf("Fetched Events: %v", events) + require.Len(t, events, 1, "Expected to return 1 event") + assert.Equal(t, event.Message, events[0].Message, "Expected Message to be equal to event message created") + assert.Equal(t, event.Type, events[0].Type, "Expected Type to be equal to event type created") + assert.Equal(t, event.EventTime.UTC(), events[0].EventTime, "Expected EventTime to be saved as a string from event time created") + }) + t.Run("can get kubernetes events for non admin namespace when non admin", func(t *testing.T) { + kcl := &KubeClient{ + cli: kfake.NewSimpleClientset(), + instanceID: "instance", + isKubeAdmin: false, + nonAdminNamespaces: []string{"nonAdmin"}, + } + + event := corev1.Event{ + InvolvedObject: corev1.ObjectReference{UID: "resourceId"}, + Action: "something", + ObjectMeta: metav1.ObjectMeta{Namespace: "nonAdmin", Name: "myEvent"}, + EventTime: metav1.NowMicro(), + Type: "warning", + Message: "This event has a very serious warning", + } + + _, err := kcl.cli.CoreV1().Events("nonAdmin").Create(t.Context(), &event, metav1.CreateOptions{}) + require.NoError(t, err, "Failed to create Event") + + events, err := kcl.GetEvents("nonAdmin", "resourceId") + require.NoError(t, err, "Failed to fetch Cron Jobs") + + t.Logf("Fetched Events: %v", events) + require.Len(t, events, 1, "Expected to return 1 event") + assert.Equal(t, event.Message, events[0].Message, "Expected Message to be equal to event message created") + assert.Equal(t, event.Type, events[0].Type, "Expected Type to be equal to event type created") + assert.Equal(t, event.EventTime.UTC(), events[0].EventTime, "Expected EventTime to be saved as a string from event time created") + }) + + t.Run("cannot get kubernetes events for admin namespace when non admin", func(t *testing.T) { + kcl := &KubeClient{ + cli: kfake.NewSimpleClientset(), + instanceID: "instance", + isKubeAdmin: false, + nonAdminNamespaces: []string{"nonAdmin"}, + } + + event := corev1.Event{ + InvolvedObject: corev1.ObjectReference{UID: "resourceId"}, + Action: "something", + ObjectMeta: metav1.ObjectMeta{Namespace: "admin", Name: "myEvent"}, + EventTime: metav1.NowMicro(), + Type: "warning", + Message: "This event has a very serious warning", + } + + _, err := kcl.cli.CoreV1().Events("admin").Create(t.Context(), &event, metav1.CreateOptions{}) + require.NoError(t, err, "Failed to create Event") + + events, err := kcl.GetEvents("admin", "resourceId") + require.NoError(t, err, "Failed to fetch Cron Jobs") + t.Logf("Fetched Events: %v", events) + assert.Empty(t, events, "Expected to return 0 events") + }) +} diff --git a/api/kubernetes/cli/exec.go b/api/kubernetes/cli/exec.go new file mode 100644 index 0000000..a46a0be --- /dev/null +++ b/api/kubernetes/cli/exec.go @@ -0,0 +1,101 @@ +package cli + +import ( + "context" + "errors" + "fmt" + + portainer "github.com/portainer/portainer/api" + "github.com/rs/zerolog/log" + v1 "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" +) + +var ( + channelProtocolList = []string{ + "v5.channel.k8s.io", + "v4.channel.k8s.io", + "v3.channel.k8s.io", + "v2.channel.k8s.io", + "channel.k8s.io", + } +) + +// StartExecProcess starts an exec process inside a container using an in-cluster config. +// This is a blocking operation. +func (kcl *KubeClient) StartExecProcess(params portainer.KubeExecParams) { + config, err := rest.InClusterConfig() + if err != nil { + params.ErrChan <- err + return + } + + if !params.UseAdminToken { + config.BearerToken = params.Token + config.BearerTokenFile = "" + } + + req := kcl.cli.CoreV1().RESTClient(). + Post(). + Resource("pods"). + Name(params.PodName). + Namespace(params.Namespace). + SubResource("exec") + + req.VersionedParams(&v1.PodExecOptions{ + Container: params.ContainerName, + Command: params.Command, + Stdin: true, + Stdout: true, + Stderr: true, + TTY: true, + }, scheme.ParameterCodec) + + streamOpts := remotecommand.StreamOptions{ + Stdin: params.Stdin, + Stdout: params.Stdout, + Tty: true, + TerminalSizeQueue: params.ResizeQueue, + } + + // Try WebSocket executor first, fall back to SPDY if it fails + exec, err := remotecommand.NewWebSocketExecutorForProtocols( + config, + "GET", // WebSocket uses GET for the upgrade request + req.URL().String(), + channelProtocolList..., + ) + if err == nil { + err = exec.StreamWithContext(context.TODO(), streamOpts) + if err == nil { + params.ErrChan <- nil + return + } + + log.Warn(). + Err(err). + Str("context", "StartExecProcess"). + Msg("WebSocket exec failed, falling back to SPDY") + } + + // Fall back to SPDY executor + exec, err = remotecommand.NewSPDYExecutor(config, "POST", req.URL()) + if err != nil { + params.ErrChan <- fmt.Errorf("unable to create SPDY executor: %w", err) + return + } + + err = exec.StreamWithContext(context.TODO(), streamOpts) + if err != nil { + var exitError utilexec.ExitError + if !errors.As(err, &exitError) { + params.ErrChan <- fmt.Errorf("unable to start exec process: %w", err) + return + } + } + + params.ErrChan <- nil +} diff --git a/api/kubernetes/cli/ingress.go b/api/kubernetes/cli/ingress.go new file mode 100644 index 0000000..022df55 --- /dev/null +++ b/api/kubernetes/cli/ingress.go @@ -0,0 +1,371 @@ +package cli + +import ( + "context" + "fmt" + "strings" + + models "github.com/portainer/portainer/api/http/models/kubernetes" + "github.com/portainer/portainer/api/stacks/stackutils" + "github.com/rs/zerolog/log" + netv1 "k8s.io/api/networking/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func (kcl *KubeClient) GetIngressControllers() (models.K8sIngressControllers, error) { + classeses, err := kcl.cli.NetworkingV1().IngressClasses().List(context.Background(), metav1.ListOptions{}) + if err != nil { + return nil, err + } + + ingresses, err := kcl.GetIngresses("") + if err != nil { + return nil, err + } + + usedClasses := make(map[string]struct{}) + for _, ingress := range ingresses { + usedClasses[ingress.ClassName] = struct{}{} + } + + results := []models.K8sIngressController{} + for _, class := range classeses.Items { + ingressClass := parseIngressClass(class) + if _, ok := usedClasses[class.Name]; ok { + ingressClass.Used = true + } + + results = append(results, ingressClass) + } + + return results, nil +} + +// fetchIngressClasses fetches all the ingress classes in a k8s endpoint. +func (kcl *KubeClient) fetchIngressClasses() ([]models.K8sIngressController, error) { + ingressClasses, err := kcl.cli.NetworkingV1().IngressClasses().List(context.Background(), metav1.ListOptions{}) + if err != nil { + return nil, err + } + + var controllers []models.K8sIngressController + for _, ingressClass := range ingressClasses.Items { + controllers = append(controllers, parseIngressClass(ingressClass)) + } + return controllers, nil +} + +// parseIngressClass converts a k8s native ingress class object to a Portainer K8sIngressController object. +func parseIngressClass(ingressClasses netv1.IngressClass) models.K8sIngressController { + ingressContoller := models.K8sIngressController{ + Name: ingressClasses.Spec.Controller, + ClassName: ingressClasses.Name, + } + + switch { + case strings.Contains(ingressContoller.Name, "nginx"): + ingressContoller.Type = "nginx" + case strings.Contains(ingressContoller.Name, "traefik"): + ingressContoller.Type = "traefik" + default: + ingressContoller.Type = "other" + } + + return ingressContoller +} + +// GetIngress gets an ingress in a given namespace in a k8s endpoint. +func (kcl *KubeClient) GetIngress(namespace, ingressName string) (models.K8sIngressInfo, error) { + ingress, err := kcl.cli.NetworkingV1().Ingresses(namespace).Get(context.Background(), ingressName, metav1.GetOptions{}) + if err != nil { + return models.K8sIngressInfo{}, err + } + + return parseIngress(*ingress), nil +} + +// GetIngresses gets all the ingresses for a given namespace in a k8s endpoint. +func (kcl *KubeClient) GetIngresses(namespace string) ([]models.K8sIngressInfo, error) { + if kcl.GetIsKubeAdmin() { + return kcl.fetchIngresses(namespace) + } + + return kcl.fetchIngressesForNonAdmin(namespace) +} + +// fetchIngressesForNonAdmin gets all the ingresses for non-admin users in a k8s endpoint. +func (kcl *KubeClient) fetchIngressesForNonAdmin(namespace string) ([]models.K8sIngressInfo, error) { + nonAdminNamespaces := kcl.GetClientNonAdminNamespaces() + + log.Debug(). + Strs("non_admin_namespaces", nonAdminNamespaces). + Msg("fetching ingresses for non-admin user") + + if len(nonAdminNamespaces) == 0 { + return nil, nil + } + + ingresses, err := kcl.fetchIngresses(namespace) + if err != nil { + return nil, err + } + + nonAdminNamespaceSet := kcl.buildNonAdminNamespacesMap() + results := make([]models.K8sIngressInfo, 0) + for _, ingress := range ingresses { + if _, ok := nonAdminNamespaceSet[ingress.Namespace]; ok { + results = append(results, ingress) + } + } + + return results, nil +} + +// fetchIngresses fetches all the ingresses for a given namespace in a k8s endpoint. +func (kcl *KubeClient) fetchIngresses(namespace string) ([]models.K8sIngressInfo, error) { + ingresses, err := kcl.cli.NetworkingV1().Ingresses(namespace).List(context.Background(), metav1.ListOptions{}) + if err != nil { + return nil, err + } + + ingressClasses, err := kcl.fetchIngressClasses() + if err != nil { + return nil, err + } + + results := []models.K8sIngressInfo{} + if len(ingresses.Items) == 0 { + return results, nil + } + + for _, ingress := range ingresses.Items { + result := parseIngress(ingress) + if ingress.Spec.IngressClassName != nil { + result.Type = findUsedIngressFromIngressClasses(ingressClasses, *ingress.Spec.IngressClassName).Name + } + results = append(results, result) + } + + return results, nil +} + +// parseIngress converts a k8s native ingress object to a Portainer K8sIngressInfo object. +func parseIngress(ingress netv1.Ingress) models.K8sIngressInfo { + ingressClassName := "" + if ingress.Spec.IngressClassName != nil { + ingressClassName = *ingress.Spec.IngressClassName + } + + result := models.K8sIngressInfo{ + Name: ingress.Name, + Namespace: ingress.Namespace, + UID: string(ingress.UID), + Annotations: ingress.Annotations, + Labels: ingress.Labels, + CreationDate: ingress.CreationTimestamp.Time, + ClassName: ingressClassName, + } + + for _, tls := range ingress.Spec.TLS { + result.TLS = append(result.TLS, models.K8sIngressTLS{ + Hosts: tls.Hosts, + SecretName: tls.SecretName, + }) + } + + hosts := make(map[string]struct{}) + for _, r := range ingress.Spec.Rules { + hosts[r.Host] = struct{}{} + + if r.HTTP == nil { + continue + } + for _, p := range r.HTTP.Paths { + var path models.K8sIngressPath + path.IngressName = result.Name + path.Host = r.Host + + path.Path = p.Path + if p.PathType != nil { + path.PathType = string(*p.PathType) + } + if p.Backend.Service != nil { + path.ServiceName = p.Backend.Service.Name + path.Port = int(p.Backend.Service.Port.Number) + } + result.Paths = append(result.Paths, path) + } + } + + for host := range hosts { + result.Hosts = append(result.Hosts, host) + } + + return result +} + +// findUsedIngressFromIngressClasses searches for an ingress in a slice of ingress classes and returns the ingress if found. +func findUsedIngressFromIngressClasses(ingressClasses []models.K8sIngressController, className string) models.K8sIngressController { + for _, ingressClass := range ingressClasses { + if ingressClass.ClassName == className { + return ingressClass + } + } + + return models.K8sIngressController{} +} + +// CreateIngress creates a new ingress in a given namespace in a k8s endpoint. +func (kcl *KubeClient) CreateIngress(namespace string, info models.K8sIngressInfo, owner string) error { + ingress := kcl.convertToK8sIngress(info, owner) + _, err := kcl.cli.NetworkingV1().Ingresses(namespace).Create(context.Background(), &ingress, metav1.CreateOptions{}) + if err != nil { + return err + } + + return nil +} + +// convertToK8sIngress converts a Portainer K8sIngressInfo object to a k8s native Ingress object. +// this is required for create and update operations. +func (kcl *KubeClient) convertToK8sIngress(info models.K8sIngressInfo, owner string) netv1.Ingress { + ingressSpec := netv1.IngressSpec{} + if info.ClassName != "" { + ingressSpec.IngressClassName = &info.ClassName + } + + result := netv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: info.Name, + Namespace: info.Namespace, + Annotations: info.Annotations, + }, + + Spec: ingressSpec, + } + + labels := make(map[string]string) + labels["io.portainer.kubernetes.ingress.owner"] = stackutils.SanitizeLabel(owner) + result.Labels = labels + + tls := []netv1.IngressTLS{} + for _, t := range info.TLS { + tls = append(tls, netv1.IngressTLS{ + Hosts: t.Hosts, + SecretName: t.SecretName, + }) + } + result.Spec.TLS = tls + + rules := make(map[string][]netv1.HTTPIngressPath) + for _, path := range info.Paths { + pathType := netv1.PathType(path.PathType) + rules[path.Host] = append(rules[path.Host], netv1.HTTPIngressPath{ + Path: path.Path, + PathType: &pathType, + Backend: netv1.IngressBackend{ + Service: &netv1.IngressServiceBackend{ + Name: path.ServiceName, + Port: netv1.ServiceBackendPort{ + Number: int32(path.Port), + }, + }, + }, + }) + } + + for rule, paths := range rules { + result.Spec.Rules = append(result.Spec.Rules, netv1.IngressRule{ + Host: rule, + IngressRuleValue: netv1.IngressRuleValue{ + HTTP: &netv1.HTTPIngressRuleValue{ + Paths: paths, + }, + }, + }) + } + + for _, host := range info.Hosts { + if _, ok := rules[host]; !ok { + result.Spec.Rules = append(result.Spec.Rules, netv1.IngressRule{ + Host: host, + }) + } + } + + return result +} + +// DeleteIngresses processes a K8sIngressDeleteRequest by deleting each ingress +// in its given namespace. +func (kcl *KubeClient) DeleteIngresses(reqs models.K8sIngressDeleteRequests) error { + for namespace := range reqs { + for _, ingress := range reqs[namespace] { + err := kcl.cli.NetworkingV1().Ingresses(namespace).Delete( + context.Background(), + ingress, + metav1.DeleteOptions{}, + ) + + if err != nil { + return err + } + } + } + + return nil +} + +// UpdateIngress updates an existing ingress in a given namespace in a k8s endpoint. +func (kcl *KubeClient) UpdateIngress(namespace string, info models.K8sIngressInfo) error { + ingress := kcl.convertToK8sIngress(info, "") + _, err := kcl.cli.NetworkingV1().Ingresses(namespace).Update(context.Background(), &ingress, metav1.UpdateOptions{}) + if err != nil { + return err + } + + return nil +} + +// CombineIngressWithService combines an ingress with a service that is being used by the ingress. +// this is required to display the service that is being used by the ingress in the UI edit view. +func (kcl *KubeClient) CombineIngressWithService(ingress models.K8sIngressInfo) (models.K8sIngressInfo, error) { + services, err := kcl.GetServices(ingress.Namespace) + if err != nil { + return models.K8sIngressInfo{}, fmt.Errorf("an error occurred during the CombineIngressWithService operation, unable to retrieve services from the Kubernetes for a namespace level user. Error: %w", err) + } + + serviceMap := kcl.buildServicesMap(services) + for pathIndex, path := range ingress.Paths { + if _, ok := serviceMap[path.ServiceName]; ok { + ingress.Paths[pathIndex].HasService = true + } + } + + return ingress, nil +} + +// CombineIngressesWithServices combines a list of ingresses with a list of services that are being used by the ingresses. +// this is required to display the services that are being used by the ingresses in the UI list view. +func (kcl *KubeClient) CombineIngressesWithServices(ingresses []models.K8sIngressInfo) ([]models.K8sIngressInfo, error) { + services, err := kcl.GetServices("") + if err != nil { + if k8serrors.IsUnauthorized(err) { + return nil, fmt.Errorf("an error occurred during the CombineIngressesWithServices operation, unauthorized access to the Kubernetes API. Error: %w", err) + } + + return nil, fmt.Errorf("an error occurred during the CombineIngressesWithServices operation, unable to retrieve services from the Kubernetes for a cluster level user. Error: %w", err) + } + + serviceMap := kcl.buildServicesMap(services) + for ingressIndex, ingress := range ingresses { + for pathIndex, path := range ingress.Paths { + if _, ok := serviceMap[path.ServiceName]; ok { + (ingresses)[ingressIndex].Paths[pathIndex].HasService = true + } + } + } + + return ingresses, nil +} diff --git a/api/kubernetes/cli/ingress_test.go b/api/kubernetes/cli/ingress_test.go new file mode 100644 index 0000000..e6e36a6 --- /dev/null +++ b/api/kubernetes/cli/ingress_test.go @@ -0,0 +1,16 @@ +package cli + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGetIngresses(t *testing.T) { + t.Parallel() + kcl := &KubeClient{} + + ingresses, err := kcl.GetIngresses("default") + require.NoError(t, err) + require.Empty(t, ingresses) +} diff --git a/api/kubernetes/cli/job.go b/api/kubernetes/cli/job.go new file mode 100644 index 0000000..8949436 --- /dev/null +++ b/api/kubernetes/cli/job.go @@ -0,0 +1,252 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "sort" + "strings" + "time" + + models "github.com/portainer/portainer/api/http/models/kubernetes" + "github.com/rs/zerolog/log" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// GetJobs returns all jobs in the given namespace +// If the user is a kube admin, it returns all jobs in the namespace +// Otherwise, it returns only the jobs in the non-admin namespaces +func (kcl *KubeClient) GetJobs(namespace string, includeCronJobChildren bool) ([]models.K8sJob, error) { + if kcl.GetIsKubeAdmin() { + return kcl.fetchJobs(namespace, includeCronJobChildren) + } + + return kcl.fetchJobsForNonAdmin(namespace, includeCronJobChildren) +} + +// fetchJobsForNonAdmin returns all jobs in the given namespace +// It returns only the jobs in the non-admin namespaces +func (kcl *KubeClient) fetchJobsForNonAdmin(namespace string, includeCronJobChildren bool) ([]models.K8sJob, error) { + jobs, err := kcl.fetchJobs(namespace, includeCronJobChildren) + if err != nil { + return nil, err + } + + nonAdminNamespaceSet := kcl.buildNonAdminNamespacesMap() + results := make([]models.K8sJob, 0) + for _, job := range jobs { + if _, ok := nonAdminNamespaceSet[job.Namespace]; ok { + results = append(results, job) + } + } + + return results, nil +} + +// fetchJobs returns all jobs in the given namespace +// It returns all jobs in the namespace +func (kcl *KubeClient) fetchJobs(namespace string, includeCronJobChildren bool) ([]models.K8sJob, error) { + jobs, err := kcl.cli.BatchV1().Jobs(namespace).List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return nil, err + } + + results := make([]models.K8sJob, 0) + for _, job := range jobs.Items { + if !includeCronJobChildren && checkCronJobOwner(job) { + continue + } + + results = append(results, kcl.parseJob(job)) + } + + return results, nil +} + +// checkCronJobOwner checks if the job has a cronjob owner +// it returns true if the job has a cronjob owner +// otherwise, it returns false +func checkCronJobOwner(job batchv1.Job) bool { + for _, owner := range job.OwnerReferences { + if owner.Kind == "CronJob" { + return true + } + } + + return false +} + +// parseJob converts a batchv1.Job object to a models.K8sJob object. +func (kcl *KubeClient) parseJob(job batchv1.Job) models.K8sJob { + times := parseJobTimes(job) + status, failedReason := determineJobStatus(job) + podName := getJobPodName(kcl, job) + + var command string + var container corev1.Container + if len(job.Spec.Template.Spec.Containers) > 0 { + command = strings.Join(job.Spec.Template.Spec.Containers[0].Command, " ") + container = job.Spec.Template.Spec.Containers[0] + } + + var backoffLimit int32 + if job.Spec.BackoffLimit != nil { + backoffLimit = *job.Spec.BackoffLimit + } + + var completions int32 + if job.Spec.Completions != nil { + completions = *job.Spec.Completions + } + + return models.K8sJob{ + ID: string(job.UID), + Namespace: job.Namespace, + Name: job.Name, + PodName: podName, + Command: command, + Container: container, + BackoffLimit: backoffLimit, + Completions: completions, + StartTime: times.start, + FinishTime: times.finish, + Duration: times.duration, + Status: status, + FailedReason: failedReason, + IsSystem: kcl.isSystemJob(job.Namespace), + } +} + +func (kcl *KubeClient) isSystemJob(namespace string) bool { + return kcl.isSystemNamespace(namespace) +} + +type jobTimes struct { + start string + finish string + duration string +} + +func parseJobTimes(job batchv1.Job) jobTimes { + times := jobTimes{ + start: "N/A", + finish: "N/A", + duration: "N/A", + } + + st := job.Status.StartTime + if st == nil { + return times + } + + times.start = st.Format(time.RFC3339) + times.duration = time.Since(st.Time).Truncate(time.Minute).String() + + if ct := job.Status.CompletionTime; ct != nil { + times.finish = ct.Format(time.RFC3339) + times.duration = ct.Time.Sub(st.Time).String() + } + + return times +} + +func determineJobStatus(job batchv1.Job) (status, failedReason string) { + failedReason = "N/A" + + switch { + case job.Status.Failed > 0: + return "Failed", getLatestJobCondition(job.Status.Conditions) + case job.Status.Succeeded > 0: + return "Succeeded", failedReason + case job.Status.Active == 0: + return "Completed", failedReason + default: + return "Running", failedReason + } +} + +func getJobPodName(kcl *KubeClient, job batchv1.Job) string { + pod, err := kcl.getLatestJobPod(job.Namespace, job.Name) + if err != nil { + log.Warn().Err(err). + Str("job", job.Name). + Str("namespace", job.Namespace). + Msg("Failed to get latest job pod") + return "" + } + + if pod != nil { + return pod.Name + } + return "" +} + +// getCronJobExecutions returns the jobs for a given cronjob +// it returns the jobs for the cronjob +func (kcl *KubeClient) getCronJobExecutions(cronJobName string, cronJobNamespace string, jobs *batchv1.JobList) ([]models.K8sJob, error) { + maxItems := 5 + + results := make([]models.K8sJob, 0) + for _, job := range jobs.Items { + if job.Namespace != cronJobNamespace { + continue + } + + for _, owner := range job.OwnerReferences { + if owner.Kind == "CronJob" && owner.Name == cronJobName { + results = append(results, kcl.parseJob(job)) + + if len(results) >= maxItems { + return results, nil + } + } + } + } + + return results, nil +} + +// DeleteJobs deletes the provided list of jobs +// it returns an error if any of the jobs are not found or if there is an error deleting the jobs +func (kcl *KubeClient) DeleteJobs(payload models.K8sJobDeleteRequests) error { + var errs error + for namespace := range payload { + for _, jobName := range payload[namespace] { + client := kcl.cli.BatchV1().Jobs(namespace) + + _, err := client.Get(context.Background(), jobName, metav1.GetOptions{}) + if err != nil { + if k8serrors.IsNotFound(err) { + continue + } + + errs = errors.Join(errs, err) + } + + if err := client.Delete(context.Background(), jobName, metav1.DeleteOptions{}); err != nil { + errs = errors.Join(errs, err) + } + } + } + + return errs +} + +// getLatestJobCondition returns the latest condition of the job +// it returns the latest condition of the job +// this is only used for the failed reason +func getLatestJobCondition(conditions []batchv1.JobCondition) string { + if len(conditions) == 0 { + return "No conditions" + } + + sort.Slice(conditions, func(i, j int) bool { + return conditions[i].LastTransitionTime.After(conditions[j].LastTransitionTime.Time) + }) + + latest := conditions[0] + return fmt.Sprintf("%s: %s", latest.Type, latest.Message) +} diff --git a/api/kubernetes/cli/job_test.go b/api/kubernetes/cli/job_test.go new file mode 100644 index 0000000..f1e7736 --- /dev/null +++ b/api/kubernetes/cli/job_test.go @@ -0,0 +1,91 @@ +package cli + +import ( + "testing" + "time" + + models "github.com/portainer/portainer/api/http/models/kubernetes" + + "github.com/stretchr/testify/require" + batchv1 "k8s.io/api/batch/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + kfake "k8s.io/client-go/kubernetes/fake" +) + +// TestFetchJobs tests the fetchJobs method for both admin and non-admin clients +// It creates a fake Kubernetes client and passes it to the fetchJobs method +// It then logs the fetched jobs +// non-admin client will have access to the default namespace only +func (kcl *KubeClient) TestFetchJobs(t *testing.T) { + t.Run("admin client can fetch jobs from all namespaces", func(t *testing.T) { + kcl.cli = kfake.NewSimpleClientset() + kcl.instanceID = "test" + kcl.isKubeAdmin = true + + jobs, err := kcl.GetJobs("", false) + if err != nil { + t.Fatalf("Failed to fetch jobs: %v", err) + } + + t.Logf("Fetched jobs: %v", jobs) + }) + + t.Run("non-admin client can fetch jobs from the default namespace only", func(t *testing.T) { + kcl.cli = kfake.NewSimpleClientset() + kcl.instanceID = "test" + kcl.isKubeAdmin = false + kcl.SetClientNonAdminNamespaces([]string{"default"}) + + jobs, err := kcl.GetJobs("", false) + if err != nil { + t.Fatalf("Failed to fetch jobs: %v", err) + } + + t.Logf("Fetched jobs: %v", jobs) + }) + + t.Run("delete jobs", func(t *testing.T) { + kcl.cli = kfake.NewSimpleClientset() + kcl.instanceID = "test" + + _, err := kcl.cli.BatchV1().Jobs("default").Create(t.Context(), &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{Name: "test-job"}, + }, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("Failed to create job: %v", err) + } + + err = kcl.DeleteJobs(models.K8sJobDeleteRequests{ + "default": []string{"test-job"}, + }) + + if err != nil { + t.Fatalf("Failed to delete jobs: %v", err) + } + }) +} + +func TestParseJobTimes(t *testing.T) { + t.Parallel() + // Empty job + jobTimes := parseJobTimes(batchv1.Job{}) + + require.Equal(t, "N/A", jobTimes.duration) + require.Equal(t, "N/A", jobTimes.start) + require.Equal(t, "N/A", jobTimes.finish) + + // Full job + now := time.Now() + completionTime := now.Add(10 * time.Minute) + + jobTimes = parseJobTimes(batchv1.Job{ + Status: batchv1.JobStatus{ + StartTime: &metav1.Time{Time: now}, + CompletionTime: &metav1.Time{Time: completionTime}, + }, + }) + + require.Equal(t, "10m0s", jobTimes.duration) + require.Equal(t, now.Format(time.RFC3339), jobTimes.start) + require.Equal(t, completionTime.Format(time.RFC3339), jobTimes.finish) +} diff --git a/api/kubernetes/cli/metrics.go b/api/kubernetes/cli/metrics.go new file mode 100644 index 0000000..cd9a85e --- /dev/null +++ b/api/kubernetes/cli/metrics.go @@ -0,0 +1,41 @@ +package cli + +import ( + "context" + "regexp" + + models "github.com/portainer/portainer/api/http/models/kubernetes" + + "github.com/segmentio/encoding/json" +) + +// featureGateEnabled reports whether the named Kubernetes feature gate is active +// on the API server by scraping /metrics and checking the +// kubernetes_feature_enabled gauge for that gate name. +func (kcl *KubeClient) featureGateEnabled(ctx context.Context, gate string) (bool, error) { + raw, err := kcl.cli.CoreV1().RESTClient().Get(). + AbsPath("/metrics"). + DoRaw(ctx) + if err != nil { + return false, err + } + re := regexp.MustCompile(`(?m)kubernetes_feature_enabled\{[^}]*name="` + regexp.QuoteMeta(gate) + `"[^}]*\}\s+1$`) + return re.Match(raw), nil +} + +// SupportsPodRestart reports whether the RestartAllContainersOnContainerExits +// feature gate is active, which is required for the pods/restart subresource. +func (kcl *KubeClient) SupportsPodRestart(ctx context.Context) (bool, error) { + return kcl.featureGateEnabled(ctx, "RestartAllContainersOnContainerExits") +} + +func (kcl *KubeClient) GetMetrics() (models.K8sMetrics, error) { + var metrics models.K8sMetrics + resp, err := kcl.cli.CoreV1().RESTClient().Get().AbsPath("apis/metrics.k8s.io/v1beta1/nodes").DoRaw(context.Background()) + if err != nil { + return metrics, err + } + + err = json.Unmarshal(resp, &metrics) + return metrics, err +} diff --git a/api/kubernetes/cli/metrics_test.go b/api/kubernetes/cli/metrics_test.go new file mode 100644 index 0000000..f45eaa9 --- /dev/null +++ b/api/kubernetes/cli/metrics_test.go @@ -0,0 +1,95 @@ +package cli + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" +) + +func metricsKubeClient(t *testing.T, statusCode int, body string) *KubeClient { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(statusCode) + _, _ = fmt.Fprint(w, body) + })) + t.Cleanup(server.Close) + cli, err := kubernetes.NewForConfig(&rest.Config{Host: server.URL}) + require.NoError(t, err) + return &KubeClient{cli: cli} +} + +func TestFeatureGateEnabled(t *testing.T) { + t.Parallel() + + t.Run("returns true when feature gate is enabled", func(t *testing.T) { + t.Parallel() + kcl := metricsKubeClient(t, http.StatusOK, + "kubernetes_feature_enabled{name=\"TestGate\",stage=\"ALPHA\"} 1\n") + ok, err := kcl.featureGateEnabled(t.Context(), "TestGate") + require.NoError(t, err) + assert.True(t, ok) + }) + + t.Run("returns true when labels are in different order", func(t *testing.T) { + t.Parallel() + kcl := metricsKubeClient(t, http.StatusOK, + "kubernetes_feature_enabled{stage=\"ALPHA\",name=\"TestGate\"} 1\n") + ok, err := kcl.featureGateEnabled(t.Context(), "TestGate") + require.NoError(t, err) + assert.True(t, ok) + }) + + t.Run("returns false when feature gate is disabled", func(t *testing.T) { + t.Parallel() + kcl := metricsKubeClient(t, http.StatusOK, + "kubernetes_feature_enabled{name=\"TestGate\",stage=\"ALPHA\"} 0\n") + ok, err := kcl.featureGateEnabled(t.Context(), "TestGate") + require.NoError(t, err) + assert.False(t, ok) + }) + + t.Run("returns false when feature gate is absent", func(t *testing.T) { + t.Parallel() + kcl := metricsKubeClient(t, http.StatusOK, + "kubernetes_feature_enabled{name=\"OtherGate\",stage=\"ALPHA\"} 1\n") + ok, err := kcl.featureGateEnabled(t.Context(), "TestGate") + require.NoError(t, err) + assert.False(t, ok) + }) + + t.Run("returns error when metrics endpoint is unavailable", func(t *testing.T) { + t.Parallel() + kcl := metricsKubeClient(t, http.StatusForbidden, "") + ok, err := kcl.featureGateEnabled(t.Context(), "TestGate") + require.Error(t, err) + assert.False(t, ok) + }) +} + +func TestSupportsPodRestart(t *testing.T) { + t.Parallel() + + t.Run("returns true when feature gate is enabled", func(t *testing.T) { + t.Parallel() + kcl := metricsKubeClient(t, http.StatusOK, + "kubernetes_feature_enabled{name=\"RestartAllContainersOnContainerExits\",stage=\"ALPHA\"} 1\n") + ok, err := kcl.SupportsPodRestart(t.Context()) + require.NoError(t, err) + assert.True(t, ok) + }) + + t.Run("returns false when feature gate is absent", func(t *testing.T) { + t.Parallel() + kcl := metricsKubeClient(t, http.StatusOK, + "kubernetes_feature_enabled{name=\"OtherFeature\",stage=\"ALPHA\"} 1\n") + ok, err := kcl.SupportsPodRestart(t.Context()) + require.NoError(t, err) + assert.False(t, ok) + }) +} diff --git a/api/kubernetes/cli/namespace.go b/api/kubernetes/cli/namespace.go new file mode 100644 index 0000000..34d3a89 --- /dev/null +++ b/api/kubernetes/cli/namespace.go @@ -0,0 +1,465 @@ +package cli + +import ( + "context" + "fmt" + "net/http" + "sort" + "strconv" + "time" + + "github.com/pkg/errors" + portainer "github.com/portainer/portainer/api" + models "github.com/portainer/portainer/api/http/models/kubernetes" + "github.com/portainer/portainer/api/stacks/stackutils" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/response" + "github.com/rs/zerolog/log" + corev1 "k8s.io/api/core/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + systemNamespaceLabel = "io.portainer.kubernetes.namespace.system" + namespaceOwnerLabel = "io.portainer.kubernetes.resourcepool.owner" + namespaceNameLabel = "io.portainer.kubernetes.resourcepool.name" +) + +func defaultSystemNamespaces() map[string]struct{} { + return map[string]struct{}{ + "kube-system": {}, + "kube-public": {}, + "kube-node-lease": {}, + "portainer": {}, + } +} + +// GetNamespaces gets the namespaces in the current k8s environment(endpoint). +// if the user is an admin, all namespaces in the current k8s environment(endpoint) are fetched using the fetchNamespaces function. +// otherwise, namespaces the non-admin user has access to will be used to filter the namespaces based on the allowed namespaces. +func (kcl *KubeClient) GetNamespaces() (map[string]portainer.K8sNamespaceInfo, error) { + if kcl.GetIsKubeAdmin() { + return kcl.fetchNamespaces() + } + + return kcl.fetchNamespacesForNonAdmin() +} + +// fetchNamespacesForNonAdmin gets the namespaces in the current k8s environment(endpoint) for the non-admin user. +func (kcl *KubeClient) fetchNamespacesForNonAdmin() (map[string]portainer.K8sNamespaceInfo, error) { + log.Debug(). + Str("context", "fetchNamespacesForNonAdmin"). + Msg("Fetching namespaces for non-admin user") + + if len(kcl.GetClientNonAdminNamespaces()) == 0 { + return nil, nil + } + + namespaces, err := kcl.fetchNamespaces() + if err != nil { + return nil, fmt.Errorf("an error occurred during the fetchNamespacesForNonAdmin operation, unable to list namespaces for the non-admin user: %w", err) + } + + nonAdminNamespaceSet := kcl.buildNonAdminNamespacesMap() + results := make(map[string]portainer.K8sNamespaceInfo) + for _, namespace := range namespaces { + if _, exists := nonAdminNamespaceSet[namespace.Name]; exists { + results[namespace.Name] = namespace + } + } + + return results, nil +} + +// fetchNamespaces gets the namespaces in the current k8s environment(endpoint). +// this function is used by both admin and non-admin users. +// the result gets parsed to a map of namespace name to namespace info. +func (kcl *KubeClient) fetchNamespaces() (map[string]portainer.K8sNamespaceInfo, error) { + namespaces, err := kcl.cli.CoreV1().Namespaces().List(context.TODO(), metav1.ListOptions{}) + if err != nil { + log.Error(). + Str("context", "fetchNamespaces"). + Err(err). + Msg("Failed to list namespaces") + + return nil, fmt.Errorf("an error occurred during the fetchNamespacesForAdmin operation, unable to list namespaces for the admin user: %w", err) + } + + results := make(map[string]portainer.K8sNamespaceInfo) + for _, namespace := range namespaces.Items { + results[namespace.Name] = parseNamespace(&namespace) + } + + return results, nil +} + +// parseNamespace converts a k8s namespace object to a portainer namespace object. +func parseNamespace(namespace *corev1.Namespace) portainer.K8sNamespaceInfo { + return portainer.K8sNamespaceInfo{ + Id: string(namespace.UID), + Name: namespace.Name, + Status: namespace.Status, + Annotations: namespace.Annotations, + CreationDate: namespace.CreationTimestamp.Format(time.RFC3339), + NamespaceOwner: namespace.Labels[namespaceOwnerLabel], + IsSystem: isSystemNamespace(namespace), + IsDefault: namespace.Name == defaultNamespace, + } +} + +// GetNamespace gets the namespace in the current k8s environment(endpoint). +func (kcl *KubeClient) GetNamespace(name string) (portainer.K8sNamespaceInfo, error) { + if !kcl.GetIsKubeAdmin() { + if _, allowed := kcl.buildNonAdminNamespacesMap()[name]; !allowed { + log.Warn(). + Str("context", "GetNamespace"). + Str("namespace", name). + Msg("Non-admin user denied access to namespace not in allowed list") + return portainer.K8sNamespaceInfo{}, k8serrors.NewForbidden( + corev1.Resource("namespaces"), + name, + errors.New("user does not have access to this namespace"), + ) + } + } + + namespace, err := kcl.cli.CoreV1().Namespaces().Get(context.TODO(), name, metav1.GetOptions{}) + if err != nil { + log.Error(). + Str("context", "GetNamespace"). + Str("namespace", name). + Err(err). + Msg("Failed to get namespace") + return portainer.K8sNamespaceInfo{}, err + } + + return parseNamespace(namespace), nil +} + +// CreateNamespace creates a new namespace in a k8s endpoint. +func (kcl *KubeClient) CreateNamespace(info models.K8sNamespaceDetails) (*corev1.Namespace, error) { + portainerLabels := map[string]string{ + namespaceNameLabel: stackutils.SanitizeLabel(info.Name), + namespaceOwnerLabel: stackutils.SanitizeLabel(info.Owner), + } + + var ns corev1.Namespace + ns.Name = info.Name + ns.Annotations = info.Annotations + ns.Labels = portainerLabels + + namespace, err := kcl.cli.CoreV1().Namespaces().Create(context.Background(), &ns, metav1.CreateOptions{}) + if err != nil { + log.Error(). + Err(err). + Str("context", "CreateNamespace"). + Str("Namespace", info.Name). + Msg("Failed to create the namespace") + + return nil, err + } + + if err := kcl.createOrUpdateNamespaceResourceQuota(info, portainerLabels); err != nil { + log.Error(). + Err(err). + Str("context", "CreateNamespace"). + Str("name", info.Name). + Msg("failed to create or update resource quota for namespace") + return nil, err + } + + return namespace, nil +} + +// UpdateNamespace updates a namespace in a k8s endpoint. +func (kcl *KubeClient) UpdateNamespace(info models.K8sNamespaceDetails) (*corev1.Namespace, error) { + portainerLabels := map[string]string{ + namespaceNameLabel: stackutils.SanitizeLabel(info.Name), + namespaceOwnerLabel: stackutils.SanitizeLabel(info.Owner), + } + + namespace := corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: info.Name, + Annotations: info.Annotations, + }, + } + + updatedNamespace, err := kcl.cli.CoreV1().Namespaces().Update(context.Background(), &namespace, metav1.UpdateOptions{}) + if err != nil { + log.Error(). + Str("context", "UpdateNamespace"). + Str("namespace", info.Name). + Err(err). + Msg("Failed to update namespace") + return nil, err + } + + if err := kcl.createOrUpdateNamespaceResourceQuota(info, portainerLabels); err != nil { + log.Error(). + Err(err). + Str("context", "UpdateNamespace"). + Str("name", info.Name). + Msg("failed to create or update resource quota for namespace") + return nil, err + } + + return updatedNamespace, nil +} + +func (kcl *KubeClient) createOrUpdateNamespaceResourceQuota(info models.K8sNamespaceDetails, portainerLabels map[string]string) error { + if !info.ResourceQuota.Enabled { + if err := kcl.deleteNamespaceResourceQuota(info.Name); err != nil { + log.Debug().Err(err).Str("context", "createOrUpdateNamespaceResourceQuota").Str("name", info.Name).Msg("failed to delete resource quota for namespace") + } + return nil + } + + resourceQuota := &corev1.ResourceQuota{ + ObjectMeta: metav1.ObjectMeta{ + Name: "portainer-rq-" + info.Name, + Namespace: info.Name, + Labels: portainerLabels, + }, + Spec: corev1.ResourceQuotaSpec{ + Hard: corev1.ResourceList{}, + }, + } + + if info.ResourceQuota.Enabled { + memory := resource.MustParse(info.ResourceQuota.Memory) + cpu := resource.MustParse(info.ResourceQuota.CPU) + + if memory.Value() > 0 { + memQuota := memory + resourceQuota.Spec.Hard[corev1.ResourceLimitsMemory] = memQuota + resourceQuota.Spec.Hard[corev1.ResourceRequestsMemory] = memQuota + } + + if cpu.Value() > 0 { + cpuQuota := cpu + resourceQuota.Spec.Hard[corev1.ResourceLimitsCPU] = cpuQuota + resourceQuota.Spec.Hard[corev1.ResourceRequestsCPU] = cpuQuota + } + } + + _, err := kcl.cli.CoreV1().ResourceQuotas(info.Name).Update(context.Background(), resourceQuota, metav1.UpdateOptions{}) + if err != nil { + if k8serrors.IsNotFound(err) { + log.Warn(). + Str("context", "createOrUpdateNamespaceResourceQuota"). + Str("name", info.Name). + Msg("resource quota not found, creating") + _, err = kcl.cli.CoreV1().ResourceQuotas(info.Name).Create(context.Background(), resourceQuota, metav1.CreateOptions{}) + } + } + + return err +} + +func (kcl *KubeClient) deleteNamespaceResourceQuota(namespaceName string) error { + err := kcl.cli.CoreV1().ResourceQuotas(namespaceName).Delete(context.Background(), "portainer-rq-"+namespaceName, metav1.DeleteOptions{}) + if err != nil && !k8serrors.IsNotFound(err) { + log.Error(). + Str("context", "deleteNamespaceResourceQuota"). + Str("name", namespaceName). + Err(err). + Msg("failed to delete resource quota for namespace") + return err + } + log.Warn(). + Str("context", "deleteNamespaceResourceQuota"). + Str("name", namespaceName). + Msg("resource quota to delete not found") + return nil +} + +func isSystemNamespace(namespace *corev1.Namespace) bool { + systemLabelValue, hasSystemLabel := namespace.Labels[systemNamespaceLabel] + if hasSystemLabel { + return systemLabelValue == "true" + } + + return isSystemDefaultNamespace(namespace.Name) +} + +func isSystemDefaultNamespace(namespace string) bool { + systemNamespaces := defaultSystemNamespaces() + _, isSystem := systemNamespaces[namespace] + return isSystem +} + +func (kcl *KubeClient) isSystemNamespace(namespace string) bool { + ns, err := kcl.cli.CoreV1().Namespaces().Get(context.TODO(), namespace, metav1.GetOptions{}) + if err != nil { + return false + } + + return isSystemNamespace(ns) +} + +// ToggleSystemState will set a namespace as a system namespace, or remove this state +// if isSystem is true it will set `systemNamespaceLabel` to "true" and false otherwise +// this will skip if namespace is "default" or if the required state is already set +func (kcl *KubeClient) ToggleSystemState(namespaceName string, isSystem bool) error { + if namespaceName == "default" { + return nil + } + + namespace, err := kcl.cli.CoreV1().Namespaces().Get(context.TODO(), namespaceName, metav1.GetOptions{}) + if err != nil { + log.Error(). + Str("context", "ToggleSystemState"). + Str("namespace", namespaceName). + Err(err). + Msg("failed to get namespace") + return errors.Wrap(err, "failed fetching namespace object") + } + + if isSystemNamespace(namespace) == isSystem { + return nil + } + + if namespace.Labels == nil { + namespace.Labels = map[string]string{} + } + + namespace.Labels[systemNamespaceLabel] = strconv.FormatBool(isSystem) + + if _, err := kcl.cli.CoreV1().Namespaces().Update(context.TODO(), namespace, metav1.UpdateOptions{}); err != nil { + log.Error(). + Str("context", "ToggleSystemState"). + Str("namespace", namespaceName). + Err(err). + Msg("failed updating namespace object") + return errors.Wrap(err, "failed updating namespace object") + } + + if isSystem { + return kcl.NamespaceAccessPoliciesDeleteNamespace(namespaceName) + } + + return nil +} + +func (kcl *KubeClient) DeleteNamespace(namespaceName string) (*corev1.Namespace, error) { + namespace, err := kcl.cli.CoreV1().Namespaces().Get(context.Background(), namespaceName, metav1.GetOptions{}) + if err != nil { + log.Error(). + Str("context", "DeleteNamespace"). + Str("namespace", namespaceName). + Err(err). + Msg("failed fetching namespace object") + return nil, err + } + + err = kcl.cli.CoreV1().Namespaces().Delete(context.Background(), namespaceName, metav1.DeleteOptions{}) + if err != nil { + log.Error(). + Str("context", "DeleteNamespace"). + Str("namespace", namespaceName). + Err(err). + Msg("failed deleting namespace object") + return nil, err + } + + return namespace, nil +} + +// CombineNamespacesWithUnhealthyEvents combines namespaces with unhealthy events across all namespaces +func (kcl *KubeClient) CombineNamespacesWithUnhealthyEvents(namespaces map[string]portainer.K8sNamespaceInfo) (map[string]portainer.K8sNamespaceInfo, error) { + allEvents, err := kcl.GetEvents("", "") + if err != nil && !k8serrors.IsNotFound(err) { + log.Error(). + Str("context", "CombineNamespacesWithUnhealthyEvents"). + Err(err). + Msg("unable to retrieve unhealthy events from the Kubernetes for an admin user") + return nil, err + } + + unhealthyEventCounts := make(map[string]int) + for _, event := range allEvents { + if event.Type == "Warning" { + unhealthyEventCounts[event.Namespace]++ + } + } + + for namespaceName, namespace := range namespaces { + if count, exists := unhealthyEventCounts[namespaceName]; exists { + namespace.UnhealthyEventCount = count + namespaces[namespaceName] = namespace + } + } + + return namespaces, nil +} + +// CombineNamespacesWithResourceQuotas combines namespaces with resource quotas where matching is based on "portainer-rq-"+namespace.Name +func (kcl *KubeClient) CombineNamespacesWithResourceQuotas(namespaces map[string]portainer.K8sNamespaceInfo, w http.ResponseWriter) *httperror.HandlerError { + resourceQuotas, err := kcl.GetResourceQuotas("") + if err != nil && !k8serrors.IsNotFound(err) { + log.Error(). + Str("context", "CombineNamespacesWithResourceQuotas"). + Err(err). + Msg("unable to retrieve resource quotas from the Kubernetes for an admin user") + return httperror.InternalServerError("an error occurred during the CombineNamespacesWithResourceQuotas operation, unable to retrieve resource quotas from the Kubernetes for an admin user. Error: ", err) + } + + if len(*resourceQuotas) > 0 { + return response.JSON(w, kcl.UpdateNamespacesWithResourceQuotas(namespaces, *resourceQuotas)) + } + + return response.JSON(w, kcl.ConvertNamespaceMapToSlice(namespaces)) +} + +// CombineNamespaceWithResourceQuota combines a namespace with a resource quota prefixed with "portainer-rq-"+namespace.Name +func (kcl *KubeClient) CombineNamespaceWithResourceQuota(namespace portainer.K8sNamespaceInfo, w http.ResponseWriter) *httperror.HandlerError { + resourceQuota, err := kcl.GetPortainerResourceQuota(namespace.Name) + if err != nil && !k8serrors.IsNotFound(err) { + log.Error(). + Str("context", "CombineNamespaceWithResourceQuota"). + Str("namespace", namespace.Name). + Err(err). + Msg("unable to retrieve the resource quota associated with the namespace") + return httperror.InternalServerError(fmt.Sprintf("an error occurred during the CombineNamespaceWithResourceQuota operation, unable to retrieve the resource quota associated with the namespace: %s for a non-admin user. Error: ", namespace.Name), err) + } + + if resourceQuota != nil { + namespace.ResourceQuota = resourceQuota + } + + return response.JSON(w, namespace) +} + +// buildNonAdminNamespacesMap builds a map of non-admin namespaces. +// the map is used to filter the namespaces based on the allowed namespaces. +func (kcl *KubeClient) buildNonAdminNamespacesMap() map[string]struct{} { + nonAdminNamespaces := kcl.GetClientNonAdminNamespaces() + nonAdminNamespaceSet := make(map[string]struct{}, len(nonAdminNamespaces)) + + for _, namespace := range nonAdminNamespaces { + if !isSystemDefaultNamespace(namespace) { + nonAdminNamespaceSet[namespace] = struct{}{} + } + } + + return nonAdminNamespaceSet +} + +// ConvertNamespaceMapToSlice converts the namespace map to a slice of namespaces. +// this is used to for the API response. +func (kcl *KubeClient) ConvertNamespaceMapToSlice(namespaces map[string]portainer.K8sNamespaceInfo) []portainer.K8sNamespaceInfo { + namespaceSlice := make([]portainer.K8sNamespaceInfo, 0, len(namespaces)) + for _, namespace := range namespaces { + namespaceSlice = append(namespaceSlice, namespace) + } + + // Sort namespaces by name + sort.Slice(namespaceSlice, func(i, j int) bool { + return namespaceSlice[i].Name < namespaceSlice[j].Name + }) + + return namespaceSlice +} diff --git a/api/kubernetes/cli/namespace_test.go b/api/kubernetes/cli/namespace_test.go new file mode 100644 index 0000000..2574b14 --- /dev/null +++ b/api/kubernetes/cli/namespace_test.go @@ -0,0 +1,244 @@ +package cli + +import ( + "strconv" + "testing" + + portainer "github.com/portainer/portainer/api" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + core "k8s.io/api/core/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + kfake "k8s.io/client-go/kubernetes/fake" +) + +func Test_ToggleSystemState(t *testing.T) { + t.Parallel() + t.Run("should skip is default (exit without error)", func(t *testing.T) { + nsName := "default" + kcl := &KubeClient{ + cli: kfake.NewSimpleClientset(&core.Namespace{ObjectMeta: metav1.ObjectMeta{Name: nsName}}), + instanceID: "instance", + } + + err := kcl.ToggleSystemState(nsName, true) + require.NoError(t, err) + + ns, err := kcl.cli.CoreV1().Namespaces().Get(t.Context(), nsName, metav1.GetOptions{}) + require.NoError(t, err) + + _, exists := ns.Labels[systemNamespaceLabel] + assert.False(t, exists, "system label should not exists") + }) + + t.Run("should fail if namespace doesn't exist", func(t *testing.T) { + nsName := "not-exist" + kcl := &KubeClient{ + cli: kfake.NewSimpleClientset(), + instanceID: "instance", + } + + err := kcl.ToggleSystemState(nsName, true) + require.Error(t, err) + }) + + t.Run("if called with the same state, should skip (exit without error)", func(t *testing.T) { + nsName := "namespace" + tests := []struct { + isSystem bool + }{ + {isSystem: true}, + {isSystem: false}, + } + + for _, test := range tests { + t.Run(strconv.FormatBool(test.isSystem), func(t *testing.T) { + kcl := &KubeClient{ + cli: kfake.NewSimpleClientset(&core.Namespace{ObjectMeta: metav1.ObjectMeta{Name: nsName, Labels: map[string]string{ + systemNamespaceLabel: strconv.FormatBool(test.isSystem), + }}}), + instanceID: "instance", + } + + err := kcl.ToggleSystemState(nsName, test.isSystem) + require.NoError(t, err) + + ns, err := kcl.cli.CoreV1().Namespaces().Get(t.Context(), nsName, metav1.GetOptions{}) + require.NoError(t, err) + + assert.Equal(t, test.isSystem, isSystemNamespace(ns)) + }) + } + }) + + t.Run("for regular namespace if isSystem is true and doesn't have a label, should set the label to true", func(t *testing.T) { + nsName := "namespace" + + config := &core.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: portainerConfigMapName, + Namespace: portainerNamespace, + }, + Data: map[string]string{ + "NamespaceAccessPolicies": `{"ns1":{"UserAccessPolicies":{"2":{"RoleId":0}}}, "ns2":{"UserAccessPolicies":{"2":{"RoleId":0}}}}`, + }, + } + + kcl := &KubeClient{ + cli: kfake.NewSimpleClientset(&core.Namespace{ObjectMeta: metav1.ObjectMeta{Name: nsName}}, config), + instanceID: "instance", + } + + err := kcl.ToggleSystemState(nsName, true) + require.NoError(t, err) + + ns, err := kcl.cli.CoreV1().Namespaces().Get(t.Context(), nsName, metav1.GetOptions{}) + require.NoError(t, err) + + labelValue, exists := ns.Labels[systemNamespaceLabel] + assert.True(t, exists, "system label should exists") + + assert.Equal(t, "true", labelValue) + }) + + t.Run("for default system namespace if isSystem is false and doesn't have a label, should set the label to false", func(t *testing.T) { + nsName := "portainer" + + kcl := &KubeClient{ + cli: kfake.NewSimpleClientset(&core.Namespace{ObjectMeta: metav1.ObjectMeta{Name: nsName}}), + instanceID: "instance", + } + + err := kcl.ToggleSystemState(nsName, false) + require.NoError(t, err) + + ns, err := kcl.cli.CoreV1().Namespaces().Get(t.Context(), nsName, metav1.GetOptions{}) + require.NoError(t, err) + + labelValue, exists := ns.Labels[systemNamespaceLabel] + assert.True(t, exists, "system label should exists") + + assert.Equal(t, "false", labelValue) + }) + + t.Run("for system namespace (with label), if called with false, should set the label", func(t *testing.T) { + nsName := "namespace" + + kcl := &KubeClient{ + cli: kfake.NewSimpleClientset(&core.Namespace{ObjectMeta: metav1.ObjectMeta{Name: nsName, Labels: map[string]string{ + systemNamespaceLabel: "true", + }}}), + instanceID: "instance", + } + + err := kcl.ToggleSystemState(nsName, false) + require.NoError(t, err) + + ns, err := kcl.cli.CoreV1().Namespaces().Get(t.Context(), nsName, metav1.GetOptions{}) + require.NoError(t, err) + + labelValue, exists := ns.Labels[systemNamespaceLabel] + assert.True(t, exists, "system label should exists") + assert.Equal(t, "false", labelValue) + }) + + t.Run("for non system namespace (with label), if called with true, should set the label, and remove accesses", func(t *testing.T) { + nsName := "ns1" + + namespace := &core.Namespace{ObjectMeta: metav1.ObjectMeta{Name: nsName, Labels: map[string]string{ + systemNamespaceLabel: "false", + }}} + + config := &core.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: portainerConfigMapName, + Namespace: portainerNamespace, + }, + Data: map[string]string{ + "NamespaceAccessPolicies": `{"ns1":{"UserAccessPolicies":{"2":{"RoleId":0}}}, "ns2":{"UserAccessPolicies":{"2":{"RoleId":0}}}}`, + }, + } + + kcl := &KubeClient{ + cli: kfake.NewSimpleClientset(namespace, config), + instanceID: "instance", + } + + err := kcl.ToggleSystemState(nsName, true) + require.NoError(t, err) + + ns, err := kcl.cli.CoreV1().Namespaces().Get(t.Context(), nsName, metav1.GetOptions{}) + require.NoError(t, err) + + labelValue, exists := ns.Labels[systemNamespaceLabel] + assert.True(t, exists, "system label should exists") + assert.Equal(t, "true", labelValue) + + expectedPolicies := map[string]portainer.K8sNamespaceAccessPolicy{ + "ns2": {UserAccessPolicies: portainer.UserAccessPolicies{2: {RoleID: 0}}}, + } + + actualPolicies, err := kcl.GetNamespaceAccessPolicies() + require.NoError(t, err, "failed to fetch policies") + assert.Equal(t, expectedPolicies, actualPolicies) + }) +} + +func Test_GetNamespace(t *testing.T) { + t.Parallel() + + newClient := func() *KubeClient { + return &KubeClient{ + cli: kfake.NewSimpleClientset( + &core.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "ns-1"}}, + &core.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "ns-2"}}, + &core.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "kube-system"}}, + ), + instanceID: "instance", + } + } + + t.Run("admin can fetch any namespace", func(t *testing.T) { + kcl := newClient() + kcl.SetIsKubeAdmin(true) + kcl.SetClientNonAdminNamespaces(nil) + + for _, name := range []string{"ns-1", "ns-2", "kube-system"} { + ns, err := kcl.GetNamespace(name) + require.NoError(t, err) + assert.Equal(t, name, ns.Name) + } + }) + + t.Run("non-admin can fetch a namespace in their namespace access", func(t *testing.T) { + kcl := newClient() + kcl.SetIsKubeAdmin(false) + kcl.SetClientNonAdminNamespaces([]string{"ns-1"}) + + ns, err := kcl.GetNamespace("ns-1") + require.NoError(t, err) + assert.Equal(t, "ns-1", ns.Name) + }) + + t.Run("non-admin is forbidden from a namespace outside their namespace access", func(t *testing.T) { + kcl := newClient() + kcl.SetIsKubeAdmin(false) + kcl.SetClientNonAdminNamespaces([]string{"ns-1"}) + + _, err := kcl.GetNamespace("ns-2") + require.Error(t, err) + assert.True(t, k8serrors.IsForbidden(err), "expected a Forbidden error, got %v", err) + }) + + t.Run("non-admin with no namespace access is forbidden from any namespace", func(t *testing.T) { + kcl := newClient() + kcl.SetIsKubeAdmin(false) + kcl.SetClientNonAdminNamespaces(nil) + + _, err := kcl.GetNamespace("ns-1") + require.Error(t, err) + assert.True(t, k8serrors.IsForbidden(err), "expected a Forbidden error, got %v", err) + }) +} diff --git a/api/kubernetes/cli/naming.go b/api/kubernetes/cli/naming.go new file mode 100644 index 0000000..f42289a --- /dev/null +++ b/api/kubernetes/cli/naming.go @@ -0,0 +1,34 @@ +package cli + +import ( + "fmt" +) + +const ( + defaultNamespace = "default" + portainerNamespace = "portainer" + portainerUserCRName = "portainer-cr-user" + portainerUserCRBName = "portainer-crb-user" + portainerClusterAdminServiceAccountName = "portainer-sa-clusteradmin" + portainerUserServiceAccountPrefix = "portainer-sa-user" + portainerRBPrefix = "portainer-rb" + portainerConfigMapName = "portainer-config" + portainerConfigMapAccessPoliciesKey = "NamespaceAccessPolicies" + portainerShellPodPrefix = "portainer-pod-kubectl-shell" +) + +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) +} + +func userShellPodPrefix(serviceAccountName string) string { + return fmt.Sprintf("%s-%s-", portainerShellPodPrefix, serviceAccountName) +} diff --git a/api/kubernetes/cli/nodes.go b/api/kubernetes/cli/nodes.go new file mode 100644 index 0000000..9337924 --- /dev/null +++ b/api/kubernetes/cli/nodes.go @@ -0,0 +1,22 @@ +package cli + +import ( + "context" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// GetClusterNodes returns all nodes from the Kubernetes cluster. +func (kcl *KubeClient) GetClusterNodes() ([]corev1.Node, error) { + nodes, err := kcl.cli.CoreV1().Nodes().List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return nil, err + } + + for i := range nodes.Items { + nodes.Items[i].ManagedFields = nil + } + + return nodes.Items, nil +} diff --git a/api/kubernetes/cli/nodes_limits.go b/api/kubernetes/cli/nodes_limits.go new file mode 100644 index 0000000..0b2a641 --- /dev/null +++ b/api/kubernetes/cli/nodes_limits.go @@ -0,0 +1,104 @@ +package cli + +import ( + "context" + + portainer "github.com/portainer/portainer/api" + "github.com/rs/zerolog/log" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// GetNodesLimits gets the CPU and Memory limits(unused resources) of all nodes in the current k8s environment(endpoint) connection +func (kcl *KubeClient) GetNodesLimits() (portainer.K8sNodesLimits, error) { + nodesLimits := make(portainer.K8sNodesLimits) + + nodes, err := kcl.cli.CoreV1().Nodes().List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return nil, err + } + + pods, err := kcl.cli.CoreV1().Pods("").List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return nil, err + } + + for _, item := range nodes.Items { + cpu := item.Status.Allocatable.Cpu().MilliValue() + memory := item.Status.Allocatable.Memory().Value() // bytes + + nodesLimits[item.Name] = &portainer.K8sNodeLimits{ + CPU: cpu, + Memory: memory, + } + } + + for _, item := range pods.Items { + if nodeLimits, ok := nodesLimits[item.Spec.NodeName]; ok { + for _, container := range item.Spec.Containers { + nodeLimits.CPU -= container.Resources.Requests.Cpu().MilliValue() + nodeLimits.Memory -= container.Resources.Requests.Memory().Value() + } + } + } + + return nodesLimits, nil +} + +// GetMaxResourceLimits gets the maximum CPU and Memory limits(unused resources) of all nodes in the current k8s environment(endpoint) connection, minus the accumulated resourcequotas for all namespaces except the one we're editing (skipNamespace) +// if skipNamespace is set to "" then all namespaces are considered +func (kcl *KubeClient) GetMaxResourceLimits(skipNamespace string, overCommitEnabled bool, resourceOverCommitPercent int) (portainer.K8sNodeLimits, error) { + limits := portainer.K8sNodeLimits{} + nodes, err := kcl.cli.CoreV1().Nodes().List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return limits, err + } + + // accumulated node limits + memory := int64(0) + for _, node := range nodes.Items { + limits.CPU += node.Status.Allocatable.Cpu().MilliValue() + memory += node.Status.Allocatable.Memory().Value() // bytes + } + limits.Memory = memory / 1000000 // B to MB + + if !overCommitEnabled { + namespaces, err := kcl.cli.CoreV1().Namespaces().List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return limits, err + } + + reservedPercent := float64(resourceOverCommitPercent) / 100.0 + + reserved := portainer.K8sNodeLimits{} + for _, namespace := range namespaces.Items { + // skip the namespace we're editing + if namespace.Name == skipNamespace { + continue + } + + // minus accumulated resourcequotas for all namespaces except the one we're editing + resourceQuota, err := kcl.cli.CoreV1().ResourceQuotas(namespace.Name).List(context.TODO(), metav1.ListOptions{}) + if err != nil { + log.Debug().Msgf("error getting resourcequota for namespace %s: %s", namespace.Name, err) + continue // skip it + } + + for _, rq := range resourceQuota.Items { + hardLimits := rq.Status.Hard + for resourceType, limit := range hardLimits { + switch resourceType { + case "limits.cpu": + reserved.CPU += limit.MilliValue() + case "limits.memory": + reserved.Memory += limit.ScaledValue(6) // MB + } + } + } + } + + limits.CPU = limits.CPU - int64(float64(limits.CPU)*reservedPercent) - reserved.CPU + limits.Memory = limits.Memory - int64(float64(limits.Memory)*reservedPercent) - reserved.Memory + } + + return limits, nil +} diff --git a/api/kubernetes/cli/nodes_limits_test.go b/api/kubernetes/cli/nodes_limits_test.go new file mode 100644 index 0000000..044d1af --- /dev/null +++ b/api/kubernetes/cli/nodes_limits_test.go @@ -0,0 +1,140 @@ +package cli + +import ( + "reflect" + "testing" + + portainer "github.com/portainer/portainer/api" + + v1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + kfake "k8s.io/client-go/kubernetes/fake" +) + +func newNodes() *v1.NodeList { + return &v1.NodeList{ + Items: []v1.Node{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "test-node-0", + }, + Status: v1.NodeStatus{ + Allocatable: v1.ResourceList{ + v1.ResourceCPU: resource.MustParse("2"), + v1.ResourceMemory: resource.MustParse("4M"), + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "test-node-1", + }, + Status: v1.NodeStatus{ + Allocatable: v1.ResourceList{ + v1.ResourceCPU: resource.MustParse("3"), + v1.ResourceMemory: resource.MustParse("6M"), + }, + }, + }, + }, + } +} + +func newPods() *v1.PodList { + return &v1.PodList{ + Items: []v1.Pod{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "test-container-0", + Namespace: "test-namespace-0", + }, + Spec: v1.PodSpec{ + NodeName: "test-node-0", + Containers: []v1.Container{ + { + Name: "test-container-0", + Resources: v1.ResourceRequirements{ + Requests: v1.ResourceList{ + v1.ResourceCPU: resource.MustParse("1"), + v1.ResourceMemory: resource.MustParse("2M"), + }, + }, + }, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "test-container-1", + Namespace: "test-namespace-1", + }, + Spec: v1.PodSpec{ + NodeName: "test-node-1", + Containers: []v1.Container{ + { + Name: "test-container-1", + Resources: v1.ResourceRequirements{ + Requests: v1.ResourceList{ + v1.ResourceCPU: resource.MustParse("2"), + v1.ResourceMemory: resource.MustParse("3M"), + }, + }, + }, + }, + }, + }, + }, + } +} + +func TestKubeClient_GetNodesLimits(t *testing.T) { + t.Parallel() + type fields struct { + cli kubernetes.Interface + } + + fieldsInstance := fields{ + cli: kfake.NewSimpleClientset(newNodes(), newPods()), + } + + tests := []struct { + name string + fields fields + want portainer.K8sNodesLimits + wantErr bool + }{ + { + name: "2 nodes 2 pods", + fields: fieldsInstance, + want: portainer.K8sNodesLimits{ + "test-node-0": &portainer.K8sNodeLimits{ + CPU: 1000, + Memory: 2000000, + }, + "test-node-1": &portainer.K8sNodeLimits{ + CPU: 1000, + Memory: 3000000, + }, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + kcl := &KubeClient{ + cli: tt.fields.cli, + } + got, err := kcl.GetNodesLimits() + if (err != nil) != tt.wantErr { + t.Errorf("GetNodesLimits() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("GetNodesLimits() got = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/api/kubernetes/cli/nodes_test.go b/api/kubernetes/cli/nodes_test.go new file mode 100644 index 0000000..e2df3c3 --- /dev/null +++ b/api/kubernetes/cli/nodes_test.go @@ -0,0 +1,64 @@ +package cli + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + kfake "k8s.io/client-go/kubernetes/fake" +) + +func TestGetClusterNodes_ReturnsAllNodes(t *testing.T) { + t.Parallel() + + nodeList := &corev1.NodeList{ + Items: []corev1.Node{ + {ObjectMeta: metav1.ObjectMeta{Name: "node-0"}}, + {ObjectMeta: metav1.ObjectMeta{Name: "node-1"}}, + }, + } + + kcl := &KubeClient{cli: kfake.NewSimpleClientset(nodeList)} + + got, err := kcl.GetClusterNodes() + require.NoError(t, err) + assert.Len(t, got, 2) + assert.Equal(t, "node-0", got[0].Name) + assert.Equal(t, "node-1", got[1].Name) +} + +func TestGetClusterNodes_EmptyCluster(t *testing.T) { + t.Parallel() + + kcl := &KubeClient{cli: kfake.NewSimpleClientset()} + + got, err := kcl.GetClusterNodes() + require.NoError(t, err) + assert.Empty(t, got) +} + +func TestGetClusterNodes_StripsManagedFields(t *testing.T) { + t.Parallel() + + nodeList := &corev1.NodeList{ + Items: []corev1.Node{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "node-0", + ManagedFields: []metav1.ManagedFieldsEntry{ + {Manager: "kubectl"}, + }, + }, + }, + }, + } + + kcl := &KubeClient{cli: kfake.NewSimpleClientset(nodeList)} + + got, err := kcl.GetClusterNodes() + require.NoError(t, err) + require.Len(t, got, 1) + assert.Nil(t, got[0].ManagedFields) +} diff --git a/api/kubernetes/cli/persistent_volume_claims.go b/api/kubernetes/cli/persistent_volume_claims.go new file mode 100644 index 0000000..981a74d --- /dev/null +++ b/api/kubernetes/cli/persistent_volume_claims.go @@ -0,0 +1,255 @@ +package cli + +import ( + "context" + "errors" + "fmt" + + models "github.com/portainer/portainer/api/http/models/kubernetes" + "github.com/rs/zerolog/log" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" +) + +// GetPersistentVolumeClaims returns all PVCs in the given namespace (or all namespaces if empty). +// For non-admin users, results are filtered to their accessible namespaces. +func (kcl *KubeClient) GetPersistentVolumeClaims(namespace string) ([]models.K8sPersistentVolumeClaim, error) { + if kcl.GetIsKubeAdmin() { + return kcl.fetchPersistentVolumeClaims(namespace) + } + + return kcl.fetchPersistentVolumeClaimsForNonAdmin(namespace) +} + +// GetPersistentVolumeClaim returns a single PVC by namespace and name. +func (kcl *KubeClient) GetPersistentVolumeClaim(namespace, name string) (*models.K8sPersistentVolumeClaim, error) { + if !kcl.GetIsKubeAdmin() { + allowedNamespaces := kcl.buildNonAdminNamespacesMap() + if _, ok := allowedNamespaces[namespace]; !ok { + return nil, fmt.Errorf("access to namespace %s is not authorized: %w", namespace, ErrUnauthorized) + } + } + + pvc, err := kcl.cli.CoreV1().PersistentVolumeClaims(namespace).Get(context.Background(), name, metav1.GetOptions{}) + if err != nil { + return nil, fmt.Errorf("unable to get persistent volume claim %s/%s. Error: %w", namespace, name, err) + } + + result := parsePersistentVolumeClaimDetail(pvc) + return &result, nil +} + +// DeletePersistentVolumeClaims deletes the specified PVCs. +func (kcl *KubeClient) DeletePersistentVolumeClaims(reqs models.K8sVolumeDeleteRequests) error { + for _, req := range reqs { + log.Debug(). + Str("context", "DeletePersistentVolumeClaims"). + Str("namespace", req.Namespace). + Str("name", req.Name). + Msg("Deleting persistent volume claim") + + err := kcl.cli.CoreV1().PersistentVolumeClaims(req.Namespace).Delete(context.Background(), req.Name, metav1.DeleteOptions{}) + if err != nil { + return fmt.Errorf("unable to delete persistent volume claim %s/%s. Error: %w", req.Namespace, req.Name, err) + } + } + + return nil +} + +// ResizePersistentVolumeClaim resizes a PVC to the given new size. +// The storage class must have AllowVolumeExpansion set to true. +func (kcl *KubeClient) ResizePersistentVolumeClaim(namespace, name, newSize string) error { + pvc, err := kcl.cli.CoreV1().PersistentVolumeClaims(namespace).Get(context.Background(), name, metav1.GetOptions{}) + if err != nil { + return fmt.Errorf("unable to get persistent volume claim %s/%s. Error: %w", namespace, name, err) + } + + if pvc.Spec.StorageClassName != nil { + sc, err := kcl.GetStorageClass(*pvc.Spec.StorageClassName) + if err != nil { + return fmt.Errorf("unable to get storage class %s. Error: %w", *pvc.Spec.StorageClassName, err) + } + + if sc.AllowVolumeExpansion == nil || !*sc.AllowVolumeExpansion { + return errors.New("storage class " + sc.Name + " does not allow volume expansion") + } + } + + _, err = resource.ParseQuantity(newSize) + if err != nil { + return fmt.Errorf("invalid size format %q. Error: %w", newSize, err) + } + + patch := fmt.Sprintf(`{"spec":{"resources":{"requests":{"storage":"%s"}}}}`, newSize) + + _, err = kcl.cli.CoreV1().PersistentVolumeClaims(namespace).Patch( + context.Background(), + name, + types.MergePatchType, + []byte(patch), + metav1.PatchOptions{}, + ) + if err != nil { + return fmt.Errorf("unable to resize persistent volume claim %s/%s. Error: %w", namespace, name, err) + } + + return nil +} + +func (kcl *KubeClient) fetchPersistentVolumeClaims(namespace string) ([]models.K8sPersistentVolumeClaim, error) { + pvcList, err := kcl.cli.CoreV1().PersistentVolumeClaims(namespace).List(context.Background(), metav1.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("unable to list persistent volume claims. Error: %w", err) + } + + storageClasses, err := kcl.GetStorageClasses() + if err != nil { + return nil, fmt.Errorf("unable to list storage classes for volume expansion lookup. Error: %w", err) + } + + scExpansionMap := make(map[string]bool, len(storageClasses)) + for _, sc := range storageClasses { + if sc.AllowVolumeExpansion != nil { + scExpansionMap[sc.Name] = *sc.AllowVolumeExpansion + } + } + + results := make([]models.K8sPersistentVolumeClaim, 0, len(pvcList.Items)) + for i := range pvcList.Items { + pvc := parsePersistentVolumeClaimDetail(&pvcList.Items[i]) + if pvc.StorageClass != nil { + pvc.AllowVolumeExpansion = scExpansionMap[*pvc.StorageClass] + } + results = append(results, pvc) + } + + return results, nil +} + +func (kcl *KubeClient) fetchPersistentVolumeClaimsForNonAdmin(namespace string) ([]models.K8sPersistentVolumeClaim, error) { + pvcs, err := kcl.fetchPersistentVolumeClaims(namespace) + if err != nil { + return nil, err + } + + nonAdminNamespaceSet := kcl.buildNonAdminNamespacesMap() + results := make([]models.K8sPersistentVolumeClaim, 0) + for _, pvc := range pvcs { + if _, ok := nonAdminNamespaceSet[pvc.Namespace]; ok { + results = append(results, pvc) + } + } + + return results, nil +} + +// parsePersistentVolumeClaimDetail parses a PVC into the model with access modes and storage request string. +func parsePersistentVolumeClaimDetail(pvc *corev1.PersistentVolumeClaim) models.K8sPersistentVolumeClaim { + storage := pvc.Spec.Resources.Requests[corev1.ResourceStorage] + return models.K8sPersistentVolumeClaim{ + ID: string(pvc.UID), + Name: pvc.Name, + Namespace: pvc.Namespace, + CreationDate: pvc.CreationTimestamp.Time, + Storage: storage.Value(), + StorageRequest: storage.String(), + AccessModes: humanReadableAccessModes(pvc.Spec.AccessModes), + HumanReadableAccessModes: pvc.Spec.AccessModes, + VolumeName: pvc.Spec.VolumeName, + ResourcesRequests: &pvc.Spec.Resources.Requests, + StorageClass: pvc.Spec.StorageClassName, + VolumeMode: pvc.Spec.VolumeMode, + OwningApplications: nil, + Phase: pvc.Status.Phase, + Labels: pvc.Labels, + } +} + +// CombineClaimsWithApplications enriches each PVC with the workloads that mount it. +func (kcl *KubeClient) CombineClaimsWithApplications(pvcs []models.K8sPersistentVolumeClaim) ([]models.K8sPersistentVolumeClaim, error) { + pods, err := kcl.cli.CoreV1().Pods("").List(context.Background(), metav1.ListOptions{}) + if err != nil { + if k8serrors.IsNotFound(err) { + return pvcs, nil + } + return nil, fmt.Errorf("failed to list pods: %w", err) + } + + replicaSetItems := make([]appsv1.ReplicaSet, 0) + deploymentItems := make([]appsv1.Deployment, 0) + if containsReplicaSetOwnerReference(pods) { + replicaSets, err := kcl.cli.AppsV1().ReplicaSets("").List(context.Background(), metav1.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to list replica sets: %w", err) + } + replicaSetItems = replicaSets.Items + + deployments, err := kcl.cli.AppsV1().Deployments("").List(context.Background(), metav1.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to list deployments: %w", err) + } + deploymentItems = deployments.Items + } + + statefulSetItems := make([]appsv1.StatefulSet, 0) + if containsStatefulSetOwnerReference(pods) { + statefulSets, err := kcl.cli.AppsV1().StatefulSets("").List(context.Background(), metav1.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to list stateful sets: %w", err) + } + statefulSetItems = statefulSets.Items + } + + daemonSetItems := make([]appsv1.DaemonSet, 0) + if containsDaemonSetOwnerReference(pods) { + daemonSets, err := kcl.cli.AppsV1().DaemonSets("").List(context.Background(), metav1.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to list daemon sets: %w", err) + } + daemonSetItems = daemonSets.Items + } + + resources := PortainerApplicationResources{ + ReplicaSets: replicaSetItems, + Deployments: deploymentItems, + StatefulSets: statefulSetItems, + DaemonSets: daemonSetItems, + } + + for i := range pvcs { + for _, pod := range pods.Items { + for _, podVolume := range pod.Spec.Volumes { + if podVolume.PersistentVolumeClaim == nil { + continue + } + if podVolume.PersistentVolumeClaim.ClaimName != pvcs[i].Name || pod.Namespace != pvcs[i].Namespace { + continue + } + application, err := kcl.ConvertPodToApplication(pod, resources, false) + if err != nil { + return nil, fmt.Errorf("failed to convert pod to application: %w", err) + } + if application == nil { + continue + } + alreadyAdded := false + for _, existing := range pvcs[i].OwningApplications { + if existing.Name == application.Name && existing.Namespace == application.Namespace { + alreadyAdded = true + break + } + } + if !alreadyAdded { + pvcs[i].OwningApplications = append(pvcs[i].OwningApplications, *application) + } + } + } + } + + return pvcs, nil +} diff --git a/api/kubernetes/cli/persistent_volume_claims_test.go b/api/kubernetes/cli/persistent_volume_claims_test.go new file mode 100644 index 0000000..7ad4175 --- /dev/null +++ b/api/kubernetes/cli/persistent_volume_claims_test.go @@ -0,0 +1,378 @@ +package cli + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + storagev1 "k8s.io/api/storage/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + kfake "k8s.io/client-go/kubernetes/fake" +) + +func makeStorageClass(name string, allowExpansion bool) *storagev1.StorageClass { + return &storagev1.StorageClass{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + Provisioner: "kubernetes.io/no-provisioner", + AllowVolumeExpansion: &allowExpansion, + } +} + +func makePVC(namespace, name, scName string) *corev1.PersistentVolumeClaim { + storageRequest := resource.MustParse("1Gi") + pvc := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: corev1.PersistentVolumeClaimSpec{ + Resources: corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceStorage: storageRequest, + }, + }, + }, + } + if scName != "" { + pvc.Spec.StorageClassName = &scName + } + return pvc +} + +func TestResizePersistentVolumeClaim(t *testing.T) { + t.Parallel() + + t.Run("returns error when storage class does not allow expansion", func(t *testing.T) { + t.Parallel() + k := NewTestKubeClient(kfake.NewClientset()) + + sc := makeStorageClass("no-expand", false) + _, err := k.cli.StorageV1().StorageClasses().Create(t.Context(), sc, metav1.CreateOptions{}) + require.NoError(t, err) + + pvc := makePVC("default", "pvc-no-expand", "no-expand") + _, err = k.cli.CoreV1().PersistentVolumeClaims("default").Create(t.Context(), pvc, metav1.CreateOptions{}) + require.NoError(t, err) + + err = k.ResizePersistentVolumeClaim("default", "pvc-no-expand", "2Gi") + require.Error(t, err) + assert.Contains(t, err.Error(), "does not allow volume expansion") + }) + + t.Run("returns error for invalid size format", func(t *testing.T) { + t.Parallel() + k := NewTestKubeClient(kfake.NewClientset()) + + sc := makeStorageClass("expandable", true) + _, err := k.cli.StorageV1().StorageClasses().Create(t.Context(), sc, metav1.CreateOptions{}) + require.NoError(t, err) + + pvc := makePVC("default", "pvc-badsize", "expandable") + _, err = k.cli.CoreV1().PersistentVolumeClaims("default").Create(t.Context(), pvc, metav1.CreateOptions{}) + require.NoError(t, err) + + err = k.ResizePersistentVolumeClaim("default", "pvc-badsize", "notasize") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid size format") + }) + + t.Run("success when storage class allows expansion with valid size", func(t *testing.T) { + t.Parallel() + k := NewTestKubeClient(kfake.NewClientset()) + + sc := makeStorageClass("expandable", true) + _, err := k.cli.StorageV1().StorageClasses().Create(t.Context(), sc, metav1.CreateOptions{}) + require.NoError(t, err) + + pvc := makePVC("default", "pvc-resize", "expandable") + _, err = k.cli.CoreV1().PersistentVolumeClaims("default").Create(t.Context(), pvc, metav1.CreateOptions{}) + require.NoError(t, err) + + err = k.ResizePersistentVolumeClaim("default", "pvc-resize", "5Gi") + require.NoError(t, err) + }) + + t.Run("success when PVC has no storage class", func(t *testing.T) { + t.Parallel() + k := NewTestKubeClient(kfake.NewClientset()) + + pvc := makePVC("default", "pvc-no-sc", "") + _, err := k.cli.CoreV1().PersistentVolumeClaims("default").Create(t.Context(), pvc, metav1.CreateOptions{}) + require.NoError(t, err) + + err = k.ResizePersistentVolumeClaim("default", "pvc-no-sc", "10Gi") + require.NoError(t, err) + }) + + t.Run("returns error for non-existent PVC", func(t *testing.T) { + t.Parallel() + k := NewTestKubeClient(kfake.NewClientset()) + + err := k.ResizePersistentVolumeClaim("default", "does-not-exist", "2Gi") + require.Error(t, err) + }) +} + +func TestGetPersistentVolumeClaims_EnrichesExpansionFlag(t *testing.T) { + t.Parallel() + + t.Run("PVC with AllowVolumeExpansion=true SC gets flag true", func(t *testing.T) { + t.Parallel() + k := NewTestKubeClient(kfake.NewClientset()) + + sc := makeStorageClass("expandable", true) + _, err := k.cli.StorageV1().StorageClasses().Create(t.Context(), sc, metav1.CreateOptions{}) + require.NoError(t, err) + + pvc := makePVC("default", "pvc-expand", "expandable") + _, err = k.cli.CoreV1().PersistentVolumeClaims("default").Create(t.Context(), pvc, metav1.CreateOptions{}) + require.NoError(t, err) + + pvcs, err := k.GetPersistentVolumeClaims("default") + require.NoError(t, err) + require.Len(t, pvcs, 1) + assert.True(t, pvcs[0].AllowVolumeExpansion) + }) + + t.Run("PVC with AllowVolumeExpansion=false SC gets flag false", func(t *testing.T) { + t.Parallel() + k := NewTestKubeClient(kfake.NewClientset()) + + sc := makeStorageClass("not-expandable", false) + _, err := k.cli.StorageV1().StorageClasses().Create(t.Context(), sc, metav1.CreateOptions{}) + require.NoError(t, err) + + pvc := makePVC("default", "pvc-no-expand", "not-expandable") + _, err = k.cli.CoreV1().PersistentVolumeClaims("default").Create(t.Context(), pvc, metav1.CreateOptions{}) + require.NoError(t, err) + + pvcs, err := k.GetPersistentVolumeClaims("default") + require.NoError(t, err) + require.Len(t, pvcs, 1) + assert.False(t, pvcs[0].AllowVolumeExpansion) + }) +} + +func makePod(namespace, name, pvcName string) *corev1.Pod { + return &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}, + Spec: corev1.PodSpec{ + Volumes: []corev1.Volume{ + { + Name: "data", + VolumeSource: corev1.VolumeSource{ + PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ + ClaimName: pvcName, + }, + }, + }, + }, + Containers: []corev1.Container{{Name: "app", Image: "nginx"}}, + }, + } +} + +func makePodWithDeploymentOwner(namespace, name, pvcName, rsName string) *corev1.Pod { + pod := makePod(namespace, name, pvcName) + pod.OwnerReferences = []metav1.OwnerReference{ + {Kind: "ReplicaSet", Name: rsName, APIVersion: "apps/v1"}, + } + return pod +} + +func makeReplicaSet(namespace, name, deploymentName string) *appsv1.ReplicaSet { + return &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + OwnerReferences: []metav1.OwnerReference{ + {Kind: "Deployment", Name: deploymentName, APIVersion: "apps/v1"}, + }, + }, + } +} + +func makeDeployment(namespace, name string) *appsv1.Deployment { + return &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}, + Spec: appsv1.DeploymentSpec{ + Selector: &metav1.LabelSelector{}, + }, + } +} + +func TestCombineClaimsWithApplications(t *testing.T) { + t.Parallel() + + t.Run("no pods leaves owningApplications empty", func(t *testing.T) { + t.Parallel() + k := NewTestKubeClient(kfake.NewClientset()) + + pvc := makePVC("default", "my-pvc", "") + _, err := k.cli.CoreV1().PersistentVolumeClaims("default").Create(t.Context(), pvc, metav1.CreateOptions{}) + require.NoError(t, err) + + pvcs, err := k.GetPersistentVolumeClaims("default") + require.NoError(t, err) + + result, err := k.CombineClaimsWithApplications(pvcs) + require.NoError(t, err) + require.Len(t, result, 1) + assert.Empty(t, result[0].OwningApplications) + }) + + t.Run("pod with no PVC volumes leaves owningApplications empty", func(t *testing.T) { + t.Parallel() + k := NewTestKubeClient(kfake.NewClientset()) + + pvc := makePVC("default", "my-pvc", "") + _, err := k.cli.CoreV1().PersistentVolumeClaims("default").Create(t.Context(), pvc, metav1.CreateOptions{}) + require.NoError(t, err) + + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "app", Namespace: "default"}, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "app", Image: "nginx"}}, + }, + } + _, err = k.cli.CoreV1().Pods("default").Create(t.Context(), pod, metav1.CreateOptions{}) + require.NoError(t, err) + + pvcs, err := k.GetPersistentVolumeClaims("default") + require.NoError(t, err) + + result, err := k.CombineClaimsWithApplications(pvcs) + require.NoError(t, err) + assert.Empty(t, result[0].OwningApplications) + }) + + t.Run("pod mounting PVC populates owningApplications", func(t *testing.T) { + t.Parallel() + k := NewTestKubeClient(kfake.NewClientset()) + + pvc := makePVC("default", "my-pvc", "") + _, err := k.cli.CoreV1().PersistentVolumeClaims("default").Create(t.Context(), pvc, metav1.CreateOptions{}) + require.NoError(t, err) + + pod := makePod("default", "my-pod", "my-pvc") + _, err = k.cli.CoreV1().Pods("default").Create(t.Context(), pod, metav1.CreateOptions{}) + require.NoError(t, err) + + pvcs, err := k.GetPersistentVolumeClaims("default") + require.NoError(t, err) + + result, err := k.CombineClaimsWithApplications(pvcs) + require.NoError(t, err) + require.Len(t, result[0].OwningApplications, 1) + assert.Equal(t, "my-pod", result[0].OwningApplications[0].Name) + assert.Equal(t, "Pod", result[0].OwningApplications[0].ApplicationType) + assert.Equal(t, "default", result[0].OwningApplications[0].ResourcePool) + }) + + t.Run("pod in different namespace does not match PVC", func(t *testing.T) { + t.Parallel() + k := NewTestKubeClient(kfake.NewClientset()) + + pvc := makePVC("default", "my-pvc", "") + _, err := k.cli.CoreV1().PersistentVolumeClaims("default").Create(t.Context(), pvc, metav1.CreateOptions{}) + require.NoError(t, err) + + pod := makePod("other-ns", "other-pod", "my-pvc") + _, err = k.cli.CoreV1().Pods("other-ns").Create(t.Context(), pod, metav1.CreateOptions{}) + require.NoError(t, err) + + pvcs, err := k.GetPersistentVolumeClaims("default") + require.NoError(t, err) + + result, err := k.CombineClaimsWithApplications(pvcs) + require.NoError(t, err) + assert.Empty(t, result[0].OwningApplications) + }) + + t.Run("two pods mounting the same PVC from the same Deployment are deduplicated", func(t *testing.T) { + t.Parallel() + k := NewTestKubeClient(kfake.NewClientset()) + + pvc := makePVC("default", "shared-pvc", "") + _, err := k.cli.CoreV1().PersistentVolumeClaims("default").Create(t.Context(), pvc, metav1.CreateOptions{}) + require.NoError(t, err) + + deploy := makeDeployment("default", "my-deploy") + _, err = k.cli.AppsV1().Deployments("default").Create(t.Context(), deploy, metav1.CreateOptions{}) + require.NoError(t, err) + + rs := makeReplicaSet("default", "my-deploy-rs", "my-deploy") + _, err = k.cli.AppsV1().ReplicaSets("default").Create(t.Context(), rs, metav1.CreateOptions{}) + require.NoError(t, err) + + pod1 := makePodWithDeploymentOwner("default", "my-deploy-pod-1", "shared-pvc", "my-deploy-rs") + pod2 := makePodWithDeploymentOwner("default", "my-deploy-pod-2", "shared-pvc", "my-deploy-rs") + _, err = k.cli.CoreV1().Pods("default").Create(t.Context(), pod1, metav1.CreateOptions{}) + require.NoError(t, err) + _, err = k.cli.CoreV1().Pods("default").Create(t.Context(), pod2, metav1.CreateOptions{}) + require.NoError(t, err) + + pvcs, err := k.GetPersistentVolumeClaims("default") + require.NoError(t, err) + + result, err := k.CombineClaimsWithApplications(pvcs) + require.NoError(t, err) + require.Len(t, result[0].OwningApplications, 1) + assert.Equal(t, "my-deploy", result[0].OwningApplications[0].Name) + assert.Equal(t, "Deployment", result[0].OwningApplications[0].ApplicationType) + }) + + t.Run("pod owned by Deployment resolves application name to Deployment", func(t *testing.T) { + t.Parallel() + k := NewTestKubeClient(kfake.NewClientset()) + + pvc := makePVC("default", "app-pvc", "") + _, err := k.cli.CoreV1().PersistentVolumeClaims("default").Create(t.Context(), pvc, metav1.CreateOptions{}) + require.NoError(t, err) + + deploy := makeDeployment("default", "web-app") + _, err = k.cli.AppsV1().Deployments("default").Create(t.Context(), deploy, metav1.CreateOptions{}) + require.NoError(t, err) + + rs := makeReplicaSet("default", "web-app-rs-abc", "web-app") + _, err = k.cli.AppsV1().ReplicaSets("default").Create(t.Context(), rs, metav1.CreateOptions{}) + require.NoError(t, err) + + pod := makePodWithDeploymentOwner("default", "web-app-pod-xyz", "app-pvc", "web-app-rs-abc") + _, err = k.cli.CoreV1().Pods("default").Create(t.Context(), pod, metav1.CreateOptions{}) + require.NoError(t, err) + + pvcs, err := k.GetPersistentVolumeClaims("default") + require.NoError(t, err) + + result, err := k.CombineClaimsWithApplications(pvcs) + require.NoError(t, err) + require.Len(t, result[0].OwningApplications, 1) + assert.Equal(t, "web-app", result[0].OwningApplications[0].Name) + assert.Equal(t, "Deployment", result[0].OwningApplications[0].ApplicationType) + assert.Equal(t, "default", result[0].OwningApplications[0].ResourcePool) + }) + + t.Run("unrelated pod mounting a different PVC does not affect result", func(t *testing.T) { + t.Parallel() + k := NewTestKubeClient(kfake.NewClientset()) + + pvc := makePVC("default", "target-pvc", "") + _, err := k.cli.CoreV1().PersistentVolumeClaims("default").Create(t.Context(), pvc, metav1.CreateOptions{}) + require.NoError(t, err) + + pod := makePod("default", "unrelated-pod", "other-pvc") + _, err = k.cli.CoreV1().Pods("default").Create(t.Context(), pod, metav1.CreateOptions{}) + require.NoError(t, err) + + pvcs, err := k.GetPersistentVolumeClaims("default") + require.NoError(t, err) + + result, err := k.CombineClaimsWithApplications(pvcs) + require.NoError(t, err) + assert.Empty(t, result[0].OwningApplications) + }) +} diff --git a/api/kubernetes/cli/persistent_volumes.go b/api/kubernetes/cli/persistent_volumes.go new file mode 100644 index 0000000..0f8d9c5 --- /dev/null +++ b/api/kubernetes/cli/persistent_volumes.go @@ -0,0 +1,156 @@ +package cli + +import ( + "context" + "fmt" + + "github.com/segmentio/encoding/json" + + models "github.com/portainer/portainer/api/http/models/kubernetes" + "github.com/rs/zerolog/log" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" +) + +// GetPersistentVolumes returns all PersistentVolumes in the cluster. +// For non-admin users, results are filtered to PVs bound to their accessible namespaces. +func (kcl *KubeClient) GetPersistentVolumes() ([]models.K8sPersistentVolume, error) { + pvList, err := kcl.cli.CoreV1().PersistentVolumes().List(context.Background(), metav1.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("unable to list persistent volumes. Error: %w", err) + } + + results := make([]models.K8sPersistentVolume, 0) + if kcl.GetIsKubeAdmin() { + for i := range pvList.Items { + results = append(results, parsePersistentVolumeDetail(&pvList.Items[i])) + } + return results, nil + } + + allowedNamespaces := kcl.buildNonAdminNamespacesMap() + for i := range pvList.Items { + pv := &pvList.Items[i] + if pv.Spec.ClaimRef != nil { + if _, ok := allowedNamespaces[pv.Spec.ClaimRef.Namespace]; ok { + results = append(results, parsePersistentVolumeDetail(pv)) + } + } + } + return results, nil +} + +// GetPersistentVolume returns a single PersistentVolume by name. +// For non-admin users, access is restricted to PVs bound to their accessible namespaces. +func (kcl *KubeClient) GetPersistentVolume(name string) (*models.K8sPersistentVolume, error) { + pv, err := kcl.cli.CoreV1().PersistentVolumes().Get(context.Background(), name, metav1.GetOptions{}) + if err != nil { + return nil, fmt.Errorf("unable to get persistent volume %s. Error: %w", name, err) + } + + if !kcl.GetIsKubeAdmin() { + allowedNamespaces := kcl.buildNonAdminNamespacesMap() + if pv.Spec.ClaimRef == nil { + return nil, fmt.Errorf("access to unbound persistent volume %s is not authorized: %w", name, ErrUnauthorized) + } + if _, ok := allowedNamespaces[pv.Spec.ClaimRef.Namespace]; !ok { + return nil, fmt.Errorf("access to persistent volume %s is not authorized: %w", name, ErrUnauthorized) + } + } + + result := parsePersistentVolumeDetail(pv) + return &result, nil +} + +// DeletePersistentVolumes deletes the specified PersistentVolumes by name. +func (kcl *KubeClient) DeletePersistentVolumes(names []string) error { + for _, name := range names { + log.Debug(). + Str("context", "DeletePersistentVolumes"). + Str("persistent_volume", name). + Msg("Deleting persistent volume") + + err := kcl.cli.CoreV1().PersistentVolumes().Delete(context.Background(), name, metav1.DeleteOptions{}) + if err != nil { + return fmt.Errorf("unable to delete persistent volume %s. Error: %w", name, err) + } + } + + return nil +} + +// UpdatePersistentVolumeReclaimPolicy updates the reclaim policy on a PV. +func (kcl *KubeClient) UpdatePersistentVolumeReclaimPolicy(name string, policy corev1.PersistentVolumeReclaimPolicy) error { + switch policy { + case corev1.PersistentVolumeReclaimRetain, + corev1.PersistentVolumeReclaimDelete, + corev1.PersistentVolumeReclaimRecycle: + // valid + default: + return fmt.Errorf("invalid reclaim policy %q: must be Retain, Delete, or Recycle", policy) + } + + patch := map[string]any{ + "spec": map[string]any{ + "persistentVolumeReclaimPolicy": policy, + }, + } + patchBytes, err := json.Marshal(patch) + if err != nil { + return fmt.Errorf("unable to marshal reclaim policy patch. Error: %w", err) + } + + _, err = kcl.cli.CoreV1().PersistentVolumes().Patch( + context.Background(), + name, + types.MergePatchType, + patchBytes, + metav1.PatchOptions{}, + ) + if err != nil { + return fmt.Errorf("unable to update reclaim policy for persistent volume %s. Error: %w", name, err) + } + + return nil +} + +// parsePersistentVolumeDetail parses a full PV into the model with status and access modes. +func parsePersistentVolumeDetail(pv *corev1.PersistentVolume) models.K8sPersistentVolume { + return models.K8sPersistentVolume{ + Name: pv.Name, + Annotations: pv.Annotations, + Labels: pv.Labels, + AccessModes: humanReadableAccessModes(pv.Spec.AccessModes), + HumanReadableAccessModes: pv.Spec.AccessModes, + Capacity: pv.Spec.Capacity, + ClaimRef: pv.Spec.ClaimRef, + StorageClassName: pv.Spec.StorageClassName, + PersistentVolumeReclaimPolicy: pv.Spec.PersistentVolumeReclaimPolicy, + VolumeMode: pv.Spec.VolumeMode, + CSI: pv.Spec.CSI, + Status: pv.Status.Phase, + CreationDate: pv.CreationTimestamp.Time, + } +} + +// humanReadableAccessModes converts Kubernetes access modes to short abbreviations. +func humanReadableAccessModes(modes []corev1.PersistentVolumeAccessMode) []string { + readable := make([]string, 0, len(modes)) + for _, mode := range modes { + switch mode { + case corev1.ReadWriteOnce: + readable = append(readable, "RWO") + case corev1.ReadOnlyMany: + readable = append(readable, "ROX") + case corev1.ReadWriteMany: + readable = append(readable, "RWX") + case corev1.ReadWriteOncePod: + readable = append(readable, "RWOP") + default: + readable = append(readable, string(mode)) + } + } + + return readable +} diff --git a/api/kubernetes/cli/persistent_volumes_test.go b/api/kubernetes/cli/persistent_volumes_test.go new file mode 100644 index 0000000..49c951f --- /dev/null +++ b/api/kubernetes/cli/persistent_volumes_test.go @@ -0,0 +1,166 @@ +package cli + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + kfake "k8s.io/client-go/kubernetes/fake" +) + +func TestHumanReadableAccessModes(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input []corev1.PersistentVolumeAccessMode + want []string + }{ + { + name: "ReadWriteOnce", + input: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, + want: []string{"RWO"}, + }, + { + name: "ReadOnlyMany", + input: []corev1.PersistentVolumeAccessMode{corev1.ReadOnlyMany}, + want: []string{"ROX"}, + }, + { + name: "ReadWriteMany", + input: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteMany}, + want: []string{"RWX"}, + }, + { + name: "ReadWriteOncePod", + input: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOncePod}, + want: []string{"RWOP"}, + }, + { + name: "unknown mode passes through", + input: []corev1.PersistentVolumeAccessMode{"SomeUnknownMode"}, + want: []string{"SomeUnknownMode"}, + }, + { + name: "empty slice returns empty slice", + input: []corev1.PersistentVolumeAccessMode{}, + want: []string{}, + }, + { + name: "multiple modes in order", + input: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce, corev1.ReadOnlyMany, corev1.ReadWriteMany, corev1.ReadWriteOncePod}, + want: []string{"RWO", "ROX", "RWX", "RWOP"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := humanReadableAccessModes(tt.input) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestGetPersistentVolumes(t *testing.T) { + t.Parallel() + + t.Run("empty cluster returns empty slice", func(t *testing.T) { + t.Parallel() + k := NewTestKubeClient(kfake.NewClientset()) + pvs, err := k.GetPersistentVolumes() + require.NoError(t, err) + assert.Empty(t, pvs) + }) + + t.Run("returns correctly parsed PVs", func(t *testing.T) { + t.Parallel() + k := NewTestKubeClient(kfake.NewClientset()) + + pv := &corev1.PersistentVolume{ + ObjectMeta: metav1.ObjectMeta{ + Name: "pv-one", + }, + Spec: corev1.PersistentVolumeSpec{ + StorageClassName: "standard", + AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, + }, + Status: corev1.PersistentVolumeStatus{ + Phase: corev1.VolumeBound, + }, + } + + _, err := k.cli.CoreV1().PersistentVolumes().Create(t.Context(), pv, metav1.CreateOptions{}) + require.NoError(t, err) + + pvs, err := k.GetPersistentVolumes() + require.NoError(t, err) + require.Len(t, pvs, 1) + + got := pvs[0] + assert.Equal(t, "pv-one", got.Name) + assert.Equal(t, "standard", got.StorageClassName) + assert.Equal(t, corev1.VolumeBound, got.Status) + assert.Equal(t, []string{"RWO"}, got.AccessModes) + }) +} + +func TestDeletePersistentVolumes(t *testing.T) { + t.Parallel() + + t.Run("successfully deletes specified PVs", func(t *testing.T) { + t.Parallel() + k := NewTestKubeClient(kfake.NewClientset()) + + for _, name := range []string{"pv-a", "pv-b", "pv-c"} { + pv := &corev1.PersistentVolume{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + } + _, err := k.cli.CoreV1().PersistentVolumes().Create(t.Context(), pv, metav1.CreateOptions{}) + require.NoError(t, err) + } + + err := k.DeletePersistentVolumes([]string{"pv-a", "pv-c"}) + require.NoError(t, err) + + pvs, err := k.GetPersistentVolumes() + require.NoError(t, err) + require.Len(t, pvs, 1) + assert.Equal(t, "pv-b", pvs[0].Name) + }) + + t.Run("returns error for non-existent PV", func(t *testing.T) { + t.Parallel() + k := NewTestKubeClient(kfake.NewClientset()) + + err := k.DeletePersistentVolumes([]string{"does-not-exist"}) + assert.Error(t, err) + }) +} + +func TestUpdatePersistentVolumeReclaimPolicy(t *testing.T) { + t.Parallel() + + t.Run("successfully patches reclaim policy on existing PV", func(t *testing.T) { + t.Parallel() + k := NewTestKubeClient(kfake.NewClientset()) + + pv := &corev1.PersistentVolume{ + ObjectMeta: metav1.ObjectMeta{Name: "pv-reclaim"}, + Spec: corev1.PersistentVolumeSpec{ + PersistentVolumeReclaimPolicy: corev1.PersistentVolumeReclaimDelete, + }, + } + _, err := k.cli.CoreV1().PersistentVolumes().Create(t.Context(), pv, metav1.CreateOptions{}) + require.NoError(t, err) + + err = k.UpdatePersistentVolumeReclaimPolicy("pv-reclaim", corev1.PersistentVolumeReclaimRetain) + require.NoError(t, err) + + updated, err := k.GetPersistentVolume("pv-reclaim") + require.NoError(t, err) + assert.Equal(t, corev1.PersistentVolumeReclaimRetain, updated.PersistentVolumeReclaimPolicy) + }) +} diff --git a/api/kubernetes/cli/pod.go b/api/kubernetes/cli/pod.go new file mode 100644 index 0000000..31aec5e --- /dev/null +++ b/api/kubernetes/cli/pod.go @@ -0,0 +1,335 @@ +package cli + +import ( + "context" + "fmt" + "strconv" + "time" + + portainer "github.com/portainer/portainer/api" + models "github.com/portainer/portainer/api/http/models/kubernetes" + + "github.com/pkg/errors" + "github.com/rs/zerolog/log" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func (kcl *KubeClient) GetPods(namespace string) ([]corev1.Pod, error) { + pods, err := kcl.cli.CoreV1().Pods(namespace).List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return nil, err + } + + return pods.Items, nil +} + +// DeletePod deletes a single pod. The owning controller (Deployment, +// StatefulSet, DaemonSet, ...) is responsible for recreating it. For naked +// pods the pod is removed permanently. +func (kcl *KubeClient) DeletePod(namespace, name string) error { + return kcl.cli.CoreV1().Pods(namespace).Delete(context.TODO(), name, metav1.DeleteOptions{}) +} + +// RestartPod restarts all containers inside a pod in place using the +// Kubernetes 1.35 alpha pod-restart subresource. The pod itself is preserved. +// Requires the cluster to expose the corresponding subresource (and feature +// gate). On clusters that don't, the API server typically returns 404 or 405 +// which is surfaced to the caller. +func (kcl *KubeClient) RestartPod(namespace, name string) error { + return kcl.cli.CoreV1().RESTClient().Post(). + Namespace(namespace). + Resource("pods"). + Name(name). + SubResource("restart"). + Do(context.TODO()). + Error() +} + +// isReplicaSetOwner checks if the pod's owner reference is a ReplicaSet +func isReplicaSetOwner(pod corev1.Pod) bool { + return len(pod.OwnerReferences) > 0 && pod.OwnerReferences[0].Kind == "ReplicaSet" +} + +// updateOwnerReferenceToDeployment updates the pod's owner reference to the Deployment if applicable +func updateOwnerReferenceToDeployment(pod *corev1.Pod, replicaSets []appsv1.ReplicaSet) { + for _, replicaSet := range replicaSets { + if pod.OwnerReferences[0].Name == replicaSet.Name { + if len(replicaSet.OwnerReferences) > 0 && replicaSet.OwnerReferences[0].Kind == "Deployment" { + pod.OwnerReferences[0].Kind = "Deployment" + pod.OwnerReferences[0].Name = replicaSet.OwnerReferences[0].Name + } + break + } + } +} + +// containsStatefulSetOwnerReference checks if the pod list contains a pod with a StatefulSet owner reference +func containsStatefulSetOwnerReference(pods *corev1.PodList) bool { + for _, pod := range pods.Items { + if len(pod.OwnerReferences) > 0 && pod.OwnerReferences[0].Kind == "StatefulSet" { + return true + } + } + return false +} + +// containsDaemonSetOwnerReference checks if the pod list contains a pod with a DaemonSet owner reference +func containsDaemonSetOwnerReference(pods *corev1.PodList) bool { + for _, pod := range pods.Items { + if len(pod.OwnerReferences) > 0 && pod.OwnerReferences[0].Kind == "DaemonSet" { + return true + } + } + return false +} + +// containsReplicaSetOwnerReference checks if the pod list contains a pod with a ReplicaSet owner reference +func containsReplicaSetOwnerReference(pods *corev1.PodList) bool { + for _, pod := range pods.Items { + if len(pod.OwnerReferences) > 0 && pod.OwnerReferences[0].Kind == "ReplicaSet" { + return true + } + } + return false +} + +// CreateUserShellPod will create a kubectl based shell for the specified user by mounting their respective service account. +// The lifecycle of the pod is managed in this function; this entails management of the following pod operations: +// - The shell pod will be scoped to specified service accounts access permissions +// - The shell pod will be automatically removed if it's not ready after specified period of time +// - The shell pod will be automatically removed after a specified max life (prevent zombie pods) +// - The shell pod will be automatically removed if request is cancelled (or client closes websocket connection) +func (kcl *KubeClient) CreateUserShellPod(ctx context.Context, serviceAccountName, shellPodImage string) (*portainer.KubernetesShellPod, error) { + maxPodKeepAliveSecondsStr := strconv.Itoa(int(portainer.WebSocketKeepAlive.Seconds())) + + podPrefix := userShellPodPrefix(serviceAccountName) + + podSpec := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: podPrefix, + Namespace: portainerNamespace, + Annotations: map[string]string{ + "kubernetes.io/pod.type": "kubectl-shell", + }, + }, + Spec: corev1.PodSpec{ + TerminationGracePeriodSeconds: new(int64), + ServiceAccountName: serviceAccountName, + Containers: []corev1.Container{ + { + Name: "kubectl-shell-container", + Image: shellPodImage, + Command: []string{"sleep"}, + // Specify sleep time to prevent zombie pods in case portainer process is terminated + Args: []string{maxPodKeepAliveSecondsStr}, + ImagePullPolicy: corev1.PullIfNotPresent, + }, + }, + RestartPolicy: corev1.RestartPolicyNever, + }, + } + + shellPod, err := kcl.cli.CoreV1().Pods(portainerNamespace).Create(context.TODO(), podSpec, metav1.CreateOptions{}) + if err != nil { + return nil, errors.Wrap(err, "error creating shell pod") + } + + // Wait for pod to reach ready state + timeoutCtx, cancelFunc := context.WithTimeout(ctx, 20*time.Second) + defer cancelFunc() + + if err := kcl.waitForPodStatus(timeoutCtx, corev1.PodRunning, shellPod); err != nil { + innerErr := kcl.cli.CoreV1().Pods(portainerNamespace).Delete(context.TODO(), shellPod.Name, metav1.DeleteOptions{}) + if innerErr != nil { + log.Warn().Err(innerErr).Msg("error deleting shell pod after failing to wait for ready status") + } + + return nil, errors.Wrap(err, "aborting pod creation; error waiting for shell pod ready status") + } + + var containerName string + if len(shellPod.Spec.Containers) > 0 { + containerName = shellPod.Spec.Containers[0].Name + } + + podData := &portainer.KubernetesShellPod{ + Namespace: shellPod.Namespace, + PodName: shellPod.Name, + ContainerName: containerName, + ShellExecCommand: "env TERM=xterm-256color /bin/bash", + } + + // Handle pod lifecycle/cleanup - terminate pod after maxPodKeepAlive or upon request (long-lived) cancellation + go func() { + select { + case <-time.After(portainer.WebSocketKeepAlive): + log.Debug().Msg("pod removal schedule duration exceeded") + + if err := kcl.cli.CoreV1().Pods(portainerNamespace).Delete(context.TODO(), shellPod.Name, metav1.DeleteOptions{}); err != nil { + log.Warn().Err(err).Msg("error deleting shell pod after max keep alive duration exceeded") + } + case <-ctx.Done(): + err := ctx.Err() + log.Debug().Err(err).Msg("context error") + + if err := kcl.cli.CoreV1().Pods(portainerNamespace).Delete(context.TODO(), shellPod.Name, metav1.DeleteOptions{}); err != nil { + log.Warn().Err(err).Msg("error deleting shell pod after context cancellation") + } + } + }() + + return podData, nil +} + +// waitForPodStatus will wait until duration d (from now) for a pod to reach defined phase/status. +// The pod status will be polled at specified delay until the pod reaches ready state. +func (kcl *KubeClient) waitForPodStatus(ctx context.Context, phase corev1.PodPhase, pod *corev1.Pod) error { + log.Debug().Str("pod", pod.Name).Msg("waiting for pod ready") + + for { + select { + case <-ctx.Done(): + return ctx.Err() + default: + pod, err := kcl.cli.CoreV1().Pods(pod.Namespace).Get(context.TODO(), pod.Name, metav1.GetOptions{}) + if err != nil { + return err + } + + if pod.Status.Phase == phase { + return nil + } + + time.Sleep(500 * time.Millisecond) + } + } +} + +// fetchAllApplicationsListResources fetches all pods, replica sets, stateful sets, and daemon sets across the cluster, i.e. all namespaces +// this is required for the applications list view +func (kcl *KubeClient) fetchAllApplicationsListResources(namespace string, podListOptions metav1.ListOptions) (PortainerApplicationResources, error) { + return kcl.fetchResourcesWithOwnerReferences(namespace, podListOptions, true, true) +} + +// fetchResourcesWithOwnerReferences fetches pods and other resources based on owner references +func (kcl *KubeClient) fetchResourcesWithOwnerReferences(namespace string, podListOptions metav1.ListOptions, includeStatefulSets, includeDaemonSets bool) (PortainerApplicationResources, error) { + pods, err := kcl.cli.CoreV1().Pods(namespace).List(context.Background(), podListOptions) + if err != nil { + if k8serrors.IsNotFound(err) { + return PortainerApplicationResources{}, nil + } + return PortainerApplicationResources{}, fmt.Errorf("unable to list pods across the cluster: %w", err) + } + + portainerApplicationResources := PortainerApplicationResources{Pods: pods.Items} + + replicaSets, err := kcl.cli.AppsV1().ReplicaSets(namespace).List(context.Background(), metav1.ListOptions{}) + if err != nil && !k8serrors.IsNotFound(err) { + return PortainerApplicationResources{}, fmt.Errorf("unable to list replica sets across the cluster: %w", err) + } + portainerApplicationResources.ReplicaSets = replicaSets.Items + + deployments, err := kcl.cli.AppsV1().Deployments(namespace).List(context.Background(), metav1.ListOptions{}) + if err != nil && !k8serrors.IsNotFound(err) { + return PortainerApplicationResources{}, fmt.Errorf("unable to list deployments across the cluster: %w", err) + } + portainerApplicationResources.Deployments = deployments.Items + + if includeStatefulSets { + statefulSets, err := kcl.cli.AppsV1().StatefulSets(namespace).List(context.Background(), metav1.ListOptions{}) + if err != nil && !k8serrors.IsNotFound(err) { + return PortainerApplicationResources{}, fmt.Errorf("unable to list stateful sets across the cluster: %w", err) + } + portainerApplicationResources.StatefulSets = statefulSets.Items + } + + if includeDaemonSets { + daemonSets, err := kcl.cli.AppsV1().DaemonSets(namespace).List(context.Background(), metav1.ListOptions{}) + if err != nil && !k8serrors.IsNotFound(err) { + return PortainerApplicationResources{}, fmt.Errorf("unable to list daemon sets across the cluster: %w", err) + } + portainerApplicationResources.DaemonSets = daemonSets.Items + } + + services, err := kcl.cli.CoreV1().Services(namespace).List(context.Background(), metav1.ListOptions{}) + if err != nil && !k8serrors.IsNotFound(err) { + return PortainerApplicationResources{}, fmt.Errorf("unable to list services across the cluster: %w", err) + } + portainerApplicationResources.Services = services.Items + + hpas, err := kcl.cli.AutoscalingV2().HorizontalPodAutoscalers(namespace).List(context.Background(), metav1.ListOptions{}) + if err != nil && !k8serrors.IsNotFound(err) { + return PortainerApplicationResources{}, fmt.Errorf("unable to list horizontal pod autoscalers across the cluster: %w", err) + } + portainerApplicationResources.HorizontalPodAutoscalers = hpas.Items + + return portainerApplicationResources, nil +} + +// isPodUsingConfigMap checks if a pod is using a specific ConfigMap +func isPodUsingConfigMap(pod *corev1.Pod, configMap models.K8sConfigMap) bool { + if pod.Namespace != configMap.Namespace { + return false + } + + for _, volume := range pod.Spec.Volumes { + if volume.ConfigMap != nil && volume.ConfigMap.Name == configMap.Name { + return true + } + } + + for _, container := range pod.Spec.Containers { + for _, env := range container.Env { + if env.ValueFrom != nil && env.ValueFrom.ConfigMapKeyRef != nil && env.ValueFrom.ConfigMapKeyRef.Name == configMap.Name { + return true + } + } + } + + return false +} + +// isPodUsingSecret checks if a pod is using a specific Secret +func isPodUsingSecret(pod *corev1.Pod, secret models.K8sSecret) bool { + if pod.Namespace != secret.Namespace { + return false + } + + for _, volume := range pod.Spec.Volumes { + if volume.Secret != nil && volume.Secret.SecretName == secret.Name { + return true + } + } + + for _, container := range pod.Spec.Containers { + for _, env := range container.Env { + if env.ValueFrom != nil && env.ValueFrom.SecretKeyRef != nil && env.ValueFrom.SecretKeyRef.Name == secret.Name { + return true + } + } + } + + return false +} + +// getLatestJobPod returns the pods that are owned by a job +// it returns an error if there is an error fetching the pods +func (kcl *KubeClient) getLatestJobPod(namespace string, jobName string) (*corev1.Pod, error) { + pods, err := kcl.cli.CoreV1().Pods(namespace).List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return nil, err + } + + for _, pod := range pods.Items { + for _, owner := range pod.OwnerReferences { + if owner.Kind == "Job" && owner.Name == jobName { + return &pod, nil + } + } + } + + return nil, nil +} diff --git a/api/kubernetes/cli/pod_test.go b/api/kubernetes/cli/pod_test.go new file mode 100644 index 0000000..e553fd7 --- /dev/null +++ b/api/kubernetes/cli/pod_test.go @@ -0,0 +1,125 @@ +package cli + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + v1 "k8s.io/api/core/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + kfake "k8s.io/client-go/kubernetes/fake" +) + +func TestDeletePod(t *testing.T) { + t.Parallel() + + t.Run("deletes an existing pod", func(t *testing.T) { + t.Parallel() + pod := &v1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "my-pod", Namespace: "default"}} + kcl := &KubeClient{cli: kfake.NewSimpleClientset(pod)} + + err := kcl.DeletePod("default", "my-pod") + require.NoError(t, err) + }) + + t.Run("returns not-found error for a missing pod", func(t *testing.T) { + t.Parallel() + kcl := &KubeClient{cli: kfake.NewSimpleClientset()} + + err := kcl.DeletePod("default", "nonexistent") + require.Error(t, err) + assert.True(t, k8serrors.IsNotFound(err), "expected a not-found error, got: %v", err) + }) + + t.Run("deletes only the named pod leaving others intact", func(t *testing.T) { + t.Parallel() + podA := &v1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "pod-a", Namespace: "default"}} + podB := &v1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "pod-b", Namespace: "default"}} + fakeClient := kfake.NewSimpleClientset(podA, podB) + kcl := &KubeClient{cli: fakeClient} + + err := kcl.DeletePod("default", "pod-a") + require.NoError(t, err) + + _, err = fakeClient.CoreV1().Pods("default").Get(t.Context(), "pod-a", metav1.GetOptions{}) + assert.True(t, k8serrors.IsNotFound(err), "pod-a should have been deleted") + + _, err = fakeClient.CoreV1().Pods("default").Get(t.Context(), "pod-b", metav1.GetOptions{}) + require.NoError(t, err, "pod-b should still exist") + }) + + t.Run("returns not-found when pod exists in a different namespace", func(t *testing.T) { + t.Parallel() + pod := &v1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "my-pod", Namespace: "other"}} + kcl := &KubeClient{cli: kfake.NewSimpleClientset(pod)} + + err := kcl.DeletePod("default", "my-pod") + require.Error(t, err) + assert.True(t, k8serrors.IsNotFound(err)) + }) +} + +func Test_waitForPodStatus(t *testing.T) { + t.Parallel() + + t.Run("successfully errors on cancelled context", func(t *testing.T) { + k := &KubeClient{ + cli: kfake.NewSimpleClientset(), + instanceID: "test", + } + + podSpec := &v1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: defaultNamespace}, + Spec: v1.PodSpec{ + Containers: []v1.Container{ + {Name: "test-pod", Image: "containous/whoami"}, + }, + }, + } + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + err := k.waitForPodStatus(ctx, v1.PodRunning, podSpec) + if !errors.Is(err, context.Canceled) { + t.Errorf("waitForPodStatus should throw context cancellation error; err=%s", err) + } + }) + + t.Run("successfully errors on timeout", func(t *testing.T) { + k := &KubeClient{ + cli: kfake.NewSimpleClientset(), + instanceID: "test", + } + + podSpec := &v1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: defaultNamespace}, + Spec: v1.PodSpec{ + Containers: []v1.Container{ + {Name: "test-pod", Image: "containous/whoami"}, + }, + }, + } + + pod, err := k.cli.CoreV1().Pods(defaultNamespace).Create(t.Context(), podSpec, metav1.CreateOptions{}) + if err != nil { + t.Errorf("failed to create pod; err=%s", err) + } + defer func() { + err := k.cli.CoreV1().Pods(defaultNamespace).Delete(t.Context(), pod.Name, metav1.DeleteOptions{}) + require.NoError(t, err) + }() + + ctx, cancelFunc := context.WithTimeout(t.Context(), 0*time.Second) + defer cancelFunc() + + err = k.waitForPodStatus(ctx, v1.PodRunning, podSpec) + if !errors.Is(err, context.DeadlineExceeded) { + t.Errorf("waitForPodStatus should throw deadline exceeded error; err=%s", err) + } + }) + +} diff --git a/api/kubernetes/cli/rbac.go b/api/kubernetes/cli/rbac.go new file mode 100644 index 0000000..8d065f4 --- /dev/null +++ b/api/kubernetes/cli/rbac.go @@ -0,0 +1,201 @@ +package cli + +import ( + "context" + "time" + + "github.com/portainer/portainer/api/internal/randomstring" + "github.com/rs/zerolog/log" + authv1 "k8s.io/api/authorization/v1" + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + authv1types "k8s.io/client-go/kubernetes/typed/authorization/v1" + corev1types "k8s.io/client-go/kubernetes/typed/core/v1" + rbacv1types "k8s.io/client-go/kubernetes/typed/rbac/v1" +) + +const maxRetries = 5 + +// IsRBACEnabled checks if RBAC is enabled in the cluster by creating a service account, then checking it's access to a resourcequota before and after setting a cluster role and cluster role binding +func (kcl *KubeClient) IsRBACEnabled() (bool, error) { + namespace := "default" + verb := "list" + resource := "resourcequotas" + + saClient := kcl.cli.CoreV1().ServiceAccounts(namespace) + uniqueString := randomstring.RandomString(4) // Append a unique string to resource names, in case they already exist + saName := "portainer-rbac-test-sa-" + uniqueString + if err := createServiceAccount(saClient, saName, namespace); err != nil { + log.Error().Err(err).Msg("Error creating service account") + + return false, err + } + defer deleteServiceAccount(saClient, saName) + + accessReviewClient := kcl.cli.AuthorizationV1().LocalSubjectAccessReviews(namespace) + allowed, err := checkServiceAccountAccess(accessReviewClient, saName, verb, resource, namespace) + if err != nil { + log.Error().Err(err).Msg("Error checking service account access") + + return false, err + } + + // If the service account with no authorizations is allowed, RBAC must be disabled + if allowed { + return false, nil + } + + // Otherwise give the service account an rbac authorisation and check again + roleClient := kcl.cli.RbacV1().Roles(namespace) + roleName := "portainer-rbac-test-role-" + uniqueString + if err := createRole(roleClient, roleName, verb, resource, namespace); err != nil { + log.Error().Err(err).Msg("Error creating role") + + return false, err + } + defer deleteRole(roleClient, roleName) + + roleBindingClient := kcl.cli.RbacV1().RoleBindings(namespace) + roleBindingName := "portainer-rbac-test-role-binding-" + uniqueString + if err := createRoleBinding(roleBindingClient, roleBindingName, roleName, saName, namespace); err != nil { + log.Error().Err(err).Msg("Error creating role binding") + + return false, err + } + defer deleteRoleBinding(roleBindingClient, roleBindingName) + + allowed, err = checkServiceAccountAccess(accessReviewClient, saName, verb, resource, namespace) + if err != nil { + log.Error().Err(err).Msg("Error checking service account access with authorizations added") + + return false, err + } + + // If the service account allowed to list resource quotas after given rbac role, then RBAC is enabled + return allowed, nil +} + +func createServiceAccount(saClient corev1types.ServiceAccountInterface, name string, namespace string) error { + serviceAccount := &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + } + + _, err := saClient.Create(context.Background(), serviceAccount, metav1.CreateOptions{}) + + return err +} + +func deleteServiceAccount(saClient corev1types.ServiceAccountInterface, name string) { + if err := saClient.Delete(context.Background(), name, metav1.DeleteOptions{}); err != nil { + log.Error().Err(err).Msg("Error deleting service account: " + name) + } +} + +func createRole(roleClient rbacv1types.RoleInterface, name string, verb string, resource string, namespace string) error { + role := &rbacv1.Role{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Rules: []rbacv1.PolicyRule{ + { + APIGroups: []string{""}, + Verbs: []string{verb}, + Resources: []string{resource}, + }, + }, + } + + _, err := roleClient.Create(context.Background(), role, metav1.CreateOptions{}) + + return err +} + +func deleteRole(roleClient rbacv1types.RoleInterface, name string) { + if err := roleClient.Delete(context.Background(), name, metav1.DeleteOptions{}); err != nil { + log.Error().Err(err).Msg("Error deleting role: " + name) + } +} + +func createRoleBinding(roleBindingClient rbacv1types.RoleBindingInterface, clusterRoleBindingName string, roleName string, serviceAccountName string, namespace string) error { + clusterRoleBinding := &rbacv1.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterRoleBindingName, + }, + Subjects: []rbacv1.Subject{ + { + Kind: "ServiceAccount", + Name: serviceAccountName, + Namespace: namespace, + }, + }, + RoleRef: rbacv1.RoleRef{ + Kind: "Role", + Name: roleName, + APIGroup: "rbac.authorization.k8s.io", + }, + } + + roleBinding, err := roleBindingClient.Create(context.Background(), clusterRoleBinding, metav1.CreateOptions{}) + if err != nil { + log.Error().Err(err).Msg("Error creating role binding: " + clusterRoleBindingName) + + return err + } + + // Retry checkRoleBinding a maximum of 5 times with a 100ms wait after each attempt + for range maxRetries { + err = checkRoleBinding(roleBindingClient, roleBinding.Name) + + time.Sleep(100 * time.Millisecond) // Wait for 100ms, even if the check passes + + if err == nil { + break + } + } + + return err +} + +func checkRoleBinding(roleBindingClient rbacv1types.RoleBindingInterface, name string) error { + if _, err := roleBindingClient.Get(context.Background(), name, metav1.GetOptions{}); err != nil { + log.Error().Err(err).Msg("Error finding rolebinding: " + name) + + return err + } + + return nil +} + +func deleteRoleBinding(roleBindingClient rbacv1types.RoleBindingInterface, name string) { + if err := roleBindingClient.Delete(context.Background(), name, metav1.DeleteOptions{}); err != nil { + log.Error().Err(err).Msg("Error deleting role binding: " + name) + } +} + +func checkServiceAccountAccess(accessReviewClient authv1types.LocalSubjectAccessReviewInterface, serviceAccountName string, verb string, resource string, namespace string) (bool, error) { + subjectAccessReview := &authv1.LocalSubjectAccessReview{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, + }, + Spec: authv1.SubjectAccessReviewSpec{ + ResourceAttributes: &authv1.ResourceAttributes{ + Namespace: namespace, + Verb: verb, + Resource: resource, + }, + User: "system:serviceaccount:default:" + serviceAccountName, // a workaround to be able to use the service account as a user + }, + } + + result, err := accessReviewClient.Create(context.Background(), subjectAccessReview, metav1.CreateOptions{}) + if err != nil { + return false, err + } + + return result.Status.Allowed, nil +} diff --git a/api/kubernetes/cli/registries.go b/api/kubernetes/cli/registries.go new file mode 100644 index 0000000..aaccaf3 --- /dev/null +++ b/api/kubernetes/cli/registries.go @@ -0,0 +1,104 @@ +package cli + +import ( + "context" + "strconv" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/internal/registryutils" + + "github.com/pkg/errors" + "github.com/segmentio/encoding/json" + v1 "k8s.io/api/core/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + secretDockerConfigKey = ".dockerconfigjson" + labelRegistryType = "io.portainer.kubernetes.registry.type" + annotationRegistryID = "portainer.io/registry.id" +) + +type ( + dockerConfig struct { + Auths map[string]registryDockerConfig `json:"auths"` + } + + registryDockerConfig struct { + Username string `json:"username"` + Password string `json:"password"` + Email string `json:"email"` + } +) + +func (kcl *KubeClient) DeleteRegistrySecret(registry portainer.RegistryID, namespace string) error { + if err := kcl.cli.CoreV1().Secrets(namespace).Delete(context.TODO(), registryutils.RegistrySecretName(registry), metav1.DeleteOptions{}); err != nil && !k8serrors.IsNotFound(err) { + return errors.Wrap(err, "failed removing secret") + } + + return nil +} + +func (kcl *KubeClient) CreateRegistrySecret(registry *portainer.Registry, namespace string) error { + username, password, err := registryutils.GetRegEffectiveCredential(registry) + if err != nil { + return err + } + + config := dockerConfig{ + Auths: map[string]registryDockerConfig{ + registry.URL: { + Username: username, + Password: password, + }, + }, + } + + configByte, err := json.Marshal(config) + if err != nil { + return errors.Wrap(err, "failed marshal config") + } + + secret := &v1.Secret{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "v1", + Kind: "Secret", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: registryutils.RegistrySecretName(registry.ID), + Labels: map[string]string{ + labelRegistryType: strconv.Itoa(int(registry.Type)), + "app.kubernetes.io/managed-by": "portainer", + }, + Annotations: map[string]string{ + annotationRegistryID: strconv.Itoa(int(registry.ID)), + }, + }, + Data: map[string][]byte{ + secretDockerConfigKey: configByte, + }, + Type: v1.SecretTypeDockerConfigJson, + } + + if _, err := kcl.cli.CoreV1().Secrets(namespace).Create(context.TODO(), secret, metav1.CreateOptions{}); err != nil && !k8serrors.IsAlreadyExists(err) { + return errors.Wrap(err, "failed saving secret") + } + + return nil +} + +func (cli *KubeClient) IsRegistrySecret(namespace, secretName string) (bool, error) { + secret, err := cli.cli.CoreV1().Secrets(namespace).Get(context.TODO(), secretName, metav1.GetOptions{}) + if err != nil { + if k8serrors.IsNotFound(err) { + return false, nil + } + + return false, err + } + + isSecret := secret.Type == v1.SecretTypeDockerConfigJson + + return isSecret, nil +} diff --git a/api/kubernetes/cli/resize.go b/api/kubernetes/cli/resize.go new file mode 100644 index 0000000..80109e9 --- /dev/null +++ b/api/kubernetes/cli/resize.go @@ -0,0 +1,44 @@ +package cli + +import ( + "sync" + + "k8s.io/client-go/tools/remotecommand" +) + +// TerminalSizeQueue implements remotecommand.TerminalSizeQueue for Kubernetes pod exec. +// Resize events are received via Push and forwarded to the Kubernetes API server. +type TerminalSizeQueue struct { + resizeChan chan *remotecommand.TerminalSize + done chan struct{} + closeOnce sync.Once +} + +func NewTerminalSizeQueue() *TerminalSizeQueue { + return &TerminalSizeQueue{ + resizeChan: make(chan *remotecommand.TerminalSize), + done: make(chan struct{}), + } +} + +// Next blocks until the next terminal resize event or the queue is closed. +func (q *TerminalSizeQueue) Next() *remotecommand.TerminalSize { + return <-q.resizeChan +} + +// Push queues a terminal resize. +// Push is safe to call after Close. +func (q *TerminalSizeQueue) Push(cols, rows uint16) { + select { + case <-q.done: + case q.resizeChan <- &remotecommand.TerminalSize{Width: cols, Height: rows}: + } +} + +// Close shuts down the queue. Safe to call multiple times. +func (q *TerminalSizeQueue) Close() { + q.closeOnce.Do(func() { + close(q.done) + close(q.resizeChan) + }) +} diff --git a/api/kubernetes/cli/resize_test.go b/api/kubernetes/cli/resize_test.go new file mode 100644 index 0000000..b1a97ab --- /dev/null +++ b/api/kubernetes/cli/resize_test.go @@ -0,0 +1,63 @@ +package cli + +import ( + "testing" + + "github.com/stretchr/testify/require" + "k8s.io/client-go/tools/remotecommand" +) + +func Test_TerminalSizeQueue(t *testing.T) { + t.Parallel() + t.Run("Next returns pushed size", func(t *testing.T) { + q := NewTerminalSizeQueue() + defer q.Close() + + go q.Push(80, 24) + size := q.Next() + require.NotNil(t, size) + require.Equal(t, uint16(80), size.Width) + require.Equal(t, uint16(24), size.Height) + }) + + t.Run("Next returns sizes in push order", func(t *testing.T) { + q := NewTerminalSizeQueue() + defer q.Close() + + go q.Push(80, 24) + size := q.Next() + require.NotNil(t, size) + require.Equal(t, uint16(80), size.Width) + require.Equal(t, uint16(24), size.Height) + + go q.Push(120, 40) + size = q.Next() + require.NotNil(t, size) + require.Equal(t, uint16(120), size.Width) + require.Equal(t, uint16(40), size.Height) + }) + + t.Run("Close causes Next to return nil", func(t *testing.T) { + q := NewTerminalSizeQueue() + q.Close() + + size := q.Next() + require.Nil(t, size) + }) + + t.Run("Next unblocks when queue is closed", func(t *testing.T) { + q := NewTerminalSizeQueue() + + result := make(chan *remotecommand.TerminalSize, 1) + go func() { result <- q.Next() }() + + q.Close() + require.Nil(t, <-result) + }) + + t.Run("Close is idempotent", func(t *testing.T) { + q := NewTerminalSizeQueue() + q.Close() + q.Close() + }) +} diff --git a/api/kubernetes/cli/resource.go b/api/kubernetes/cli/resource.go new file mode 100644 index 0000000..f3d69f5 --- /dev/null +++ b/api/kubernetes/cli/resource.go @@ -0,0 +1,27 @@ +package cli + +import ( + "bytes" + + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/serializer/json" +) + +func GenerateYAML(obj runtime.Object) (string, error) { + serializer := json.NewSerializerWithOptions( + json.DefaultMetaFactory, nil, nil, + json.SerializerOptions{ + Yaml: true, + Pretty: true, + Strict: true, + }, + ) + + b := new(bytes.Buffer) + err := serializer.Encode(obj, b) + if err != nil { + return "", err + } + + return b.String(), nil +} diff --git a/api/kubernetes/cli/resource_quota.go b/api/kubernetes/cli/resource_quota.go new file mode 100644 index 0000000..5edb2b6 --- /dev/null +++ b/api/kubernetes/cli/resource_quota.go @@ -0,0 +1,100 @@ +package cli + +import ( + "context" + "fmt" + + portainer "github.com/portainer/portainer/api" + "github.com/rs/zerolog/log" + corev1 "k8s.io/api/core/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// GetResourceQuotas gets all resource quotas in the current k8s environment(endpoint). +// if the user is an admin, all resource quotas in all namespaces are fetched. +// otherwise, namespaces the non-admin user has access to will be used to filter the resource quotas. +func (kcl *KubeClient) GetResourceQuotas(namespace string) (*[]corev1.ResourceQuota, error) { + if kcl.GetIsKubeAdmin() { + return kcl.fetchResourceQuotas(namespace) + } + + return kcl.fetchResourceQuotasForNonAdmin(namespace) +} + +// fetchResourceQuotasForNonAdmin gets the resource quotas in the current k8s environment(endpoint) for a non-admin user. +// the role of the user must have read access to the resource quotas in the defined namespaces. +func (kcl *KubeClient) fetchResourceQuotasForNonAdmin(namespace string) (*[]corev1.ResourceQuota, error) { + nonAdminNamespaces := kcl.GetClientNonAdminNamespaces() + + log.Debug(). + Strs("non_admin_namespaces", nonAdminNamespaces). + Msg("fetching resource quotas for non-admin user") + + if len(nonAdminNamespaces) == 0 { + return nil, nil + } + + resourceQuotas, err := kcl.fetchResourceQuotas(namespace) + if err != nil && !k8serrors.IsNotFound(err) { + return nil, err + } + + nonAdminNamespaceSet := kcl.buildNonAdminNamespacesMap() + results := []corev1.ResourceQuota{} + for _, resourceQuota := range *resourceQuotas { + if _, exists := nonAdminNamespaceSet[resourceQuota.Namespace]; exists { + results = append(results, resourceQuota) + } + } + + return &results, nil +} + +func (kcl *KubeClient) fetchResourceQuotas(namespace string) (*[]corev1.ResourceQuota, error) { + resourceQuotas, err := kcl.cli.CoreV1().ResourceQuotas(namespace).List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("an error occurred, failed to list resource quotas for the admin user: %w", err) + } + + return &resourceQuotas.Items, nil +} + +// GetPortainerResourceQuota gets the resource quota for the portainer namespace. +// The resource quota is prefixed with "portainer-rq-". +func (kcl *KubeClient) GetPortainerResourceQuota(namespace string) (*corev1.ResourceQuota, error) { + return kcl.cli.CoreV1().ResourceQuotas(namespace).Get(context.TODO(), "portainer-rq-"+namespace, metav1.GetOptions{}) +} + +// GetResourceQuota gets a resource quota in a specific namespace. +func (kcl *KubeClient) GetResourceQuota(namespace, resourceQuota string) (*corev1.ResourceQuota, error) { + return kcl.cli.CoreV1().ResourceQuotas(namespace).Get(context.TODO(), resourceQuota, metav1.GetOptions{}) +} + +// UpdateNamespacesWithResourceQuotas updates the namespaces with the resource quotas. +// The resource quotas are matched with the namespaces by name. +func (kcl *KubeClient) UpdateNamespacesWithResourceQuotas(namespaces map[string]portainer.K8sNamespaceInfo, resourceQuotas []corev1.ResourceQuota) []portainer.K8sNamespaceInfo { + namespacesWithQuota := map[string]portainer.K8sNamespaceInfo{} + + for _, namespace := range namespaces { + resourceQuota := kcl.GetResourceQuotaFromNamespace(namespace, resourceQuotas) + if resourceQuota != nil { + namespace.ResourceQuota = resourceQuota + } + + namespacesWithQuota[namespace.Name] = namespace + } + + return kcl.ConvertNamespaceMapToSlice(namespacesWithQuota) +} + +// GetResourceQuotaFromNamespace gets the resource quota in a specific namespace where the resource quota's name is prefixed with "portainer-rq-". +func (kcl *KubeClient) GetResourceQuotaFromNamespace(namespace portainer.K8sNamespaceInfo, resourceQuotas []corev1.ResourceQuota) *corev1.ResourceQuota { + for _, resourceQuota := range resourceQuotas { + if resourceQuota.Namespace == namespace.Name && resourceQuota.Name == "portainer-rq-"+namespace.Name { + return &resourceQuota + } + } + + return nil +} diff --git a/api/kubernetes/cli/resource_quota_test.go b/api/kubernetes/cli/resource_quota_test.go new file mode 100644 index 0000000..50f0292 --- /dev/null +++ b/api/kubernetes/cli/resource_quota_test.go @@ -0,0 +1,16 @@ +package cli + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGetResourceQuotas(t *testing.T) { + t.Parallel() + kcl := &KubeClient{} + + resourceQuotas, err := kcl.GetResourceQuotas("default") + require.NoError(t, err) + require.Empty(t, resourceQuotas) +} diff --git a/api/kubernetes/cli/resource_test.go b/api/kubernetes/cli/resource_test.go new file mode 100644 index 0000000..c560209 --- /dev/null +++ b/api/kubernetes/cli/resource_test.go @@ -0,0 +1,131 @@ +package cli + +import ( + "strings" + "testing" + + portainer "github.com/portainer/portainer/api" + + "github.com/stretchr/testify/require" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + clientV1 "k8s.io/client-go/tools/clientcmd/api/v1" +) + +// compareYAMLStrings will compare 2 strings by stripping tabs, newlines and whitespaces from both strings +func compareYAMLStrings(in1, in2 string) int { + r := strings.NewReplacer("\t", "", "\n", "", " ", "") + in1 = r.Replace(in1) + in2 = r.Replace(in2) + return strings.Compare(in1, in2) +} + +func Test_GenerateYAML(t *testing.T) { + t.Parallel() + resourceYAMLTests := []struct { + title string + resource runtime.Object + wantYAML string + }{ + { + title: "Config", + resource: &clientV1.Config{ + APIVersion: "v1", + Kind: "Config", + CurrentContext: "portainer-ctx", + Contexts: []clientV1.NamedContext{ + { + Name: "portainer-ctx", + Context: clientV1.Context{ + AuthInfo: "test-user", + Cluster: "portainer-cluster", + }, + }, + }, + Clusters: []clientV1.NamedCluster{ + { + Name: "portainer-cluster", + Cluster: clientV1.Cluster{ + Server: "localhost", + InsecureSkipTLSVerify: true, + }, + }, + }, + AuthInfos: []clientV1.NamedAuthInfo{ + { + Name: "test-user", + AuthInfo: clientV1.AuthInfo{ + Token: "test-token", + }, + }, + }, + }, + wantYAML: ` + apiVersion: v1 + clusters: + - cluster: + insecure-skip-tls-verify: true + server: localhost + name: portainer-cluster + contexts: + - context: + cluster: portainer-cluster + user: test-user + name: portainer-ctx + current-context: portainer-ctx + kind: Config + users: + - name: test-user + user: + token: test-token + `, + }, + } + + for _, ryt := range resourceYAMLTests { + t.Run(ryt.title, func(t *testing.T) { + yaml, err := GenerateYAML(ryt.resource) + if err != nil { + t.Errorf("generateYamlConfig failed; err=%s", err) + } + + if compareYAMLStrings(yaml, ryt.wantYAML) != 0 { + t.Errorf("generateYamlConfig failed;\ngot=\n%s\nwant=\n%s", yaml, ryt.wantYAML) + } + }) + } +} + +func TestGetResourceQuotaFromNamespace(t *testing.T) { + t.Parallel() + kcl := &KubeClient{} + + namespace := portainer.K8sNamespaceInfo{Name: "my-namespace"} + resourceQuotas := []v1.ResourceQuota{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "portainer-rq-" + namespace.Name + "-1", + Namespace: namespace.Name, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "portainer-rq-" + namespace.Name, + Namespace: namespace.Name, + }, + }, + } + + rq := kcl.GetResourceQuotaFromNamespace(namespace, resourceQuotas) + require.NotNil(t, rq) + require.Equal(t, namespace.Name, rq.Namespace) + + // Empty cases + rq = kcl.GetResourceQuotaFromNamespace(namespace, nil) + require.Nil(t, rq) + + namespace.Name = "another-namespace" + rq = kcl.GetResourceQuotaFromNamespace(namespace, resourceQuotas) + require.Nil(t, rq) +} diff --git a/api/kubernetes/cli/role.go b/api/kubernetes/cli/role.go new file mode 100644 index 0000000..ac98388 --- /dev/null +++ b/api/kubernetes/cli/role.go @@ -0,0 +1,160 @@ +package cli + +import ( + "context" + "errors" + "strings" + + models "github.com/portainer/portainer/api/http/models/kubernetes" + "github.com/rs/zerolog/log" + rbacv1 "k8s.io/api/rbac/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// GetRoles gets all the roles for either at the cluster level or a given namespace in a k8s endpoint. +// It returns a list of K8sRole objects. +func (kcl *KubeClient) GetRoles(namespace string) ([]models.K8sRole, error) { + if kcl.GetIsKubeAdmin() { + return kcl.fetchRoles(namespace) + } + + return kcl.fetchRolesForNonAdmin(namespace) +} + +// fetchRolesForNonAdmin gets all the roles for either at the cluster level or a given namespace in a k8s endpoint. +// the namespace will be coming from NonAdminNamespaces as non-admin users are restricted to certain namespaces. +// it returns a list of K8sRole objects. +func (kcl *KubeClient) fetchRolesForNonAdmin(namespace string) ([]models.K8sRole, error) { + roles, err := kcl.fetchRoles(namespace) + if err != nil { + return nil, err + } + + nonAdminNamespaceSet := kcl.buildNonAdminNamespacesMap() + results := make([]models.K8sRole, 0) + for _, role := range roles { + if _, ok := nonAdminNamespaceSet[role.Namespace]; ok { + results = append(results, role) + } + } + + return results, nil +} + +// fetchRoles returns a list of all Roles in the specified namespace. +func (kcl *KubeClient) fetchRoles(namespace string) ([]models.K8sRole, error) { + roles, err := kcl.cli.RbacV1().Roles(namespace).List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return nil, err + } + + results := make([]models.K8sRole, 0) + for _, role := range roles.Items { + results = append(results, kcl.parseRole(role)) + } + + return results, nil +} + +// parseRole converts a rbacv1.Role object to a models.K8sRole object. +func (kcl *KubeClient) parseRole(role rbacv1.Role) models.K8sRole { + return models.K8sRole{ + Name: role.Name, + UID: role.UID, + Namespace: role.Namespace, + CreationDate: role.CreationTimestamp.Time, + IsSystem: kcl.isSystemRole(&role), + } +} + +func getPortainerUserDefaultPolicies() []rbacv1.PolicyRule { + return []rbacv1.PolicyRule{ + { + Verbs: []string{"list", "get"}, + Resources: []string{"namespaces", "nodes", "endpoints"}, + APIGroups: []string{""}, + }, + { + Verbs: []string{"list"}, + Resources: []string{"storageclasses"}, + APIGroups: []string{"storage.k8s.io"}, + }, + { + Verbs: []string{"list", "get"}, + Resources: []string{"namespaces", "pods", "nodes"}, + APIGroups: []string{"metrics.k8s.io"}, + }, + { + Verbs: []string{"list"}, + Resources: []string{"ingressclasses"}, + APIGroups: []string{"networking.k8s.io"}, + }, + } +} + +func (kcl *KubeClient) upsertPortainerK8sClusterRoles() error { + clusterRole := &rbacv1.ClusterRole{ + ObjectMeta: metav1.ObjectMeta{ + Name: portainerUserCRName, + }, + Rules: getPortainerUserDefaultPolicies(), + } + + _, err := kcl.cli.RbacV1().ClusterRoles().Create(context.TODO(), clusterRole, metav1.CreateOptions{}) + if err != nil { + if k8serrors.IsAlreadyExists(err) { + _, err = kcl.cli.RbacV1().ClusterRoles().Update(context.TODO(), clusterRole, metav1.UpdateOptions{}) + } + if err != nil { + return err + } + } + + return nil +} + +func getPortainerDefaultK8sRoleNames() []string { + return []string{ + string(portainerUserCRName), + } +} + +func (kcl *KubeClient) isSystemRole(role *rbacv1.Role) bool { + if strings.HasPrefix(role.Name, "system:") { + return true + } + + return kcl.isSystemNamespace(role.Namespace) +} + +// DeleteRoles processes a K8sServiceDeleteRequest by deleting each role +// in its given namespace. +func (kcl *KubeClient) DeleteRoles(reqs models.K8sRoleDeleteRequests) error { + var errs error + for namespace := range reqs { + for _, name := range reqs[namespace] { + client := kcl.cli.RbacV1().Roles(namespace) + + role, err := client.Get(context.Background(), name, metav1.GetOptions{}) + if err != nil { + if k8serrors.IsNotFound(err) { + continue + } + + // This is a more serious error to do with the client so we return right away + return err + } + + if kcl.isSystemRole(role) { + log.Error().Str("role_name", name).Msg("ignoring delete of 'system' role, not allowed") + } + + if err := client.Delete(context.TODO(), name, metav1.DeleteOptions{}); err != nil { + errs = errors.Join(errs, err) + } + } + } + + return errs +} diff --git a/api/kubernetes/cli/role_binding.go b/api/kubernetes/cli/role_binding.go new file mode 100644 index 0000000..5baef7a --- /dev/null +++ b/api/kubernetes/cli/role_binding.go @@ -0,0 +1,132 @@ +package cli + +import ( + "context" + "errors" + "strings" + + models "github.com/portainer/portainer/api/http/models/kubernetes" + "github.com/rs/zerolog/log" + rbacv1 "k8s.io/api/rbac/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// GetRoleBindings gets all the roleBindings for either at the cluster level or a given namespace in a k8s endpoint. +// It returns a list of K8sRoleBinding objects. +func (kcl *KubeClient) GetRoleBindings(namespace string) ([]models.K8sRoleBinding, error) { + if kcl.GetIsKubeAdmin() { + return kcl.fetchRoleBindings(namespace) + } + + return kcl.fetchRolebindingsForNonAdmin(namespace) +} + +// fetchRolebindingsForNonAdmin gets all the roleBindings for either at the cluster level or a given namespace in a k8s endpoint. +// the namespace will be coming from NonAdminNamespaces as non-admin users are restricted to certain namespaces. +// it returns a list of K8sRoleBinding objects. +func (kcl *KubeClient) fetchRolebindingsForNonAdmin(namespace string) ([]models.K8sRoleBinding, error) { + roleBindings, err := kcl.fetchRoleBindings(namespace) + if err != nil { + return nil, err + } + + nonAdminNamespaceSet := kcl.buildNonAdminNamespacesMap() + results := make([]models.K8sRoleBinding, 0) + for _, roleBinding := range roleBindings { + if _, ok := nonAdminNamespaceSet[roleBinding.Namespace]; ok { + results = append(results, roleBinding) + } + } + + return results, nil +} + +// fetchRoleBindings returns a list of all Roles in the specified namespace. +func (kcl *KubeClient) fetchRoleBindings(namespace string) ([]models.K8sRoleBinding, error) { + roleBindings, err := kcl.cli.RbacV1().RoleBindings(namespace).List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return nil, err + } + + results := make([]models.K8sRoleBinding, 0) + for _, roleBinding := range roleBindings.Items { + results = append(results, kcl.parseRoleBinding(roleBinding)) + } + + return results, nil +} + +// parseRoleBinding converts a rbacv1.RoleBinding object to a models.K8sRoleBinding object. +func (kcl *KubeClient) parseRoleBinding(roleBinding rbacv1.RoleBinding) models.K8sRoleBinding { + return models.K8sRoleBinding{ + Name: roleBinding.Name, + UID: roleBinding.UID, + Namespace: roleBinding.Namespace, + RoleRef: roleBinding.RoleRef, + Subjects: roleBinding.Subjects, + CreationDate: roleBinding.CreationTimestamp.Time, + IsSystem: kcl.isSystemRoleBinding(&roleBinding), + } +} + +func (kcl *KubeClient) isSystemRoleBinding(rb *rbacv1.RoleBinding) bool { + if strings.HasPrefix(rb.Name, "system:") { + return true + } + + if rb.Labels != nil { + if rb.Labels["kubernetes.io/bootstrapping"] == "rbac-defaults" { + return true + } + } + + if rb.RoleRef.Name != "" { + role, err := kcl.getRole(rb.Namespace, rb.RoleRef.Name) + if err != nil { + return false + } + + // Linked to a role that is marked a system role + if kcl.isSystemRole(role) { + return true + } + } + + return false +} + +func (kcl *KubeClient) getRole(namespace, name string) (*rbacv1.Role, error) { + client := kcl.cli.RbacV1().Roles(namespace) + return client.Get(context.Background(), name, metav1.GetOptions{}) +} + +// DeleteRoleBindings processes a K8sServiceDeleteRequest by deleting each service +// in its given namespace. +func (kcl *KubeClient) DeleteRoleBindings(reqs models.K8sRoleBindingDeleteRequests) error { + var errs error + for namespace := range reqs { + for _, name := range reqs[namespace] { + client := kcl.cli.RbacV1().RoleBindings(namespace) + + roleBinding, err := client.Get(context.Background(), name, metav1.GetOptions{}) + if err != nil { + if k8serrors.IsNotFound(err) { + continue + } + + // This is a more serious error to do with the client so we return right away + return err + } + + if kcl.isSystemRoleBinding(roleBinding) { + log.Error().Str("role_name", name).Msg("ignoring delete of 'system' role binding, not allowed") + } + + if err := client.Delete(context.Background(), name, metav1.DeleteOptions{}); err != nil { + errs = errors.Join(errs, err) + } + } + } + return errs +} diff --git a/api/kubernetes/cli/secret.go b/api/kubernetes/cli/secret.go new file mode 100644 index 0000000..67652b3 --- /dev/null +++ b/api/kubernetes/cli/secret.go @@ -0,0 +1,262 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "time" + + models "github.com/portainer/portainer/api/http/models/kubernetes" + "github.com/rs/zerolog/log" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + labelPortainerKubeConfigOwner = "io.portainer.kubernetes.configuration.owner" + labelPortainerKubeConfigOwnerId = "io.portainer.kubernetes.configuration.owner.id" +) + +// GetSecrets gets all the Secrets for a given namespace in a k8s endpoint. +// if the user is an admin, all secrets in the current k8s environment(endpoint) are fetched using the getSecrets function. +// otherwise, namespaces the non-admin user has access to will be used to filter the secrets based on the allowed namespaces. +func (kcl *KubeClient) GetSecrets(namespace string) ([]models.K8sSecret, error) { + if kcl.GetIsKubeAdmin() { + return kcl.getSecrets(namespace) + } + + return kcl.getSecretsForNonAdmin(namespace) +} + +// getSecretsForNonAdmin fetches the secrets in the namespaces the user has access to. +// This function is called when the user is not an admin. +func (kcl *KubeClient) getSecretsForNonAdmin(namespace string) ([]models.K8sSecret, error) { + nonAdminNamespaces := kcl.GetClientNonAdminNamespaces() + + log.Debug(). + Strs("non_admin_namespaces", nonAdminNamespaces). + Msg("fetching secrets for non-admin user") + + if len(nonAdminNamespaces) == 0 { + return nil, nil + } + + secrets, err := kcl.getSecrets(namespace) + if err != nil { + return nil, err + } + + nonAdminNamespaceSet := kcl.buildNonAdminNamespacesMap() + results := make([]models.K8sSecret, 0) + for _, secret := range secrets { + if _, ok := nonAdminNamespaceSet[secret.Namespace]; ok { + results = append(results, secret) + } + } + + return results, nil +} + +// getSecrets gets all the Secrets for a given namespace in a k8s endpoint. +// the result is a list of secrets parsed into a K8sSecret struct. +func (kcl *KubeClient) getSecrets(namespace string) ([]models.K8sSecret, error) { + secrets, err := kcl.cli.CoreV1().Secrets(namespace).List(context.Background(), metav1.ListOptions{}) + if err != nil { + return nil, err + } + + results := []models.K8sSecret{} + for _, secret := range secrets.Items { + results = append(results, parseSecret(&secret, false)) + } + + return results, nil +} + +// GetSecret gets a Secret by name for a given namespace. +// the result is a secret parsed into a K8sSecret struct. +func (kcl *KubeClient) GetSecret(namespace string, secretName string) (models.K8sSecret, error) { + secret, err := kcl.cli.CoreV1().Secrets(namespace).Get(context.Background(), secretName, metav1.GetOptions{}) + if err != nil { + return models.K8sSecret{}, err + } + + return parseSecret(secret, true), nil +} + +// parseSecret parses a k8s Secret object into a K8sSecret struct. +// for get operation, withData will be set to true. +// otherwise, only metadata will be parsed. +func parseSecret(secret *corev1.Secret, withData bool) models.K8sSecret { + result := models.K8sSecret{ + K8sConfiguration: models.K8sConfiguration{ + UID: string(secret.UID), + Name: secret.Name, + Namespace: secret.Namespace, + CreationDate: secret.CreationTimestamp.Time.UTC().Format(time.RFC3339), + Annotations: secret.Annotations, + Labels: secret.Labels, + ConfigurationOwner: secret.Labels[labelPortainerKubeConfigOwner], + ConfigurationOwnerId: secret.Labels[labelPortainerKubeConfigOwnerId], + }, + SecretType: string(secret.Type), + } + + if withData { + secretData := secret.Data + secretDataMap := make(map[string]string, len(secretData)) + for key, value := range secretData { + secretDataMap[key] = string(value) + } + + result.Data = secretDataMap + } + + return result +} + +// SetSecretsIsUsed combines the secrets with the applications that use them. +// the function fetches all the pods and service accounts in the cluster and checks if the secret is used by any of them. +// if the secret is used by a pod or service account, the secret is marked as used. +// otherwise, the secret is returned as is. +func (kcl *KubeClient) SetSecretsIsUsed(secrets *[]models.K8sSecret) error { + portainerApplicationResources, err := kcl.fetchAllApplicationsListResources("", metav1.ListOptions{}) + if err != nil { + return fmt.Errorf("an error occurred during the SetSecretsIsUsed operation, unable to fetch Portainer application resources. Error: %w", err) + } + + serviceAccounts, err := kcl.GetServiceAccounts("") + if err != nil { + return fmt.Errorf("an error occurred during the SetSecretsIsUsed operation, unable to fetch service accounts. Error: %w", err) + } + + for i := range *secrets { + secret := &(*secrets)[i] + + if isSecretUsedByServiceAccount(*secret, serviceAccounts) { + secret.IsUsed = true + continue + } + + for _, pod := range portainerApplicationResources.Pods { + if isPodUsingSecret(&pod, *secret) { + secret.IsUsed = true + break + } + } + } + + return nil +} + +func isSecretUsedByServiceAccount(secret models.K8sSecret, serviceAccounts []models.K8sServiceAccount) bool { + for _, serviceAccount := range serviceAccounts { + if serviceAccount.Namespace != secret.Namespace { + continue + } + + for _, imagePullSecret := range serviceAccount.ImagePullSecrets { + if imagePullSecret.Name == secret.Name { + return true + } + } + } + + return false +} + +// CombineSecretWithApplications combines the secret with the applications that use it. +// the function fetches all the pods in the cluster and checks if the secret is used by any of the pods. +// it needs to check if the pods are owned by a replica set to determine if the pod is part of a deployment. +func (kcl *KubeClient) CombineSecretWithApplications(secret models.K8sSecret) (models.K8sSecret, error) { + pods, err := kcl.cli.CoreV1().Pods(secret.Namespace).List(context.Background(), metav1.ListOptions{}) + if err != nil { + return models.K8sSecret{}, fmt.Errorf("an error occurred during the CombineSecretWithApplications operation, unable to get pods. Error: %w", err) + } + + replicaSetsItems := []appsv1.ReplicaSet{} + if containsReplicaSetOwnerReference(pods) { + replicaSets, err := kcl.cli.AppsV1().ReplicaSets(secret.Namespace).List(context.Background(), metav1.ListOptions{}) + if err != nil { + return models.K8sSecret{}, fmt.Errorf("an error occurred during the CombineSecretWithApplications operation, unable to get replica sets. Error: %w", err) + } + replicaSetsItems = replicaSets.Items + } + + applicationConfigurationOwners, err := kcl.GetApplicationConfigurationOwnersFromSecret(secret, pods.Items, replicaSetsItems) + if err != nil { + return models.K8sSecret{}, fmt.Errorf("an error occurred during the CombineSecretWithApplications operation, unable to get applications from secret. Error: %w", err) + } + + if len(applicationConfigurationOwners) > 0 { + secret.ConfigurationOwnerResources = applicationConfigurationOwners + secret.IsUsed = true + } + + return secret, nil +} + +func (kcl *KubeClient) createServiceAccountToken(serviceAccountName string) error { + serviceAccountSecretName := userServiceAccountTokenSecretName(serviceAccountName, kcl.instanceID) + + serviceAccountSecret := &corev1.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(context.TODO(), serviceAccountSecret, metav1.CreateOptions{}) + 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(context.TODO(), 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(endpoint)/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(context.TODO(), 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/secret_test.go b/api/kubernetes/cli/secret_test.go new file mode 100644 index 0000000..4dcffff --- /dev/null +++ b/api/kubernetes/cli/secret_test.go @@ -0,0 +1,152 @@ +package cli + +import ( + "testing" + + models "github.com/portainer/portainer/api/http/models/kubernetes" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + kfake "k8s.io/client-go/kubernetes/fake" +) + +func Test_SetSecretsIsUsed_ServiceAccountImagePullSecret(t *testing.T) { + t.Parallel() + + k := NewTestKubeClient(kfake.NewClientset()) + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "registry-secret", + Namespace: "default", + }, + Type: corev1.SecretTypeDockerConfigJson, + } + + _, err := k.cli.CoreV1().Secrets("default").Create(t.Context(), secret, metav1.CreateOptions{}) + require.NoError(t, err) + + serviceAccount := &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: "app-sa", + Namespace: "default", + }, + ImagePullSecrets: []corev1.LocalObjectReference{ + {Name: "registry-secret"}, + }, + } + _, err = k.cli.CoreV1().ServiceAccounts("default").Create(t.Context(), serviceAccount, metav1.CreateOptions{}) + require.NoError(t, err) + + secrets := []models.K8sSecret{parseSecret(secret, false)} + + err = k.SetSecretsIsUsed(&secrets) + + require.NoError(t, err) + assert.True(t, secrets[0].IsUsed) +} + +func Test_SetSecretsIsUsed_NotReferencedByAnySA(t *testing.T) { + t.Parallel() + + k := NewTestKubeClient(kfake.NewClientset()) + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "unused-secret", + Namespace: "default", + }, + Type: corev1.SecretTypeDockerConfigJson, + } + _, err := k.cli.CoreV1().Secrets("default").Create(t.Context(), secret, metav1.CreateOptions{}) + require.NoError(t, err) + + serviceAccount := &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: "app-sa", + Namespace: "default", + }, + ImagePullSecrets: []corev1.LocalObjectReference{ + {Name: "other-secret"}, + }, + } + _, err = k.cli.CoreV1().ServiceAccounts("default").Create(t.Context(), serviceAccount, metav1.CreateOptions{}) + require.NoError(t, err) + + secrets := []models.K8sSecret{parseSecret(secret, false)} + + err = k.SetSecretsIsUsed(&secrets) + + require.NoError(t, err) + assert.False(t, secrets[0].IsUsed) +} + +func Test_SetSecretsIsUsed_SAInDifferentNamespace(t *testing.T) { + t.Parallel() + + k := NewTestKubeClient(kfake.NewClientset()) + // Create a secret named "registry-secret" in the default namespace + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "registry-secret", + Namespace: "default", + }, + Type: corev1.SecretTypeDockerConfigJson, + } + _, err := k.cli.CoreV1().Secrets("default").Create(t.Context(), secret, metav1.CreateOptions{}) + require.NoError(t, err) + + // Create a service account in a different namespace that references a secret with the same name. + // In Kubernetes, secrets are namespace-scoped, so this SA references a different secret + // (one that doesn't exist in "other-namespace"), not the one we created in "default". + serviceAccount := &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: "app-sa", + Namespace: "other-namespace", + }, + ImagePullSecrets: []corev1.LocalObjectReference{ + {Name: "registry-secret"}, + }, + } + _, err = k.cli.CoreV1().ServiceAccounts("other-namespace").Create(t.Context(), serviceAccount, metav1.CreateOptions{}) + require.NoError(t, err) + + secrets := []models.K8sSecret{parseSecret(secret, false)} + + err = k.SetSecretsIsUsed(&secrets) + + // The secret in the default namespace should not be marked as used, + // since the SA reference is to a secret in a different namespace. + require.NoError(t, err) + assert.False(t, secrets[0].IsUsed) +} + +func Test_SetSecretsIsUsed_SAWithEmptyImagePullSecrets(t *testing.T) { + t.Parallel() + + k := NewTestKubeClient(kfake.NewClientset()) + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "registry-secret", + Namespace: "default", + }, + Type: corev1.SecretTypeDockerConfigJson, + } + _, err := k.cli.CoreV1().Secrets("default").Create(t.Context(), secret, metav1.CreateOptions{}) + require.NoError(t, err) + + serviceAccount := &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: "app-sa", + Namespace: "default", + }, + } + _, err = k.cli.CoreV1().ServiceAccounts("default").Create(t.Context(), serviceAccount, metav1.CreateOptions{}) + require.NoError(t, err) + + secrets := []models.K8sSecret{parseSecret(secret, false)} + + err = k.SetSecretsIsUsed(&secrets) + + require.NoError(t, err) + assert.False(t, secrets[0].IsUsed) +} diff --git a/api/kubernetes/cli/server_version.go b/api/kubernetes/cli/server_version.go new file mode 100644 index 0000000..b529c22 --- /dev/null +++ b/api/kubernetes/cli/server_version.go @@ -0,0 +1,7 @@ +package cli + +import "k8s.io/apimachinery/pkg/version" + +func (kcl *KubeClient) ServerVersion() (*version.Info, error) { + return kcl.cli.Discovery().ServerVersion() +} diff --git a/api/kubernetes/cli/service.go b/api/kubernetes/cli/service.go new file mode 100644 index 0000000..ce48ec0 --- /dev/null +++ b/api/kubernetes/cli/service.go @@ -0,0 +1,227 @@ +package cli + +import ( + "context" + "fmt" + + models "github.com/portainer/portainer/api/http/models/kubernetes" + "github.com/rs/zerolog/log" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" +) + +// GetServices gets all the services for either at the cluster level or a given namespace in a k8s endpoint. +// It returns a list of K8sServiceInfo objects. +func (kcl *KubeClient) GetServices(namespace string) ([]models.K8sServiceInfo, error) { + if kcl.GetIsKubeAdmin() { + return kcl.fetchServices(namespace) + } + + return kcl.fetchServicesForNonAdmin(namespace) +} + +// fetchServicesForNonAdmin gets all the services for either at the cluster level or a given namespace in a k8s endpoint. +// the namespace will be coming from NonAdminNamespaces as non-admin users are restricted to certain namespaces. +// it returns a list of K8sServiceInfo objects. +func (kcl *KubeClient) fetchServicesForNonAdmin(namespace string) ([]models.K8sServiceInfo, error) { + nonAdminNamespaces := kcl.GetClientNonAdminNamespaces() + + log.Debug(). + Strs("non_admin_namespaces", nonAdminNamespaces). + Msg("fetching services for non-admin user") + + if len(nonAdminNamespaces) == 0 { + return nil, nil + } + + services, err := kcl.fetchServices(namespace) + if err != nil { + return nil, err + } + + nonAdminNamespaceSet := kcl.buildNonAdminNamespacesMap() + results := make([]models.K8sServiceInfo, 0) + for _, service := range services { + if _, ok := nonAdminNamespaceSet[service.Namespace]; ok { + results = append(results, service) + } + } + + return results, nil +} + +// fetchServices gets the services in a given namespace in a k8s endpoint. +// It returns a list of K8sServiceInfo objects. +func (kcl *KubeClient) fetchServices(namespace string) ([]models.K8sServiceInfo, error) { + services, err := kcl.cli.CoreV1().Services(namespace).List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return nil, err + } + + results := make([]models.K8sServiceInfo, 0) + for _, service := range services.Items { + results = append(results, parseService(service)) + } + + return results, nil +} + +// parseService converts a k8s native service object to a Portainer K8sServiceInfo object. +// service ports, ingress status, labels, annotations, cluster IPs, and external IPs are parsed. +// it returns a K8sServiceInfo object. +func parseService(service corev1.Service) models.K8sServiceInfo { + servicePorts := make([]models.K8sServicePort, 0) + for _, port := range service.Spec.Ports { + servicePorts = append(servicePorts, models.K8sServicePort{ + Name: port.Name, + NodePort: int(port.NodePort), + Port: int(port.Port), + Protocol: string(port.Protocol), + TargetPort: port.TargetPort.String(), + }) + } + + ingressStatus := make([]models.K8sServiceIngress, 0) + for _, status := range service.Status.LoadBalancer.Ingress { + ingressStatus = append(ingressStatus, models.K8sServiceIngress{ + IP: status.IP, + Hostname: status.Hostname, + }) + } + + return models.K8sServiceInfo{ + Name: service.Name, + UID: string(service.GetUID()), + Type: string(service.Spec.Type), + Namespace: service.Namespace, + CreationDate: service.GetCreationTimestamp().String(), + AllocateLoadBalancerNodePorts: service.Spec.AllocateLoadBalancerNodePorts, + Ports: servicePorts, + IngressStatus: ingressStatus, + Labels: service.GetLabels(), + Annotations: service.GetAnnotations(), + ClusterIPs: service.Spec.ClusterIPs, + ExternalName: service.Spec.ExternalName, + ExternalIPs: service.Spec.ExternalIPs, + Selector: service.Spec.Selector, + } +} + +// convertToK8sService converts a K8sServiceInfo object back to a k8s native service object. +// this is required for create and update operations. +// it returns a v1.Service object. +func (kcl *KubeClient) convertToK8sService(info models.K8sServiceInfo) corev1.Service { + service := corev1.Service{} + service.Name = info.Name + service.Spec.Type = corev1.ServiceType(info.Type) + service.Namespace = info.Namespace + service.Annotations = info.Annotations + service.Labels = info.Labels + service.Spec.AllocateLoadBalancerNodePorts = info.AllocateLoadBalancerNodePorts + service.Spec.Selector = info.Selector + + for _, p := range info.Ports { + port := corev1.ServicePort{} + port.Name = p.Name + port.NodePort = int32(p.NodePort) + port.Port = int32(p.Port) + port.Protocol = corev1.Protocol(p.Protocol) + port.TargetPort = intstr.FromString(p.TargetPort) + service.Spec.Ports = append(service.Spec.Ports, port) + } + + for _, i := range info.IngressStatus { + service.Status.LoadBalancer.Ingress = append( + service.Status.LoadBalancer.Ingress, + corev1.LoadBalancerIngress{IP: i.IP, Hostname: i.Hostname}, + ) + } + + return service +} + +// CreateService creates a new service in a given namespace in a k8s endpoint. +func (kcl *KubeClient) CreateService(namespace string, info models.K8sServiceInfo) error { + service := kcl.convertToK8sService(info) + _, err := kcl.cli.CoreV1().Services(namespace).Create(context.Background(), &service, metav1.CreateOptions{}) + return err +} + +// DeleteServices processes a K8sServiceDeleteRequest by deleting each service +// in its given namespace. +func (kcl *KubeClient) DeleteServices(reqs models.K8sServiceDeleteRequests) error { + for namespace := range reqs { + for _, service := range reqs[namespace] { + err := kcl.cli.CoreV1().Services(namespace).Delete(context.Background(), service, metav1.DeleteOptions{}) + if err != nil { + return err + } + } + } + + return nil +} + +// UpdateService updates service in a given namespace in a k8s endpoint. +func (kcl *KubeClient) UpdateService(namespace string, info models.K8sServiceInfo) error { + service := kcl.convertToK8sService(info) + _, err := kcl.cli.CoreV1().Services(namespace).Update(context.Background(), &service, metav1.UpdateOptions{}) + return err +} + +// CombineServicesWithApplications retrieves applications based on service selectors in a given namespace +// for all services, it lists pods based on the service selector and converts the pod to an application +// if replicasets are found, it updates the owner reference to deployment +// it then combines the service with the application +// finally, it returns a list of K8sServiceInfo objects +func (kcl *KubeClient) CombineServicesWithApplications(services []models.K8sServiceInfo) ([]models.K8sServiceInfo, error) { + if containsServiceWithSelector(services) { + updatedServices := make([]models.K8sServiceInfo, len(services)) + portainerApplicationResources, err := kcl.fetchAllApplicationsListResources("", metav1.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("an error occurred during the CombineServicesWithApplications operation, unable to fetch pods and replica sets. Error: %w", err) + } + + for index, service := range services { + updatedService := service + + application, err := kcl.GetApplicationFromServiceSelector(portainerApplicationResources.Pods, service, portainerApplicationResources.ReplicaSets) + if err != nil { + return services, fmt.Errorf("an error occurred during the CombineServicesWithApplications operation, unable to get application from service. Error: %w", err) + } + + if application != nil { + updatedService.Applications = append(updatedService.Applications, *application) + } + + updatedServices[index] = updatedService + } + + return updatedServices, nil + } + + return services, nil +} + +// containsServiceWithSelector checks if a list of services contains a service with a selector +// it returns true if any service has a selector, otherwise false +func containsServiceWithSelector(services []models.K8sServiceInfo) bool { + for _, service := range services { + if len(service.Selector) > 0 { + return true + } + } + return false +} + +// buildServicesMap builds a map of service names from a list of K8sServiceInfo objects +// it returns a map of service names for lookups +func (kcl *KubeClient) buildServicesMap(services []models.K8sServiceInfo) map[string]struct{} { + serviceMap := make(map[string]struct{}) + for _, service := range services { + serviceMap[service.Name] = struct{}{} + } + return serviceMap +} diff --git a/api/kubernetes/cli/service_account.go b/api/kubernetes/cli/service_account.go new file mode 100644 index 0000000..a1558d3 --- /dev/null +++ b/api/kubernetes/cli/service_account.go @@ -0,0 +1,372 @@ +package cli + +import ( + "context" + "errors" + "fmt" + + portainer "github.com/portainer/portainer/api" + models "github.com/portainer/portainer/api/http/models/kubernetes" + corev1 "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" +) + +// GetServiceAccounts gets all the service accounts for either at the cluster level or a given namespace in a k8s endpoint. +// It returns a list of K8sServiceAccount objects. +func (kcl *KubeClient) GetServiceAccounts(namespace string) ([]models.K8sServiceAccount, error) { + if kcl.GetIsKubeAdmin() { + return kcl.fetchServiceAccounts(namespace) + } + + return kcl.fetchServiceAccountsForNonAdmin(namespace) +} + +// fetchServiceAccountsForNonAdmin gets all the service accounts for either at the cluster level or a given namespace in a k8s endpoint. +// the namespace will be coming from NonAdminNamespaces as non-admin users are restricted to certain namespaces. +// it returns a list of K8sServiceAccount objects. +func (kcl *KubeClient) fetchServiceAccountsForNonAdmin(namespace string) ([]models.K8sServiceAccount, error) { + serviceAccounts, err := kcl.fetchServiceAccounts(namespace) + if err != nil { + return nil, err + } + + nonAdminNamespaceSet := kcl.buildNonAdminNamespacesMap() + results := make([]models.K8sServiceAccount, 0) + for _, serviceAccount := range serviceAccounts { + if _, ok := nonAdminNamespaceSet[serviceAccount.Namespace]; ok { + results = append(results, serviceAccount) + } + } + + return results, nil +} + +// fetchServiceAccounts returns a list of all ServiceAccounts in the specified namespace. +func (kcl *KubeClient) fetchServiceAccounts(namespace string) ([]models.K8sServiceAccount, error) { + serviceAccounts, err := kcl.cli.CoreV1().ServiceAccounts(namespace).List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return nil, err + } + + results := make([]models.K8sServiceAccount, 0) + for _, serviceAccount := range serviceAccounts.Items { + results = append(results, kcl.parseServiceAccount(serviceAccount)) + } + + return results, nil +} + +// parseServiceAccount converts a corev1.ServiceAccount object to a models.K8sServiceAccount object. +func (kcl *KubeClient) parseServiceAccount(serviceAccount corev1.ServiceAccount) models.K8sServiceAccount { + return models.K8sServiceAccount{ + Name: serviceAccount.Name, + UID: serviceAccount.UID, + Namespace: serviceAccount.Namespace, + CreationDate: serviceAccount.CreationTimestamp.Time, + IsSystem: kcl.isSystemServiceAccount(serviceAccount.Namespace), + ImagePullSecrets: serviceAccount.ImagePullSecrets, + } +} + +// GetServiceAccount returns the details of a single service account in the given namespace. +func (kcl *KubeClient) GetServiceAccount(namespace, name string) (models.K8sServiceAccount, error) { + sa, err := kcl.cli.CoreV1().ServiceAccounts(namespace).Get(context.TODO(), name, metav1.GetOptions{}) + if err != nil { + return models.K8sServiceAccount{}, err + } + + return models.K8sServiceAccount{ + Name: sa.Name, + UID: sa.UID, + Namespace: sa.Namespace, + CreationDate: sa.CreationTimestamp.Time, + IsSystem: kcl.isSystemServiceAccount(sa.Namespace), + AutomountServiceAccountToken: sa.AutomountServiceAccountToken, + ImagePullSecrets: sa.ImagePullSecrets, + Labels: sa.Labels, + Annotations: sa.Annotations, + }, nil +} + +// GetPortainerUserServiceAccount returns the portainer ServiceAccountName associated to the specified user. +func (kcl *KubeClient) GetPortainerUserServiceAccount(tokenData *portainer.TokenData) (*corev1.ServiceAccount, error) { + portainerUserServiceAccountName := UserServiceAccountName(int(tokenData.ID), kcl.instanceID) + if tokenData.Role == portainer.AdministratorRole { + portainerUserServiceAccountName = portainerClusterAdminServiceAccountName + } + + // verify name exists as service account resource within portainer namespace + serviceAccount, err := kcl.cli.CoreV1().ServiceAccounts(portainerNamespace).Get(context.TODO(), portainerUserServiceAccountName, metav1.GetOptions{}) + if err != nil { + return nil, err + } + + return serviceAccount, nil +} + +func (kcl *KubeClient) isSystemServiceAccount(namespace string) bool { + return kcl.isSystemNamespace(namespace) +} + +// DeleteServices processes a K8sServiceDeleteRequest by deleting each service +// in its given namespace. +func (kcl *KubeClient) DeleteServiceAccounts(reqs models.K8sServiceAccountDeleteRequests) error { + var errs error + for namespace := range reqs { + for _, serviceName := range reqs[namespace] { + client := kcl.cli.CoreV1().ServiceAccounts(namespace) + + sa, err := client.Get(context.Background(), serviceName, metav1.GetOptions{}) + if err != nil { + if k8serrors.IsNotFound(err) { + continue + } + + return err + } + + if kcl.isSystemServiceAccount(sa.Namespace) { + return fmt.Errorf("cannot delete system service account %q", namespace+"/"+serviceName) + } + + if err := client.Delete(context.Background(), serviceName, metav1.DeleteOptions{}); err != nil { + errs = errors.Join(errs, err) + } + } + } + + return errs +} + +// 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, restrictDefaultNamespace bool) error { + serviceAccountName := UserServiceAccountName(userID, kcl.instanceID) + + err := kcl.ensureRequiredResourcesExist() + if err != nil { + return err + } + + 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.setupNamespaceAccesses(userID, teamIDs, serviceAccountName, restrictDefaultNamespace) +} + +func (kcl *KubeClient) ensureRequiredResourcesExist() error { + return kcl.upsertPortainerK8sClusterRoles() +} + +func (kcl *KubeClient) createUserServiceAccount(namespace, serviceAccountName string) error { + serviceAccount := &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: serviceAccountName, + }, + } + + _, err := kcl.cli.CoreV1().ServiceAccounts(namespace).Create(context.TODO(), serviceAccount, metav1.CreateOptions{}) + if err != nil && !k8serrors.IsAlreadyExists(err) { + return err + } + + return nil +} + +func (kcl *KubeClient) ensureServiceAccountHasPortainerUserClusterRole(serviceAccountName string) error { + clusterRoleBinding, err := kcl.cli.RbacV1().ClusterRoleBindings().Get(context.TODO(), 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(context.TODO(), clusterRoleBinding, metav1.CreateOptions{}) + 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(context.TODO(), clusterRoleBinding, metav1.UpdateOptions{}) + return err +} + +func (kcl *KubeClient) removeNamespaceAccessForServiceAccount(serviceAccountName, namespace string) error { + roleBindingName := namespaceClusterRoleBindingName(namespace, kcl.instanceID) + + roleBinding, err := kcl.cli.RbacV1().RoleBindings(namespace).Get(context.TODO(), 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(context.TODO(), roleBinding, metav1.UpdateOptions{}) + return err +} + +func (kcl *KubeClient) AddImagePullSecretToServiceAccount(namespace, serviceAccountName, secretName string) error { + sa, err := kcl.cli.CoreV1().ServiceAccounts(namespace).Get(context.TODO(), serviceAccountName, metav1.GetOptions{}) + if err != nil { + return err + } + + for _, ref := range sa.ImagePullSecrets { + if ref.Name == secretName { + return nil + } + } + + sa.ImagePullSecrets = append(sa.ImagePullSecrets, corev1.LocalObjectReference{Name: secretName}) + + _, err = kcl.cli.CoreV1().ServiceAccounts(namespace).Update(context.TODO(), sa, metav1.UpdateOptions{}) + return err +} + +func (kcl *KubeClient) RemoveImagePullSecretFromServiceAccount(namespace, serviceAccountName, secretName string) error { + sa, err := kcl.cli.CoreV1().ServiceAccounts(namespace).Get(context.TODO(), serviceAccountName, metav1.GetOptions{}) + if err != nil { + if k8serrors.IsNotFound(err) { + return nil + } + return err + } + + updated := sa.ImagePullSecrets[:0] + changed := false + for _, ref := range sa.ImagePullSecrets { + if ref.Name == secretName { + changed = true + continue + } + updated = append(updated, ref) + } + + if !changed { + return nil + } + + sa.ImagePullSecrets = updated + + _, err = kcl.cli.CoreV1().ServiceAccounts(namespace).Update(context.TODO(), sa, metav1.UpdateOptions{}) + return err +} + +func (kcl *KubeClient) UpdateServiceAccountImagePullSecrets(namespace, name string, secretNames []string) error { + sa, err := kcl.cli.CoreV1().ServiceAccounts(namespace).Get(context.TODO(), name, metav1.GetOptions{}) + if err != nil { + return fmt.Errorf("failed to get service account %q: %w", name, err) + } + + refs := make([]corev1.LocalObjectReference, 0, len(secretNames)) + for _, s := range secretNames { + refs = append(refs, corev1.LocalObjectReference{Name: s}) + } + sa.ImagePullSecrets = refs + + _, err = kcl.cli.CoreV1().ServiceAccounts(namespace).Update(context.TODO(), sa, metav1.UpdateOptions{}) + if err != nil { + return fmt.Errorf("failed to update service account %q: %w", name, err) + } + return nil +} + +func (kcl *KubeClient) ensureNamespaceAccessForServiceAccount(serviceAccountName, namespace string) error { + roleBindingName := namespaceClusterRoleBindingName(namespace, kcl.instanceID) + + roleBinding, err := kcl.cli.RbacV1().RoleBindings(namespace).Get(context.TODO(), 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(context.TODO(), roleBinding, metav1.CreateOptions{}) + 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(context.TODO(), roleBinding, metav1.UpdateOptions{}) + return err +} diff --git a/api/kubernetes/cli/service_account_test.go b/api/kubernetes/cli/service_account_test.go new file mode 100644 index 0000000..845f0e0 --- /dev/null +++ b/api/kubernetes/cli/service_account_test.go @@ -0,0 +1,422 @@ +package cli + +import ( + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + kfake "k8s.io/client-go/kubernetes/fake" +) + +func Test_GetServiceAccount(t *testing.T) { + t.Parallel() + + t.Run("returns error if non-existent", func(t *testing.T) { + k := &KubeClient{ + cli: kfake.NewSimpleClientset(), + instanceID: "test", + } + tokenData := &portainer.TokenData{ID: 1} + _, err := k.GetPortainerUserServiceAccount(tokenData) + if err == nil { + t.Error("GetPortainerUserServiceAccount should fail with service account not found") + } + }) + + t.Run("succeeds for cluster admin role", func(t *testing.T) { + k := &KubeClient{ + cli: kfake.NewSimpleClientset(), + instanceID: "test", + } + + tokenData := &portainer.TokenData{ + ID: 1, + Role: portainer.AdministratorRole, + Username: portainerClusterAdminServiceAccountName, + } + serviceAccount := &v1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: tokenData.Username, + }, + } + _, err := k.cli.CoreV1().ServiceAccounts(portainerNamespace).Create(t.Context(), serviceAccount, metav1.CreateOptions{}) + if err != nil { + t.Errorf("failed to create service acount; err=%s", err) + } + defer func() { + err := k.cli.CoreV1().ServiceAccounts(portainerNamespace).Delete(t.Context(), serviceAccount.Name, metav1.DeleteOptions{}) + require.NoError(t, err) + }() + + sa, err := k.GetPortainerUserServiceAccount(tokenData) + if err != nil { + t.Errorf("GetPortainerUserServiceAccount should succeed; err=%s", err) + } + + want := "portainer-sa-clusteradmin" + if sa.Name != want { + t.Errorf("GetServiceAccount should succeed and return correct sa name; got=%s want=%s", sa.Name, want) + } + }) + + t.Run("succeeds for standard user role", func(t *testing.T) { + k := &KubeClient{ + cli: kfake.NewSimpleClientset(), + instanceID: "test", + } + + tokenData := &portainer.TokenData{ + ID: 1, + Role: portainer.StandardUserRole, + } + serviceAccountName := UserServiceAccountName(int(tokenData.ID), k.instanceID) + serviceAccount := &v1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: serviceAccountName, + }, + } + _, err := k.cli.CoreV1().ServiceAccounts(portainerNamespace).Create(t.Context(), serviceAccount, metav1.CreateOptions{}) + if err != nil { + t.Errorf("failed to create service acount; err=%s", err) + } + defer func() { + err := k.cli.CoreV1().ServiceAccounts(portainerNamespace).Delete(t.Context(), serviceAccount.Name, metav1.DeleteOptions{}) + require.NoError(t, err) + }() + + sa, err := k.GetPortainerUserServiceAccount(tokenData) + if err != nil { + t.Errorf("GetPortainerUserServiceAccount should succeed; err=%s", err) + } + + want := "portainer-sa-user-test-1" + if sa.Name != want { + t.Errorf("GetPortainerUserServiceAccount should succeed and return correct sa name; got=%s want=%s", sa.Name, want) + } + }) + +} + +func TestGetServiceAccountDetails(t *testing.T) { + t.Parallel() + t.Run("returns service account details", func(t *testing.T) { + automount := false + sa := &v1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-sa", + Namespace: "default", + Labels: map[string]string{"app": "web"}, + }, + AutomountServiceAccountToken: &automount, + ImagePullSecrets: []v1.LocalObjectReference{ + {Name: "registry-secret"}, + }, + } + kcl := &KubeClient{ + cli: kfake.NewSimpleClientset(sa), + instanceID: "test", + } + + result, err := kcl.GetServiceAccount("default", "my-sa") + require.NoError(t, err) + + assert.Equal(t, "my-sa", result.Name) + assert.Equal(t, "default", result.Namespace) + assert.Equal(t, &automount, result.AutomountServiceAccountToken) + assert.Len(t, result.ImagePullSecrets, 1) + assert.Equal(t, "registry-secret", result.ImagePullSecrets[0].Name) + assert.Equal(t, map[string]string{"app": "web"}, result.Labels) + }) + + t.Run("returns error when service account not found", func(t *testing.T) { + kcl := &KubeClient{ + cli: kfake.NewSimpleClientset(), + instanceID: "test", + } + + _, err := kcl.GetServiceAccount("default", "does-not-exist") + require.Error(t, err) + }) + + t.Run("marks system namespace accounts as system", func(t *testing.T) { + sa := &v1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{Name: "default", Namespace: "kube-system"}, + } + ns := &v1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: "kube-system"}, + } + kcl := &KubeClient{ + cli: kfake.NewSimpleClientset(sa, ns), + instanceID: "test", + } + + result, err := kcl.GetServiceAccount("kube-system", "default") + require.NoError(t, err) + assert.True(t, result.IsSystem) + }) + + t.Run("returns nil automount when not set", func(t *testing.T) { + sa := &v1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{Name: "my-sa", Namespace: "default"}, + } + kcl := &KubeClient{ + cli: kfake.NewSimpleClientset(sa), + instanceID: "test", + } + + result, err := kcl.GetServiceAccount("default", "my-sa") + require.NoError(t, err) + assert.Nil(t, result.AutomountServiceAccountToken) + }) +} + +func TestAddImagePullSecretToServiceAccount(t *testing.T) { + t.Parallel() + newKCL := func(sa *v1.ServiceAccount) *KubeClient { + return &KubeClient{cli: kfake.NewSimpleClientset(sa), instanceID: "test"} + } + + defaultSA := func(namespace string, refs ...string) *v1.ServiceAccount { + pullSecrets := make([]v1.LocalObjectReference, len(refs)) + for i, r := range refs { + pullSecrets[i] = v1.LocalObjectReference{Name: r} + } + return &v1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{Name: "default", Namespace: namespace}, + ImagePullSecrets: pullSecrets, + } + } + + t.Run("adds entry to SA with empty ImagePullSecrets", func(t *testing.T) { + kcl := newKCL(defaultSA("ns-a")) + require.NoError(t, kcl.AddImagePullSecretToServiceAccount("ns-a", "default", "registry-1")) + + sa, err := kcl.cli.CoreV1().ServiceAccounts("ns-a").Get(t.Context(), "default", metav1.GetOptions{}) + require.NoError(t, err) + require.Len(t, sa.ImagePullSecrets, 1) + assert.Equal(t, "registry-1", sa.ImagePullSecrets[0].Name) + }) + + t.Run("is idempotent when secret already present", func(t *testing.T) { + kcl := newKCL(defaultSA("ns-a", "registry-1")) + require.NoError(t, kcl.AddImagePullSecretToServiceAccount("ns-a", "default", "registry-1")) + + sa, err := kcl.cli.CoreV1().ServiceAccounts("ns-a").Get(t.Context(), "default", metav1.GetOptions{}) + require.NoError(t, err) + assert.Len(t, sa.ImagePullSecrets, 1) + }) + + t.Run("preserves pre-existing pull secrets", func(t *testing.T) { + kcl := newKCL(defaultSA("ns-a", "other-1", "other-2")) + require.NoError(t, kcl.AddImagePullSecretToServiceAccount("ns-a", "default", "registry-3")) + + sa, err := kcl.cli.CoreV1().ServiceAccounts("ns-a").Get(t.Context(), "default", metav1.GetOptions{}) + require.NoError(t, err) + require.Len(t, sa.ImagePullSecrets, 3) + assert.Equal(t, "other-1", sa.ImagePullSecrets[0].Name) + assert.Equal(t, "other-2", sa.ImagePullSecrets[1].Name) + assert.Equal(t, "registry-3", sa.ImagePullSecrets[2].Name) + }) + + t.Run("returns error when SA does not exist", func(t *testing.T) { + kcl := &KubeClient{cli: kfake.NewSimpleClientset(), instanceID: "test"} + err := kcl.AddImagePullSecretToServiceAccount("ns-a", "default", "registry-1") + require.Error(t, err) + }) + + t.Run("works with non-default service account name", func(t *testing.T) { + sa := &v1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{Name: "custom", Namespace: "ns-a"}, + } + kcl := newKCL(sa) + require.NoError(t, kcl.AddImagePullSecretToServiceAccount("ns-a", "custom", "registry-1")) + + got, err := kcl.cli.CoreV1().ServiceAccounts("ns-a").Get(t.Context(), "custom", metav1.GetOptions{}) + require.NoError(t, err) + require.Len(t, got.ImagePullSecrets, 1) + assert.Equal(t, "registry-1", got.ImagePullSecrets[0].Name) + }) +} + +func TestRemoveImagePullSecretFromServiceAccount(t *testing.T) { + t.Parallel() + newKCL := func(sa *v1.ServiceAccount) *KubeClient { + return &KubeClient{cli: kfake.NewSimpleClientset(sa), instanceID: "test"} + } + + defaultSA := func(namespace string, refs ...string) *v1.ServiceAccount { + pullSecrets := make([]v1.LocalObjectReference, len(refs)) + for i, r := range refs { + pullSecrets[i] = v1.LocalObjectReference{Name: r} + } + return &v1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{Name: "default", Namespace: namespace}, + ImagePullSecrets: pullSecrets, + } + } + + t.Run("removes target entry from ImagePullSecrets", func(t *testing.T) { + kcl := newKCL(defaultSA("ns-a", "registry-1")) + require.NoError(t, kcl.RemoveImagePullSecretFromServiceAccount("ns-a", "default", "registry-1")) + + sa, err := kcl.cli.CoreV1().ServiceAccounts("ns-a").Get(t.Context(), "default", metav1.GetOptions{}) + require.NoError(t, err) + assert.Empty(t, sa.ImagePullSecrets) + }) + + t.Run("is idempotent when secret not in list", func(t *testing.T) { + kcl := newKCL(defaultSA("ns-a", "other-secret")) + require.NoError(t, kcl.RemoveImagePullSecretFromServiceAccount("ns-a", "default", "registry-1")) + + sa, err := kcl.cli.CoreV1().ServiceAccounts("ns-a").Get(t.Context(), "default", metav1.GetOptions{}) + require.NoError(t, err) + assert.Len(t, sa.ImagePullSecrets, 1) + }) + + t.Run("preserves other pull secrets when removing target", func(t *testing.T) { + kcl := newKCL(defaultSA("ns-a", "other-1", "registry-2", "other-3")) + require.NoError(t, kcl.RemoveImagePullSecretFromServiceAccount("ns-a", "default", "registry-2")) + + sa, err := kcl.cli.CoreV1().ServiceAccounts("ns-a").Get(t.Context(), "default", metav1.GetOptions{}) + require.NoError(t, err) + require.Len(t, sa.ImagePullSecrets, 2) + assert.Equal(t, "other-1", sa.ImagePullSecrets[0].Name) + assert.Equal(t, "other-3", sa.ImagePullSecrets[1].Name) + }) + + t.Run("returns nil when SA does not exist", func(t *testing.T) { + kcl := &KubeClient{cli: kfake.NewSimpleClientset(), instanceID: "test"} + require.NoError(t, kcl.RemoveImagePullSecretFromServiceAccount("ns-a", "default", "registry-1")) + }) + + t.Run("returns nil when ImagePullSecrets is nil", func(t *testing.T) { + sa := &v1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{Name: "default", Namespace: "ns-a"}, + } + kcl := newKCL(sa) + require.NoError(t, kcl.RemoveImagePullSecretFromServiceAccount("ns-a", "default", "registry-1")) + }) + + t.Run("removes all occurrences when target appears more than once", func(t *testing.T) { + kcl := newKCL(defaultSA("ns-a", "registry-1", "registry-1")) + require.NoError(t, kcl.RemoveImagePullSecretFromServiceAccount("ns-a", "default", "registry-1")) + + sa, err := kcl.cli.CoreV1().ServiceAccounts("ns-a").Get(t.Context(), "default", metav1.GetOptions{}) + require.NoError(t, err) + assert.Empty(t, sa.ImagePullSecrets) + }) +} + +func TestGetServiceAccount_CreatesAndFetches(t *testing.T) { + t.Parallel() + t.Run("returns annotations when set", func(t *testing.T) { + sa := &v1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: "annotated-sa", + Namespace: "default", + Annotations: map[string]string{"example.com/key": "value"}, + }, + } + kcl := &KubeClient{ + cli: kfake.NewSimpleClientset(sa), + instanceID: "test", + } + + result, err := kcl.GetServiceAccount("default", "annotated-sa") + require.NoError(t, err) + assert.Equal(t, map[string]string{"example.com/key": "value"}, result.Annotations) + }) + + t.Run("round-trips UID correctly", func(t *testing.T) { + sa := &v1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: "uid-sa", + Namespace: "default", + UID: "abc-123-def", + }, + } + kcl := &KubeClient{ + cli: kfake.NewSimpleClientset(sa), + instanceID: "test", + } + + result, err := kcl.GetServiceAccount("default", "uid-sa") + require.NoError(t, err) + assert.Equal(t, "abc-123-def", string(result.UID)) + }) + + t.Run("creates service account and fetches it back", func(t *testing.T) { + kcl := &KubeClient{ + cli: kfake.NewSimpleClientset(), + instanceID: "test", + } + + sa := &v1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{Name: "fresh-sa", Namespace: "staging"}, + } + _, err := kcl.cli.CoreV1().ServiceAccounts("staging").Create(t.Context(), sa, metav1.CreateOptions{}) + require.NoError(t, err) + + result, err := kcl.GetServiceAccount("staging", "fresh-sa") + require.NoError(t, err) + assert.Equal(t, "fresh-sa", result.Name) + assert.Equal(t, "staging", result.Namespace) + }) +} + +func TestUpdateServiceAccountImagePullSecrets(t *testing.T) { + t.Parallel() + newKCL := func(sa *v1.ServiceAccount) *KubeClient { + return &KubeClient{cli: kfake.NewSimpleClientset(sa), instanceID: "test"} + } + + defaultSA := func(namespace string, refs ...string) *v1.ServiceAccount { + pullSecrets := make([]v1.LocalObjectReference, len(refs)) + for i, r := range refs { + pullSecrets[i] = v1.LocalObjectReference{Name: r} + } + return &v1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{Name: "default", Namespace: namespace}, + ImagePullSecrets: pullSecrets, + } + } + + t.Run("sets full list replacing existing", func(t *testing.T) { + kcl := newKCL(defaultSA("ns-a", "old-1", "old-2")) + require.NoError(t, kcl.UpdateServiceAccountImagePullSecrets("ns-a", "default", []string{"new-1"})) + + sa, err := kcl.cli.CoreV1().ServiceAccounts("ns-a").Get(t.Context(), "default", metav1.GetOptions{}) + require.NoError(t, err) + require.Len(t, sa.ImagePullSecrets, 1) + assert.Equal(t, "new-1", sa.ImagePullSecrets[0].Name) + }) + + t.Run("clears list when secretNames is empty", func(t *testing.T) { + kcl := newKCL(defaultSA("ns-a", "secret-1", "secret-2")) + require.NoError(t, kcl.UpdateServiceAccountImagePullSecrets("ns-a", "default", []string{})) + + sa, err := kcl.cli.CoreV1().ServiceAccounts("ns-a").Get(t.Context(), "default", metav1.GetOptions{}) + require.NoError(t, err) + assert.Empty(t, sa.ImagePullSecrets) + }) + + t.Run("returns error when SA does not exist", func(t *testing.T) { + kcl := &KubeClient{cli: kfake.NewSimpleClientset(), instanceID: "test"} + err := kcl.UpdateServiceAccountImagePullSecrets("ns-a", "does-not-exist", []string{"secret-1"}) + require.Error(t, err) + }) + + t.Run("sets list on SA with no existing pull secrets", func(t *testing.T) { + kcl := newKCL(defaultSA("ns-a")) + require.NoError(t, kcl.UpdateServiceAccountImagePullSecrets("ns-a", "default", []string{"s1", "s2"})) + + sa, err := kcl.cli.CoreV1().ServiceAccounts("ns-a").Get(t.Context(), "default", metav1.GetOptions{}) + require.NoError(t, err) + require.Len(t, sa.ImagePullSecrets, 2) + assert.Equal(t, "s1", sa.ImagePullSecrets[0].Name) + assert.Equal(t, "s2", sa.ImagePullSecrets[1].Name) + }) +} diff --git a/api/kubernetes/cli/service_test.go b/api/kubernetes/cli/service_test.go new file mode 100644 index 0000000..6d49805 --- /dev/null +++ b/api/kubernetes/cli/service_test.go @@ -0,0 +1,16 @@ +package cli + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGetServices(t *testing.T) { + t.Parallel() + kcl := &KubeClient{} + + services, err := kcl.GetServices("default") + require.NoError(t, err) + require.Empty(t, services) +} diff --git a/api/kubernetes/cli/storage.go b/api/kubernetes/cli/storage.go new file mode 100644 index 0000000..4b8ca7e --- /dev/null +++ b/api/kubernetes/cli/storage.go @@ -0,0 +1,37 @@ +package cli + +import ( + "context" + + portainer "github.com/portainer/portainer/api" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func (kcl *KubeClient) GetStorage() ([]portainer.KubernetesStorageClassConfig, error) { + var storages []portainer.KubernetesStorageClassConfig + + storageClient := kcl.cli.StorageV1().StorageClasses() + storageList, err := storageClient.List(context.Background(), metav1.ListOptions{}) + if err != nil { + return storages, err + } + + for _, s := range storageList.Items { + var storage portainer.KubernetesStorageClassConfig + + v, ok := s.Annotations["storageclass.kubernetes.io/is-default-class"] + if !ok || v != "true" { + continue + } + storage.Name = s.Name + storage.Provisioner = s.Provisioner + storage.AccessModes = []string{"RWO"} + if s.AllowVolumeExpansion != nil { + storage.AllowVolumeExpansion = *s.AllowVolumeExpansion + } + + storages = append(storages, storage) + } + + return storages, nil +} diff --git a/api/kubernetes/cli/storage_classes.go b/api/kubernetes/cli/storage_classes.go new file mode 100644 index 0000000..d27b805 --- /dev/null +++ b/api/kubernetes/cli/storage_classes.go @@ -0,0 +1,136 @@ +package cli + +import ( + "context" + "fmt" + + "github.com/segmentio/encoding/json" + + models "github.com/portainer/portainer/api/http/models/kubernetes" + "github.com/rs/zerolog/log" + storagev1 "k8s.io/api/storage/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" +) + +const storageClassDefaultAnnotation = "storageclass.kubernetes.io/is-default-class" + +// GetStorageClasses returns all StorageClasses in the cluster. +func (kcl *KubeClient) GetStorageClasses() ([]models.K8sStorageClass, error) { + scList, err := kcl.cli.StorageV1().StorageClasses().List(context.Background(), metav1.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("unable to list storage classes. Error: %w", err) + } + + results := make([]models.K8sStorageClass, 0, len(scList.Items)) + for i := range scList.Items { + results = append(results, parseStorageClassDetail(&scList.Items[i])) + } + + return results, nil +} + +// GetStorageClass returns a single StorageClass by name. +func (kcl *KubeClient) GetStorageClass(name string) (*models.K8sStorageClass, error) { + sc, err := kcl.cli.StorageV1().StorageClasses().Get(context.Background(), name, metav1.GetOptions{}) + if err != nil { + return nil, fmt.Errorf("unable to get storage class %s. Error: %w", name, err) + } + + result := parseStorageClassDetail(sc) + return &result, nil +} + +// DeleteStorageClasses deletes the specified StorageClasses by name. +func (kcl *KubeClient) DeleteStorageClasses(names []string) error { + for _, name := range names { + log.Debug(). + Str("context", "DeleteStorageClasses"). + Str("storage_class", name). + Msg("Deleting storage class") + + err := kcl.cli.StorageV1().StorageClasses().Delete(context.Background(), name, metav1.DeleteOptions{}) + if err != nil { + return fmt.Errorf("unable to delete storage class %s. Error: %w", name, err) + } + } + + return nil +} + +// SetDefaultStorageClass sets the specified StorageClass as the default. +// It removes the default annotation from any other StorageClass that is currently default. +func (kcl *KubeClient) SetDefaultStorageClass(name string) error { + _, err := kcl.cli.StorageV1().StorageClasses().Get(context.Background(), name, metav1.GetOptions{}) + if err != nil { + return fmt.Errorf("unable to find storage class %s. Error: %w", name, err) + } + + scList, err := kcl.cli.StorageV1().StorageClasses().List(context.Background(), metav1.ListOptions{}) + if err != nil { + return fmt.Errorf("unable to list storage classes. Error: %w", err) + } + + for i := range scList.Items { + sc := &scList.Items[i] + isCurrentDefault := sc.Annotations[storageClassDefaultAnnotation] == "true" + isTarget := sc.Name == name + + if isTarget && !isCurrentDefault { + if err := kcl.patchStorageClassDefaultAnnotation(sc.Name, "true"); err != nil { + return err + } + } else if !isTarget && isCurrentDefault { + if err := kcl.patchStorageClassDefaultAnnotation(sc.Name, "false"); err != nil { + return err + } + } + } + + return nil +} + +func (kcl *KubeClient) patchStorageClassDefaultAnnotation(name, value string) error { + patch := map[string]any{ + "metadata": map[string]any{ + "annotations": map[string]string{ + storageClassDefaultAnnotation: value, + }, + }, + } + + patchBytes, err := json.Marshal(patch) + if err != nil { + return fmt.Errorf("unable to marshal patch for storage class %s. Error: %w", name, err) + } + + _, err = kcl.cli.StorageV1().StorageClasses().Patch( + context.Background(), + name, + types.MergePatchType, + patchBytes, + metav1.PatchOptions{}, + ) + if err != nil { + return fmt.Errorf("unable to patch default annotation on storage class %s. Error: %w", name, err) + } + + return nil +} + +// parseStorageClassDetail parses a StorageClass with full detail including default status. +func parseStorageClassDetail(sc *storagev1.StorageClass) models.K8sStorageClass { + isDefault := sc.Annotations[storageClassDefaultAnnotation] == "true" + return models.K8sStorageClass{ + Name: sc.Name, + Provisioner: sc.Provisioner, + ReclaimPolicy: sc.ReclaimPolicy, + AllowVolumeExpansion: sc.AllowVolumeExpansion, + IsDefault: isDefault, + Annotations: sc.Annotations, + Labels: sc.Labels, + CreationDate: sc.CreationTimestamp.Time, + Parameters: sc.Parameters, + MountOptions: sc.MountOptions, + } +} diff --git a/api/kubernetes/cli/storage_classes_test.go b/api/kubernetes/cli/storage_classes_test.go new file mode 100644 index 0000000..878a281 --- /dev/null +++ b/api/kubernetes/cli/storage_classes_test.go @@ -0,0 +1,246 @@ +package cli + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + storagev1 "k8s.io/api/storage/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + kfake "k8s.io/client-go/kubernetes/fake" +) + +func TestParseStorageClassDetail_IsDefault(t *testing.T) { + t.Parallel() + + t.Run("annotation true sets isDefault", func(t *testing.T) { + t.Parallel() + sc := &storagev1.StorageClass{ + ObjectMeta: metav1.ObjectMeta{ + Name: "fast", + Annotations: map[string]string{ + storageClassDefaultAnnotation: "true", + }, + }, + } + result := parseStorageClassDetail(sc) + assert.True(t, result.IsDefault) + }) + + t.Run("annotation false gives isDefault=false", func(t *testing.T) { + t.Parallel() + sc := &storagev1.StorageClass{ + ObjectMeta: metav1.ObjectMeta{ + Name: "slow", + Annotations: map[string]string{ + storageClassDefaultAnnotation: "false", + }, + }, + } + result := parseStorageClassDetail(sc) + assert.False(t, result.IsDefault) + }) + + t.Run("missing annotation gives isDefault=false", func(t *testing.T) { + t.Parallel() + sc := &storagev1.StorageClass{ + ObjectMeta: metav1.ObjectMeta{Name: "no-annotation"}, + } + result := parseStorageClassDetail(sc) + assert.False(t, result.IsDefault) + }) +} + +func TestSetDefaultStorageClass(t *testing.T) { + t.Parallel() + + t.Run("sets default annotation on target SC", func(t *testing.T) { + t.Parallel() + k := NewTestKubeClient(kfake.NewClientset()) + + sc := &storagev1.StorageClass{ + ObjectMeta: metav1.ObjectMeta{Name: "standard"}, + } + _, err := k.cli.StorageV1().StorageClasses().Create(t.Context(), sc, metav1.CreateOptions{}) + require.NoError(t, err) + + err = k.SetDefaultStorageClass("standard") + require.NoError(t, err) + + result, err := k.GetStorageClass("standard") + require.NoError(t, err) + assert.True(t, result.IsDefault) + }) + + t.Run("removes default annotation from previously-default SC", func(t *testing.T) { + t.Parallel() + k := NewTestKubeClient(kfake.NewClientset()) + + oldDefault := &storagev1.StorageClass{ + ObjectMeta: metav1.ObjectMeta{ + Name: "old-default", + Annotations: map[string]string{ + storageClassDefaultAnnotation: "true", + }, + }, + } + newDefault := &storagev1.StorageClass{ + ObjectMeta: metav1.ObjectMeta{Name: "new-default"}, + } + + _, err := k.cli.StorageV1().StorageClasses().Create(t.Context(), oldDefault, metav1.CreateOptions{}) + require.NoError(t, err) + _, err = k.cli.StorageV1().StorageClasses().Create(t.Context(), newDefault, metav1.CreateOptions{}) + require.NoError(t, err) + + err = k.SetDefaultStorageClass("new-default") + require.NoError(t, err) + + old, err := k.GetStorageClass("old-default") + require.NoError(t, err) + assert.False(t, old.IsDefault) + + newSC, err := k.GetStorageClass("new-default") + require.NoError(t, err) + assert.True(t, newSC.IsDefault) + }) + + t.Run("no-op if target is already default", func(t *testing.T) { + t.Parallel() + k := NewTestKubeClient(kfake.NewClientset()) + + sc := &storagev1.StorageClass{ + ObjectMeta: metav1.ObjectMeta{ + Name: "already-default", + Annotations: map[string]string{ + storageClassDefaultAnnotation: "true", + }, + }, + } + _, err := k.cli.StorageV1().StorageClasses().Create(t.Context(), sc, metav1.CreateOptions{}) + require.NoError(t, err) + + err = k.SetDefaultStorageClass("already-default") + require.NoError(t, err) + + result, err := k.GetStorageClass("already-default") + require.NoError(t, err) + assert.True(t, result.IsDefault) + }) + + t.Run("handles multiple SCs where only one was previously default", func(t *testing.T) { + t.Parallel() + k := NewTestKubeClient(kfake.NewClientset()) + + scs := []*storagev1.StorageClass{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "sc-default", + Annotations: map[string]string{ + storageClassDefaultAnnotation: "true", + }, + }, + }, + {ObjectMeta: metav1.ObjectMeta{Name: "sc-other-1"}}, + {ObjectMeta: metav1.ObjectMeta{Name: "sc-other-2"}}, + {ObjectMeta: metav1.ObjectMeta{Name: "sc-target"}}, + } + + for _, sc := range scs { + _, err := k.cli.StorageV1().StorageClasses().Create(t.Context(), sc, metav1.CreateOptions{}) + require.NoError(t, err) + } + + err := k.SetDefaultStorageClass("sc-target") + require.NoError(t, err) + + target, err := k.GetStorageClass("sc-target") + require.NoError(t, err) + assert.True(t, target.IsDefault) + + oldDefault, err := k.GetStorageClass("sc-default") + require.NoError(t, err) + assert.False(t, oldDefault.IsDefault) + + for _, name := range []string{"sc-other-1", "sc-other-2"} { + sc, err := k.GetStorageClass(name) + require.NoError(t, err) + assert.False(t, sc.IsDefault) + } + }) +} + +func TestGetStorageClasses(t *testing.T) { + t.Parallel() + + t.Run("empty cluster returns empty slice", func(t *testing.T) { + t.Parallel() + k := NewTestKubeClient(kfake.NewClientset()) + scs, err := k.GetStorageClasses() + require.NoError(t, err) + assert.Empty(t, scs) + }) + + t.Run("returns all SCs with correct fields", func(t *testing.T) { + t.Parallel() + k := NewTestKubeClient(kfake.NewClientset()) + + allowExpansion := true + sc := &storagev1.StorageClass{ + ObjectMeta: metav1.ObjectMeta{ + Name: "fast", + Annotations: map[string]string{ + storageClassDefaultAnnotation: "true", + }, + }, + Provisioner: "kubernetes.io/aws-ebs", + AllowVolumeExpansion: &allowExpansion, + } + _, err := k.cli.StorageV1().StorageClasses().Create(t.Context(), sc, metav1.CreateOptions{}) + require.NoError(t, err) + + scs, err := k.GetStorageClasses() + require.NoError(t, err) + require.Len(t, scs, 1) + + got := scs[0] + assert.Equal(t, "fast", got.Name) + assert.Equal(t, "kubernetes.io/aws-ebs", got.Provisioner) + assert.True(t, got.IsDefault) + require.NotNil(t, got.AllowVolumeExpansion) + assert.True(t, *got.AllowVolumeExpansion) + }) +} + +func TestDeleteStorageClasses(t *testing.T) { + t.Parallel() + + t.Run("deletes specified SCs", func(t *testing.T) { + t.Parallel() + k := NewTestKubeClient(kfake.NewClientset()) + + for _, name := range []string{"sc-a", "sc-b", "sc-c"} { + sc := &storagev1.StorageClass{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + } + _, err := k.cli.StorageV1().StorageClasses().Create(t.Context(), sc, metav1.CreateOptions{}) + require.NoError(t, err) + } + + err := k.DeleteStorageClasses([]string{"sc-a", "sc-c"}) + require.NoError(t, err) + + scs, err := k.GetStorageClasses() + require.NoError(t, err) + require.Len(t, scs, 1) + assert.Equal(t, "sc-b", scs[0].Name) + }) + + t.Run("returns error for non-existent SC", func(t *testing.T) { + t.Parallel() + k := NewTestKubeClient(kfake.NewClientset()) + + err := k.DeleteStorageClasses([]string{"does-not-exist"}) + assert.Error(t, err) + }) +} diff --git a/api/kubernetes/cli/test_helpers.go b/api/kubernetes/cli/test_helpers.go new file mode 100644 index 0000000..689e964 --- /dev/null +++ b/api/kubernetes/cli/test_helpers.go @@ -0,0 +1,27 @@ +package cli + +import ( + "strconv" + + portainer "github.com/portainer/portainer/api" + "k8s.io/client-go/kubernetes" +) + +// NewTestClientFactory creates a ClientFactory with a pre-seeded KubeClient for +// a specific endpoint ID. Intended for use in tests across packages that need to +// inject a fake Kubernetes client without a real cluster connection. +func NewTestClientFactory(endpointID portainer.EndpointID, kcl *KubeClient) *ClientFactory { + factory, _ := NewClientFactory(nil, nil, nil, "test", "", "") + factory.endpointProxyClients.Set(strconv.Itoa(int(endpointID)), kcl, 0) + return factory +} + +// NewTestKubeClient creates a KubeClient backed by the provided kubernetes.Interface. +// Intended for use in tests. +func NewTestKubeClient(clientset kubernetes.Interface) *KubeClient { + return &KubeClient{ + cli: clientset, + instanceID: "test", + isKubeAdmin: true, + } +} diff --git a/api/kubernetes/cli/volumes.go b/api/kubernetes/cli/volumes.go new file mode 100644 index 0000000..e98054f --- /dev/null +++ b/api/kubernetes/cli/volumes.go @@ -0,0 +1,305 @@ +package cli + +import ( + "context" + "fmt" + + models "github.com/portainer/portainer/api/http/models/kubernetes" + "github.com/rs/zerolog/log" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + storagev1 "k8s.io/api/storage/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// GetVolumes gets the volumes in the current k8s environment(endpoint). +// If the user is an admin, it fetches all the volumes in the cluster. +// If the user is not an admin, it fetches the volumes in the namespaces the user has access to. +// It returns a list of K8sVolumeInfo. +func (kcl *KubeClient) GetVolumes(namespace string) ([]models.K8sVolumeInfo, error) { + if kcl.GetIsKubeAdmin() { + return kcl.fetchVolumes(namespace) + } + + return kcl.fetchVolumesForNonAdmin(namespace) +} + +// GetVolume gets the volume with the given name and namespace. +func (kcl *KubeClient) GetVolume(namespace, volumeName string) (*models.K8sVolumeInfo, error) { + persistentVolumeClaim, err := kcl.cli.CoreV1().PersistentVolumeClaims(namespace).Get(context.TODO(), volumeName, metav1.GetOptions{}) + if err != nil { + if k8serrors.IsNotFound(err) { + return nil, nil + } + + return nil, err + } + + persistentVolumesMap, storageClassesMap, err := kcl.fetchPersistentVolumesAndStorageClassesMap() + if err != nil { + return nil, err + } + + volume := parseVolume(persistentVolumeClaim, persistentVolumesMap, storageClassesMap) + return &volume, nil +} + +// fetchVolumesForNonAdmin fetches the volumes in the namespaces the user has access to. +// This function is called when the user is not an admin. +// It fetches all the persistent volume claims, persistent volumes and storage classes in the namespaces the user has access to. +func (kcl *KubeClient) fetchVolumesForNonAdmin(namespace string) ([]models.K8sVolumeInfo, error) { + nonAdminNamespaces := kcl.GetClientNonAdminNamespaces() + + log.Debug(). + Strs("non_admin_namespaces", nonAdminNamespaces). + Msg("fetching volumes for non-admin user") + + if len(nonAdminNamespaces) == 0 { + return nil, nil + } + + volumes, err := kcl.fetchVolumes(namespace) + if err != nil { + return nil, err + } + + nonAdminNamespaceSet := kcl.buildNonAdminNamespacesMap() + results := make([]models.K8sVolumeInfo, 0) + for _, volume := range volumes { + if _, ok := nonAdminNamespaceSet[volume.PersistentVolumeClaim.Namespace]; ok { + results = append(results, volume) + } + } + + return results, nil +} + +// fetchVolumes fetches all the persistent volume claims, persistent volumes and storage classes in the given namespace. +// It returns a list of K8sVolumeInfo. +// This function is called by fetchVolumesForAdmin and fetchVolumesForNonAdmin. +func (kcl *KubeClient) fetchVolumes(namespace string) ([]models.K8sVolumeInfo, error) { + volumes := make([]models.K8sVolumeInfo, 0) + persistentVolumeClaims, err := kcl.cli.CoreV1().PersistentVolumeClaims(namespace).List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return nil, err + } + + if len(persistentVolumeClaims.Items) > 0 { + persistentVolumesMap, storageClassesMap, err := kcl.fetchPersistentVolumesAndStorageClassesMap() + if err != nil { + return nil, err + } + + for _, persistentVolumeClaim := range persistentVolumeClaims.Items { + volumes = append(volumes, parseVolume(&persistentVolumeClaim, persistentVolumesMap, storageClassesMap)) + } + } + + return volumes, nil +} + +// parseVolume parses the given persistent volume claim and returns a K8sVolumeInfo. +// This function is called by fetchVolumes. +// It returns a K8sVolumeInfo. +func parseVolume(persistentVolumeClaim *corev1.PersistentVolumeClaim, persistentVolumesMap map[string]models.K8sPersistentVolume, storageClassesMap map[string]models.K8sStorageClass) models.K8sVolumeInfo { + volume := models.K8sVolumeInfo{} + volumeClaim := parsePersistentVolumeClaim(persistentVolumeClaim) + + if volumeClaim.VolumeName != "" { + persistentVolume, ok := persistentVolumesMap[volumeClaim.VolumeName] + if ok { + volume.PersistentVolume = persistentVolume + } + } + + if volumeClaim.StorageClass != nil { + storageClass, ok := storageClassesMap[*volumeClaim.StorageClass] + if ok { + volume.StorageClass = storageClass + } + } + + volume.PersistentVolumeClaim = volumeClaim + return volume +} + +// parsePersistentVolumeClaim parses the given persistent volume claim and returns a K8sPersistentVolumeClaim. +func parsePersistentVolumeClaim(volume *corev1.PersistentVolumeClaim) models.K8sPersistentVolumeClaim { + storage := volume.Spec.Resources.Requests[corev1.ResourceStorage] + return models.K8sPersistentVolumeClaim{ + ID: string(volume.UID), + Name: volume.Name, + Namespace: volume.Namespace, + CreationDate: volume.CreationTimestamp.Time, + Storage: storage.Value(), + StorageRequest: storage.String(), + AccessModes: humanReadableAccessModes(volume.Spec.AccessModes), + HumanReadableAccessModes: volume.Spec.AccessModes, + VolumeName: volume.Spec.VolumeName, + ResourcesRequests: &volume.Spec.Resources.Requests, + StorageClass: volume.Spec.StorageClassName, + VolumeMode: volume.Spec.VolumeMode, + OwningApplications: nil, + Phase: volume.Status.Phase, + Labels: volume.Labels, + } +} + +// parsePersistentVolume parses the given persistent volume and returns a K8sPersistentVolume. +func parsePersistentVolume(volume *corev1.PersistentVolume) models.K8sPersistentVolume { + return models.K8sPersistentVolume{ + Name: volume.Name, + Annotations: volume.Annotations, + Labels: volume.Labels, + AccessModes: humanReadableAccessModes(volume.Spec.AccessModes), + HumanReadableAccessModes: volume.Spec.AccessModes, + Capacity: volume.Spec.Capacity, + ClaimRef: volume.Spec.ClaimRef, + StorageClassName: volume.Spec.StorageClassName, + PersistentVolumeReclaimPolicy: volume.Spec.PersistentVolumeReclaimPolicy, + VolumeMode: volume.Spec.VolumeMode, + CSI: volume.Spec.CSI, + Status: volume.Status.Phase, + CreationDate: volume.CreationTimestamp.Time, + } +} + +// buildPersistentVolumesMap builds a map of persistent volumes. +func (kcl *KubeClient) buildPersistentVolumesMap(persistentVolumes *corev1.PersistentVolumeList) map[string]models.K8sPersistentVolume { + persistentVolumesMap := make(map[string]models.K8sPersistentVolume) + for _, persistentVolume := range persistentVolumes.Items { + persistentVolumesMap[persistentVolume.Name] = parsePersistentVolume(&persistentVolume) + } + + return persistentVolumesMap +} + +// parseStorageClass parses the given storage class and returns a K8sStorageClass. +func parseStorageClass(storageClass *storagev1.StorageClass) models.K8sStorageClass { + return models.K8sStorageClass{ + Name: storageClass.Name, + Provisioner: storageClass.Provisioner, + ReclaimPolicy: storageClass.ReclaimPolicy, + AllowVolumeExpansion: storageClass.AllowVolumeExpansion, + } +} + +// buildStorageClassesMap builds a map of storage classes. +func (kcl *KubeClient) buildStorageClassesMap(storageClasses *storagev1.StorageClassList) map[string]models.K8sStorageClass { + storageClassesMap := make(map[string]models.K8sStorageClass) + for _, storageClass := range storageClasses.Items { + storageClassesMap[storageClass.Name] = parseStorageClass(&storageClass) + } + + return storageClassesMap +} + +// fetchPersistentVolumesAndStorageClassesMap fetches all the persistent volumes and storage classes in the cluster. +// It returns a map of persistent volumes and a map of storage classes. +func (kcl *KubeClient) fetchPersistentVolumesAndStorageClassesMap() (map[string]models.K8sPersistentVolume, map[string]models.K8sStorageClass, error) { + persistentVolumes, err := kcl.cli.CoreV1().PersistentVolumes().List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return nil, nil, err + } + persistentVolumesMap := kcl.buildPersistentVolumesMap(persistentVolumes) + + storageClasses, err := kcl.cli.StorageV1().StorageClasses().List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return nil, nil, err + } + storageClassesMap := kcl.buildStorageClassesMap(storageClasses) + + return persistentVolumesMap, storageClassesMap, nil +} + +// CombineVolumesWithApplications combines the volumes with the applications that use them. +func (kcl *KubeClient) CombineVolumesWithApplications(volumes *[]models.K8sVolumeInfo) (*[]models.K8sVolumeInfo, error) { + pods, err := kcl.cli.CoreV1().Pods("").List(context.Background(), metav1.ListOptions{}) + if err != nil { + if k8serrors.IsNotFound(err) { + return volumes, nil + } + log.Error().Err(err).Msg("Failed to list pods across the cluster") + return nil, fmt.Errorf("an error occurred during the CombineServicesWithApplications operation, unable to list pods across the cluster. Error: %w", err) + } + + hasReplicaSetOwnerReference := containsReplicaSetOwnerReference(pods) + replicaSetItems := make([]appsv1.ReplicaSet, 0) + deploymentItems := make([]appsv1.Deployment, 0) + if hasReplicaSetOwnerReference { + replicaSets, err := kcl.cli.AppsV1().ReplicaSets("").List(context.Background(), metav1.ListOptions{}) + if err != nil { + log.Error().Err(err).Msg("Failed to list replica sets across the cluster") + return nil, fmt.Errorf("an error occurred during the CombineVolumesWithApplications operation, unable to list replica sets across the cluster. Error: %w", err) + } + replicaSetItems = replicaSets.Items + + deployments, err := kcl.cli.AppsV1().Deployments("").List(context.Background(), metav1.ListOptions{}) + if err != nil { + log.Error().Err(err).Msg("Failed to list deployments across the cluster") + return nil, fmt.Errorf("an error occurred during the CombineVolumesWithApplications operation, unable to list deployments across the cluster. Error: %w", err) + } + deploymentItems = deployments.Items + } + + hasStatefulSetOwnerReference := containsStatefulSetOwnerReference(pods) + statefulSetItems := make([]appsv1.StatefulSet, 0) + if hasStatefulSetOwnerReference { + statefulSets, err := kcl.cli.AppsV1().StatefulSets("").List(context.Background(), metav1.ListOptions{}) + if err != nil { + log.Error().Err(err).Msg("Failed to list stateful sets across the cluster") + return nil, fmt.Errorf("an error occurred during the CombineVolumesWithApplications operation, unable to list stateful sets across the cluster. Error: %w", err) + } + statefulSetItems = statefulSets.Items + } + + hasDaemonSetOwnerReference := containsDaemonSetOwnerReference(pods) + daemonSetItems := make([]appsv1.DaemonSet, 0) + if hasDaemonSetOwnerReference { + daemonSets, err := kcl.cli.AppsV1().DaemonSets("").List(context.Background(), metav1.ListOptions{}) + if err != nil { + log.Error().Err(err).Msg("Failed to list daemon sets across the cluster") + return nil, fmt.Errorf("an error occurred during the CombineVolumesWithApplications operation, unable to list daemon sets across the cluster. Error: %w", err) + } + daemonSetItems = daemonSets.Items + } + + return kcl.updateVolumesWithOwningApplications(volumes, pods, deploymentItems, replicaSetItems, statefulSetItems, daemonSetItems) +} + +// updateVolumesWithOwningApplications updates the volumes with the applications that use them. +func (kcl *KubeClient) updateVolumesWithOwningApplications(volumes *[]models.K8sVolumeInfo, pods *corev1.PodList, deploymentItems []appsv1.Deployment, replicaSetItems []appsv1.ReplicaSet, statefulSetItems []appsv1.StatefulSet, daemonSetItems []appsv1.DaemonSet) (*[]models.K8sVolumeInfo, error) { + for i, volume := range *volumes { + for _, pod := range pods.Items { + if pod.Spec.Volumes != nil { + for _, podVolume := range pod.Spec.Volumes { + if podVolume.PersistentVolumeClaim != nil && podVolume.PersistentVolumeClaim.ClaimName == volume.PersistentVolumeClaim.Name && pod.Namespace == volume.PersistentVolumeClaim.Namespace { + application, err := kcl.ConvertPodToApplication(pod, PortainerApplicationResources{ + ReplicaSets: replicaSetItems, + Deployments: deploymentItems, + StatefulSets: statefulSetItems, + DaemonSets: daemonSetItems, + }, false) + if err != nil { + log.Error().Err(err).Msg("Failed to convert pod to application") + return nil, fmt.Errorf("an error occurred during the CombineServicesWithApplications operation, unable to convert pod to application. Error: %w", err) + } + // Check if the application already exists in the OwningApplications slice + exists := false + for _, existingApp := range (*volumes)[i].PersistentVolumeClaim.OwningApplications { + if existingApp.Name == application.Name && existingApp.Namespace == application.Namespace { + exists = true + break + } + } + if !exists && application != nil { + (*volumes)[i].PersistentVolumeClaim.OwningApplications = append((*volumes)[i].PersistentVolumeClaim.OwningApplications, *application) + } + } + } + } + } + } + return volumes, nil +} diff --git a/api/kubernetes/cli/volumes_test.go b/api/kubernetes/cli/volumes_test.go new file mode 100644 index 0000000..283fb7c --- /dev/null +++ b/api/kubernetes/cli/volumes_test.go @@ -0,0 +1,16 @@ +package cli + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGetVolumes(t *testing.T) { + t.Parallel() + kcl := &KubeClient{} + + volumes, err := kcl.GetVolumes("default") + require.NoError(t, err) + require.Empty(t, volumes) +} diff --git a/api/kubernetes/constants.go b/api/kubernetes/constants.go new file mode 100644 index 0000000..a2d934a --- /dev/null +++ b/api/kubernetes/constants.go @@ -0,0 +1,6 @@ +package kubernetes + +const ( + // DefaultNamespace is the default namespace used when no namespace is specified + DefaultNamespace = "default" +) diff --git a/api/kubernetes/kubeclusteraccess_service.go b/api/kubernetes/kubeclusteraccess_service.go new file mode 100644 index 0000000..fce7f55 --- /dev/null +++ b/api/kubernetes/kubeclusteraccess_service.go @@ -0,0 +1,125 @@ +package kubernetes + +import ( + "crypto/x509" + "encoding/base64" + "encoding/pem" + "net/url" + "os" + "strconv" + + portainer "github.com/portainer/portainer/api" + + "github.com/pkg/errors" + "github.com/rs/zerolog/log" +) + +// KubeClusterAccessService represents a service that is responsible for centralizing kube cluster access data +type KubeClusterAccessService interface { + IsSecure() bool + GetClusterDetails(hostURL string, endpointId portainer.EndpointID, isInternal bool) kubernetesClusterAccessData +} + +// KubernetesClusterAccess represents core details which can be used to generate KubeConfig file/data +type kubernetesClusterAccessData struct { + ClusterServerURL string `example:"https://mycompany.k8s.com"` + CertificateAuthorityFile string `example:"/data/tls/localhost.crt"` + CertificateAuthorityData string `example:"MIIC5TCCAc2gAwIBAgIJAJ+...+xuhOaFXwQ=="` +} + +type kubeClusterAccessService struct { + baseURL string + httpsBindAddr string + certificateAuthorityFile string + certificateAuthorityData string +} + +var ( + errTLSCertNotProvided = errors.New("tls cert path not provided") + errTLSCertFileMissing = errors.New("missing tls cert file") + errTLSCertIncorrectType = errors.New("incorrect tls cert type") + errTLSCertValidation = errors.New("failed to parse tls certificate") +) + +// NewKubeClusterAccessService creates a new instance of a KubeClusterAccessService +func NewKubeClusterAccessService(baseURL, httpsBindAddr, tlsCertPath string) KubeClusterAccessService { + certificateAuthorityData, err := getCertificateAuthorityData(tlsCertPath) + if err != nil { + log.Debug().Err(err).Msg("generated KubeConfig will be insecure") + } + + return &kubeClusterAccessService{ + baseURL: baseURL, + httpsBindAddr: httpsBindAddr, + certificateAuthorityFile: tlsCertPath, + certificateAuthorityData: certificateAuthorityData, + } +} + +// getCertificateAuthorityData reads tls certificate from supplied path and verifies the tls certificate +// then returns content (string) of the certificate within `-----BEGIN CERTIFICATE-----` and `-----END CERTIFICATE-----` +func getCertificateAuthorityData(tlsCertPath string) (string, error) { + if tlsCertPath == "" { + return "", errTLSCertNotProvided + } + + data, err := os.ReadFile(tlsCertPath) + if err != nil { + return "", errors.Wrap(errTLSCertFileMissing, err.Error()) + } + + block, _ := pem.Decode(data) + if block == nil || block.Type != "CERTIFICATE" { + return "", errTLSCertIncorrectType + } + + certificate, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return "", errors.Wrap(errTLSCertValidation, err.Error()) + } + + return base64.StdEncoding.EncodeToString(certificate.Raw), nil +} + +// IsSecure specifies whether generated KubeConfig structs from the service will not have `insecure-skip-tls-verify: true` +// this is based on the fact that we can successfully extract `certificateAuthorityData` from +// certificate file at `tlsCertPath`. If we can successfully extract `certificateAuthorityData`, +// then this will be used as `certificate-authority-data` attribute in a generated KubeConfig. +func (service *kubeClusterAccessService) IsSecure() bool { + return service.certificateAuthorityData != "" +} + +// GetClusterDetails returns K8s cluster access details for the specified environment(endpoint). +// The struct can be used to: +// - generate a kubeconfig file +// - pass down params to binaries +// - isInternal is used to determine whether the kubeclient is accessed internally (for example using the kube client for backend calls) or externally (for example downloading the kubeconfig file) +func (service *kubeClusterAccessService) GetClusterDetails(hostURL string, endpointID portainer.EndpointID, isInternal bool) kubernetesClusterAccessData { + if hostURL == "localhost" { + hostURL += service.httpsBindAddr + } + + baseURL := service.baseURL + // When the kubeclient call is internal, the baseURL should not be used. + if isInternal { + baseURL = "" + } + + log.Debug(). + Str("host_URL", hostURL). + Str("HTTPS_bind_address", service.httpsBindAddr). + Str("base_URL", baseURL). + Bool("is_internal", isInternal). + Msg("kubeconfig") + + clusterServerURL, err := url.JoinPath("https://", hostURL, baseURL, "/api/endpoints/", strconv.Itoa(int(endpointID)), "/kubernetes") + if err != nil { + log.Error().Err(err).Msg("Failed to create Kubeconfig cluster URL") + } + + return kubernetesClusterAccessData{ + ClusterServerURL: clusterServerURL, + CertificateAuthorityFile: service.certificateAuthorityFile, + CertificateAuthorityData: service.certificateAuthorityData, + } +} diff --git a/api/kubernetes/kubeclusteraccess_service_test.go b/api/kubernetes/kubeclusteraccess_service_test.go new file mode 100644 index 0000000..156c2a1 --- /dev/null +++ b/api/kubernetes/kubeclusteraccess_service_test.go @@ -0,0 +1,138 @@ +package kubernetes + +import ( + "fmt" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TLS certificate can be generated using: +// openssl req -x509 -out localhost.crt -keyout localhost.key -newkey rsa:2048 -nodes -sha25 -subj '/CN=localhost' -extensions EXT -config <( \ +// printf "[dn]\nCN=localhost\n[req]\ndistinguished_name = dn\n[EXT]\nsubjectAltName=DNS:localhost\nkeyUsage=digitalSignature\nextendedKeyUsage=serverAuth") +const certData = `-----BEGIN CERTIFICATE----- +MIIC5TCCAc2gAwIBAgIJAJ+poiEBdsplMA0GCSqGSIb3DQEBCwUAMBQxEjAQBgNV +BAMMCWxvY2FsaG9zdDAeFw0yMTA4MDQwNDM0MTZaFw0yMTA5MDMwNDM0MTZaMBQx +EjAQBgNVBAMMCWxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC +ggEBAKQ0HStP34FY/lSDIfMG9MV/lKNUkiLZcMXepbyhPit4ND/w9kOA4WTJ+oP0 +B2IYklRvLkneZOfQiPweGAPwZl3CjwII6gL6NCkhcXXAJ4JQ9duL5Q6pL//95Ocv +X+qMTssyS1DcH88F6v+gifACLpvG86G9V0DeSGS2fqqfOJngrOCgum1DsWi3Xsew +B3A7GkPRjYmckU3t4iHgcMb+6lGQAxtnllSM9DpqGnjXRs4mnQHKgufaeW5nvHXi +oa5l0aHIhN6MQS99QwKwfml7UtWAYhSJksMrrTovB6rThYpp2ID/iU9MGfkpxubT +oA6scv8alFa8Bo+NEKo255dxsScCAwEAAaM6MDgwFAYDVR0RBA0wC4IJbG9jYWxo +b3N0MAsGA1UdDwQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDATANBgkqhkiG9w0B +AQsFAAOCAQEALFBHW/r79KOj5bhoDtHs8h/ESAlD5DJI/kzc1RajA8AuWPsaagG/ +S0Bqiq2ApMA6Tr3t9An8peaLCaUapWw59kyQcwwPXm9vxhEEfoBRtk8po8XblsUS +Q5Ku07ycSg5NBGEW2rCLsvjQFuQiAt8sW4jGCCN+ph/GQF9XC8ir+ssiqiMEkbm/ +JaK7sTi5kZ/GsSK8bJ+9N/ztoFr89YYEWjjOuIS3HNMdBcuQXIel7siEFdNjbzMo +iuViiuhTPJkxKOzCmv52cxf15B0/+cgcImoX4zc9Z0NxKthBmIe00ojexE0ZBOFi +4PxB7Ou6y/c9OvJb7gJv3z08+xuhOaFXwQ== +-----END CERTIFICATE----- +` + +// string within the `-----BEGIN CERTIFICATE-----` and `-----END CERTIFICATE-----` without linebreaks +const certDataString = "MIIC5TCCAc2gAwIBAgIJAJ+poiEBdsplMA0GCSqGSIb3DQEBCwUAMBQxEjAQBgNVBAMMCWxvY2FsaG9zdDAeFw0yMTA4MDQwNDM0MTZaFw0yMTA5MDMwNDM0MTZaMBQxEjAQBgNVBAMMCWxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKQ0HStP34FY/lSDIfMG9MV/lKNUkiLZcMXepbyhPit4ND/w9kOA4WTJ+oP0B2IYklRvLkneZOfQiPweGAPwZl3CjwII6gL6NCkhcXXAJ4JQ9duL5Q6pL//95OcvX+qMTssyS1DcH88F6v+gifACLpvG86G9V0DeSGS2fqqfOJngrOCgum1DsWi3XsewB3A7GkPRjYmckU3t4iHgcMb+6lGQAxtnllSM9DpqGnjXRs4mnQHKgufaeW5nvHXioa5l0aHIhN6MQS99QwKwfml7UtWAYhSJksMrrTovB6rThYpp2ID/iU9MGfkpxubToA6scv8alFa8Bo+NEKo255dxsScCAwEAAaM6MDgwFAYDVR0RBA0wC4IJbG9jYWxob3N0MAsGA1UdDwQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDATANBgkqhkiG9w0BAQsFAAOCAQEALFBHW/r79KOj5bhoDtHs8h/ESAlD5DJI/kzc1RajA8AuWPsaagG/S0Bqiq2ApMA6Tr3t9An8peaLCaUapWw59kyQcwwPXm9vxhEEfoBRtk8po8XblsUSQ5Ku07ycSg5NBGEW2rCLsvjQFuQiAt8sW4jGCCN+ph/GQF9XC8ir+ssiqiMEkbm/JaK7sTi5kZ/GsSK8bJ+9N/ztoFr89YYEWjjOuIS3HNMdBcuQXIel7siEFdNjbzMoiuViiuhTPJkxKOzCmv52cxf15B0/+cgcImoX4zc9Z0NxKthBmIe00ojexE0ZBOFi4PxB7Ou6y/c9OvJb7gJv3z08+xuhOaFXwQ==" + +func createTempFile(filename, content string, t *testing.T) string { + tempPath := t.TempDir() + filePath := fmt.Sprintf("%s/%s", tempPath, filename) + + err := os.WriteFile(filePath, []byte(content), 0644) + require.NoError(t, err) + + return filePath +} + +func Test_getCertificateAuthorityData(t *testing.T) { + t.Parallel() + is := assert.New(t) + + t.Run("getCertificateAuthorityData fails on tls cert not provided", func(t *testing.T) { + _, err := getCertificateAuthorityData("") + is.ErrorIs(err, errTLSCertNotProvided, "getCertificateAuthorityData should fail with %w", errTLSCertNotProvided) + }) + + t.Run("getCertificateAuthorityData fails on tls cert provided but missing file", func(t *testing.T) { + _, err := getCertificateAuthorityData("/tmp/non-existent.crt") + is.ErrorIs(err, errTLSCertFileMissing, "getCertificateAuthorityData should fail with %w", errTLSCertFileMissing) + }) + + t.Run("getCertificateAuthorityData fails on tls cert provided but invalid file data", func(t *testing.T) { + filePath := createTempFile("invalid-cert.crt", "hello\ngo\n", t) + _, err := getCertificateAuthorityData(filePath) + is.ErrorIs(err, errTLSCertIncorrectType, "getCertificateAuthorityData should fail with %w", errTLSCertIncorrectType) + }) + + t.Run("getCertificateAuthorityData succeeds on valid tls cert provided", func(t *testing.T) { + filePath := createTempFile("valid-cert.crt", certData, t) + + certificateAuthorityData, err := getCertificateAuthorityData(filePath) + require.NoError(t, err, "getCertificateAuthorityData succeed with valid cert; err=%w", errTLSCertIncorrectType) + + is.Equal(certDataString, certificateAuthorityData, "returned certificateAuthorityData should be %s", certDataString) + }) +} + +func TestKubeClusterAccessService_IsSecure(t *testing.T) { + t.Parallel() + is := assert.New(t) + + t.Run("IsSecure should be false", func(t *testing.T) { + kcs := NewKubeClusterAccessService("", "", "") + is.False(kcs.IsSecure(), "should be false if TLS cert not provided") + }) + + t.Run("IsSecure should be false", func(t *testing.T) { + filePath := createTempFile("valid-cert.crt", certData, t) + + kcs := NewKubeClusterAccessService("", "", filePath) + is.True(kcs.IsSecure(), "should be true if valid TLS cert (path and content) provided") + }) +} + +func TestKubeClusterAccessService_GetKubeConfigInternal(t *testing.T) { + t.Parallel() + is := assert.New(t) + + t.Run("GetClusterDetails contains host address", func(t *testing.T) { + kcs := NewKubeClusterAccessService("/", "", "") + clusterAccessDetails := kcs.GetClusterDetails("mysite.com", 1, true) + is.Contains(clusterAccessDetails.ClusterServerURL, "https://mysite.com", "should contain host address") + }) + + t.Run("GetClusterDetails contains environment proxy url", func(t *testing.T) { + kcs := NewKubeClusterAccessService("/", "", "") + clusterAccessDetails := kcs.GetClusterDetails("mysite.com", 100, true) + is.Contains(clusterAccessDetails.ClusterServerURL, "api/endpoints/100/kubernetes", "should contain environment proxy url") + }) + + t.Run("GetClusterDetails returns insecure cluster access config", func(t *testing.T) { + kcs := NewKubeClusterAccessService("/", ":9443", "") + clusterAccessDetails := kcs.GetClusterDetails("mysite.com", 1, true) + + wantClusterAccessDetails := kubernetesClusterAccessData{ + ClusterServerURL: "https://mysite.com/api/endpoints/1/kubernetes", + CertificateAuthorityFile: "", + CertificateAuthorityData: "", + } + + is.Equal(wantClusterAccessDetails, clusterAccessDetails) + }) + + t.Run("GetClusterDetails returns secure cluster access config", func(t *testing.T) { + filePath := createTempFile("valid-cert.crt", certData, t) + + kcs := NewKubeClusterAccessService("/", "", filePath) + clusterAccessDetails := kcs.GetClusterDetails("localhost", 1, true) + + wantClusterAccessDetails := kubernetesClusterAccessData{ + ClusterServerURL: "https://localhost/api/endpoints/1/kubernetes", + CertificateAuthorityFile: filePath, + CertificateAuthorityData: certDataString, + } + + is.Equal(wantClusterAccessDetails, clusterAccessDetails) + }) +} diff --git a/api/kubernetes/privateregistries/labels.go b/api/kubernetes/privateregistries/labels.go new file mode 100644 index 0000000..dc78081 --- /dev/null +++ b/api/kubernetes/privateregistries/labels.go @@ -0,0 +1,5 @@ +package privateregistries + +const ( + RegistryIDLabel = "portainer.io/registry.id" +) diff --git a/api/kubernetes/snapshot.go b/api/kubernetes/snapshot.go new file mode 100644 index 0000000..c8f6c2e --- /dev/null +++ b/api/kubernetes/snapshot.go @@ -0,0 +1,28 @@ +package kubernetes + +import ( + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/kubernetes/cli" + "github.com/portainer/portainer/pkg/snapshot" +) + +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 environment(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.CreateKubernetesSnapshot(client) +} diff --git a/api/kubernetes/validation/validation.go b/api/kubernetes/validation/validation.go new file mode 100644 index 0000000..4f3e3d1 --- /dev/null +++ b/api/kubernetes/validation/validation.go @@ -0,0 +1,61 @@ +package validation + +// borrowed from apimachinery@v0.17.2/pkg/util/validation/validation.go +// https://github.com/kubernetes/kubernetes/blob/master/staging/src/k8s.io/apimachinery/pkg/util/validation/validation.go + +import ( + "errors" + "fmt" + "regexp" + "strings" +) + +const dns1123LabelFmt string = "[a-z0-9]([-a-z0-9]*[a-z0-9])?" +const dns1123SubdomainFmt string = dns1123LabelFmt + "(\\." + dns1123LabelFmt + ")*" +const DNS1123SubdomainMaxLength int = 253 + +var dns1123SubdomainRegexp = regexp.MustCompile("^" + dns1123SubdomainFmt + "$") + +// IsDNS1123Subdomain tests for a string that conforms to the definition of a subdomain in DNS (RFC 1123). +func IsDNS1123Subdomain(value string) error { + var errs error + if len(value) > DNS1123SubdomainMaxLength { + errs = errors.Join(errs, MaxLenError(DNS1123SubdomainMaxLength)) + } + if !dns1123SubdomainRegexp.MatchString(value) { + errs = errors.Join(errs, RegexError(dns1123SubdomainFmt, "example.com")) + } + return errs +} + +// MaxLenError returns a string explanation of a "string too long" validation failure. +func MaxLenError(length int) error { + return fmt.Errorf("must be no more than %d characters", length) +} + +// RegexError returns a string explanation of a regex validation failure. +func RegexError(fmt string, examples ...string) error { + var s strings.Builder + + _, _ = s.WriteString("must match the regex ") + _, _ = s.WriteString(fmt) + + if len(examples) == 0 { + return errors.New(s.String()) + } + + s.WriteString(" (e.g. ") + for i := range examples { + if i > 0 { + _, _ = s.WriteString(" or ") + } + + _, _ = s.WriteRune('\'') + _, _ = s.WriteString(examples[i]) + _, _ = s.WriteRune('\'') + } + + _, _ = s.WriteRune(')') + + return errors.New(s.String()) +} diff --git a/api/kubernetes/yaml.go b/api/kubernetes/yaml.go new file mode 100644 index 0000000..4b435d1 --- /dev/null +++ b/api/kubernetes/yaml.go @@ -0,0 +1,206 @@ +package kubernetes + +import ( + "bytes" + "fmt" + "io" + "maps" + "strconv" + "strings" + + "github.com/pkg/errors" + "github.com/portainer/portainer/api/stacks/stackutils" + "go.yaml.in/yaml/v3" +) + +const ( + labelPortainerAppStack = "io.portainer.kubernetes.application.stack" + labelPortainerAppStackID = "io.portainer.kubernetes.application.stackid" + labelPortainerAppName = "io.portainer.kubernetes.application.name" + labelPortainerAppOwner = "io.portainer.kubernetes.application.owner" + labelPortainerAppOwnerId = "io.portainer.kubernetes.application.owner.id" + labelPortainerAppKind = "io.portainer.kubernetes.application.kind" + labelPortainerAppStackKind = "io.portainer.kubernetes.application.stackKind" +) + +// KubeAppLabels are labels applied to all resources deployed in a kubernetes stack +type KubeAppLabels struct { + StackID int + StackName string + Owner string + OwnerId string + Kind string + StackKind string +} + +// ToMap converts KubeAppLabels to a map[string]string +func (kal *KubeAppLabels) ToMap() map[string]string { + labels := map[string]string{ + labelPortainerAppStackID: strconv.Itoa(kal.StackID), + labelPortainerAppStack: stackutils.SanitizeLabel(kal.StackName), + labelPortainerAppName: stackutils.SanitizeLabel(kal.StackName), + labelPortainerAppOwner: stackutils.SanitizeLabel(kal.Owner), + labelPortainerAppKind: kal.Kind, + labelPortainerAppOwnerId: kal.OwnerId, + } + + // Add optional labels only if they are non-empty + if kal.StackKind != "" { + labels[labelPortainerAppStackKind] = kal.StackKind + } + + return labels +} + +// GetHelmAppLabels returns the labels to be applied to portainer deployed helm applications +func GetHelmAppLabels(name, owner string) map[string]string { + return map[string]string{ + labelPortainerAppName: name, + labelPortainerAppOwner: stackutils.SanitizeLabel(owner), + } +} + +// AddAppLabels adds required labels to "Resource"->metadata->labels. +// It'll add those labels to all Resource (nodes with a kind property exluding a list) it can find in provided yaml. +// Items in the yaml file could either be organised as a list or broken into multi documents. +func AddAppLabels(manifestYaml []byte, appLabels map[string]string) ([]byte, error) { + if bytes.Equal(manifestYaml, []byte("")) { + return manifestYaml, nil + } + + postProcessYaml := func(yamlDoc any) error { + addResourceLabels(yamlDoc, appLabels) + return nil + } + + docs, err := ExtractDocuments(manifestYaml, postProcessYaml) + if err != nil { + return nil, err + } + + return bytes.Join(docs, []byte("---\n")), nil +} + +// ExtractDocuments extracts all the documents from a yaml file +// Optionally post-process each document with a function, which can modify the document in place. +// Pass in nil for postProcessYaml to skip post-processing. +func ExtractDocuments(manifestYaml []byte, postProcessYaml func(any) error) ([][]byte, error) { + docs := make([][]byte, 0) + yamlDecoder := yaml.NewDecoder(bytes.NewReader(manifestYaml)) + + for { + m := make(map[string]any) + err := yamlDecoder.Decode(&m) + + // if there are no more documents in the file + if errors.Is(err, io.EOF) { + break + } + + // if there's any other error, return it + if err != nil { + return nil, errors.Wrap(err, "failed to decode yaml manifest") + } + + // if decoded document is empty, skip to next document + if m == nil { + continue + } + + // optionally post-process yaml + if postProcessYaml != nil { + if err := postProcessYaml(m); err != nil { + return nil, errors.Wrap(err, "failed to post process yaml document") + } + } + + var out bytes.Buffer + yamlEncoder := yaml.NewEncoder(&out) + yamlEncoder.SetIndent(2) + if err := yamlEncoder.Encode(m); err != nil { + return nil, errors.Wrap(err, "failed to marshal yaml manifest") + } + + docs = append(docs, out.Bytes()) + } + + return docs, nil +} + +// GetNamespace returns the namespace of a kubernetes resource from its metadata +// It returns an empty string if namespace is not found in the resource +func GetNamespace(manifestYaml []byte) (string, error) { + yamlDecoder := yaml.NewDecoder(bytes.NewReader(manifestYaml)) + m := make(map[string]any) + err := yamlDecoder.Decode(&m) + if err != nil { + return "", errors.Wrap(err, "failed to unmarshal yaml manifest when obtaining namespace") + } + + kind, ok := m["kind"].(string) + if !ok { + return "", errors.New("invalid kubernetes manifest, missing 'kind' field") + } + + if _, ok := m["metadata"]; ok { + var namespace any + var ok bool + if strings.EqualFold(kind, "namespace") { + namespace, ok = m["metadata"].(map[string]any)["name"] + } else { + namespace, ok = m["metadata"].(map[string]any)["namespace"] + } + + if ok { + if v, ok := namespace.(string); ok { + return v, nil + } + return "", errors.New("invalid kubernetes manifest, 'namespace' field is not a string") + } + } + return "", nil +} + +func addResourceLabels(yamlDoc any, appLabels map[string]string) { + m, ok := yamlDoc.(map[string]any) + if !ok { + return + } + + kind, ok := m["kind"] + if ok && !strings.EqualFold(kind.(string), "list") { + addLabels(m, appLabels) + return + } + + for _, v := range m { + switch v := v.(type) { + case map[string]any: + addResourceLabels(v, appLabels) + case []any: + for _, item := range v { + addResourceLabels(item, appLabels) + } + } + } +} + +func addLabels(obj map[string]any, appLabels map[string]string) { + metadata := make(map[string]any) + if m, ok := obj["metadata"]; ok { + metadata = m.(map[string]any) + } + + labels := make(map[string]string) + if l, ok := metadata["labels"]; ok { + for k, v := range l.(map[string]any) { + labels[k] = fmt.Sprintf("%v", v) + } + } + + // merge app labels with existing labels + maps.Copy(labels, appLabels) + + metadata["labels"] = labels + obj["metadata"] = metadata +} diff --git a/api/kubernetes/yaml_test.go b/api/kubernetes/yaml_test.go new file mode 100644 index 0000000..bfd0f8f --- /dev/null +++ b/api/kubernetes/yaml_test.go @@ -0,0 +1,823 @@ +package kubernetes + +import ( + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_AddAppLabels(t *testing.T) { + t.Parallel() + tests := []struct { + name string + input string + wantOutput string + }{ + { + name: "single deployment without labels", + input: `apiVersion: apps/v1 +kind: Deployment +metadata: + name: busybox +spec: + replicas: 3 + selector: + matchLabels: + app: busybox + template: + metadata: + labels: + app: busybox + spec: + containers: + - image: busybox + name: busybox +`, + wantOutput: `apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + io.portainer.kubernetes.application.kind: git + io.portainer.kubernetes.application.name: best-name + io.portainer.kubernetes.application.owner: best-owner + io.portainer.kubernetes.application.owner.id: "" + io.portainer.kubernetes.application.stack: best-name + io.portainer.kubernetes.application.stackid: "123" + name: busybox +spec: + replicas: 3 + selector: + matchLabels: + app: busybox + template: + metadata: + labels: + app: busybox + spec: + containers: + - image: busybox + name: busybox +`, + }, + { + name: "single deployment with existing labels", + input: `apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + foo: bar + name: busybox +spec: + replicas: 3 + selector: + matchLabels: + app: busybox + template: + metadata: + labels: + app: busybox + spec: + containers: + - image: busybox + name: busybox +`, + wantOutput: `apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + foo: bar + io.portainer.kubernetes.application.kind: git + io.portainer.kubernetes.application.name: best-name + io.portainer.kubernetes.application.owner: best-owner + io.portainer.kubernetes.application.owner.id: "" + io.portainer.kubernetes.application.stack: best-name + io.portainer.kubernetes.application.stackid: "123" + name: busybox +spec: + replicas: 3 + selector: + matchLabels: + app: busybox + template: + metadata: + labels: + app: busybox + spec: + containers: + - image: busybox + name: busybox +`, + }, + { + name: "complex kompose output", + input: `apiVersion: v1 +items: + - apiVersion: v1 + kind: Service + metadata: + creationTimestamp: null + labels: + io.kompose.service: web + name: web + spec: + ports: + - name: "5000" + port: 5000 + targetPort: 5000 + selector: + io.kompose.service: web + status: + loadBalancer: {} + - apiVersion: apps/v1 + kind: Deployment + metadata: + creationTimestamp: null + labels: + io.kompose.service: redis + name: redis + spec: + replicas: 1 + selector: + matchLabels: + io.kompose.service: redis + strategy: {} + template: + metadata: + creationTimestamp: null + labels: + io.kompose.service: redis + status: {} + - apiVersion: apps/v1 + kind: Deployment + metadata: + creationTimestamp: null + name: web + spec: + replicas: 1 + selector: + matchLabels: + io.kompose.service: web + strategy: + type: Recreate + template: + metadata: + creationTimestamp: null + labels: + io.kompose.service: web + status: {} +kind: List +metadata: {} +`, + wantOutput: `apiVersion: v1 +items: + - apiVersion: v1 + kind: Service + metadata: + creationTimestamp: null + labels: + io.kompose.service: web + io.portainer.kubernetes.application.kind: git + io.portainer.kubernetes.application.name: best-name + io.portainer.kubernetes.application.owner: best-owner + io.portainer.kubernetes.application.owner.id: "" + io.portainer.kubernetes.application.stack: best-name + io.portainer.kubernetes.application.stackid: "123" + name: web + spec: + ports: + - name: "5000" + port: 5000 + targetPort: 5000 + selector: + io.kompose.service: web + status: + loadBalancer: {} + - apiVersion: apps/v1 + kind: Deployment + metadata: + creationTimestamp: null + labels: + io.kompose.service: redis + io.portainer.kubernetes.application.kind: git + io.portainer.kubernetes.application.name: best-name + io.portainer.kubernetes.application.owner: best-owner + io.portainer.kubernetes.application.owner.id: "" + io.portainer.kubernetes.application.stack: best-name + io.portainer.kubernetes.application.stackid: "123" + name: redis + spec: + replicas: 1 + selector: + matchLabels: + io.kompose.service: redis + strategy: {} + template: + metadata: + creationTimestamp: null + labels: + io.kompose.service: redis + status: {} + - apiVersion: apps/v1 + kind: Deployment + metadata: + creationTimestamp: null + labels: + io.portainer.kubernetes.application.kind: git + io.portainer.kubernetes.application.name: best-name + io.portainer.kubernetes.application.owner: best-owner + io.portainer.kubernetes.application.owner.id: "" + io.portainer.kubernetes.application.stack: best-name + io.portainer.kubernetes.application.stackid: "123" + name: web + spec: + replicas: 1 + selector: + matchLabels: + io.kompose.service: web + strategy: + type: Recreate + template: + metadata: + creationTimestamp: null + labels: + io.kompose.service: web + status: {} +kind: List +metadata: {} +`, + }, + { + name: "multiple items separated by ---", + input: `apiVersion: apps/v1 +kind: Deployment +metadata: + name: busybox +spec: + replicas: 3 + selector: + matchLabels: + app: busybox + template: + metadata: + labels: + app: busybox + spec: + containers: + - image: busybox + name: busybox +--- +apiVersion: v1 +kind: Service +metadata: + creationTimestamp: null + labels: + io.kompose.service: web + name: web +spec: + ports: + - name: "5000" + port: 5000 + targetPort: 5000 + selector: + io.kompose.service: web +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + foo: bar + name: busybox +spec: + replicas: 3 + selector: + matchLabels: + app: busybox + template: + metadata: + labels: + app: busybox + spec: + containers: + - image: busybox + name: busybox +`, + wantOutput: `apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + io.portainer.kubernetes.application.kind: git + io.portainer.kubernetes.application.name: best-name + io.portainer.kubernetes.application.owner: best-owner + io.portainer.kubernetes.application.owner.id: "" + io.portainer.kubernetes.application.stack: best-name + io.portainer.kubernetes.application.stackid: "123" + name: busybox +spec: + replicas: 3 + selector: + matchLabels: + app: busybox + template: + metadata: + labels: + app: busybox + spec: + containers: + - image: busybox + name: busybox +--- +apiVersion: v1 +kind: Service +metadata: + creationTimestamp: null + labels: + io.kompose.service: web + io.portainer.kubernetes.application.kind: git + io.portainer.kubernetes.application.name: best-name + io.portainer.kubernetes.application.owner: best-owner + io.portainer.kubernetes.application.owner.id: "" + io.portainer.kubernetes.application.stack: best-name + io.portainer.kubernetes.application.stackid: "123" + name: web +spec: + ports: + - name: "5000" + port: 5000 + targetPort: 5000 + selector: + io.kompose.service: web +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + foo: bar + io.portainer.kubernetes.application.kind: git + io.portainer.kubernetes.application.name: best-name + io.portainer.kubernetes.application.owner: best-owner + io.portainer.kubernetes.application.owner.id: "" + io.portainer.kubernetes.application.stack: best-name + io.portainer.kubernetes.application.stackid: "123" + name: busybox +spec: + replicas: 3 + selector: + matchLabels: + app: busybox + template: + metadata: + labels: + app: busybox + spec: + containers: + - image: busybox + name: busybox +`, + }, + { + name: "empty", + input: "", + wantOutput: "", + }, + { + name: "no only deployments", + input: `apiVersion: v1 +kind: Service +metadata: + creationTimestamp: null + labels: + io.kompose.service: web + name: web +spec: + ports: + - name: "5000" + port: 5000 + targetPort: 5000 + selector: + io.kompose.service: web +`, + wantOutput: `apiVersion: v1 +kind: Service +metadata: + creationTimestamp: null + labels: + io.kompose.service: web + io.portainer.kubernetes.application.kind: git + io.portainer.kubernetes.application.name: best-name + io.portainer.kubernetes.application.owner: best-owner + io.portainer.kubernetes.application.owner.id: "" + io.portainer.kubernetes.application.stack: best-name + io.portainer.kubernetes.application.stackid: "123" + name: web +spec: + ports: + - name: "5000" + port: 5000 + targetPort: 5000 + selector: + io.kompose.service: web +`, + }, + } + + labels := KubeAppLabels{ + StackID: 123, + StackName: "best-name", + Owner: "best-owner", + Kind: "git", + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := AddAppLabels([]byte(tt.input), labels.ToMap()) + require.NoError(t, err) + assert.Equal(t, tt.wantOutput, string(result)) + }) + } +} + +func Test_AddAppLabels_HelmApp(t *testing.T) { + t.Parallel() + labels := GetHelmAppLabels("best-name", "best-owner") + + tests := []struct { + name string + input string + wantOutput string + }{ + { + name: "bitnami nginx configmap", + input: `apiVersion: v1 +kind: ConfigMap +metadata: + name: nginx-test-server-block + labels: + app.kubernetes.io/name: nginx + helm.sh/chart: nginx-9.5.4 + app.kubernetes.io/instance: nginx-test + app.kubernetes.io/managed-by: Helm +data: + server-blocks-paths.conf: |- + include "/opt/bitnami/nginx/conf/server_blocks/ldap/*.conf"; + include "/opt/bitnami/nginx/conf/server_blocks/common/*.conf"; +`, + wantOutput: `apiVersion: v1 +data: + server-blocks-paths.conf: |- + include "/opt/bitnami/nginx/conf/server_blocks/ldap/*.conf"; + include "/opt/bitnami/nginx/conf/server_blocks/common/*.conf"; +kind: ConfigMap +metadata: + labels: + app.kubernetes.io/instance: nginx-test + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: nginx + helm.sh/chart: nginx-9.5.4 + io.portainer.kubernetes.application.name: best-name + io.portainer.kubernetes.application.owner: best-owner + name: nginx-test-server-block +`, + }, + { + name: "bitnami nginx service", + input: `apiVersion: v1 +kind: Service +metadata: + name: nginx-test + labels: + app.kubernetes.io/name: nginx + helm.sh/chart: nginx-9.5.4 + app.kubernetes.io/instance: nginx-test + app.kubernetes.io/managed-by: Helm +spec: + type: LoadBalancer + externalTrafficPolicy: "Cluster" + ports: + - name: http + port: 80 + targetPort: http + selector: + app.kubernetes.io/name: nginx + app.kubernetes.io/instance: nginx-test +`, + wantOutput: `apiVersion: v1 +kind: Service +metadata: + labels: + app.kubernetes.io/instance: nginx-test + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: nginx + helm.sh/chart: nginx-9.5.4 + io.portainer.kubernetes.application.name: best-name + io.portainer.kubernetes.application.owner: best-owner + name: nginx-test +spec: + externalTrafficPolicy: Cluster + ports: + - name: http + port: 80 + targetPort: http + selector: + app.kubernetes.io/instance: nginx-test + app.kubernetes.io/name: nginx + type: LoadBalancer +`, + }, + { + name: "bitnami nginx deployment", + input: `apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx-test + labels: + app.kubernetes.io/name: nginx + helm.sh/chart: nginx-9.5.4 + app.kubernetes.io/instance: nginx-test + app.kubernetes.io/managed-by: Helm +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: nginx + app.kubernetes.io/instance: nginx-test + template: + metadata: + labels: + app.kubernetes.io/name: nginx + helm.sh/chart: nginx-9.5.4 + app.kubernetes.io/instance: nginx-test + app.kubernetes.io/managed-by: Helm + spec: + automountServiceAccountToken: false + shareProcessNamespace: false + serviceAccountName: default + containers: + - name: nginx + image: docker.io/bitnami/nginx:1.21.3-debian-10-r0 + imagePullPolicy: "IfNotPresent" +`, + wantOutput: `apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app.kubernetes.io/instance: nginx-test + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: nginx + helm.sh/chart: nginx-9.5.4 + io.portainer.kubernetes.application.name: best-name + io.portainer.kubernetes.application.owner: best-owner + name: nginx-test +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/instance: nginx-test + app.kubernetes.io/name: nginx + template: + metadata: + labels: + app.kubernetes.io/instance: nginx-test + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: nginx + helm.sh/chart: nginx-9.5.4 + spec: + automountServiceAccountToken: false + containers: + - image: docker.io/bitnami/nginx:1.21.3-debian-10-r0 + imagePullPolicy: IfNotPresent + name: nginx + serviceAccountName: default + shareProcessNamespace: false +`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := AddAppLabels([]byte(tt.input), labels) + require.NoError(t, err) + assert.Equal(t, tt.wantOutput, string(result)) + }) + } +} + +func Test_DocumentSeperator(t *testing.T) { + t.Parallel() + labels := KubeAppLabels{ + StackID: 123, + StackName: "best-name", + Owner: "best-owner", + Kind: "git", + } + + input := `apiVersion: v1 +kind: Service +metadata: + labels: + io.kompose.service: database +--- +apiVersion: v1 +kind: Service +metadata: + labels: + io.kompose.service: backend +` + expected := `apiVersion: v1 +kind: Service +metadata: + labels: + io.kompose.service: database + io.portainer.kubernetes.application.kind: git + io.portainer.kubernetes.application.name: best-name + io.portainer.kubernetes.application.owner: best-owner + io.portainer.kubernetes.application.owner.id: "" + io.portainer.kubernetes.application.stack: best-name + io.portainer.kubernetes.application.stackid: "123" +--- +apiVersion: v1 +kind: Service +metadata: + labels: + io.kompose.service: backend + io.portainer.kubernetes.application.kind: git + io.portainer.kubernetes.application.name: best-name + io.portainer.kubernetes.application.owner: best-owner + io.portainer.kubernetes.application.owner.id: "" + io.portainer.kubernetes.application.stack: best-name + io.portainer.kubernetes.application.stackid: "123" +` + result, err := AddAppLabels([]byte(input), labels.ToMap()) + require.NoError(t, err) + assert.Equal(t, expected, string(result)) +} + +func Test_GetNamespace(t *testing.T) { + t.Parallel() + tests := []struct { + name string + input string + want string + }{ + { + name: "valid namespace", + input: `apiVersion: v1 +kind: Namespace +metadata: + name: test-namespace +`, + want: "test-namespace", + }, + { + name: "invalid namespace", + input: `apiVersion: v1 +kind: Namespace +`, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := GetNamespace([]byte(tt.input)) + require.NoError(t, err) + assert.Equal(t, tt.want, result) + }) + } +} + +func Test_ExtractDocuments(t *testing.T) { + t.Parallel() + tests := []struct { + name string + input string + want []string + }{ + { + name: "multiple documents", + input: `apiVersion: v1 +kind: Namespace +--- +apiVersion: v1 +kind: Service +`, + want: []string{`apiVersion: v1 +kind: Namespace +`, `apiVersion: v1 +kind: Service +`}, + }, + { + name: "single document", + input: `apiVersion: v1 +kind: Namespace +`, + want: []string{`apiVersion: v1 +kind: Namespace +`}, + }, + { + name: "empty document separator is skipped", + input: `apiVersion: v1 +kind: Namespace +--- +--- +apiVersion: v1 +kind: Service +`, + want: []string{`apiVersion: v1 +kind: Namespace +`, `apiVersion: v1 +kind: Service +`}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + results, err := ExtractDocuments([]byte(tt.input), nil) + require.NoError(t, err) + + for i := range results { + assert.Equal(t, tt.want[i], string(results[i])) + } + }) + } +} + +func Test_ExtractDocuments_PostProcess(t *testing.T) { + t.Parallel() + + input := `apiVersion: v1 +kind: Namespace +metadata: + name: test +` + + t.Run("post-process callback is applied to each document", func(t *testing.T) { + called := 0 + result, err := ExtractDocuments([]byte(input), func(doc any) error { + called++ + m := doc.(map[string]any) + m["injected"] = "value" + return nil + }) + require.NoError(t, err) + assert.Equal(t, 1, called) + assert.Contains(t, string(result[0]), "injected") + }) + + t.Run("post-process callback error is returned", func(t *testing.T) { + _, err := ExtractDocuments([]byte(input), func(any) error { + return errors.New("post-process failed") + }) + require.ErrorContains(t, err, "post-process failed") + }) +} + +// Test_ExtractDocuments_MalformedYAML is a regression test for the infinite loop +// described in https://github.com/portainer/portainer/issues/13051. +// Previously, a malformed YAML document (bad indentation) caused Decode() to +// return both err != nil and m == nil. The pre-fix implementation checked +// m == nil first and continued, skipping the EOF check and looping forever. +func Test_ExtractDocuments_MalformedYAML(t *testing.T) { + malformedYAML := `--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: error +spec: + replicas: 1 + selector: + matchLabels: + app: error + template: + metadata: + labels: + app: error + spec: + containers: + - name: alpine + image: alpine:latest + command: [ "/bin/bash", "-c" ] + args: + - | + echo "crash" +` + + done := make(chan error, 1) + go func() { + _, err := ExtractDocuments([]byte(malformedYAML), nil) + done <- err + }() + + select { + case err := <-done: + // Should return an error, not hang + require.Error(t, err, "expected an error for malformed YAML, not a hang") + case <-time.After(3 * time.Second): + t.Fatal("ExtractDocuments hung on malformed YAML (infinite loop — issue #13051 reproduced)") + } +} diff --git a/api/ldap/ldap.go b/api/ldap/ldap.go new file mode 100644 index 0000000..0b38714 --- /dev/null +++ b/api/ldap/ldap.go @@ -0,0 +1,331 @@ +package ldap + +import ( + "fmt" + "strings" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/crypto" + httperrors "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/api/logs" + + ldap "github.com/go-ldap/ldap/v3" + "github.com/pkg/errors" +) + +var ( + // errUserNotFound defines an error raised when the user is not found via LDAP search + // or that too many entries (> 1) are returned. + errUserNotFound = errors.New("User not found or too many entries returned") +) + +// Service represents a service used to authenticate users against a LDAP/AD. +type Service struct{} + +func createConnection(settings *portainer.LDAPSettings) (*ldap.Conn, error) { + conn, err := createConnectionForURL(settings.URL, settings) + if err != nil { + return nil, errors.Wrap(err, "failed creating LDAP connection") + } + + return conn, nil +} + +func createConnectionForURL(url string, settings *portainer.LDAPSettings) (*ldap.Conn, error) { + if !settings.TLSConfig.TLS && !settings.StartTLS { + return ldap.Dial("tcp", url) + } + + // Store the original value to ensure the TLSConfig is created + t := settings.TLSConfig.TLS + settings.TLSConfig.TLS = settings.TLSConfig.TLS || settings.StartTLS + + config, err := crypto.CreateTLSConfigurationFromDisk(settings.TLSConfig) + if err != nil { + return nil, err + } + + // Restore the original value + settings.TLSConfig.TLS = t + + if settings.TLSConfig.TLS || settings.StartTLS { + config.ServerName = strings.Split(url, ":")[0] + } + + if settings.TLSConfig.TLS { + return ldap.DialTLS("tcp", url, config) + } + + conn, err := ldap.Dial("tcp", url) + if err != nil { + return nil, err + } + + if err := conn.StartTLS(config); err != nil { + return nil, err + } + + return conn, nil +} + +// AuthenticateUser is used to authenticate a user against a LDAP/AD. +func (Service) AuthenticateUser(username, password string, settings *portainer.LDAPSettings) error { + connection, err := createConnection(settings) + if err != nil { + return err + } + defer logs.CloseAndLogErr(connection) + + if !settings.AnonymousMode { + err = connection.Bind(settings.ReaderDN, settings.Password) + if err != nil { + return err + } + } + + userDN, err := searchUser(username, connection, settings.SearchSettings) + if err != nil { + if errors.Is(err, errUserNotFound) { + // prevent user enumeration timing attack by attempting the bind with a fake user + // and whatever password was provided should definitely fail + // https://en.wikipedia.org/wiki/Timing_attack + userDN = "portainer-fake-ldap-username" + } else { + return err + } + } + + err = connection.Bind(userDN, password) + if err != nil { + return httperrors.ErrUnauthorized + } + + return nil +} + +// GetUserGroups is used to retrieve user groups from LDAP/AD. +func (Service) GetUserGroups(username string, settings *portainer.LDAPSettings) ([]string, error) { + connection, err := createConnection(settings) + if err != nil { + return nil, err + } + defer logs.CloseAndLogErr(connection) + + if !settings.AnonymousMode { + err = connection.Bind(settings.ReaderDN, settings.Password) + if err != nil { + return nil, err + } + } + + userDN, err := searchUser(username, connection, settings.SearchSettings) + if err != nil { + return nil, err + } + + userGroups := getGroupsByUser(userDN, connection, settings.GroupSearchSettings) + + return userGroups, nil +} + +// SearchUsers searches for users with the specified settings +func (Service) SearchUsers(settings *portainer.LDAPSettings) ([]string, error) { + connection, err := createConnection(settings) + if err != nil { + return nil, err + } + defer logs.CloseAndLogErr(connection) + + if !settings.AnonymousMode { + err = connection.Bind(settings.ReaderDN, settings.Password) + if err != nil { + return nil, err + } + } + + users := map[string]bool{} + + for _, searchSettings := range settings.SearchSettings { + searchRequest := ldap.NewSearchRequest( + searchSettings.BaseDN, + ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, + searchSettings.Filter, + []string{"dn", searchSettings.UserNameAttribute}, + nil, + ) + + sr, err := connection.Search(searchRequest) + if err != nil { + return nil, err + } + + for _, user := range sr.Entries { + username := user.GetAttributeValue(searchSettings.UserNameAttribute) + if username != "" { + users[username] = true + } + } + } + + usersList := []string{} + for user := range users { + usersList = append(usersList, user) + } + + return usersList, nil +} + +// SearchGroups searches for groups with the specified settings +func (Service) SearchGroups(settings *portainer.LDAPSettings) ([]portainer.LDAPUser, error) { + type groupSet map[string]bool + + connection, err := createConnection(settings) + if err != nil { + return nil, err + } + defer logs.CloseAndLogErr(connection) + + if !settings.AnonymousMode { + err = connection.Bind(settings.ReaderDN, settings.Password) + if err != nil { + return nil, err + } + } + + userGroups := map[string]groupSet{} + + for _, searchSettings := range settings.GroupSearchSettings { + searchRequest := ldap.NewSearchRequest( + searchSettings.GroupBaseDN, + ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, + searchSettings.GroupFilter, + []string{"cn", searchSettings.GroupAttribute}, + nil, + ) + + sr, err := connection.Search(searchRequest) + if err != nil { + return nil, err + } + + for _, entry := range sr.Entries { + members := entry.GetAttributeValues(searchSettings.GroupAttribute) + for _, username := range members { + _, ok := userGroups[username] + if !ok { + userGroups[username] = groupSet{} + } + userGroups[username][entry.GetAttributeValue("cn")] = true + } + } + } + + users := []portainer.LDAPUser{} + + for username, groups := range userGroups { + groupList := []string{} + for group := range groups { + groupList = append(groupList, group) + } + user := portainer.LDAPUser{ + Name: username, + Groups: groupList, + } + users = append(users, user) + } + + return users, nil +} + +func searchUser(username string, conn *ldap.Conn, settings []portainer.LDAPSearchSettings) (string, error) { + var userDN string + found := false + usernameEscaped := ldap.EscapeFilter(username) + + for _, searchSettings := range settings { + searchRequest := ldap.NewSearchRequest( + searchSettings.BaseDN, + ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, + fmt.Sprintf("(&%s(%s=%s))", searchSettings.Filter, searchSettings.UserNameAttribute, usernameEscaped), + []string{"dn"}, + nil, + ) + + // Deliberately skip errors on the search request so that we can jump to other search settings + // if any issue arise with the current one. + sr, err := conn.Search(searchRequest) + if err != nil { + continue + } + + if len(sr.Entries) == 1 { + found = true + userDN = sr.Entries[0].DN + break + } + } + + if !found { + return "", errUserNotFound + } + + return userDN, nil +} + +// Get a list of group names for specified user from LDAP/AD +func getGroupsByUser(userDN string, conn *ldap.Conn, settings []portainer.LDAPGroupSearchSettings) []string { + groups := make([]string, 0) + userDNEscaped := ldap.EscapeFilter(userDN) + + for _, searchSettings := range settings { + searchRequest := ldap.NewSearchRequest( + searchSettings.GroupBaseDN, + ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, + fmt.Sprintf("(&%s(%s=%s))", searchSettings.GroupFilter, searchSettings.GroupAttribute, userDNEscaped), + []string{"cn"}, + nil, + ) + + // Deliberately skip errors on the search request so that we can jump to other search settings + // if any issue arise with the current one. + sr, err := conn.Search(searchRequest) + if err != nil { + continue + } + + for _, entry := range sr.Entries { + for _, attr := range entry.Attributes { + if len(attr.Values) > 0 { + groups = append(groups, attr.Values[0]) + } + } + } + } + + return groups +} + +// TestConnectivity is used to test a connection against the LDAP server using the credentials +// specified in the LDAPSettings. +func (Service) TestConnectivity(settings *portainer.LDAPSettings) error { + connection, err := createConnection(settings) + if err != nil { + return err + } + defer logs.CloseAndLogErr(connection) + + if !settings.AnonymousMode { + err = connection.Bind(settings.ReaderDN, settings.Password) + if err != nil { + return err + } + + } else { + err = connection.UnauthenticatedBind("") + if err != nil { + return err + } + } + + return nil +} diff --git a/api/ldap/ldap_test.go b/api/ldap/ldap_test.go new file mode 100644 index 0000000..071a7ab --- /dev/null +++ b/api/ldap/ldap_test.go @@ -0,0 +1,97 @@ +package ldap + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/pkg/fips" + + "github.com/stretchr/testify/require" +) + +func TestCreateConnectionForURL(t *testing.T) { + t.Parallel() + fips.InitFIPS(false) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + defer srv.Close() + + tlsSrv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + defer tlsSrv.Close() + + srvURL, err := url.Parse(tlsSrv.URL) + require.NoError(t, err) + + // TCP + + settings := &portainer.LDAPSettings{ + URL: srvURL.Host, + } + + conn, err := createConnectionForURL(settings.URL, settings) + require.NoError(t, err) + require.NotNil(t, conn) + err = conn.Close() + require.NoError(t, err) + + // TLS + + settings.TLSConfig = portainer.TLSConfiguration{ + TLS: true, + TLSSkipVerify: true, + } + + conn, err = createConnectionForURL(settings.URL, settings) + require.NoError(t, err) + require.NotNil(t, conn) + err = conn.Close() + require.NoError(t, err) + + // Invalid TLS + + settings.TLSConfig = portainer.TLSConfiguration{ + TLS: true, + TLSSkipVerify: true, + TLSCertPath: "/invalid/path/cert", + TLSKeyPath: "/invalid/path/key", + } + + conn, err = createConnectionForURL(settings.URL, settings) + require.Error(t, err) + require.Nil(t, conn) + + // StartTLS + + settings.TLSConfig.TLS = false + settings.StartTLS = true + + conn, err = createConnectionForURL(settings.URL, settings) + require.Error(t, err) + require.Nil(t, conn) +} + +func TestFailures(t *testing.T) { + t.Parallel() + s := Service{} + + err := s.AuthenticateUser("username", "password", &portainer.LDAPSettings{}) + require.Error(t, err) + + uGroups, err := s.GetUserGroups("username", &portainer.LDAPSettings{}) + require.Error(t, err) + require.Empty(t, uGroups) + + users, err := s.SearchUsers(&portainer.LDAPSettings{}) + require.Error(t, err) + require.Empty(t, users) + + groups, err := s.SearchGroups(&portainer.LDAPSettings{}) + require.Error(t, err) + require.Empty(t, groups) + + err = s.TestConnectivity(&portainer.LDAPSettings{}) + require.Error(t, err) +} diff --git a/api/logoutcontext/logout_context.go b/api/logoutcontext/logout_context.go new file mode 100644 index 0000000..ea172ba --- /dev/null +++ b/api/logoutcontext/logout_context.go @@ -0,0 +1,20 @@ +package logoutcontext + +import ( + "context" +) + +const LogoutPrefix = "logout-" + +func GetContext(token string) context.Context { + return GetService(logoutToken(token)).GetLogoutCtx() +} + +func Cancel(token string) { + GetService(logoutToken(token)).Cancel() + RemoveService(logoutToken(token)) +} + +func logoutToken(token string) string { + return LogoutPrefix + token +} diff --git a/api/logoutcontext/logoutcontext_test.go b/api/logoutcontext/logoutcontext_test.go new file mode 100644 index 0000000..203d83b --- /dev/null +++ b/api/logoutcontext/logoutcontext_test.go @@ -0,0 +1,106 @@ +package logoutcontext + +import ( + "context" + "sync" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGetContext_ReturnsActiveContext(t *testing.T) { + t.Parallel() + + token := "token-get-context-active" + defer Cancel(token) + + ctx := GetContext(token) + require.NoError(t, ctx.Err()) +} + +func TestCancel_CancelsContext(t *testing.T) { + t.Parallel() + + token := "token-cancel" + + ctx := GetContext(token) + require.NoError(t, ctx.Err()) + + Cancel(token) + + require.ErrorIs(t, ctx.Err(), context.Canceled) +} + +func TestCancel_RemovesService(t *testing.T) { + t.Parallel() + + token := "token-cancel-removes" + + first := GetContext(token) + Cancel(token) + + second := GetContext(token) + defer Cancel(token) + + require.ErrorIs(t, first.Err(), context.Canceled) + require.NoError(t, second.Err()) + require.NotEqual(t, first, second) +} + +func TestGetService_ReturnsSameServiceForSameToken(t *testing.T) { + t.Parallel() + + token := logoutToken("token-same-service") + defer RemoveService(token) + + s1 := GetService(token) + s2 := GetService(token) + + require.Same(t, s1, s2) +} + +func TestGetService_ReturnsDistinctServicesForDifferentTokens(t *testing.T) { + t.Parallel() + + tokenA := logoutToken("token-distinct-a") + tokenB := logoutToken("token-distinct-b") + defer RemoveService(tokenA) + defer RemoveService(tokenB) + + sA := GetService(tokenA) + sB := GetService(tokenB) + + require.NotSame(t, sA, sB) +} + +func TestGetService_ConcurrentAccess(t *testing.T) { + t.Parallel() + + const goroutines = 50 + token := logoutToken("token-concurrent") + defer RemoveService(token) + + var wg sync.WaitGroup + services := make([]*Service, goroutines) + + for i := range goroutines { + wg.Add(1) + go func(i int) { + defer wg.Done() + services[i] = GetService(token) + }(i) + } + + wg.Wait() + + for i := 1; i < goroutines; i++ { + require.Same(t, services[0], services[i]) + } +} + +func TestLogoutToken_AddsPrefix(t *testing.T) { + t.Parallel() + + result := logoutToken("abc123") + require.Equal(t, LogoutPrefix+"abc123", result) +} diff --git a/api/logoutcontext/service.go b/api/logoutcontext/service.go new file mode 100644 index 0000000..d608b0e --- /dev/null +++ b/api/logoutcontext/service.go @@ -0,0 +1,28 @@ +package logoutcontext + +import ( + "context" +) + +type ( + Service struct { + ctx context.Context + cancel context.CancelFunc + } +) + +func NewService() *Service { + ctx, cancel := context.WithCancel(context.Background()) + return &Service{ + ctx: ctx, + cancel: cancel, + } +} + +func (s *Service) Cancel() { + s.cancel() +} + +func (s *Service) GetLogoutCtx() context.Context { + return s.ctx +} diff --git a/api/logoutcontext/service_factory.go b/api/logoutcontext/service_factory.go new file mode 100644 index 0000000..c01e4ae --- /dev/null +++ b/api/logoutcontext/service_factory.go @@ -0,0 +1,34 @@ +package logoutcontext + +import "sync" + +type ( + ServiceFactory struct { + mu sync.Mutex + services map[string]*Service + } +) + +var serviceFactory = ServiceFactory{ + services: make(map[string]*Service), +} + +func GetService(token string) *Service { + serviceFactory.mu.Lock() + defer serviceFactory.mu.Unlock() + + service, ok := serviceFactory.services[token] + if !ok { + service = NewService() + serviceFactory.services[token] = service + } + + return service +} + +func RemoveService(token string) { + serviceFactory.mu.Lock() + defer serviceFactory.mu.Unlock() + + delete(serviceFactory.services, token) +} diff --git a/api/logs/log.go b/api/logs/log.go new file mode 100644 index 0000000..1c6ff74 --- /dev/null +++ b/api/logs/log.go @@ -0,0 +1,70 @@ +package logs + +import ( + "fmt" + "io" + stdlog "log" + "os" + + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "github.com/rs/zerolog/pkgerrors" +) + +func ConfigureLogger() { + zerolog.ErrorStackFieldName = "stack_trace" + zerolog.ErrorStackMarshaler = pkgerrors.MarshalStack + zerolog.TimeFieldFormat = zerolog.TimeFormatUnix + + stdlog.SetFlags(0) + stdlog.SetOutput(log.Logger) + + log.Logger = log.Logger.With().Caller().Stack().Logger() +} + +func SetLoggingLevel(level string) { + switch level { + case "ERROR": + zerolog.SetGlobalLevel(zerolog.ErrorLevel) + case "WARN": + zerolog.SetGlobalLevel(zerolog.WarnLevel) + case "INFO": + zerolog.SetGlobalLevel(zerolog.InfoLevel) + case "DEBUG": + zerolog.SetGlobalLevel(zerolog.DebugLevel) + } +} + +func SetLoggingMode(mode string) { + switch mode { + case "PRETTY": + log.Logger = log.Output(zerolog.ConsoleWriter{ + Out: os.Stderr, + TimeFormat: "2006/01/02 03:04PM", + FormatMessage: formatMessage, + }) + case "NOCOLOR": + log.Logger = log.Output(zerolog.ConsoleWriter{ + Out: os.Stderr, + TimeFormat: "2006/01/02 03:04PM", + FormatMessage: formatMessage, + NoColor: true, + }) + case "JSON": + log.Logger = log.Output(os.Stderr) + } +} + +func formatMessage(i any) string { + if i == nil { + return "" + } + + return fmt.Sprintf("%s |", i) +} + +func CloseAndLogErr(c io.Closer) { + if err := c.Close(); err != nil { + log.Error().Err(err).Msg("failure to close resource") + } +} diff --git a/api/logs/log_test.go b/api/logs/log_test.go new file mode 100644 index 0000000..e3034e8 --- /dev/null +++ b/api/logs/log_test.go @@ -0,0 +1,111 @@ +package logs + +import ( + "errors" + "testing" + + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + + "github.com/stretchr/testify/require" +) + +func saveGlobalLevel(t *testing.T) { + t.Helper() + orig := zerolog.GlobalLevel() + t.Cleanup(func() { zerolog.SetGlobalLevel(orig) }) +} + +func saveLogger(t *testing.T) { + t.Helper() + orig := log.Logger + t.Cleanup(func() { log.Logger = orig }) +} + +func TestSetLoggingLevel_Error(t *testing.T) { + saveGlobalLevel(t) + + SetLoggingLevel("ERROR") + require.Equal(t, zerolog.ErrorLevel, zerolog.GlobalLevel()) +} + +func TestSetLoggingLevel_Warn(t *testing.T) { + saveGlobalLevel(t) + + SetLoggingLevel("WARN") + require.Equal(t, zerolog.WarnLevel, zerolog.GlobalLevel()) +} + +func TestSetLoggingLevel_Info(t *testing.T) { + saveGlobalLevel(t) + + SetLoggingLevel("INFO") + require.Equal(t, zerolog.InfoLevel, zerolog.GlobalLevel()) +} + +func TestSetLoggingLevel_Debug(t *testing.T) { + saveGlobalLevel(t) + + SetLoggingLevel("DEBUG") + require.Equal(t, zerolog.DebugLevel, zerolog.GlobalLevel()) +} + +func TestSetLoggingLevel_UnknownLevelIsNoop(t *testing.T) { + saveGlobalLevel(t) + + zerolog.SetGlobalLevel(zerolog.InfoLevel) + SetLoggingLevel("TRACE") + require.Equal(t, zerolog.InfoLevel, zerolog.GlobalLevel()) +} + +func TestSetLoggingMode_Pretty(t *testing.T) { + saveLogger(t) + + SetLoggingMode("PRETTY") +} + +func TestSetLoggingMode_Nocolor(t *testing.T) { + saveLogger(t) + + SetLoggingMode("NOCOLOR") +} + +func TestSetLoggingMode_JSON(t *testing.T) { + saveLogger(t) + + SetLoggingMode("JSON") +} + +func TestSetLoggingMode_UnknownModeIsNoop(t *testing.T) { + saveLogger(t) + + SetLoggingMode("UNKNOWN") +} + +func TestFormatMessage_NonNil(t *testing.T) { + t.Parallel() + + require.Equal(t, "hello |", formatMessage("hello")) +} + +func TestFormatMessage_Nil(t *testing.T) { + t.Parallel() + + require.Empty(t, formatMessage(nil)) +} + +type stubCloser struct{ err error } + +func (s *stubCloser) Close() error { return s.err } + +func TestCloseAndLogErr_Success(t *testing.T) { + t.Parallel() + + CloseAndLogErr(&stubCloser{err: nil}) +} + +func TestCloseAndLogErr_Error(t *testing.T) { + t.Parallel() + + CloseAndLogErr(&stubCloser{err: errors.New("close failed")}) +} diff --git a/api/motd/service.go b/api/motd/service.go new file mode 100644 index 0000000..d53a5f1 --- /dev/null +++ b/api/motd/service.go @@ -0,0 +1,100 @@ +package motd + +import ( + "context" + "strings" + "sync/atomic" + "time" + + "github.com/portainer/portainer/api/http/client" + "github.com/portainer/portainer/pkg/libcrypto" + libclient "github.com/portainer/portainer/pkg/libhttp/client" + "github.com/portainer/portainer/pkg/schedule" + "github.com/rs/zerolog/log" + + "github.com/segmentio/encoding/json" +) + +const refreshInterval = 6 * time.Hour + +// Motd holds the processed message of the day data. +type Motd struct { + Title string `json:"Title"` + Message string `json:"Message"` + ContentLayout map[string]string `json:"ContentLayout"` + Style string `json:"Style"` + Hash []byte `json:"Hash"` +} + +// Service fetches and caches the MOTD from an external URL. +type Service struct { + cached atomic.Pointer[Motd] + motdURL string +} + +// NewService creates a new MOTD service that fetches from motdURL. +func NewService(motdURL string) *Service { + return &Service{ + motdURL: motdURL, + } +} + +// Start warms the cache immediately and refreshes it every refreshInterval. +func (s *Service) Start(ctx context.Context) { + if err := libclient.ExternalRequestDisabled(s.motdURL); err != nil { + return + } + + go s.refresh() + go schedule.RunOnInterval(ctx, refreshInterval, s.refresh, nil) +} + +// GetCached returns the cached MOTD +func (s *Service) GetCached() Motd { + motd := s.cached.Load() + if motd == nil { + return Motd{} + } + + return *motd +} + +type motdData struct { + Title string `json:"title"` + Message []string `json:"message"` + ContentLayout map[string]string `json:"contentLayout"` + Style string `json:"style"` +} + +func (s *Service) refresh() { + if err := libclient.ExternalRequestDisabled(s.motdURL); err != nil { + log.Debug().Err(err).Msg("External request disabled: MOTD") + + s.cached.Store(nil) + + return + } + + raw, err := client.Get(s.motdURL, 0) + if err != nil { + log.Debug().Err(err).Msg("Failed to fetch MOTD") + return + } + + var data motdData + if err := json.Unmarshal(raw, &data); err != nil { + log.Debug().Err(err).Msg("Failed to parse MOTD") + return + } + + message := strings.Join(data.Message, "\n") + hash := libcrypto.InsecureHashFromBytes([]byte(message)) + + s.cached.Store(&Motd{ + Title: data.Title, + Message: message, + Hash: hash, + ContentLayout: data.ContentLayout, + Style: data.Style, + }) +} diff --git a/api/motd/service_test.go b/api/motd/service_test.go new file mode 100644 index 0000000..283b474 --- /dev/null +++ b/api/motd/service_test.go @@ -0,0 +1,84 @@ +package motd + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGetCached_InitiallyEmpty(t *testing.T) { + t.Parallel() + svc := NewService("http://unused") + assert.Equal(t, Motd{}, svc.GetCached()) +} + +func TestRefresh_Success_PopulatesCache(t *testing.T) { + t.Parallel() + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"title":"Test title","message":["Hello","world"]}`)) + })) + defer ts.Close() + + svc := NewService(ts.URL) + svc.refresh() + + result := svc.GetCached() + assert.Equal(t, "Test title", result.Title) + assert.Equal(t, "Hello\nworld", result.Message) + assert.NotEmpty(t, result.Hash) +} + +func TestRefresh_FetchError_KeepsPreviousCache(t *testing.T) { + t.Parallel() + // First populate cache with good data + good := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"title":"Cached","message":["Cached message"]}`)) + })) + defer good.Close() + + svc := NewService(good.URL) + svc.refresh() + assert.Equal(t, "Cached", svc.GetCached().Title) + + // Now point at a failing server and refresh + svc.motdURL = "http://127.0.0.1:0" // unreachable + svc.refresh() + + // Cache should be unchanged + assert.Equal(t, "Cached", svc.GetCached().Title) +} + +func TestRefresh_InvalidJSON_KeepsPreviousCache(t *testing.T) { + t.Parallel() + good := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"title":"Cached","message":["Cached message"]}`)) + })) + defer good.Close() + + svc := NewService(good.URL) + svc.refresh() + assert.Equal(t, "Cached", svc.GetCached().Title) + + // Serve invalid JSON + bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`not json`)) + })) + defer bad.Close() + + svc.motdURL = bad.URL + svc.refresh() + + assert.Equal(t, "Cached", svc.GetCached().Title) +} + +func TestRefresh_FetchError_CacheRemainsEmpty(t *testing.T) { + t.Parallel() + svc := NewService("http://127.0.0.1:0") // unreachable + svc.refresh() + assert.Equal(t, Motd{}, svc.GetCached()) +} diff --git a/api/oauth/oauth.go b/api/oauth/oauth.go new file mode 100644 index 0000000..7221189 --- /dev/null +++ b/api/oauth/oauth.go @@ -0,0 +1,187 @@ +package oauth + +import ( + "context" + "io" + "maps" + "mime" + "net/http" + "net/url" + "strings" + "time" + + portainer "github.com/portainer/portainer/api" + + "github.com/golang-jwt/jwt/v5" + "github.com/pkg/errors" + "github.com/rs/zerolog/log" + "github.com/segmentio/encoding/json" + "golang.org/x/oauth2" +) + +// 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 environment(endpoint). +// On success, it will then return the username and token expiry time associated to authenticated user by fetching this information +// from the resource server and matching it with the user identifier setting. +func (Service) Authenticate(ctx context.Context, code string, configuration *portainer.OAuthSettings) (string, error) { + ctx, cancel := context.WithTimeout(ctx, time.Minute) + defer cancel() + + token, err := GetOAuthToken(ctx, code, configuration) + if err != nil { + log.Error().Err(err).Msg("failed retrieving oauth token") + + return "", err + } + + idToken, err := GetIdToken(token) + if err != nil { + log.Error().Err(err).Msg("failed parsing id_token") + } + + resource, err := GetResource(ctx, token.AccessToken, configuration.ResourceURI) + if err != nil { + log.Error().Err(err).Msg("failed retrieving resource") + + return "", err + } + + maps.Copy(resource, idToken) + + username, err := GetUsername(resource, configuration.UserIdentifier) + if err != nil { + log.Error().Err(err).Msg("failed retrieving username") + + return "", err + } + + return username, nil +} + +func GetOAuthToken(ctx context.Context, code string, configuration *portainer.OAuthSettings) (*oauth2.Token, error) { + unescapedCode, err := url.QueryUnescape(code) + if err != nil { + return nil, err + } + + config := buildConfig(configuration) + + return config.Exchange(ctx, unescapedCode) +} + +// GetIdToken retrieves parsed id_token from the OAuth token response. +// This is necessary for OAuth providers like Azure +// that do not provide information about user groups on the user resource endpoint. +func GetIdToken(token *oauth2.Token) (map[string]any, error) { + tokenData := make(map[string]any) + + idToken := token.Extra("id_token") + if idToken == nil { + return tokenData, nil + } + + jwtParser := jwt.NewParser(jwt.WithoutClaimsValidation()) + + t, _, err := jwtParser.ParseUnverified(idToken.(string), jwt.MapClaims{}) + if err != nil { + return tokenData, errors.Wrap(err, "failed to parse id_token") + } + + if claims, ok := t.Claims.(jwt.MapClaims); ok { + maps.Copy(tokenData, claims) + } + + return tokenData, nil +} + +func GetResource(ctx context.Context, token string, resourceURI string) (map[string]any, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, resourceURI, nil) + if err != nil { + return nil, err + } + + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer func() { + if err := resp.Body.Close(); err != nil { + log.Warn().Err(err).Msg("failed to close response body") + } + }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + if resp.StatusCode != http.StatusOK { + return nil, &oauth2.RetrieveError{ + Response: resp, + Body: body, + } + } + + // Some OAuth providers (e.g. Cloudflare Access) return malformed Content-Type headers + // (e.g. "application/json; charset=utf-8, application/json") that mime.ParseMediaType + // cannot parse. We intentionally ignore that error: if parsing fails, content is empty, + // the urlencoded branch is skipped, and json.Unmarshal below acts as the final validator. + originalContentType := resp.Header.Get("Content-Type") + content, _, err := mime.ParseMediaType(originalContentType) + if err != nil { + log.Debug(). + Err(err). + Str("context", "OAuthResourceFetch"). + Str("original_content_type", originalContentType). + Str("parsed_content_type", content). + Msg("Failed to parse Content-Type header from resource endpoint, falling back to JSON") + } + + if content == "application/x-www-form-urlencoded" || content == "text/plain" { + values, err := url.ParseQuery(string(body)) + if err != nil { + return nil, err + } + + datamap := make(map[string]any) + for k, v := range values { + if len(v) == 0 { + datamap[k] = "" + } else { + datamap[k] = v[0] + } + } + + return datamap, nil + } + + var datamap map[string]any + if err = json.Unmarshal(body, &datamap); err != nil { + return nil, err + } + + return datamap, nil +} + +func buildConfig(config *portainer.OAuthSettings) *oauth2.Config { + return &oauth2.Config{ + ClientID: config.ClientID, + ClientSecret: config.ClientSecret, + RedirectURL: config.RedirectURI, + Scopes: strings.Split(config.Scopes, ","), + Endpoint: oauth2.Endpoint{ + AuthURL: config.AuthorizationURI, + TokenURL: config.AccessTokenURI, + AuthStyle: config.AuthStyle, + }, + } +} diff --git a/api/oauth/oauth_resource.go b/api/oauth/oauth_resource.go new file mode 100644 index 0000000..f0f1d7a --- /dev/null +++ b/api/oauth/oauth_resource.go @@ -0,0 +1,22 @@ +package oauth + +import ( + "errors" + "strconv" +) + +func GetUsername(datamap map[string]any, userIdentifier string) (string, error) { + username, ok := datamap[userIdentifier].(string) + if ok && username != "" { + return username, nil + } + + if !ok { + username, ok := datamap[userIdentifier].(float64) + if ok && username != 0 { + return strconv.Itoa(int(username)), nil + } + } + + return "", errors.New("failed to extract username from oauth resource") +} diff --git a/api/oauth/oauth_resource_test.go b/api/oauth/oauth_resource_test.go new file mode 100644 index 0000000..9968587 --- /dev/null +++ b/api/oauth/oauth_resource_test.go @@ -0,0 +1,74 @@ +package oauth + +import ( + "testing" + + portainer "github.com/portainer/portainer/api" +) + +func Test_getUsername(t *testing.T) { + t.Parallel() + t.Run("fails for non-matching user identifier", func(t *testing.T) { + oauthSettings := &portainer.OAuthSettings{UserIdentifier: "username"} + datamap := map[string]any{"name": "john"} + + if _, err := GetUsername(datamap, oauthSettings.UserIdentifier); err == nil { + t.Errorf("getUsername should fail if user identifier doesn't exist as key in oauth userinfo object") + } + }) + + t.Run("fails if username is empty string", func(t *testing.T) { + oauthSettings := &portainer.OAuthSettings{UserIdentifier: "username"} + datamap := map[string]any{"username": ""} + + if _, err := GetUsername(datamap, oauthSettings.UserIdentifier); err == nil { + t.Errorf("getUsername should fail if username from oauth userinfo object is empty string") + } + }) + + t.Run("fails if username is 0 int", func(t *testing.T) { + oauthSettings := &portainer.OAuthSettings{UserIdentifier: "username"} + datamap := map[string]any{"username": 0} + + if _, err := GetUsername(datamap, oauthSettings.UserIdentifier); err == nil { + t.Errorf("getUsername should fail if username from oauth userinfo object is 0 val int") + } + }) + + t.Run("fails if username is negative int", func(t *testing.T) { + oauthSettings := &portainer.OAuthSettings{UserIdentifier: "username"} + datamap := map[string]any{"username": -1} + + if _, err := GetUsername(datamap, oauthSettings.UserIdentifier); err == nil { + t.Errorf("getUsername should fail if username from oauth userinfo object is -1 (negative) int") + } + }) + + t.Run("succeeds if username is matched and is not empty", func(t *testing.T) { + oauthSettings := &portainer.OAuthSettings{UserIdentifier: "username"} + datamap := map[string]any{"username": "john"} + + if _, err := GetUsername(datamap, oauthSettings.UserIdentifier); err != nil { + t.Errorf("getUsername should succeed if username from oauth userinfo object matched and non-empty") + } + }) + + // looks like a bug!? + t.Run("fails if username is matched and is positive int", func(t *testing.T) { + oauthSettings := &portainer.OAuthSettings{UserIdentifier: "username"} + datamap := map[string]any{"username": 1} + + if _, err := GetUsername(datamap, oauthSettings.UserIdentifier); err == nil { + t.Errorf("getUsername should fail if username from oauth userinfo object matched is positive int") + } + }) + + t.Run("succeeds if username is matched and is non-zero (or negative) float", func(t *testing.T) { + oauthSettings := &portainer.OAuthSettings{UserIdentifier: "username"} + datamap := map[string]any{"username": 1.1} + + if _, err := GetUsername(datamap, oauthSettings.UserIdentifier); err != nil { + t.Errorf("getUsername should succeed if username from oauth userinfo object matched and non-zero (or negative)") + } + }) +} diff --git a/api/oauth/oauth_test.go b/api/oauth/oauth_test.go new file mode 100644 index 0000000..823107b --- /dev/null +++ b/api/oauth/oauth_test.go @@ -0,0 +1,182 @@ +package oauth + +import ( + "net/http" + "net/http/httptest" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/oauth/oauthtest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/oauth2" +) + +func Test_getOAuthToken(t *testing.T) { + t.Parallel() + validCode := "valid-code" + srv, config := oauthtest.RunOAuthServer(validCode, &portainer.OAuthSettings{}) + defer srv.Close() + + t.Run("getOAuthToken fails upon invalid code", func(t *testing.T) { + code := "" + if _, err := GetOAuthToken(t.Context(), code, config); err == nil { + t.Errorf("getOAuthToken should fail upon providing invalid code; code=%v", code) + } + }) + + t.Run("getOAuthToken succeeds upon providing valid code", func(t *testing.T) { + code := validCode + token, err := GetOAuthToken(t.Context(), code, config) + + if token == nil || err != nil { + t.Errorf("getOAuthToken should successfully return access token upon providing valid code") + } + }) +} + +func Test_getIdToken(t *testing.T) { + t.Parallel() + verifiedToken := `eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJPbmxpbmUgSldUIEJ1aWxkZXIiLCJpYXQiOjE2NTM1NDA3MjksImV4cCI6MTY4NTA3NjcyOSwiYXVkIjoid3d3LmV4YW1wbGUuY29tIiwic3ViIjoiam9obi5kb2VAZXhhbXBsZS5jb20iLCJHaXZlbk5hbWUiOiJKb2huIiwiU3VybmFtZSI6IkRvZSIsIkdyb3VwcyI6WyJGaXJzdCIsIlNlY29uZCJdfQ.GeU8XCV4Y4p5Vm-i63Aj7UP5zpb_0Zxb7-DjM2_z-s8` + nonVerifiedToken := `eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJPbmxpbmUgSldUIEJ1aWxkZXIiLCJpYXQiOjE2NTM1NDA3MjksImV4cCI6MTY4NTA3NjcyOSwiYXVkIjoid3d3LmV4YW1wbGUuY29tIiwic3ViIjoiam9obi5kb2VAZXhhbXBsZS5jb20iLCJHaXZlbk5hbWUiOiJKb2huIiwiU3VybmFtZSI6IkRvZSIsIkdyb3VwcyI6WyJGaXJzdCIsIlNlY29uZCJdfQ.` + claims := map[string]any{ + "iss": "Online JWT Builder", + "iat": float64(1653540729), + "exp": float64(1685076729), + "aud": "www.example.com", + "sub": "john.doe@example.com", + "GivenName": "John", + "Surname": "Doe", + "Groups": []any{"First", "Second"}, + } + + tests := []struct { + testName string + idToken string + expectedResult map[string]any + expectedError error + }{ + { + testName: "should return claims if token exists and is verified", + idToken: verifiedToken, + expectedResult: claims, + expectedError: nil, + }, + { + testName: "should return claims if token exists but is not verified", + idToken: nonVerifiedToken, + expectedResult: claims, + expectedError: nil, + }, + { + testName: "should return empty map if token does not exist", + idToken: "", + expectedResult: make(map[string]any), + expectedError: nil, + }, + } + + for _, tc := range tests { + t.Run(tc.testName, func(t *testing.T) { + token := &oauth2.Token{} + if tc.idToken != "" { + token = token.WithExtra(map[string]any{"id_token": tc.idToken}) + } + + result, err := GetIdToken(token) + require.ErrorIs(t, err, tc.expectedError) + assert.Equal(t, tc.expectedResult, result) + }) + } +} + +func Test_getResource(t *testing.T) { + t.Parallel() + srv, config := oauthtest.RunOAuthServer("", &portainer.OAuthSettings{}) + defer srv.Close() + + t.Run("should fail upon missing Authorization Bearer header", func(t *testing.T) { + if _, err := GetResource(t.Context(), "", config.ResourceURI); err == nil { + t.Errorf("getResource should fail if access token is not provided in auth bearer header") + } + }) + + t.Run("should fail upon providing incorrect Authorization Bearer header", func(t *testing.T) { + if _, err := GetResource(t.Context(), "incorrect-token", config.ResourceURI); err == nil { + t.Errorf("getResource should fail if incorrect access token provided in auth bearer header") + } + }) + + t.Run("should succeed upon providing correct Authorization Bearer header", func(t *testing.T) { + if _, err := GetResource(t.Context(), oauthtest.AccessToken, config.ResourceURI); err != nil { + t.Errorf("getResource should succeed if correct access token provided in auth bearer header") + } + }) +} + +func Test_getResource_malformedContentType(t *testing.T) { + t.Parallel() + body := `{"username":"test-oauth-user"}` + + tests := []struct { + name string + contentType string + }{ + { + name: "duplicate mime types separated by comma", + contentType: "application/json; charset=utf-8, application/json", + }, + { + name: "missing mime type with only parameters", + contentType: "; charset=utf-8", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", tc.contentType) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(body)) + })) + defer srv.Close() + + result, err := GetResource(t.Context(), "any-token", srv.URL) + require.NoError(t, err, "GetResource should succeed despite malformed Content-Type header") + assert.Equal(t, "test-oauth-user", result["username"]) + }) + } +} + +func Test_Authenticate(t *testing.T) { + t.Parallel() + code := "valid-code" + authService := NewService() + + t.Run("should fail if user identifier does not get matched in resource", func(t *testing.T) { + srv, config := oauthtest.RunOAuthServer(code, &portainer.OAuthSettings{}) + defer srv.Close() + + if _, err := authService.Authenticate(t.Context(), code, config); err == nil { + t.Error("Authenticate should fail to extract username from resource if incorrect UserIdentifier provided") + } + }) + + t.Run("should succeed if user identifier does get matched in resource", func(t *testing.T) { + config := &portainer.OAuthSettings{UserIdentifier: "username"} + srv, config := oauthtest.RunOAuthServer(code, config) + defer srv.Close() + + username, err := authService.Authenticate(t.Context(), code, config) + if err != nil { + t.Errorf("Authenticate should succeed to extract username from resource if correct UserIdentifier provided; UserIdentifier=%s", config.UserIdentifier) + } + + want := "test-oauth-user" + if username != want { + t.Errorf("Authenticate should return correct username; got=%s, want=%s", username, want) + } + }) + +} diff --git a/api/oauth/oauthtest/oauth_server.go b/api/oauth/oauthtest/oauth_server.go new file mode 100644 index 0000000..08368f6 --- /dev/null +++ b/api/oauth/oauthtest/oauth_server.go @@ -0,0 +1,103 @@ +package oauthtest + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" + + portainer "github.com/portainer/portainer/api" + "github.com/rs/zerolog/log" + + "github.com/gorilla/mux" + "github.com/segmentio/encoding/json" +) + +const AccessToken = "test-token" + +// OAuthRoutes is an OAuth 2.0 compliant handler +func OAuthRoutes(code string, config *portainer.OAuthSettings) http.Handler { + router := mux.NewRouter() + + router.HandleFunc( + "/authorize", + func(w http.ResponseWriter, req *http.Request) { + location := fmt.Sprintf("%s?code=%s&state=%s", config.RedirectURI, code, "anything") + // w.Header().Set("Location", location) + // w.WriteHeader(http.StatusFound) + http.Redirect(w, req, location, http.StatusFound) + }, + ).Methods(http.MethodGet) + + router.HandleFunc( + "/access_token", + func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/json") + + if err := req.ParseForm(); err != nil { + if _, innerErr := fmt.Fprintf(w, "ParseForm() err: %v", err); innerErr != nil { + log.Warn().Err(innerErr).Msg("failed to write response") + } + + return + } + + reqCode := req.FormValue("code") + if reqCode != code { + w.WriteHeader(http.StatusUnauthorized) + return + } + + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(map[string]any{ + "token_type": "Bearer", + "expires_in": 86400, + "access_token": AccessToken, + "scope": "groups", + }); err != nil { + log.Warn().Err(err).Msg("failed to write response") + } + }, + ).Methods(http.MethodPost) + + router.HandleFunc( + "/user", + func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/json") + + authHeader := req.Header.Get("Authorization") + splitToken := strings.Split(authHeader, "Bearer ") + if len(splitToken) < 2 || splitToken[1] != AccessToken { + w.WriteHeader(http.StatusUnauthorized) + return + } + + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(map[string]any{ + "username": "test-oauth-user", + "groups": "testing", + }); err != nil { + log.Warn().Err(err).Msg("failed to write response") + } + }, + ).Methods(http.MethodGet) + + return router +} + +// RunOAuthServer is a barebones OAuth 2.0 compliant test server which can be used to test OAuth 2 functionality +func RunOAuthServer(code string, config *portainer.OAuthSettings) (*httptest.Server, *portainer.OAuthSettings) { + srv := httptest.NewUnstartedServer(http.DefaultServeMux) + + addr := srv.Listener.Addr() + + config.AuthorizationURI = fmt.Sprintf("http://%s/authorize", addr) + config.AccessTokenURI = fmt.Sprintf("http://%s/access_token", addr) + config.ResourceURI = fmt.Sprintf("http://%s/user", addr) + config.RedirectURI = fmt.Sprintf("http://%s/", addr) + + srv.Config.Handler = OAuthRoutes(code, config) + srv.Start() + + return srv, config +} diff --git a/api/pending_action.go b/api/pending_action.go new file mode 100644 index 0000000..212ba22 --- /dev/null +++ b/api/pending_action.go @@ -0,0 +1,50 @@ +package portainer + +import "github.com/segmentio/encoding/json" + +type ( + PendingActionID int + PendingAction struct { + ID PendingActionID `json:"ID"` + EndpointID EndpointID `json:"EndpointID"` + Action string `json:"Action"` + ActionData any `json:"ActionData"` + CreatedAt int64 `json:"CreatedAt"` + } + + PendingActionHandler interface { + Execute(PendingAction, *Endpoint) error + } +) + +// MarshalJSON marshals the PendingAction struct to JSON +// and converts the ActionData field to an embedded json string +// This makes unmarshalling the ActionData field easier +func (pa PendingAction) MarshalJSON() ([]byte, error) { + // Create a map to hold the marshalled fields + data := map[string]any{ + "ID": pa.ID, + "EndpointID": pa.EndpointID, + "Action": pa.Action, + "CreatedAt": pa.CreatedAt, + } + + actionDataBytes, err := json.Marshal(pa.ActionData) + if err != nil { + return nil, err + } + data["ActionData"] = string(actionDataBytes) + + // Marshal the map + return json.Marshal(data) +} + +// Unmarshal the ActionData field from a string to a specific type. +func (pa PendingAction) UnmarshallActionData(v any) error { + s, ok := pa.ActionData.(string) + if !ok { + return nil + } + + return json.Unmarshal([]byte(s), v) +} diff --git a/api/pendingactions/actions/actions.go b/api/pendingactions/actions/actions.go new file mode 100644 index 0000000..4931589 --- /dev/null +++ b/api/pendingactions/actions/actions.go @@ -0,0 +1,7 @@ +package actions + +const ( + CleanNAPWithOverridePolicies = "CleanNAPWithOverridePolicies" + DeletePortainerK8sRegistrySecrets = "DeletePortainerK8sRegistrySecrets" + PostInitMigrateEnvironment = "PostInitMigrateEnvironment" +) diff --git a/api/pendingactions/handlers/clean_nap_with_override_policies.go b/api/pendingactions/handlers/clean_nap_with_override_policies.go new file mode 100644 index 0000000..94f31cb --- /dev/null +++ b/api/pendingactions/handlers/clean_nap_with_override_policies.go @@ -0,0 +1,78 @@ +package handlers + +import ( + "fmt" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/internal/authorization" + "github.com/portainer/portainer/api/pendingactions/actions" + "github.com/rs/zerolog/log" +) + +type ( + cleanNAPWithOverridePolicies struct { + EndpointGroupID portainer.EndpointGroupID + } + + HandlerCleanNAPWithOverridePolicies struct { + authorizationService *authorization.Service + dataStore dataservices.DataStore + } +) + +// NewCleanNAPWithOverridePolicies creates a new CleanNAPWithOverridePolicies pending action +func NewCleanNAPWithOverridePolicies(endpointID portainer.EndpointID, gid *portainer.EndpointGroupID) portainer.PendingAction { + pendingAction := portainer.PendingAction{ + EndpointID: endpointID, + Action: actions.CleanNAPWithOverridePolicies, + } + + if gid != nil { + pendingAction.ActionData = cleanNAPWithOverridePolicies{ + EndpointGroupID: *gid, + } + } + + return pendingAction +} + +// NewHandlerCleanNAPWithOverridePolicies creates a new handler to execute CleanNAPWithOverridePolicies pending action +func NewHandlerCleanNAPWithOverridePolicies( + authorizationService *authorization.Service, + dataStore dataservices.DataStore, +) *HandlerCleanNAPWithOverridePolicies { + return &HandlerCleanNAPWithOverridePolicies{ + authorizationService: authorizationService, + dataStore: dataStore, + } +} + +func (h *HandlerCleanNAPWithOverridePolicies) Execute(pendingAction portainer.PendingAction, endpoint *portainer.Endpoint) error { + if pendingAction.ActionData == nil { + return h.authorizationService.CleanNAPWithOverridePolicies(h.dataStore, endpoint, nil) + } + + var payload cleanNAPWithOverridePolicies + if err := pendingAction.UnmarshallActionData(&payload); err != nil { + log.Error().Err(err).Msgf("Error unmarshalling endpoint group ID for cleaning NAP with override policies for environment %d", endpoint.ID) + return fmt.Errorf("failed to unmarshal endpoint group ID for cleaning NAP with override policies for environment %d: %w", endpoint.ID, err) + } + + if payload.EndpointGroupID == 0 { + return h.authorizationService.CleanNAPWithOverridePolicies(h.dataStore, endpoint, nil) + } + + endpointGroup, err := h.dataStore.EndpointGroup().Read(payload.EndpointGroupID) + if err != nil { + log.Error().Err(err).Msgf("Error reading environment group to clean NAP with override policies for environment %d and environment group %d", endpoint.ID, endpointGroup.ID) + return fmt.Errorf("failed to retrieve environment group %d: %w", payload.EndpointGroupID, err) + } + + if err := h.authorizationService.CleanNAPWithOverridePolicies(h.dataStore, endpoint, endpointGroup); err != nil { + log.Error().Err(err).Msgf("Error cleaning NAP with override policies for environment %d and environment group %d", endpoint.ID, endpointGroup.ID) + return fmt.Errorf("failed to clean NAP with override policies for environment %d and environment group %d: %w", endpoint.ID, endpointGroup.ID, err) + } + + return nil +} diff --git a/api/pendingactions/handlers/delete_k8s_registry_secrets.go b/api/pendingactions/handlers/delete_k8s_registry_secrets.go new file mode 100644 index 0000000..d97848f --- /dev/null +++ b/api/pendingactions/handlers/delete_k8s_registry_secrets.go @@ -0,0 +1,79 @@ +package handlers + +import ( + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/internal/authorization" + "github.com/portainer/portainer/api/internal/registryutils" + kubecli "github.com/portainer/portainer/api/kubernetes/cli" + "github.com/portainer/portainer/api/pendingactions/actions" +) + +type ( + HandlerDeleteK8sRegistrySecrets struct { + authorizationService *authorization.Service + dataStore dataservices.DataStore + kubeFactory *kubecli.ClientFactory + } + + deleteK8sRegistrySecretsData struct { + RegistryID portainer.RegistryID `json:"RegistryID"` + Namespaces []string `json:"Namespaces"` + } +) + +// NewDeleteK8sRegistrySecrets creates a new DeleteK8sRegistrySecrets pending action +func NewDeleteK8sRegistrySecrets(endpointID portainer.EndpointID, registryID portainer.RegistryID, namespaces []string) portainer.PendingAction { + return portainer.PendingAction{ + EndpointID: endpointID, + Action: actions.DeletePortainerK8sRegistrySecrets, + ActionData: &deleteK8sRegistrySecretsData{ + RegistryID: registryID, + Namespaces: namespaces, + }, + } +} + +// NewHandlerDeleteRegistrySecrets creates a new handler to execute DeleteK8sRegistrySecrets pending action +func NewHandlerDeleteRegistrySecrets( + authorizationService *authorization.Service, + dataStore dataservices.DataStore, + kubeFactory *kubecli.ClientFactory, +) *HandlerDeleteK8sRegistrySecrets { + return &HandlerDeleteK8sRegistrySecrets{ + authorizationService: authorizationService, + dataStore: dataStore, + kubeFactory: kubeFactory, + } +} + +func (h *HandlerDeleteK8sRegistrySecrets) Execute(pa portainer.PendingAction, endpoint *portainer.Endpoint) error { + if endpoint == nil || pa.ActionData == nil { + return nil + } + + var registryData deleteK8sRegistrySecretsData + err := pa.UnmarshallActionData(®istryData) + if err != nil { + return err + } + + kubeClient, err := h.kubeFactory.GetPrivilegedKubeClient(endpoint) + if err != nil { + return err + } + + secretName := registryutils.RegistrySecretName(registryData.RegistryID) + + for _, namespace := range registryData.Namespaces { + if err = kubeClient.RemoveImagePullSecretFromServiceAccount(namespace, "default", secretName); err != nil { + return err + } + + if err = kubeClient.DeleteRegistrySecret(registryData.RegistryID, namespace); err != nil { + return err + } + } + + return nil +} diff --git a/api/pendingactions/handlers/delete_k8s_registry_secrets_test.go b/api/pendingactions/handlers/delete_k8s_registry_secrets_test.go new file mode 100644 index 0000000..2b5a4e9 --- /dev/null +++ b/api/pendingactions/handlers/delete_k8s_registry_secrets_test.go @@ -0,0 +1,126 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "testing" + + portainer "github.com/portainer/portainer/api" + kubecli "github.com/portainer/portainer/api/kubernetes/cli" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + kfake "k8s.io/client-go/kubernetes/fake" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// buildPendingAction creates a PendingAction with ActionData serialized as a +// JSON string — matching the format UnmarshallActionData expects (string type assertion). +func buildPendingAction(endpointID portainer.EndpointID, registryID portainer.RegistryID, namespaces []string) portainer.PendingAction { + data := deleteK8sRegistrySecretsData{ + RegistryID: registryID, + Namespaces: namespaces, + } + raw, _ := json.Marshal(data) + return portainer.PendingAction{ + EndpointID: endpointID, + ActionData: string(raw), + } +} + +func TestHandlerDeleteK8sRegistrySecrets_Execute(t *testing.T) { + t.Parallel() + const endpointID portainer.EndpointID = 1 + const registryID portainer.RegistryID = 3 + + endpoint := &portainer.Endpoint{ID: endpointID, Name: "test-env", Type: portainer.AgentOnKubernetesEnvironment} + + t.Run("nil endpoint - returns nil without error", func(t *testing.T) { + factory := kubecli.NewTestClientFactory(endpointID, kubecli.NewTestKubeClient(kfake.NewSimpleClientset())) + h := NewHandlerDeleteRegistrySecrets(nil, nil, factory) + pa := buildPendingAction(endpointID, registryID, []string{"ns-a"}) + err := h.Execute(pa, nil) + require.NoError(t, err) + }) + + t.Run("nil action data - returns nil without error", func(t *testing.T) { + factory := kubecli.NewTestClientFactory(endpointID, kubecli.NewTestKubeClient(kfake.NewSimpleClientset())) + h := NewHandlerDeleteRegistrySecrets(nil, nil, factory) + pa := portainer.PendingAction{EndpointID: endpointID, ActionData: nil} + err := h.Execute(pa, endpoint) + require.NoError(t, err) + }) + + t.Run("malformed action data - returns unmarshal error", func(t *testing.T) { + factory := kubecli.NewTestClientFactory(endpointID, kubecli.NewTestKubeClient(kfake.NewSimpleClientset())) + h := NewHandlerDeleteRegistrySecrets(nil, nil, factory) + pa := portainer.PendingAction{ + EndpointID: endpointID, + ActionData: "{invalid json", + } + err := h.Execute(pa, endpoint) + require.Error(t, err) + }) + + t.Run("kube factory has no client for endpoint - returns error", func(t *testing.T) { + emptyFactory, err := kubecli.NewClientFactory(nil, nil, nil, "test", "", "") + require.NoError(t, err) + + h := NewHandlerDeleteRegistrySecrets(nil, nil, emptyFactory) + pa := buildPendingAction(endpointID, registryID, []string{"ns-a"}) + localEndpoint := &portainer.Endpoint{ + ID: endpointID, + Name: "test-env", + Type: portainer.KubernetesLocalEnvironment, + } + execErr := h.Execute(pa, localEndpoint) + assert.Error(t, execErr, "should return error when kube client cannot be obtained") + }) + + t.Run("all namespaces succeed - returns no error", func(t *testing.T) { + secretName := fmt.Sprintf("registry-%d", registryID) + + saA := &v1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{Name: "default", Namespace: "ns-a"}, + ImagePullSecrets: []v1.LocalObjectReference{{Name: secretName}}, + } + saB := &v1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{Name: "default", Namespace: "ns-b"}, + ImagePullSecrets: []v1.LocalObjectReference{{Name: secretName}}, + } + secretA := &v1.Secret{ObjectMeta: metav1.ObjectMeta{Name: secretName, Namespace: "ns-a"}} + secretB := &v1.Secret{ObjectMeta: metav1.ObjectMeta{Name: secretName, Namespace: "ns-b"}} + + fakeK8s := kfake.NewSimpleClientset(saA, saB, secretA, secretB) + factory := kubecli.NewTestClientFactory(endpointID, kubecli.NewTestKubeClient(fakeK8s)) + h := NewHandlerDeleteRegistrySecrets(nil, nil, factory) + pa := buildPendingAction(endpointID, registryID, []string{"ns-a", "ns-b"}) + + err := h.Execute(pa, endpoint) + require.NoError(t, err) + }) + + t.Run("SA not found is idempotent - Execute succeeds", func(t *testing.T) { + secretName := fmt.Sprintf("registry-%d", registryID) + secretA := &v1.Secret{ObjectMeta: metav1.ObjectMeta{Name: secretName, Namespace: "ns-a"}} + // No SA present; RemoveImagePullSecretFromServiceAccount treats not-found as idempotent + fakeK8s := kfake.NewSimpleClientset(secretA) + factory := kubecli.NewTestClientFactory(endpointID, kubecli.NewTestKubeClient(fakeK8s)) + h := NewHandlerDeleteRegistrySecrets(nil, nil, factory) + pa := buildPendingAction(endpointID, registryID, []string{"ns-a"}) + + err := h.Execute(pa, endpoint) + require.NoError(t, err, "missing SA should be treated as idempotent") + }) + + t.Run("empty namespaces list - Execute succeeds with no operations", func(t *testing.T) { + fakeK8s := kfake.NewSimpleClientset() + factory := kubecli.NewTestClientFactory(endpointID, kubecli.NewTestKubeClient(fakeK8s)) + h := NewHandlerDeleteRegistrySecrets(nil, nil, factory) + pa := buildPendingAction(endpointID, registryID, []string{}) + + err := h.Execute(pa, endpoint) + require.NoError(t, err) + }) +} diff --git a/api/pendingactions/handlers/post_init_migrate_environment.go b/api/pendingactions/handlers/post_init_migrate_environment.go new file mode 100644 index 0000000..cdbfe67 --- /dev/null +++ b/api/pendingactions/handlers/post_init_migrate_environment.go @@ -0,0 +1,58 @@ +package handlers + +import ( + "fmt" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/datastore/postinit" + dockerClient "github.com/portainer/portainer/api/docker/client" + "github.com/portainer/portainer/api/internal/authorization" + kubecli "github.com/portainer/portainer/api/kubernetes/cli" + "github.com/rs/zerolog/log" +) + +type HandlerPostInitMigrateEnvironment struct { + authorizationService *authorization.Service + dataStore dataservices.DataStore + kubeFactory *kubecli.ClientFactory + dockerFactory *dockerClient.ClientFactory + assetsPath string + kubernetesDeployer portainer.KubernetesDeployer +} + +// NewPostInitMigrateEnvironment creates a new PostInitMigrateEnvironment pending action +func NewHandlerPostInitMigrateEnvironment( + authorizationService *authorization.Service, + dataStore dataservices.DataStore, + kubeFactory *kubecli.ClientFactory, + dockerFactory *dockerClient.ClientFactory, + assetsPath string, + kubernetesDeployer portainer.KubernetesDeployer, +) *HandlerPostInitMigrateEnvironment { + return &HandlerPostInitMigrateEnvironment{ + authorizationService: authorizationService, + dataStore: dataStore, + kubeFactory: kubeFactory, + dockerFactory: dockerFactory, + assetsPath: assetsPath, + kubernetesDeployer: kubernetesDeployer, + } +} + +func (h *HandlerPostInitMigrateEnvironment) Execute(_ portainer.PendingAction, endpoint *portainer.Endpoint) error { + postInitMigrator := postinit.NewPostInitMigrator( + h.kubeFactory, + h.dockerFactory, + h.dataStore, + h.assetsPath, + h.kubernetesDeployer, + ) + err := postInitMigrator.MigrateEnvironment(endpoint) + if err != nil { + log.Error().Err(err).Msgf("Error running post-init migrations for edge environment %d", endpoint.ID) + return fmt.Errorf("failed running post-init migrations for edge environment %d: %w", endpoint.ID, err) + } + + return nil +} diff --git a/api/pendingactions/pendingactions.go b/api/pendingactions/pendingactions.go new file mode 100644 index 0000000..95c6276 --- /dev/null +++ b/api/pendingactions/pendingactions.go @@ -0,0 +1,154 @@ +package pendingactions + +import ( + "fmt" + "reflect" + "sync" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/internal/endpointutils" + kubecli "github.com/portainer/portainer/api/kubernetes/cli" + + "github.com/rs/zerolog/log" +) + +type PendingActionsService struct { + kubeFactory *kubecli.ClientFactory + dataStore dataservices.DataStore + mu sync.Mutex +} + +var handlers = make(map[string]portainer.PendingActionHandler) + +func NewService(dataStore dataservices.DataStore, kubeFactory *kubecli.ClientFactory) *PendingActionsService { + return &PendingActionsService{dataStore: dataStore, kubeFactory: kubeFactory} +} + +func (service *PendingActionsService) RegisterHandler(name string, handler portainer.PendingActionHandler) { + handlers[name] = handler +} + +func (service *PendingActionsService) Create(tx dataservices.DataStoreTx, action portainer.PendingAction) error { + // Check if this pendingAction already exists + pendingActions, err := tx.PendingActions().ReadAll(func(a portainer.PendingAction) bool { + return a.EndpointID == action.EndpointID && a.Action == action.Action && reflect.DeepEqual(a.ActionData, action.ActionData) + }) + if err != nil { + return fmt.Errorf("failed to retrieve pending actions: %w", err) + } + + if len(pendingActions) > 0 { + // Same endpoint, same action and data, don't create a repeat + log.Debug(). + Str("action", action.Action). + Int("endpoint_id", int(action.EndpointID)). + Msg("pending action already exists for environment, skipping...") + + return nil + } + + return tx.PendingActions().Create(&action) +} + +func (service *PendingActionsService) Execute(id portainer.EndpointID) { + // Run in a goroutine to avoid blocking the main thread due to db tx = + go service.execute(id) +} + +func (service *PendingActionsService) execute(environmentID portainer.EndpointID) { + service.mu.Lock() + defer service.mu.Unlock() + + endpoint, err := service.dataStore.Endpoint().Endpoint(environmentID) + if err != nil { + log.Debug().Err(err).Int("endpoint_id", int(environmentID)).Msg("failed to retrieve environment") + + return + } + + isKubernetesEndpoint := endpointutils.IsKubernetesEndpoint(endpoint) && !endpointutils.IsEdgeEndpoint(endpoint) + + if !isKubernetesEndpoint { + // Edge environments check the heartbeat + // Other environments check the endpoint status + if endpointutils.IsEdgeEndpoint(endpoint) { + if !endpoint.Heartbeat { + return + } + } else if endpoint.Status != portainer.EndpointStatusUp { + return + } + } else { + // For Kubernetes endpoints, we need to check if the endpoint is up by + // creating a kube client and performing a simple operation + client, err := service.kubeFactory.GetPrivilegedKubeClient(endpoint) + if err != nil { + log.Debug(). + Err(err). + Int("endpoint_id", int(environmentID)). + Msg("failed to create Kubernetes client for environment") + + return + } + + if _, err = client.ServerVersion(); err != nil { + log.Debug(). + Err(err). + Str("endpoint_name", endpoint.Name). + Int("endpoint_id", int(environmentID)). + Msg("environment is not up") + + return + } + } + + pendingActions, err := service.dataStore.PendingActions().ReadAll(func(a portainer.PendingAction) bool { + return a.EndpointID == environmentID + }) + if err != nil { + log.Warn().Err(err).Msg("failed to read pending actions") + return + } + + if len(pendingActions) > 0 { + log.Debug().Int("pending_action_count", len(pendingActions)).Msg("found pending actions") + } + + for _, pendingAction := range pendingActions { + log.Debug(). + Int("pending_action_id", int(pendingAction.ID)). + Str("action", pendingAction.Action). + Msg("executing pending action") + if err := service.executePendingAction(pendingAction, endpoint); err != nil { + log.Warn().Err(err).Msg("failed to execute pending action") + + continue + } + + if err := service.dataStore.PendingActions().Delete(pendingAction.ID); err != nil { + log.Warn().Err(err).Msg("failed to delete pending action") + + continue + } + + log.Debug().Int("pending_action_id", int(pendingAction.ID)).Msg("pending action finished") + } +} + +func (service *PendingActionsService) executePendingAction(pendingAction portainer.PendingAction, endpoint *portainer.Endpoint) error { + defer func() { + if r := recover(); r != nil { + log.Error().Msgf("recovered from panic while executing pending action %s for environment %d: %v", pendingAction.Action, pendingAction.EndpointID, r) + } + }() + + handler, ok := handlers[pendingAction.Action] + if !ok { + log.Warn().Str("action", pendingAction.Action).Msg("no handler found for pending action") + + return nil + } + + return handler.Execute(pendingAction, endpoint) +} diff --git a/api/pendingactions/pendingactions_test.go b/api/pendingactions/pendingactions_test.go new file mode 100644 index 0000000..e0685e3 --- /dev/null +++ b/api/pendingactions/pendingactions_test.go @@ -0,0 +1,93 @@ +package pendingactions + +import ( + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/internal/testhelpers" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestExecute(t *testing.T) { + t.Parallel() + tests := []struct { + name string + endpoint *portainer.Endpoint + pendingActions []portainer.PendingAction + shouldExecute bool + }{ + { + name: "Edge endpoint with heartbeat should execute", + // Create test endpoint + endpoint: &portainer.Endpoint{ + ID: 1, + Heartbeat: true, + Type: portainer.EdgeAgentOnDockerEnvironment, + EdgeID: "edge-1", + }, + pendingActions: []portainer.PendingAction{ + {ID: 1, EndpointID: 1, Action: "test-action"}, + }, + shouldExecute: true, + }, + { + name: "Edge endpoint without heartbeat should not execute", + endpoint: &portainer.Endpoint{ + ID: 2, + EdgeID: "edge-2", + Heartbeat: false, + Type: portainer.EdgeAgentOnDockerEnvironment, + }, + pendingActions: []portainer.PendingAction{ + {ID: 2, EndpointID: 2, Action: "test-action"}, + }, + shouldExecute: false, + }, + { + name: "Regular endpoint with status UP should execute", + endpoint: &portainer.Endpoint{ + ID: 3, + Status: portainer.EndpointStatusUp, + Type: portainer.AgentOnDockerEnvironment, + }, + pendingActions: []portainer.PendingAction{ + {ID: 3, EndpointID: 3, Action: "test-action"}, + }, + shouldExecute: true, + }, + { + name: "Regular endpoint with status DOWN should not execute", + endpoint: &portainer.Endpoint{ + ID: 4, + Status: portainer.EndpointStatusDown, + Type: portainer.AgentOnDockerEnvironment, + }, + pendingActions: []portainer.PendingAction{ + {ID: 4, EndpointID: 4, Action: "test-action"}, + }, + shouldExecute: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Setup services + store := testhelpers.NewDatastore(testhelpers.WithEndpoints([]portainer.Endpoint{*tt.endpoint}), testhelpers.WithPendingActions(tt.pendingActions)) + service := NewService(store, nil) + + // Execute + service.execute(tt.endpoint.ID) + + // Verify expectations + pendingActions, err := store.PendingActions().ReadAll() + require.NoError(t, err) + + if tt.shouldExecute { + assert.Len(t, pendingActions, len(tt.pendingActions)-1) + } else { + assert.Len(t, pendingActions, len(tt.pendingActions)) + } + }) + } +} diff --git a/api/platform/platform.go b/api/platform/platform.go new file mode 100644 index 0000000..3da4b90 --- /dev/null +++ b/api/platform/platform.go @@ -0,0 +1,56 @@ +package platform + +import ( + "os" +) + +const ( + PodmanMode = "PODMAN" + KubernetesServiceHost = "KUBERNETES_SERVICE_HOST" +) + +// ContainerPlatform represent the platform on which the container is running (Docker, Kubernetes) +type ContainerPlatform string + +const ( + // PlatformDocker represent the Docker platform (Unknown) + PlatformDocker = ContainerPlatform("Docker") + // PlatformDockerStandalone represent the Docker platform (Standalone) + PlatformDockerStandalone = ContainerPlatform("Docker Standalone") + // PlatformDockerSwarm represent the Docker platform (Swarm) + PlatformDockerSwarm = ContainerPlatform("Docker Swarm") + // PlatformKubernetes represent the Kubernetes platform + PlatformKubernetes = ContainerPlatform("Kubernetes") + // PlatformPodman represent the Podman platform (Standalone) + PlatformPodman = ContainerPlatform("Podman") +) + +// DetermineContainerPlatform will check for the existence of the PODMAN_MODE +// or KUBERNETES_SERVICE_HOST environment variable to determine if +// the container is running on Podman or inside the Kubernetes platform. +// Defaults to Docker otherwise. +func DetermineContainerPlatform() ContainerPlatform { + podmanModeEnvVar := os.Getenv(PodmanMode) + if podmanModeEnvVar == "1" { + return PlatformPodman + } + + serviceHostKubernetesEnvVar := os.Getenv(KubernetesServiceHost) + if serviceHostKubernetesEnvVar != "" { + return PlatformKubernetes + } + + if !isRunningInContainer() { + return "" + } + + return PlatformDocker +} + +// isRunningInContainer returns true if the process is running inside a container +// this code is taken from https://github.com/moby/libnetwork/blob/master/drivers/bridge/setup_bridgenetfiltering.go +func isRunningInContainer() bool { + _, err := os.Stat("/.dockerenv") + + return !os.IsNotExist(err) +} diff --git a/api/platform/platform_test.go b/api/platform/platform_test.go new file mode 100644 index 0000000..be18621 --- /dev/null +++ b/api/platform/platform_test.go @@ -0,0 +1,155 @@ +package platform + +import ( + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/internal/testhelpers" + + "github.com/stretchr/testify/require" +) + +func TestDetermineContainerPlatform_Podman(t *testing.T) { + t.Setenv(PodmanMode, "1") + + require.Equal(t, PlatformPodman, DetermineContainerPlatform()) +} + +func TestDetermineContainerPlatform_Kubernetes(t *testing.T) { + t.Setenv(KubernetesServiceHost, "10.96.0.1") + + require.Equal(t, PlatformKubernetes, DetermineContainerPlatform()) +} + +func TestDetermineContainerPlatform_PodmanTakesPrecedenceOverKubernetes(t *testing.T) { + t.Setenv(PodmanMode, "1") + t.Setenv(KubernetesServiceHost, "10.96.0.1") + + require.Equal(t, PlatformPodman, DetermineContainerPlatform()) +} + +func TestCheckDockerEnvTypeForUpgrade_UnixSocket(t *testing.T) { + t.Parallel() + + endpoint := &portainer.Endpoint{URL: "unix:///var/run/docker.sock"} + require.Equal(t, PlatformDockerStandalone, checkDockerEnvTypeForUpgrade(endpoint)) +} + +func TestCheckDockerEnvTypeForUpgrade_Npipe(t *testing.T) { + t.Parallel() + + endpoint := &portainer.Endpoint{URL: "npipe:////./pipe/docker_engine", Type: portainer.DockerEnvironment} + require.Equal(t, PlatformDockerStandalone, checkDockerEnvTypeForUpgrade(endpoint)) +} + +func TestCheckDockerEnvTypeForUpgrade_Swarm(t *testing.T) { + t.Parallel() + + endpoint := &portainer.Endpoint{URL: "tcp://tasks.portainer_agent:9001"} + require.Equal(t, PlatformDockerSwarm, checkDockerEnvTypeForUpgrade(endpoint)) +} + +func TestCheckDockerEnvTypeForUpgrade_RemoteTCP(t *testing.T) { + t.Parallel() + + endpoint := &portainer.Endpoint{URL: "tcp://192.168.1.100:2376"} + require.Equal(t, ContainerPlatform(""), checkDockerEnvTypeForUpgrade(endpoint)) +} + +func TestDetectLocalEnvironment_UnsupportedPlatform(t *testing.T) { + t.Setenv(PodmanMode, "1") + t.Setenv(KubernetesServiceHost, "") + + ds := testhelpers.NewDatastore(testhelpers.WithEndpoints([]portainer.Endpoint{ + {ID: 1, Type: portainer.DockerEnvironment}, + })) + + endpoint, platform, err := detectLocalEnvironment(ds) + require.NoError(t, err) + require.Nil(t, endpoint) + require.Empty(t, platform) +} + +func TestDetectLocalEnvironment_NoEndpoints(t *testing.T) { + t.Setenv(KubernetesServiceHost, "10.96.0.1") + t.Setenv(PodmanMode, "") + + ds := testhelpers.NewDatastore(testhelpers.WithEndpoints([]portainer.Endpoint{})) + + endpoint, platform, err := detectLocalEnvironment(ds) + require.NoError(t, err) + require.Nil(t, endpoint) + require.Empty(t, platform) +} + +func TestDetectLocalEnvironment_KubernetesEndpointFound(t *testing.T) { + t.Setenv(KubernetesServiceHost, "10.96.0.1") + t.Setenv(PodmanMode, "") + + kube := portainer.Endpoint{ID: 1, Name: "local-k8s", Type: portainer.KubernetesLocalEnvironment} + ds := testhelpers.NewDatastore(testhelpers.WithEndpoints([]portainer.Endpoint{kube})) + + endpoint, platform, err := detectLocalEnvironment(ds) + require.NoError(t, err) + require.NotNil(t, endpoint) + require.Equal(t, portainer.EndpointID(1), endpoint.ID) + require.Equal(t, PlatformKubernetes, platform) +} + +func TestDetectLocalEnvironment_NoMatchingEndpointType(t *testing.T) { + t.Setenv(KubernetesServiceHost, "10.96.0.1") + t.Setenv(PodmanMode, "") + + docker := portainer.Endpoint{ID: 1, Type: portainer.DockerEnvironment} + ds := testhelpers.NewDatastore(testhelpers.WithEndpoints([]portainer.Endpoint{docker})) + + _, _, err := detectLocalEnvironment(ds) + require.ErrorIs(t, err, ErrNoLocalEnvironment) +} + +func TestService_GetPlatform(t *testing.T) { + t.Setenv(KubernetesServiceHost, "10.96.0.1") + t.Setenv(PodmanMode, "") + + kube := portainer.Endpoint{ID: 1, Type: portainer.KubernetesLocalEnvironment} + ds := testhelpers.NewDatastore(testhelpers.WithEndpoints([]portainer.Endpoint{kube})) + + svc := NewService(ds) + + platform, err := svc.GetPlatform() + require.NoError(t, err) + require.Equal(t, PlatformKubernetes, platform) +} + +func TestService_GetLocalEnvironment(t *testing.T) { + t.Setenv(KubernetesServiceHost, "10.96.0.1") + t.Setenv(PodmanMode, "") + + kube := portainer.Endpoint{ID: 1, Type: portainer.KubernetesLocalEnvironment} + ds := testhelpers.NewDatastore(testhelpers.WithEndpoints([]portainer.Endpoint{kube})) + + svc := NewService(ds) + + env, err := svc.GetLocalEnvironment() + require.NoError(t, err) + require.NotNil(t, env) + require.Equal(t, portainer.EndpointID(1), env.ID) +} + +func TestService_CachesLoadedEnvironment(t *testing.T) { + t.Setenv(KubernetesServiceHost, "10.96.0.1") + t.Setenv(PodmanMode, "") + + kube := portainer.Endpoint{ID: 1, Type: portainer.KubernetesLocalEnvironment} + ds := testhelpers.NewDatastore(testhelpers.WithEndpoints([]portainer.Endpoint{kube})) + + svc := NewService(ds) + + env1, err := svc.GetLocalEnvironment() + require.NoError(t, err) + + env2, err := svc.GetLocalEnvironment() + require.NoError(t, err) + + require.Same(t, env1, env2) +} diff --git a/api/platform/service.go b/api/platform/service.go new file mode 100644 index 0000000..5bb1d53 --- /dev/null +++ b/api/platform/service.go @@ -0,0 +1,133 @@ +package platform + +import ( + "errors" + "fmt" + "slices" + "strings" + "sync" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/internal/endpointutils" + + "github.com/rs/zerolog/log" +) + +var ( + ErrNoLocalEnvironment = errors.New("No local environment was detected") +) + +type Service interface { + GetLocalEnvironment() (*portainer.Endpoint, error) + GetPlatform() (ContainerPlatform, error) +} + +type service struct { + dataStore dataservices.DataStore + environment *portainer.Endpoint + platform ContainerPlatform + mu sync.Mutex +} + +func NewService(dataStore dataservices.DataStore) *service { + return &service{dataStore: dataStore} +} + +func (service *service) loadEnvAndPlatform() error { + if service.environment != nil { + return nil + } + + environment, platform, err := detectLocalEnvironment(service.dataStore) + if err != nil { + return err + } + + service.environment = environment + service.platform = platform + + return nil +} + +func (service *service) GetLocalEnvironment() (*portainer.Endpoint, error) { + service.mu.Lock() + defer service.mu.Unlock() + + if err := service.loadEnvAndPlatform(); err != nil { + return nil, err + } + + return service.environment, nil +} + +func (service *service) GetPlatform() (ContainerPlatform, error) { + service.mu.Lock() + defer service.mu.Unlock() + + if err := service.loadEnvAndPlatform(); err != nil { + return "", err + } + + return service.platform, nil +} + +var platformToEndpointType = map[ContainerPlatform][]portainer.EndpointType{ + PlatformDocker: {portainer.AgentOnDockerEnvironment, portainer.DockerEnvironment}, + PlatformKubernetes: {portainer.KubernetesLocalEnvironment}, +} + +func detectLocalEnvironment(dataStore dataservices.DataStore) (*portainer.Endpoint, ContainerPlatform, error) { + platform := DetermineContainerPlatform() + + if !slices.Contains([]ContainerPlatform{PlatformDocker, PlatformKubernetes}, platform) { + log.Debug(). + Str("platform", string(platform)). + Msg("environment not supported for upgrade") + + return nil, "", nil + } + + endpoints, err := dataStore.Endpoint().Endpoints() + if err != nil { + return nil, "", fmt.Errorf("failed to retrieve environments: %w", err) + } + + // skip guessing when there is no endpoints registered in DB + if len(endpoints) == 0 { + return nil, "", nil + } + + endpointTypes, ok := platformToEndpointType[platform] + if !ok { + return nil, "", errors.New("failed to determine environment type") + } + + for _, endpoint := range endpoints { + if !slices.Contains(endpointTypes, endpoint.Type) { + continue + } + + if platform != PlatformDocker { + return &endpoint, platform, nil + } + + if dockerPlatform := checkDockerEnvTypeForUpgrade(&endpoint); dockerPlatform != "" { + return &endpoint, dockerPlatform, nil + } + } + + return nil, "", ErrNoLocalEnvironment +} + +func checkDockerEnvTypeForUpgrade(environment *portainer.Endpoint) ContainerPlatform { + if endpointutils.IsLocalEndpoint(environment) { // standalone + return PlatformDockerStandalone + } + + if strings.HasPrefix(environment.URL, "tcp://tasks.") { // swarm + return PlatformDockerSwarm + } + + return "" +} diff --git a/api/portainer.go b/api/portainer.go new file mode 100644 index 0000000..f944fbe --- /dev/null +++ b/api/portainer.go @@ -0,0 +1,2732 @@ +package portainer + +import ( + "context" + "fmt" + "io" + "net" + "net/http" + "time" + + gittypes "github.com/portainer/portainer/api/git/types" + models "github.com/portainer/portainer/api/http/models/kubernetes" + "github.com/portainer/portainer/api/roar" + "github.com/portainer/portainer/pkg/featureflags" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/image" + "github.com/docker/docker/api/types/network" + "github.com/docker/docker/api/types/system" + "github.com/docker/docker/api/types/volume" + "github.com/segmentio/encoding/json" + "golang.org/x/oauth2" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/version" + "k8s.io/client-go/tools/remotecommand" +) + +type ( + // AccessPolicy represent a policy that can be associated to a user or team + AccessPolicy struct { + // Role identifier. Reference the role that will be associated to this access policy + RoleID RoleID `json:"RoleId" example:"1" validate:"required"` + // Namespaces is a list of namespaces that this access policy applies to. Only used for namespaced level roles + Namespaces []string `json:"Namespaces,omitempty"` + } + + // AgentPlatform represents a platform type for an Agent + AgentPlatform int + + // AuthenticationMethod represents the authentication method used to authenticate a user + AuthenticationMethod int + + // Authorization represents an authorization associated to an operation + Authorization string + + // Authorizations represents a set of authorizations associated to a role + Authorizations map[Authorization]bool + + // AutoUpdateSettings represents the git auto sync config for stack deployment + AutoUpdateSettings struct { + // Auto update interval + Interval string `example:"1m30s"` + // A UUID generated from client + Webhook string `example:"05de31a2-79fa-4644-9c12-faa67e5c49f0"` + // Autoupdate job id + JobID string `example:"15"` + // Force update ignores repo changes + ForceUpdate bool `example:"false"` + // Pull latest image + ForcePullImage bool `example:"false"` + } + + // AzureCredentials represents the credentials used to connect to an Azure + // environment(endpoint). + AzureCredentials struct { + // Azure application ID + ApplicationID string `json:"ApplicationID" example:"eag7cdo9-o09l-9i83-9dO9-f0b23oe78db4" validate:"required"` + // Azure tenant ID + TenantID string `json:"TenantID" example:"34ddc78d-4fel-2358-8cc1-df84c8o839f5" validate:"required"` + // Azure authentication key + AuthenticationKey string `json:"AuthenticationKey" example:"cOrXoK/1D35w8YQ8nH1/8ZGwzz45JIYD5jxHKXEQknk=" validate:"required"` + } + + // CLIFlags represents the available flags on the CLI + CLIFlags struct { + Addr *string + AddrHTTPS *string + TunnelAddr *string + TunnelPort *string + AdminPassword *string + AdminPasswordFile *string + Assets *string + CSP *bool + CompactDB *bool + Data *string + FeatureFlags *[]string + EnableEdgeComputeFeatures *bool + EndpointURL *string + Labels *[]Pair + Logo *string + NoAnalytics *bool + Templates *string + TLS *bool + TLSSkipVerify *bool + HasTLSCacert *bool + TLSCacert *string + TLSCert *string + TLSKey *string + HTTPDisabled *bool + HTTPEnabled *bool + Rollback *bool + SnapshotInterval *string + BaseURL *string + InitialMmapSize *int + MaxBatchSize *int + MaxBatchDelay *time.Duration + SecretKeyName *string + LogLevel *string + LogMode *string + KubectlShellImage *string + KubectlShellImageSet bool + PullLimitCheckDisabled *bool + TrustedOrigins *string + NoSetupToken *bool + SetupToken *string + } + + // CustomTemplateVariableDefinition + CustomTemplateVariableDefinition struct { + Name string `json:"name" example:"MY_VAR"` + Label string `json:"label" example:"My Variable"` + DefaultValue string `json:"defaultValue" example:"default value"` + Description string `json:"description" example:"Description"` + } + + // CustomTemplate represents a custom template + CustomTemplate struct { + // CustomTemplate Identifier + ID CustomTemplateID `json:"Id" example:"1"` + // Title of the template + Title string `json:"Title" example:"Nginx"` + // Description of the template + Description string `json:"Description" example:"High performance web server"` + // Path on disk to the repository hosting the Stack file + ProjectPath string `json:"ProjectPath" example:"/data/custom_template/3"` + // Path to the Stack file + EntryPoint string `json:"EntryPoint" example:"docker-compose.yml"` + // User identifier who created this template + CreatedByUserID UserID `json:"CreatedByUserId" example:"3"` + // A note that will be displayed in the UI. Supports HTML content + Note string `json:"Note" example:"This is my custom template"` + // Platform associated to the template. + // Valid values are: 1 - 'linux', 2 - 'windows' + Platform CustomTemplatePlatform `json:"Platform" example:"1" enums:"1,2"` + // URL of the template's logo + Logo string `json:"Logo" example:"https://portainer.io/img/logo.svg"` + // Type of created stack: + // * 1 - swarm + // * 2 - compose + // * 3 - kubernetes + Type StackType `json:"Type" example:"1" enums:"1,2,3"` + ResourceControl *ResourceControl `json:"ResourceControl"` + Variables []CustomTemplateVariableDefinition + GitConfig *gittypes.RepoConfig `json:"GitConfig"` + Artifact *Artifact `json:"artifact,omitempty"` + // IsComposeFormat indicates if the Kubernetes template is created from a Docker Compose file + IsComposeFormat bool `example:"false"` + // EdgeTemplate indicates if this template purpose for Edge Stack + EdgeTemplate bool `example:"false"` + } + + // CustomTemplateID represents a custom template identifier + CustomTemplateID int + + // CustomTemplatePlatform represents a custom template platform + CustomTemplatePlatform int + + // DiagnosticsData represents the diagnostics data for an environment + // this contains the logs, telnet, traceroute, dns and proxy information + // which will be part of the DockerSnapshot and KubernetesSnapshot structs + DiagnosticsData struct { + Log string `json:"Log,omitempty"` + Telnet map[string]string `json:"Telnet,omitempty"` + DNS map[string]string `json:"DNS,omitempty"` + Proxy map[string]string `json:"Proxy,omitempty"` + } + + // DockerHub represents all the required information to connect and use the + // Docker Hub + DockerHub struct { + // Is authentication against DockerHub enabled + Authentication bool `json:"Authentication" example:"true"` + // Username used to authenticate against the DockerHub + Username string `json:"Username" example:"user"` + // Password used to authenticate against the DockerHub + Password string `json:"Password,omitempty" example:"passwd"` + } + + // DockerSnapshot represents a snapshot of a specific Docker environment(endpoint) at a specific time + DockerSnapshot struct { + Time int64 `json:"Time" validate:"required"` + DockerVersion string `json:"DockerVersion" validate:"required"` + Swarm bool `json:"Swarm" validate:"required"` + TotalCPU int `json:"TotalCPU" validate:"required"` + TotalMemory int64 `json:"TotalMemory" validate:"required"` + ContainerCount int `json:"ContainerCount" validate:"required"` + RunningContainerCount int `json:"RunningContainerCount" validate:"required"` + StoppedContainerCount int `json:"StoppedContainerCount" validate:"required"` + HealthyContainerCount int `json:"HealthyContainerCount" validate:"required"` + UnhealthyContainerCount int `json:"UnhealthyContainerCount" validate:"required"` + VolumeCount int `json:"VolumeCount" validate:"required"` + ImageCount int `json:"ImageCount" validate:"required"` + ServiceCount int `json:"ServiceCount" validate:"required"` + StackCount int `json:"StackCount" validate:"required"` + SnapshotRaw DockerSnapshotRaw `json:"DockerSnapshotRaw"` + NodeCount int `json:"NodeCount" validate:"required"` + GpuUseAll bool `json:"GpuUseAll" validate:"required"` + GpuUseList []string `json:"GpuUseList,omitempty"` + IsPodman bool `json:"IsPodman" validate:"required"` + DiagnosticsData *DiagnosticsData `json:"DiagnosticsData,omitempty"` + PerformanceMetrics *PerformanceMetrics `json:"PerformanceMetrics,omitempty"` + } + + // PerformanceMetrics represents the performance metrics of a Docker, Swarm, Podman, and Kubernetes environments + PerformanceMetrics struct { + CPUUsage float64 `json:"CPUUsage,omitempty"` + MemoryUsage float64 `json:"MemoryUsage,omitempty"` + DiskUsage float64 `json:"DiskUsage,omitempty"` + NetworkUsage float64 `json:"NetworkUsage,omitempty"` + } + + // DockerContainerSnapshot is an extent of Docker's Container struct + // It contains some information of Docker's ContainerJSON struct + DockerContainerSnapshot struct { + types.Container + Env []string `json:"Env,omitempty"` // EE-5240 + } + + // DockerSnapshotRaw represents all the information related to a snapshot as returned by the Docker API + DockerSnapshotRaw struct { + Containers []DockerContainerSnapshot `json:"Containers,omitempty" swaggerignore:"true"` + Volumes volume.ListResponse `json:"Volumes" swaggerignore:"true"` + Networks []network.Summary `json:"Networks,omitempty" swaggerignore:"true"` + Images []image.Summary `json:"Images,omitempty" swaggerignore:"true"` + Info system.Info `json:"Info" swaggerignore:"true"` + Version types.Version `json:"Version" swaggerignore:"true"` + } + + // EdgeGroup represents an Edge group + EdgeGroup struct { + // EdgeGroup Identifier + ID EdgeGroupID `json:"Id" example:"1"` + Name string `json:"Name"` + Dynamic bool `json:"Dynamic"` + TagIDs []TagID `json:"TagIds"` + EndpointIDs roar.Roar[EndpointID] `json:"EndpointIds" validate:"optional"` + PartialMatch bool `json:"PartialMatch"` + + // Deprecated: only used for API responses + Endpoints []EndpointID `json:"Endpoints"` + } + + // EdgeGroupID represents an Edge group identifier + EdgeGroupID int + + // EdgeJob represents a job that can run on Edge environments(endpoints). + EdgeJob struct { + // EdgeJob Identifier + ID EdgeJobID `json:"Id" example:"1"` + Created int64 `json:"Created"` + CronExpression string `json:"CronExpression"` + Endpoints map[EndpointID]EdgeJobEndpointMeta `json:"Endpoints"` + EdgeGroups []EdgeGroupID `json:"EdgeGroups"` + Name string `json:"Name"` + ScriptPath string `json:"ScriptPath"` + Recurring bool `json:"Recurring"` + Version int `json:"Version"` + + // Field used for log collection of Endpoints belonging to EdgeGroups + GroupLogsCollection map[EndpointID]EdgeJobEndpointMeta + } + + // EdgeJobEndpointMeta represents a meta data object for an Edge job and Environment(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(endpoints). + // + // Deprecated: in favor of EdgeJob + EdgeSchedule struct { + // EdgeSchedule Identifier + ID ScheduleID `json:"Id" example:"1"` + CronExpression string `json:"CronExpression"` + Script string `json:"Script"` + Version int `json:"Version"` + Endpoints []EndpointID `json:"Endpoints"` + } + + // StackDeploymentStatus records one status transition in the current deployment lifecycle. + // The slice is reset at the start of each new deployment, so it represents the + // progression of the most recent deployment only (e.g. Deploying -> Active). + StackDeploymentStatus struct { + Status StackStatus `json:"Status"` + Time int64 `json:"Time"` + Message string `json:"Message,omitempty"` // populated on Error entries + } + + // StackDeploymentInfo records the information of a deployed stack + StackDeploymentInfo struct { + // Version is the version of the stack and also is the deployed version in edge agent + Version int `json:"Version"` + // FileVersion is the version of the stack file, used to detect changes + FileVersion int `json:"FileVersion"` + // ConfigHash is the commit hash of the git repository used for deploying the stack + ConfigHash string `json:"ConfigHash,omitempty"` + // RepositoryURL is the git repository URL used for deploying the stack + RepositoryURL string `json:"RepositoryURL,omitempty"` + // ConfigFilePath is the path to the config file in the git repository used for deploying the stack + ConfigFilePath string `json:"ConfigFilePath,omitempty"` + // ReferenceName is the git reference (branch/tag) used for deploying the stack + ReferenceName string `json:"ReferenceName,omitempty"` + // AdditionalFiles are the additional files used for deploying the stack + AdditionalFiles []string `json:"AdditionalFiles,omitempty"` + // SourceID is the Source used for deploying the stack + SourceID SourceID `json:"SourceID,omitempty"` + } + + // EdgeStack represents an edge stack + EdgeStack struct { + // EdgeStack Identifier + ID EdgeStackID `json:"Id" example:"1"` + Name string `json:"Name"` + Status map[EndpointID]EdgeStackStatus `json:"Status"` + // StatusArray map[EndpointID][]EdgeStackStatus `json:"StatusArray"` + CreationDate int64 `json:"CreationDate"` + EdgeGroups []EdgeGroupID `json:"EdgeGroups"` + ProjectPath string `json:"ProjectPath"` + EntryPoint string `json:"EntryPoint"` + Version int `json:"Version"` + NumDeployments int `json:"NumDeployments"` + ManifestPath string `json:"ManifestPath"` + DeploymentType EdgeStackDeploymentType `json:"DeploymentType"` + // Uses the manifest's namespaces instead of the default one + UseManifestNamespaces bool + // The username id which created this stack + CreatedByUserId string `example:"1"` + // The username which created this stack + CreatedBy string `example:"admin"` + } + + // HelmConfig represents the Helm configuration for an edge stack. + // Exactly one of ChartPath (git repo deployment) or ChartURL (Helm repo deployment) must be set. + HelmConfig struct { + // ChartPath is the path to a Helm chart folder within the cloned git repository. + // Used exclusively for git repo helm deployments. Mutually exclusive with ChartURL. + ChartPath string `json:"ChartPath,omitempty" example:"charts/my-app"` + // ValuesFiles is a list of relative paths to Helm values YAML files within the cloned git repository. + // Used exclusively for git repo helm deployments. + ValuesFiles []string `json:"ValuesFiles,omitempty" example:"['values/prod.yaml', 'values/secrets.yaml']"` + // ChartURL is the URL of a Helm chart repository. + // Used exclusively for Helm repository deployments. Mutually exclusive with ChartPath. + ChartURL string `json:"ChartURL,omitempty" example:"https://charts.bitnami.com/bitnami"` + // ChartName is the name of the Helm chart within the repository. + // Required for Helm repository deployments. + ChartName string `json:"ChartName,omitempty" example:"nginx"` + // ChartVersion is the version of the Helm chart to deploy. Empty means latest. + // Used exclusively for Helm repository deployments. + ChartVersion string `json:"ChartVersion,omitempty" example:"15.0.0"` + // ValuesInline is the inline YAML string of Helm values. + // Used exclusively for Helm repository deployments. + ValuesInline string `json:"ValuesInline,omitempty" example:"replicaCount: 2"` + // Atomic enables automatic rollback on deployment failure (equivalent to helm --atomic). + // Used by both git repo and Helm repository deployments. + Atomic bool `json:"Atomic" example:"true"` + // Timeout sets the deadline for Helm operations (equivalent to helm --timeout, e.g. "5m0s"). + // Used by both git repo and Helm repository deployments. + Timeout string `json:"Timeout,omitempty" example:"5m0s"` + // Namespace is the Kubernetes namespace to deploy the Helm chart into. + // Used by both git repo and Helm repository deployments. + Namespace string `json:"Namespace,omitempty" example:"default"` + } + + EdgeStackStatusForEnv struct { + EndpointID EndpointID + Status []EdgeStackDeploymentStatus + // EE only feature + DeploymentInfo StackDeploymentInfo + // RePullImage is a flag to indicate whether the auto update is trigger to re-pull image + RePullImage bool `json:"RePullImage,omitempty"` + // ForceRedeploy is a flag to indicate whether the force redeployment is set for the current + // deployment of the edge stack. The redeployment could be triggered by GitOps Update or manually by user. + ForceRedeploy bool `json:"ForceRedeploy,omitempty"` + + // Deprecated(2.36): use ForceRedeploy and RePullImage instead for cleaner + // responsibility, but keep it for backward compatibility. To remove in future versions (2.44+) + // ReadyRePullImage is a flag to indicate whether the auto update is trigger to re-pull image + ReadyRePullImage bool `json:"ReadyRePullImage,omitempty"` + } + + EdgeStackDeploymentType int + + // EdgeStackID represents an edge stack id + EdgeStackID int + + EdgeStackStatusDetails struct { + Pending bool + Ok bool + Error bool + Acknowledged bool + Remove bool + RemoteUpdateSuccess bool + ImagesPulled bool + } + + // EdgeStackStatus represents an edge stack status + EdgeStackStatus struct { + Status []EdgeStackDeploymentStatus + EndpointID EndpointID + // EE only feature + DeploymentInfo StackDeploymentInfo + // ReadyRePullImage is a flag to indicate whether the auto update is trigger to re-pull image + ReadyRePullImage bool `json:"ReadyRePullImage,omitempty"` + + // Deprecated + Details *EdgeStackStatusDetails `json:"Details,omitempty"` + // Deprecated + Error string `json:"Error,omitempty"` + // Deprecated + Type EdgeStackStatusType `json:"Type,omitempty"` + } + + // EdgeStackDeploymentStatus represents an edge stack deployment status + EdgeStackDeploymentStatus struct { + Time int64 + Type EdgeStackStatusType + Error string `json:"Error,omitempty"` + // EE only feature + RollbackTo *int `json:"RollbackTo,omitempty"` + Version int `json:"Version,omitempty"` + } + + // EdgeStackStatusType represents an edge stack status type + EdgeStackStatusType int + + // Environment(Endpoint) represents a Docker environment(endpoint) with all the info required + // to connect to it + Endpoint struct { + // Environment(Endpoint) Identifier + ID EndpointID `json:"Id" example:"1" validate:"required"` + // Environment(Endpoint) name + Name string `json:"Name" example:"my-environment" validate:"required"` + // Environment(Endpoint) environment(endpoint) type. 1 for a Docker environment(endpoint), 2 for an agent on Docker environment(endpoint) or 3 for an Azure environment(endpoint). + Type EndpointType `json:"Type" example:"1" validate:"required"` + // ContainerEngine represents the container engine type. This can be 'docker' or 'podman' when interacting directly with these environments, otherwise '' for kubernetes environments. + ContainerEngine string `json:"ContainerEngine" example:"docker" validate:"required"` + // URL or IP address of the Docker host associated to this environment(endpoint) + URL string `json:"URL" example:"docker.mydomain.tld:2375" validate:"required"` + // Environment(Endpoint) group identifier + GroupID EndpointGroupID `json:"GroupId" example:"1" validate:"required"` + // URL or IP address where exposed containers will be reachable + PublicURL string `json:"PublicURL" example:"docker.mydomain.tld:2375" validate:"required"` + Gpus []Pair `json:"Gpus,omitempty"` + TLSConfig TLSConfiguration `json:"TLSConfig" validate:"required"` + AzureCredentials AzureCredentials `json:"AzureCredentials,omitzero"` + // List of tag identifiers to which this environment(endpoint) is associated + TagIDs []TagID `json:"TagIds,omitempty"` + // The status of the environment(endpoint) (1 - up, 2 - down, 3 - provisioning, 4 - error) + Status EndpointStatus `json:"Status,omitempty" example:"1" enums:"1,2,3,4"` + // List of snapshots + Snapshots []DockerSnapshot `json:"Snapshots,omitempty"` + // List of user identifiers authorized to connect to this environment(endpoint) + UserAccessPolicies UserAccessPolicies `json:"UserAccessPolicies,omitempty"` + // List of team identifiers authorized to connect to this environment(endpoint) + TeamAccessPolicies TeamAccessPolicies `json:"TeamAccessPolicies,omitempty"` + // The identifier of the edge agent associated with this environment(endpoint) + EdgeID string `json:"EdgeID,omitempty"` + // The key which is used to map the agent to Portainer + EdgeKey string `json:"EdgeKey" validate:"required"` + // The check in interval for edge agent (in seconds) + EdgeCheckinInterval int `json:"EdgeCheckinInterval" example:"5" validate:"required"` + // Associated Kubernetes data + Kubernetes KubernetesData `json:"Kubernetes" validate:"required"` + // Maximum version of docker-compose + ComposeSyntaxMaxVersion string `json:"ComposeSyntaxMaxVersion" example:"3.8" validate:"required"` + // Environment(Endpoint) specific security settings + SecuritySettings EndpointSecuritySettings `validate:"required"` + // LastCheckInDate mark last check-in date on checkin + LastCheckInDate int64 `validate:"required"` + // Heartbeat indicates the heartbeat status of an edge environment + Heartbeat bool `json:"Heartbeat" example:"true"` + + // Whether the device has been trusted or not by the user + UserTrusted bool `json:"UserTrusted,omitempty"` + + // Whether we need to run any "post init migrations". + PostInitMigrations EndpointPostInitMigrations `json:"PostInitMigrations" swaggerignore:"true"` + + Edge EnvironmentEdgeSettings `validate:"required"` + + Agent EnvironmentAgentData `validate:"required"` + + EnableGPUManagement bool `json:"EnableGPUManagement,omitempty"` + + // Deprecated fields + // Deprecated in DBVersion == 4 + TLS bool `json:"TLS,omitempty" swaggerignore:"true"` + TLSCACertPath string `json:"TLSCACert,omitempty" swaggerignore:"true"` + TLSCertPath string `json:"TLSCert,omitempty" swaggerignore:"true"` + TLSKeyPath string `json:"TLSKey,omitempty" swaggerignore:"true"` + + // Deprecated in DBVersion == 18 + AuthorizedUsers []UserID `json:"AuthorizedUsers,omitempty" swaggerignore:"true"` + AuthorizedTeams []TeamID `json:"AuthorizedTeams,omitempty" swaggerignore:"true"` + + // Deprecated in DBVersion == 22 + Tags []string `json:"Tags,omitempty" swaggerignore:"true"` + + // Deprecated v2.18 + IsEdgeDevice bool `json:"IsEdgeDevice,omitempty" swaggerignore:"true"` + } + + // EnvironmentAgentData represents the data associated to an agent deployed + EnvironmentAgentData struct { + Version string `json:"Version,omitempty" example:"1.0.0"` + } + + EnvironmentEdgeSettings struct { + // Whether the device has been started in edge async mode + AsyncMode bool `validate:"required"` + // The ping interval for edge agent - used in edge async mode [seconds] + PingInterval int `json:"PingInterval" example:"60" validate:"required"` + // The snapshot interval for edge agent - used in edge async mode [seconds] + SnapshotInterval int `json:"SnapshotInterval" example:"60" validate:"required"` + // The command list interval for edge agent - used in edge async mode [seconds] + CommandInterval int `json:"CommandInterval" example:"60" validate:"required"` + } + + // EndpointAuthorizations represents the authorizations associated to a set of environments(endpoints) + EndpointAuthorizations map[EndpointID]Authorizations + + // EndpointGroup represents a group of environments(endpoints). + // + // An environment(endpoint) may belong to only 1 environment(endpoint) group. + EndpointGroup struct { + // Environment(Endpoint) group Identifier + ID EndpointGroupID `json:"Id" example:"1" validate:"required"` + // Environment(Endpoint) group name + Name string `json:"Name" example:"my-environment-group" validate:"required"` + // Description associated to the environment(endpoint) group + Description string `json:"Description" example:"Environment(Endpoint) group description" validate:"required"` + UserAccessPolicies UserAccessPolicies `json:"UserAccessPolicies,omitempty"` + TeamAccessPolicies TeamAccessPolicies `json:"TeamAccessPolicies,omitempty"` + // List of tags associated to this environment(endpoint) group + TagIDs []TagID `json:"TagIds,omitempty"` + + // Deprecated fields + Labels []Pair `json:"Labels,omitempty" swaggerignore:"true"` + + // Deprecated in DBVersion == 18 + AuthorizedUsers []UserID `json:"AuthorizedUsers,omitempty" swaggerignore:"true"` + AuthorizedTeams []TeamID `json:"AuthorizedTeams,omitempty" swaggerignore:"true"` + + // Deprecated in DBVersion == 22 + Tags []string `json:"Tags,omitempty" swaggerignore:"true"` + } + + PolicyChartSummary struct { + ChartName string `json:"ChartName"` + Fingerprint string `json:"Fingerprint"` + PolicyID PolicyID `json:"PolicyID,omitempty"` // 0 when server hasn't populated the field + } + + PolicyChartStatus struct { + // EnvironmentID is the endpoint this status belongs to. + // Stored so that ReadAll can group statuses by endpoint without parsing keys. + EnvironmentID EndpointID `json:"environmentID,omitempty"` + ChartName string `json:"chartName"` + Fingerprint string `json:"fingerprint"` + Status HelmInstallStatus `json:"status"` + Message string `json:"message"` + Namespace string `json:"namespace"` + // Unix timestamp + LastAttemptTime int64 `json:"lastAttemptTime"` + } + + ImageBundle struct { + FileName string `json:"FileName"` + EncodedTarGz string `json:"EncodedTarGz"` + } + + PolicyChartBundle struct { + PolicyChartSummary `mapstructure:",squash"` + EncodedTgz string `json:"EncodedTgz"` + Namespace string `json:"Namespace"` + ReleaseName string `json:"ReleaseName,omitempty"` + // Base64 YAML kubectl-applied by the agent before Helm install when set (e.g. Gatekeeper gatekeeper-system namespace + PSA labels). + PreReleaseManifest string `json:"PreReleaseManifest,omitempty"` + EncodedValues string `json:"EncodedValues"` + PreInstallDeletions []ResourceDeletion `json:"PreInstallDeletions,omitempty"` + PreInstallAdoptions []ResourceAdoption `json:"PreInstallAdoptions,omitempty"` + // WaitForCRDs lists CRD names that must be registered in API discovery after + // this chart installs before the agent proceeds to the next chart. + WaitForCRDs []string `json:"WaitForCRDs,omitempty"` + // NoWait disables waiting for pods to be ready after install. + NoWait bool `json:"NoWait,omitempty"` + } + + // ResourceDeletion identifies an existing Kubernetes resource to delete before policy install + ResourceDeletion struct { + APIVersion string `json:"apiVersion" example:"v1" yaml:"apiVersion"` + Kind string `json:"kind" example:"Secret" yaml:"kind"` + Name string `json:"name" example:"registry-1" yaml:"name"` + Namespace string `json:"namespace,omitempty" example:"default" yaml:"namespace,omitempty"` + } + + // ResourceAdoption identifies an existing Kubernetes resource to adopt into a Helm release + ResourceAdoption struct { + APIVersion string `json:"apiVersion" example:"v1" yaml:"apiVersion"` + Kind string `json:"kind" example:"Secret" yaml:"kind"` + Name string `json:"name" example:"registry-1" yaml:"name"` + Namespace string `json:"namespace,omitempty" example:"default" yaml:"namespace,omitempty"` + } + + // RestoreSettings contains instructions for restoring environment-level settings + RestoreSettings struct { + Manifest string `json:"manifest,omitempty"` // Base64-encoded Kubernetes YAML manifest + } + + // RestoreSettingsBundle maps restore type to restoration instructions + RestoreSettingsBundle map[PolicyType]RestoreSettings + + PolicyID int + + // PolicyDesiredState is the per-policy desired state sent from server to agent + // in PollStatusResponse.PolicyStates (per-policy payload format). + PolicyDesiredState struct { + PolicyID PolicyID `json:"policyID"` + Type string `json:"type"` // e.g. "helm-k8s" + Fingerprint string `json:"fingerprint"` // install-affecting only; restore manifest excluded + Config []byte `json:"config"` // handler-specific config blob (e.g. HelmPolicyConfig JSON) + } + + // PolicyStatesAsyncPayload is the value of an async "policyStates" command. + // States carries per-policy desired state; ChartBundles carries helm chart tarballs + // for policies that need installing (emitted only on mutation, never idle polls). + PolicyStatesAsyncPayload struct { + States []PolicyDesiredState `json:"states"` + ChartBundles []PolicyChartBundle `json:"chartBundles,omitempty"` + RestoreBundle RestoreSettingsBundle `json:"restoreBundle,omitempty"` + } + + // PolicyActualState is the per-policy actual state reported by the agent + // via PUT /endpoints/{id}/edge/policies/statuses. + PolicyActualState struct { + PolicyID PolicyID `json:"policyID"` + Type string `json:"type"` + Fingerprint string `json:"fingerprint"` + Status string `json:"status"` // applying|applied|failed|removing + Message string `json:"message,omitempty"` + } + + // HelmPolicyConfig is the Config payload for "helm-k8s" PolicyDesiredState entries. + // Bundles are not included in the poll response Config — they travel separately + // (sync: on-demand GetCharts; async: PolicyStatesCommandPayload.ChartBundles). + // RestoreSettings is helm-internal metadata and is intentionally NOT part of the + // fingerprint — see Fingerprint contract in reconcile-refactor-plan.md. + HelmPolicyConfig struct { + Charts []PolicyChartSummary `json:"charts"` + Bundles []PolicyChartBundle `json:"bundles,omitempty"` + RestoreSettings *RestoreSettings `json:"restoreSettings,omitempty"` + } + + // PolicyType represents the type of policy + PolicyType string +) + +type ( + // EndpointGroupID represents an environment(endpoint) group identifier + EndpointGroupID int + + // EndpointID represents an environment(endpoint) identifier + EndpointID int + + // EndpointStatus represents the status of an environment(endpoint) + EndpointStatus int + + // EndpointSyncJob represents a scheduled job that synchronize environments(endpoints) based on an external file + // Deprecated + EndpointSyncJob struct{} + + // EndpointSecuritySettings represents settings for an environment(endpoint) + EndpointSecuritySettings struct { + // Whether non-administrator should be able to use bind mounts when creating containers + AllowBindMountsForRegularUsers bool `json:"allowBindMountsForRegularUsers" example:"false" validate:"required"` + // Whether non-administrator should be able to use privileged mode when creating containers + AllowPrivilegedModeForRegularUsers bool `json:"allowPrivilegedModeForRegularUsers" example:"false" validate:"required"` + // Whether non-administrator should be able to browse volumes + AllowVolumeBrowserForRegularUsers bool `json:"allowVolumeBrowserForRegularUsers" example:"true" validate:"required"` + // Whether non-administrator should be able to use the host pid + AllowHostNamespaceForRegularUsers bool `json:"allowHostNamespaceForRegularUsers" example:"true" validate:"required"` + // Whether non-administrator should be able to use device mapping + AllowDeviceMappingForRegularUsers bool `json:"allowDeviceMappingForRegularUsers" example:"true" validate:"required"` + // Whether non-administrator should be able to manage stacks + AllowStackManagementForRegularUsers bool `json:"allowStackManagementForRegularUsers" example:"true" validate:"required"` + // Whether non-administrator should be able to use container capabilities + AllowContainerCapabilitiesForRegularUsers bool `json:"allowContainerCapabilitiesForRegularUsers" example:"true" validate:"required"` + // Whether non-administrator should be able to use sysctl settings + AllowSysctlSettingForRegularUsers bool `json:"allowSysctlSettingForRegularUsers" example:"true" validate:"required"` + // Whether non-administrator should be able to use security-opt settings + AllowSecurityOptForRegularUsers bool `json:"allowSecurityOptForRegularUsers" example:"true" validate:"required"` + // Whether host management features are enabled + EnableHostManagementFeatures bool `json:"enableHostManagementFeatures" example:"true" validate:"required"` + } + + // EndpointType represents the type of an environment(endpoint) + EndpointType int + + // PlatformType represents the platform that an agent is running on + PlatformType int + + // EndpointRelation represents a environment(endpoint) relation object + EndpointRelation struct { + EndpointID EndpointID + EdgeStacks map[EdgeStackID]bool + } + + // EndpointPostInitMigrations + EndpointPostInitMigrations struct { + MigrateIngresses bool `json:"MigrateIngresses"` + MigrateGPUs bool `json:"MigrateGPUs"` + MigrateRegistrySASecrets bool `json:"MigrateRegistrySASecrets"` + } + + // Extension represents a deprecated Portainer extension + Extension struct { + ID ExtensionID `json:"Id" example:"1"` + Enabled bool `json:"Enabled"` + Name string `json:"Name,omitempty"` + ShortDescription string `json:"ShortDescription,omitempty"` + Description string `json:"Description,omitempty"` + DescriptionURL string `json:"DescriptionURL,omitempty"` + Price string `json:"Price,omitempty"` + PriceDescription string `json:"PriceDescription,omitempty"` + Deal bool `json:"Deal,omitempty"` + Available bool `json:"Available,omitempty"` + License ExtensionLicenseInformation `json:"License,omitzero"` + Version string `json:"Version"` + UpdateAvailable bool `json:"UpdateAvailable"` + ShopURL string `json:"ShopURL,omitempty"` + Images []string `json:"Images,omitempty"` + Logo string `json:"Logo,omitempty"` + } + + // ExtensionID represents a extension identifier + ExtensionID int + + // GitlabRegistryData represents data required for gitlab registry to work + GitlabRegistryData struct { + ProjectID int `json:"ProjectId"` + InstanceURL string `json:"InstanceURL"` + ProjectPath string `json:"ProjectPath"` + } + + // GithubRegistryData represents data required for Github registry to work + GithubRegistryData struct { + UseOrganisation bool `json:"UseOrganisation"` + OrganisationName string `json:"OrganisationName"` + } + + HelmUserRepositoryID int + + // HelmUserRepositories stores a Helm repository URL for the given user + HelmUserRepository struct { + // Membership Identifier + ID HelmUserRepositoryID `json:"Id" example:"1"` + // User identifier + UserID UserID `json:"UserId" example:"1"` + // Helm repository URL + URL string `json:"URL" example:"https://charts.bitnami.com/bitnami"` + } + + // QuayRegistryData represents data required for Quay registry to work + QuayRegistryData struct { + UseOrganisation bool `json:"UseOrganisation,omitempty"` + OrganisationName string `json:"OrganisationName"` + } + + // EcrData represents data required for ECR registry + EcrData struct { + Region string `json:"Region" example:"ap-southeast-2"` + } + + // JobType represents a job type + JobType int + + K8sNamespaceInfo struct { + Id string `json:"Id"` + Name string `json:"Name"` + Status corev1.NamespaceStatus `json:"Status"` + Annotations map[string]string `json:"Annotations"` + CreationDate string `json:"CreationDate"` + UnhealthyEventCount int `json:"UnhealthyEventCount"` + NamespaceOwner string `json:"NamespaceOwner"` + IsSystem bool `json:"IsSystem"` + IsDefault bool `json:"IsDefault"` + ResourceQuota *corev1.ResourceQuota `json:"ResourceQuota"` + } + + K8sNodeLimits struct { + CPU int64 `json:"CPU"` + Memory int64 `json:"Memory"` + } + + K8sNodesLimits map[string]*K8sNodeLimits + + K8sNamespaceAccessPolicy struct { + UserAccessPolicies UserAccessPolicies `json:"UserAccessPolicies"` + TeamAccessPolicies TeamAccessPolicies `json:"TeamAccessPolicies"` + } + + // KubernetesData contains all the Kubernetes related environment(endpoint) information + KubernetesData struct { + Snapshots []KubernetesSnapshot `json:"Snapshots,omitempty"` + Configuration KubernetesConfiguration `json:"Configuration" validate:"required"` + Flags KubernetesFlags `json:"Flags" validate:"required"` + } + + // KubernetesFlags are used to detect if we need to run initial cluster + // detection again. + KubernetesFlags struct { + IsServerMetricsDetected bool `json:"IsServerMetricsDetected" validate:"required"` + IsServerIngressClassDetected bool `json:"IsServerIngressClassDetected" validate:"required"` + IsServerStorageDetected bool `json:"IsServerStorageDetected" validate:"required"` + GPUOperator bool `json:"GPUOperator,omitempty"` + } + + // KubernetesSnapshot represents a snapshot of a specific Kubernetes environment(endpoint) at a specific time + KubernetesSnapshot struct { + Time int64 `json:"Time" validate:"required"` + KubernetesVersion string `json:"KubernetesVersion" validate:"required"` + NodeCount int `json:"NodeCount" validate:"required"` + TotalCPU int64 `json:"TotalCPU" validate:"required"` + TotalMemory int64 `json:"TotalMemory" validate:"required"` + ClusterType string `json:"ClusterType,omitempty"` + GPUNodeCount int `json:"GPUNodeCount,omitempty"` + TotalGPU map[string]int64 `json:"TotalGPU,omitempty"` + DiagnosticsData *DiagnosticsData `json:"DiagnosticsData,omitempty"` + PerformanceMetrics *PerformanceMetrics `json:"PerformanceMetrics,omitempty"` + } + + // KubernetesConfiguration represents the configuration of a Kubernetes environment(endpoint) + KubernetesConfiguration struct { + UseLoadBalancer bool `json:"UseLoadBalancer"` + UseServerMetrics bool `json:"UseServerMetrics"` + EnableResourceOverCommit bool `json:"EnableResourceOverCommit"` + ResourceOverCommitPercentage int `json:"ResourceOverCommitPercentage"` + StorageClasses []KubernetesStorageClassConfig `json:"StorageClasses,omitempty"` + IngressClasses []KubernetesIngressClassConfig `json:"IngressClasses,omitempty"` + RestrictDefaultNamespace bool `json:"RestrictDefaultNamespace"` + IngressAvailabilityPerNamespace bool `json:"IngressAvailabilityPerNamespace" validate:"required"` + AllowNoneIngressClass bool `json:"AllowNoneIngressClass" validate:"required"` + } + + // KubernetesStorageClassConfig represents a Kubernetes Storage Class configuration + KubernetesStorageClassConfig struct { + Name string `json:"Name" validate:"required"` + AccessModes []string `json:"AccessModes,omitempty"` + Provisioner string `json:"Provisioner" validate:"required"` + AllowVolumeExpansion bool `json:"AllowVolumeExpansion" validate:"required"` + } + + // KubernetesIngressClassConfig represents a Kubernetes Ingress Class configuration + KubernetesIngressClassConfig struct { + Name string `json:"Name" validate:"required"` + Type string `json:"Type" validate:"required"` + GloballyBlocked bool `json:"Blocked"` + BlockedNamespaces []string `json:"BlockedNamespaces,omitempty"` + } + + // KubernetesShellPod represents a Kubectl Shell details to facilitate pod exec functionality + KubernetesShellPod struct { + Namespace string + PodName string + ContainerName string + ShellExecCommand string + } + + // InternalAuthSettings represents settings used for the default 'internal' authentication + InternalAuthSettings struct { + RequiredPasswordLength int + } + + // LDAPGroupSearchSettings represents settings used to search for groups in a LDAP server + LDAPGroupSearchSettings struct { + // The distinguished name of the element from which the LDAP server will search for groups + GroupBaseDN string `json:"GroupBaseDN" example:"dc=ldap,dc=domain,dc=tld"` + // The LDAP search filter used to select group elements, optional + GroupFilter string `json:"GroupFilter" example:"(objectClass=account"` + // LDAP attribute which denotes the group membership + GroupAttribute string `json:"GroupAttribute" example:"member"` + } + + // LDAPSearchSettings represents settings used to search for users in a LDAP server + LDAPSearchSettings struct { + // The distinguished name of the element from which the LDAP server will search for users + BaseDN string `json:"BaseDN" example:"dc=ldap,dc=domain,dc=tld"` + // Optional LDAP search filter used to select user elements + Filter string `json:"Filter" example:"(objectClass=account)"` + // LDAP attribute which denotes the username + UserNameAttribute string `json:"UserNameAttribute" example:"uid"` + } + + // LDAPSettings represents the settings used to connect to a LDAP server + LDAPSettings struct { + // Enable this option if the server is configured for Anonymous access. When enabled, ReaderDN and Password will not be used + AnonymousMode bool `json:"AnonymousMode" example:"true" validate:"validate_bool"` + // Account that will be used to search for users + ReaderDN string `json:"ReaderDN" example:"cn=readonly-account,dc=ldap,dc=domain,dc=tld" validate:"required_if=AnonymousMode false"` + // Password of the account that will be used to search users + Password string `json:"Password,omitempty" example:"readonly-password" validate:"required_if=AnonymousMode false"` + // URL or IP address of the LDAP server + URL string `json:"URL" example:"myldap.domain.tld:389" validate:"hostname_port"` + TLSConfig TLSConfiguration `json:"TLSConfig"` + // Whether LDAP connection should use StartTLS + StartTLS bool `json:"StartTLS" example:"true"` + SearchSettings []LDAPSearchSettings `json:"SearchSettings"` + GroupSearchSettings []LDAPGroupSearchSettings `json:"GroupSearchSettings"` + // Automatically provision users and assign them to matching LDAP group names + AutoCreateUsers bool `json:"AutoCreateUsers" example:"true"` + } + + // LDAPUser represents a LDAP user + LDAPUser struct { + Name string + Groups []string + } + + // ExtensionLicenseInformation represents information about an extension license + ExtensionLicenseInformation struct { + LicenseKey string `json:"LicenseKey,omitempty"` + Company string `json:"Company,omitempty"` + Expiration string `json:"Expiration,omitempty"` + Valid bool `json:"Valid,omitempty"` + } + + // MembershipRole represents the role of a user within a team + MembershipRole int + + // OAuthSettings represents the settings used to authorize with an authorization server + OAuthSettings struct { + ClientID string `json:"ClientID"` + ClientSecret string `json:"ClientSecret,omitempty"` + AccessTokenURI string `json:"AccessTokenURI"` + AuthorizationURI string `json:"AuthorizationURI"` + ResourceURI string `json:"ResourceURI"` + RedirectURI string `json:"RedirectURI"` + UserIdentifier string `json:"UserIdentifier"` + Scopes string `json:"Scopes"` + OAuthAutoCreateUsers bool `json:"OAuthAutoCreateUsers"` + DefaultTeamID TeamID `json:"DefaultTeamID"` + SSO bool `json:"SSO"` + LogoutURI string `json:"LogoutURI"` + KubeSecretKey []byte `json:"KubeSecretKey"` + AuthStyle oauth2.AuthStyle `json:"AuthStyle"` + } + + // Pair defines a key/value string pair + Pair struct { + Name string `json:"name" example:"name" validate:"required"` + Value string `json:"value" example:"value" validate:"required"` + } + + // Registry represents a Docker registry with all the info required + // to connect to it + Registry struct { + // Registry Identifier + ID RegistryID `json:"Id" example:"1"` + // Registry Type (1 - Quay, 2 - Azure, 3 - Custom, 4 - Gitlab, 5 - ProGet, 6 - DockerHub, 7 - ECR) + Type RegistryType `json:"Type" enums:"1,2,3,4,5,6,7"` + // Registry Name + Name string `json:"Name" example:"my-registry"` + // URL or IP address of the Docker registry + URL string `json:"URL" example:"registry.mydomain.tld:2375"` + // Base URL, introduced for ProGet registry + BaseURL string `json:"BaseURL" example:"registry.mydomain.tld:2375"` + // Is authentication against this registry enabled + Authentication bool `json:"Authentication" example:"true"` + // Username or AccessKeyID used to authenticate against this registry + Username string `json:"Username" example:"registry user"` + // Password or SecretAccessKey used to authenticate against this registry + Password string `json:"Password,omitempty" example:"registry_password"` + ManagementConfiguration *RegistryManagementConfiguration `json:"ManagementConfiguration"` + Gitlab GitlabRegistryData `json:"Gitlab"` + Github GithubRegistryData `json:"Github"` + Quay QuayRegistryData `json:"Quay"` + Ecr EcrData `json:"Ecr"` + RegistryAccesses RegistryAccesses `json:"RegistryAccesses"` + + // Deprecated fields + // Deprecated in DBVersion == 31 + UserAccessPolicies UserAccessPolicies `json:"UserAccessPolicies"` + // Deprecated in DBVersion == 31 + TeamAccessPolicies TeamAccessPolicies `json:"TeamAccessPolicies"` + + // Deprecated in DBVersion == 18 + AuthorizedUsers []UserID `json:"AuthorizedUsers"` + // Deprecated in DBVersion == 18 + AuthorizedTeams []TeamID `json:"AuthorizedTeams"` + + // Stores temporary access token + AccessToken string `json:"AccessToken,omitempty"` + AccessTokenExpiry int64 `json:"AccessTokenExpiry,omitempty"` + } + + RegistryAccesses map[EndpointID]RegistryAccessPolicies + + RegistryAccessPolicies struct { + // Docker specific fields (with docker, users/teams have access to a registry) + UserAccessPolicies UserAccessPolicies `json:"UserAccessPolicies"` + TeamAccessPolicies TeamAccessPolicies `json:"TeamAccessPolicies"` + // Kubernetes specific fields (with kubernetes, namespaces have access to a registry, if users/teams have access to the same namespace, they have access to the registry) + Namespaces []string `json:"Namespaces"` + } + + // RegistryID represents a registry identifier + RegistryID int + + // RegistryManagementConfiguration represents a configuration that can be used to query + // the registry API via the registry management extension. + RegistryManagementConfiguration struct { + Type RegistryType `json:"Type"` + Authentication bool `json:"Authentication"` + Username string `json:"Username"` + Password string `json:"Password"` + TLSConfig TLSConfiguration `json:"TLSConfig"` + Ecr EcrData `json:"Ecr"` + AccessToken string `json:"AccessToken,omitempty"` + AccessTokenExpiry int64 `json:"AccessTokenExpiry,omitempty"` + } + + // RegistryType represents a type of registry + RegistryType int + + // ResourceAccessLevel represents the level of control associated to a resource + ResourceAccessLevel int + + // ResourceControl represent a reference to a Docker resource with specific access controls + ResourceControl struct { + // ResourceControl Identifier + ID ResourceControlID `json:"Id" example:"1"` + // 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 + ResourceID string `json:"ResourceId" example:"617c5f22bb9b023d6daab7cba43a57576f83492867bc767d1c59416b065e5f08"` + // List of Docker resources that will inherit this access control + SubResourceIDs []string `json:"SubResourceIds" example:"617c5f22bb9b023d6daab7cba43a57576f83492867bc767d1c59416b065e5f08"` + // Type of Docker resource. Valid values are: 1- container, 2 -service + // 3 - volume, 4 - secret, 5 - stack, 6 - config or 7 - custom template + Type ResourceControlType `json:"Type" example:"1"` + UserAccesses []UserResourceAccess `json:"UserAccesses"` + TeamAccesses []TeamResourceAccess `json:"TeamAccesses"` + // Permit access to the associated resource to any user + Public bool `json:"Public" example:"true"` + // Permit access to resource only to admins + AdministratorsOnly bool `json:"AdministratorsOnly" example:"true"` + System bool `json:"System"` + + // Deprecated fields + // Deprecated in DBVersion == 2 + OwnerID UserID `json:"OwnerId,omitempty"` + AccessLevel ResourceAccessLevel `json:"AccessLevel,omitempty"` + } + + // ResourceControlID represents a resource control identifier + ResourceControlID int + + // ResourceControlType represents the type of resource associated to the resource control (volume, container, service...) + ResourceControlType int + + // Role represents a set of authorizations that can be associated to a user or + // to a team. + Role struct { + // Role Identifier + ID RoleID `json:"Id" example:"1"` + // Role name + Name string `json:"Name" example:"HelpDesk"` + // Role description + Description string `json:"Description" example:"Read-only access of all resources in an environment(endpoint)"` + // Authorizations associated to a role + Authorizations Authorizations `json:"Authorizations"` + Priority int `json:"Priority"` + } + + // RoleID represents a role identifier + RoleID int + + // APIKeyID represents an API key identifier + APIKeyID int + + // APIKey represents an API key + APIKey struct { + ID APIKeyID `json:"id" example:"1"` + UserID UserID `json:"userId" example:"1"` + Description string `json:"description" example:"portainer-api-key"` + Prefix string `json:"prefix"` // API key identifier (7 char prefix) + DateCreated int64 `json:"dateCreated"` // Unix timestamp (UTC) when the API key was created + LastUsed int64 `json:"lastUsed"` // Unix timestamp (UTC) when the API key was last used + Digest string `json:"digest,omitempty"` // Digest represents SHA256 hash of the raw API key + } + + // Schedule represents a scheduled job. + // 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 { + // Schedule Identifier + ID ScheduleID `json:"Id" example:"1"` + 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 + ScriptExecutionJob struct { + Endpoints []EndpointID + Image string + ScriptPath string + RetryCount int + RetryInterval int + } + + GlobalDeploymentOptions struct { + HideStacksFunctionality bool `json:"hideStacksFunctionality" example:"false"` + } + + Edge struct { + // The command list interval for edge agent - used in edge async mode (in seconds) + CommandInterval int `json:"CommandInterval" example:"5"` + // The ping interval for edge agent - used in edge async mode (in seconds) + PingInterval int `json:"PingInterval" example:"5"` + // The snapshot interval for edge agent - used in edge async mode (in seconds) + SnapshotInterval int `json:"SnapshotInterval" example:"5"` + + // Deprecated 2.18 + AsyncMode bool `json:"AsyncMode,omitempty" example:"false"` + } + + // Settings represents the application settings + Settings struct { + // 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 + LogoURL string `json:"LogoURL" example:"https://mycompany.mydomain.tld/logo.png"` + // A list of label name & value that will be used to hide containers when querying containers + BlackListedLabels []Pair `json:"BlackListedLabels"` + // Active authentication method for the Portainer instance. Valid values are: 1 for internal, 2 for LDAP, or 3 for oauth + AuthenticationMethod AuthenticationMethod `json:"AuthenticationMethod" example:"1"` + InternalAuthSettings InternalAuthSettings `json:"InternalAuthSettings"` + LDAPSettings LDAPSettings `json:"LDAPSettings"` + OAuthSettings OAuthSettings `json:"OAuthSettings"` + FeatureFlagSettings map[featureflags.Feature]bool `json:"FeatureFlagSettings"` + // The interval in which environment(endpoint) snapshots are created + SnapshotInterval string `json:"SnapshotInterval" example:"5m"` + // URL to the templates that will be displayed in the UI when navigating to App Templates + TemplatesURL string `json:"TemplatesURL" example:"https://raw.githubusercontent.com/portainer/templates/master/templates.json"` + // Deployment options for encouraging git ops workflows + GlobalDeploymentOptions GlobalDeploymentOptions `json:"GlobalDeploymentOptions"` + // The default check in interval for edge agent (in seconds) + EdgeAgentCheckinInterval int `json:"EdgeAgentCheckinInterval" example:"5"` + // Whether edge compute features are enabled + EnableEdgeComputeFeatures bool `json:"EnableEdgeComputeFeatures"` + // The duration of a user session + UserSessionTimeout string `json:"UserSessionTimeout" example:"5m"` + // The expiry of a Kubeconfig + KubeconfigExpiry string `json:"KubeconfigExpiry" example:"24h"` + // Helm repository URL, defaults to "https://charts.bitnami.com/bitnami" + HelmRepositoryURL string `json:"HelmRepositoryURL" example:"https://charts.bitnami.com/bitnami"` + // KubectlImage, defaults to portainer/kubectl-shell + KubectlShellImage string `json:"KubectlShellImage" example:"portainer/kubectl-shell"` + // TrustOnFirstConnect makes Portainer accepting edge agent connection by default + TrustOnFirstConnect bool `json:"TrustOnFirstConnect" example:"false"` + // EnforceEdgeID makes Portainer store the Edge ID instead of accepting anyone + EnforceEdgeID bool `json:"EnforceEdgeID" example:"false"` + // Container environment parameter AGENT_SECRET + AgentSecret string `json:"AgentSecret"` + // EdgePortainerURL is the URL that is exposed to edge agents + EdgePortainerURL string `json:"EdgePortainerUrl"` + + Edge Edge `json:"Edge"` + + // Deprecated fields + DisplayDonationHeader bool `json:"DisplayDonationHeader,omitempty"` + DisplayExternalContributors bool `json:"DisplayExternalContributors,omitempty"` + + // Deprecated fields v26 + EnableHostManagementFeatures bool `json:"EnableHostManagementFeatures,omitempty"` + AllowVolumeBrowserForRegularUsers bool `json:"AllowVolumeBrowserForRegularUsers,omitempty"` + AllowBindMountsForRegularUsers bool `json:"AllowBindMountsForRegularUsers,omitempty"` + AllowPrivilegedModeForRegularUsers bool `json:"AllowPrivilegedModeForRegularUsers,omitempty"` + AllowHostNamespaceForRegularUsers bool `json:"AllowHostNamespaceForRegularUsers,omitempty"` + AllowStackManagementForRegularUsers bool `json:"AllowStackManagementForRegularUsers,omitempty"` + AllowDeviceMappingForRegularUsers bool `json:"AllowDeviceMappingForRegularUsers,omitempty"` + AllowContainerCapabilitiesForRegularUsers bool `json:"AllowContainerCapabilitiesForRegularUsers,omitempty"` + + IsDockerDesktopExtension bool `json:"IsDockerDesktopExtension,omitempty"` + + // ForceSecureCookies forces the Secure attribute on auth cookies regardless of detected scheme. + // Enable when Portainer runs behind a TLS-terminating proxy. + ForceSecureCookies bool `json:"ForceSecureCookies" example:"false"` + } + + // SnapshotJob represents a scheduled job that can create environment(endpoint) snapshots + SnapshotJob struct{} + + // SoftwareEdition represents an edition of Portainer + SoftwareEdition int + + // AllowList holds the list of permitted outbound proxy destinations. + AllowList struct { + ID AllowListKey `json:"Id"` + Mode SSRFMode `json:"Mode"` + Entries []string `json:"Entries"` + } + + // ParsedAllowList holds the three parsed forms of allow list entries. + ParsedAllowList struct { + Mode SSRFMode + Nets []*net.IPNet + Hosts map[string]bool + Wilds []string // stored as ".foo.com" ("*." prefix stripped) + } + + // SSLSettings represents a pair of SSL certificate and key + SSLSettings struct { + CertPath string `json:"certPath"` + KeyPath string `json:"keyPath"` + SelfSigned bool `json:"selfSigned"` + HTTPEnabled bool `json:"httpEnabled"` + } + + // Stack represents a Docker stack created via docker stack deploy + Stack struct { + // Stack Identifier + ID StackID `json:"Id" example:"1"` + // Stack name + Name string `json:"Name" example:"myStack"` + // Stack type. 1 for a Swarm stack, 2 for a Compose stack + Type StackType `json:"Type" example:"2"` + // Environment(Endpoint) identifier. Reference the environment(endpoint) that will be used for deployment + EndpointID EndpointID `json:"EndpointId" example:"1"` + // Cluster identifier of the Swarm cluster where the stack is deployed + SwarmID string `json:"SwarmId" example:"jpofkc0i9uo9wtx1zesuk649w"` + // EntryPoint is the path to the config file relative to the project root. + // NOTE: For git stacks this mirrors GitConfig.ConfigFilePath and the two are kept in sync + // by stackUpdateGit. The deploy command builder (compose_unpacker_cmd_builder) uses this + // field directly; Kubernetes deploy and git clone operations use GitConfig.ConfigFilePath. + EntryPoint string `json:"EntryPoint" example:"docker-compose.yml"` + // A list of environment(endpoint) variables used during stack deployment + Env []Pair `json:"Env"` + // + ResourceControl *ResourceControl `json:"ResourceControl"` + // Stack status (1 - active, 2 - inactive, 3 - deploying, 4 - error) + Status StackStatus `json:"Status" example:"1"` + // DeploymentStartStatus is the stack status captured when the current + // deployment starts. It is used by deployment logic during the current + // deployment attempt and is cleared/replaced when a new deployment begins. + DeploymentStartStatus StackStatus `json:"DeploymentStartStatus" example:"1"` + // Path on disk to the repository hosting the Stack file + ProjectPath string `example:"/data/compose/myStack_jpofkc0i9uo9wtx1zesuk649w"` + // The date in unix time when stack was created + CreationDate int64 `example:"1587399600"` + // The username which created this stack + CreatedBy string `example:"admin"` + // The date in unix time when stack was last updated + UpdateDate int64 `example:"1587399600"` + // The username which last updated this stack + UpdatedBy string `example:"bob"` + // Only applies when deploying stack with multiple files + AdditionalFiles []string `json:"AdditionalFiles"` + // The GitOps update settings of a git stack + AutoUpdate *AutoUpdateSettings `json:"AutoUpdate"` + // The stack deployment option + Option *StackOption `json:"Option"` + // GitConfig is the git repository configuration for git-backed stacks. + // Deprecated: loaded from Source via WorkflowID; kept for DB backwards-compatibility only. + // Non-migration code must not read or write this field; use Source records instead. + GitConfig *gittypes.RepoConfig `json:"GitConfig"` + // WorkflowID is the ID of the Workflow that owns the Source for this stack. + WorkflowID WorkflowID `json:"WorkflowID,omitempty"` + // CurrentDeploymentInfo records the git repository state at the time of the last actual deployment. + CurrentDeploymentInfo *StackDeploymentInfo `json:"CurrentDeploymentInfo,omitempty"` + // Whether the stack is from a app template + FromAppTemplate bool `example:"false"` + // Kubernetes namespace if stack is a kube application + Namespace string `example:"default"` + // DeploymentStatus records the status progression of the current deployment. + // Cleared when a new deployment starts. + DeploymentStatus []StackDeploymentStatus `json:"DeploymentStatus,omitempty"` + } + + // StackOption represents the options for stack deployment + StackOption struct { + // Prune services that are no longer referenced + Prune bool `example:"false"` + // Enable atomic rollback on failure (Helm --atomic flag for Kubernetes Helm stacks) + HelmAtomic bool `example:"false"` + } + + // 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 + + // Source represents a GitOps source that can be referenced by stacks or deployments. + Source struct { + ID SourceID `json:"id" example:"1"` + Name string `json:"name" example:"my-source"` + LastSync int64 `json:"lastSync,omitempty" example:"1587399600"` + Type SourceType `json:"type" example:"1"` + Git *gittypes.GitSource `json:"git,omitempty"` + Registry *Registry `json:"registry,omitempty"` + Helm *HelmConfig `json:"helm,omitempty"` + + Public bool `json:"public"` + AdministratorsOnly bool `json:"administratorsOnly"` + UserAccesses []UserID `json:"userAccesses"` + TeamAccesses []TeamID `json:"teamAccesses"` + OwnerID UserID `json:"ownerID,omitempty"` + Status SourceStatus `json:"status,omitempty"` + StatusError string `json:"statusError,omitempty"` + } + + SourceStatus int + + // SourceID represents a source identifier + SourceID int + + // SourceType represents the type of a source + SourceType int + + // Status represents the application status + Status struct { + // Portainer API version + Version string `json:"Version" example:"2.0.0"` + // Server Instance ID + InstanceID string `example:"299ab403-70a8-4c05-92f7-bf7a994d50df"` + } + + // Tag represents a tag that can be associated to a resource + Tag struct { + // Tag identifier + ID TagID `example:"1"` + // Tag name + Name string `json:"Name" example:"org/acme"` + // A set of environment(endpoint) ids that have this tag + Endpoints map[EndpointID]bool `json:"Endpoints"` + // A set of environment(endpoint) group ids that have this tag + EndpointGroups map[EndpointGroupID]bool `json:"EndpointGroups"` + } + + // TagID represents a tag identifier + TagID int + + // Team represents a list of user accounts + Team struct { + // Team Identifier + ID TeamID `json:"Id" example:"1"` + // Team name + Name string `json:"Name" example:"developers"` + } + + // TeamAccessPolicies represent the association of an access policy and a team + TeamAccessPolicies map[TeamID]AccessPolicy + + // TeamID represents a team identifier + TeamID int + + // TeamMembership represents a membership association between a user and a team. + // + // A user may belong to multiple teams. + TeamMembership struct { + // Membership Identifier + ID TeamMembershipID `json:"Id" example:"1"` + // User identifier + UserID UserID `json:"UserID" example:"1"` + // Team identifier + TeamID TeamID `json:"TeamID" example:"1"` + // Team role (1 for team leader and 2 for team member) + Role MembershipRole `json:"Role" example:"1"` + } + + // TeamMembershipID represents a team membership identifier + TeamMembershipID int + + // TeamResourceAccess represents the level of control on a resource for a specific team + TeamResourceAccess struct { + TeamID TeamID `json:"TeamId"` + AccessLevel ResourceAccessLevel `json:"AccessLevel"` + } + + // Template represents an application template that can be used as an App Template + // or an Edge template + Template struct { + // Mandatory container/stack fields + // Template Identifier + ID TemplateID `json:"id" example:"1"` + // Template type. Valid values are: 1 (container), 2 (Swarm stack), 3 (Compose stack), 4 (Compose edge stack) + Type TemplateType `json:"type" example:"1"` + // Title of the template + Title string `json:"title" example:"Nginx"` + // Description of the template + Description string `json:"description" example:"High performance web server"` + // Whether the template should be available to administrators only + AdministratorOnly bool `json:"administrator_only" example:"true"` + + // Mandatory container fields + // Image associated to a container template. Mandatory for a container template + Image string `json:"image" example:"nginx:latest"` + + // Mandatory stack fields + Repository TemplateRepository `json:"repository"` + + // Mandatory Edge stack fields + // Stack file used for this template + StackFile string `json:"stackFile"` + + // Optional stack/container fields + // Default name for the stack/container to be used on deployment + Name string `json:"name,omitempty" example:"mystackname"` + // URL of the template's logo + Logo string `json:"logo,omitempty" example:"https://portainer.io/img/logo.svg"` + // A list of environment(endpoint) variables used during the template deployment + Env []TemplateEnv `json:"env,omitempty"` + // A note that will be displayed in the UI. Supports HTML content + Note string `json:"note,omitempty" example:"This is my custom template"` + // Platform associated to the template. + // Valid values are: 'linux', 'windows' or leave empty for multi-platform + Platform string `json:"platform,omitempty" example:"linux"` + // A list of categories associated to the template + Categories []string `json:"categories,omitempty" example:"database"` + + // Optional container fields + // The URL of a registry associated to the image for a container template + Registry string `json:"registry,omitempty" example:"quay.io"` + // The command that will be executed in a container template + Command string `json:"command,omitempty" example:"ls -lah"` + // Name of a network that will be used on container deployment if it exists inside the environment(endpoint) + Network string `json:"network,omitempty" example:"mynet"` + // A list of volumes used during the container template deployment + Volumes []TemplateVolume `json:"volumes,omitempty"` + // A list of ports exposed by the container + Ports []string `json:"ports,omitempty" example:"8080:80/tcp"` + // Container labels + Labels []Pair `json:"labels,omitempty"` + // Whether the container should be started in privileged mode + Privileged bool `json:"privileged,omitempty" example:"true"` + // Whether the container should be started in + // interactive mode (-i -t equivalent on the CLI) + Interactive bool `json:"interactive,omitempty" example:"true"` + // Container restart policy + RestartPolicy string `json:"restart_policy,omitempty" example:"on-failure"` + // Container hostname + Hostname string `json:"hostname,omitempty" example:"mycontainer"` + } + + // TemplateEnv represents a template environment(endpoint) variable configuration + TemplateEnv struct { + // name of the environment(endpoint) variable + Name string `json:"name" example:"MYSQL_ROOT_PASSWORD"` + // Text for the label that will be generated in the UI + Label string `json:"label,omitempty" example:"Root password"` + // Content of the tooltip that will be generated in the UI + Description string `json:"description,omitempty" example:"MySQL root account password"` + // Default value that will be set for the variable + Default string `json:"default,omitempty" example:"default_value"` + // If set to true, will not generate any input for this variable in the UI + Preset bool `json:"preset,omitempty" example:"false"` + // A list of name/value that will be used to generate a dropdown in the UI + Select []TemplateEnvSelect `json:"select,omitempty"` + } + + // TemplateEnvSelect represents text/value pair that will be displayed as a choice for the + // template user + TemplateEnvSelect struct { + // Some text that will displayed as a choice + Text string `json:"text" example:"text value"` + // A value that will be associated to the choice + Value string `json:"value" example:"value"` + // Will set this choice as the default choice + Default bool `json:"default" example:"false"` + } + + // TemplateID represents a template identifier + TemplateID int + + // TemplateRepository represents the git repository configuration for a template + TemplateRepository struct { + // URL of a git repository used to deploy a stack template. Mandatory for a Swarm/Compose stack template + URL string `json:"url" example:"https://github.com/portainer/portainer-compose"` + // Path to the stack file inside the git repository + StackFile string `json:"stackfile" example:"./subfolder/docker-compose.yml"` + } + + // TemplateType represents the type of a template + TemplateType int + + // TemplateVolume represents a template volume configuration + TemplateVolume struct { + // Path inside the container + Container string `json:"container" example:"/data"` + // Path on the host + Bind string `json:"bind,omitempty" example:"/tmp"` + // Whether the volume used should be readonly + ReadOnly bool `json:"readonly,omitempty" example:"true"` + } + + // TLSConfiguration represents a TLS configuration + TLSConfiguration struct { + // Use TLS + TLS bool `json:"TLS" example:"true" validate:"required"` + // Skip the verification of the server TLS certificate + TLSSkipVerify bool `json:"TLSSkipVerify" example:"false" validate:"required"` + // Path to the TLS CA certificate file + TLSCACertPath string `json:"TLSCACert,omitempty" example:"/data/tls/ca.pem"` + // Path to the TLS client certificate file + TLSCertPath string `json:"TLSCert,omitempty" example:"/data/tls/cert.pem"` + // Path to the TLS client key file + TLSKeyPath string `json:"TLSKey,omitempty" example:"/data/tls/key.pem"` + } + + // TLSFileType represents a type of TLS file required to connect to a Docker environment(endpoint). + // It can be either a TLS CA file, a TLS certificate file or a TLS key file + TLSFileType int + + // TokenData represents the data embedded in a JWT token + TokenData struct { + ID UserID + Username string + Role UserRole + ForceChangePassword bool + Token string + } + + // TunnelDetails represents information associated to a tunnel + TunnelDetails struct { + Status string + LastActivity time.Time + Port int + Credentials string + HasSnapshot bool + } + + // TunnelServerInfo represents information associated to the tunnel server + TunnelServerInfo struct { + PrivateKeySeed string `json:"PrivateKeySeed"` + } + + // User represents a user account + User struct { + // User Identifier + ID UserID `json:"Id" example:"1" validate:"required"` + Username string `json:"Username" example:"bob" validate:"required"` + Password string `json:"Password,omitempty" swaggerignore:"true"` + // User role (1 for administrator account and 2 for regular account) + Role UserRole `json:"Role" example:"1" validate:"required"` + TokenIssueAt int64 `json:"TokenIssueAt" example:"1"` + ThemeSettings UserThemeSettings `json:"ThemeSettings"` + UseCache bool `json:"UseCache" example:"true"` + + // Deprecated fields + + // Deprecated + UserTheme string `json:"UserTheme,omitempty" example:"dark" swaggerignore:"true"` + // Deprecated in DBVersion == 25 + PortainerAuthorizations Authorizations `swaggerignore:"true"` + // Deprecated in DBVersion == 25 + EndpointAuthorizations EndpointAuthorizations `swaggerignore:"true"` + } + + // UserAccessPolicies represent the association of an access policy and a user + UserAccessPolicies map[UserID]AccessPolicy + + // UserID represents a user identifier + UserID int + + // UserResourceAccess represents the level of control on a resource for a specific user + UserResourceAccess struct { + UserID UserID `json:"UserId"` + AccessLevel ResourceAccessLevel `json:"AccessLevel"` + } + + // UserRole represents the role of a user. It can be either an administrator + // or a regular user + UserRole int + + // UserThemeSettings represents the theme settings for a user + UserThemeSettings struct { + // Color represents the color theme of the UI + Color string `json:"color" example:"dark" enums:"dark,light,highcontrast,auto,"` + } + + // Webhook represents a url webhook that can be used to update a service + Webhook struct { + // Webhook Identifier + ID WebhookID `json:"Id" example:"1"` + Token string `json:"Token"` + ResourceID string `json:"ResourceId"` + EndpointID EndpointID `json:"EndpointId"` + RegistryID RegistryID `json:"RegistryId"` + // Type of webhook (1 - service) + WebhookType WebhookType `json:"Type"` + } + + // WebhookID represents a webhook identifier. + WebhookID int + + // WebhookType represents the type of resource a webhook is related to + WebhookType int + + // Artifact is one entry in a Workflow's artifact list, pairing target IDs with source files + Artifact struct { + StackID StackID `json:"stackId,omitempty"` + EdgeStackID EdgeStackID `json:"edgeStackId,omitempty"` + Files []ArtifactFile `json:"files,omitempty"` + EnvIDs []EndpointID `json:"envIds,omitempty"` + EnvGroups []EndpointGroupID `json:"envGroups,omitempty"` + EdgeGroups []EdgeGroupID `json:"edgeGroups,omitempty"` + } + + // ArtifactFile represents one file within an artifact, tied to a specific source and location within it + ArtifactFile struct { + SourceID SourceID `json:"sourceId"` + Path string `json:"path,omitempty" example:"portainer.yaml"` + Ref string `json:"ref,omitempty" example:"refs/heads/main"` + Hash string `json:"hash,omitempty" example:"abc123"` + RefStatus SourceStatus `json:"refStatus,omitempty"` + RefError string `json:"refError,omitempty"` + PathStatus SourceStatus `json:"pathStatus,omitempty"` + PathError string `json:"pathError,omitempty"` + } + + // Workflow represents a GitOps workflow + Workflow struct { + ID WorkflowID `json:"id" example:"1"` + Name string `json:"name,omitempty" example:"my-workflow"` + Artifacts []Artifact `json:"artifacts,omitempty"` + } + + WorkflowID int + + Snapshot struct { + EndpointID EndpointID `json:"EndpointId"` + Docker *DockerSnapshot `json:"Docker"` + Kubernetes *KubernetesSnapshot `json:"Kubernetes"` + } + + SnapshotRawMessage struct { + EndpointID EndpointID `json:"EndpointId"` + Docker json.RawMessage `json:"Docker"` + Kubernetes json.RawMessage `json:"Kubernetes"` + } + + // CLIService represents a service for managing CLI + CLIService interface { + ParseFlags(version string) (*CLIFlags, error) + ValidateFlags(flags *CLIFlags) error + } + + ComposeOptions struct { + Registries []Registry + } + + ComposeUpOptions struct { + ComposeOptions + + // ForceRecreate forces to recreate containers + ForceRecreate bool + // AbortOnContainerExit will stop the deployment if a container exits. + // This is useful when running a onetime task. + // + // When this is set, docker compose will output its logs to stdout + AbortOnContainerExit bool + Prune bool + } + + ComposeDownOptions struct { + // RemoveVolumes will remove the named volumes declared in the compose file + // and anonymous volumes attached to the stack's containers + // Drives `docker compose down --volumes` + RemoveVolumes bool + } + + ComposeRunOptions struct { + ComposeOptions + + // Remove will remove the container after it has stopped + Remove bool + // Args are the arguments to pass to the container + Args []string + // Detached will run the container in the background + Detached bool + } + + // ComposeStackManager represents a service to manage Compose stacks + ComposeStackManager interface { + ComposeSyntaxMaxVersion() string + NormalizeStackName(name string) string + Run(ctx context.Context, stack *Stack, endpoint *Endpoint, serviceName string, options ComposeRunOptions) error + Up(ctx context.Context, stack *Stack, endpoint *Endpoint, options ComposeUpOptions) error + Down(ctx context.Context, stack *Stack, endpoint *Endpoint) error + Pull(ctx context.Context, stack *Stack, endpoint *Endpoint, options ComposeOptions) error + } + + // CryptoService represents a service for encrypting/hashing data + CryptoService interface { + Hash(data string) (string, error) + CompareHashAndData(hash string, data string) error + } + + // DigitalSignatureService represents a service to manage digital signatures + DigitalSignatureService interface { + ParseKeyPair(private, public []byte) error + GenerateKeyPair() ([]byte, []byte, error) + EncodedPublicKey() string + PEMHeaders() (string, string) + CreateSignature(message string) (string, error) + } + + // DockerSnapshotter represents a service used to create Docker environment(endpoint) snapshots + DockerSnapshotter interface { + CreateSnapshot(endpoint *Endpoint) (*DockerSnapshot, error) + } + + // FileService represents a service for managing files + FileService interface { + GetFileContent(trustedRootPath, filePath string) ([]byte, error) + Copy(fromFilePath string, toFilePath string, deleteIfExists bool) error + Rename(oldPath, newPath string) error + RemoveDirectory(directoryPath string) error + StoreTLSFileFromBytes(folder string, fileType TLSFileType, data []byte) (string, error) + GetPathForTLSFile(folder string, fileType TLSFileType) (string, error) + DeleteTLSFile(folder string, fileType TLSFileType) error + DeleteTLSFiles(folder string) error + GetStackProjectPath(stackIdentifier string) string + GetStackProjectPathByVersion(stackIdentifier string, version int, commitHash string) string + StoreStackFileFromBytes(stackIdentifier, fileName string, data []byte) (string, error) + StoreStackFileFromBytesByVersion(stackIdentifier, fileName string, version int, data []byte) (string, error) + UpdateStoreStackFileFromBytes(stackIdentifier, fileName string, data []byte) (string, error) + UpdateStoreStackFileFromBytesByVersion(stackIdentifier, fileName string, version int, commitHash string, data []byte) (string, error) + RemoveStackFileBackup(stackIdentifier, fileName string) error + RemoveStackFileBackupByVersion(stackIdentifier string, version int, fileName string) error + RollbackStackFile(stackIdentifier, fileName string) error + RollbackStackFileByVersion(stackIdentifier string, version int, fileName string) error + GetEdgeStackProjectPath(edgeStackIdentifier string) string + StoreEdgeStackFileFromBytes(edgeStackIdentifier, fileName string, data []byte) (string, error) + GetEdgeStackProjectPathByVersion(edgeStackIdentifier string, version int, commitHash string) string + StoreEdgeStackFileFromBytesByVersion(edgeStackIdentifier, fileName string, version int, data []byte) (string, error) + FormProjectPathByVersion(projectPath string, version int, commitHash string) string + SafeMoveDirectory(src, dst string) error + StoreRegistryManagementFileFromBytes(folder, fileName string, data []byte) (string, error) + KeyPairFilesExist() (bool, error) + StoreKeyPair(private, public []byte, privatePEMHeader, publicPEMHeader string) error + LoadKeyPair() ([]byte, []byte, error) + WriteJSONToFile(path string, content any) error + FileExists(path string) (bool, 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) + GetDatastorePath() string + GetDefaultSSLCertsPath() (string, string) + StoreSSLCertPair(cert, key []byte) (string, string, error) + CopySSLCertPair(certPath, keyPath string) (string, string, error) + CopySSLCACert(caCertPath string) (string, error) + StoreMTLSCertificates(caCert, cert, key []byte) (string, string, string, error) + GetMTLSCertificates() (string, string, string, error) + GetDefaultChiselPrivateKeyPath() string + StoreChiselPrivateKey(privateKey []byte) error + } + + // GitService represents a service for managing Git + GitService interface { + CloneRepository( + ctx context.Context, + destination string, + repositoryURL, + referenceName, + username, + password string, + tlsSkipVerify bool, + ) error + LatestCommitID( + ctx context.Context, + repositoryURL, + referenceName, + username, + password string, + tlsSkipVerify bool, + ) (string, error) + ListRefs( + ctx context.Context, + repositoryURL, + username, + password string, + hardRefresh bool, + tlsSkipVerify bool, + ) ([]string, error) + ListFiles( + ctx context.Context, + repositoryURL, + referenceName, + username, + password string, + dirOnly, + hardRefresh bool, + includeExts []string, + tlsSkipVerify bool, + ) ([]string, error) + } + + // JWTService represents a service for managing JWT tokens + JWTService interface { + GenerateToken(data *TokenData) (string, time.Time, error) + GenerateTokenForKubeconfig(data *TokenData) (string, error) + ParseAndVerifyToken(token string) (*TokenData, string, time.Time, error) + SetUserSessionDuration(userSessionDuration time.Duration) + } + + // KubeExecParams holds parameters for KubeClient.StartExecProcess. + KubeExecParams struct { + Token string + UseAdminToken bool + Namespace string + PodName string + ContainerName string + Command []string + Stdin io.Reader // bound to the exec process stdin + Stdout io.Writer // receives exec process output + ResizeQueue remotecommand.TerminalSizeQueue // nil if resize not needed + ErrChan chan error + } + + // KubeClient represents a service used to query a Kubernetes environment(endpoint) + KubeClient interface { + // Access + GetIsKubeAdmin() bool + SetIsKubeAdmin(isKubeAdmin bool) + GetClientNonAdminNamespaces() []string + SetClientNonAdminNamespaces([]string) + NamespaceAccessPoliciesDeleteNamespace(ns string) error + UpdateNamespaceAccessPolicies(accessPolicies map[string]K8sNamespaceAccessPolicy) error + GetNamespaceAccessPolicies() (map[string]K8sNamespaceAccessPolicy, error) + GetNonAdminNamespaces(userID int, teamIDs []int, isRestrictDefaultNamespace bool) ([]string, error) + + // Applications + GetApplications(namespace, nodeName string) ([]models.K8sApplication, error) + GetApplicationsResource(namespace, node string) (models.K8sApplicationResource, error) + GetClusterNodes() ([]corev1.Node, error) + + // ClusterRole + GetClusterRoles() ([]models.K8sClusterRole, error) + DeleteClusterRoles(req models.K8sClusterRoleDeleteRequests) error + + // ConfigMap + GetConfigMap(namespace, configMapName string) (models.K8sConfigMap, error) + CombineConfigMapWithApplications(configMap models.K8sConfigMap) (models.K8sConfigMap, error) + + // CronJob + GetCronJobs(namespace string) ([]models.K8sCronJob, error) + DeleteCronJobs(payload models.K8sCronJobDeleteRequests) error + + // Event + GetEvents(namespace string, resourceId string) ([]models.K8sEvent, error) + + // Exec + StartExecProcess(params KubeExecParams) + + // ClusterRoleBinding + GetClusterRoleBindings() ([]models.K8sClusterRoleBinding, error) + DeleteClusterRoleBindings(reqs models.K8sClusterRoleBindingDeleteRequests) error + + // Dashboard + GetDashboard() (models.K8sDashboard, error) + + // Deployment + HasStackName(namespace string, stackName string) (bool, error) + + // Ingress + GetIngressControllers() (models.K8sIngressControllers, error) + GetIngress(namespace, ingressName string) (models.K8sIngressInfo, error) + GetIngresses(namespace string) ([]models.K8sIngressInfo, error) + CreateIngress(namespace string, info models.K8sIngressInfo, owner string) error + DeleteIngresses(reqs models.K8sIngressDeleteRequests) error + UpdateIngress(namespace string, info models.K8sIngressInfo) error + CombineIngressWithService(ingress models.K8sIngressInfo) (models.K8sIngressInfo, error) + CombineIngressesWithServices(ingresses []models.K8sIngressInfo) ([]models.K8sIngressInfo, error) + + // Job + GetJobs(namespace string, includeCronJobChildren bool) ([]models.K8sJob, error) + DeleteJobs(payload models.K8sJobDeleteRequests) error + + // Metrics + GetMetrics() (models.K8sMetrics, error) + + // Namespace + ToggleSystemState(namespaceName string, isSystem bool) error + UpdateNamespace(info models.K8sNamespaceDetails) (*corev1.Namespace, error) + GetNamespace(name string) (K8sNamespaceInfo, error) + CreateNamespace(info models.K8sNamespaceDetails) (*corev1.Namespace, error) + GetNamespaces() (map[string]K8sNamespaceInfo, error) + CombineNamespaceWithResourceQuota(namespace K8sNamespaceInfo, w http.ResponseWriter) *httperror.HandlerError + DeleteNamespace(namespaceName string) (*corev1.Namespace, error) + CombineNamespacesWithResourceQuotas(namespaces map[string]K8sNamespaceInfo, w http.ResponseWriter) *httperror.HandlerError + ConvertNamespaceMapToSlice(namespaces map[string]K8sNamespaceInfo) []K8sNamespaceInfo + + // NodeLimits + GetNodesLimits() (K8sNodesLimits, error) + GetMaxResourceLimits(skipNamespace string, overCommitEnabled bool, resourceOverCommitPercent int) (K8sNodeLimits, error) + + // Pod + CreateUserShellPod(ctx context.Context, serviceAccountName, shellPodImage string) (*KubernetesShellPod, error) + DeletePod(namespace, name string) error + RestartPod(namespace, name string) error + SupportsPodRestart(ctx context.Context) (bool, error) + + // RBAC + IsRBACEnabled() (bool, error) + + // Registries + DeleteRegistrySecret(registry RegistryID, namespace string) error + CreateRegistrySecret(registry *Registry, namespace string) error + IsRegistrySecret(namespace, secretName string) (bool, error) + + // RoleBinding + GetRoleBindings(namespace string) ([]models.K8sRoleBinding, error) + DeleteRoleBindings(reqs models.K8sRoleBindingDeleteRequests) error + + // Role + DeleteRoles(reqs models.K8sRoleDeleteRequests) error + + // Secret + GetSecrets(namespace string) ([]models.K8sSecret, error) + GetSecret(namespace string, secretName string) (models.K8sSecret, error) + CombineSecretWithApplications(secret models.K8sSecret) (models.K8sSecret, error) + + // ServiceAccount + GetServiceAccounts(namespace string) ([]models.K8sServiceAccount, error) + GetServiceAccount(namespace, name string) (models.K8sServiceAccount, error) + DeleteServiceAccounts(reqs models.K8sServiceAccountDeleteRequests) error + AddImagePullSecretToServiceAccount(namespace, serviceAccountName, secretName string) error + RemoveImagePullSecretFromServiceAccount(namespace, serviceAccountName, secretName string) error + UpdateServiceAccountImagePullSecrets(namespace, name string, secretNames []string) error + SetupUserServiceAccount(int, []int, bool) error + GetPortainerUserServiceAccount(tokendata *TokenData) (*corev1.ServiceAccount, error) + GetServiceAccountBearerToken(userID int) (string, error) + + // Service + GetServices(namespace string) ([]models.K8sServiceInfo, error) + CombineServicesWithApplications(services []models.K8sServiceInfo) ([]models.K8sServiceInfo, error) + CreateService(namespace string, info models.K8sServiceInfo) error + DeleteServices(reqs models.K8sServiceDeleteRequests) error + UpdateService(namespace string, info models.K8sServiceInfo) error + + // ServerVersion + ServerVersion() (*version.Info, error) + + // Storage + GetStorage() ([]KubernetesStorageClassConfig, error) + + // Volumes + GetVolumes(namespace string) ([]models.K8sVolumeInfo, error) + GetVolume(namespace, volumeName string) (*models.K8sVolumeInfo, error) + CombineVolumesWithApplications(volumes *[]models.K8sVolumeInfo) (*[]models.K8sVolumeInfo, error) + + // StorageClass + GetStorageClasses() ([]models.K8sStorageClass, error) + GetStorageClass(name string) (*models.K8sStorageClass, error) + DeleteStorageClasses(names []string) error + SetDefaultStorageClass(name string) error + + // PersistentVolume + GetPersistentVolumes() ([]models.K8sPersistentVolume, error) + GetPersistentVolume(name string) (*models.K8sPersistentVolume, error) + DeletePersistentVolumes(names []string) error + UpdatePersistentVolumeReclaimPolicy(name string, policy corev1.PersistentVolumeReclaimPolicy) error + + // PersistentVolumeClaim + GetPersistentVolumeClaims(namespace string) ([]models.K8sPersistentVolumeClaim, error) + GetPersistentVolumeClaim(namespace, name string) (*models.K8sPersistentVolumeClaim, error) + DeletePersistentVolumeClaims(reqs models.K8sVolumeDeleteRequests) error + ResizePersistentVolumeClaim(namespace, name, newSize string) error + } + + // KubernetesDeployer represents a service to deploy a manifest inside a Kubernetes environment(endpoint) + KubernetesDeployer interface { + Deploy(ctx context.Context, userID UserID, endpoint *Endpoint, manifestFiles []string, namespace string) (string, error) + Remove(ctx context.Context, userID UserID, endpoint *Endpoint, manifestFiles []string, namespace string) (string, error) + } + + // KubernetesSnapshotter represents a service used to create Kubernetes environment(endpoint) snapshots + KubernetesSnapshotter interface { + CreateSnapshot(endpoint *Endpoint) (*KubernetesSnapshot, error) + } + + // LDAPService represents a service used to authenticate users against a LDAP/AD + LDAPService interface { + AuthenticateUser(username, password string, settings *LDAPSettings) error + TestConnectivity(settings *LDAPSettings) error + GetUserGroups(username string, settings *LDAPSettings) ([]string, error) + SearchGroups(settings *LDAPSettings) ([]LDAPUser, error) + SearchUsers(settings *LDAPSettings) ([]string, error) + } + + // OAuthService represents a service used to authenticate users using OAuth + OAuthService interface { + Authenticate(ctx context.Context, code string, configuration *OAuthSettings) (string, error) + } + + // ReverseTunnelService represents a service used to manage reverse tunnel connections. + ReverseTunnelService interface { + StartTunnelServer(addr, port string, snapshotService SnapshotService) error + StopTunnelServer() error + GenerateEdgeKey(apiURL, tunnelAddr string, endpointIdentifier int) string + Open(endpoint *Endpoint) error + Config(endpointID EndpointID) TunnelDetails + TunnelAddr(endpoint *Endpoint) (string, error) + UpdateLastActivity(endpointID EndpointID) + KeepTunnelAlive(endpointID EndpointID, ctx context.Context, maxKeepAlive time.Duration) + } + + // Server defines the interface to serve the API + Server interface { + Start(ctx context.Context) error + } + + // SnapshotService represents a service for managing environment(endpoint) snapshots + SnapshotService interface { + Start(ctx context.Context) + SetSnapshotInterval(snapshotInterval string) error + SnapshotEndpoint(endpoint *Endpoint) error + FillSnapshotData(endpoint *Endpoint, includeRaw bool) error + } + + // SwarmStackManager represents a service to manage Swarm stacks + SwarmStackManager interface { + Deploy(ctx context.Context, stack *Stack, prune bool, pullImage bool, endpoint *Endpoint, registries []Registry) error + Remove(ctx context.Context, stack *Stack, endpoint *Endpoint) error + NormalizeStackName(name string) string + } +) + +const ( + // APIVersion is the version number of the Portainer API + APIVersion = "2.43.0" + // Support annotation for the API version ("STS" for Short-Term Support or "LTS" for Long-Term Support) + APIVersionSupport = "STS" + // Edition is what this edition of Portainer is called + Edition = PortainerCE + // ComposeSyntaxMaxVersion is a maximum supported version of the docker compose syntax + ComposeSyntaxMaxVersion = "3.9" + // 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" + // ReleasesURL represents the URL used to retrieve all releases of Portainer + ReleasesURL = "https://api.github.com/repos/portainer/portainer/releases" + // VersionCheckURL represents the URL used to retrieve the latest version of Portainer + VersionCheckURL = ReleasesURL + "/latest" + // 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" + // HTTPAlertStateHeaderName is the name of the header used to transmit edge alert evaluation state + HTTPAlertStateHeaderName = "X-PortainerAgent-AlertState" + // HTTPResponseAgentGPUOperator represents the name of the header indicating whether the GPU operator is enabled on the agent + HTTPResponseAgentGPUOperator = "Portainer-Agent-GPU-Operator" + // PortainerAgentSignatureMessage represents the message used to create a digital signature + // to be used when communicating with an agent + PortainerAgentSignatureMessage = "Portainer-App" + // DefaultSnapshotInterval represents the default interval between each environment snapshot job + DefaultSnapshotInterval = "5m" + // DefaultEdgeAgentCheckinIntervalInSeconds represents the default interval (in seconds) used by Edge agents to checkin with the Portainer instance + DefaultEdgeAgentCheckinIntervalInSeconds = 5 + // DefaultTemplatesURL represents the URL to the official templates supported by Portainer + DefaultTemplatesURL = "https://raw.githubusercontent.com/portainer/templates/v3/templates.json" + // DefaultHelmrepositoryURL represents the URL to the official templates supported by Bitnami + DefaultHelmRepositoryURL = "https://charts.bitnami.com/bitnami" + // DefaultUserSessionTimeout represents the default timeout after which the user session is cleared + DefaultUserSessionTimeout = "8h" + // DefaultUserSessionTimeout represents the default timeout after which the user session is cleared + DefaultKubeconfigExpiry = "0" + // DefaultKubectlShellImage represents the default image and tag for the kubectl shell + DefaultKubectlShellImage = "portainer/kubectl-shell:" + APIVersion + // WebSocketKeepAlive web socket keep alive for edge environments + WebSocketKeepAlive = 1 * time.Hour + // AuthCookieName is the name of the cookie used to store the JWT token + AuthCookieKey = "portainer_api_key" + // PortainerCacheHeader is used to enabled FE caching for Kubernetes resources + PortainerCacheHeader = "X-Portainer-Cache" + // KubectlShellImageEnvVar is the environment variable used to override the default kubectl shell image + KubectlShellImageEnvVar = "KUBECTL_SHELL_IMAGE" + // PullLimitCheckDisabledEnvVar is the environment variable used to disable the pull limit check + PullLimitCheckDisabledEnvVar = "PULL_LIMIT_CHECK_DISABLED" + // FeatureFlagEnvVar is the environment variable used to set the list of enabled feature flags + FeatureFlagEnvVar = "FEATURE_FLAG" + // LicenseServerBaseURL represents the base URL of the API used to validate + // an extension license. + LicenseServerBaseURL = "https://api.portainer.io" + // URL to validate licenses along with system metadata. + LicenseCheckInURL = LicenseServerBaseURL + "/licenses/checkin" + // TrustedOriginsEnvVar is the environment variable used to set the trusted origins for CSRF protection + TrustedOriginsEnvVar = "TRUSTED_ORIGINS" + // CSPEnvVar is the environment variable used to enable/disable the Content Security Policy + CSPEnvVar = "CSP" + // CompactDBEnvVar is the environment variable used to enable/disable the startup compaction of the database + CompactDBEnvVar = "COMPACT_DB" + // NoSetupTokenEnvVar is the environment variable used to disable the setup token requirement on an uninitialized instance + NoSetupTokenEnvVar = "PORTAINER_NO_SETUP_TOKEN" + // SetupTokenEnvVar is the environment variable used to provide a custom setup token for admin initialization and restore on an uninitialized instance + SetupTokenEnvVar = "PORTAINER_SETUP_TOKEN" +) + +// List of supported features +var SupportedFeatureFlags = []featureflags.Feature{"hsts", "csp"} + +const ( + _ AuthenticationMethod = iota + // AuthenticationInternal represents the internal authentication method (authentication against Portainer API) + AuthenticationInternal + // AuthenticationLDAP represents the LDAP authentication method (authentication against a LDAP server) + AuthenticationLDAP + // AuthenticationOAuth represents the OAuth authentication method (authentication against a authorization server) + AuthenticationOAuth +) + +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 ( + // EdgeStackDeploymentCompose represent an edge stack deployed using a compose file + EdgeStackDeploymentCompose EdgeStackDeploymentType = iota + // EdgeStackDeploymentKubernetes represent an edge stack deployed using a kubernetes manifest file + EdgeStackDeploymentKubernetes +) + +const ( + // EdgeStackStatusPending represents a pending edge stack + EdgeStackStatusPending EdgeStackStatusType = iota + // EdgeStackStatusDeploymentReceived represents an edge environment which received the edge stack deployment + EdgeStackStatusDeploymentReceived + // EdgeStackStatusError represents an edge environment which failed to deploy its edge stack + EdgeStackStatusError + // EdgeStackStatusAcknowledged represents an acknowledged edge stack + EdgeStackStatusAcknowledged + // EdgeStackStatusRemoved represents a removed edge stack + EdgeStackStatusRemoved + // StatusRemoteUpdateSuccess represents a successfully updated edge stack + EdgeStackStatusRemoteUpdateSuccess + // EdgeStackStatusImagesPulled represents a successfully images-pulling + EdgeStackStatusImagesPulled + // EdgeStackStatusRunning represents a running Edge stack + EdgeStackStatusRunning + // EdgeStackStatusDeploying represents an Edge stack which is being deployed + EdgeStackStatusDeploying + // EdgeStackStatusRemoving represents an Edge stack which is being removed + EdgeStackStatusRemoving + // EdgeStackStatusPausedDeploying represents a paused Edge stack + EdgeStackStatusPausedDeploying + // EdgeStackStatusRollingBack represents an Edge stack which is being rolled back + EdgeStackStatusRollingBack + // EdgeStackStatusRolledBack represents an Edge stack which has rolled back + EdgeStackStatusRolledBack + // EdgeStackStatusCompleted represents a completed Edge stack + EdgeStackStatusCompleted +) + +var edgeStackStatusTypeStr = map[EdgeStackStatusType]string{ + EdgeStackStatusPending: "Pending", + EdgeStackStatusDeploymentReceived: "DeploymentReceived", + EdgeStackStatusError: "Error", + EdgeStackStatusAcknowledged: "Acknowledged", + EdgeStackStatusRemoved: "Removed", + EdgeStackStatusRemoteUpdateSuccess: "RemoteUpdateSuccess", + EdgeStackStatusImagesPulled: "ImagesPulled", + EdgeStackStatusRunning: "Running", + EdgeStackStatusDeploying: "Deploying", + EdgeStackStatusRemoving: "Removing", + EdgeStackStatusPausedDeploying: "PausedDeploying", + EdgeStackStatusRollingBack: "RollingBack", + EdgeStackStatusRolledBack: "RolledBack", + EdgeStackStatusCompleted: "Completed", +} + +func (s EdgeStackStatusType) String() string { + if str, ok := edgeStackStatusTypeStr[s]; ok { + return fmt.Sprintf("%d (%s)", s, str) + } + return fmt.Sprintf("%d (UNKNOWN)", s) +} + +const ( + _ EndpointStatus = iota + // EndpointStatusUp is used to represent an available environment(endpoint) + EndpointStatusUp + // EndpointStatusDown is used to represent an unavailable environment(endpoint) + EndpointStatusDown +) + +const ( + _ EndpointType = iota + // DockerEnvironment represents an environment(endpoint) connected to a Docker environment(endpoint) via the Docker API or Socket + DockerEnvironment + // AgentOnDockerEnvironment represents an environment(endpoint) connected to a Portainer agent deployed on a Docker environment(endpoint) + AgentOnDockerEnvironment + // AzureEnvironment represents an environment(endpoint) connected to an Azure environment(endpoint) + AzureEnvironment + // EdgeAgentOnDockerEnvironment represents an environment(endpoint) connected to an Edge agent deployed on a Docker environment(endpoint) + EdgeAgentOnDockerEnvironment + // KubernetesLocalEnvironment represents an environment(endpoint) connected to a local Kubernetes environment(endpoint) + KubernetesLocalEnvironment + // AgentOnKubernetesEnvironment represents an environment(endpoint) connected to a Portainer agent deployed on a Kubernetes environment(endpoint) + AgentOnKubernetesEnvironment + // EdgeAgentOnKubernetesEnvironment represents an environment(endpoint) connected to an Edge agent deployed on a Kubernetes environment(endpoint) + EdgeAgentOnKubernetesEnvironment +) + +const ( + DockerPlatformType PlatformType = iota + KubernetesPlatformType + AzurePlatformType + PodmanPlatformType + UnknownPlatformType +) + +const ( + _ JobType = iota + // SnapshotJobType is a system job used to create environment(endpoint) snapshots + SnapshotJobType = 2 +) + +const ( + _ MembershipRole = iota + // TeamLeader represents a leader role inside a team + TeamLeader + // TeamMember represents a member role inside a team + TeamMember +) + +const ( + _ SoftwareEdition = iota + // PortainerCE represents the community edition of Portainer + PortainerCE + // PortainerBE represents the business edition of Portainer + PortainerBE + // PortainerEE represents the business edition of Portainer + PortainerEE +) + +const ( + _ SourceType = iota + SourceTypeGit + SourceTypeRegistry + SourceTypeHelm +) + +const ( + // SourceStatusUnknown means the check has not been performed yet + SourceStatusUnknown SourceStatus = iota + // SourceStatusHealthy means the last check succeeded + SourceStatusHealthy + // SourceStatusError means the last check failed + SourceStatusError +) + +const ( + _ RegistryType = iota + // QuayRegistry represents a Quay.io registry + QuayRegistry + // AzureRegistry represents an ACR registry + AzureRegistry + // CustomRegistry represents a custom registry + CustomRegistry + // GitlabRegistry represents a gitlab registry + GitlabRegistry + // ProGetRegistry represents a proget registry + ProGetRegistry + // DockerHubRegistry represents a dockerhub registry + DockerHubRegistry + // EcrRegistry represents an ECR registry + EcrRegistry + // Github container registry + GithubRegistry +) + +const ( + _ ResourceAccessLevel = iota + // ReadWriteAccessLevel represents an access level with read-write permissions on a resource + ReadWriteAccessLevel +) + +const ( + _ ResourceControlType = iota + // ContainerResourceControl represents a resource control associated to a Docker container + ContainerResourceControl + // ServiceResourceControl represents a resource control associated to a Docker service + ServiceResourceControl + // VolumeResourceControl represents a resource control associated to a Docker volume + VolumeResourceControl + // NetworkResourceControl represents a resource control associated to a Docker network + NetworkResourceControl + // SecretResourceControl represents a resource control associated to a Docker secret + SecretResourceControl + // StackResourceControl represents a resource control associated to a stack composed of Docker services + StackResourceControl + // ConfigResourceControl represents a resource control associated to a Docker config + ConfigResourceControl + // CustomTemplateResourceControl represents a resource control associated to a custom template + CustomTemplateResourceControl + // ContainerGroupResourceControl represents a resource control associated to an Azure container group + ContainerGroupResourceControl +) + +const ( + _ StackType = iota + // DockerSwarmStack represents a stack managed via docker stack + 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 // 1 - deployed and running + StackStatusInactive // 2 - intentionally stopped + StackStatusDeploying // 3 - deployment in progress + StackStatusError // 4 - deployment failed +) + +const ( + _ TemplateType = iota + // ContainerTemplate represents a container template + ContainerTemplate + // SwarmStackTemplate represents a template used to deploy a Swarm stack + SwarmStackTemplate + // ComposeStackTemplate represents a template used to deploy a Compose stack + ComposeStackTemplate +) + +const ( + // TLSFileCA represents a TLS CA certificate file + TLSFileCA TLSFileType = iota + // TLSFileCert represents a TLS certificate file + TLSFileCert + // TLSFileKey represents a TLS key file + TLSFileKey +) + +const ( + _ UserRole = iota + // AdministratorRole represents an administrator user role + AdministratorRole + // StandardUserRole represents a regular user role + StandardUserRole +) + +const ( + _ WebhookType = iota + // ServiceWebhook is a webhook for restarting a docker service + ServiceWebhook +) + +const ( + // EdgeAgentIdle represents an idle state for a tunnel connected to an Edge environment(endpoint). + EdgeAgentIdle string = "IDLE" + // EdgeAgentManagementRequired represents a required state for a tunnel connected to an Edge environment(endpoint) + EdgeAgentManagementRequired string = "REQUIRED" +) + +// represents an authorization type +const ( + OperationDockerContainerArchiveInfo Authorization = "DockerContainerArchiveInfo" + OperationDockerContainerList Authorization = "DockerContainerList" + OperationDockerContainerExport Authorization = "DockerContainerExport" + OperationDockerContainerChanges Authorization = "DockerContainerChanges" + OperationDockerContainerInspect Authorization = "DockerContainerInspect" + OperationDockerContainerTop Authorization = "DockerContainerTop" + OperationDockerContainerLogs Authorization = "DockerContainerLogs" + OperationDockerContainerStats Authorization = "DockerContainerStats" + OperationDockerContainerAttachWebsocket Authorization = "DockerContainerAttachWebsocket" + OperationDockerContainerArchive Authorization = "DockerContainerArchive" + OperationDockerContainerCreate Authorization = "DockerContainerCreate" + OperationDockerContainerPrune Authorization = "DockerContainerPrune" + OperationDockerContainerKill Authorization = "DockerContainerKill" + OperationDockerContainerPause Authorization = "DockerContainerPause" + OperationDockerContainerUnpause Authorization = "DockerContainerUnpause" + OperationDockerContainerRestart Authorization = "DockerContainerRestart" + OperationDockerContainerStart Authorization = "DockerContainerStart" + OperationDockerContainerStop Authorization = "DockerContainerStop" + OperationDockerContainerWait Authorization = "DockerContainerWait" + OperationDockerContainerResize Authorization = "DockerContainerResize" + OperationDockerContainerAttach Authorization = "DockerContainerAttach" + OperationDockerContainerExec Authorization = "DockerContainerExec" + OperationDockerContainerRename Authorization = "DockerContainerRename" + OperationDockerContainerUpdate Authorization = "DockerContainerUpdate" + OperationDockerContainerPutContainerArchive Authorization = "DockerContainerPutContainerArchive" + OperationDockerContainerDelete Authorization = "DockerContainerDelete" + OperationDockerImageList Authorization = "DockerImageList" + OperationDockerImageSearch Authorization = "DockerImageSearch" + OperationDockerImageGetAll Authorization = "DockerImageGetAll" + OperationDockerImageGet Authorization = "DockerImageGet" + OperationDockerImageHistory Authorization = "DockerImageHistory" + OperationDockerImageInspect Authorization = "DockerImageInspect" + OperationDockerImageLoad Authorization = "DockerImageLoad" + OperationDockerImageCreate Authorization = "DockerImageCreate" + OperationDockerImagePrune Authorization = "DockerImagePrune" + OperationDockerImagePush Authorization = "DockerImagePush" + OperationDockerImageTag Authorization = "DockerImageTag" + OperationDockerImageDelete Authorization = "DockerImageDelete" + OperationDockerImageCommit Authorization = "DockerImageCommit" + OperationDockerImageBuild Authorization = "DockerImageBuild" + OperationDockerNetworkList Authorization = "DockerNetworkList" + OperationDockerNetworkInspect Authorization = "DockerNetworkInspect" + OperationDockerNetworkCreate Authorization = "DockerNetworkCreate" + OperationDockerNetworkConnect Authorization = "DockerNetworkConnect" + OperationDockerNetworkDisconnect Authorization = "DockerNetworkDisconnect" + OperationDockerNetworkPrune Authorization = "DockerNetworkPrune" + OperationDockerNetworkDelete Authorization = "DockerNetworkDelete" + OperationDockerVolumeList Authorization = "DockerVolumeList" + OperationDockerVolumeInspect Authorization = "DockerVolumeInspect" + OperationDockerVolumeCreate Authorization = "DockerVolumeCreate" + OperationDockerVolumePrune Authorization = "DockerVolumePrune" + OperationDockerVolumeDelete Authorization = "DockerVolumeDelete" + OperationDockerExecInspect Authorization = "DockerExecInspect" + OperationDockerExecStart Authorization = "DockerExecStart" + OperationDockerExecResize Authorization = "DockerExecResize" + OperationDockerSwarmInspect Authorization = "DockerSwarmInspect" + OperationDockerSwarmUnlockKey Authorization = "DockerSwarmUnlockKey" + OperationDockerSwarmInit Authorization = "DockerSwarmInit" + OperationDockerSwarmJoin Authorization = "DockerSwarmJoin" + OperationDockerSwarmLeave Authorization = "DockerSwarmLeave" + OperationDockerSwarmUpdate Authorization = "DockerSwarmUpdate" + OperationDockerSwarmUnlock Authorization = "DockerSwarmUnlock" + OperationDockerNodeList Authorization = "DockerNodeList" + OperationDockerNodeInspect Authorization = "DockerNodeInspect" + OperationDockerNodeUpdate Authorization = "DockerNodeUpdate" + OperationDockerNodeDelete Authorization = "DockerNodeDelete" + OperationDockerServiceList Authorization = "DockerServiceList" + OperationDockerServiceInspect Authorization = "DockerServiceInspect" + OperationDockerServiceLogs Authorization = "DockerServiceLogs" + OperationDockerServiceCreate Authorization = "DockerServiceCreate" + OperationDockerServiceUpdate Authorization = "DockerServiceUpdate" + OperationDockerServiceDelete Authorization = "DockerServiceDelete" + OperationDockerSecretList Authorization = "DockerSecretList" + OperationDockerSecretInspect Authorization = "DockerSecretInspect" + OperationDockerSecretCreate Authorization = "DockerSecretCreate" + OperationDockerSecretUpdate Authorization = "DockerSecretUpdate" + OperationDockerSecretDelete Authorization = "DockerSecretDelete" + OperationDockerConfigList Authorization = "DockerConfigList" + OperationDockerConfigInspect Authorization = "DockerConfigInspect" + OperationDockerConfigCreate Authorization = "DockerConfigCreate" + OperationDockerConfigUpdate Authorization = "DockerConfigUpdate" + OperationDockerConfigDelete Authorization = "DockerConfigDelete" + OperationDockerTaskList Authorization = "DockerTaskList" + OperationDockerTaskInspect Authorization = "DockerTaskInspect" + OperationDockerTaskLogs Authorization = "DockerTaskLogs" + OperationDockerPluginList Authorization = "DockerPluginList" + OperationDockerPluginPrivileges Authorization = "DockerPluginPrivileges" + OperationDockerPluginInspect Authorization = "DockerPluginInspect" + OperationDockerPluginPull Authorization = "DockerPluginPull" + OperationDockerPluginCreate Authorization = "DockerPluginCreate" + OperationDockerPluginEnable Authorization = "DockerPluginEnable" + OperationDockerPluginDisable Authorization = "DockerPluginDisable" + OperationDockerPluginPush Authorization = "DockerPluginPush" + OperationDockerPluginUpgrade Authorization = "DockerPluginUpgrade" + OperationDockerPluginSet Authorization = "DockerPluginSet" + OperationDockerPluginDelete Authorization = "DockerPluginDelete" + OperationDockerSessionStart Authorization = "DockerSessionStart" + OperationDockerDistributionInspect Authorization = "DockerDistributionInspect" + OperationDockerBuildPrune Authorization = "DockerBuildPrune" + OperationDockerBuildCancel Authorization = "DockerBuildCancel" + OperationDockerPing Authorization = "DockerPing" + OperationDockerInfo Authorization = "DockerInfo" + OperationDockerEvents Authorization = "DockerEvents" + OperationDockerSystem Authorization = "DockerSystem" + OperationDockerVersion Authorization = "DockerVersion" + + OperationDockerAgentPing Authorization = "DockerAgentPing" + OperationDockerAgentList Authorization = "DockerAgentList" + OperationDockerAgentHostInfo Authorization = "DockerAgentHostInfo" + OperationDockerAgentBrowseDelete Authorization = "DockerAgentBrowseDelete" + OperationDockerAgentBrowseGet Authorization = "DockerAgentBrowseGet" + OperationDockerAgentBrowseList Authorization = "DockerAgentBrowseList" + OperationDockerAgentBrowsePut Authorization = "DockerAgentBrowsePut" + OperationDockerAgentBrowseRename Authorization = "DockerAgentBrowseRename" + + OperationPortainerDockerHubInspect Authorization = "PortainerDockerHubInspect" + OperationPortainerDockerHubUpdate Authorization = "PortainerDockerHubUpdate" + OperationPortainerEndpointGroupCreate Authorization = "PortainerEndpointGroupCreate" + OperationPortainerEndpointGroupList Authorization = "PortainerEndpointGroupList" + OperationPortainerEndpointGroupDelete Authorization = "PortainerEndpointGroupDelete" + OperationPortainerEndpointGroupInspect Authorization = "PortainerEndpointGroupInspect" + OperationPortainerEndpointGroupUpdate Authorization = "PortainerEndpointGroupEdit" + OperationPortainerEndpointGroupAccess Authorization = "PortainerEndpointGroupAccess " + OperationPortainerEndpointList Authorization = "PortainerEndpointList" + OperationPortainerEndpointInspect Authorization = "PortainerEndpointInspect" + OperationPortainerEndpointCreate Authorization = "PortainerEndpointCreate" + OperationPortainerEndpointJob Authorization = "PortainerEndpointJob" + OperationPortainerEndpointSnapshots Authorization = "PortainerEndpointSnapshots" + OperationPortainerEndpointSnapshot Authorization = "PortainerEndpointSnapshot" + OperationPortainerEndpointUpdate Authorization = "PortainerEndpointUpdate" + OperationPortainerEndpointUpdateAccess Authorization = "PortainerEndpointUpdateAccess" + OperationPortainerEndpointDelete Authorization = "PortainerEndpointDelete" + OperationPortainerExtensionList Authorization = "PortainerExtensionList" + OperationPortainerExtensionInspect Authorization = "PortainerExtensionInspect" + OperationPortainerExtensionCreate Authorization = "PortainerExtensionCreate" + OperationPortainerExtensionUpdate Authorization = "PortainerExtensionUpdate" + OperationPortainerExtensionDelete Authorization = "PortainerExtensionDelete" + OperationPortainerMOTD Authorization = "PortainerMOTD" + OperationPortainerRegistryList Authorization = "PortainerRegistryList" + OperationPortainerRegistryInspect Authorization = "PortainerRegistryInspect" + OperationPortainerRegistryCreate Authorization = "PortainerRegistryCreate" + OperationPortainerRegistryConfigure Authorization = "PortainerRegistryConfigure" + OperationPortainerRegistryUpdate Authorization = "PortainerRegistryUpdate" + OperationPortainerRegistryUpdateAccess Authorization = "PortainerRegistryUpdateAccess" + OperationPortainerRegistryDelete Authorization = "PortainerRegistryDelete" + OperationPortainerResourceControlCreate Authorization = "PortainerResourceControlCreate" + OperationPortainerResourceControlUpdate Authorization = "PortainerResourceControlUpdate" + OperationPortainerResourceControlDelete Authorization = "PortainerResourceControlDelete" + OperationPortainerRoleList Authorization = "PortainerRoleList" + OperationPortainerRoleInspect Authorization = "PortainerRoleInspect" + OperationPortainerRoleCreate Authorization = "PortainerRoleCreate" + OperationPortainerRoleUpdate Authorization = "PortainerRoleUpdate" + OperationPortainerRoleDelete Authorization = "PortainerRoleDelete" + OperationPortainerScheduleList Authorization = "PortainerScheduleList" + OperationPortainerScheduleInspect Authorization = "PortainerScheduleInspect" + OperationPortainerScheduleFile Authorization = "PortainerScheduleFile" + OperationPortainerScheduleTasks Authorization = "PortainerScheduleTasks" + OperationPortainerScheduleCreate Authorization = "PortainerScheduleCreate" + OperationPortainerScheduleUpdate Authorization = "PortainerScheduleUpdate" + OperationPortainerScheduleDelete Authorization = "PortainerScheduleDelete" + OperationPortainerSettingsInspect Authorization = "PortainerSettingsInspect" + OperationPortainerSettingsUpdate Authorization = "PortainerSettingsUpdate" + OperationPortainerSettingsLDAPCheck Authorization = "PortainerSettingsLDAPCheck" + OperationPortainerStackList Authorization = "PortainerStackList" + OperationPortainerStackInspect Authorization = "PortainerStackInspect" + OperationPortainerStackFile Authorization = "PortainerStackFile" + OperationPortainerStackCreate Authorization = "PortainerStackCreate" + OperationPortainerStackMigrate Authorization = "PortainerStackMigrate" + OperationPortainerStackUpdate Authorization = "PortainerStackUpdate" + OperationPortainerStackDelete Authorization = "PortainerStackDelete" + OperationPortainerTagList Authorization = "PortainerTagList" + OperationPortainerTagCreate Authorization = "PortainerTagCreate" + OperationPortainerTagDelete Authorization = "PortainerTagDelete" + OperationPortainerTeamMembershipList Authorization = "PortainerTeamMembershipList" + OperationPortainerTeamMembershipCreate Authorization = "PortainerTeamMembershipCreate" + OperationPortainerTeamMembershipUpdate Authorization = "PortainerTeamMembershipUpdate" + OperationPortainerTeamMembershipDelete Authorization = "PortainerTeamMembershipDelete" + OperationPortainerTeamList Authorization = "PortainerTeamList" + OperationPortainerTeamInspect Authorization = "PortainerTeamInspect" + OperationPortainerTeamMemberships Authorization = "PortainerTeamMemberships" + OperationPortainerTeamCreate Authorization = "PortainerTeamCreate" + OperationPortainerTeamUpdate Authorization = "PortainerTeamUpdate" + OperationPortainerTeamDelete Authorization = "PortainerTeamDelete" + OperationPortainerTemplateList Authorization = "PortainerTemplateList" + OperationPortainerTemplateInspect Authorization = "PortainerTemplateInspect" + OperationPortainerTemplateCreate Authorization = "PortainerTemplateCreate" + OperationPortainerTemplateUpdate Authorization = "PortainerTemplateUpdate" + OperationPortainerTemplateDelete Authorization = "PortainerTemplateDelete" + OperationPortainerUploadTLS Authorization = "PortainerUploadTLS" + OperationPortainerUserList Authorization = "PortainerUserList" + OperationPortainerUserInspect Authorization = "PortainerUserInspect" + OperationPortainerUserMemberships Authorization = "PortainerUserMemberships" + OperationPortainerUserCreate Authorization = "PortainerUserCreate" + OperationPortainerUserListToken Authorization = "PortainerUserListToken" + OperationPortainerUserCreateToken Authorization = "PortainerUserCreateToken" + OperationPortainerUserRevokeToken Authorization = "PortainerUserRevokeToken" + OperationPortainerUserUpdate Authorization = "PortainerUserUpdate" + OperationPortainerUserUpdatePassword Authorization = "PortainerUserUpdatePassword" + OperationPortainerUserDelete Authorization = "PortainerUserDelete" + OperationPortainerWebsocketExec Authorization = "PortainerWebsocketExec" + OperationPortainerWebhookList Authorization = "PortainerWebhookList" + OperationPortainerWebhookCreate Authorization = "PortainerWebhookCreate" + OperationPortainerWebhookDelete Authorization = "PortainerWebhookDelete" + + OperationDockerUndefined Authorization = "DockerUndefined" + OperationDockerAgentUndefined Authorization = "DockerAgentUndefined" + OperationPortainerUndefined Authorization = "PortainerUndefined" + + EndpointResourcesAccess Authorization = "EndpointResourcesAccess" + + // Deprecated operations + OperationPortainerEndpointExtensionAdd Authorization = "PortainerEndpointExtensionAdd" + OperationPortainerEndpointExtensionRemove Authorization = "PortainerEndpointExtensionRemove" + OperationIntegrationStoridgeAdmin Authorization = "IntegrationStoridgeAdmin" +) + +// GetEditionLabel returns the portainer edition label +func (e SoftwareEdition) GetEditionLabel() string { + switch e { + case PortainerCE: + return "CE" + case PortainerBE: + return "BE" + case PortainerEE: + return "EE" + } + + return "CE" +} + +const ( + AzurePathContainerGroups = "/subscriptions/*/providers/Microsoft.ContainerInstance/containerGroups" + AzurePathContainerGroup = "/subscriptions/*/resourceGroups/*/providers/Microsoft.ContainerInstance/containerGroups/*" +) + +type PerDevConfigsFilterType string + +const ( + PerDevConfigsTypeFile PerDevConfigsFilterType = "file" + PerDevConfigsTypeDir PerDevConfigsFilterType = "dir" +) + +const ( + ContainerEngineDocker = "docker" + ContainerEnginePodman = "podman" +) + +const ( + // PolicyType constants + RbacK8s PolicyType = "rbac-k8s" + SecurityK8s PolicyType = "security-k8s" + SetupK8s PolicyType = "setup-k8s" + RegistryK8s PolicyType = "registry-k8s" + RbacDocker PolicyType = "rbac-docker" + SecurityDocker PolicyType = "security-docker" + SetupDocker PolicyType = "setup-docker" + RegistryDocker PolicyType = "registry-docker" + ChangeConfirmation PolicyType = "change-confirmation" + CleanupDocker PolicyType = "cleanup-docker" + ObservabilityK8s PolicyType = "observability-k8s" + PodSecurityStandardsK8s PolicyType = "pod-security-standards-k8s" + NetworkSecurityK8s PolicyType = "network-security-k8s" +) + +type HelmInstallStatus string + +const ( + HelmInstallStatusInstalling HelmInstallStatus = "installing" + HelmInstallStatusInstalled HelmInstallStatus = "installed" + HelmInstallStatusFailed HelmInstallStatus = "failed" + HelmInstallStatusUninstalling HelmInstallStatus = "uninstalling" + HelmInstallStatusConflict HelmInstallStatus = "conflict" +) + +func DefaultEndpointSecuritySettings() EndpointSecuritySettings { + return EndpointSecuritySettings{ + AllowBindMountsForRegularUsers: false, + AllowContainerCapabilitiesForRegularUsers: false, + AllowDeviceMappingForRegularUsers: false, + AllowHostNamespaceForRegularUsers: false, + AllowPrivilegedModeForRegularUsers: false, + AllowSysctlSettingForRegularUsers: false, + AllowSecurityOptForRegularUsers: false, + AllowVolumeBrowserForRegularUsers: false, + EnableHostManagementFeatures: false, + + AllowStackManagementForRegularUsers: true, + } +} + +type AllowListKey int + +const ( + AllowListSSRF AllowListKey = iota +) + +type SSRFMode int + +const ( + SSRFModeOff SSRFMode = iota + SSRFModeAudit + SSRFModeEnforce +) diff --git a/api/roar/roar.go b/api/roar/roar.go new file mode 100644 index 0000000..6edc67f --- /dev/null +++ b/api/roar/roar.go @@ -0,0 +1,145 @@ +package roar + +import ( + "fmt" + + "github.com/RoaringBitmap/roaring/v2" +) + +type Roar[T ~int] struct { + rb *roaring.Bitmap +} + +// Iterate iterates over the bitmap, calling the given callback with each value in the bitmap. If the callback returns +// false, the iteration is halted. +// The iteration results are undefined if the bitmap is modified (e.g., with Add or Remove). +// There is no guarantee as to what order the values will be iterated. +func (r *Roar[T]) Iterate(f func(T) bool) { + if r.rb == nil { + return + } + + r.rb.Iterate(func(e uint32) bool { + return f(T(e)) + }) +} + +// Len returns the number of elements contained in the bitmap +func (r *Roar[T]) Len() int { + if r.rb == nil { + return 0 + } + + return int(r.rb.GetCardinality()) +} + +// Remove removes the given element from the bitmap +func (r *Roar[T]) Remove(e T) { + if r.rb == nil { + return + } + + r.rb.Remove(uint32(e)) +} + +// Add adds the given element to the bitmap +func (r *Roar[T]) Add(e T) { + if r.rb == nil { + r.rb = roaring.New() + } + + r.rb.AddInt(int(e)) +} + +// Contains returns whether the bitmap contains the given element or not +func (r *Roar[T]) Contains(e T) bool { + if r.rb == nil { + return false + } + + return r.rb.ContainsInt(int(e)) +} + +// Union combines the elements of the given bitmap with this bitmap +func (r *Roar[T]) Union(other Roar[T]) { + if other.rb == nil { + return + } else if r.rb == nil { + r.rb = roaring.New() + } + + r.rb.Or(other.rb) +} + +// Intersection modifies this bitmap to only contain elements that are also in the other bitmap +func (r *Roar[T]) Intersection(other Roar[T]) { + if other.rb == nil { + if r.rb != nil { + r.rb.Clear() + } + + return + } + + if r.rb == nil { + r.rb = roaring.New() + } + + r.rb.And(other.rb) +} + +// ToSlice converts the bitmap to a slice of elements +func (r *Roar[T]) ToSlice() []T { + if r.rb == nil { + return make([]T, 0) + } + + slice := make([]T, 0, r.rb.GetCardinality()) + r.rb.Iterate(func(e uint32) bool { + slice = append(slice, T(e)) + + return true + }) + + return slice +} + +func (r *Roar[T]) MarshalJSON() ([]byte, error) { + if r.rb == nil { + return []byte("null"), nil + } + + r.rb.RunOptimize() + + buf, err := r.rb.ToBase64() + if err != nil { + return nil, fmt.Errorf("failed to encode roaring bitmap: %w", err) + } + + return fmt.Appendf(nil, `"%s"`, buf), nil +} + +func (r *Roar[T]) UnmarshalJSON(data []byte) error { + if len(data) == 0 || string(data) == "null" { + return nil + } + + r.rb = roaring.New() + + _, err := r.rb.FromBase64(string(data[1 : len(data)-1])) + + return err +} + +// FromSlice creates a Roar by adding all elements from the provided slices +func FromSlice[T ~int](ess ...[]T) Roar[T] { + var r Roar[T] + + for _, es := range ess { + for _, e := range es { + r.Add(e) + } + } + + return r +} diff --git a/api/roar/roar_test.go b/api/roar/roar_test.go new file mode 100644 index 0000000..3f2ed73 --- /dev/null +++ b/api/roar/roar_test.go @@ -0,0 +1,126 @@ +package roar + +import ( + "slices" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestRoar(t *testing.T) { + t.Parallel() + r := Roar[int]{} + require.Equal(t, 0, r.Len()) + + r.Add(1) + require.Equal(t, 1, r.Len()) + require.True(t, r.Contains(1)) + require.False(t, r.Contains(2)) + + r.Add(2) + require.Equal(t, 2, r.Len()) + require.True(t, r.Contains(2)) + + r.Remove(1) + require.Equal(t, 1, r.Len()) + require.False(t, r.Contains(1)) + + s := FromSlice([]int{3, 4, 5}) + require.Equal(t, 3, s.Len()) + require.True(t, s.Contains(3)) + require.True(t, s.Contains(4)) + require.True(t, s.Contains(5)) + + r.Union(s) + require.Equal(t, 4, r.Len()) + require.True(t, r.Contains(2)) + require.True(t, r.Contains(3)) + require.True(t, r.Contains(4)) + require.True(t, r.Contains(5)) + + r.Iterate(func(id int) bool { + require.True(t, slices.Contains([]int{2, 3, 4, 5}, id)) + + return true + }) + + rSlice := r.ToSlice() + require.Equal(t, []int{2, 3, 4, 5}, rSlice) + + r.Intersection(FromSlice([]int{4})) + require.Equal(t, 1, r.Len()) + require.True(t, r.Contains(4)) + require.False(t, r.Contains(2)) + require.False(t, r.Contains(3)) + require.False(t, r.Contains(5)) + + b, err := r.MarshalJSON() + require.NoError(t, err) + require.NotEqual(t, "null", string(b)) + require.True(t, strings.HasPrefix(string(b), `"`)) + require.True(t, strings.HasSuffix(string(b), `"`)) +} + +func TestNilSafety(t *testing.T) { + t.Parallel() + var r, s, u Roar[int] + + r.Iterate(func(id int) bool { + require.Fail(t, "should not iterate over nil Roar") + + return true + }) + + b, err := r.MarshalJSON() + require.NoError(t, err) + require.Equal(t, "null", string(b)) + + err = r.UnmarshalJSON([]byte("null")) + require.NoError(t, err) + require.Equal(t, 0, r.Len()) + + r.Contains(1) + r.Remove(1) + + require.Equal(t, 0, r.Len()) + require.Empty(t, r.ToSlice()) + + r.Add(1) + require.Equal(t, 1, r.Len()) + require.False(t, r.Contains(2)) + + s.Union(r) + require.Equal(t, 1, s.Len()) + require.True(t, s.Contains(1)) + + r.Union(u) + require.Equal(t, 1, r.Len()) + require.True(t, r.Contains(1)) + + s.Intersection(u) + require.Equal(t, 0, s.Len()) + + u.Intersection(r) + require.Equal(t, 0, u.Len()) +} + +func TestJSON(t *testing.T) { + t.Parallel() + var r, u Roar[int] + + r.Add(1) + r.Add(2) + r.Add(3) + + b, err := r.MarshalJSON() + require.NoError(t, err) + require.NotEqual(t, "null", string(b)) + + err = u.UnmarshalJSON(b) + require.NoError(t, err) + require.Equal(t, 3, u.Len()) + require.True(t, u.Contains(1)) + require.True(t, u.Contains(2)) + require.True(t, u.Contains(3)) +} diff --git a/api/scheduler/scheduler.go b/api/scheduler/scheduler.go new file mode 100644 index 0000000..9565f0e --- /dev/null +++ b/api/scheduler/scheduler.go @@ -0,0 +1,141 @@ +package scheduler + +import ( + "context" + "strconv" + "sync" + "time" + + "github.com/pkg/errors" + "github.com/robfig/cron/v3" + "github.com/rs/zerolog/log" +) + +type Scheduler struct { + crontab *cron.Cron + activeJobs map[cron.EntryID]context.CancelFunc + mu sync.Mutex +} + +type PermanentError struct { + err error +} + +func NewPermanentError(err error) *PermanentError { + return &PermanentError{err: err} +} + +func (e *PermanentError) Error() string { + return e.err.Error() +} + +func NewScheduler(ctx context.Context) *Scheduler { + crontab := cron.New(cron.WithChain(cron.Recover(cron.DefaultLogger))) + crontab.Start() + + s := &Scheduler{ + crontab: crontab, + activeJobs: make(map[cron.EntryID]context.CancelFunc), + } + + if ctx != nil { + go func() { + <-ctx.Done() + + if err := s.Shutdown(); err != nil { + log.Error().Err(err).Msg("failed to shutdown the scheduler") + } + }() + } + + return s +} + +// Shutdown stops the scheduler and waits for it to stop if it is running; otherwise does nothing. +func (s *Scheduler) Shutdown() error { + if s.crontab == nil { + return nil + } + + log.Debug().Msg("stopping scheduler") + + ctx := s.crontab.Stop() + <-ctx.Done() + + s.mu.Lock() + defer s.mu.Unlock() + + for _, job := range s.crontab.Entries() { + if cancel, ok := s.activeJobs[job.ID]; ok { + cancel() + } + } + + err := ctx.Err() + if errors.Is(err, context.Canceled) { + return nil + } + + return err +} + +// StopJob stops the job from being run in the future +func (s *Scheduler) StopJob(jobID string) error { + id, err := strconv.Atoi(jobID) + if err != nil { + return errors.Wrapf(err, "failed convert jobID %q to int", jobID) + } + + entryID := cron.EntryID(id) + + s.mu.Lock() + defer s.mu.Unlock() + + if cancel, ok := s.activeJobs[entryID]; ok { + cancel() + } + + return nil +} + +// StartJobEvery schedules a new periodic job with a given duration. +// Returns job id that could be used to stop the given job. +// When job run returns an error, that job won't be run again. +func (s *Scheduler) StartJobEvery(duration time.Duration, job func() error) string { + entryID := new(cron.EntryID) + + cancelFn := func() { + log.Debug().Msg("job cancelled, stopping") + s.crontab.Remove(*entryID) + delete(s.activeJobs, *entryID) + } + + jobFn := cron.FuncJob(func() { + err := job() + if err == nil { + return + } + + var permErr *PermanentError + if errors.As(err, &permErr) { + log.Error().Err(permErr).Msg("job returned a permanent error, it will be stopped") + + s.mu.Lock() + defer s.mu.Unlock() + + cancelFn() + + return + } + + log.Error().Err(err).Msg("job returned an error, it will be rescheduled") + }) + + s.mu.Lock() + defer s.mu.Unlock() + + *entryID = s.crontab.Schedule(cron.Every(duration), jobFn) + s.activeJobs[*entryID] = cancelFn + + return strconv.Itoa(int(*entryID)) +} diff --git a/api/scheduler/scheduler_test.go b/api/scheduler/scheduler_test.go new file mode 100644 index 0000000..5f5d020 --- /dev/null +++ b/api/scheduler/scheduler_test.go @@ -0,0 +1,175 @@ +package scheduler + +import ( + "context" + "errors" + "sync/atomic" + "testing" + "testing/synctest" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var jobInterval = time.Second + +func requireNoShutdownErr(t *testing.T, fn func() error) { + require.NoError(t, fn(), "scheduler must be shutdown without error") +} + +func Test_ScheduledJobRuns(t *testing.T) { + t.Parallel() + synctest.Test(t, func(t *testing.T) { + s := NewScheduler(t.Context()) + defer requireNoShutdownErr(t, s.Shutdown) + + ctx, cancel := context.WithTimeout(t.Context(), 2*jobInterval) + + var workDone bool + s.StartJobEvery(jobInterval, func() error { + workDone = true + + cancel() + + return nil + }) + + <-ctx.Done() + assert.True(t, workDone, "value should been set in the job") + }) +} + +func Test_JobCanBeStopped(t *testing.T) { + t.Parallel() + synctest.Test(t, func(t *testing.T) { + s := NewScheduler(t.Context()) + defer requireNoShutdownErr(t, s.Shutdown) + + ctx, cancel := context.WithTimeout(t.Context(), 2*jobInterval) + + var workDone bool + jobID := s.StartJobEvery(jobInterval, func() error { + workDone = true + + cancel() + + return nil + }) + + err := s.StopJob(jobID) + require.NoError(t, err) + + <-ctx.Done() + assert.False(t, workDone, "job shouldn't had a chance to run") + }) +} + +func Test_JobShouldStop_UponPermError(t *testing.T) { + t.Parallel() + synctest.Test(t, func(t *testing.T) { + s := NewScheduler(t.Context()) + defer requireNoShutdownErr(t, s.Shutdown) + + var acc int + + ch := make(chan struct{}) + s.StartJobEvery(jobInterval, func() error { + acc++ + close(ch) + + return NewPermanentError(errors.New("failed")) + }) + + <-time.After(3 * jobInterval) + <-ch + assert.Equal(t, 1, acc, "job stop after the first run because it returns an error") + }) +} + +func Test_JobShouldNotStop_UponError(t *testing.T) { + t.Parallel() + synctest.Test(t, func(t *testing.T) { + s := NewScheduler(t.Context()) + defer requireNoShutdownErr(t, s.Shutdown) + + var acc atomic.Int64 + + ch := make(chan struct{}) + s.StartJobEvery(jobInterval, func() error { + if acc.Add(1) == 2 { + close(ch) + + return NewPermanentError(errors.New("failed")) + } + + return errors.New("non-permanent error") + }) + + <-time.After(3 * jobInterval) + <-ch + assert.Equal(t, int64(2), acc.Load()) + }) +} + +func Test_CanTerminateAllJobs_ByShuttingDownScheduler(t *testing.T) { + t.Parallel() + synctest.Test(t, func(t *testing.T) { + s := NewScheduler(t.Context()) + + ctx, cancel := context.WithTimeout(t.Context(), 2*jobInterval) + + var workDone bool + s.StartJobEvery(jobInterval, func() error { + workDone = true + cancel() + + return nil + }) + + requireNoShutdownErr(t, s.Shutdown) + + <-ctx.Done() + assert.False(t, workDone, "job shouldn't had a chance to run") + }) +} + +func Test_CanTerminateAllJobs_ByCancellingParentContext(t *testing.T) { + t.Parallel() + synctest.Test(t, func(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 2*jobInterval) + s := NewScheduler(ctx) + + var workDone bool + s.StartJobEvery(jobInterval, func() error { + workDone = true + cancel() + + return nil + }) + + cancel() + + <-ctx.Done() + assert.False(t, workDone, "job shouldn't had a chance to run") + }) +} + +func Test_StartJobEvery_Concurrently(t *testing.T) { + t.Parallel() + synctest.Test(t, func(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 2*jobInterval) + s := NewScheduler(ctx) + + f := func() error { + return errors.New("error") + } + + go s.StartJobEvery(jobInterval, f) + s.StartJobEvery(jobInterval, f) + + cancel() + + <-ctx.Done() + }) +} diff --git a/api/set/set.go b/api/set/set.go new file mode 100644 index 0000000..1376842 --- /dev/null +++ b/api/set/set.go @@ -0,0 +1,111 @@ +package set + +type SetKey interface { + ~int | ~string +} + +type Set[T SetKey] map[T]bool + +// Add adds a key to the set. +func (s Set[T]) Add(key T) { + s[key] = true +} + +// Contains returns true if the set contains the key. +func (s Set[T]) Contains(key T) bool { + _, ok := s[key] + + return ok +} + +// Remove removes a key from the set. +func (s Set[T]) Remove(key T) { + delete(s, key) +} + +// Len returns the number of keys in the set. +func (s Set[T]) Len() int { + return len(s) +} + +// IsEmpty returns true if the set is empty. +func (s Set[T]) IsEmpty() bool { + return len(s) == 0 +} + +// Clear removes all keys from the set. +func (s Set[T]) Keys() []T { + keys := make([]T, s.Len()) + + i := 0 + for k := range s { + keys[i] = k + i++ + } + + return keys +} + +// Clear removes all keys from the set. +func (s Set[T]) Copy() Set[T] { + c := make(Set[T]) + + for key := range s { + c.Add(key) + } + + return c +} + +// Difference returns a new set containing the keys that are in the first set but not in the second set. +func (s Set[T]) Difference(second Set[T]) Set[T] { + difference := s.Copy() + + for key := range second { + difference.Remove(key) + } + + return difference +} + +// Union returns a new set containing the keys that are in either set. +func Union[T SetKey](sets ...Set[T]) Set[T] { + union := make(Set[T]) + + for _, set := range sets { + for key := range set { + union.Add(key) + } + } + + return union +} + +// Intersection returns a new set containing the keys that are in all sets. +func Intersection[T SetKey](sets ...Set[T]) Set[T] { + if len(sets) == 0 { + return make(Set[T]) + } + + intersection := sets[0].Copy() + + for _, set := range sets[1:] { + for key := range intersection { + if !set.Contains(key) { + intersection.Remove(key) + } + } + } + + return intersection +} + +// ToSet returns a new set containing the keys. +func ToSet[T SetKey](keys []T) Set[T] { + set := make(Set[T]) + for _, key := range keys { + set.Add(key) + } + + return set +} diff --git a/api/set/set_test.go b/api/set/set_test.go new file mode 100644 index 0000000..e109340 --- /dev/null +++ b/api/set/set_test.go @@ -0,0 +1,203 @@ +package set + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestAdd(t *testing.T) { + t.Parallel() + + s := make(Set[int]) + s.Add(1) + s.Add(2) + s.Add(2) + + require.Equal(t, 2, s.Len()) + require.True(t, s.Contains(1)) + require.True(t, s.Contains(2)) +} + +func TestContains(t *testing.T) { + t.Parallel() + + s := make(Set[string]) + s.Add("hello") + + require.True(t, s.Contains("hello")) + require.False(t, s.Contains("world")) +} + +func TestRemove(t *testing.T) { + t.Parallel() + + s := make(Set[int]) + s.Add(1) + s.Add(2) + s.Remove(1) + s.Remove(99) + + require.Equal(t, 1, s.Len()) + require.False(t, s.Contains(1)) + require.True(t, s.Contains(2)) +} + +func TestIsEmpty(t *testing.T) { + t.Parallel() + + s := make(Set[int]) + require.True(t, s.IsEmpty()) + + s.Add(1) + require.False(t, s.IsEmpty()) + + s.Remove(1) + require.True(t, s.IsEmpty()) +} + +func TestKeys(t *testing.T) { + t.Parallel() + + s := ToSet([]int{1, 2, 3}) + keys := s.Keys() + + require.Len(t, keys, 3) + require.ElementsMatch(t, []int{1, 2, 3}, keys) +} + +func TestCopy(t *testing.T) { + t.Parallel() + + original := ToSet([]string{"a", "b", "c"}) + copied := original.Copy() + + require.Equal(t, original.Len(), copied.Len()) + require.True(t, copied.Contains("a")) + require.True(t, copied.Contains("b")) + require.True(t, copied.Contains("c")) + + copied.Add("d") + require.False(t, original.Contains("d")) + + copied.Remove("a") + require.True(t, original.Contains("a")) +} + +func TestDifference(t *testing.T) { + t.Parallel() + + a := ToSet([]int{1, 2, 3, 4}) + b := ToSet([]int{3, 4, 5}) + + diff := a.Difference(b) + + require.Equal(t, 2, diff.Len()) + require.True(t, diff.Contains(1)) + require.True(t, diff.Contains(2)) + require.False(t, diff.Contains(3)) + require.False(t, diff.Contains(4)) +} + +func TestDifference_EmptySecond(t *testing.T) { + t.Parallel() + + a := ToSet([]int{1, 2, 3}) + b := make(Set[int]) + + diff := a.Difference(b) + + require.Equal(t, 3, diff.Len()) +} + +func TestDifference_EmptyFirst(t *testing.T) { + t.Parallel() + + a := make(Set[int]) + b := ToSet([]int{1, 2, 3}) + + diff := a.Difference(b) + + require.True(t, diff.IsEmpty()) +} + +func TestUnion(t *testing.T) { + t.Parallel() + + a := ToSet([]int{1, 2}) + b := ToSet([]int{2, 3}) + c := ToSet([]int{3, 4}) + + u := Union(a, b, c) + + require.Equal(t, 4, u.Len()) + require.True(t, u.Contains(1)) + require.True(t, u.Contains(2)) + require.True(t, u.Contains(3)) + require.True(t, u.Contains(4)) +} + +func TestUnion_NoSets(t *testing.T) { + t.Parallel() + + u := Union[int]() + require.True(t, u.IsEmpty()) +} + +func TestIntersection(t *testing.T) { + t.Parallel() + + a := ToSet([]int{1, 2, 3}) + b := ToSet([]int{2, 3, 4}) + c := ToSet([]int{3, 4, 5}) + + inter := Intersection(a, b, c) + + require.Equal(t, 1, inter.Len()) + require.True(t, inter.Contains(3)) +} + +func TestIntersection_NoOverlap(t *testing.T) { + t.Parallel() + + a := ToSet([]int{1, 2}) + b := ToSet([]int{3, 4}) + + inter := Intersection(a, b) + + require.True(t, inter.IsEmpty()) +} + +func TestIntersection_NoSets(t *testing.T) { + t.Parallel() + + inter := Intersection[int]() + require.True(t, inter.IsEmpty()) +} + +func TestIntersection_SingleSet(t *testing.T) { + t.Parallel() + + a := ToSet([]int{1, 2, 3}) + inter := Intersection(a) + + require.Equal(t, 3, inter.Len()) +} + +func TestToSet(t *testing.T) { + t.Parallel() + + keys := []string{"x", "y", "x"} + s := ToSet(keys) + + require.Equal(t, 2, s.Len()) + require.True(t, s.Contains("x")) + require.True(t, s.Contains("y")) +} + +func TestToSet_Empty(t *testing.T) { + t.Parallel() + + s := ToSet([]int{}) + require.True(t, s.IsEmpty()) +} diff --git a/api/slicesx/filter.go b/api/slicesx/filter.go new file mode 100644 index 0000000..5f9a2e8 --- /dev/null +++ b/api/slicesx/filter.go @@ -0,0 +1,23 @@ +package slicesx + +import "slices" + +// Iterates over elements of collection, returning an array of all elements predicate returns truthy for. +// +// Note: Unlike `FilterInPlace`, this method returns a new array. +func Filter[T any](input []T, predicate func(T) bool) []T { + result := make([]T, 0) + for i := range input { + if predicate(input[i]) { + result = append(result, input[i]) + } + } + return result +} + +// Filter in place all elements from input that predicate returns truthy for. +// +// Note: Unlike `Filter`, this method mutates input. +func FilterInPlace[T any](input []T, predicate func(T) bool) []T { + return slices.DeleteFunc(input, func(v T) bool { return !predicate(v) }) +} diff --git a/api/slicesx/filter_test.go b/api/slicesx/filter_test.go new file mode 100644 index 0000000..40d4376 --- /dev/null +++ b/api/slicesx/filter_test.go @@ -0,0 +1,95 @@ +package slicesx_test + +import ( + "testing" + + "github.com/portainer/portainer/api/slicesx" + + "github.com/stretchr/testify/require" +) + +func Test_Filter(t *testing.T) { + t.Parallel() + test(t, slicesx.Filter, "Filter even numbers", + []int{1, 2, 3, 4, 5, 6, 7, 8, 9}, + []int{2, 4, 6, 8}, + func(x int) bool { return x%2 == 0 }, + ) + test(t, slicesx.Filter, "Filter odd numbers", + []int{1, 2, 3, 4, 5, 6, 7, 8, 9}, + []int{1, 3, 5, 7, 9}, + func(x int) bool { return x%2 == 1 }, + ) + test(t, slicesx.Filter, "Filter strings starting with 'A'", + []string{"Apple", "Banana", "Avocado", "Grapes", "Apricot"}, + []string{"Apple", "Avocado", "Apricot"}, + func(s string) bool { return s[0] == 'A' }, + ) + test(t, slicesx.Filter, "Filter strings longer than 5 chars", + []string{"Apple", "Banana", "Avocado", "Grapes", "Apricot"}, + []string{"Banana", "Avocado", "Grapes", "Apricot"}, + func(s string) bool { return len(s) > 5 }, + ) +} + +func Test_Retain(t *testing.T) { + t.Parallel() + test(t, slicesx.FilterInPlace, "Filter even numbers", + []int{1, 2, 3, 4, 5, 6, 7, 8, 9}, + []int{2, 4, 6, 8}, + func(x int) bool { return x%2 == 0 }, + ) + test(t, slicesx.FilterInPlace, "Filter odd numbers", + []int{1, 2, 3, 4, 5, 6, 7, 8, 9}, + []int{1, 3, 5, 7, 9}, + func(x int) bool { return x%2 == 1 }, + ) + test(t, slicesx.FilterInPlace, "Filter strings starting with 'A'", + []string{"Apple", "Banana", "Avocado", "Grapes", "Apricot"}, + []string{"Apple", "Avocado", "Apricot"}, + func(s string) bool { return s[0] == 'A' }, + ) + test(t, slicesx.FilterInPlace, "Filter strings longer than 5 chars", + []string{"Apple", "Banana", "Avocado", "Grapes", "Apricot"}, + []string{"Banana", "Avocado", "Grapes", "Apricot"}, + func(s string) bool { return len(s) > 5 }, + ) +} + +func Benchmark_Filter(b *testing.B) { + n := 100000 + + source := make([]int, n) + for i := range source { + source[i] = i + } + + for b.Loop() { + e := slicesx.Filter(source, func(x int) bool { return x%2 == 0 }) + require.Len(b, e, n/2) + } +} + +func Benchmark_FilterInPlace(b *testing.B) { + n := 100000 + + source := make([]int, n) + for i := range source { + source[i] = i + } + + // Preallocate all copies before timing + // because FilterInPlace mutates the original slice + copies := make([][]int, b.N) + for i := range b.N { + buf := make([]int, len(source)) + copy(buf, source) + copies[i] = buf + } + + b.ResetTimer() + for i := range b.N { + e := slicesx.FilterInPlace(copies[i], func(x int) bool { return x%2 == 0 }) + require.Len(b, e, n/2) + } +} diff --git a/api/slicesx/flatten.go b/api/slicesx/flatten.go new file mode 100644 index 0000000..56a77f3 --- /dev/null +++ b/api/slicesx/flatten.go @@ -0,0 +1,7 @@ +package slicesx + +import "slices" + +func Flatten[T any](input [][]T) []T { + return slices.Concat(input...) +} diff --git a/api/slicesx/flatten_test.go b/api/slicesx/flatten_test.go new file mode 100644 index 0000000..5a6bca1 --- /dev/null +++ b/api/slicesx/flatten_test.go @@ -0,0 +1,20 @@ +package slicesx_test + +import ( + "testing" + + "github.com/portainer/portainer/api/slicesx" + "github.com/stretchr/testify/assert" +) + +func Test_Flatten(t *testing.T) { + t.Parallel() + t.Run("Flatten an array of arrays", func(t *testing.T) { + is := assert.New(t) + + source := [][]int{{1, 2, 3}, {4, 5, 6}, {7, 8, 9}} + expected := []int{1, 2, 3, 4, 5, 6, 7, 8, 9} + is.ElementsMatch(slicesx.Flatten(source), expected) + + }) +} diff --git a/api/slicesx/group_by.go b/api/slicesx/group_by.go new file mode 100644 index 0000000..7c87c2e --- /dev/null +++ b/api/slicesx/group_by.go @@ -0,0 +1,10 @@ +package slicesx + +func GroupBy[A any, K comparable](input []A, f func(A) K) map[K][]A { + result := make(map[K][]A) + for _, v := range input { + key := f(v) + result[key] = append(result[key], v) + } + return result +} diff --git a/api/slicesx/group_by_test.go b/api/slicesx/group_by_test.go new file mode 100644 index 0000000..27ae322 --- /dev/null +++ b/api/slicesx/group_by_test.go @@ -0,0 +1,21 @@ +package slicesx_test + +import ( + "reflect" + "testing" + + "github.com/portainer/portainer/api/slicesx" +) + +func TestGroupBy(t *testing.T) { + t.Parallel() + input := []string{"apple", "banana", "cherry", "date", "elderberry"} + f := func(a string) int { + return len(a) + } + expected := map[int][]string{5: {"apple"}, 6: {"banana", "cherry"}, 4: {"date"}, 10: {"elderberry"}} + result := slicesx.GroupBy(input, f) + if !reflect.DeepEqual(expected, result) { + t.Errorf("Expected %v, got %v", expected, result) + } +} diff --git a/api/slicesx/includes.go b/api/slicesx/includes.go new file mode 100644 index 0000000..377a542 --- /dev/null +++ b/api/slicesx/includes.go @@ -0,0 +1,17 @@ +package slicesx + +import "slices" + +// Checks if predicate returns truthy for any element of input. Iteration is stopped once predicate returns truthy. +func Some[T any](input []T, predicate func(T) bool) bool { + return slices.ContainsFunc(input, predicate) +} + +// Checks if predicate returns truthy for all elements of input. Iteration is stopped once predicate returns falsey. +// +// Note: This method returns true for empty collections because everything is true of elements of empty collections. +// https://en.wikipedia.org/wiki/Vacuous_truth +func Every[T any](input []T, predicate func(T) bool) bool { + // if the slice doesn't contain an inverted predicate then all items follow the predicate + return !slices.ContainsFunc(input, func(t T) bool { return !predicate(t) }) +} diff --git a/api/slicesx/includes_test.go b/api/slicesx/includes_test.go new file mode 100644 index 0000000..6c36d32 --- /dev/null +++ b/api/slicesx/includes_test.go @@ -0,0 +1,78 @@ +package slicesx_test + +import ( + "testing" + + "github.com/portainer/portainer/api/slicesx" +) + +func Test_Every(t *testing.T) { + t.Parallel() + test(t, slicesx.Every, "All start with an A (ok)", + []string{"Apple", "Avocado", "Apricot"}, + true, + func(s string) bool { return s[0] == 'A' }, + ) + test(t, slicesx.Every, "All start with an A (ko = some don't start with A)", + []string{"Apple", "Avocado", "Banana"}, + false, + func(s string) bool { return s[0] == 'A' }, + ) + test(t, slicesx.Every, "All are under 5 (ok)", + []int{1, 2, 3}, + true, + func(i int) bool { return i < 5 }, + ) + test(t, slicesx.Every, "All are under 5 (ko = some above 10)", + []int{1, 2, 10}, + false, + func(i int) bool { return i < 5 }, + ) + test(t, slicesx.Every, "All are true (ok)", + []struct{ x bool }{{x: true}, {x: true}, {x: true}}, + true, + func(s struct{ x bool }) bool { return s.x }) + test(t, slicesx.Every, "All are true (ko = some are false)", + []struct{ x bool }{{x: true}, {x: true}, {x: false}}, + false, + func(s struct{ x bool }) bool { return s.x }) + test(t, slicesx.Every, "Must be true on empty slice", + []int{}, + true, + func(i int) bool { return i%2 == 0 }, + ) +} + +func Test_Some(t *testing.T) { + t.Parallel() + test(t, slicesx.Some, "Some start with an A (ok)", + []string{"Apple", "Avocado", "Banana"}, + true, + func(s string) bool { return s[0] == 'A' }, + ) + test(t, slicesx.Some, "Some start with an A (ko = all don't start with A)", + []string{"Banana", "Cherry", "Peach"}, + false, + func(s string) bool { return s[0] == 'A' }, + ) + test(t, slicesx.Some, "Some are under 5 (ok)", + []int{1, 2, 30}, + true, + func(i int) bool { return i < 5 }, + ) + test(t, slicesx.Some, "Some are under 5 (ko = all above 5)", + []int{10, 11, 12}, + false, + func(i int) bool { return i < 5 }, + ) + test(t, slicesx.Some, "Some are true (ok)", + []struct{ x bool }{{x: true}, {x: true}, {x: false}}, + true, + func(s struct{ x bool }) bool { return s.x }, + ) + test(t, slicesx.Some, "Some are true (ko = all are false)", + []struct{ x bool }{{x: false}, {x: false}, {x: false}}, + false, + func(s struct{ x bool }) bool { return s.x }, + ) +} diff --git a/api/slicesx/map.go b/api/slicesx/map.go new file mode 100644 index 0000000..7e24bdd --- /dev/null +++ b/api/slicesx/map.go @@ -0,0 +1,15 @@ +package slicesx + +// Map applies the given function to each element of the slice and returns a new slice with the results +func Map[T, U any](s []T, f func(T) U) []U { + result := make([]U, len(s)) + for i, v := range s { + result[i] = f(v) + } + return result +} + +// FlatMap applies the given function to each element of the slice and returns a new slice with the flattened results +func FlatMap[T, U any](s []T, f func(T) []U) []U { + return Flatten(Map(s, f)) +} diff --git a/api/slicesx/map_test.go b/api/slicesx/map_test.go new file mode 100644 index 0000000..1d44c27 --- /dev/null +++ b/api/slicesx/map_test.go @@ -0,0 +1,45 @@ +package slicesx_test + +import ( + "strconv" + "testing" + + "github.com/portainer/portainer/api/slicesx" +) + +func Test_Map(t *testing.T) { + t.Parallel() + test(t, slicesx.Map, "Map integers to strings", + []int{1, 2, 3, 4, 5}, + []string{"1", "2", "3", "4", "5"}, + strconv.Itoa, + ) + test(t, slicesx.Map, "Map strings to integers", + []string{"1", "2", "3", "4", "5"}, + []int{1, 2, 3, 4, 5}, + func(s string) int { + n, _ := strconv.Atoi(s) + return n + }, + ) +} + +func Test_FlatMap(t *testing.T) { + t.Parallel() + test(t, slicesx.FlatMap, "Map integers to strings and flatten", + []int{1, 2, 3, 4, 5}, + []string{"1", "1", "2", "2", "3", "3", "4", "4", "5", "5"}, + func(i int) []string { + x := strconv.Itoa(i) + return []string{x, x} + }, + ) + test(t, slicesx.FlatMap, "Map strings to integers and flatten", + []string{"1", "2", "3", "4", "5"}, + []int{1, 1, 2, 2, 3, 3, 4, 4, 5, 5}, + func(s string) []int { + n, _ := strconv.Atoi(s) + return []int{n, n} + }, + ) +} diff --git a/api/slicesx/partition.go b/api/slicesx/partition.go new file mode 100644 index 0000000..2ed834b --- /dev/null +++ b/api/slicesx/partition.go @@ -0,0 +1,19 @@ +package slicesx + +// Split elements in two slices. +// The first contains elements predicate returns truthy for +// The second contains elements predicate returns falsey for +// The predicate is invoked with one argument: (value). +func Partition[T any](input []T, predicate func(T) bool) ([]T, []T) { + truthy := make([]T, 0) + falsey := make([]T, 0) + + for i := range input { + if predicate(input[i]) { + truthy = append(truthy, input[i]) + } else { + falsey = append(falsey, input[i]) + } + } + return truthy, falsey +} diff --git a/api/slicesx/partition_test.go b/api/slicesx/partition_test.go new file mode 100644 index 0000000..6e09146 --- /dev/null +++ b/api/slicesx/partition_test.go @@ -0,0 +1,32 @@ +package slicesx_test + +import ( + "testing" + + "github.com/portainer/portainer/api/slicesx" +) + +func partition[T any](input []T, predicate func(T) bool) [2][]T { + left, right := slicesx.Partition(input, predicate) + return [2][]T{left, right} +} + +func Test_Partition(t *testing.T) { + t.Parallel() + + test(t, partition, "Partition even and odd", + []int{1, 2, 3, 4, 5, 6, 7, 8, 9}, + [2][]int{{2, 4, 6, 8}, {1, 3, 5, 7, 9}}, + func(x int) bool { return x%2 == 0 }, + ) + test(t, partition, "Partition strings starting with 'A'", + []string{"Apple", "Banana", "Avocado", "Grapes", "Apricot"}, + [2][]string{{"Apple", "Avocado", "Apricot"}, {"Banana", "Grapes"}}, + func(s string) bool { return s[0] == 'A' }, + ) + test(t, partition, "Partition strings longer than 5 chars", + []string{"Apple", "Banana", "Avocado", "Grapes", "Apricot"}, + [2][]string{{"Banana", "Avocado", "Grapes", "Apricot"}, {"Apple"}}, + func(s string) bool { return len(s) > 5 }, + ) +} diff --git a/api/slicesx/slicesx_test.go b/api/slicesx/slicesx_test.go new file mode 100644 index 0000000..1bb8a76 --- /dev/null +++ b/api/slicesx/slicesx_test.go @@ -0,0 +1,29 @@ +package slicesx_test + +import ( + "reflect" + "testing" + + "github.com/stretchr/testify/assert" +) + +type libFunc[T, U, V any] func([]T, func(T) U) V +type predicateFunc[T, U any] func(T) U + +func test[T, U, V any](t *testing.T, libFn libFunc[T, U, V], name string, input []T, expected V, predicate predicateFunc[T, U]) { + t.Helper() + + t.Run(name, func(t *testing.T) { + is := assert.New(t) + + result := libFn(input, predicate) + + switch reflect.TypeOf(result).Kind() { + case reflect.Slice, reflect.Array: + is.Equal(expected, result) + is.ElementsMatch(expected, result) + default: + is.Equal(expected, result) + } + }) +} diff --git a/api/slicesx/unique.go b/api/slicesx/unique.go new file mode 100644 index 0000000..8659b07 --- /dev/null +++ b/api/slicesx/unique.go @@ -0,0 +1,21 @@ +package slicesx + +func Unique[T comparable](items []T) []T { + return UniqueBy(items, func(item T) T { + return item + }) +} + +func UniqueBy[ItemType any, ComparableType comparable](items []ItemType, accessorFunc func(ItemType) ComparableType) []ItemType { + includedItems := make(map[ComparableType]bool) + result := []ItemType{} + + for _, item := range items { + if _, isIncluded := includedItems[accessorFunc(item)]; !isIncluded { + includedItems[accessorFunc(item)] = true + result = append(result, item) + } + } + + return result +} diff --git a/api/slicesx/unique_test.go b/api/slicesx/unique_test.go new file mode 100644 index 0000000..298465a --- /dev/null +++ b/api/slicesx/unique_test.go @@ -0,0 +1,48 @@ +package slicesx_test + +import ( + "testing" + + "github.com/portainer/portainer/api/slicesx" + "github.com/stretchr/testify/assert" +) + +func Test_Unique(t *testing.T) { + t.Parallel() + is := assert.New(t) + t.Run("Should extract unique numbers", func(t *testing.T) { + + source := []int{1, 1, 2, 3, 4, 4, 5, 4, 6, 7, 8, 9, 1} + result := slicesx.Unique(source) + expected := []int{1, 2, 3, 4, 5, 6, 7, 8, 9} + + is.ElementsMatch(result, expected) + }) + + t.Run("Should return empty array", func(t *testing.T) { + source := []int{} + result := slicesx.Unique(source) + expected := []int{} + is.ElementsMatch(result, expected) + }) +} + +func Test_UniqueBy(t *testing.T) { + t.Parallel() + is := assert.New(t) + t.Run("Should extract unique numbers by property", func(t *testing.T) { + + source := []struct{ int }{{1}, {1}, {2}, {3}, {4}, {4}, {5}, {4}, {6}, {7}, {8}, {9}, {1}} + result := slicesx.UniqueBy(source, func(item struct{ int }) int { return item.int }) + expected := []struct{ int }{{1}, {2}, {3}, {4}, {5}, {6}, {7}, {8}, {9}} + + is.ElementsMatch(result, expected) + }) + + t.Run("Should return empty array", func(t *testing.T) { + source := []int{} + result := slicesx.UniqueBy(source, func(x int) int { return x }) + expected := []int{} + is.ElementsMatch(result, expected) + }) +} diff --git a/api/stacks/deployments/autoupdate.go b/api/stacks/deployments/autoupdate.go new file mode 100644 index 0000000..b201691 --- /dev/null +++ b/api/stacks/deployments/autoupdate.go @@ -0,0 +1,36 @@ +package deployments + +import ( + "context" + "time" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/scheduler" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + + "github.com/rs/zerolog/log" +) + +func StartAutoupdate(ctx context.Context, stackID portainer.StackID, interval string, scheduler *scheduler.Scheduler, stackDeployer StackDeployer, datastore dataservices.DataStore, gitService portainer.GitService) (jobID string, e *httperror.HandlerError) { + d, err := time.ParseDuration(interval) + if err != nil { + return "", httperror.BadRequest("Unable to parse stack's auto update interval", err) + } + + jobID = scheduler.StartJobEvery(d, func() error { + return RedeployWhenChanged(ctx, stackID, stackDeployer, datastore, gitService) + }) + + return jobID, nil +} + +func StopAutoupdate(stackID portainer.StackID, jobID string, scheduler *scheduler.Scheduler) { + if jobID == "" { + return + } + + if err := scheduler.StopJob(jobID); err != nil { + log.Warn().Int("stack_id", int(stackID)).Msg("could not stop the job for the stack") + } +} diff --git a/api/stacks/deployments/compose_unpacker_cmd_builder.go b/api/stacks/deployments/compose_unpacker_cmd_builder.go new file mode 100644 index 0000000..f6b889d --- /dev/null +++ b/api/stacks/deployments/compose_unpacker_cmd_builder.go @@ -0,0 +1,245 @@ +package deployments + +import ( + "fmt" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + gittypes "github.com/portainer/portainer/api/git/types" + "github.com/portainer/portainer/api/internal/registryutils" +) + +type StackRemoteOperation string + +const ( + OperationDeploy StackRemoteOperation = "compose-deploy" + OperationUndeploy StackRemoteOperation = "compose-undeploy" + OperationComposeStart StackRemoteOperation = "compose-start" + OperationComposeStop StackRemoteOperation = "compose-stop" + OperationSwarmDeploy StackRemoteOperation = "swarm-deploy" + OperationSwarmUndeploy StackRemoteOperation = "swarm-undeploy" + OperationSwarmStart StackRemoteOperation = "swarm-start" + OperationSwarmStop StackRemoteOperation = "swarm-stop" +) + +const ( + UnpackerCmdDeploy = "deploy" + UnpackerCmdUndeploy = "undeploy" + UnpackerCmdSwarmDeploy = "swarm-deploy" + UnpackerCmdSwarmUndeploy = "swarm-undeploy" +) + +type unpackerCmdBuilderOptions struct { + pullImage bool + prune bool + forceRecreate bool + composeDestination string + registries []portainer.Registry + gitConfig *gittypes.RepoConfig +} + +type buildCmdFunc func(stack *portainer.Stack, opts unpackerCmdBuilderOptions, registries []string, env []string) []string + +var funcmap = map[StackRemoteOperation]buildCmdFunc{ + OperationDeploy: buildDeployCmd, + OperationUndeploy: buildUndeployCmd, + OperationComposeStart: buildComposeStartCmd, + OperationComposeStop: buildComposeStopCmd, + OperationSwarmDeploy: buildSwarmDeployCmd, + OperationSwarmUndeploy: buildSwarmUndeployCmd, + OperationSwarmStart: buildSwarmStartCmd, + OperationSwarmStop: buildSwarmStopCmd, +} + +// build the unpacker cmd for stack based on stackOperation +func (d *stackDeployer) buildUnpackerCmdForStack(stack *portainer.Stack, operation StackRemoteOperation, opts unpackerCmdBuilderOptions) ([]string, error) { + fn := funcmap[operation] + if fn == nil { + return nil, fmt.Errorf("unknown stack operation %s", operation) + } + + registriesStrings := generateRegistriesStrings(opts.registries, d.dataStore) + envStrings := getEnv(stack.Env) + + return fn(stack, opts, registriesStrings, envStrings), nil +} + +// deploy [-u username -p password] [--skip-tls-verify] [--force-recreate] [-r] [-k] [--env KEY1=VALUE1 --env KEY2=VALUE2] [...] +func buildDeployCmd(stack *portainer.Stack, opts unpackerCmdBuilderOptions, registries []string, env []string) []string { + cmd := []string{UnpackerCmdDeploy} + cmd = appendGitAuthIfNeeded(cmd, opts.gitConfig) + cmd = appendSkipTLSVerifyIfNeeded(cmd, opts.gitConfig) + cmd = appendForceRecreateIfNeeded(cmd, opts.forceRecreate) + + if opts.prune { + cmd = append(cmd, "-r") + } + + cmd = append(cmd, env...) + cmd = append(cmd, registries...) + cmd = append(cmd, + opts.gitConfig.URL, + opts.gitConfig.ReferenceName, + stack.Name, + opts.composeDestination, + stack.EntryPoint, + ) + + return append(cmd, stack.AdditionalFiles...) +} + +// undeploy [-u username -p password] [-k] [...] +func buildUndeployCmd(stack *portainer.Stack, opts unpackerCmdBuilderOptions, registries []string, env []string) []string { + cmd := []string{UnpackerCmdUndeploy} + cmd = appendGitAuthIfNeeded(cmd, opts.gitConfig) + cmd = append(cmd, opts.gitConfig.URL, + stack.Name, + opts.composeDestination, + stack.EntryPoint, + ) + + return append(cmd, stack.AdditionalFiles...) +} + +// deploy [-u username -p password] [--skip-tls-verify] [-k] [--env KEY1=VALUE1 --env KEY2=VALUE2] [...] +func buildComposeStartCmd(stack *portainer.Stack, opts unpackerCmdBuilderOptions, registries []string, env []string) []string { + cmd := []string{UnpackerCmdDeploy} + cmd = appendGitAuthIfNeeded(cmd, opts.gitConfig) + cmd = appendSkipTLSVerifyIfNeeded(cmd, opts.gitConfig) + cmd = append(cmd, "-k") + cmd = append(cmd, env...) + cmd = append(cmd, registries...) + cmd = append(cmd, opts.gitConfig.URL, + opts.gitConfig.ReferenceName, + stack.Name, + opts.composeDestination, + stack.EntryPoint, + ) + + return append(cmd, stack.AdditionalFiles...) +} + +// undeploy [-u username -p password] [-k] [...] +func buildComposeStopCmd(stack *portainer.Stack, opts unpackerCmdBuilderOptions, registries []string, env []string) []string { + cmd := []string{UnpackerCmdUndeploy} + cmd = appendGitAuthIfNeeded(cmd, opts.gitConfig) + cmd = append(cmd, + "-k", + opts.gitConfig.URL, + stack.Name, + opts.composeDestination, + stack.EntryPoint, + ) + + return append(cmd, stack.AdditionalFiles...) +} + +// swarm-deploy [-u username -p password] [--skip-tls-verify] [--force-recreate] [-f] [-r] [-k] [--env KEY1=VALUE1 --env KEY2=VALUE2] [...] +func buildSwarmDeployCmd(stack *portainer.Stack, opts unpackerCmdBuilderOptions, registries []string, env []string) []string { + cmd := []string{UnpackerCmdSwarmDeploy} + cmd = appendGitAuthIfNeeded(cmd, opts.gitConfig) + cmd = appendSkipTLSVerifyIfNeeded(cmd, opts.gitConfig) + cmd = appendForceRecreateIfNeeded(cmd, opts.forceRecreate) + if opts.pullImage { + cmd = append(cmd, "-f") + } + + if opts.prune { + cmd = append(cmd, "-r") + } + + cmd = append(cmd, env...) + cmd = append(cmd, registries...) + cmd = append(cmd, opts.gitConfig.URL, + opts.gitConfig.ReferenceName, + stack.Name, + opts.composeDestination, + stack.EntryPoint, + ) + + return append(cmd, stack.AdditionalFiles...) +} + +// swarm-undeploy [-k] +func buildSwarmUndeployCmd(stack *portainer.Stack, opts unpackerCmdBuilderOptions, registries []string, env []string) []string { + return []string{UnpackerCmdSwarmUndeploy, stack.Name, opts.composeDestination} +} + +// swarm-deploy [-u username -p password] [-f] [-r] [-k] [--skip-tls-verify] [--env KEY1=VALUE1 --env KEY2=VALUE2] [...] +func buildSwarmStartCmd(stack *portainer.Stack, opts unpackerCmdBuilderOptions, registries []string, env []string) []string { + cmd := []string{UnpackerCmdSwarmDeploy, "-f", "-r", "-k"} + cmd = appendSkipTLSVerifyIfNeeded(cmd, opts.gitConfig) + cmd = append(cmd, getEnv(stack.Env)...) + cmd = append(cmd, registries...) + cmd = append(cmd, opts.gitConfig.URL, + opts.gitConfig.ReferenceName, + stack.Name, + opts.composeDestination, + stack.EntryPoint, + ) + + return append(cmd, stack.AdditionalFiles...) +} + +// swarm-undeploy [-k] +func buildSwarmStopCmd(stack *portainer.Stack, opts unpackerCmdBuilderOptions, registries []string, env []string) []string { + return []string{UnpackerCmdSwarmUndeploy, "-k", stack.Name, opts.composeDestination} +} + +func appendGitAuthIfNeeded(cmd []string, gc *gittypes.RepoConfig) []string { + if gc == nil || gc.Authentication == nil || gc.Authentication.Password == "" { + return cmd + } + + return append(cmd, "-u", gc.Authentication.Username, "-p", gc.Authentication.Password) +} + +func appendSkipTLSVerifyIfNeeded(cmd []string, gc *gittypes.RepoConfig) []string { + if gc == nil || !gc.TLSSkipVerify { + return cmd + } + + return append(cmd, "--skip-tls-verify") +} + +func appendForceRecreateIfNeeded(cmd []string, forceRecreate bool) []string { + if forceRecreate { + cmd = append(cmd, "--force-recreate") + } + + return cmd +} + +func generateRegistriesStrings(registries []portainer.Registry, dataStore dataservices.DataStore) []string { + cmds := []string{} + + for _, registry := range registries { + if registry.Authentication { + if err := registryutils.EnsureRegTokenValid(dataStore, ®istry); err != nil { + continue + } + + username, password, err := registryutils.GetRegEffectiveCredential(®istry) + if err != nil { + continue + } + + cmds = append(cmds, fmt.Sprintf("--registry=%s:%s:%s", username, password, registry.URL)) + } + } + + return cmds +} + +func getEnv(env []portainer.Pair) []string { + if len(env) == 0 { + return nil + } + + cmd := []string{} + for _, pair := range env { + cmd = append(cmd, fmt.Sprintf(`--env=%s=%s`, pair.Name, pair.Value)) + } + + return cmd +} diff --git a/api/stacks/deployments/deploy.go b/api/stacks/deployments/deploy.go new file mode 100644 index 0000000..1462d05 --- /dev/null +++ b/api/stacks/deployments/deploy.go @@ -0,0 +1,304 @@ +package deployments + +import ( + "cmp" + "context" + "fmt" + "strconv" + "time" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/agent" + "github.com/portainer/portainer/api/crypto" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/dataservices/source" + "github.com/portainer/portainer/api/git/update" + "github.com/portainer/portainer/api/gitops/workflows" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/scheduler" + "github.com/portainer/portainer/api/stacks/stackutils" + + "github.com/pkg/errors" + "github.com/rs/zerolog/log" + "golang.org/x/sync/singleflight" +) + +type StackAuthorMissingErr struct { + stackID int + authorName string +} + +func (e *StackAuthorMissingErr) Error() string { + return fmt.Sprintf("stack's %v author %s is missing", e.stackID, e.authorName) +} + +var singleflightGroup = &singleflight.Group{} + +// RedeployWhenChanged pull and redeploy the stack when git repo changed +// Stack will always be redeployed if force deployment is set to true +func RedeployWhenChanged(ctx context.Context, stackID portainer.StackID, deployer StackDeployer, datastore dataservices.DataStore, gitService portainer.GitService) error { + stack, err := datastore.Stack().Read(stackID) + if dataservices.IsErrObjectNotFound(err) { + return scheduler.NewPermanentError(errors.WithMessagef(err, "failed to get the stack %v", stackID)) + } else if err != nil { + return errors.WithMessagef(err, "failed to get the stack %v", stackID) + } + + // Webhook + if stack.AutoUpdate != nil && stack.AutoUpdate.Webhook != "" { + return redeployWhenChanged(ctx, stack, deployer, datastore, gitService, true) + } + + // Polling + _, err, _ = singleflightGroup.Do(strconv.Itoa(int(stackID)), func() (any, error) { + return nil, redeployWhenChanged(ctx, stack, deployer, datastore, gitService, false) + }) + + return err +} + +func redeployWhenChanged(ctx context.Context, stack *portainer.Stack, deployer StackDeployer, datastore dataservices.DataStore, gitService portainer.GitService, webhook bool) error { + log.Debug().Int("stack_id", int(stack.ID)).Msg("redeploying stack") + + if stack.WorkflowID == 0 { + return nil // do nothing if it isn't a git-based stack + } + + endpoint, err := datastore.Endpoint().Endpoint(stack.EndpointID) + if dataservices.IsErrObjectNotFound(err) { + return scheduler.NewPermanentError( + errors.WithMessagef(err, + "failed to find the environment %v associated to the stack %v", + stack.EndpointID, + stack.ID, + ), + ) + } else if err != nil { + return errors.WithMessagef(err, "failed to find the environment %v associated to the stack %v", stack.EndpointID, stack.ID) + } + + author := cmp.Or(stack.UpdatedBy, stack.CreatedBy) + + user, err := datastore.User().UserByUsername(author) + if err != nil { + log.Warn(). + Int("stack_id", int(stack.ID)). + Str("stack", stack.Name). + Str("author", author). + Int("endpoint_id", int(stack.EndpointID)). + Msg("cannot auto update a stack, stack author user is missing") + + return &StackAuthorMissingErr{int(stack.ID), author} + } + + if !isEnvironmentOnline(endpoint) { + return nil + } + + if webhook { + go func() { + if err := redeployWhenChangedSecondStage(ctx, stack, deployer, datastore, gitService, user, endpoint); err != nil { + log.Error().Err(err). + Int("stack_id", int(stack.ID)). + Str("stack", stack.Name). + Str("author", author). + Int("endpoint_id", int(stack.EndpointID)). + Msg("webhook failed to redeploy a stack") + } + }() + + return nil + } + + return redeployWhenChangedSecondStage(ctx, stack, deployer, datastore, gitService, user, endpoint) +} + +func redeployWhenChangedSecondStage( + ctx context.Context, + stack *portainer.Stack, + deployer StackDeployer, + datastore dataservices.DataStore, + gitService portainer.GitService, + user *portainer.User, + endpoint *portainer.Endpoint, +) error { + var teamMemberships []portainer.TeamMembership + if err := datastore.ViewTx(func(tx dataservices.DataStoreTx) error { + var err error + teamMemberships, err = tx.TeamMembership().TeamMembershipsByUserID(user.ID) + return err + }); err != nil { + return err + } + + userContext := source.NewUserContext(user, teamMemberships) + gitSrc, file, err := workflows.GitSourceAndArtifactForStack(datastore, userContext, stack.WorkflowID, stack.ID) + if err != nil { + return errors.WithMessagef(err, "failed to load git config for stack %v", stack.ID) + } + + if gitSrc == nil { + return nil + } + + gitConfig := workflows.MergeSourceAndFile(gitSrc, file) + + var gitCommitChangedOrForceUpdate bool + + if !stack.FromAppTemplate { + updated, newHash, err := update.UpdateGitObject(ctx, gitService, fmt.Sprintf("stack:%d", stack.ID), gitConfig, false, stack.ProjectPath) + if err != nil { + if txErr := datastore.UpdateTx(func(tx dataservices.DataStoreTx) error { + return workflows.SaveStackStatus(tx, userContext, stack.WorkflowID, stack.ID, gitSrc.ID, err) + }); txErr != nil { + return fmt.Errorf("git check failed for stack %d: %w (and failed to persist status: %w)", stack.ID, err, txErr) + } + + return err + } + + if txErr := datastore.UpdateTx(func(tx dataservices.DataStoreTx) error { + return workflows.SaveStackStatus(tx, userContext, stack.WorkflowID, stack.ID, gitSrc.ID, nil) + }); txErr != nil { + return fmt.Errorf("failed to persist git sync status for stack %d: %w", stack.ID, txErr) + } + + if updated { + gitConfig.ConfigHash = newHash + + stack.UpdateDate = time.Now().Unix() + gitCommitChangedOrForceUpdate = updated + } + + if stack.AutoUpdate != nil && stack.AutoUpdate.ForceUpdate { + gitCommitChangedOrForceUpdate = true + } + } + + if !gitCommitChangedOrForceUpdate { + return nil + } + + if err := datastore.UpdateTx(func(tx dataservices.DataStoreTx) error { + stackutils.PrepareStackStatusForDeployment(stack) + return tx.Stack().Update(stack.ID, stack) + }); err != nil { + return errors.WithMessagef(err, "failed to set the deploying status for stack %v", stack.ID) + } + + stack.CurrentDeploymentInfo = &portainer.StackDeploymentInfo{ + RepositoryURL: gitConfig.URL, + ReferenceName: gitConfig.ReferenceName, + ConfigFilePath: gitConfig.ConfigFilePath, + AdditionalFiles: stack.AdditionalFiles, + ConfigHash: gitConfig.ConfigHash, + SourceID: gitSrc.ID, + } + + registries, err := getUserRegistries(datastore, user, endpoint.ID) + if dataservices.IsErrObjectNotFound(err) { + return scheduler.NewPermanentError(err) + } else if err != nil { + return err + } + + redeployStack := func(stack *portainer.Stack) error { + var err error + switch stack.Type { + case portainer.DockerComposeStack: + if stackutils.IsRelativePathStack(stack) { + err = deployer.DeployRemoteComposeStack(ctx, user.ID, stack, endpoint, registries, true, true, false) + } else { + err = deployer.DeployComposeStack(ctx, stack, endpoint, registries, true, true, false) + } + + if err != nil { + return errors.WithMessagef(err, "failed to deploy a docker compose stack %v", stack.ID) + } + case portainer.DockerSwarmStack: + if stackutils.IsRelativePathStack(stack) { + err = deployer.DeployRemoteSwarmStack(ctx, user.ID, stack, endpoint, registries, true, true) + } else { + err = deployer.DeploySwarmStack(ctx, stack, endpoint, registries, true, true) + } + if err != nil { + return errors.WithMessagef(err, "failed to deploy a docker compose stack %v", stack.ID) + } + case portainer.KubernetesStack: + log.Debug().Int("stack_id", int(stack.ID)).Msg("deploying a kube app") + + if err := deployer.DeployKubernetesStack(ctx, stack, endpoint, user); err != nil { + return errors.WithMessagef(err, "failed to deploy a kubernetes app stack %v", stack.ID) + } + default: + return errors.Errorf("cannot update stack, type %v is unsupported", stack.Type) + } + + return nil + } + + deployErr := redeployStack(stack) + + if err := datastore.UpdateTx(func(tx dataservices.DataStoreTx) error { + stack.UpdateDate = time.Now().Unix() + + stackutils.UpdateStackStatusFromDeploymentResult(stack, deployErr) + if err := tx.Stack().Update(stack.ID, stack); err != nil { + return err + } + + newHash := gitConfig.ConfigHash + + return workflows.UpdateArtifactFileForStack(tx, stack.WorkflowID, stack.ID, gitSrc.ID, func(a *portainer.ArtifactFile) { + a.Hash = newHash + a.RefStatus = portainer.SourceStatusHealthy + a.RefError = "" + a.PathStatus = portainer.SourceStatusHealthy + a.PathError = "" + }) + }); err != nil { + return errors.WithMessagef(err, "failed to update the stack %v", stack.ID) + } + + return nil +} + +func getUserRegistries(datastore dataservices.DataStore, user *portainer.User, endpointID portainer.EndpointID) ([]portainer.Registry, error) { + registries, err := datastore.Registry().ReadAll() + if err != nil { + return nil, errors.WithMessage(err, "unable to retrieve registries from the database") + } + + if user.Role == portainer.AdministratorRole { + return registries, nil + } + + userMemberships, err := datastore.TeamMembership().TeamMembershipsByUserID(user.ID) + if err != nil { + return nil, errors.WithMessagef(err, "failed to fetch memberships of the stack author [%s]", user.Username) + } + + filteredRegistries := make([]portainer.Registry, 0, len(registries)) + for _, registry := range registries { + if security.AuthorizedRegistryAccess(®istry, user, userMemberships, endpointID) { + filteredRegistries = append(filteredRegistries, registry) + } + } + + return filteredRegistries, nil +} + +func isEnvironmentOnline(endpoint *portainer.Endpoint) bool { + if endpoint.Type != portainer.AgentOnDockerEnvironment && + endpoint.Type != portainer.AgentOnKubernetesEnvironment { + return true + } + + tlsConfig, err := crypto.CreateTLSConfigurationFromDisk(endpoint.TLSConfig) + if err != nil { + return false + } + + _, _, err = agent.GetAgentVersionAndPlatform(endpoint.URL, tlsConfig) + return err == nil +} diff --git a/api/stacks/deployments/deploy_test.go b/api/stacks/deployments/deploy_test.go new file mode 100644 index 0000000..488e98d --- /dev/null +++ b/api/stacks/deployments/deploy_test.go @@ -0,0 +1,434 @@ +package deployments + +import ( + "context" + "crypto/tls" + "errors" + "net/http" + "strconv" + "strings" + "testing" + "time" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/crypto" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/dataservices/source" + "github.com/portainer/portainer/api/datastore" + gittypes "github.com/portainer/portainer/api/git/types" + "github.com/portainer/portainer/api/internal/testhelpers" + "github.com/portainer/portainer/pkg/fips" + "github.com/portainer/portainer/pkg/libhttp/response" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const localhostCert = `-----BEGIN CERTIFICATE----- +MIIEOjCCAiKgAwIBAgIRALg8rJET2/9LjKSxHj0dQhYwDQYJKoZIhvcNAQELBQAw +FzEVMBMGA1UEAxMMUG9ydGFpbmVyIENBMB4XDTIzMTAxMTE5NDcxMVoXDTI1MDQx +MTE5NTM0MVowFDESMBAGA1UEAxMJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEAx4nNGiwcCqUCxZyVLIHqvjTy20ZtZDVCedssTv1W5tmz +YqOIYGaW3CqzlRn6vBHu9bMHXef4+XfS0igKBn76MAKn5IcTccIWIal+5jq48pI3 +c2FzQ3qNujX2zqZPjAjhJnVeVCP3kJu4wUtuubswLPBVLdktGa6EkL+8nu6o0Phw +6scV6s3gUmQk5/lpH4FIff8M7NAdTOxiFImQ1M0vplKtaEeiCnskpgyI8CbZl7X0 +38Pu178W3+LqB7N4iMy2gKnBwjsXzw/+1dfUGkKjYdDBD+kNEKrQ4dwkjkrkQVdt +Z+GN26NvXHoeeyX/MLnVgdLbiIjvsf0DDIhabKqTcwIDAQABo4GDMIGAMA4GA1Ud +DwEB/wQEAwIDuDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwHQYDVR0O +BBYEFPCefmK5Szzlfs8FRCa5+kRCIEWuMB8GA1UdIwQYMBaAFKZZ074SR/ajD3zE +gxpLGRvFT3XAMA8GA1UdEQQIMAaHBH8AAAEwDQYJKoZIhvcNAQELBQADggIBABcQ +/WPSUpuQvrcVBsmIlOMz74cDZYuIDls/mAcB/yP3mm+oWlO0qvH/F/BMs1P/bkgj +fByQZq8Zmi6/TEZNlGvW7KGx077VxDKi8jd1jL3gLDPmkFjYuGeIWQusgxBu1y3m +0WoTTqnkoism1mzV/dgNwrm3YQIV4H/fi9EEdQSm0UFRTKSAGBkwS7N2pmNb5yQO +U8glFpyznCv4evDJbs/JUUXKYExgFFhWUd25P7iBRLXg/BFfqdSTiUGUj/Msz0pO +Evqmq78eIiXjyyKSxzve6/mEIeq6AE3AC9zH+fwTd6Mhp+T2P/S/iO4EU19IMR4m +sbNBd6h/3GvRekO1KbqQ42awuMnxvWT0NVclSxiU1lMpAmRmk/w9z7wB3r4n7oh4 +iiOTl5VSw1UBkcLDOJw+HB/FU2PdVFfIJKRfjLCZOGrcJX9vEcz7dYGpB5HrdqOc +/8q5j1g6f/pGE+20HITrtz6ChguETzqw5dLNeKeolC6bVH8yEtmpnP2n8VPnT9Di +V+hnONcJ+wd/dkBqabGr7LPG24Kj1F2Zp3CDDvJA94FaEsgaLfSg3JD+43uRCOWM +RuqU8bGuhQRqilR2dSIOrFaW2+MeUHsb24cUn/pkHqKpSg+RBEnf6QfGDlIgqYEl +19f/HFVBc/a8lM/D81lMyDbjQ9zH4LDYj4ipBbkL +-----END CERTIFICATE-----` + +const localhostKey = `-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEAx4nNGiwcCqUCxZyVLIHqvjTy20ZtZDVCedssTv1W5tmzYqOI +YGaW3CqzlRn6vBHu9bMHXef4+XfS0igKBn76MAKn5IcTccIWIal+5jq48pI3c2Fz +Q3qNujX2zqZPjAjhJnVeVCP3kJu4wUtuubswLPBVLdktGa6EkL+8nu6o0Phw6scV +6s3gUmQk5/lpH4FIff8M7NAdTOxiFImQ1M0vplKtaEeiCnskpgyI8CbZl7X038Pu +178W3+LqB7N4iMy2gKnBwjsXzw/+1dfUGkKjYdDBD+kNEKrQ4dwkjkrkQVdtZ+GN +26NvXHoeeyX/MLnVgdLbiIjvsf0DDIhabKqTcwIDAQABAoIBAQCqSP6BPG195A52 +iEeCISksw9ERsou+fflKNvIcQvV7swP0xOyooERUhhiVwQMKpx9QDUXXLRV8CHch +JExR+OEYQdv4GhJM/b6XYafLYQfe80thKyQLzTXQWSdUeffe4OEMShODKOKoRUyp +oO9Qj9/wKfX3V6S2iwnU4dxdofztv+YP9rYQyjnhKbv/9OfeCp2Pb9eFKKRsA+QQ +xneDz1+wr8ToTuiTn8HBPNSeSAKvhzXuzyluI7VAetRloNgCtumrA9kpVbW2cDgE +Gk0q3RY125ejFELQO/cOJFuBsqoJlvPxzg8/vHyfyF9hFMqbqvcUw2e1eqHpnJd5 +dP4+ZGYZAoGBAOOFuPXMLBts0rN9mfNbVfx36H+aOCL77SafZvWm0D+rH69QN3/q +/ZSWQEjwH5Tzn1e+NVcl/Um2vL/dIyEGBklXQ7yAyJo25gpEOD/rt1U94HKzMOwy +yKtsKghRAOx0piie7ORS6MGbEOQxU3/1Eg1uvd0qoSnALqJ/le75QpFXAoGBAOCD +aZQTszzDddr1cFPzLyqjIGJWfPcDYSONXVcCeQmhvC7mkfw9SWdIfku7JbdNgFYq +ZAAU0klsLX0lEe8f4A12FnHNylKoxmTWdE3wWPptejdA1KUgzt/2kNljgOMFuY0Q +rlCEW/Jabrg5aFMwVVG8bHLZR0xalfniDvXLvnFFAoGACdztJLKiIto31BIYz2Th +OF2WVZnA3ztej3MPioydsHThnb7zePcd4QgWZ1MJe3KIMMyNEWcTMNPcINEcSb0y +HpHK3OwURiMlG8LTUWoNe4OALFi6QTL+YfgBZnTkflucLFyfVlKFxobLV6kPvpdI +Hg7z6heD/wRWwTKYtFBX42cCgYBIeoQJ9rYlRqB0eEm0AEzYweLBfFRJVgD0/j0E +ytqSPnFG3s6AFLTur9t9zUPmwhFNP9Aaqp4cb9zbiq0YejzVe6rRQHMxbiTmBslz +I8VFyzPqRHahfE7sxGeMlm/UWlPFc34ipigcvA8EUBwaxv60LVUBWp2Gy7OhANZ9 +iTHI1QKBgQCdHFj9dnbpaEHA426CoaPsyj5cv2nBLRf8p1cs71sq+qQOGlGJfajm +L9x22ol5c5rToZa1qKSnSdSDCud298MyRujMUy2UcUKHeNs3MK9AT41sDv266I7b +vJUUCFYm8+9p6gTVOcoMit+eGSwa81PCPEs1TnU1PV/PaDFeUhn/mg== +-----END RSA PRIVATE KEY-----` + +var adminUserContext = source.InsecureNewAdminContext() + +type noopDeployer struct{} + +// without unpacker +func (s noopDeployer) DeploySwarmStack(_ context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune, pullImage bool) error { + return nil +} + +func (s noopDeployer) DeployComposeStack(_ context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune, forcePullImage, forceRecreate bool) error { + return nil +} + +func (s noopDeployer) UndeployComposeStack(_ context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint) error { + return nil +} + +func (s noopDeployer) DeployKubernetesStack(_ context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint, user *portainer.User) error { + return nil +} + +// with unpacker +func (s noopDeployer) DeployRemoteComposeStack(_ context.Context, userId portainer.UserID, stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune, forcePullImage, forceRecreate bool) error { + return nil +} +func (s noopDeployer) UndeployRemoteComposeStack(_ context.Context, userId portainer.UserID, stack *portainer.Stack, endpoint *portainer.Endpoint) error { + return nil +} +func (s noopDeployer) StartRemoteComposeStack(_ context.Context, userId portainer.UserID, stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry) error { + return nil +} +func (s noopDeployer) StopRemoteComposeStack(_ context.Context, userId portainer.UserID, stack *portainer.Stack, endpoint *portainer.Endpoint) error { + return nil +} +func (s noopDeployer) DeployRemoteSwarmStack(_ context.Context, userId portainer.UserID, stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune, pullImage bool) error { + return nil +} +func (s noopDeployer) UndeployRemoteSwarmStack(_ context.Context, userId portainer.UserID, stack *portainer.Stack, endpoint *portainer.Endpoint) error { + return nil +} +func (s noopDeployer) StartRemoteSwarmStack(_ context.Context, userId portainer.UserID, stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry) error { + return nil +} +func (s noopDeployer) StopRemoteSwarmStack(_ context.Context, userId portainer.UserID, stack *portainer.Stack, endpoint *portainer.Endpoint) error { + return nil +} + +func agentServer(t *testing.T) string { + h := http.NewServeMux() + + h.HandleFunc("/ping", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set(portainer.PortainerAgentHeader, "v2.19.0") + w.Header().Set(portainer.HTTPResponseAgentPlatform, strconv.Itoa(int(portainer.AgentPlatformDocker))) + + _ = response.Empty(w) + }) + + cert, err := tls.X509KeyPair([]byte(localhostCert), []byte(localhostKey)) + require.NoError(t, err) + + tlsConfig := crypto.CreateTLSConfiguration(false) + tlsConfig.Certificates = []tls.Certificate{cert} + + l, err := tls.Listen("tcp", "127.0.0.1:0", tlsConfig) + require.NoError(t, err) + + s := &http.Server{ + Handler: h, + } + + errCh := make(chan error) + go func() { + errCh <- s.Serve(l) + }() + + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + require.NoError(t, s.Shutdown(ctx)) + require.ErrorIs(t, <-errCh, http.ErrServerClosed) + }) + + return "http://" + l.Addr().String() +} + +func Test_redeployWhenChanged_FailsWhenCannotFindStack(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + err := RedeployWhenChanged(t.Context(), 1, nil, store, nil) + require.Error(t, err) + assert.Truef(t, strings.HasPrefix(err.Error(), "failed to get the stack"), "it isn't an error we expected: %v", err.Error()) +} + +func Test_redeployWhenChanged_DoesNothingWhenNotAGitBasedStack(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + admin := &portainer.User{ID: 1, Username: "admin"} + err := store.User().Create(admin) + require.NoError(t, err, "error creating an admin") + + err = store.Stack().Create(&portainer.Stack{ID: 1, CreatedBy: "admin"}) + require.NoError(t, err, "failed to create a test stack") + + err = RedeployWhenChanged(t.Context(), 1, nil, store, testhelpers.NewGitService(nil, "")) + require.NoError(t, err) +} + +func Test_redeployWhenChanged_DoesNothingWhenNoGitChanges(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + tmpDir := t.TempDir() + + admin := &portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole} + err := store.User().Create(admin) + require.NoError(t, err, "error creating an admin") + + err = store.Endpoint().Create(&portainer.Endpoint{ID: 0}) + require.NoError(t, err, "error creating environment") + + src := &portainer.Source{ + Type: portainer.SourceTypeGit, + Git: &gittypes.GitSource{ + URL: "url", + }, + } + err = store.Source().Create(adminUserContext, src) + require.NoError(t, err, "failed to create source") + + wf := &portainer.Workflow{Artifacts: []portainer.Artifact{{StackID: 1, Files: []portainer.ArtifactFile{{SourceID: src.ID}}}}} + err = store.Workflow().Create(wf) + require.NoError(t, err, "failed to create workflow") + + err = store.Stack().Create(&portainer.Stack{ + ID: 1, + CreatedBy: "admin", + ProjectPath: tmpDir, + WorkflowID: wf.ID, + }) + require.NoError(t, err, "failed to create a test stack") + + err = RedeployWhenChanged(t.Context(), 1, nil, store, testhelpers.NewGitService(nil, "oldHash")) + require.NoError(t, err) + + updatedSrc, err := store.Source().Read(adminUserContext, src.ID) + require.NoError(t, err) + require.Equal(t, portainer.SourceStatusHealthy, updatedSrc.Status) + require.Empty(t, updatedSrc.StatusError) + require.NotZero(t, updatedSrc.LastSync) +} + +func Test_redeployWhenChanged_FailsWhenCannotClone(t *testing.T) { + fips.InitFIPS(false) + + cloneErr := errors.New("failed to clone") + _, store := datastore.MustNewTestStore(t, false, true) + + admin := &portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole} + err := store.User().Create(admin) + require.NoError(t, err, "error creating an admin") + + err = store.Endpoint().Create(&portainer.Endpoint{ + ID: 0, + URL: agentServer(t), + TLSConfig: portainer.TLSConfiguration{ + TLS: true, + TLSSkipVerify: true, + }, + }) + require.NoError(t, err, "error creating environment") + + src := &portainer.Source{ + Type: portainer.SourceTypeGit, + Git: &gittypes.GitSource{ + URL: "url", + }, + } + err = store.Source().Create(adminUserContext, src) + require.NoError(t, err, "failed to create source") + + wf := &portainer.Workflow{Artifacts: []portainer.Artifact{{ + StackID: 1, + Files: []portainer.ArtifactFile{{SourceID: src.ID}}, + }}} + err = store.Workflow().Create(wf) + require.NoError(t, err, "failed to create workflow") + + err = store.Stack().Create(&portainer.Stack{ + ID: 1, + CreatedBy: "admin", + WorkflowID: wf.ID, + }) + require.NoError(t, err, "failed to create a test stack") + + err = RedeployWhenChanged(t.Context(), 1, nil, store, testhelpers.NewGitService(cloneErr, "newHash")) + require.Error(t, err) + require.ErrorIs(t, err, cloneErr, "should failed to clone but didn't, check test setup") + + updatedSrc, err := store.Source().Read(adminUserContext, src.ID) + require.NoError(t, err) + require.Equal(t, portainer.SourceStatusError, updatedSrc.Status) + require.Contains(t, updatedSrc.StatusError, cloneErr.Error()) + require.Zero(t, updatedSrc.LastSync) +} + +func setupRedeployStore(t *testing.T, stackType portainer.StackType) (dataservices.DataStore, portainer.StackID) { + t.Helper() + + _, store := datastore.MustNewTestStore(t, false, true) + tmpDir := t.TempDir() + + err := store.Endpoint().Create(&portainer.Endpoint{ID: 1}) + require.NoError(t, err, "error creating environment") + + username := "user" + err = store.User().Create(&portainer.User{Username: username, Role: portainer.AdministratorRole}) + require.NoError(t, err, "error creating a user") + + src := &portainer.Source{ + Type: portainer.SourceTypeGit, + Git: &gittypes.GitSource{ + URL: "url", + }, + } + err = store.Source().Create(adminUserContext, src) + require.NoError(t, err, "failed to create source") + + wf := &portainer.Workflow{Artifacts: []portainer.Artifact{{Files: []portainer.ArtifactFile{{SourceID: src.ID}}}}} + err = store.Workflow().Create(wf) + require.NoError(t, err, "failed to create workflow") + + const stackID portainer.StackID = 1 + + err = store.Stack().Create(&portainer.Stack{ + ID: stackID, + EndpointID: 1, + ProjectPath: tmpDir, + UpdatedBy: username, + WorkflowID: wf.ID, + Type: stackType, + }) + require.NoError(t, err, "failed to create a test stack") + + return store, stackID +} + +func Test_redeployWhenChanged_DockerComposeStack(t *testing.T) { + t.Parallel() + + store, stackID := setupRedeployStore(t, portainer.DockerComposeStack) + + err := RedeployWhenChanged(t.Context(), stackID, noopDeployer{}, store, testhelpers.NewGitService(nil, "newHash")) + require.NoError(t, err) +} + +func Test_redeployWhenChanged_DockerSwarmStack(t *testing.T) { + t.Parallel() + + store, stackID := setupRedeployStore(t, portainer.DockerSwarmStack) + + err := RedeployWhenChanged(t.Context(), stackID, noopDeployer{}, store, testhelpers.NewGitService(nil, "newHash")) + require.NoError(t, err) +} + +func Test_redeployWhenChanged_KubernetesStack(t *testing.T) { + t.Parallel() + + store, stackID := setupRedeployStore(t, portainer.KubernetesStack) + + err := RedeployWhenChanged(t.Context(), stackID, noopDeployer{}, store, testhelpers.NewGitService(nil, "newHash")) + require.NoError(t, err) +} + +func Test_getUserRegistries(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + endpointID := 123 + + admin := &portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole} + err := store.User().Create(admin) + require.NoError(t, err, "error creating an admin") + + user := &portainer.User{ID: 2, Username: "user", Role: portainer.StandardUserRole} + err = store.User().Create(user) + require.NoError(t, err, "error creating a user") + + team := portainer.Team{ID: 1} + + err = store.TeamMembership().Create(&portainer.TeamMembership{ + ID: 1, + UserID: user.ID, + TeamID: team.ID, + Role: portainer.TeamMember, + }) + require.NoError(t, err) + + registryReachableByUser := portainer.Registry{ + ID: 1, + Name: "registryReachableByUser", + RegistryAccesses: portainer.RegistryAccesses{ + portainer.EndpointID(endpointID): { + UserAccessPolicies: map[portainer.UserID]portainer.AccessPolicy{ + user.ID: {RoleID: portainer.RoleID(portainer.StandardUserRole)}, + }, + }, + }, + } + err = store.Registry().Create(®istryReachableByUser) + require.NoError(t, err, "couldn't create a registry") + + registryReachableByTeam := portainer.Registry{ + ID: 2, + Name: "registryReachableByTeam", + RegistryAccesses: portainer.RegistryAccesses{ + portainer.EndpointID(endpointID): { + TeamAccessPolicies: map[portainer.TeamID]portainer.AccessPolicy{ + team.ID: {RoleID: portainer.RoleID(portainer.StandardUserRole)}, + }, + }, + }, + } + err = store.Registry().Create(®istryReachableByTeam) + require.NoError(t, err, "couldn't create a registry") + + registryRestricted := portainer.Registry{ + ID: 3, + Name: "registryRestricted", + RegistryAccesses: portainer.RegistryAccesses{ + portainer.EndpointID(endpointID): { + UserAccessPolicies: map[portainer.UserID]portainer.AccessPolicy{ + user.ID + 100: {RoleID: portainer.RoleID(portainer.StandardUserRole)}, + }, + }, + }, + } + err = store.Registry().Create(®istryRestricted) + require.NoError(t, err, "couldn't create a registry") + + t.Run("admin should has access to all registries", func(t *testing.T) { + registries, err := getUserRegistries(store, admin, portainer.EndpointID(endpointID)) + require.NoError(t, err) + assert.ElementsMatch(t, []portainer.Registry{registryReachableByUser, registryReachableByTeam, registryRestricted}, registries) + }) + + t.Run("regular user has access to registries allowed to him and/or his team", func(t *testing.T) { + registries, err := getUserRegistries(store, user, portainer.EndpointID(endpointID)) + require.NoError(t, err) + assert.ElementsMatch(t, []portainer.Registry{registryReachableByUser, registryReachableByTeam}, registries) + }) +} diff --git a/api/stacks/deployments/deployer.go b/api/stacks/deployments/deployer.go new file mode 100644 index 0000000..63127bd --- /dev/null +++ b/api/stacks/deployments/deployer.go @@ -0,0 +1,106 @@ +package deployments + +import ( + "context" + "sync" + + "github.com/pkg/errors" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + dockerclient "github.com/portainer/portainer/api/docker/client" + k "github.com/portainer/portainer/api/kubernetes" +) + +type BaseStackDeployer interface { + DeploySwarmStack(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune, pullImage bool) error + DeployComposeStack(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune, forcePullImage, forceRecreate bool) error + UndeployComposeStack(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint) error + DeployKubernetesStack(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint, user *portainer.User) error +} + +type StackDeployer interface { + BaseStackDeployer + RemoteStackDeployer +} + +type stackDeployer struct { + lock *sync.Mutex + swarmStackManager portainer.SwarmStackManager + composeStackManager portainer.ComposeStackManager + kubernetesDeployer portainer.KubernetesDeployer + ClientFactory *dockerclient.ClientFactory + dataStore dataservices.DataStore +} + +// NewStackDeployer inits a stackDeployer struct with a SwarmStackManager, a ComposeStackManager and a KubernetesDeployer +func NewStackDeployer(swarmStackManager portainer.SwarmStackManager, composeStackManager portainer.ComposeStackManager, + kubernetesDeployer portainer.KubernetesDeployer, clientFactory *dockerclient.ClientFactory, dataStore dataservices.DataStore, +) *stackDeployer { + return &stackDeployer{ + lock: &sync.Mutex{}, + swarmStackManager: swarmStackManager, + composeStackManager: composeStackManager, + kubernetesDeployer: kubernetesDeployer, + ClientFactory: clientFactory, + dataStore: dataStore, + } +} + +func (d *stackDeployer) DeploySwarmStack(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune, pullImage bool) error { + d.lock.Lock() + defer d.lock.Unlock() + + return d.swarmStackManager.Deploy(ctx, stack, prune, pullImage, endpoint, registries) +} + +func (d *stackDeployer) DeployComposeStack(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune, forcePullImage, forceRecreate bool) error { + d.lock.Lock() + defer d.lock.Unlock() + + options := portainer.ComposeOptions{Registries: registries} + + // --force-recreate doesn't pull updated images + if forcePullImage { + if err := d.composeStackManager.Pull(ctx, stack, endpoint, options); err != nil { + return err + } + } + + return d.composeStackManager.Up(ctx, stack, endpoint, portainer.ComposeUpOptions{ + ComposeOptions: options, + ForceRecreate: forceRecreate, + Prune: prune, + }) +} + +func (d *stackDeployer) UndeployComposeStack(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint) error { + d.lock.Lock() + defer d.lock.Unlock() + + return d.composeStackManager.Down(ctx, stack, endpoint) +} + +func (d *stackDeployer) DeployKubernetesStack(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint, user *portainer.User) error { + d.lock.Lock() + defer d.lock.Unlock() + + appLabels := k.KubeAppLabels{ + StackID: int(stack.ID), + StackName: stack.Name, + Owner: user.Username, + } + + if stack.WorkflowID == 0 { + appLabels.Kind = "content" + } else { + appLabels.Kind = "git" + } + + k8sDeploymentConfig := CreateKubernetesStackDeploymentConfig(stack, d.kubernetesDeployer, appLabels, user, endpoint) + + if err := k8sDeploymentConfig.Deploy(ctx); err != nil { + return errors.Wrap(err, "failed to deploy kubernetes application") + } + + return nil +} diff --git a/api/stacks/deployments/deployer_remote.go b/api/stacks/deployments/deployer_remote.go new file mode 100644 index 0000000..fa57de5 --- /dev/null +++ b/api/stacks/deployments/deployer_remote.go @@ -0,0 +1,427 @@ +package deployments + +import ( + "bytes" + "context" + "fmt" + "io" + "os" + "strings" + "time" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/dataservices/source" + "github.com/portainer/portainer/api/filesystem" + "github.com/portainer/portainer/api/gitops/workflows" + "github.com/portainer/portainer/api/logs" + "github.com/portainer/portainer/pkg/librand" + + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/image" + "github.com/docker/docker/api/types/swarm" + "github.com/docker/docker/api/types/system" + dockerclient "github.com/docker/docker/client" + "github.com/docker/docker/pkg/stdcopy" + "github.com/pkg/errors" + "github.com/rs/zerolog/log" + "github.com/segmentio/encoding/json" +) + +const ( + defaultUnpackerImage = "portainer/compose-unpacker:" + portainer.APIVersion + composeUnpackerImageEnvVar = "COMPOSE_UNPACKER_IMAGE" + composePathPrefix = "portainer-compose-unpacker" +) + +type RemoteStackDeployer interface { + // compose + DeployRemoteComposeStack(ctx context.Context, userId portainer.UserID, stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune bool, forcePullImage bool, forceRecreate bool) error + UndeployRemoteComposeStack(ctx context.Context, userId portainer.UserID, stack *portainer.Stack, endpoint *portainer.Endpoint) error + StartRemoteComposeStack(ctx context.Context, userId portainer.UserID, stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry) error + StopRemoteComposeStack(ctx context.Context, userId portainer.UserID, stack *portainer.Stack, endpoint *portainer.Endpoint) error + // swarm + DeployRemoteSwarmStack(ctx context.Context, userId portainer.UserID, stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune bool, pullImage bool) error + UndeployRemoteSwarmStack(ctx context.Context, userId portainer.UserID, stack *portainer.Stack, endpoint *portainer.Endpoint) error + StartRemoteSwarmStack(ctx context.Context, userId portainer.UserID, stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry) error + StopRemoteSwarmStack(ctx context.Context, userId portainer.UserID, stack *portainer.Stack, endpoint *portainer.Endpoint) error +} + +// Deploy a compose stack on remote environment using a https://github.com/portainer/compose-unpacker container +func (d *stackDeployer) DeployRemoteComposeStack( + ctx context.Context, + userId portainer.UserID, + stack *portainer.Stack, + endpoint *portainer.Endpoint, + registries []portainer.Registry, + prune bool, + forcePullImage bool, + forceRecreate bool, +) error { + d.lock.Lock() + defer d.lock.Unlock() + + options := portainer.ComposeOptions{Registries: registries} + + // --force-recreate doesn't pull updated images + if forcePullImage { + if err := d.composeStackManager.Pull(ctx, stack, endpoint, options); err != nil { + return err + } + } + + return d.remoteStack( + ctx, + userId, + stack, + endpoint, + OperationDeploy, + unpackerCmdBuilderOptions{ + forceRecreate: forceRecreate, + registries: registries, + prune: prune, + }, + ) +} + +// Undeploy a compose stack on remote environment using a https://github.com/portainer/compose-unpacker container +func (d *stackDeployer) UndeployRemoteComposeStack( + ctx context.Context, + userId portainer.UserID, + stack *portainer.Stack, + endpoint *portainer.Endpoint, +) error { + d.lock.Lock() + defer d.lock.Unlock() + + return d.remoteStack(ctx, userId, stack, endpoint, OperationUndeploy, unpackerCmdBuilderOptions{}) +} + +// Start a compose stack on remote environment using a https://github.com/portainer/compose-unpacker container +func (d *stackDeployer) StartRemoteComposeStack( + ctx context.Context, + userId portainer.UserID, + stack *portainer.Stack, + endpoint *portainer.Endpoint, + registries []portainer.Registry, +) error { + return d.remoteStack( + ctx, + userId, + stack, + endpoint, + OperationComposeStart, + unpackerCmdBuilderOptions{ + registries: registries, + }, + ) +} + +// Stop a compose stack on remote environment using a https://github.com/portainer/compose-unpacker container +func (d *stackDeployer) StopRemoteComposeStack( + ctx context.Context, + userId portainer.UserID, + stack *portainer.Stack, + endpoint *portainer.Endpoint, +) error { + return d.remoteStack(ctx, userId, stack, endpoint, OperationComposeStop, unpackerCmdBuilderOptions{}) +} + +// Deploy a swarm stack on remote environment using a https://github.com/portainer/compose-unpacker container +func (d *stackDeployer) DeployRemoteSwarmStack( + ctx context.Context, + userId portainer.UserID, + stack *portainer.Stack, + endpoint *portainer.Endpoint, + registries []portainer.Registry, + prune bool, + pullImage bool, +) error { + d.lock.Lock() + defer d.lock.Unlock() + + return d.remoteStack(ctx, userId, stack, endpoint, OperationSwarmDeploy, unpackerCmdBuilderOptions{ + pullImage: pullImage, + prune: prune, + forceRecreate: stack.AutoUpdate != nil && stack.AutoUpdate.ForceUpdate, + registries: registries, + }) +} + +// Undeploy a swarm stack on remote environment using a https://github.com/portainer/compose-unpacker container +func (d *stackDeployer) UndeployRemoteSwarmStack( + ctx context.Context, + userId portainer.UserID, + stack *portainer.Stack, + endpoint *portainer.Endpoint, +) error { + d.lock.Lock() + defer d.lock.Unlock() + + return d.remoteStack(ctx, userId, stack, endpoint, OperationSwarmUndeploy, unpackerCmdBuilderOptions{}) +} + +// Start a swarm stack on remote environment using a https://github.com/portainer/compose-unpacker container +func (d *stackDeployer) StartRemoteSwarmStack( + ctx context.Context, + userId portainer.UserID, + stack *portainer.Stack, + endpoint *portainer.Endpoint, + registries []portainer.Registry, +) error { + return d.remoteStack( + ctx, + userId, + stack, + endpoint, + OperationSwarmStart, + unpackerCmdBuilderOptions{registries: registries}, + ) +} + +// Stop a swarm stack on remote environment using a https://github.com/portainer/compose-unpacker container +func (d *stackDeployer) StopRemoteSwarmStack( + ctx context.Context, + userId portainer.UserID, + stack *portainer.Stack, + endpoint *portainer.Endpoint, +) error { + return d.remoteStack( + ctx, + userId, + stack, + endpoint, + OperationSwarmStop, + unpackerCmdBuilderOptions{}, + ) +} + +// Does all the heavy lifting: +// * connect to env +// * build the args for compose-unpacker +// * deploy compose-unpacker container +// * wait for deployment to end +// * gather deployment logs and bubble them up +func (d *stackDeployer) remoteStack(ctx context.Context, userID portainer.UserID, stack *portainer.Stack, endpoint *portainer.Endpoint, operation StackRemoteOperation, opts unpackerCmdBuilderOptions) error { + if stack.WorkflowID != 0 && opts.gitConfig == nil { + if err := d.dataStore.ViewTx(func(tx dataservices.DataStoreTx) error { + user, err := tx.User().Read(userID) + if err != nil { + return err + } + + memberships, err := tx.TeamMembership().TeamMembershipsByUserID(userID) + if err != nil { + return err + } + + userContext := source.NewUserContext(user, memberships) + src, file, err := workflows.GitSourceAndArtifactForStack(tx, userContext, stack.WorkflowID, stack.ID) + if err != nil { + return errors.Wrap(err, "failed to load git config for remote stack") + } + + if src != nil { + opts.gitConfig = workflows.MergeSourceAndFile(src, file) + } + return nil + }); err != nil { + return err + } + } + + cli, err := d.createDockerClient(ctx, endpoint) + if err != nil { + return errors.WithMessage(err, "unable to create docker client") + } + defer logs.CloseAndLogErr(cli) + + unpackerImg := getUnpackerImage() + log.Debug().Str("unpacker_image", unpackerImg).Msg("Resolved unpacker image") + + reader, err := cli.ImagePull(ctx, unpackerImg, image.PullOptions{}) + if err != nil { + return errors.Wrap(err, "unable to pull unpacker image") + } + defer logs.CloseAndLogErr(reader) + _, _ = io.Copy(io.Discard, reader) + + info, err := cli.Info(ctx) + if err != nil { + return errors.Wrap(err, "unable to get agent info") + } + // ensure the targetSocketBindHost is changed to podman for podman environments + targetSocketBindHost := getTargetSocketBindHost(info.OSType, endpoint.ContainerEngine) + targetSocketBindContainer := getTargetSocketBindContainer(info.OSType) + + composeDestination := filesystem.JoinPaths(stack.ProjectPath, composePathPrefix) + + opts.composeDestination = composeDestination + + cmd, err := d.buildUnpackerCmdForStack(stack, operation, opts) + if err != nil { + return errors.Wrap(err, "unable to build command for unpacker") + } + + log.Debug(). + Str("image", unpackerImg). + Str("cmd", strings.Join(cmd, " ")). + Msg("running unpacker") + + unpackerContainer, err := cli.ContainerCreate(ctx, &container.Config{ + Image: unpackerImg, + Cmd: cmd, + }, &container.HostConfig{ + Binds: []string{ + fmt.Sprintf("%s:%s", composeDestination, composeDestination), + fmt.Sprintf("%s:%s", targetSocketBindHost, targetSocketBindContainer), + }, + }, nil, nil, fmt.Sprintf("portainer-unpacker-%d-%s-%d", stack.ID, stack.Name, librand.Intn(100))) + if err != nil { + return errors.Wrap(err, "unable to create unpacker container") + } + defer func() { + if err := cli.ContainerRemove(ctx, unpackerContainer.ID, container.RemoveOptions{}); err != nil { + log.Warn().Err(err).Msg("unable to remove unpacker container") + } + }() + + if err := cli.ContainerStart(ctx, unpackerContainer.ID, container.StartOptions{}); err != nil { + return errors.Wrap(err, "start unpacker container error") + } + + statusCh, errCh := cli.ContainerWait(ctx, unpackerContainer.ID, container.WaitConditionNotRunning) + select { + case err := <-errCh: + if err != nil { + return errors.Wrap(err, "An error occurred while waiting for the deployment of the stack.") + } + case <-statusCh: + } + + stdErr := &bytes.Buffer{} + + out, err := cli.ContainerLogs(ctx, unpackerContainer.ID, container.LogsOptions{ShowStdout: true, ShowStderr: true}) + if err != nil { + log.Error().Err(err).Msg("unable to get logs from unpacker container") + } else { + if _, err := stdcopy.StdCopy(io.Discard, stdErr, out); err != nil { + log.Warn().Err(err).Msg("unable to parse logs from unpacker container") + } else { + log.Info(). + Str("output", stdErr.String()). + Msg("Stack deployment output") + } + } + + status, err := cli.ContainerInspect(ctx, unpackerContainer.ID) + if err != nil { + return errors.Wrap(err, "fetch container information error") + } + + if status.State.ExitCode == 0 { + return nil + } + + dec := json.NewDecoder(stdErr) + for { + errorStruct := struct { + Level string + Error string + }{} + + if err := dec.Decode(&errorStruct); errors.Is(err, io.EOF) { + break + } else if err != nil { + log.Warn().Err(err).Msg("unable to parse logs from unpacker container") + + continue + } + + if errorStruct.Level == "error" { + return fmt.Errorf("an error occurred while running unpacker container with exit code %d: %s", status.State.ExitCode, errorStruct.Error) + } + } + + return fmt.Errorf("an error occurred while running unpacker container with exit code %d", status.State.ExitCode) +} + +// Creates a docker client with 1 hour timeout +func (d *stackDeployer) createDockerClient(ctx context.Context, endpoint *portainer.Endpoint) (*dockerclient.Client, error) { + timeout := 3600 * time.Second + cli, err := d.ClientFactory.CreateClient(endpoint, "", &timeout) + if err != nil { + return nil, errors.Wrap(err, "unable to create Docker client") + } + + info, err := cli.Info(ctx) + if err != nil { + return nil, errors.Wrap(err, "unable to get agent info") + } + + if isNotInASwarm(&info) { + return cli, nil + } + defer logs.CloseAndLogErr(cli) + + nodes, err := cli.NodeList(ctx, types.NodeListOptions{}) + if err != nil { + return nil, errors.Wrap(err, "unable to list nodes") + } + + if len(nodes) == 0 { + return nil, errors.New("no nodes available") + } + + var managerNode swarm.Node + for _, node := range nodes { + if node.ManagerStatus != nil && node.ManagerStatus.Leader { + managerNode = node + break + } + } + + if managerNode.ID == "" { + return nil, errors.New("no leader node available") + } + + return d.ClientFactory.CreateClient(endpoint, managerNode.Description.Hostname, &timeout) +} + +func getUnpackerImage() string { + image := os.Getenv(composeUnpackerImageEnvVar) + if image == "" { + image = defaultUnpackerImage + } + + return image +} + +func getTargetSocketBindHost(osType string, containerEngine string) string { + targetSocketBind := "//./pipe/docker_engine" + if strings.EqualFold(osType, "linux") { + if containerEngine == portainer.ContainerEnginePodman { + targetSocketBind = "/run/podman/podman.sock" + } else { + targetSocketBind = "/var/run/docker.sock" + } + } + + return targetSocketBind +} + +func getTargetSocketBindContainer(osType string) string { + targetSocketBind := "//./pipe/docker_engine" + if strings.EqualFold(osType, "linux") { + targetSocketBind = "/var/run/docker.sock" + } + + return targetSocketBind +} + +// Per https://stackoverflow.com/a/50590287 and Docker's LocalNodeState possible values +// `LocalNodeStateInactive` means the node is not in a swarm cluster +func isNotInASwarm(info *system.Info) bool { + return info.Swarm.LocalNodeState == swarm.LocalNodeStateInactive +} diff --git a/api/stacks/deployments/deployment_compose_config.go b/api/stacks/deployments/deployment_compose_config.go new file mode 100644 index 0000000..bd214fb --- /dev/null +++ b/api/stacks/deployments/deployment_compose_config.go @@ -0,0 +1,99 @@ +package deployments + +import ( + "context" + "fmt" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/registryutils" + "github.com/portainer/portainer/api/stacks/stackutils" + + "github.com/pkg/errors" + "github.com/rs/zerolog/log" +) + +type ComposeStackDeploymentConfig struct { + stack *portainer.Stack + endpoint *portainer.Endpoint + registries []portainer.Registry + isAdmin bool + user *portainer.User + forcePullImage bool + ForceCreate bool + FileService portainer.FileService + StackDeployer StackDeployer + prune bool +} + +func CreateComposeStackDeploymentConfigTx(tx dataservices.DataStoreTx, securityContext *security.RestrictedRequestContext, stack *portainer.Stack, endpoint *portainer.Endpoint, fileService portainer.FileService, deployer StackDeployer, prune, forcePullImage, forceCreate bool) (*ComposeStackDeploymentConfig, error) { + user, err := tx.User().Read(securityContext.UserID) + if err != nil { + return nil, fmt.Errorf("unable to load user information from the database: %w", err) + } + + registries, err := tx.Registry().ReadAll() + if err != nil { + return nil, fmt.Errorf("unable to retrieve registries from the database: %w", err) + } + + filteredRegistries := security.FilterRegistries(registries, user, securityContext.UserMemberships, endpoint.ID) + + registryutils.RefreshAndPersistECRTokens(tx, filteredRegistries) + + config := &ComposeStackDeploymentConfig{ + stack: stack, + endpoint: endpoint, + registries: filteredRegistries, + prune: prune, + isAdmin: securityContext.IsAdmin, + user: user, + forcePullImage: forcePullImage, + ForceCreate: forceCreate, + FileService: fileService, + StackDeployer: deployer, + } + + return config, nil +} + +func (config *ComposeStackDeploymentConfig) Deploy(ctx context.Context) error { + if config.FileService == nil || config.StackDeployer == nil { + log.Debug().Msg("file service or stack deployer is not initialized") + return errors.New("file service or stack deployer cannot be nil") + } + + isAdminOrEndpointAdmin := stackutils.UserIsAdminOrEndpointAdmin(config.user) + if !isAdminOrEndpointAdmin && config.endpoint != nil { + if err := stackutils.ValidateStackFiles(config.stack, &config.endpoint.SecuritySettings, config.FileService); err != nil { + return err + } + } + + if err := stackutils.ValidateComposeURLs(ctx, config.stack, config.FileService); err != nil { + return err + } + + if stackutils.IsRelativePathStack(config.stack) { + return config.StackDeployer.DeployRemoteComposeStack(ctx, config.user.ID, config.stack, config.endpoint, config.registries, config.prune, config.forcePullImage, config.ForceCreate) + } + + return config.StackDeployer.DeployComposeStack(ctx, config.stack, config.endpoint, config.registries, config.prune, config.forcePullImage, config.ForceCreate) +} + +func (config *ComposeStackDeploymentConfig) Undeploy(ctx context.Context) error { + if config.StackDeployer == nil { + log.Debug().Msg("stack deployer is not initialized") + return errors.New("stack deployer cannot be nil") + } + + if stackutils.IsRelativePathStack(config.stack) { + return config.StackDeployer.UndeployRemoteComposeStack(ctx, config.user.ID, config.stack, config.endpoint) + } + return config.StackDeployer.UndeployComposeStack(ctx, config.stack, config.endpoint) +} + +func (config *ComposeStackDeploymentConfig) GetResponse() string { + return "" +} diff --git a/api/stacks/deployments/deployment_config.go b/api/stacks/deployments/deployment_config.go new file mode 100644 index 0000000..01ee2e7 --- /dev/null +++ b/api/stacks/deployments/deployment_config.go @@ -0,0 +1,9 @@ +package deployments + +import "context" + +type StackDeploymentConfiger interface { + Deploy(ctx context.Context) error + Undeploy(ctx context.Context) error + GetResponse() string +} diff --git a/api/stacks/deployments/deployment_kubernetes_config.go b/api/stacks/deployments/deployment_kubernetes_config.go new file mode 100644 index 0000000..30848d3 --- /dev/null +++ b/api/stacks/deployments/deployment_kubernetes_config.go @@ -0,0 +1,88 @@ +package deployments + +import ( + "context" + "fmt" + "os" + + "github.com/pkg/errors" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/filesystem" + k "github.com/portainer/portainer/api/kubernetes" + "github.com/portainer/portainer/api/stacks/stackutils" + "github.com/rs/zerolog/log" +) + +type KubernetesStackDeploymentConfig struct { + stack *portainer.Stack + kubernetesDeployer portainer.KubernetesDeployer + appLabels k.KubeAppLabels + user *portainer.User + endpoint *portainer.Endpoint + output string +} + +func CreateKubernetesStackDeploymentConfig(stack *portainer.Stack, kubeDeployer portainer.KubernetesDeployer, appLabels k.KubeAppLabels, user *portainer.User, endpoint *portainer.Endpoint) *KubernetesStackDeploymentConfig { + return &KubernetesStackDeploymentConfig{ + stack: stack, + kubernetesDeployer: kubeDeployer, + appLabels: appLabels, + user: user, + endpoint: endpoint, + } +} + +func (config *KubernetesStackDeploymentConfig) Deploy(ctx context.Context) error { + fileNames := stackutils.GetStackFilePaths(config.stack, false) + + manifestFilePaths := make([]string, 0, len(fileNames)) + + tmpDir, err := os.MkdirTemp("", "kub_deployment") + if err != nil { + return errors.Wrap(err, "failed to create temp kub deployment directory") + } + + defer func() { + if err := os.RemoveAll(tmpDir); err != nil { + log.Warn().Err(err).Msg("failed to remove temp kub deployment directory") + } + }() + + for _, fileName := range fileNames { + manifestFilePath := filesystem.JoinPaths(tmpDir, fileName) + manifestContent, err := os.ReadFile(filesystem.JoinPaths(config.stack.ProjectPath, fileName)) + if err != nil { + return errors.Wrap(err, "failed to read manifest file") + } + + manifestContent, err = k.AddAppLabels(manifestContent, config.appLabels.ToMap()) + if err != nil { + return errors.Wrap(err, "failed to add application labels") + } + + if err := filesystem.WriteToFile(manifestFilePath, manifestContent); err != nil { + return errors.Wrap(err, "failed to create temp manifest file") + } + + manifestFilePaths = append(manifestFilePaths, manifestFilePath) + } + + output, err := config.kubernetesDeployer.Deploy(ctx, config.user.ID, config.endpoint, manifestFilePaths, config.stack.Namespace) + if err != nil { + return fmt.Errorf("failed to deploy kubernete stack: %w", err) + } + + config.output = output + return nil +} + +func (config *KubernetesStackDeploymentConfig) Undeploy(ctx context.Context) error { + // Kubernetes is an orchestrator that handles partial failures internally, + // so there is no need to remove failed resources before redeploying. + // This method exists only to satisfy the deployment interface. + return nil +} + +func (config *KubernetesStackDeploymentConfig) GetResponse() string { + return config.output +} diff --git a/api/stacks/deployments/deployment_swarm_config.go b/api/stacks/deployments/deployment_swarm_config.go new file mode 100644 index 0000000..3122420 --- /dev/null +++ b/api/stacks/deployments/deployment_swarm_config.go @@ -0,0 +1,94 @@ +package deployments + +import ( + "context" + "fmt" + "log" + + "github.com/pkg/errors" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/registryutils" + "github.com/portainer/portainer/api/stacks/stackutils" +) + +type SwarmStackDeploymentConfig struct { + stack *portainer.Stack + endpoint *portainer.Endpoint + registries []portainer.Registry + prune bool + isAdmin bool + user *portainer.User + pullImage bool + FileService portainer.FileService + StackDeployer StackDeployer +} + +func CreateSwarmStackDeploymentConfigTx(tx dataservices.DataStoreTx, securityContext *security.RestrictedRequestContext, stack *portainer.Stack, endpoint *portainer.Endpoint, fileService portainer.FileService, deployer StackDeployer, prune bool, pullImage bool) (*SwarmStackDeploymentConfig, error) { + user, err := tx.User().Read(securityContext.UserID) + if err != nil { + return nil, fmt.Errorf("unable to load user information from the database: %w", err) + } + + registries, err := tx.Registry().ReadAll() + if err != nil { + return nil, fmt.Errorf("unable to retrieve registries from the database: %w", err) + } + + filteredRegistries := security.FilterRegistries(registries, user, securityContext.UserMemberships, endpoint.ID) + + registryutils.RefreshAndPersistECRTokens(tx, filteredRegistries) + + config := &SwarmStackDeploymentConfig{ + stack: stack, + endpoint: endpoint, + registries: filteredRegistries, + prune: prune, + isAdmin: securityContext.IsAdmin, + user: user, + pullImage: pullImage, + FileService: fileService, + StackDeployer: deployer, + } + + return config, nil +} + +func (config *SwarmStackDeploymentConfig) Deploy(ctx context.Context) error { + if config.FileService == nil || config.StackDeployer == nil { + log.Println("[deployment, swarm] file service or stack deployer is not initialised") + return errors.New("file service or stack deployer cannot be nil") + } + + isAdminOrEndpointAdmin := stackutils.UserIsAdminOrEndpointAdmin(config.user) + + settings := &config.endpoint.SecuritySettings + + if !isAdminOrEndpointAdmin { + if err := stackutils.ValidateStackFiles(config.stack, settings, config.FileService); err != nil { + return err + } + } + + if err := stackutils.ValidateComposeURLs(ctx, config.stack, config.FileService); err != nil { + return err + } + + if stackutils.IsRelativePathStack(config.stack) { + return config.StackDeployer.DeployRemoteSwarmStack(ctx, config.user.ID, config.stack, config.endpoint, config.registries, config.prune, config.pullImage) + } + + return config.StackDeployer.DeploySwarmStack(ctx, config.stack, config.endpoint, config.registries, config.prune, config.pullImage) +} + +func (config *SwarmStackDeploymentConfig) Undeploy(ctx context.Context) error { + // Swarm is an orchestrator that handles partial failures internally, + // so there is no need to remove failed resources before redeploying. + // This method exists only to satisfy the deployment interface. + return nil +} + +func (config *SwarmStackDeploymentConfig) GetResponse() string { + return "" +} diff --git a/api/stacks/deployments/scheduled.go b/api/stacks/deployments/scheduled.go new file mode 100644 index 0000000..44ee81c --- /dev/null +++ b/api/stacks/deployments/scheduled.go @@ -0,0 +1,35 @@ +package deployments + +import ( + "context" + "time" + + "github.com/pkg/errors" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/scheduler" +) + +func StartStackSchedules(scheduler *scheduler.Scheduler, stackdeployer StackDeployer, datastore dataservices.DataStore, gitService portainer.GitService) error { + stacks, err := datastore.Stack().RefreshableStacks() + if err != nil { + return errors.Wrap(err, "failed to fetch refreshable stacks") + } + + for _, stack := range stacks { + d, err := time.ParseDuration(stack.AutoUpdate.Interval) + if err != nil { + return errors.Wrap(err, "Unable to parse auto update interval") + } + stackID := stack.ID // to be captured by the scheduled function + jobID := scheduler.StartJobEvery(d, func() error { + return RedeployWhenChanged(context.Background(), stackID, stackdeployer, datastore, gitService) + }) + + stack.AutoUpdate.JobID = jobID + if err := datastore.Stack().Update(stack.ID, &stack); err != nil { + return errors.Wrap(err, "failed to update stack job id") + } + } + return nil +} diff --git a/api/stacks/stackbuilders/compose_file_builder.go b/api/stacks/stackbuilders/compose_file_builder.go new file mode 100644 index 0000000..a2f7bd5 --- /dev/null +++ b/api/stacks/stackbuilders/compose_file_builder.go @@ -0,0 +1,50 @@ +package stackbuilders + +import ( + "context" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/filesystem" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/stacks/deployments" +) + +type ComposeStackFileBuilder struct { + StackBuilder + SecurityContext *security.RestrictedRequestContext +} + +// CreateComposeStackFileBuilder creates a builder for compose stacks deployed from a file (either uploaded or provided as text content). +func CreateComposeStackFileBuilder(securityContext *security.RestrictedRequestContext, + dataStore dataservices.DataStore, + fileService portainer.FileService, + stackDeployer deployments.StackDeployer) *ComposeStackFileBuilder { + + return &ComposeStackFileBuilder{ + StackBuilder: CreateStackBuilder(dataStore, fileService, stackDeployer), + SecurityContext: securityContext, + } +} + +func (b *ComposeStackFileBuilder) prepare(_ context.Context, payload *StackPayload, userID portainer.UserID) error { + b.stack.Name = payload.Name + b.stack.Type = portainer.DockerComposeStack + b.stack.EntryPoint = filesystem.ComposeFileDefaultName + b.stack.Env = payload.Env + b.stack.FromAppTemplate = payload.FromAppTemplate + + if err := b.initCreatedBy(userID); err != nil { + return err + } + + return b.storeStackFile(payload.StackFileContent) +} + +func (b *ComposeStackFileBuilder) deploy(ctx context.Context, endpoint *portainer.Endpoint) error { + if err := b.initComposeDeployment(b.SecurityContext, endpoint); err != nil { + return err + } + + return b.deploymentConfiger.Deploy(ctx) +} diff --git a/api/stacks/stackbuilders/compose_git_builder.go b/api/stacks/stackbuilders/compose_git_builder.go new file mode 100644 index 0000000..6eb77f3 --- /dev/null +++ b/api/stacks/stackbuilders/compose_git_builder.go @@ -0,0 +1,52 @@ +package stackbuilders + +import ( + "context" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/scheduler" + "github.com/portainer/portainer/api/stacks/deployments" +) + +type ComposeStackGitBuilder struct { + GitMethodStackBuilder + SecurityContext *security.RestrictedRequestContext +} + +// CreateComposeStackGitBuilder creates a builder for the compose stack (docker standalone) that will be deployed by git repository method +func CreateComposeStackGitBuilder(securityContext *security.RestrictedRequestContext, + dataStore dataservices.DataStore, + fileService portainer.FileService, + gitService portainer.GitService, + scheduler *scheduler.Scheduler, + stackDeployer deployments.StackDeployer) *ComposeStackGitBuilder { + + return &ComposeStackGitBuilder{ + GitMethodStackBuilder: GitMethodStackBuilder{ + StackBuilder: CreateStackBuilder(dataStore, fileService, stackDeployer), + gitService: gitService, + scheduler: scheduler, + }, + SecurityContext: securityContext, + } +} + +func (b *ComposeStackGitBuilder) prepare(ctx context.Context, payload *StackPayload, userID portainer.UserID) error { + b.stack.Name = payload.Name + b.stack.Type = portainer.DockerComposeStack + b.stack.EntryPoint = payload.ComposeFile + b.stack.FromAppTemplate = payload.FromAppTemplate + b.stack.Env = payload.Env + + return b.GitMethodStackBuilder.prepare(ctx, payload, userID) +} + +func (b *ComposeStackGitBuilder) deploy(ctx context.Context, endpoint *portainer.Endpoint) error { + if err := b.initComposeDeployment(b.SecurityContext, endpoint); err != nil { + return err + } + + return b.deploymentConfiger.Deploy(ctx) +} diff --git a/api/stacks/stackbuilders/director.go b/api/stacks/stackbuilders/director.go new file mode 100644 index 0000000..f0a9ea7 --- /dev/null +++ b/api/stacks/stackbuilders/director.go @@ -0,0 +1,93 @@ +package stackbuilders + +import ( + "context" + "time" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/stacks/stackutils" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + + "github.com/rs/zerolog/log" +) + +// stackBuildProcess is the common interface shared by all stack build methods. +type stackBuildProcess interface { + setGeneralInfo(payload *StackPayload, endpoint *portainer.Endpoint) + // prepare handles all pre-save steps: sets type-specific metadata, stores + // files on disk, or clones the git repository. + prepare(ctx context.Context, payload *StackPayload, userID portainer.UserID) error + saveStack() (*portainer.Stack, error) + deploy(ctx context.Context, endpoint *portainer.Endpoint) error + // postDeploy runs after a successful deployment: for git builders it enables + // auto-update; for other builders it is a no-op. + postDeploy(ctx context.Context, stack *portainer.Stack) error +} + +// Build executes the stack build process. It returns the created stack and any +// error encountered during the process. The returned error is of type +// *httperror.HandlerError, which could be an InternalServerError depending on +// the error encountered during the stack build process. +// +// The stack is saved to DB with Status=Deploying and returned immediately. +// Deployment runs in a background goroutine. The caller must poll +// GET /stacks/{id} to track completion. +func Build(ctx context.Context, dataStore dataservices.DataStore, builder stackBuildProcess, payload *StackPayload, endpoint *portainer.Endpoint, userID portainer.UserID) (*portainer.Stack, *httperror.HandlerError) { + builder.setGeneralInfo(payload, endpoint) + + if err := builder.prepare(ctx, payload, userID); err != nil { + return nil, httperror.InternalServerError("Failed to prepare stack", err) + } + + stack, err := builder.saveStack() + if err != nil { + return nil, httperror.InternalServerError("Failed to save stack", err) + } + + go deploy(dataStore, builder, stack.ID, endpoint) + + return stack, nil +} + +func deploy(dataStore dataservices.DataStore, builder stackBuildProcess, stackID portainer.StackID, endpoint *portainer.Endpoint) { + backgroundCtx := context.Background() + ctx, cancel := context.WithTimeout(backgroundCtx, 15*time.Minute) + defer cancel() + + deployErr := builder.deploy(ctx, endpoint) + + var stack *portainer.Stack + + if err := dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + var err error + + stack, err = tx.Stack().Read(stackID) + if err != nil { + return err + } + + stackutils.UpdateStackStatusFromDeploymentResult(stack, deployErr) + + return tx.Stack().Update(stack.ID, stack) + }); err != nil { + log.Error().Err(err). + AnErr("deploy_error", deployErr). + Int("stack_id", int(stackID)). + Str("context", "deploy"). + Msg("Failed to update stack status after async deployment") + + return + } + + if deployErr != nil { + return + } + + if err := builder.postDeploy(backgroundCtx, stack); err != nil { + log.Error().Err(err). + Int("stack_id", int(stackID)). + Str("context", "deploy"). + Msg("Failed to run post-deployment hook") + } +} diff --git a/api/stacks/stackbuilders/director_test.go b/api/stacks/stackbuilders/director_test.go new file mode 100644 index 0000000..a5e1057 --- /dev/null +++ b/api/stacks/stackbuilders/director_test.go @@ -0,0 +1,164 @@ +package stackbuilders + +import ( + "context" + "errors" + "net/http" + "sync/atomic" + "testing" + "time" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/datastore" + httperrors "github.com/portainer/portainer/api/http/errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Stubs + +type stubBuilder struct { + store *datastore.Store + savedStack *portainer.Stack + saveErr error + deployErr error + hookCalled atomic.Bool +} + +func (s *stubBuilder) setGeneralInfo(_ *StackPayload, _ *portainer.Endpoint) { + if s.savedStack == nil { + return + } + + now := time.Now().Unix() + s.savedStack.Status = portainer.StackStatusDeploying + s.savedStack.DeploymentStatus = []portainer.StackDeploymentStatus{ + {Status: portainer.StackStatusDeploying, Time: now}, + } +} + +func (s *stubBuilder) prepare(_ context.Context, _ *StackPayload, _ portainer.UserID) error { + return nil +} + +func (s *stubBuilder) saveStack() (*portainer.Stack, error) { + if s.saveErr != nil { + return nil, s.saveErr + } + + return s.savedStack, s.store.Stack().Create(s.savedStack) +} + +func (s *stubBuilder) deploy(_ context.Context, _ *portainer.Endpoint) error { + return s.deployErr +} + +func (s *stubBuilder) postDeploy(_ context.Context, _ *portainer.Stack) error { + s.hookCalled.Store(true) + + return nil +} + +// Helpers + +func waitForStackStatus(t *testing.T, store *datastore.Store, id portainer.StackID, wantStatus portainer.StackStatus) *portainer.Stack { + t.Helper() + + var stack *portainer.Stack + + require.Eventually(t, func() bool { + var err error + stack, err = store.Stack().Read(id) + + return err == nil && stack.Status == wantStatus + }, 5*time.Second, 10*time.Millisecond, "stack did not reach status %d in time", wantStatus) + + return stack +} + +// Tests + +func TestBuild_SaveError_ErrUnauthorized_ReturnsInternalServerError(t *testing.T) { + t.Parallel() + builder := &stubBuilder{saveErr: httperrors.ErrUnauthorized} + + _, herr := Build(t.Context(), nil, builder, &StackPayload{}, &portainer.Endpoint{}, 0) + + require.NotNil(t, herr) + assert.Equal(t, http.StatusInternalServerError, herr.StatusCode) +} + +func TestBuild_SaveError_ReturnsInternalServerError(t *testing.T) { + t.Parallel() + builder := &stubBuilder{saveErr: errors.New("db error")} + + _, herr := Build(t.Context(), nil, builder, &StackPayload{}, &portainer.Endpoint{}, 0) + + require.NotNil(t, herr) + assert.Equal(t, http.StatusInternalServerError, herr.StatusCode) +} + +func TestBuild_SpawnAsync_DeploySuccess_UpdatesStackStatusToActive(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, true, false) + stack := &portainer.Stack{ID: 1} + builder := &stubBuilder{store: store, savedStack: stack} + + _, herr := Build(t.Context(), store, builder, &StackPayload{}, &portainer.Endpoint{}, 0) + require.Nil(t, herr) + + updated := waitForStackStatus(t, store, stack.ID, portainer.StackStatusActive) + + assert.Equal(t, portainer.StackStatusActive, updated.Status) + require.Len(t, updated.DeploymentStatus, 2) + assert.Equal(t, portainer.StackStatusDeploying, updated.DeploymentStatus[0].Status) + assert.Equal(t, portainer.StackStatusActive, updated.DeploymentStatus[1].Status) +} + +func TestBuild_SpawnAsync_DeployFailure_UpdatesStackStatusToError(t *testing.T) { + t.Parallel() + deployErr := errors.New("failed to pull image nginx:999") + _, store := datastore.MustNewTestStore(t, true, false) + stack := &portainer.Stack{ID: 1} + builder := &stubBuilder{store: store, savedStack: stack, deployErr: deployErr} + + _, herr := Build(t.Context(), store, builder, &StackPayload{}, &portainer.Endpoint{}, 0) + require.Nil(t, herr) + + updated := waitForStackStatus(t, store, stack.ID, portainer.StackStatusError) + + assert.Equal(t, portainer.StackStatusError, updated.Status) + require.Len(t, updated.DeploymentStatus, 2) + assert.Equal(t, portainer.StackStatusDeploying, updated.DeploymentStatus[0].Status) + lastEntry := updated.DeploymentStatus[1] + assert.Equal(t, portainer.StackStatusError, lastEntry.Status) + assert.Equal(t, deployErr.Error(), lastEntry.Message) +} + +func TestBuild_SpawnAsync_PostDeployHook_CalledOnSuccess(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, true, false) + stack := &portainer.Stack{ID: 1} + builder := &stubBuilder{store: store, savedStack: stack} + + _, herr := Build(t.Context(), store, builder, &StackPayload{}, &portainer.Endpoint{}, 0) + require.Nil(t, herr) + + waitForStackStatus(t, store, stack.ID, portainer.StackStatusActive) + + require.Eventually(t, builder.hookCalled.Load, 5*time.Second, 10*time.Millisecond, "post-deploy hook should be called after a successful deployment") +} + +func TestBuild_SpawnAsync_PostDeployHook_NotCalledOnDeployFailure(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, true, false) + stack := &portainer.Stack{ID: 1} + builder := &stubBuilder{store: store, savedStack: stack, deployErr: errors.New("failed to deploy")} + + _, herr := Build(t.Context(), store, builder, &StackPayload{}, &portainer.Endpoint{}, 0) + require.Nil(t, herr) + + waitForStackStatus(t, store, stack.ID, portainer.StackStatusError) + + require.False(t, builder.hookCalled.Load(), "post-deploy hook should not be called after a failed deployment") +} diff --git a/api/stacks/stackbuilders/k8s_git_builder.go b/api/stacks/stackbuilders/k8s_git_builder.go new file mode 100644 index 0000000..6b4c9db --- /dev/null +++ b/api/stacks/stackbuilders/k8s_git_builder.go @@ -0,0 +1,58 @@ +package stackbuilders + +import ( + "context" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/scheduler" + "github.com/portainer/portainer/api/stacks/deployments" +) + +type KubernetesStackGitBuilder struct { + GitMethodStackBuilder + kubernetesDeployer portainer.KubernetesDeployer + user *portainer.User +} + +// CreateKubernetesStackGitBuilder creates a builder for the Kubernetes stack that will be deployed by git repository method +func CreateKubernetesStackGitBuilder(dataStore dataservices.DataStore, + fileService portainer.FileService, + gitService portainer.GitService, + scheduler *scheduler.Scheduler, + stackDeployer deployments.StackDeployer, + kubernetesDeployer portainer.KubernetesDeployer, + user *portainer.User) *KubernetesStackGitBuilder { + + return &KubernetesStackGitBuilder{ + GitMethodStackBuilder: GitMethodStackBuilder{ + StackBuilder: CreateStackBuilder(dataStore, fileService, stackDeployer), + gitService: gitService, + scheduler: scheduler, + }, + kubernetesDeployer: kubernetesDeployer, + user: user, + } +} + +func (b *KubernetesStackGitBuilder) prepare(ctx context.Context, payload *StackPayload, userID portainer.UserID) error { + b.stack.Type = portainer.KubernetesStack + b.stack.Namespace = payload.Namespace + b.stack.Name = payload.StackName + b.stack.EntryPoint = payload.ManifestFile + if err := b.GitMethodStackBuilder.prepare(ctx, payload, userID); err != nil { + return err + } + + b.deploymentConfiger = newKubernetesDeploymentConfig(b.stack, b.kubernetesDeployer, "git", b.user, b.endpoint) + + return nil +} + +func (b *KubernetesStackGitBuilder) deploy(ctx context.Context, endpoint *portainer.Endpoint) error { + return b.deploymentConfiger.Deploy(ctx) +} + +func (b *KubernetesStackGitBuilder) GetResponse() string { + return b.deploymentConfiger.GetResponse() +} diff --git a/api/stacks/stackbuilders/k8s_stack_builder.go b/api/stacks/stackbuilders/k8s_stack_builder.go new file mode 100644 index 0000000..ac3b1dc --- /dev/null +++ b/api/stacks/stackbuilders/k8s_stack_builder.go @@ -0,0 +1,102 @@ +package stackbuilders + +import ( + "context" + "fmt" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/filesystem" + "github.com/portainer/portainer/api/http/client" + k "github.com/portainer/portainer/api/kubernetes" + "github.com/portainer/portainer/api/stacks/deployments" + "github.com/portainer/portainer/api/stacks/stackutils" +) + +type KubernetesStackBuilder struct { + StackBuilder + kubernetesDeployer portainer.KubernetesDeployer + user *portainer.User + kind string + contentFn func(*StackPayload) ([]byte, error) +} + +// CreateK8sStackFileContentBuilder creates a builder for the Kubernetes stack deployed from file content. +func CreateK8sStackFileContentBuilder(dataStore dataservices.DataStore, + fileService portainer.FileService, + stackDeployer deployments.StackDeployer, + kubernetesDeployer portainer.KubernetesDeployer, + user *portainer.User) *KubernetesStackBuilder { + + return &KubernetesStackBuilder{ + StackBuilder: CreateStackBuilder(dataStore, fileService, stackDeployer), + kubernetesDeployer: kubernetesDeployer, + user: user, + kind: "content", + contentFn: func(p *StackPayload) ([]byte, error) { + return p.StackFileContent, nil + }, + } +} + +// CreateKubernetesStackUrlBuilder creates a builder for the Kubernetes stack deployed from a URL. +func CreateKubernetesStackUrlBuilder(dataStore dataservices.DataStore, + fileService portainer.FileService, + stackDeployer deployments.StackDeployer, + kubernetesDeployer portainer.KubernetesDeployer, + user *portainer.User) *KubernetesStackBuilder { + + return &KubernetesStackBuilder{ + StackBuilder: CreateStackBuilder(dataStore, fileService, stackDeployer), + kubernetesDeployer: kubernetesDeployer, + user: user, + kind: "url", + contentFn: func(p *StackPayload) ([]byte, error) { + return client.Get(p.ManifestURL, 30) + }, + } +} + +func (b *KubernetesStackBuilder) prepare(_ context.Context, payload *StackPayload, userID portainer.UserID) error { + b.stack.Name = payload.StackName + b.stack.Type = portainer.KubernetesStack + b.stack.EntryPoint = filesystem.ManifestFileDefaultName + b.stack.Namespace = payload.Namespace + b.stack.FromAppTemplate = payload.FromAppTemplate + + if err := b.initCreatedBy(userID); err != nil { + return err + } + + content, err := b.contentFn(payload) + if err != nil { + return fmt.Errorf("unable to retrieve manifest content: %w", err) + } + + if err := b.storeStackFile(content); err != nil { + return err + } + + b.deploymentConfiger = newKubernetesDeploymentConfig(b.stack, b.kubernetesDeployer, b.kind, b.user, b.endpoint) + + return nil +} + +func (b *KubernetesStackBuilder) deploy(ctx context.Context, endpoint *portainer.Endpoint) error { + return b.deploymentConfiger.Deploy(ctx) +} + +func (b *KubernetesStackBuilder) GetResponse() string { + return b.deploymentConfiger.GetResponse() +} + +func newKubernetesDeploymentConfig(stack *portainer.Stack, deployer portainer.KubernetesDeployer, kind string, user *portainer.User, endpoint *portainer.Endpoint) deployments.StackDeploymentConfiger { + k8sAppLabel := k.KubeAppLabels{ + StackID: int(stack.ID), + StackName: stack.Name, + Owner: stackutils.SanitizeLabel(stack.CreatedBy), + Kind: kind, + } + + return deployments.CreateKubernetesStackDeploymentConfig(stack, deployer, k8sAppLabel, user, endpoint) +} diff --git a/api/stacks/stackbuilders/stack_builder.go b/api/stacks/stackbuilders/stack_builder.go new file mode 100644 index 0000000..0181c40 --- /dev/null +++ b/api/stacks/stackbuilders/stack_builder.go @@ -0,0 +1,140 @@ +package stackbuilders + +import ( + "context" + "fmt" + "strconv" + "time" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/stacks/deployments" + "github.com/portainer/portainer/api/stacks/stackutils" + + "github.com/rs/zerolog/log" +) + +type StackBuilder struct { + stack *portainer.Stack + endpoint *portainer.Endpoint + dataStore dataservices.DataStore + fileService portainer.FileService + stackDeployer deployments.StackDeployer + deploymentConfiger deployments.StackDeploymentConfiger + doCleanUp bool +} + +func CreateStackBuilder(dataStore dataservices.DataStore, fileService portainer.FileService, deployer deployments.StackDeployer) StackBuilder { + return StackBuilder{ + stack: &portainer.Stack{}, + dataStore: dataStore, + fileService: fileService, + stackDeployer: deployer, + doCleanUp: true, + } +} + +func (b *StackBuilder) setGeneralInfo(_ *StackPayload, endpoint *portainer.Endpoint) { + b.endpoint = endpoint + stackID := b.dataStore.Stack().GetNextIdentifier() + b.stack.ID = portainer.StackID(stackID) + b.stack.EndpointID = endpoint.ID + now := time.Now().Unix() + b.stack.CreationDate = now + stackutils.PrepareStackStatusForDeployment(b.stack) +} + +func (b *StackBuilder) deploy(ctx context.Context, _ *portainer.Endpoint) error { + return b.deploymentConfiger.Deploy(ctx) +} + +func (b *StackBuilder) postDeploy(_ context.Context, _ *portainer.Stack) error { return nil } + +func (b *StackBuilder) saveStack() (*portainer.Stack, error) { + defer func() { _ = b.cleanUp() }() + + if err := b.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + if err := tx.Stack().Create(b.stack); err != nil { + return fmt.Errorf("Unable to persist the stack inside the database: %w", err) + } + + return nil + }); err != nil { + return nil, err + } + + b.doCleanUp = false + + return b.stack, nil +} + +func (b *StackBuilder) cleanUp() error { + if !b.doCleanUp { + return nil + } + + if b.stack.WorkflowID != 0 { + if err := b.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + err := tx.Workflow().Delete(b.stack.WorkflowID) + if tx.IsErrObjectNotFound(err) { + return nil + } + + return err + }); err != nil { + log.Error().Err(err).Msg("unable to cleanup orphan workflow records after failed stack creation") + } + } + + if err := b.fileService.RemoveDirectory(b.stack.ProjectPath); err != nil { + log.Error().Err(err).Msg("unable to cleanup stack creation") + } + + return nil +} + +func (b *StackBuilder) initCreatedBy(userID portainer.UserID) error { + return b.dataStore.ViewTx(func(tx dataservices.DataStoreTx) error { + user, err := tx.User().Read(userID) + if err != nil { + return fmt.Errorf("failed to find stack author: %w", err) + } + b.stack.CreatedBy = user.Username + return nil + }) +} + +func (b *StackBuilder) storeStackFile(content []byte) error { + stackFolder := strconv.Itoa(int(b.stack.ID)) + projectPath, err := b.fileService.StoreStackFileFromBytes(stackFolder, b.stack.EntryPoint, content) + if err != nil { + return err + } + + b.stack.ProjectPath = projectPath + + return nil +} + +func (b *StackBuilder) initComposeDeployment(secCtx *security.RestrictedRequestContext, endpoint *portainer.Endpoint) error { + config, err := deployments.CreateComposeStackDeploymentConfigTx(b.dataStore, secCtx, b.stack, endpoint, b.fileService, b.stackDeployer, false, false, false) + if err != nil { + return fmt.Errorf("failed to create compose deployment config: %w", err) + } + + b.deploymentConfiger = config + + return nil +} + +func (b *StackBuilder) initSwarmDeployment(secCtx *security.RestrictedRequestContext, endpoint *portainer.Endpoint) error { + config, err := deployments.CreateSwarmStackDeploymentConfigTx(b.dataStore, secCtx, b.stack, endpoint, b.fileService, b.stackDeployer, false, true) + if err != nil { + return fmt.Errorf("failed to create swarm deployment config: %w", err) + } + + b.deploymentConfiger = config + + return nil +} diff --git a/api/stacks/stackbuilders/stack_git_builder.go b/api/stacks/stackbuilders/stack_git_builder.go new file mode 100644 index 0000000..d52e6f1 --- /dev/null +++ b/api/stacks/stackbuilders/stack_git_builder.go @@ -0,0 +1,209 @@ +package stackbuilders + +import ( + "context" + "fmt" + "strconv" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/dataservices/source" + "github.com/portainer/portainer/api/filesystem" + gittypes "github.com/portainer/portainer/api/git/types" + "github.com/portainer/portainer/api/gitops/workflows" + "github.com/portainer/portainer/api/scheduler" + "github.com/portainer/portainer/api/stacks/deployments" + "github.com/portainer/portainer/api/stacks/stackutils" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/ssrf" +) + +type GitMethodStackBuilder struct { + StackBuilder + gitService portainer.GitService + scheduler *scheduler.Scheduler +} + +func (b *GitMethodStackBuilder) prepare(ctx context.Context, payload *StackPayload, userID portainer.UserID) error { + b.stack.AdditionalFiles = payload.AdditionalFiles + b.stack.AutoUpdate = payload.AutoUpdate + + if err := b.initCreatedBy(userID); err != nil { + return err + } + + var userContext source.UserContext + if err := b.dataStore.ViewTx(func(tx dataservices.DataStoreTx) error { + user, err := tx.User().Read(userID) + if err != nil { + return httperror.InternalServerError("Unable to read user", err) + } + memberships, err := tx.TeamMembership().TeamMembershipsByUserID(userID) + if err != nil { + return httperror.InternalServerError("Unable to read user team memberships", err) + } + + userContext = source.NewUserContext(user, memberships) + return nil + }); err != nil { + return err + } + + var repoConfig gittypes.RepoConfig + var sourceID portainer.SourceID + + if payload.SourceID != 0 { + src, err := b.dataStore.Source().Read(userContext, payload.SourceID) + if err != nil { + return fmt.Errorf("failed to read source: %w", err) + } + if src.Git == nil { + return fmt.Errorf("source %d has no git configuration", payload.SourceID) + } + + repoConfig.URL = src.Git.URL + repoConfig.Authentication = src.Git.Authentication + repoConfig.TLSSkipVerify = src.Git.TLSSkipVerify + repoConfig.ReferenceName = payload.ReferenceName + sourceID = src.ID + } else { + if payload.Authentication { + repoConfig.Authentication = &gittypes.GitAuthentication{ + Username: payload.Username, + Password: payload.Password, + } + } + + repoConfig.URL = payload.URL + repoConfig.ReferenceName = payload.ReferenceName + repoConfig.TLSSkipVerify = payload.TLSSkipVerify + } + + repoConfig.ConfigFilePath = payload.ComposeFile + if payload.ComposeFile == "" { + repoConfig.ConfigFilePath = filesystem.ComposeFileDefaultName + } + + // If a manifest file is specified (for kube git apps), then use it instead of the default compose file name + if payload.ManifestFile != "" { + repoConfig.ConfigFilePath = payload.ManifestFile + } + + stackFolder := strconv.Itoa(int(b.stack.ID)) + // Set the project path on the disk + b.stack.ProjectPath = b.fileService.GetStackProjectPath(stackFolder) + + getProjectPath := func() string { + stackFolder := fmt.Sprintf("%d", b.stack.ID) + return b.fileService.GetStackProjectPath(stackFolder) + } + + if err := ssrf.CheckURL(ctx, repoConfig.URL); err != nil { + return fmt.Errorf("repository URL blocked by SSRF policy: %w", err) + } + + commitHash, err := stackutils.DownloadGitRepository(ctx, repoConfig, b.gitService, getProjectPath) + if err != nil { + if txErr := b.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + return workflows.SaveSourceStatus(tx, userContext, sourceID, err) + }); txErr != nil { + return fmt.Errorf("failed to download git repository: %w (and failed to persist status: %w)", err, txErr) + } + + return fmt.Errorf("failed to download git repository: %w", err) + } + + // Update the latest commit id + repoConfig.ConfigHash = commitHash + + var workflowID portainer.WorkflowID + + if err := b.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + file := portainer.ArtifactFile{ + Path: repoConfig.ConfigFilePath, + Ref: repoConfig.ReferenceName, + Hash: repoConfig.ConfigHash, + RefStatus: portainer.SourceStatusHealthy, + PathStatus: portainer.SourceStatusHealthy, + } + + if sourceID != 0 { + file.SourceID = sourceID + } else { + repoConfig.URL = gittypes.SanitizeURL(repoConfig.URL) + + src, err := workflows.FindOrCreateGitSource(tx, userContext, &portainer.Source{ + Name: gittypes.RepoName(repoConfig.URL), + Type: portainer.SourceTypeGit, + Git: &gittypes.GitSource{ + URL: repoConfig.URL, + Authentication: repoConfig.Authentication, + TLSSkipVerify: repoConfig.TLSSkipVerify, + }, + }) + if err != nil { + return fmt.Errorf("failed to find or create source: %w", err) + } + + file.SourceID = src.ID + } + + if err := workflows.SaveSourceStatus(tx, userContext, file.SourceID, nil); err != nil { + return fmt.Errorf("failed to persist source sync status: %w", err) + } + + wf := &portainer.Workflow{ + Name: b.stack.Name, + Artifacts: []portainer.Artifact{{ + StackID: b.stack.ID, + Files: []portainer.ArtifactFile{file}, + }}, + } + if err := tx.Workflow().Create(wf); err != nil { + return fmt.Errorf("failed to create workflow: %w", err) + } + + workflowID = wf.ID + + return nil + }); err != nil { + return err + } + + b.stack.WorkflowID = workflowID + + return nil +} + +// postDeploy enables the auto-update scheduler job for the stack if configured, +// and persists the resulting job ID back to the database. +func (b *GitMethodStackBuilder) postDeploy(ctx context.Context, stack *portainer.Stack) error { + if stack.AutoUpdate == nil || stack.AutoUpdate.Interval == "" { + return nil + } + + jobID, err := deployments.StartAutoupdate(ctx, stack.ID, + stack.AutoUpdate.Interval, + b.scheduler, + b.stackDeployer, + b.dataStore, + b.gitService) + if err != nil { + return err + } + + return b.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + s, err := tx.Stack().Read(stack.ID) + if err != nil { + return fmt.Errorf("Unable to retrieve the stack from the database: %w", err) + } + + s.AutoUpdate.JobID = jobID + + if err := tx.Stack().Update(s.ID, s); err != nil { + return fmt.Errorf("Unable to update the stack inside the database: %w", err) + } + + return nil + }) +} diff --git a/api/stacks/stackbuilders/stack_git_builder_test.go b/api/stacks/stackbuilders/stack_git_builder_test.go new file mode 100644 index 0000000..384673e --- /dev/null +++ b/api/stacks/stackbuilders/stack_git_builder_test.go @@ -0,0 +1,196 @@ +package stackbuilders + +import ( + "context" + "errors" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices/source" + "github.com/portainer/portainer/api/datastore" + gittypes "github.com/portainer/portainer/api/git/types" + "github.com/portainer/portainer/api/gitops/workflows" + "github.com/portainer/portainer/api/internal/testhelpers" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var adminUserContext = source.InsecureNewAdminContext() + +// stubFileService satisfies portainer.FileService for git builder tests. +type stubFileService struct { + portainer.FileService +} + +func (s *stubFileService) GetStackProjectPath(stackIdentifier string) string { + return "/data/compose/" + stackIdentifier +} + +func newGitMethodBuilder(t *testing.T, commitHash string) *GitMethodStackBuilder { + t.Helper() + _, store := datastore.MustNewTestStore(t, false, false) + require.NoError(t, store.User().Create(&portainer.User{ID: 1, Username: "testuser", Role: portainer.AdministratorRole})) + return &GitMethodStackBuilder{ + StackBuilder: StackBuilder{ + stack: &portainer.Stack{}, + fileService: &stubFileService{}, + dataStore: store, + }, + gitService: testhelpers.NewGitService(nil, commitHash), + } +} + +func TestGitMethodStackBuilder_WithSourceID_ReferencesExistingSource(t *testing.T) { + t.Parallel() + builder := newGitMethodBuilder(t, "abc123") + builder.stack.ID = 1 + + src := &portainer.Source{ + Name: "my-repo", + Type: portainer.SourceTypeGit, + Git: &gittypes.GitSource{ + URL: "https://github.com/org/private-repo", + Authentication: &gittypes.GitAuthentication{ + Username: "git-user", + Password: "git-token", + }, + }, + } + require.NoError(t, builder.dataStore.Source().Create(adminUserContext, src)) + + payload := &StackPayload{ + RepositoryConfigPayload: RepositoryConfigPayload{ + SourceID: src.ID, + ReferenceName: "refs/heads/main", + }, + } + + err := builder.prepare(context.Background(), payload, portainer.UserID(1)) + require.NoError(t, err) + + // Workflow Artifact must reference the existing Source — not a new one. + referencedSourceID := builderWorkflowSourceID(t, builder) + assert.Equal(t, src.ID, referencedSourceID) + + // Only one Source exists — no duplicate was created. + allSources, err := builder.dataStore.Source().ReadAll(adminUserContext) + require.NoError(t, err) + assert.Len(t, allSources, 1) + + // The merged git config picks up the Source URL/auth. + readSrc, artifact, err := workflows.GitSourceAndArtifactForStack(builder.dataStore, adminUserContext, builder.stack.WorkflowID, builder.stack.ID) + require.NoError(t, err) + merged := workflows.MergeSourceAndFile(readSrc, artifact) + assert.Equal(t, "https://github.com/org/private-repo", merged.URL) + assert.Equal(t, "refs/heads/main", merged.ReferenceName) + require.NotNil(t, merged.Authentication) + assert.Equal(t, "git-user", merged.Authentication.Username) +} + +func TestGitMethodStackBuilder_WithSourceID_PersistsHealthyStatusOnSuccess(t *testing.T) { + t.Parallel() + builder := newGitMethodBuilder(t, "abc123") + builder.stack.ID = 1 + + src := &portainer.Source{ + Name: "my-repo", + Type: portainer.SourceTypeGit, + Git: &gittypes.GitSource{URL: "https://github.com/org/private-repo"}, + } + require.NoError(t, builder.dataStore.Source().Create(adminUserContext, src)) + + payload := &StackPayload{ + RepositoryConfigPayload: RepositoryConfigPayload{ + SourceID: src.ID, + ReferenceName: "refs/heads/main", + }, + } + + err := builder.prepare(t.Context(), payload, portainer.UserID(1)) + require.NoError(t, err) + + updatedSrc, err := builder.dataStore.Source().Read(adminUserContext, src.ID) + require.NoError(t, err) + assert.Equal(t, portainer.SourceStatusHealthy, updatedSrc.Status) + assert.NotZero(t, updatedSrc.LastSync) +} + +func TestGitMethodStackBuilder_WithSourceID_PersistsErrorStatusOnCloneFailure(t *testing.T) { + t.Parallel() + builder := newGitMethodBuilder(t, "abc123") + cloneErr := errors.New("failed to clone") + builder.gitService = testhelpers.NewGitService(cloneErr, "abc123") + builder.stack.ID = 1 + + src := &portainer.Source{ + Name: "my-repo", + Type: portainer.SourceTypeGit, + Git: &gittypes.GitSource{URL: "https://github.com/org/private-repo"}, + } + require.NoError(t, builder.dataStore.Source().Create(adminUserContext, src)) + + payload := &StackPayload{ + RepositoryConfigPayload: RepositoryConfigPayload{ + SourceID: src.ID, + ReferenceName: "refs/heads/main", + }, + } + + err := builder.prepare(t.Context(), payload, portainer.UserID(1)) + require.Error(t, err) + + updatedSrc, err := builder.dataStore.Source().Read(adminUserContext, src.ID) + require.NoError(t, err) + assert.Equal(t, portainer.SourceStatusError, updatedSrc.Status) + assert.Contains(t, updatedSrc.StatusError, cloneErr.Error()) + assert.Zero(t, updatedSrc.LastSync) +} + +func TestGitMethodStackBuilder_WithMissingSourceID_ReturnsError(t *testing.T) { + t.Parallel() + builder := newGitMethodBuilder(t, "abc123") + builder.stack.ID = 2 + + payload := &StackPayload{ + RepositoryConfigPayload: RepositoryConfigPayload{ + SourceID: portainer.SourceID(999), // does not exist + }, + } + + err := builder.prepare(context.Background(), payload, portainer.UserID(1)) + require.Error(t, err) +} + +func TestGitMethodStackBuilder_WithoutSourceID_InlinePathStillWorks(t *testing.T) { + t.Parallel() + builder := newGitMethodBuilder(t, "feedcafe") + builder.stack.ID = 4 + + payload := &StackPayload{ + RepositoryConfigPayload: RepositoryConfigPayload{ + URL: "https://github.com/org/public-repo", + ReferenceName: "refs/heads/main", + }, + } + + err := builder.prepare(context.Background(), payload, portainer.UserID(1)) + require.NoError(t, err) + + // A Source was created via the inline path. + allSources, err := builder.dataStore.Source().ReadAll(adminUserContext) + require.NoError(t, err) + assert.Len(t, allSources, 1) + assert.Equal(t, "https://github.com/org/public-repo", allSources[0].Git.URL) +} + +// builderWorkflowSourceID returns the first SourceID referenced by the Workflow Artifact for this stack. +func builderWorkflowSourceID(t *testing.T, builder *GitMethodStackBuilder) portainer.SourceID { + t.Helper() + require.NotZero(t, builder.stack.WorkflowID) + + wf, err := builder.dataStore.Workflow().Read(builder.stack.WorkflowID) + require.NoError(t, err) + require.Len(t, wf.Artifacts, 1) + require.Len(t, wf.Artifacts[0].Files, 1) + return wf.Artifacts[0].Files[0].SourceID +} diff --git a/api/stacks/stackbuilders/stack_payload.go b/api/stacks/stackbuilders/stack_payload.go new file mode 100644 index 0000000..8ef0059 --- /dev/null +++ b/api/stacks/stackbuilders/stack_payload.go @@ -0,0 +1,54 @@ +package stackbuilders + +import ( + portainer "github.com/portainer/portainer/api" +) + +// StackPayload contains all the fields for creating a stack with all kinds of methods +type StackPayload struct { + // Name of the stack + Name string `example:"myStack" validate:"required"` + // Swarm cluster identifier + SwarmID string `example:"jpofkc0i9uo9wtx1zesuk649w" validate:"required"` + // Stack file data + StackFileContent []byte + Webhook string + // A list of environment(endpoint) variables used during stack deployment + Env []portainer.Pair + // Optional GitOps update configuration + AutoUpdate *portainer.AutoUpdateSettings + // Whether the stack is from a app template + FromAppTemplate bool `example:"false"` + // Kubernetes stack name + StackName string + // Kubernetes stack namespace + Namespace string + // Path to the k8s Stack file. Used by k8s git repository method + ManifestFile string + // URL to the k8s Stack file. Used by k8s git repository method + ManifestURL string + // Path to the Stack file inside the Git repository + ComposeFile string `example:"docker-compose.yml" default:"docker-compose.yml"` + // Applicable when deploying with multiple stack files + AdditionalFiles []string `example:"[nz.compose.yml, uat.compose.yml]"` + // Git repository configuration of a stack + RepositoryConfigPayload +} + +type RepositoryConfigPayload struct { + // SourceID references an existing Source. + // When non-zero, only ReferenceName is still applied. + SourceID portainer.SourceID + // URL of a Git repository hosting the Stack file + URL string `example:"https://github.com/openfaas/faas"` + // Reference name of a Git repository hosting the Stack file + ReferenceName string `example:"refs/heads/master"` + // Use basic authentication to clone the Git repository + Authentication bool `example:"true"` + // Username used in basic authentication. Required when RepositoryAuthentication is true + Username string `example:"myGitUsername"` + // Password used in basic authentication. Required when RepositoryAuthentication is true + Password string `example:"myGitPassword"` + // TLSSkipVerify skips SSL verification when cloning the Git repository + TLSSkipVerify bool `example:"false"` +} diff --git a/api/stacks/stackbuilders/swarm_file_builder.go b/api/stacks/stackbuilders/swarm_file_builder.go new file mode 100644 index 0000000..13720b9 --- /dev/null +++ b/api/stacks/stackbuilders/swarm_file_builder.go @@ -0,0 +1,51 @@ +package stackbuilders + +import ( + "context" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/filesystem" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/stacks/deployments" +) + +type SwarmStackFileBuilder struct { + StackBuilder + SecurityContext *security.RestrictedRequestContext +} + +// CreateSwarmStackFileBuilder creates a builder for swarm stacks deployed from a file (either uploaded or provided as text content). +func CreateSwarmStackFileBuilder(securityContext *security.RestrictedRequestContext, + dataStore dataservices.DataStore, + fileService portainer.FileService, + stackDeployer deployments.StackDeployer) *SwarmStackFileBuilder { + + return &SwarmStackFileBuilder{ + StackBuilder: CreateStackBuilder(dataStore, fileService, stackDeployer), + SecurityContext: securityContext, + } +} + +func (b *SwarmStackFileBuilder) prepare(_ context.Context, payload *StackPayload, userID portainer.UserID) error { + b.stack.Name = payload.Name + b.stack.Type = portainer.DockerSwarmStack + b.stack.SwarmID = payload.SwarmID + b.stack.EntryPoint = filesystem.ComposeFileDefaultName + b.stack.Env = payload.Env + b.stack.FromAppTemplate = payload.FromAppTemplate + + if err := b.initCreatedBy(userID); err != nil { + return err + } + + return b.storeStackFile(payload.StackFileContent) +} + +func (b *SwarmStackFileBuilder) deploy(ctx context.Context, endpoint *portainer.Endpoint) error { + if err := b.initSwarmDeployment(b.SecurityContext, endpoint); err != nil { + return err + } + + return b.deploymentConfiger.Deploy(ctx) +} diff --git a/api/stacks/stackbuilders/swarm_git_builder.go b/api/stacks/stackbuilders/swarm_git_builder.go new file mode 100644 index 0000000..e9ecdbc --- /dev/null +++ b/api/stacks/stackbuilders/swarm_git_builder.go @@ -0,0 +1,54 @@ +package stackbuilders + +import ( + "context" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/scheduler" + "github.com/portainer/portainer/api/stacks/deployments" +) + +type SwarmStackGitBuilder struct { + GitMethodStackBuilder + SecurityContext *security.RestrictedRequestContext +} + +// CreateSwarmStackGitBuilder creates a builder for the swarm stack that will be deployed by git repository method +func CreateSwarmStackGitBuilder(securityContext *security.RestrictedRequestContext, + dataStore dataservices.DataStore, + fileService portainer.FileService, + gitService portainer.GitService, + scheduler *scheduler.Scheduler, + stackDeployer deployments.StackDeployer) *SwarmStackGitBuilder { + + return &SwarmStackGitBuilder{ + GitMethodStackBuilder: GitMethodStackBuilder{ + StackBuilder: CreateStackBuilder(dataStore, fileService, stackDeployer), + gitService: gitService, + scheduler: scheduler, + }, + SecurityContext: securityContext, + } +} + +func (b *SwarmStackGitBuilder) prepare(ctx context.Context, payload *StackPayload, userID portainer.UserID) error { + b.stack.Name = payload.Name + b.stack.Type = portainer.DockerSwarmStack + b.stack.SwarmID = payload.SwarmID + b.stack.EntryPoint = payload.ComposeFile + b.stack.FromAppTemplate = payload.FromAppTemplate + b.stack.Env = payload.Env + + return b.GitMethodStackBuilder.prepare(ctx, payload, userID) +} + +// deploy creates deployment configuration for swarm stack +func (b *SwarmStackGitBuilder) deploy(ctx context.Context, endpoint *portainer.Endpoint) error { + if err := b.initSwarmDeployment(b.SecurityContext, endpoint); err != nil { + return err + } + + return b.deploymentConfiger.Deploy(ctx) +} diff --git a/api/stacks/stackutils/env.go b/api/stacks/stackutils/env.go new file mode 100644 index 0000000..826bd38 --- /dev/null +++ b/api/stacks/stackutils/env.go @@ -0,0 +1,34 @@ +package stackutils + +import ( + "maps" + "os" + "path" + "strings" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/filesystem" + + "github.com/compose-spec/compose-go/v2/dotenv" +) + +// BuildEnvMap builds the environment variable map for stack validation/loading. +// Priority (lowest to highest): OS env, .env file, stack.Env +func BuildEnvMap(stack *portainer.Stack) map[string]string { + env := make(map[string]string, len(os.Environ())) + for _, e := range os.Environ() { + k, v, _ := strings.Cut(e, "=") + env[k] = v + } + + dotEnvPath := filesystem.JoinPaths(stack.ProjectPath, path.Dir(stack.EntryPoint), ".env") + if dotVars, err := dotenv.Read(dotEnvPath); err == nil { + maps.Copy(env, dotVars) + } + + for _, pair := range stack.Env { + env[pair.Name] = pair.Value + } + + return env +} diff --git a/api/stacks/stackutils/gitops.go b/api/stacks/stackutils/gitops.go new file mode 100644 index 0000000..3cff71f --- /dev/null +++ b/api/stacks/stackutils/gitops.go @@ -0,0 +1,61 @@ +package stackutils + +import ( + "context" + "fmt" + + "github.com/pkg/errors" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/git" + gittypes "github.com/portainer/portainer/api/git/types" +) + +var ( + ErrStackAlreadyExists = errors.New("A stack already exists with this name") + ErrWebhookIDAlreadyExists = errors.New("A webhook ID already exists") +) + +// DownloadGitRepository downloads the target git repository on the disk +// The first return value represents the commit hash of the downloaded git repository +func DownloadGitRepository(ctx context.Context, config gittypes.RepoConfig, gitService portainer.GitService, getProjectPath func() string) (string, error) { + username := "" + password := "" + if config.Authentication != nil { + username = config.Authentication.Username + password = config.Authentication.Password + } + + projectPath := getProjectPath() + err := gitService.CloneRepository( + ctx, + projectPath, + config.URL, + config.ReferenceName, + username, + password, + config.TLSSkipVerify, + ) + if err != nil { + if errors.Is(err, gittypes.ErrAuthenticationFailure) { + newErr := git.ErrInvalidGitCredential + return "", newErr + } + + newErr := fmt.Errorf("unable to clone git repository: %w", err) + return "", newErr + } + + commitID, err := gitService.LatestCommitID( + ctx, + config.URL, + config.ReferenceName, + username, + password, + config.TLSSkipVerify, + ) + if err != nil { + newErr := fmt.Errorf("unable to fetch git repository id: %w", err) + return "", newErr + } + return commitID, nil +} diff --git a/api/stacks/stackutils/gitops_test.go b/api/stacks/stackutils/gitops_test.go new file mode 100644 index 0000000..b4d8e7a --- /dev/null +++ b/api/stacks/stackutils/gitops_test.go @@ -0,0 +1,101 @@ +package stackutils + +import ( + "context" + "errors" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/git" + gittypes "github.com/portainer/portainer/api/git/types" + + "github.com/stretchr/testify/require" +) + +type mockGitSvc struct { + cloneErr error + commitID string + commitErr error +} + +func (m *mockGitSvc) CloneRepository(_ context.Context, _, _, _, _, _ string, _ bool) error { + return m.cloneErr +} + +func (m *mockGitSvc) LatestCommitID(_ context.Context, _, _, _, _ string, _ bool) (string, error) { + return m.commitID, m.commitErr +} + +func (m *mockGitSvc) ListRefs(_ context.Context, _, _, _ string, _ bool, _ bool) ([]string, error) { + return nil, nil +} + +func (m *mockGitSvc) ListFiles(_ context.Context, _, _, _, _ string, _, _ bool, _ []string, _ bool) ([]string, error) { + return nil, nil +} + +var _ portainer.GitService = (*mockGitSvc)(nil) + +func TestDownloadGitRepository_Success(t *testing.T) { + t.Parallel() + + svc := &mockGitSvc{commitID: "abc123"} + cfg := gittypes.RepoConfig{ + URL: "https://github.com/x/repo", + ReferenceName: "refs/heads/main", + } + + commitID, err := DownloadGitRepository(t.Context(), cfg, svc, func() string { return t.TempDir() }) + require.NoError(t, err) + require.Equal(t, "abc123", commitID) +} + +func TestDownloadGitRepository_NilAuthentication(t *testing.T) { + t.Parallel() + + svc := &mockGitSvc{commitID: "deadbeef"} + cfg := gittypes.RepoConfig{ + URL: "https://github.com/x/repo", + Authentication: nil, + } + + commitID, err := DownloadGitRepository(t.Context(), cfg, svc, func() string { return t.TempDir() }) + require.NoError(t, err) + require.Equal(t, "deadbeef", commitID) +} + +func TestDownloadGitRepository_AuthenticationFailure(t *testing.T) { + t.Parallel() + + svc := &mockGitSvc{cloneErr: gittypes.ErrAuthenticationFailure} + cfg := gittypes.RepoConfig{URL: "https://github.com/x/private"} + + _, err := DownloadGitRepository(t.Context(), cfg, svc, func() string { return t.TempDir() }) + require.Error(t, err) + require.ErrorIs(t, err, git.ErrInvalidGitCredential) +} + +func TestDownloadGitRepository_OtherCloneError(t *testing.T) { + t.Parallel() + + cloneErr := errors.New("network timeout") + svc := &mockGitSvc{cloneErr: cloneErr} + cfg := gittypes.RepoConfig{URL: "https://github.com/x/repo"} + + _, err := DownloadGitRepository(t.Context(), cfg, svc, func() string { return t.TempDir() }) + require.Error(t, err) + require.ErrorIs(t, err, cloneErr) + require.NotErrorIs(t, err, git.ErrInvalidGitCredential) +} + +func TestDownloadGitRepository_LatestCommitIDError(t *testing.T) { + t.Parallel() + + commitErr := errors.New("remote unreachable") + svc := &mockGitSvc{commitErr: commitErr} + cfg := gittypes.RepoConfig{URL: "https://github.com/x/repo"} + + _, err := DownloadGitRepository(t.Context(), cfg, svc, func() string { return t.TempDir() }) + require.Error(t, err) + require.ErrorIs(t, err, commitErr) +} diff --git a/api/stacks/stackutils/stack_status.go b/api/stacks/stackutils/stack_status.go new file mode 100644 index 0000000..2ce34a7 --- /dev/null +++ b/api/stacks/stackutils/stack_status.go @@ -0,0 +1,61 @@ +package stackutils + +import ( + "time" + + portainer "github.com/portainer/portainer/api" +) + +// PrepareStackStatusForDeployment transitions a stack into the deploying state before a deployment begins. +// It saves the current status in DeploymentStartStatus so that pre-deployment actions can be determined +// (e.g. whether to undeploy before redeploying), sets Status to StackStatusDeploying, and resets the +// DeploymentStatus history with a single deploying entry. +func PrepareStackStatusForDeployment(stack *portainer.Stack) { + stack.DeploymentStartStatus = stack.Status + stack.Status = portainer.StackStatusDeploying + stack.DeploymentStatus = []portainer.StackDeploymentStatus{ + {Status: portainer.StackStatusDeploying, Time: time.Now().Unix()}, + } +} + +// UpdateStackStatusFromDeploymentResult updates a stack's status after a deployment attempt completes. +// On success (err == nil) the stack is marked active; on failure it is marked as errored with the error message recorded. +func UpdateStackStatusFromDeploymentResult(stack *portainer.Stack, err error) { + if err != nil { + stack.Status = portainer.StackStatusError + stack.DeploymentStatus = append(stack.DeploymentStatus, portainer.StackDeploymentStatus{ + Status: portainer.StackStatusError, + Time: time.Now().Unix(), + Message: err.Error(), + }) + + return + } + + stack.Status = portainer.StackStatusActive + stack.DeploymentStatus = append(stack.DeploymentStatus, portainer.StackDeploymentStatus{ + Status: portainer.StackStatusActive, + Time: time.Now().Unix(), + }) +} + +// UpdateStackStatusFromUndeploymentResult updates a stack's status after an undeployment attempt completes. +// On success (err == nil) the stack is marked inactive; on failure it is marked as errored with the error message recorded. +func UpdateStackStatusFromUndeploymentResult(stack *portainer.Stack, err error) { + if err != nil { + stack.Status = portainer.StackStatusError + stack.DeploymentStatus = append(stack.DeploymentStatus, portainer.StackDeploymentStatus{ + Status: portainer.StackStatusError, + Time: time.Now().Unix(), + Message: err.Error(), + }) + + return + } + + stack.Status = portainer.StackStatusInactive + stack.DeploymentStatus = append(stack.DeploymentStatus, portainer.StackDeploymentStatus{ + Status: portainer.StackStatusInactive, + Time: time.Now().Unix(), + }) +} diff --git a/api/stacks/stackutils/stack_status_test.go b/api/stacks/stackutils/stack_status_test.go new file mode 100644 index 0000000..b9219b7 --- /dev/null +++ b/api/stacks/stackutils/stack_status_test.go @@ -0,0 +1,92 @@ +package stackutils + +import ( + "errors" + "testing" + + portainer "github.com/portainer/portainer/api" + + "github.com/stretchr/testify/require" +) + +func TestPrepareStackStatusForDeployment(t *testing.T) { + t.Parallel() + + stack := &portainer.Stack{ + Status: portainer.StackStatusActive, + } + + PrepareStackStatusForDeployment(stack) + + require.Equal(t, portainer.StackStatusActive, stack.DeploymentStartStatus) + require.Equal(t, portainer.StackStatusDeploying, stack.Status) + require.Len(t, stack.DeploymentStatus, 1) + require.Equal(t, portainer.StackStatusDeploying, stack.DeploymentStatus[0].Status) + require.Positive(t, stack.DeploymentStatus[0].Time) +} + +func TestUpdateStackStatusFromDeploymentResult(t *testing.T) { + t.Parallel() + + t.Run("on error", func(t *testing.T) { + t.Parallel() + + stack := &portainer.Stack{} + deployErr := errors.New("deployment failed") + + UpdateStackStatusFromDeploymentResult(stack, deployErr) + + require.Equal(t, portainer.StackStatusError, stack.Status) + require.Len(t, stack.DeploymentStatus, 1) + require.Equal(t, portainer.StackStatusError, stack.DeploymentStatus[0].Status) + require.Equal(t, deployErr.Error(), stack.DeploymentStatus[0].Message) + require.Positive(t, stack.DeploymentStatus[0].Time) + }) + + t.Run("on success", func(t *testing.T) { + t.Parallel() + + stack := &portainer.Stack{} + + UpdateStackStatusFromDeploymentResult(stack, nil) + + require.Equal(t, portainer.StackStatusActive, stack.Status) + require.Len(t, stack.DeploymentStatus, 1) + require.Equal(t, portainer.StackStatusActive, stack.DeploymentStatus[0].Status) + require.Empty(t, stack.DeploymentStatus[0].Message) + require.Positive(t, stack.DeploymentStatus[0].Time) + }) +} + +func TestUpdateStackStatusFromUndeploymentResult(t *testing.T) { + t.Parallel() + + t.Run("on error", func(t *testing.T) { + t.Parallel() + + stack := &portainer.Stack{} + undeployErr := errors.New("undeployment failed") + + UpdateStackStatusFromUndeploymentResult(stack, undeployErr) + + require.Equal(t, portainer.StackStatusError, stack.Status) + require.Len(t, stack.DeploymentStatus, 1) + require.Equal(t, portainer.StackStatusError, stack.DeploymentStatus[0].Status) + require.Equal(t, undeployErr.Error(), stack.DeploymentStatus[0].Message) + require.Positive(t, stack.DeploymentStatus[0].Time) + }) + + t.Run("on success", func(t *testing.T) { + t.Parallel() + + stack := &portainer.Stack{} + + UpdateStackStatusFromUndeploymentResult(stack, nil) + + require.Equal(t, portainer.StackStatusInactive, stack.Status) + require.Len(t, stack.DeploymentStatus, 1) + require.Equal(t, portainer.StackStatusInactive, stack.DeploymentStatus[0].Status) + require.Empty(t, stack.DeploymentStatus[0].Message) + require.Positive(t, stack.DeploymentStatus[0].Time) + }) +} diff --git a/api/stacks/stackutils/util.go b/api/stacks/stackutils/util.go new file mode 100644 index 0000000..1d263d0 --- /dev/null +++ b/api/stacks/stackutils/util.go @@ -0,0 +1,51 @@ +package stackutils + +import ( + "fmt" + "regexp" + "strings" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/filesystem" +) + +func UserIsAdminOrEndpointAdmin(user *portainer.User) bool { + return user.Role == portainer.AdministratorRole +} + +// GetStackFilePaths returns a list of file paths based on stack project path +// If absolute is false, the path sanitization step will be skipped, which makes the returning +// paths vulnerable to path traversal attacks. Thus, the followed function using the returning +// paths are responsible to sanitize the raw paths +// If absolute is true, the raw paths will be sanitized +func GetStackFilePaths(stack *portainer.Stack, absolute bool) []string { + if !absolute { + return append([]string{stack.EntryPoint}, stack.AdditionalFiles...) + } + + var filePaths []string + for _, file := range append([]string{stack.EntryPoint}, stack.AdditionalFiles...) { + filePaths = append(filePaths, filesystem.JoinPaths(stack.ProjectPath, file)) + } + + return filePaths +} + +// ResourceControlID returns the stack resource control id +func ResourceControlID(endpointID portainer.EndpointID, name string) string { + return fmt.Sprintf("%d_%s", endpointID, name) +} + +// convert string to valid kubernetes label by replacing invalid characters with periods and removing any periods at the beginning or end of the string +func SanitizeLabel(value string) string { + re := regexp.MustCompile(`[^A-Za-z0-9\.\-\_]+`) + onlyAllowedCharacterString := re.ReplaceAllString(value, ".") + return strings.Trim(onlyAllowedCharacterString, ".-_") +} + +// IsRelativePathStack checks if the stack is a git stack or not +func IsRelativePathStack(stack *portainer.Stack) bool { + // Always return false in CE + // This function is only for code consistency with EE + return false +} diff --git a/api/stacks/stackutils/util_test.go b/api/stacks/stackutils/util_test.go new file mode 100644 index 0000000..ea2873e --- /dev/null +++ b/api/stacks/stackutils/util_test.go @@ -0,0 +1,27 @@ +package stackutils + +import ( + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/stretchr/testify/assert" +) + +func Test_GetStackFilePaths(t *testing.T) { + t.Parallel() + stack := &portainer.Stack{ + ProjectPath: "/tmp/stack/1", + EntryPoint: "file-one.yml", + } + + t.Run("stack doesn't have additional files", func(t *testing.T) { + expected := []string{"/tmp/stack/1/file-one.yml"} + assert.ElementsMatch(t, expected, GetStackFilePaths(stack, true)) + }) + + t.Run("stack has additional files", func(t *testing.T) { + stack.AdditionalFiles = []string{"file-two.yml", "file-three.yml"} + expected := []string{"/tmp/stack/1/file-one.yml", "/tmp/stack/1/file-two.yml", "/tmp/stack/1/file-three.yml"} + assert.ElementsMatch(t, expected, GetStackFilePaths(stack, true)) + }) +} diff --git a/api/stacks/stackutils/validation.go b/api/stacks/stackutils/validation.go new file mode 100644 index 0000000..9f74ac1 --- /dev/null +++ b/api/stacks/stackutils/validation.go @@ -0,0 +1,186 @@ +package stackutils + +import ( + "context" + "fmt" + "path" + "strings" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/filesystem" + "github.com/portainer/portainer/pkg/libhttp/ssrf" + + composeloader "github.com/compose-spec/compose-go/v2/loader" + composetypes "github.com/compose-spec/compose-go/v2/types" + "github.com/pkg/errors" +) + +type StackFileValidationConfig struct { + Content []byte + SecuritySettings *portainer.EndpointSecuritySettings + Env map[string]string + WorkingDir string +} + +func IsValidStackFile(config StackFileValidationConfig) error { + composeConfigDetails := composetypes.ConfigDetails{ + ConfigFiles: []composetypes.ConfigFile{{Content: config.Content}}, + Environment: config.Env, + WorkingDir: config.WorkingDir, + } + + composeConfig, err := composeloader.LoadWithContext(context.Background(), composeConfigDetails, composeloader.WithSkipValidation) + if err != nil { + return err + } + + for _, service := range composeConfig.Services { + if !config.SecuritySettings.AllowBindMountsForRegularUsers { + for _, volume := range service.Volumes { + if volume.Type == "bind" { + return errors.New("bind-mount disabled for non administrator users") + } + } + } + + if !config.SecuritySettings.AllowPrivilegedModeForRegularUsers && service.Privileged { + return errors.New("privileged mode disabled for non administrator users") + } + + if !config.SecuritySettings.AllowHostNamespaceForRegularUsers && service.Pid == "host" { + return errors.New("pid host disabled for non administrator users") + } + + if !config.SecuritySettings.AllowDeviceMappingForRegularUsers && len(service.Devices) > 0 { + return errors.New("device mapping disabled for non administrator users") + } + + if !config.SecuritySettings.AllowSysctlSettingForRegularUsers && len(service.Sysctls) > 0 { + return errors.New("sysctl setting disabled for non administrator users") + } + + if !config.SecuritySettings.AllowSecurityOptForRegularUsers && len(service.SecurityOpt) > 0 { + return errors.New("security-opt setting disabled for non administrator users") + } + + if !config.SecuritySettings.AllowContainerCapabilitiesForRegularUsers && (len(service.CapAdd) > 0 || len(service.CapDrop) > 0) { + return errors.New("container capabilities disabled for non administrator users") + } + } + + return nil +} + +// ValidateComposeURLs parses each stack file and checks that every external URL +// (build contexts and image registry hostnames) is permitted by the active SSRF +// policy. It is a no-op when SSRF protection is disabled. +func ValidateComposeURLs(ctx context.Context, stack *portainer.Stack, fileService portainer.FileService) error { + if !ssrf.IsEnabled() { + return nil + } + + env := BuildEnvMap(stack) + workingDir := filesystem.JoinPaths(stack.ProjectPath, path.Dir(stack.EntryPoint)) + + for _, file := range GetStackFilePaths(stack, false) { + stackContent, err := fileService.GetFileContent(stack.ProjectPath, file) + if err != nil { + return errors.Wrap(err, "failed to get stack file content") + } + + if err := checkComposeFileURLs(ctx, stackContent, env, workingDir); err != nil { + return errors.Wrap(err, "stack file contains a URL blocked by the SSRF policy") + } + } + + return nil +} + +// ValidateEdgeStackComposeContent checks that every external URL in an edge +// stack's Compose file is permitted by the active SSRF policy. It is a no-op +// when SSRF protection is disabled or the deployment type is not compose. +func ValidateEdgeStackComposeContent(ctx context.Context, deploymentType portainer.EdgeStackDeploymentType, content []byte) error { + if !ssrf.IsEnabled() || deploymentType != portainer.EdgeStackDeploymentCompose { + return nil + } + + if err := checkComposeFileURLs(ctx, content, nil, ""); err != nil { + return errors.Wrap(err, "stack file contains a URL blocked by the SSRF policy") + } + + return nil +} + +func checkComposeFileURLs(ctx context.Context, content []byte, env map[string]string, workingDir string) error { + composeConfigDetails := composetypes.ConfigDetails{ + ConfigFiles: []composetypes.ConfigFile{{Content: content}}, + Environment: env, + WorkingDir: workingDir, + } + + composeConfig, err := composeloader.LoadWithContext(ctx, composeConfigDetails, composeloader.WithSkipValidation) + if err != nil { + return err + } + + for _, service := range composeConfig.Services { + if service.Build != nil { + buildCtx := service.Build.Context + if strings.HasPrefix(buildCtx, "http://") || strings.HasPrefix(buildCtx, "https://") { + if err := ssrf.CheckURL(ctx, buildCtx); err != nil { + return fmt.Errorf("service %q: build context URL blocked: %w", service.Name, err) + } + } + } + + if service.Image != "" { + if registry := extractImageRegistry(service.Image); registry != "" { + if err := ssrf.CheckURL(ctx, "https://"+registry); err != nil { + return fmt.Errorf("service %q: image registry %q blocked: %w", service.Name, registry, err) + } + } + } + } + + return nil +} + +// extractImageRegistry returns the registry hostname from an OCI image reference, +// or an empty string if the image resolves to Docker Hub (no explicit registry). +func extractImageRegistry(imageRef string) string { + ref, _, _ := strings.Cut(imageRef, "@") + + first, _, hasSlash := strings.Cut(ref, "/") + if !hasSlash { + return "" + } + + if strings.ContainsAny(first, ".:") || first == "localhost" { + return first + } + + return "" +} + +func ValidateStackFiles(stack *portainer.Stack, securitySettings *portainer.EndpointSecuritySettings, fileService portainer.FileService) error { + env := BuildEnvMap(stack) + workingDir := filesystem.JoinPaths(stack.ProjectPath, path.Dir(stack.EntryPoint)) + + for _, file := range GetStackFilePaths(stack, false) { + stackContent, err := fileService.GetFileContent(stack.ProjectPath, file) + if err != nil { + return errors.Wrap(err, "failed to get stack file content") + } + + if err := IsValidStackFile(StackFileValidationConfig{ + Content: stackContent, + SecuritySettings: securitySettings, + Env: env, + WorkingDir: workingDir, + }); err != nil { + return errors.Wrap(err, "stack config file is invalid") + } + } + + return nil +} diff --git a/api/stacks/stackutils/validation_test.go b/api/stacks/stackutils/validation_test.go new file mode 100644 index 0000000..9f02073 --- /dev/null +++ b/api/stacks/stackutils/validation_test.go @@ -0,0 +1,456 @@ +package stackutils + +import ( + "os" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/filesystem" + "github.com/portainer/portainer/pkg/libhttp/ssrf" + "github.com/stretchr/testify/require" +) + +func TestIsValidStackFile_DefaultPortEnvSubstitution(t *testing.T) { + t.Parallel() + yamlContent := []byte(` +version: "3" + +services: + webservice: + image: nginx + container_name: hello-world + networks: + - "mynet1" + ports: + - "${PORT:-8080}:80" + +networks: + mynet1: + driver: bridge + ipam: + config: + - subnet: 172.16.0.0/24 +`) + + securitySettings := &portainer.EndpointSecuritySettings{} + err := IsValidStackFile(StackFileValidationConfig{ + Content: yamlContent, + SecuritySettings: securitySettings, + }) + require.NoError(t, err) +} + +// TestIsValidStackFile_MissingEnvVarBehavior documents how port variable position affects +// validation when the env var is not provided. Docker accepts an empty host port (left side) +// but requires a valid container port (right side). +func TestIsValidStackFile_MissingEnvVarBehavior(t *testing.T) { + t.Parallel() + securitySettings := &portainer.EndpointSecuritySettings{} + + t.Run("var on left side only passes (docker allows :9090)", func(t *testing.T) { + err := IsValidStackFile(StackFileValidationConfig{ + Content: []byte(` +version: "3" +services: + api: + image: nginx + ports: + - "${API_PORT}:9090" +`), + SecuritySettings: securitySettings, + }) + require.NoError(t, err) + }) + + t.Run("var on right side fails", func(t *testing.T) { + err := IsValidStackFile(StackFileValidationConfig{ + Content: []byte(` +version: "3" +services: + api: + image: nginx + ports: + - "9090:${API_PORT}" +`), + SecuritySettings: securitySettings, + }) + require.Error(t, err) + }) + + t.Run("var on both sides fails", func(t *testing.T) { + err := IsValidStackFile(StackFileValidationConfig{ + Content: []byte(` +version: "3" +services: + api: + image: nginx + ports: + - "${API_PORT}:${API_PORT}" +`), + SecuritySettings: securitySettings, + }) + require.Error(t, err) + }) +} + +func TestIsValidStackFile_EnvVarInBothPortFields(t *testing.T) { + t.Parallel() + securitySettings := &portainer.EndpointSecuritySettings{} + err := IsValidStackFile(StackFileValidationConfig{ + Content: []byte(` +version: "3" + +services: + api: + image: nginx + ports: + - "${API_PORT}:${API_PORT}" +`), + SecuritySettings: securitySettings, + Env: map[string]string{"API_PORT": "3000"}, + }) + require.NoError(t, err) +} + +type mockFileService struct { + portainer.FileService + fileContent []byte + projectVersionPath string +} + +func (m mockFileService) GetFileContent(trustedRootPath, filePath string) ([]byte, error) { + return m.fileContent, nil +} + +func (m mockFileService) FormProjectPathByVersion(projectPath string, version int, commitHash string) string { + return m.projectVersionPath +} + +func TestValidateStackFiles_EnvVars(t *testing.T) { + t.Parallel() + fileContent := []byte(` +version: "3" + +services: + api: + image: nginx + ports: + - "${API_PORT}:${API_PORT}" +`) + + stack := &portainer.Stack{ + ProjectPath: "/tmp/stack/1", + EntryPoint: "docker-compose.yml", + Env: []portainer.Pair{{Name: "API_PORT", Value: "3000"}}, + } + + fileService := mockFileService{ + fileContent: fileContent, + projectVersionPath: "/tmp/stack/1", + } + + securitySettings := &portainer.EndpointSecuritySettings{} + err := ValidateStackFiles(stack, securitySettings, fileService) + require.NoError(t, err) +} + +func TestValidateStackFiles_OSEnvVar(t *testing.T) { + t.Setenv("HOST_PORT", "3000") + + fileContent := []byte(` +version: "3" +services: + api: + image: nginx + ports: + - "80:${HOST_PORT}" +`) + + stack := &portainer.Stack{ + ProjectPath: "/tmp/stack/1", + EntryPoint: "docker-compose.yml", + } + + fileService := mockFileService{ + fileContent: fileContent, + projectVersionPath: "/tmp/stack/1", + } + + securitySettings := &portainer.EndpointSecuritySettings{} + err := ValidateStackFiles(stack, securitySettings, fileService) + require.NoError(t, err) +} + +func TestValidateStackFiles_DotEnvFile(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + + err := os.WriteFile(filesystem.JoinPaths(tmpDir, ".env"), []byte("HOST_PORT=3000\n"), 0o600) + require.NoError(t, err) + + fileContent := []byte(` +version: "3" +services: + api: + image: nginx + ports: + - "80:${HOST_PORT}" +`) + + stack := &portainer.Stack{ + ProjectPath: tmpDir, + EntryPoint: "docker-compose.yml", + } + + fileService := mockFileService{ + fileContent: fileContent, + projectVersionPath: tmpDir, + } + + securitySettings := &portainer.EndpointSecuritySettings{} + err = ValidateStackFiles(stack, securitySettings, fileService) + require.NoError(t, err) +} + +func TestValidateStackFiles_EnvFileAttribute(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + + err := os.WriteFile(filesystem.JoinPaths(tmpDir, "web.env"), []byte("HOST_PORT=3000\n"), 0o600) + require.NoError(t, err) + + fileContent := []byte(` +version: "3" +services: + api: + image: nginx + env_file: + - ./web.env +`) + + stack := &portainer.Stack{ + ProjectPath: tmpDir, + EntryPoint: "docker-compose.yml", + } + + fileService := mockFileService{ + fileContent: fileContent, + projectVersionPath: tmpDir, + } + + securitySettings := &portainer.EndpointSecuritySettings{} + err = ValidateStackFiles(stack, securitySettings, fileService) + require.NoError(t, err) +} + +func TestValidateStackFiles_BindMountBlockedForNonAdmin(t *testing.T) { + t.Parallel() + fileContent := []byte(` +version: "3" + +services: + api: + image: nginx + volumes: + - /host/path:/container/path +`) + + stack := &portainer.Stack{ + ProjectPath: "/tmp/stack/1", + EntryPoint: "docker-compose.yml", + } + + fileService := mockFileService{ + fileContent: fileContent, + projectVersionPath: "/tmp/stack/1", + } + + securitySettings := &portainer.EndpointSecuritySettings{ + AllowBindMountsForRegularUsers: false, + } + err := ValidateStackFiles(stack, securitySettings, fileService) + require.ErrorContains(t, err, "bind-mount disabled for non administrator users") +} + +func TestExtractImageRegistry(t *testing.T) { + t.Parallel() + + cases := []struct { + image string + expected string + }{ + {"nginx", ""}, + {"nginx:latest", ""}, + {"library/nginx", ""}, + {"ghcr.io/owner/image:tag", "ghcr.io"}, + {"myregistry.com/image:tag", "myregistry.com"}, + {"myregistry.com:5000/image:tag", "myregistry.com:5000"}, + {"localhost/image:tag", "localhost"}, + {"localhost:5000/image:tag", "localhost:5000"}, + {"myregistry.com/image@sha256:abc", "myregistry.com"}, + {"169.254.169.254/image:tag", "169.254.169.254"}, + } + + for _, tc := range cases { + got := extractImageRegistry(tc.image) + require.Equal(t, tc.expected, got, "image: %s", tc.image) + } +} + +type staticAllowListService struct { + parsed portainer.ParsedAllowList +} + +func (s *staticAllowListService) ReadParsed(id portainer.AllowListKey) (*portainer.ParsedAllowList, error) { + return &s.parsed, nil +} + +func configureSSRF(t *testing.T, mode portainer.SSRFMode, entries []string) { + t.Helper() + + parsed := ssrf.ParseAllowedHosts(entries) + parsed.Mode = mode + err := ssrf.Configure(&staticAllowListService{parsed: parsed}) + require.NoError(t, err) + t.Cleanup(func() { + err := ssrf.Configure(&staticAllowListService{}) + require.NoError(t, err) + }) +} + +func TestValidateComposeURLs_DisabledSSRF(t *testing.T) { + configureSSRF(t, portainer.SSRFModeOff, nil) + + stack := &portainer.Stack{ + ProjectPath: "/tmp/stack/1", + EntryPoint: "docker-compose.yml", + } + + fileService := mockFileService{ + fileContent: []byte(` +version: "3" +services: + web: + build: + context: http://169.254.169.254/repo.tar.gz +`), + projectVersionPath: "/tmp/stack/1", + } + + err := ValidateComposeURLs(t.Context(), stack, fileService) + require.NoError(t, err) +} + +func TestValidateComposeURLs_BuildContextBlocked(t *testing.T) { + configureSSRF(t, portainer.SSRFModeEnforce, []string{"example.com"}) + + stack := &portainer.Stack{ + ProjectPath: "/tmp/stack/1", + EntryPoint: "docker-compose.yml", + } + + fileService := mockFileService{ + fileContent: []byte(` +version: "3" +services: + web: + build: + context: http://169.254.169.254/repo.tar.gz + image: nginx +`), + projectVersionPath: "/tmp/stack/1", + } + + err := ValidateComposeURLs(t.Context(), stack, fileService) + require.ErrorContains(t, err, "SSRF policy") +} + +func TestValidateComposeURLs_BuildContextPath(t *testing.T) { + configureSSRF(t, portainer.SSRFModeEnforce, []string{"example.com"}) + + stack := &portainer.Stack{ + ProjectPath: "/tmp/stack/1", + EntryPoint: "docker-compose.yml", + } + + fileService := mockFileService{ + fileContent: []byte(` +version: "3" +services: + web: + build: + context: ./app + image: nginx +`), + projectVersionPath: "/tmp/stack/1", + } + + err := ValidateComposeURLs(t.Context(), stack, fileService) + require.NoError(t, err) +} + +func TestValidateComposeURLs_ImageRegistryBlocked(t *testing.T) { + configureSSRF(t, portainer.SSRFModeEnforce, []string{"example.com"}) + + stack := &portainer.Stack{ + ProjectPath: "/tmp/stack/1", + EntryPoint: "docker-compose.yml", + } + + fileService := mockFileService{ + fileContent: []byte(` +version: "3" +services: + web: + image: 169.254.169.254/myimage:latest +`), + projectVersionPath: "/tmp/stack/1", + } + + err := ValidateComposeURLs(t.Context(), stack, fileService) + require.ErrorContains(t, err, "SSRF policy") +} + +func TestValidateComposeURLs_ImageRegistryAllowed(t *testing.T) { + configureSSRF(t, portainer.SSRFModeEnforce, []string{"myregistry.com"}) + + stack := &portainer.Stack{ + ProjectPath: "/tmp/stack/1", + EntryPoint: "docker-compose.yml", + } + + fileService := mockFileService{ + fileContent: []byte(` +version: "3" +services: + web: + image: myregistry.com/myimage:latest +`), + projectVersionPath: "/tmp/stack/1", + } + + err := ValidateComposeURLs(t.Context(), stack, fileService) + require.NoError(t, err) +} + +func TestValidateComposeURLs_DockerHubImageAllowed(t *testing.T) { + configureSSRF(t, portainer.SSRFModeEnforce, []string{"example.com"}) + + stack := &portainer.Stack{ + ProjectPath: "/tmp/stack/1", + EntryPoint: "docker-compose.yml", + } + + fileService := mockFileService{ + fileContent: []byte(` +version: "3" +services: + web: + image: nginx:latest +`), + projectVersionPath: "/tmp/stack/1", + } + + err := ValidateComposeURLs(t.Context(), stack, fileService) + require.NoError(t, err) +} diff --git a/api/swagger_config.json b/api/swagger_config.json new file mode 100644 index 0000000..240c90d --- /dev/null +++ b/api/swagger_config.json @@ -0,0 +1,5 @@ +{ + "packageName": "portainer", + "packageVersion": "2.0.0", + "projectName": "portainer" +} diff --git a/api/tag/tag.go b/api/tag/tag.go new file mode 100644 index 0000000..3b6d080 --- /dev/null +++ b/api/tag/tag.go @@ -0,0 +1,75 @@ +package tag + +import ( + portainer "github.com/portainer/portainer/api" +) + +type tagSet map[portainer.TagID]struct{} + +// Set converts an array of ids to a set +func Set(tagIDs []portainer.TagID) tagSet { + set := map[portainer.TagID]struct{}{} + for _, tagID := range tagIDs { + set[tagID] = struct{}{} + } + + return set +} + +// IntersectionCount returns the element count of the intersection of the sets +func IntersectionCount(setA, setB tagSet) int { + if len(setA) > len(setB) { + setA, setB = setB, setA + } + + count := 0 + + for tag := range setA { + if _, ok := setB[tag]; ok { + count++ + } + } + + return count +} + +// Union returns a set union of provided sets +func Union(sets ...tagSet) tagSet { + union := tagSet{} + + for _, set := range sets { + for tag := range set { + union[tag] = struct{}{} + } + } + + return union +} + +// Contains return true if setA contains setB +func Contains(setA tagSet, setB []portainer.TagID) bool { + if len(setA) == 0 || len(setB) == 0 { + return false + } + + for _, tag := range setB { + if _, ok := setA[tag]; !ok { + return false + } + } + + return true +} + +// Difference returns the set difference tagsA - tagsB +func Difference(setA tagSet, setB tagSet) tagSet { + set := tagSet{} + + for tag := range setA { + if _, ok := setB[tag]; !ok { + set[tag] = struct{}{} + } + } + + return set +} diff --git a/api/tag/tag_match.go b/api/tag/tag_match.go new file mode 100644 index 0000000..fadd02d --- /dev/null +++ b/api/tag/tag_match.go @@ -0,0 +1,19 @@ +package tag + +import portainer "github.com/portainer/portainer/api" + +// FullMatch returns true if environment tags matches all edge group tags +func FullMatch(edgeGroupTags []portainer.TagID, environmentTags tagSet) bool { + return Contains(environmentTags, edgeGroupTags) +} + +// PartialMatch returns true if environment tags matches at least one edge group tag +func PartialMatch(edgeGroupTags []portainer.TagID, environmentTags tagSet) bool { + for _, tagID := range edgeGroupTags { + if _, ok := environmentTags[tagID]; ok { + return true + } + } + + return false +} diff --git a/api/tag/tag_match_test.go b/api/tag/tag_match_test.go new file mode 100644 index 0000000..651f6dc --- /dev/null +++ b/api/tag/tag_match_test.go @@ -0,0 +1,137 @@ +package tag + +import ( + "testing" + + portainer "github.com/portainer/portainer/api" +) + +func TestFullMatch(t *testing.T) { + t.Parallel() + cases := []struct { + name string + edgeGroupTags []portainer.TagID + environmentTag tagSet + expected bool + }{ + { + name: "environment tag partially match edge group tags", + edgeGroupTags: []portainer.TagID{1, 2, 3}, + environmentTag: Set([]portainer.TagID{1, 2}), + expected: false, + }, + { + name: "edge group tags equal to environment tags", + edgeGroupTags: []portainer.TagID{1, 2}, + environmentTag: Set([]portainer.TagID{1, 2}), + expected: true, + }, + { + name: "environment tags fully match edge group tags", + edgeGroupTags: []portainer.TagID{1, 2}, + environmentTag: Set([]portainer.TagID{1, 2, 3}), + expected: true, + }, + { + name: "environment tags do not match edge group tags", + edgeGroupTags: []portainer.TagID{1, 2}, + environmentTag: Set([]portainer.TagID{3, 4}), + expected: false, + }, + { + name: "edge group has no tags and environment has tags", + edgeGroupTags: []portainer.TagID{}, + environmentTag: Set([]portainer.TagID{1, 2}), + expected: false, + }, + { + name: "edge group has tags and environment has no tags", + edgeGroupTags: []portainer.TagID{1, 2}, + environmentTag: Set([]portainer.TagID{}), + expected: false, + }, + { + name: "both edge group and environment have no tags", + edgeGroupTags: []portainer.TagID{}, + environmentTag: Set([]portainer.TagID{}), + expected: false, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + result := FullMatch(tc.edgeGroupTags, tc.environmentTag) + if result != tc.expected { + t.Errorf("Expected %v, got %v", tc.expected, result) + } + }) + } +} + +func TestPartialMatch(t *testing.T) { + t.Parallel() + cases := []struct { + name string + edgeGroupTags []portainer.TagID + environmentTag tagSet + expected bool + }{ + { + name: "environment tags partially match edge group tags 1", + edgeGroupTags: []portainer.TagID{1, 2, 3}, + environmentTag: Set([]portainer.TagID{1, 2}), + expected: true, + }, + { + name: "environment tags partially match edge group tags 2", + edgeGroupTags: []portainer.TagID{1, 2, 3}, + environmentTag: Set([]portainer.TagID{1, 4, 5}), + expected: true, + }, + { + name: "edge group tags equal to environment tags", + edgeGroupTags: []portainer.TagID{1, 2}, + environmentTag: Set([]portainer.TagID{1, 2}), + expected: true, + }, + { + name: "environment tags fully match edge group tags", + edgeGroupTags: []portainer.TagID{1, 2}, + environmentTag: Set([]portainer.TagID{1, 2, 3}), + expected: true, + }, + { + name: "environment tags do not match edge group tags", + edgeGroupTags: []portainer.TagID{1, 2}, + environmentTag: Set([]portainer.TagID{3, 4}), + expected: false, + }, + { + name: "edge group has no tags and environment has tags", + edgeGroupTags: []portainer.TagID{}, + environmentTag: Set([]portainer.TagID{1, 2}), + expected: false, + }, + { + name: "edge group has tags and environment has no tags", + edgeGroupTags: []portainer.TagID{1, 2}, + environmentTag: Set([]portainer.TagID{}), + expected: false, + }, + { + name: "both edge group and environment have no tags", + edgeGroupTags: []portainer.TagID{}, + environmentTag: Set([]portainer.TagID{}), + expected: false, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + result := PartialMatch(tc.edgeGroupTags, tc.environmentTag) + if result != tc.expected { + t.Errorf("Expected %v, got %v", tc.expected, result) + } + }) + } +} diff --git a/api/tag/tag_test.go b/api/tag/tag_test.go new file mode 100644 index 0000000..07c9c8c --- /dev/null +++ b/api/tag/tag_test.go @@ -0,0 +1,208 @@ +package tag + +import ( + "reflect" + "testing" + + portainer "github.com/portainer/portainer/api" +) + +func TestIntersectionCount(t *testing.T) { + t.Parallel() + cases := []struct { + name string + setA tagSet + setB tagSet + expected int + }{ + { + name: "positive numbers set intersection", + setA: Set([]portainer.TagID{1, 2, 3, 4, 5}), + setB: Set([]portainer.TagID{4, 5, 6, 7}), + expected: 2, + }, + { + name: "empty setA intersection", + setA: Set([]portainer.TagID{1, 2, 3}), + setB: Set([]portainer.TagID{}), + expected: 0, + }, + { + name: "empty setB intersection", + setA: Set([]portainer.TagID{}), + setB: Set([]portainer.TagID{1, 2, 3}), + expected: 0, + }, + { + name: "no common elements sets intersection", + setA: Set([]portainer.TagID{1, 2, 3}), + setB: Set([]portainer.TagID{4, 5, 6}), + expected: 0, + }, + { + name: "equal sets intersection", + setA: Set([]portainer.TagID{1, 2, 3}), + setB: Set([]portainer.TagID{1, 2, 3}), + expected: 3, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + result := IntersectionCount(tc.setA, tc.setB) + if result != tc.expected { + t.Errorf("Expected %v, got %v", tc.expected, result) + } + }) + } +} + +func TestUnion(t *testing.T) { + t.Parallel() + cases := []struct { + name string + setA tagSet + setB tagSet + expected tagSet + }{ + { + name: "non-duplicate set union", + setA: Set([]portainer.TagID{1, 2, 3}), + setB: Set([]portainer.TagID{4, 5, 6}), + expected: Set([]portainer.TagID{1, 2, 3, 4, 5, 6}), + }, + { + name: "empty setA union", + setA: Set([]portainer.TagID{1, 2, 3}), + setB: Set([]portainer.TagID{}), + expected: Set([]portainer.TagID{1, 2, 3}), + }, + { + name: "empty setB union", + setA: Set([]portainer.TagID{}), + setB: Set([]portainer.TagID{1, 2, 3}), + expected: Set([]portainer.TagID{1, 2, 3}), + }, + { + name: "duplicate elements in set union", + setA: Set([]portainer.TagID{1, 2, 3}), + setB: Set([]portainer.TagID{3, 4, 5}), + expected: Set([]portainer.TagID{1, 2, 3, 4, 5}), + }, + { + name: "equal sets union", + setA: Set([]portainer.TagID{1, 2, 3}), + setB: Set([]portainer.TagID{1, 2, 3}), + expected: Set([]portainer.TagID{1, 2, 3}), + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + result := Union(tc.setA, tc.setB) + if !reflect.DeepEqual(result, tc.expected) { + t.Errorf("Expected %v, got %v", tc.expected, result) + } + }) + } +} + +func TestContains(t *testing.T) { + t.Parallel() + cases := []struct { + name string + setA tagSet + setB []portainer.TagID + expected bool + }{ + { + name: "setA contains setB", + setA: Set([]portainer.TagID{1, 2, 3}), + setB: []portainer.TagID{1, 2}, + expected: true, + }, + { + name: "setA equals to setB", + setA: Set([]portainer.TagID{1, 2}), + setB: []portainer.TagID{1, 2}, + expected: true, + }, + { + name: "setA contains parts of setB", + setA: Set([]portainer.TagID{1, 2}), + setB: []portainer.TagID{1, 2, 3}, + expected: false, + }, + { + name: "setA does not contain setB", + setA: Set([]portainer.TagID{1, 2}), + setB: []portainer.TagID{3, 4}, + expected: false, + }, + { + name: "setA is empty and setB is not empty", + setA: Set([]portainer.TagID{}), + setB: []portainer.TagID{1, 2}, + expected: false, + }, + { + name: "setA is not empty and setB is empty", + setA: Set([]portainer.TagID{1, 2}), + setB: []portainer.TagID{}, + expected: false, + }, + { + name: "setA is empty and setB is empty", + setA: Set([]portainer.TagID{}), + setB: []portainer.TagID{}, + expected: false, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + result := Contains(tc.setA, tc.setB) + if result != tc.expected { + t.Errorf("Expected %v, got %v", tc.expected, result) + } + }) + } +} + +func TestDifference(t *testing.T) { + t.Parallel() + cases := []struct { + name string + setA tagSet + setB tagSet + expected tagSet + }{ + { + name: "positive numbers set difference", + setA: Set([]portainer.TagID{1, 2, 3, 4, 5}), + setB: Set([]portainer.TagID{4, 5, 6, 7}), + expected: Set([]portainer.TagID{1, 2, 3}), + }, + { + name: "empty set difference", + setA: Set([]portainer.TagID{1, 2, 3}), + setB: Set([]portainer.TagID{}), + expected: Set([]portainer.TagID{1, 2, 3}), + }, + { + name: "equal sets difference", + setA: Set([]portainer.TagID{1, 2, 3}), + setB: Set([]portainer.TagID{1, 2, 3}), + expected: Set([]portainer.TagID{}), + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + result := Difference(tc.setA, tc.setB) + if !reflect.DeepEqual(result, tc.expected) { + t.Errorf("Expected %v, got %v", tc.expected, result) + } + }) + } +} diff --git a/api/uac/configs.go b/api/uac/configs.go new file mode 100644 index 0000000..e1ed14d --- /dev/null +++ b/api/uac/configs.go @@ -0,0 +1,30 @@ +package uac + +import ( + "github.com/docker/docker/api/types/swarm" + portainer "github.com/portainer/portainer/api" +) + +func ConfigResourceControlGetter[ + TX txLike[RCS, TS, US], + RCS rcServiceLike, + TS teamServiceLike, + US userServiceLike, +]( + tx TX, + endpointID portainer.EndpointID, +) func(item swarm.Config) (*portainer.ResourceControl, error) { + return genericResourcControlGetter(tx, endpointID, ResourceContext[swarm.Config]{ + RCType: portainer.ConfigResourceControl, + IDGetter: ConfigResourceControlID, + LabelsGetter: ConfigLabels, + }) +} + +func ConfigResourceControlID(item swarm.Config) string { + return item.ID +} + +func ConfigLabels(item swarm.Config) map[string]string { + return item.Spec.Labels +} diff --git a/api/uac/configs_test.go b/api/uac/configs_test.go new file mode 100644 index 0000000..f3b0059 --- /dev/null +++ b/api/uac/configs_test.go @@ -0,0 +1,71 @@ +package uac + +import ( + "testing" + + "github.com/docker/docker/api/types/swarm" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/docker/consts" + "github.com/portainer/portainer/api/internal/authorization" + "github.com/portainer/portainer/api/stacks/stackutils" + "github.com/stretchr/testify/require" +) + +func TestConfigResourceControlGetter(t *testing.T) { + t.Parallel() + is := require.New(t) + + ok, store := datastore.MustNewTestStore(t, true, false) + is.True(ok) + is.NotNil(store) + + envID := portainer.EndpointID(1) + configID := "config" + stackName := "stack" + stackRCID := stackutils.ResourceControlID(envID, stackName) + serviceID := "service" + + is.NoError(store.UpdateTx(func(tx dataservices.DataStoreTx) error { + is.NoError(tx.ResourceControl().Create(authorization.NewPublicResourceControl(configID, portainer.ConfigResourceControl))) + is.NoError(tx.ResourceControl().Create(authorization.NewPublicResourceControl(stackRCID, portainer.StackResourceControl))) + is.NoError(tx.ResourceControl().Create(authorization.NewPublicResourceControl(serviceID, portainer.ServiceResourceControl))) + return nil + })) + + is.NoError(store.ViewTx(func(tx dataservices.DataStoreTx) error { + // by direct ID + rc, err := ConfigResourceControlGetter(tx, envID)(swarm.Config{ID: configID}) + is.NoError(err) + is.NotNil(rc) + is.Equal(configID, rc.ResourceID) + + // by compose stack label + rc, err = ConfigResourceControlGetter(tx, envID)( + swarm.Config{ID: "unknown", Spec: swarm.ConfigSpec{Annotations: swarm.Annotations{Labels: map[string]string{consts.ComposeStackNameLabel: stackName}}}}, + ) + is.NoError(err) + is.NotNil(rc) + is.Equal(stackRCID, rc.ResourceID) + + // by swarm stack label + rc, err = ConfigResourceControlGetter(tx, envID)( + swarm.Config{ID: "unknown", Spec: swarm.ConfigSpec{Annotations: swarm.Annotations{Labels: map[string]string{consts.SwarmStackNameLabel: stackName}}}}, + ) + is.NoError(err) + is.NotNil(rc) + is.Equal(stackRCID, rc.ResourceID) + + // by service ID + rc, err = ConfigResourceControlGetter(tx, envID)( + swarm.Config{ID: "unknown", Spec: swarm.ConfigSpec{Annotations: swarm.Annotations{Labels: map[string]string{consts.SwarmServiceIDLabel: serviceID}}}}, + ) + is.NoError(err) + is.NotNil(rc) + is.Equal(serviceID, rc.ResourceID) + + return nil + })) + +} diff --git a/api/uac/containergroup.go b/api/uac/containergroup.go new file mode 100644 index 0000000..30fd6cd --- /dev/null +++ b/api/uac/containergroup.go @@ -0,0 +1,4 @@ +package uac + +// TODO: implement UAC rules for Container groups +// See usage of portainer.ContainerGroupResourceControl diff --git a/api/uac/containers.go b/api/uac/containers.go new file mode 100644 index 0000000..d7c551f --- /dev/null +++ b/api/uac/containers.go @@ -0,0 +1,30 @@ +package uac + +import ( + "github.com/docker/docker/api/types/container" + portainer "github.com/portainer/portainer/api" +) + +func ContainerResourceControlGetter[ + TX txLike[RCS, TS, US], + RCS rcServiceLike, + TS teamServiceLike, + US userServiceLike, +]( + tx TX, + endpointID portainer.EndpointID, +) func(item container.Summary) (*portainer.ResourceControl, error) { + return genericResourcControlGetter(tx, endpointID, ResourceContext[container.Summary]{ + RCType: portainer.ContainerResourceControl, + IDGetter: ContainerResourceControlID, + LabelsGetter: ContainerLabels, + }) +} + +func ContainerResourceControlID(item container.Summary) string { + return item.ID +} + +func ContainerLabels(item container.Summary) map[string]string { + return item.Labels +} diff --git a/api/uac/containers_test.go b/api/uac/containers_test.go new file mode 100644 index 0000000..ec29be6 --- /dev/null +++ b/api/uac/containers_test.go @@ -0,0 +1,71 @@ +package uac + +import ( + "testing" + + "github.com/docker/docker/api/types/container" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/docker/consts" + "github.com/portainer/portainer/api/internal/authorization" + "github.com/portainer/portainer/api/stacks/stackutils" + "github.com/stretchr/testify/require" +) + +func TestContainerResourceControlGetter(t *testing.T) { + t.Parallel() + is := require.New(t) + + ok, store := datastore.MustNewTestStore(t, true, false) + is.True(ok) + is.NotNil(store) + + envID := portainer.EndpointID(1) + containerID := "container" + stackName := "stack" + stackRCID := stackutils.ResourceControlID(envID, stackName) + serviceID := "service" + + is.NoError(store.UpdateTx(func(tx dataservices.DataStoreTx) error { + is.NoError(tx.ResourceControl().Create(authorization.NewPublicResourceControl(containerID, portainer.ContainerResourceControl))) + is.NoError(tx.ResourceControl().Create(authorization.NewPublicResourceControl(stackRCID, portainer.StackResourceControl))) + is.NoError(tx.ResourceControl().Create(authorization.NewPublicResourceControl(serviceID, portainer.ServiceResourceControl))) + return nil + })) + + is.NoError(store.ViewTx(func(tx dataservices.DataStoreTx) error { + // by direct ID + rc, err := ContainerResourceControlGetter(tx, envID)(container.Summary{ID: containerID}) + is.NoError(err) + is.NotNil(rc) + is.Equal(containerID, rc.ResourceID) + + // by compose stack label + rc, err = ContainerResourceControlGetter(tx, envID)( + container.Summary{ID: "unknown", Labels: map[string]string{consts.ComposeStackNameLabel: stackName}}, + ) + is.NoError(err) + is.NotNil(rc) + is.Equal(stackRCID, rc.ResourceID) + + // by swarm stack label + rc, err = ContainerResourceControlGetter(tx, envID)( + container.Summary{ID: "unknown", Labels: map[string]string{consts.SwarmStackNameLabel: stackName}}, + ) + is.NoError(err) + is.NotNil(rc) + is.Equal(stackRCID, rc.ResourceID) + + // by service ID + rc, err = ContainerResourceControlGetter(tx, envID)( + container.Summary{ID: "unknown", Labels: map[string]string{consts.SwarmServiceIDLabel: serviceID}}, + ) + is.NoError(err) + is.NotNil(rc) + is.Equal(serviceID, rc.ResourceID) + + return nil + })) + +} diff --git a/api/uac/custom_template.go b/api/uac/custom_template.go new file mode 100644 index 0000000..12e998d --- /dev/null +++ b/api/uac/custom_template.go @@ -0,0 +1,4 @@ +package uac + +// TODO: implement UAC rules for Custom templates +// See usage of portainer.CustomTemplateResourceControl diff --git a/api/uac/generic_rc_getter.go b/api/uac/generic_rc_getter.go new file mode 100644 index 0000000..04ae933 --- /dev/null +++ b/api/uac/generic_rc_getter.go @@ -0,0 +1,184 @@ +package uac + +import ( + "strings" + + "github.com/docker/docker/api/types/swarm" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/docker/consts" + "github.com/portainer/portainer/api/internal/authorization" + "github.com/portainer/portainer/api/slicesx" + "github.com/rs/zerolog/log" +) + +type rcServiceLike interface { + ResourceControlByResourceIDAndType(resourceID string, resourceType portainer.ResourceControlType) (*portainer.ResourceControl, error) +} + +type teamServiceLike interface { + TeamByName(name string) (*portainer.Team, error) +} + +type userServiceLike interface { + UserIDByUsername(name string) (portainer.UserID, error) +} + +type txLike[ + RCS rcServiceLike, + TS teamServiceLike, + US userServiceLike, +] interface { + ResourceControl() RCS + Team() TS + User() US +} + +type ResourceContext[T any] struct { + RCType portainer.ResourceControlType + IDGetter func(T) string + LabelsGetter func(T) map[string]string +} + +func genericResourcControlGetter[ + T any, + TX txLike[RCS, TS, US], + RCS rcServiceLike, + TS teamServiceLike, + US userServiceLike, +]( + tx TX, + endpointID portainer.EndpointID, + context ResourceContext[T], +) func(T) (*portainer.ResourceControl, error) { + return func(item T) (*portainer.ResourceControl, error) { + resourceID := context.IDGetter(item) + resourceType := context.RCType + + if rc, err := tx.ResourceControl().ResourceControlByResourceIDAndType( + resourceID, resourceType, + ); err != nil && !dataservices.IsErrObjectNotFound(err) { + return nil, err + } else if rc != nil { + return rc, nil + } + + if context.LabelsGetter == nil { + return authorization.NewEmptyRestrictedResourceControl(resourceID, resourceType), nil + } + + labels := context.LabelsGetter(item) + if labels == nil { + return authorization.NewEmptyRestrictedResourceControl(resourceID, resourceType), nil + } + + if serviceId, ok := labels[consts.SwarmServiceIDLabel]; ok { + if rc, err := tx.ResourceControl().ResourceControlByResourceIDAndType( + ServiceResourceControlID(swarm.Service{ID: serviceId}), + portainer.ServiceResourceControl, + ); err != nil && !dataservices.IsErrObjectNotFound(err) { + return nil, err + } else if rc != nil { + return rc, nil + } + } + + if stackName, ok := labels[consts.SwarmStackNameLabel]; ok { + if rc, err := tx.ResourceControl().ResourceControlByResourceIDAndType( + StackResourceControlID(endpointID, stackName), + portainer.StackResourceControl, + ); err != nil && !dataservices.IsErrObjectNotFound(err) { + return nil, err + } else if rc != nil { + return rc, nil + } + } + + if stackName, ok := labels[consts.ComposeStackNameLabel]; ok { + if rc, err := tx.ResourceControl().ResourceControlByResourceIDAndType( + StackResourceControlID(endpointID, stackName), + portainer.StackResourceControl, + ); err != nil && !dataservices.IsErrObjectNotFound(err) { + return nil, err + } else if rc != nil { + return rc, nil + } + } + + return rcFromPortainerLabels(tx, labels, resourceID, resourceType) + } +} + +const ( + publicRCLabel = "io.portainer.accesscontrol.public" + userRCLabel = "io.portainer.accesscontrol.users" + teamRCLabel = "io.portainer.accesscontrol.teams" +) + +// translation of rules from transport.newResourceControlFromPortainerLabels(resourceLabelsObject, resourceIdentifier, resourceType) +func rcFromPortainerLabels[ + TX txLike[RCS, TS, US], + RCS rcServiceLike, + TS teamServiceLike, + US userServiceLike, +]( + tx TX, labels map[string]string, resourceID string, resourceType portainer.ResourceControlType, +) (*portainer.ResourceControl, error) { + if _, ok := labels[publicRCLabel]; ok { + return authorization.NewPublicResourceControl(resourceID, resourceType), nil + } + + teamNames := make([]string, 0) + userNames := make([]string, 0) + + if teams, ok := labels[teamRCLabel]; ok { + teamNames = getUniqueElements(teams) + } + + if users, ok := labels[userRCLabel]; ok { + userNames = getUniqueElements(users) + } + + if len(teamNames) == 0 && len(userNames) == 0 { + return authorization.NewEmptyRestrictedResourceControl(resourceID, resourceType), nil + } + + teamIDs := make([]portainer.TeamID, 0) + userIDs := make([]portainer.UserID, 0) + for _, name := range teamNames { + team, err := tx.Team().TeamByName(name) + if err != nil { + log.Warn(). + Str("name", name). + Str("resource_id", resourceID). + Msg("unknown team name in access control label, ignoring access control rule for this team") + + continue + } + + teamIDs = append(teamIDs, team.ID) + } + + for _, name := range userNames { + userID, err := tx.User().UserIDByUsername(name) + if err != nil { + log.Warn(). + Str("name", name). + Str("resource_id", resourceID). + Msg("unknown user name in access control label, ignoring access control rule for this user") + continue + } + + userIDs = append(userIDs, userID) + } + + return authorization.NewRestrictedResourceControl(resourceID, resourceType, userIDs, teamIDs), nil +} + +func getUniqueElements(items string) []string { + xs := strings.Split(items, ",") + xs = slicesx.Map(xs, strings.TrimSpace) + xs = slicesx.FilterInPlace(xs, func(x string) bool { return len(x) > 0 }) + + return slicesx.Unique(xs) +} diff --git a/api/uac/generic_rc_getter_test.go b/api/uac/generic_rc_getter_test.go new file mode 100644 index 0000000..93d0ffb --- /dev/null +++ b/api/uac/generic_rc_getter_test.go @@ -0,0 +1,172 @@ +package uac + +import ( + "testing" + + "github.com/docker/docker/api/types/container" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/docker/consts" + "github.com/portainer/portainer/api/internal/authorization" + "github.com/stretchr/testify/require" +) + +func TestGenericResourcControlGetter(t *testing.T) { + t.Parallel() + is := require.New(t) + + ok, store := datastore.MustNewTestStore(t, true, false) + is.True(ok) + is.NotNil(store) + + endpointID := portainer.EndpointID(1) + composeStackName := "compose-stack" + composeStackRCID := StackResourceControlID(endpointID, composeStackName) + swarmStackName := "swarm-stack" + swarmStackRCID := StackResourceControlID(endpointID, swarmStackName) + serviceID := "service" + + err := store.UpdateTx(func(tx dataservices.DataStoreTx) error { + // created on container create + is.NoError(tx.ResourceControl().Create(authorization.NewPublicResourceControl("container", portainer.ContainerResourceControl))) + // created on compose stack create + is.NoError(tx.ResourceControl().Create(authorization.NewPublicResourceControl(composeStackRCID, portainer.StackResourceControl))) + // created on swarm stack create + is.NoError(tx.ResourceControl().Create(authorization.NewPublicResourceControl(swarmStackRCID, portainer.StackResourceControl))) + // created a swarm service create + is.NoError(tx.ResourceControl().Create(authorization.NewPublicResourceControl(serviceID, portainer.ServiceResourceControl))) + return nil + }) + is.NoError(err) + + err = store.ViewTx(func(tx dataservices.DataStoreTx) error { + context := ResourceContext[container.Summary]{RCType: portainer.ContainerResourceControl, IDGetter: ContainerResourceControlID, LabelsGetter: ContainerLabels} + + // trying to get UAC for a container created through Portainer + rc, err := genericResourcControlGetter(tx, 1, context)(container.Summary{ID: "container"}) + is.NoError(err) + is.NotNil(rc) + + // trying to get UAC for an external container + rc, err = genericResourcControlGetter(tx, 1, context)(container.Summary{ID: "unknown"}) + is.NoError(err) + is.NotNil(rc) + is.Empty(rc.UserAccesses) + is.Empty(rc.TeamAccesses) + + // trying to get UAC for a container from a compose stack + rc, err = genericResourcControlGetter(tx, 1, context)(container.Summary{ID: "by-compose-stack-name-label", Labels: map[string]string{consts.ComposeStackNameLabel: composeStackName}}) + is.NoError(err) + is.NotNil(rc) + is.Equal(composeStackRCID, rc.ResourceID) + + // trying to get UAC for a container from a swarm stack + rc, err = genericResourcControlGetter(tx, 1, context)(container.Summary{ID: "by-swarm-stack-name-label", Labels: map[string]string{consts.SwarmStackNameLabel: swarmStackName}}) + is.NoError(err) + is.NotNil(rc) + is.Equal(swarmStackRCID, rc.ResourceID) + + // trying to get UAC for a container from a swarm service + rc, err = genericResourcControlGetter(tx, 1, context)(container.Summary{ID: "by-swarm-service-id-label", Labels: map[string]string{consts.SwarmServiceIDLabel: serviceID}}) + is.NoError(err) + is.NotNil(rc) + is.Equal(serviceID, rc.ResourceID) + + return nil + }) + + is.NoError(err) + +} + +func TestGenericResourcControlGetterWithPortainerLabels(t *testing.T) { + t.Parallel() + is := require.New(t) + + ok, store := datastore.MustNewTestStore(t, true, false) + is.True(ok) + is.NotNil(store) + + is.NoError(store.UpdateTx(func(tx dataservices.DataStoreTx) error { + is.NoError(tx.User().Create(&portainer.User{Role: portainer.AdministratorRole, Username: "admin"})) + is.NoError(tx.User().Create(&portainer.User{Role: portainer.AdministratorRole, Username: "user"})) + is.NoError(tx.Team().Create(&portainer.Team{Name: "team"})) + is.NoError(tx.TeamMembership().Create(&portainer.TeamMembership{UserID: 2, TeamID: 1})) + return nil + })) + + is.NoError(store.ViewTx(func(tx dataservices.DataStoreTx) error { + context := ResourceContext[container.Summary]{RCType: portainer.ContainerResourceControl, IDGetter: ContainerResourceControlID, LabelsGetter: ContainerLabels} + + rc, err := genericResourcControlGetter(tx, 1, context)(container.Summary{ + ID: "public", + Labels: map[string]string{publicRCLabel: "true"}, + }) + is.NoError(err) + is.NotNil(rc) + is.True(rc.Public) + + rc, err = genericResourcControlGetter(tx, 1, context)(container.Summary{ + ID: "team", + Labels: map[string]string{teamRCLabel: "team"}, + }) + is.NoError(err) + is.NotNil(rc) + is.Contains(rc.TeamAccesses, portainer.TeamResourceAccess{TeamID: 1, AccessLevel: portainer.ReadWriteAccessLevel}) + + rc, err = genericResourcControlGetter(tx, 1, context)(container.Summary{ + ID: "no-team", + Labels: map[string]string{teamRCLabel: "team2"}, + }) + is.NoError(err) + is.NotNil(rc) + is.Empty(rc.UserAccesses) + is.Empty(rc.TeamAccesses) + + rc, err = genericResourcControlGetter(tx, 1, context)(container.Summary{ + ID: "user", + Labels: map[string]string{userRCLabel: "user"}, + }) + is.NoError(err) + is.NotNil(rc) + is.Contains(rc.UserAccesses, portainer.UserResourceAccess{UserID: 2, AccessLevel: portainer.ReadWriteAccessLevel}) + + rc, err = genericResourcControlGetter(tx, 1, context)(container.Summary{ + ID: "no-user", + Labels: map[string]string{userRCLabel: "user2"}, + }) + is.NoError(err) + is.NotNil(rc) + is.Empty(rc.UserAccesses) + is.Empty(rc.TeamAccesses) + + rc, err = genericResourcControlGetter(tx, 1, context)(container.Summary{ + ID: "warn-on-unknown", + Labels: map[string]string{userRCLabel: "user2,user3", teamRCLabel: "team2,team3"}, + }) + is.NoError(err) + is.NotNil(rc) + is.Empty(rc.UserAccesses) + is.Empty(rc.TeamAccesses) + + rc, err = genericResourcControlGetter(tx, 1, context)(container.Summary{ + ID: "empty-when-no-label", + }) + is.NoError(err) + is.NotNil(rc) + is.Empty(rc.UserAccesses) + is.Empty(rc.TeamAccesses) + + rc, err = genericResourcControlGetter(tx, 1, context)(container.Summary{ + ID: "empty-when-empty-labels", + Labels: map[string]string{}, + }) + is.NoError(err) + is.NotNil(rc) + is.Empty(rc.UserAccesses) + is.Empty(rc.TeamAccesses) + + return nil + })) +} diff --git a/api/uac/networks.go b/api/uac/networks.go new file mode 100644 index 0000000..a2537c2 --- /dev/null +++ b/api/uac/networks.go @@ -0,0 +1,30 @@ +package uac + +import ( + "github.com/docker/docker/api/types/network" + portainer "github.com/portainer/portainer/api" +) + +func NetworkResourceControlGetter[ + TX txLike[RCS, TS, US], + RCS rcServiceLike, + TS teamServiceLike, + US userServiceLike, +]( + tx TX, + endpointID portainer.EndpointID, +) func(item network.Summary) (*portainer.ResourceControl, error) { + return genericResourcControlGetter(tx, endpointID, ResourceContext[network.Summary]{ + RCType: portainer.NetworkResourceControl, + IDGetter: NetworkResourceControlID, + LabelsGetter: NetworkLabels, + }) +} + +func NetworkResourceControlID(item network.Summary) string { + return item.ID +} + +func NetworkLabels(item network.Summary) map[string]string { + return item.Labels +} diff --git a/api/uac/networks_test.go b/api/uac/networks_test.go new file mode 100644 index 0000000..23d0c26 --- /dev/null +++ b/api/uac/networks_test.go @@ -0,0 +1,71 @@ +package uac + +import ( + "testing" + + "github.com/docker/docker/api/types/network" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/docker/consts" + "github.com/portainer/portainer/api/internal/authorization" + "github.com/portainer/portainer/api/stacks/stackutils" + "github.com/stretchr/testify/require" +) + +func TestNetworkResourceControlGetter(t *testing.T) { + t.Parallel() + is := require.New(t) + + ok, store := datastore.MustNewTestStore(t, true, false) + is.True(ok) + is.NotNil(store) + + envID := portainer.EndpointID(1) + networkID := "network" + stackName := "stack" + stackRCID := stackutils.ResourceControlID(envID, stackName) + serviceID := "service" + + is.NoError(store.UpdateTx(func(tx dataservices.DataStoreTx) error { + is.NoError(tx.ResourceControl().Create(authorization.NewPublicResourceControl(networkID, portainer.NetworkResourceControl))) + is.NoError(tx.ResourceControl().Create(authorization.NewPublicResourceControl(stackRCID, portainer.StackResourceControl))) + is.NoError(tx.ResourceControl().Create(authorization.NewPublicResourceControl(serviceID, portainer.ServiceResourceControl))) + return nil + })) + + is.NoError(store.ViewTx(func(tx dataservices.DataStoreTx) error { + // by direct ID + rc, err := NetworkResourceControlGetter(tx, envID)(network.Inspect{ID: networkID}) + is.NoError(err) + is.NotNil(rc) + is.Equal(networkID, rc.ResourceID) + + // by compose stack label + rc, err = NetworkResourceControlGetter(tx, envID)( + network.Inspect{ID: "unknown", Labels: map[string]string{consts.ComposeStackNameLabel: stackName}}, + ) + is.NoError(err) + is.NotNil(rc) + is.Equal(stackRCID, rc.ResourceID) + + // by swarm stack label + rc, err = NetworkResourceControlGetter(tx, envID)( + network.Inspect{ID: "unknown", Labels: map[string]string{consts.SwarmStackNameLabel: stackName}}, + ) + is.NoError(err) + is.NotNil(rc) + is.Equal(stackRCID, rc.ResourceID) + + // by service ID + rc, err = NetworkResourceControlGetter(tx, envID)( + network.Inspect{ID: "unknown", Labels: map[string]string{consts.SwarmServiceIDLabel: serviceID}}, + ) + is.NoError(err) + is.NotNil(rc) + is.Equal(serviceID, rc.ResourceID) + + return nil + })) + +} diff --git a/api/uac/secrets.go b/api/uac/secrets.go new file mode 100644 index 0000000..39fb65f --- /dev/null +++ b/api/uac/secrets.go @@ -0,0 +1,30 @@ +package uac + +import ( + "github.com/docker/docker/api/types/swarm" + portainer "github.com/portainer/portainer/api" +) + +func SecretResourceControlGetter[ + TX txLike[RCS, TS, US], + RCS rcServiceLike, + TS teamServiceLike, + US userServiceLike, +]( + tx TX, + endpointID portainer.EndpointID, +) func(item swarm.Secret) (*portainer.ResourceControl, error) { + return genericResourcControlGetter(tx, endpointID, ResourceContext[swarm.Secret]{ + RCType: portainer.SecretResourceControl, + IDGetter: SecretResourceControlID, + LabelsGetter: SecretLabels, + }) +} + +func SecretResourceControlID(item swarm.Secret) string { + return item.ID +} + +func SecretLabels(item swarm.Secret) map[string]string { + return item.Spec.Labels +} diff --git a/api/uac/secrets_test.go b/api/uac/secrets_test.go new file mode 100644 index 0000000..fe976ed --- /dev/null +++ b/api/uac/secrets_test.go @@ -0,0 +1,71 @@ +package uac + +import ( + "testing" + + "github.com/docker/docker/api/types/swarm" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/docker/consts" + "github.com/portainer/portainer/api/internal/authorization" + "github.com/portainer/portainer/api/stacks/stackutils" + "github.com/stretchr/testify/require" +) + +func TestSecretResourceControlGetter(t *testing.T) { + t.Parallel() + is := require.New(t) + + ok, store := datastore.MustNewTestStore(t, true, false) + is.True(ok) + is.NotNil(store) + + envID := portainer.EndpointID(1) + secretID := "secret" + stackName := "stack" + stackRCID := stackutils.ResourceControlID(envID, stackName) + serviceID := "service" + + is.NoError(store.UpdateTx(func(tx dataservices.DataStoreTx) error { + is.NoError(tx.ResourceControl().Create(authorization.NewPublicResourceControl(secretID, portainer.SecretResourceControl))) + is.NoError(tx.ResourceControl().Create(authorization.NewPublicResourceControl(stackRCID, portainer.StackResourceControl))) + is.NoError(tx.ResourceControl().Create(authorization.NewPublicResourceControl(serviceID, portainer.ServiceResourceControl))) + return nil + })) + + is.NoError(store.ViewTx(func(tx dataservices.DataStoreTx) error { + // by direct ID + rc, err := SecretResourceControlGetter(tx, envID)(swarm.Secret{ID: secretID}) + is.NoError(err) + is.NotNil(rc) + is.Equal(secretID, rc.ResourceID) + + // by compose stack label + rc, err = SecretResourceControlGetter(tx, envID)( + swarm.Secret{ID: "unknown", Spec: swarm.SecretSpec{Annotations: swarm.Annotations{Labels: map[string]string{consts.ComposeStackNameLabel: stackName}}}}, + ) + is.NoError(err) + is.NotNil(rc) + is.Equal(stackRCID, rc.ResourceID) + + // by swarm stack label + rc, err = SecretResourceControlGetter(tx, envID)( + swarm.Secret{ID: "unknown", Spec: swarm.SecretSpec{Annotations: swarm.Annotations{Labels: map[string]string{consts.SwarmStackNameLabel: stackName}}}}, + ) + is.NoError(err) + is.NotNil(rc) + is.Equal(stackRCID, rc.ResourceID) + + // by service ID + rc, err = SecretResourceControlGetter(tx, envID)( + swarm.Secret{ID: "unknown", Spec: swarm.SecretSpec{Annotations: swarm.Annotations{Labels: map[string]string{consts.SwarmServiceIDLabel: serviceID}}}}, + ) + is.NoError(err) + is.NotNil(rc) + is.Equal(serviceID, rc.ResourceID) + + return nil + })) + +} diff --git a/api/uac/services.go b/api/uac/services.go new file mode 100644 index 0000000..961d6ab --- /dev/null +++ b/api/uac/services.go @@ -0,0 +1,30 @@ +package uac + +import ( + "github.com/docker/docker/api/types/swarm" + portainer "github.com/portainer/portainer/api" +) + +func ServiceResourceControlGetter[ + TX txLike[RCS, TS, US], + RCS rcServiceLike, + TS teamServiceLike, + US userServiceLike, +]( + tx TX, + endpointID portainer.EndpointID, +) func(item swarm.Service) (*portainer.ResourceControl, error) { + return genericResourcControlGetter(tx, endpointID, ResourceContext[swarm.Service]{ + RCType: portainer.ServiceResourceControl, + IDGetter: ServiceResourceControlID, + LabelsGetter: ServiceLabels, + }) +} + +func ServiceResourceControlID(item swarm.Service) string { + return item.ID +} + +func ServiceLabels(item swarm.Service) map[string]string { + return item.Spec.Labels +} diff --git a/api/uac/stacks.go b/api/uac/stacks.go new file mode 100644 index 0000000..d9c34f6 --- /dev/null +++ b/api/uac/stacks.go @@ -0,0 +1,48 @@ +package uac + +import ( + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/stacks/stackutils" +) + +func StackResourceControlGetter[ + TX txLike[RCS, TS, US], + RCS rcServiceLike, + TS teamServiceLike, + US userServiceLike, +]( + tx TX, + endpointID portainer.EndpointID, +) func(item portainer.Stack) (*portainer.ResourceControl, error) { + return genericResourcControlGetter(tx, endpointID, ResourceContext[portainer.Stack]{ + RCType: portainer.StackResourceControl, + IDGetter: func(s portainer.Stack) string { return StackResourceControlID(s.EndpointID, s.Name) }, + // stacks don't have labels so we don't pass a getter + }) +} + +// TODO: replace usage of stackutils function to this package +func StackResourceControlID(endpointID portainer.EndpointID, name string) string { + return stackutils.ResourceControlID(endpointID, name) +} + +type ExternalStack struct { + Labels map[string]string +} + +// External stacks are indirectly detected either via containers or services labels +// Any UAC applied to them can only be fetched from containers'/services' labels +func ExternalStackResourceControlGetter[ + TX txLike[RCS, TS, US], + RCS rcServiceLike, + TS teamServiceLike, + US userServiceLike, +]( + tx TX, + endpointID portainer.EndpointID) func(item ExternalStack) (*portainer.ResourceControl, error) { + return genericResourcControlGetter(tx, endpointID, ResourceContext[ExternalStack]{ + RCType: portainer.StackResourceControl, + IDGetter: func(s ExternalStack) string { return "0" }, + LabelsGetter: func(es ExternalStack) map[string]string { return es.Labels }, + }) +} diff --git a/api/uac/uac.go b/api/uac/uac.go new file mode 100644 index 0000000..3f2e512 --- /dev/null +++ b/api/uac/uac.go @@ -0,0 +1,53 @@ +package uac + +import ( + "fmt" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/internal/authorization" + "github.com/portainer/portainer/api/slicesx" +) + +// FilterByResourceControl filters a list of items based on the user's role and the resource control associated to the item. +// UAC rules +// * UAC in DB (direct) +// * UAC inherited (from swarm service | swarm stack | compose stack) +// * UAC defined in labels (UAC on external resources) +func FilterByResourceControl[T any]( + items []T, + user *portainer.User, + userMemberships []portainer.TeamMembership, + resourceControlGetter func(item T) (*portainer.ResourceControl, error), +) ([]T, error) { + filteredItems := make([]T, 0) + + if user == nil { + return filteredItems, nil + } + + if canBypassUAC(user) { + return items, nil + } + + userTeamIDs := slicesx.Map(userMemberships, func(membership portainer.TeamMembership) portainer.TeamID { + return membership.TeamID + }) + + for _, item := range items { + rc, err := resourceControlGetter(item) + if err != nil { + return nil, fmt.Errorf("Unable to retrieve resource control: %w", err) + } + + // TODO: move UserCanAccessResource function to the UAC package + if authorization.UserCanAccessResource(user.ID, userTeamIDs, rc) { + filteredItems = append(filteredItems, item) + } + } + + return filteredItems, nil +} + +func canBypassUAC(user *portainer.User) bool { + return user != nil && user.Role == portainer.AdministratorRole +} diff --git a/api/uac/uac_test.go b/api/uac/uac_test.go new file mode 100644 index 0000000..08eb423 --- /dev/null +++ b/api/uac/uac_test.go @@ -0,0 +1,78 @@ +package uac + +import ( + "testing" + + "github.com/docker/docker/api/types/container" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/docker/consts" + "github.com/portainer/portainer/api/internal/authorization" + "github.com/stretchr/testify/require" +) + +func TestCanBypassUAC(t *testing.T) { + t.Parallel() + is := require.New(t) + + admin := &portainer.User{Role: portainer.AdministratorRole} + user := &portainer.User{Role: portainer.StandardUserRole} + + is.False(canBypassUAC(nil)) + is.False(canBypassUAC(user)) + is.True(canBypassUAC(admin)) +} + +func TestFilterByResourceControl(t *testing.T) { + t.Parallel() + is := require.New(t) + + ok, store := datastore.MustNewTestStore(t, true, false) + is.True(ok) + is.NotNil(store) + + endpointID := portainer.EndpointID(1) + stackRCID := StackResourceControlID(endpointID, "stack") + + is.NoError(store.UpdateTx(func(tx dataservices.DataStoreTx) error { + is.NoError(tx.ResourceControl().Create(authorization.NewPrivateResourceControl(stackRCID, portainer.StackResourceControl, 2))) + + is.NoError(tx.ResourceControl().Create(authorization.NewAdministratorsOnlyResourceControl("admin", portainer.ContainerResourceControl))) + is.NoError(tx.ResourceControl().Create(authorization.NewPrivateResourceControl("private", portainer.ContainerResourceControl, 2))) + is.NoError(tx.ResourceControl().Create(authorization.NewPublicResourceControl("public", portainer.ContainerResourceControl))) + is.NoError(tx.ResourceControl().Create(authorization.NewSystemResourceControl("system", portainer.ContainerResourceControl))) + return nil + })) + + admin := &portainer.User{ID: 1, Role: portainer.AdministratorRole} + std := &portainer.User{ID: 2, Role: portainer.StandardUserRole} + std2 := &portainer.User{ID: 3, Role: portainer.StandardUserRole} + + containers := []container.Summary{{ID: "admin"}, {ID: "private"}, {ID: "public"}, {ID: "system"}, + {ID: "bylabel", Labels: map[string]string{consts.ComposeStackNameLabel: "stack"}}, + } + + is.NoError(store.ViewTx(func(tx dataservices.DataStoreTx) error { + c, err := FilterByResourceControl(containers, admin, []portainer.TeamMembership{}, ContainerResourceControlGetter(tx, endpointID)) + is.NoError(err) + is.Len(c, len(containers)) + + c, err = FilterByResourceControl(containers, std, []portainer.TeamMembership{}, ContainerResourceControlGetter(tx, endpointID)) + is.NoError(err) + is.Len(c, 4) + is.Contains(c, container.Summary{ID: "private"}) + is.Contains(c, container.Summary{ID: "public"}) + is.Contains(c, container.Summary{ID: "system"}) + is.Contains(c, container.Summary{ID: "bylabel", Labels: map[string]string{consts.ComposeStackNameLabel: "stack"}}) + + c, err = FilterByResourceControl(containers, std2, []portainer.TeamMembership{}, ContainerResourceControlGetter(tx, endpointID)) + is.NoError(err) + is.Len(c, 2) + is.Contains(c, container.Summary{ID: "public"}) + is.Contains(c, container.Summary{ID: "system"}) + + return nil + })) + +} diff --git a/api/uac/volumes.go b/api/uac/volumes.go new file mode 100644 index 0000000..34dab92 --- /dev/null +++ b/api/uac/volumes.go @@ -0,0 +1,32 @@ +package uac + +import ( + "github.com/docker/docker/api/types/volume" + portainer "github.com/portainer/portainer/api" +) + +func VolumeResourceControlGetter[ + TX txLike[RCS, TS, US], + RCS rcServiceLike, + TS teamServiceLike, + US userServiceLike, +]( + tx TX, + endpointID portainer.EndpointID, +) func(item volume.Volume) (*portainer.ResourceControl, error) { + return genericResourcControlGetter(tx, endpointID, ResourceContext[volume.Volume]{ + RCType: portainer.VolumeResourceControl, + IDGetter: VolumeResourceControlID, + LabelsGetter: VolumeLabels, + }) +} + +// TODO: use a copy of getVolumeResourceID() +// or change the key (+ migrate) to not require using the dockerID/swarm nodeID +func VolumeResourceControlID(item volume.Volume) string { + return item.Name +} + +func VolumeLabels(item volume.Volume) map[string]string { + return item.Labels +} diff --git a/api/uac/volumes_test.go b/api/uac/volumes_test.go new file mode 100644 index 0000000..7dfde42 --- /dev/null +++ b/api/uac/volumes_test.go @@ -0,0 +1,71 @@ +package uac + +import ( + "testing" + + "github.com/docker/docker/api/types/volume" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/docker/consts" + "github.com/portainer/portainer/api/internal/authorization" + "github.com/portainer/portainer/api/stacks/stackutils" + "github.com/stretchr/testify/require" +) + +func TestVolumeResourceControlGetter(t *testing.T) { + t.Parallel() + is := require.New(t) + + ok, store := datastore.MustNewTestStore(t, true, false) + is.True(ok) + is.NotNil(store) + + envID := portainer.EndpointID(1) + volumeName := "volume" + stackName := "stack" + stackRCID := stackutils.ResourceControlID(envID, stackName) + serviceID := "service" + + is.NoError(store.UpdateTx(func(tx dataservices.DataStoreTx) error { + is.NoError(tx.ResourceControl().Create(authorization.NewPublicResourceControl(volumeName, portainer.VolumeResourceControl))) + is.NoError(tx.ResourceControl().Create(authorization.NewPublicResourceControl(stackRCID, portainer.StackResourceControl))) + is.NoError(tx.ResourceControl().Create(authorization.NewPublicResourceControl(serviceID, portainer.ServiceResourceControl))) + return nil + })) + + is.NoError(store.ViewTx(func(tx dataservices.DataStoreTx) error { + // by direct ID + rc, err := VolumeResourceControlGetter(tx, envID)(volume.Volume{Name: volumeName}) + is.NoError(err) + is.NotNil(rc) + is.Equal(volumeName, rc.ResourceID) + + // by compose stack label + rc, err = VolumeResourceControlGetter(tx, envID)( + volume.Volume{Name: "unknown", Labels: map[string]string{consts.ComposeStackNameLabel: stackName}}, + ) + is.NoError(err) + is.NotNil(rc) + is.Equal(stackRCID, rc.ResourceID) + + // by swarm stack label + rc, err = VolumeResourceControlGetter(tx, envID)( + volume.Volume{Name: "unknown", Labels: map[string]string{consts.SwarmStackNameLabel: stackName}}, + ) + is.NoError(err) + is.NotNil(rc) + is.Equal(stackRCID, rc.ResourceID) + + // by service ID + rc, err = VolumeResourceControlGetter(tx, envID)( + volume.Volume{Name: "unknown", Labels: map[string]string{consts.SwarmServiceIDLabel: serviceID}}, + ) + is.NoError(err) + is.NotNil(rc) + is.Equal(serviceID, rc.ResourceID) + + return nil + })) + +} diff --git a/api/url/url.go b/api/url/url.go new file mode 100644 index 0000000..8a752b3 --- /dev/null +++ b/api/url/url.go @@ -0,0 +1,22 @@ +package url + +import ( + "net/url" + "strings" +) + +// ParseURL parses the endpointURL using url.Parse. +// +// to prevent an error when url has port but no protocol prefix +// we add `//` prefix if needed +func ParseURL(endpointURL string) (*url.URL, error) { + if !strings.HasPrefix(endpointURL, "http") && + !strings.HasPrefix(endpointURL, "tcp") && + !strings.HasPrefix(endpointURL, "//") && + !strings.HasPrefix(endpointURL, `unix:`) && + !strings.HasPrefix(endpointURL, `npipe:`) { + endpointURL = "//" + endpointURL + } + + return url.Parse(endpointURL) +} diff --git a/api/url/url_test.go b/api/url/url_test.go new file mode 100644 index 0000000..97eca24 --- /dev/null +++ b/api/url/url_test.go @@ -0,0 +1,67 @@ +package url + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestParseURL_NoPrefix(t *testing.T) { + t.Parallel() + + u, err := ParseURL("192.168.1.1:9000") + require.NoError(t, err) + require.Equal(t, "192.168.1.1:9000", u.Host) +} + +func TestParseURL_HTTPPrefix(t *testing.T) { + t.Parallel() + + u, err := ParseURL("http://example.com:8080") + require.NoError(t, err) + require.Equal(t, "http", u.Scheme) + require.Equal(t, "example.com:8080", u.Host) +} + +func TestParseURL_HTTPSPrefix(t *testing.T) { + t.Parallel() + + u, err := ParseURL("https://example.com") + require.NoError(t, err) + require.Equal(t, "https", u.Scheme) + require.Equal(t, "example.com", u.Host) +} + +func TestParseURL_TCPPrefix(t *testing.T) { + t.Parallel() + + u, err := ParseURL("tcp://192.168.1.1:2376") + require.NoError(t, err) + require.Equal(t, "tcp", u.Scheme) + require.Equal(t, "192.168.1.1:2376", u.Host) +} + +func TestParseURL_SlashSlashPrefix(t *testing.T) { + t.Parallel() + + u, err := ParseURL("//192.168.1.1:2376") + require.NoError(t, err) + require.Equal(t, "192.168.1.1:2376", u.Host) +} + +func TestParseURL_UnixPrefix(t *testing.T) { + t.Parallel() + + u, err := ParseURL("unix:///var/run/docker.sock") + require.NoError(t, err) + require.Equal(t, "unix", u.Scheme) + require.Equal(t, "/var/run/docker.sock", u.Path) +} + +func TestParseURL_NpipePrefix(t *testing.T) { + t.Parallel() + + u, err := ParseURL("npipe:////./pipe/docker_engine") + require.NoError(t, err) + require.Equal(t, "npipe", u.Scheme) +} diff --git a/api/ws/hijack.go b/api/ws/hijack.go new file mode 100644 index 0000000..4b935b9 --- /dev/null +++ b/api/ws/hijack.go @@ -0,0 +1,172 @@ +package ws + +import ( + "bufio" + "errors" + "fmt" + "io" + "net" + "net/http" + "sync" + "time" + + "github.com/gorilla/websocket" + "github.com/portainer/portainer/api/logs" + "github.com/rs/zerolog/log" +) + +const ( + // Time allowed to write a message to the peer + WriteWait = 10 * time.Second + + // Send pings to peer with this period + PingPeriod = 50 * time.Second +) + +func HijackRequest(websocketConn *websocket.Conn, conn net.Conn, request *http.Request) error { + resp, err := sendHTTPRequest(conn, request) + if err != nil { + return err + } + defer func() { + if err := resp.Body.Close(); err != nil { + log.Warn().Err(err).Msg("failed to close response body") + } + }() + + // Check if the response status code indicates an upgrade (101 Switching Protocols) + if resp.StatusCode != http.StatusSwitchingProtocols { + return fmt.Errorf("unexpected response status code: %d", resp.StatusCode) + } + + var mu sync.Mutex + + errorChan := make(chan error, 2) + go StreamFromWebsocketToWriter(websocketConn, conn, errorChan) + go WriteReaderToWebSocket(websocketConn, &mu, conn, errorChan) + + err = <-errorChan + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNoStatusReceived) { + log.Debug().Err(err).Msg("unexpected close error") + + return err + } + + log.Debug().Msg("session ended") + + return nil +} + +// sendHTTPRequest sends an HTTP request over the provided net.Conn and parses the response. +func sendHTTPRequest(conn net.Conn, req *http.Request) (*http.Response, error) { + // Send the HTTP request to the server + if err := req.Write(conn); err != nil { + return nil, fmt.Errorf("error writing request: %w", err) + } + + // Read the response from the server + resp, err := http.ReadResponse(bufio.NewReader(conn), req) + if err != nil { + return nil, fmt.Errorf("error reading response: %w", err) + } + + return resp, nil +} + +func WriteReaderToWebSocket(websocketConn *websocket.Conn, mu *sync.Mutex, reader io.Reader, errorChan chan error) { + out := make([]byte, ReaderBufferSize) + input := make(chan string) + pingTicker := time.NewTicker(PingPeriod) + + defer logs.CloseAndLogErr(websocketConn) + + done := make(chan struct{}) + defer close(done) + + if err := websocketConn.SetReadDeadline(time.Now().Add(PingPeriod + WriteWait)); err != nil { + errorChan <- err + + return + } + + mu.Lock() + websocketConn.SetPongHandler(func(string) error { + return websocketConn.SetReadDeadline(time.Now().Add(PingPeriod + WriteWait)) + }) + + websocketConn.SetPingHandler(func(data string) error { + mu.Lock() + defer mu.Unlock() + + if err := websocketConn.SetWriteDeadline(time.Now().Add(WriteWait)); err != nil { + return err + } + + return websocketConn.WriteMessage(websocket.PongMessage, []byte(data)) + }) + mu.Unlock() + + go func() { + for { + n, err := reader.Read(out) + if err != nil { + errorChan <- err + + if !errors.Is(err, io.EOF) { + log.Debug().Err(err).Msg("error reading from server") + } + + return + } + + processedOutput := ValidString(string(out[:n])) + + select { + case input <- processedOutput: + case <-done: + return + } + } + }() + + for { + select { + case msg := <-input: + if err := wsWrite(websocketConn, mu, msg); err != nil { + log.Debug().Err(err).Msg("error writing to websocket") + errorChan <- err + + return + } + case <-pingTicker.C: + if err := wsPing(websocketConn, mu); err != nil { + log.Debug().Err(err).Msg("error writing to websocket during pong response") + errorChan <- err + + return + } + } + } +} + +func wsWrite(websocketConn *websocket.Conn, mu *sync.Mutex, msg string) error { + mu.Lock() + defer mu.Unlock() + + if err := websocketConn.SetWriteDeadline(time.Now().Add(WriteWait)); err != nil { + return err + } + + return websocketConn.WriteMessage(websocket.TextMessage, []byte(msg)) +} + +func wsPing(websocketConn *websocket.Conn, mu *sync.Mutex) error { + mu.Lock() + defer mu.Unlock() + + if err := websocketConn.SetWriteDeadline(time.Now().Add(WriteWait)); err != nil { + return err + } + + return websocketConn.WriteMessage(websocket.PingMessage, nil) +} diff --git a/api/ws/resize.go b/api/ws/resize.go new file mode 100644 index 0000000..5ce24ef --- /dev/null +++ b/api/ws/resize.go @@ -0,0 +1,35 @@ +package ws + +import ( + "github.com/segmentio/encoding/json" + + "github.com/gorilla/websocket" +) + +// SizeQueue is implemented by types that accept terminal resize events. +type SizeQueue interface { + Push(cols, rows uint16) +} + +// ResizeHandler returns a MessageHandler that intercepts terminal resize messages +// and forwards them to the given SizeQueue. +// Resize messages are JSON text frames: {"type":"resize","data":{"width":N,"height":N}}. +func ResizeHandler(q SizeQueue) MessageHandler { + return func(messageType int, data []byte) bool { + if messageType != websocket.TextMessage { + return false + } + var msg struct { + Type string `json:"type"` + Data struct { + Width uint16 `json:"width"` + Height uint16 `json:"height"` + } `json:"data"` + } + if err := json.Unmarshal(data, &msg); err != nil || msg.Type != "resize" { + return false + } + q.Push(msg.Data.Width, msg.Data.Height) + return true + } +} diff --git a/api/ws/stream.go b/api/ws/stream.go new file mode 100644 index 0000000..bcdf0ab --- /dev/null +++ b/api/ws/stream.go @@ -0,0 +1,101 @@ +package ws + +import ( + "io" + "time" + "unicode/utf8" + + "github.com/gorilla/websocket" + "github.com/rs/zerolog/log" +) + +const ReaderBufferSize = 2048 + +// MessageHandler processes a WebSocket message before it is forwarded to the writer. +// It returns true if the message was handled and should not be forwarded to the writer. +type MessageHandler func(messageType int, data []byte) bool + +func StreamFromWebsocketToWriter(websocketConn *websocket.Conn, writer io.Writer, errorChan chan error, handlers ...MessageHandler) { + for { + messageType, in, err := websocketConn.ReadMessage() + if err != nil { + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway) { + log.Debug().Err(err).Msg("unexpected close error") + } + errorChan <- err + + return + } + + if messageType != websocket.TextMessage && messageType != websocket.BinaryMessage { + continue + } + + handled := false + for _, h := range handlers { + if h(messageType, in) { + handled = true + break + } + } + + if handled { + continue + } + + if _, err := writer.Write(in); err != nil { + log.Debug().Err(err).Msg("writing error") + errorChan <- err + + return + } + } +} + +func StreamFromReaderToWebsocket(websocketConn *websocket.Conn, reader io.Reader, errorChan chan error) { + out := make([]byte, ReaderBufferSize) + + for { + n, err := reader.Read(out) + if err != nil { + errorChan <- err + + break + } + + processedOutput := ValidString(string(out[:n])) + + if err := websocketConn.SetWriteDeadline(time.Now().Add(WriteWait)); err != nil { + errorChan <- err + + break + } + + if err := websocketConn.WriteMessage(websocket.TextMessage, []byte(processedOutput)); err != nil { + errorChan <- err + + break + } + } +} + +func ValidString(s string) string { + if utf8.ValidString(s) { + return s + } + + v := make([]rune, 0, len(s)) + + for i, r := range s { + if r == utf8.RuneError { + _, size := utf8.DecodeRuneInString(s[i:]) + if size == 1 { + continue + } + } + + v = append(v, r) + } + + return string(v) +} diff --git a/api/ws/ws_test.go b/api/ws/ws_test.go new file mode 100644 index 0000000..04bad98 --- /dev/null +++ b/api/ws/ws_test.go @@ -0,0 +1,436 @@ +package ws + +import ( + "bytes" + "errors" + "io" + "net" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "unicode/utf8" + + "github.com/portainer/portainer/api/logs" + + "github.com/gorilla/websocket" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var testUpgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { return true }, +} + +// newTestWSPair creates an httptest server, upgrades one connection to websocket, +// and dials a client connection. Returns (server, client) connections +func newTestWSPair(t *testing.T) (*websocket.Conn, *websocket.Conn) { + t.Helper() + + connCh := make(chan *websocket.Conn, 1) + doneCh := make(chan struct{}) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := testUpgrader.Upgrade(w, r, nil) + if !assert.NoError(t, err) { + return + } + + connCh <- conn + <-doneCh + })) + + t.Cleanup(func() { + close(doneCh) + srv.Close() + }) + + u := "ws" + strings.TrimPrefix(srv.URL, "http") + + client, resp, err := websocket.DefaultDialer.Dial(u, nil) + require.NoError(t, err) + + t.Cleanup(func() { logs.CloseAndLogErr(resp.Body) }) + + t.Cleanup(func() { + err := client.Close() + require.NoError(t, err) + }) + + server := <-connCh + t.Cleanup(func() { + err := server.Close() + require.NoError(t, err) + }) + + return server, client +} + +type failWriter struct { + err error +} + +func (w *failWriter) Write(_ []byte) (int, error) { + return 0, w.err +} + +type failReader struct { + err error +} + +func (r *failReader) Read(_ []byte) (int, error) { + return 0, r.err +} + +func TestValidString(t *testing.T) { + t.Parallel() + + f := func(input, expected string) { + t.Helper() + result := ValidString(input) + require.Equal(t, expected, result) + require.True(t, utf8.ValidString(result)) + } + + f("hello world", "hello world") + // \xff and \xfe are invalid UTF-8 bytes and must be stripped + f("hello\xff\xfeworld", "helloworld") + f("", "") +} + +func TestStreamFromWebsocketToWriter_ForwardsMessages(t *testing.T) { + t.Parallel() + + serverConn, clientConn := newTestWSPair(t) + + var buf bytes.Buffer + errorChan := make(chan error, 1) + + go StreamFromWebsocketToWriter(serverConn, &buf, errorChan) + + err := clientConn.WriteMessage(websocket.TextMessage, []byte("hello")) + require.NoError(t, err) + + err = clientConn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, "")) + require.NoError(t, err) + + // The goroutine sends to errorChan after processing all prior messages, + // so buf is fully written by the time we receive here + err = <-errorChan + require.Error(t, err) + + require.Equal(t, "hello", buf.String()) +} + +func TestStreamFromWebsocketToWriter_HandlerInterceptsMessage(t *testing.T) { + t.Parallel() + + serverConn, clientConn := newTestWSPair(t) + + var buf bytes.Buffer + errorChan := make(chan error, 1) + + intercepted := false + handler := MessageHandler(func(_ int, data []byte) bool { + if string(data) == "intercept" { + intercepted = true + + return true + } + + return false + }) + + go StreamFromWebsocketToWriter(serverConn, &buf, errorChan, handler) + + err := clientConn.WriteMessage(websocket.TextMessage, []byte("intercept")) + require.NoError(t, err) + + err = clientConn.WriteMessage(websocket.TextMessage, []byte("forward")) + require.NoError(t, err) + + err = clientConn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, "")) + require.NoError(t, err) + + err = <-errorChan + require.Error(t, err) + + require.True(t, intercepted) + require.Equal(t, "forward", buf.String()) +} + +func TestStreamFromWebsocketToWriter_WriteError(t *testing.T) { + t.Parallel() + + serverConn, clientConn := newTestWSPair(t) + + expectedErr := errors.New("write error") + errorChan := make(chan error, 1) + + go StreamFromWebsocketToWriter(serverConn, &failWriter{err: expectedErr}, errorChan) + + err := clientConn.WriteMessage(websocket.TextMessage, []byte("trigger")) + require.NoError(t, err) + + err = <-errorChan + require.ErrorIs(t, err, expectedErr) +} + +func TestStreamFromReaderToWebsocket_ForwardsData(t *testing.T) { + t.Parallel() + + serverConn, clientConn := newTestWSPair(t) + + reader := strings.NewReader("hello world") + errorChan := make(chan error, 1) + + go StreamFromReaderToWebsocket(serverConn, reader, errorChan) + + msgType, msg, err := clientConn.ReadMessage() + require.NoError(t, err) + require.Equal(t, websocket.TextMessage, msgType) + require.Equal(t, "hello world", string(msg)) + + err = <-errorChan + require.ErrorIs(t, err, io.EOF) +} + +func TestStreamFromReaderToWebsocket_ReadError(t *testing.T) { + t.Parallel() + + serverConn, _ := newTestWSPair(t) + + expectedErr := errors.New("read error") + errorChan := make(chan error, 1) + + go StreamFromReaderToWebsocket(serverConn, &failReader{err: expectedErr}, errorChan) + + err := <-errorChan + require.ErrorIs(t, err, expectedErr) +} + +func TestWriteReaderToWebSocket_ForwardsData(t *testing.T) { + t.Parallel() + + serverConn, clientConn := newTestWSPair(t) + + var mu sync.Mutex + reader := strings.NewReader("hello") + errorChan := make(chan error, 2) + + go WriteReaderToWebSocket(serverConn, &mu, reader, errorChan) + + msgType, msg, err := clientConn.ReadMessage() + require.NoError(t, err) + require.Equal(t, websocket.TextMessage, msgType) + require.Equal(t, "hello", string(msg)) + + // The inner goroutine sends EOF after the reader is exhausted + err = <-errorChan + require.ErrorIs(t, err, io.EOF) +} + +func TestWriteReaderToWebSocket_ReaderError(t *testing.T) { + t.Parallel() + + serverConn, _ := newTestWSPair(t) + + var mu sync.Mutex + expectedErr := errors.New("read error") + errorChan := make(chan error, 2) + + go WriteReaderToWebSocket(serverConn, &mu, &failReader{err: expectedErr}, errorChan) + + err := <-errorChan + require.ErrorIs(t, err, expectedErr) +} + +// newTestWSConn creates a websocket server connection without registering a close cleanup +// for it, allowing the caller to close it manually without triggering a double-close +func newTestWSConn(t *testing.T) *websocket.Conn { + t.Helper() + + connCh := make(chan *websocket.Conn, 1) + doneCh := make(chan struct{}) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := testUpgrader.Upgrade(w, r, nil) + if !assert.NoError(t, err) { + return + } + + connCh <- conn + <-doneCh + })) + + t.Cleanup(func() { + close(doneCh) + srv.Close() + }) + + u := "ws" + strings.TrimPrefix(srv.URL, "http") + + client, resp, err := websocket.DefaultDialer.Dial(u, nil) + require.NoError(t, err) + + t.Cleanup(func() { logs.CloseAndLogErr(resp.Body) }) + + t.Cleanup(func() { logs.CloseAndLogErr(client) }) + + return <-connCh +} + +type mockSizeQueue struct { + cols uint16 + rows uint16 +} + +func (m *mockSizeQueue) Push(cols, rows uint16) { + m.cols = cols + m.rows = rows +} + +func TestResizeHandler(t *testing.T) { + t.Parallel() + + f := func(msgType int, data []byte, expectedHandled bool, expectedCols, expectedRows uint16) { + t.Helper() + q := &mockSizeQueue{} + handled := ResizeHandler(q)(msgType, data) + require.Equal(t, expectedHandled, handled) + require.Equal(t, expectedCols, q.cols) + require.Equal(t, expectedRows, q.rows) + } + + f(websocket.BinaryMessage, []byte(`{"type":"resize","data":{"width":80,"height":24}}`), false, 0, 0) + f(websocket.TextMessage, []byte(`not json`), false, 0, 0) + f(websocket.TextMessage, []byte(`{"type":"other","data":{"width":80,"height":24}}`), false, 0, 0) + f(websocket.TextMessage, []byte(`{"type":"resize","data":{"width":80,"height":24}}`), true, 80, 24) +} + +func TestWsPing_Success(t *testing.T) { + t.Parallel() + + serverConn, _ := newTestWSPair(t) + + var mu sync.Mutex + err := wsPing(serverConn, &mu) + require.NoError(t, err) +} + +func TestWsPing_ClosedConnection(t *testing.T) { + t.Parallel() + + serverConn := newTestWSConn(t) + + err := serverConn.Close() + require.NoError(t, err) + + var mu sync.Mutex + err = wsPing(serverConn, &mu) + require.Error(t, err) +} + +func TestWsWrite_ClosedConnection(t *testing.T) { + t.Parallel() + + serverConn := newTestWSConn(t) + + err := serverConn.Close() + require.NoError(t, err) + + var mu sync.Mutex + err = wsWrite(serverConn, &mu, "hello") + require.Error(t, err) +} + +func TestWriteReaderToWebSocket_ClosedConnection(t *testing.T) { + t.Parallel() + + serverConn := newTestWSConn(t) + + err := serverConn.Close() + require.NoError(t, err) + + var mu sync.Mutex + errorChan := make(chan error, 2) + go WriteReaderToWebSocket(serverConn, &mu, strings.NewReader("hello"), errorChan) + + err = <-errorChan + require.Error(t, err) +} + +func TestStreamFromReaderToWebsocket_ClosedConnection(t *testing.T) { + t.Parallel() + + serverConn := newTestWSConn(t) + + err := serverConn.Close() + require.NoError(t, err) + + errorChan := make(chan error, 1) + go StreamFromReaderToWebsocket(serverConn, strings.NewReader("hello"), errorChan) + + err = <-errorChan + require.Error(t, err) +} + +func TestHijackRequest_UnexpectedStatus(t *testing.T) { + t.Parallel() + + serverConn, _ := newTestWSPair(t) + + backendServer, backendClient := net.Pipe() + defer logs.CloseAndLogErr(backendServer) + defer logs.CloseAndLogErr(backendClient) + + go func() { + buf := make([]byte, 4096) + if _, err := backendServer.Read(buf); err != nil { + return + } + + resp := "HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n" + if _, err := backendServer.Write([]byte(resp)); err != nil { + return + } + }() + + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "http://backend/", nil) + require.NoError(t, err) + + err = HijackRequest(serverConn, backendClient, req) + require.ErrorContains(t, err, "403") +} + +func TestHijackRequest_Success(t *testing.T) { + t.Parallel() + + serverConn, _ := newTestWSPair(t) + + backendServer, backendClient := net.Pipe() + defer logs.CloseAndLogErr(backendClient) + + go func() { + defer logs.CloseAndLogErr(backendServer) + + buf := make([]byte, 4096) + if _, err := backendServer.Read(buf); err != nil { + return + } + + resp := "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n" + if _, err := backendServer.Write([]byte(resp)); err != nil { + return + } + }() + + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "http://backend/", nil) + require.NoError(t, err) + + // The backend closes after sending 101, which delivers EOF to the reader goroutine. + // HijackRequest treats EOF (not a CloseError) as a clean session end and returns nil. + err = HijackRequest(serverConn, backendClient, req) + require.NoError(t, err) +} diff --git a/app/__mocks__/fileMock.js b/app/__mocks__/fileMock.js new file mode 100644 index 0000000..86059f3 --- /dev/null +++ b/app/__mocks__/fileMock.js @@ -0,0 +1 @@ +module.exports = 'test-file-stub'; diff --git a/app/__mocks__/i18next.test.ts b/app/__mocks__/i18next.test.ts new file mode 100644 index 0000000..e6cc970 --- /dev/null +++ b/app/__mocks__/i18next.test.ts @@ -0,0 +1,39 @@ +import * as i18nextMocks from './i18next'; + +describe('mockT', () => { + it('should return correctly with no arguments', async () => { + const testText = `The company's new IT initiative, code named Phoenix Project, is critical to the + future of Parts Unlimited, but the project is massively over budget and very late. The CEO wants + Bill to report directly to him and fix the mess in ninety days or else Bill's entire department + will be outsourced.`; + + const translatedText = i18nextMocks.mockT(testText); + + expect(translatedText).toBe(testText); + }); + + test.each` + testText | args | expectedText + ${'{{fileName}} is invalid.'} | ${{ fileName: 'example_5.csv' }} | ${'example_5.csv is invalid.'} + ${'{{fileName}} {is}.'} | ${{ fileName: ' ' }} | ${' {is}.'} + ${'{{number}} of {{total}}'} | ${{ number: 0, total: 999 }} | ${'0 of 999'} + ${'There was an error:\n{{error}}'} | ${{ error: 'Failed' }} | ${'There was an error:\nFailed'} + ${'Click:{{li}}{{li2}}{{li_3}}'} | ${{ li: '', li2: 'https://', li_3: '!@#$%' }} | ${'Click:https://!@#$%'} + ${'{{happy}}😏y✔{{sad}}{{laugh}}'} | ${{ happy: '😃', sad: '😢', laugh: '🤣' }} | ${'😃😏y✔😢🤣'} + `( + 'should return correctly while handling arguments in different scenarios', + ({ testText, args, expectedText }) => { + const translatedText = i18nextMocks.mockT(testText, args); + + expect(translatedText).toBe(expectedText); + } + ); +}); + +describe('language', () => { + it('should return language', async () => { + const { language } = i18nextMocks.default; + + expect(language).toBe('en'); + }); +}); diff --git a/app/__mocks__/i18next.ts b/app/__mocks__/i18next.ts new file mode 100644 index 0000000..4194dd0 --- /dev/null +++ b/app/__mocks__/i18next.ts @@ -0,0 +1,35 @@ +function replaceBetween( + startIndex: number, + endIndex: number, + original: string, + insertion: string +) { + const result = + original.substring(0, startIndex) + + insertion + + original.substring(endIndex); + return result; +} + +export function mockT(i18nKey: string, args?: Record) { + let key = i18nKey; + + while (key.includes('{{') && args) { + const startIndex = key.indexOf('{{'); + const endIndex = key.indexOf('}}'); + + const currentArg = key.substring(startIndex + 2, endIndex); + const value = args[currentArg]; + + key = replaceBetween(startIndex, endIndex + 2, key, value); + } + + return key; +} + +export default { + t: mockT, + language: 'en', + changeLanguage: () => new Promise(() => {}), + use: () => this, +}; diff --git a/app/__mocks__/react-i18next.tsx b/app/__mocks__/react-i18next.tsx new file mode 100644 index 0000000..f2aa454 --- /dev/null +++ b/app/__mocks__/react-i18next.tsx @@ -0,0 +1,16 @@ +import { PropsWithChildren } from 'react'; + +import { mockT } from './i18next'; + +export function useTranslation() { + return { + t: mockT, + i18n: { + changeLanguage: () => new Promise(() => {}), + }, + }; +} + +export function Trans({ children }: PropsWithChildren) { + return <>{children}; +} diff --git a/app/__mocks__/styleMock.js b/app/__mocks__/styleMock.js new file mode 100644 index 0000000..f053ebf --- /dev/null +++ b/app/__mocks__/styleMock.js @@ -0,0 +1 @@ +module.exports = {}; diff --git a/app/__mocks__/svg.tsx b/app/__mocks__/svg.tsx new file mode 100644 index 0000000..42e49b9 --- /dev/null +++ b/app/__mocks__/svg.tsx @@ -0,0 +1,10 @@ +import { forwardRef } from 'react'; + +const SvgrMock = forwardRef((props, ref) => ( + // eslint-disable-next-line react/jsx-props-no-spreading + +)); + +SvgrMock.displayName = 'SvgrMock'; + +export default SvgrMock; diff --git a/app/agent/components/host-browser/hostBrowser.html b/app/agent/components/host-browser/hostBrowser.html new file mode 100644 index 0000000..53704c6 --- /dev/null +++ b/app/agent/components/host-browser/hostBrowser.html @@ -0,0 +1,12 @@ + diff --git a/app/agent/components/host-browser/hostBrowserController.js b/app/agent/components/host-browser/hostBrowserController.js new file mode 100644 index 0000000..4d5ddaa --- /dev/null +++ b/app/agent/components/host-browser/hostBrowserController.js @@ -0,0 +1,163 @@ +import _ from 'lodash-es'; +import { confirmDelete } from '@@/modals/confirm'; + +const ROOT_PATH = '/host'; + +export class HostBrowserController { + /* @ngInject */ + constructor($async, HostBrowserService, Notifications, FileSaver) { + Object.assign(this, { $async, HostBrowserService, Notifications, FileSaver }); + + 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(this.endpointId, 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(this.endpointId, filePath, newFilePath); + this.Notifications.success('File successfully renamed', this.getRelativePath(newFilePath)); + const files = await this.HostBrowserService.ls(this.endpointId, 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(this.endpointId, 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); + + confirmDelete(`Are you sure that you want to delete ${this.getRelativePath(filePath)}?`).then((confirmed) => { + if (!confirmed) { + return; + } + return this.deleteFile(filePath); + }); + } + + deleteFile(path) { + this.$async(this.deleteFileAsync, path); + } + async deleteFileAsync(path) { + try { + await this.HostBrowserService.delete(this.endpointId, path); + this.Notifications.success('File successfully deleted', this.getRelativePath(path)); + const files = await this.HostBrowserService.ls(this.endpointId, 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) { + if (!this.endpointId) { + throw new Error('missing endpoint id'); + } + try { + await this.HostBrowserService.upload(this.endpointId, 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 0000000..936d16b --- /dev/null +++ b/app/agent/components/host-browser/index.js @@ -0,0 +1,10 @@ +import angular from 'angular'; +import { HostBrowserController } from './hostBrowserController'; + +angular.module('portainer.agent').component('hostBrowser', { + controller: HostBrowserController, + templateUrl: './hostBrowser.html', + bindings: { + endpointId: '<', + }, +}); diff --git a/app/agent/components/node-selector/index.js b/app/agent/components/node-selector/index.js new file mode 100644 index 0000000..a489f99 --- /dev/null +++ b/app/agent/components/node-selector/index.js @@ -0,0 +1,12 @@ +import angular from 'angular'; + +import { NodeSelectorController } from './nodeSelectorController'; + +angular.module('portainer.agent').component('nodeSelector', { + templateUrl: './nodeSelector.html', + controller: NodeSelectorController, + bindings: { + model: '=', + endpointId: '<', + }, +}); diff --git a/app/agent/components/node-selector/nodeSelector.html b/app/agent/components/node-selector/nodeSelector.html new file mode 100644 index 0000000..7cf9815 --- /dev/null +++ b/app/agent/components/node-selector/nodeSelector.html @@ -0,0 +1,6 @@ +
+ +
+ +
+
diff --git a/app/agent/components/node-selector/nodeSelectorController.js b/app/agent/components/node-selector/nodeSelectorController.js new file mode 100644 index 0000000..26cd5bd --- /dev/null +++ b/app/agent/components/node-selector/nodeSelectorController.js @@ -0,0 +1,18 @@ +export class NodeSelectorController { + /* @ngInject */ + constructor(AgentService, Notifications) { + Object.assign(this, { AgentService, Notifications }); + } + + async $onInit() { + try { + const agents = await this.AgentService.agents(this.endpointId); + 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/index.js b/app/agent/components/volume-browser/index.js new file mode 100644 index 0000000..5d5104c --- /dev/null +++ b/app/agent/components/volume-browser/index.js @@ -0,0 +1,14 @@ +import angular from 'angular'; + +import { VolumeBrowserController } from './volumeBrowserController'; + +angular.module('portainer.agent').component('volumeBrowser', { + templateUrl: './volumeBrowser.html', + controller: VolumeBrowserController, + bindings: { + volumeId: '<', + nodeName: '<', + isUploadEnabled: '<', + endpointId: '<', + }, +}); diff --git a/app/agent/components/volume-browser/volumeBrowser.html b/app/agent/components/volume-browser/volumeBrowser.html new file mode 100644 index 0000000..76f20ef --- /dev/null +++ b/app/agent/components/volume-browser/volumeBrowser.html @@ -0,0 +1,13 @@ + diff --git a/app/agent/components/volume-browser/volumeBrowserController.js b/app/agent/components/volume-browser/volumeBrowserController.js new file mode 100644 index 0000000..c0f3073 --- /dev/null +++ b/app/agent/components/volume-browser/volumeBrowserController.js @@ -0,0 +1,154 @@ +import _ from 'lodash-es'; +import { confirmDelete } from '@@/modals/confirm'; + +export class VolumeBrowserController { + /* @ngInject */ + constructor($async, HttpRequestHelper, VolumeBrowserService, FileSaver, Blob, Notifications) { + Object.assign(this, { $async, HttpRequestHelper, VolumeBrowserService, FileSaver, Blob, Notifications }); + this.state = { + path: '/', + }; + + 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); + } + + 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}`; + + try { + await this.VolumeBrowserService.rename(this.endpointId, this.volumeId, filePath, newFilePath); + this.Notifications.success('File successfully renamed', newFilePath); + this.files = await this.VolumeBrowserService.ls(this.endpointId, this.volumeId, this.state.path); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to rename file'); + } + } + + confirmDelete(file) { + const filePath = this.state.path === '/' ? file : `${this.state.path}/${file}`; + + confirmDelete(`Are you sure that you want to delete ${filePath} ?`).then((confirmed) => { + if (!confirmed) { + return; + } + this.deleteFile(filePath); + }); + } + + 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.endpointId, 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.endpointId, this.volumeId, file); + this.Notifications.success('File successfully deleted', file); + this.files = await this.VolumeBrowserService.ls(this.endpointId, 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.endpointId, 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.endpointId, 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 '/'; + } + + const split = _.split(path, '/'); + return _.join(_.slice(split, 0, split.length - 1), '/'); + } + + buildPath(parent, file) { + if (parent === '/') { + return parent + file; + } + return `${parent}/${file}`; + } + + onFileUploaded() { + this.refreshList(); + } + + refreshList() { + this.getFilesForPath(this.state.path); + } + + async $onInit() { + this.HttpRequestHelper.setPortainerAgentTargetHeader(this.nodeName); + try { + this.files = await this.VolumeBrowserService.ls(this.endpointId, this.volumeId, this.state.path); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to browse volume'); + } + } +} diff --git a/app/agent/index.js b/app/agent/index.js new file mode 100644 index 0000000..140612a --- /dev/null +++ b/app/agent/index.js @@ -0,0 +1,3 @@ +import angular from 'angular'; + +angular.module('portainer.agent', []); diff --git a/app/agent/models/agent.js b/app/agent/models/agent.js new file mode 100644 index 0000000..c417265 --- /dev/null +++ b/app/agent/models/agent.js @@ -0,0 +1,7 @@ +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 new file mode 100644 index 0000000..fc93a52 --- /dev/null +++ b/app/agent/rest/agent.js @@ -0,0 +1,15 @@ +import angular from 'angular'; + +angular.module('portainer.agent').factory('Agent', AgentFactory); + +function AgentFactory($resource, API_ENDPOINT_ENDPOINTS, StateManager) { + return $resource( + `${API_ENDPOINT_ENDPOINTS}/:endpointId/docker/v:version/agents`, + { + version: StateManager.getAgentApiVersion, + }, + { + query: { method: 'GET', isArray: true }, + } + ); +} diff --git a/app/agent/rest/browse.js b/app/agent/rest/browse.js new file mode 100644 index 0000000..d31b315 --- /dev/null +++ b/app/agent/rest/browse.js @@ -0,0 +1,35 @@ +import angular from 'angular'; + +import { browseGetResponse } from './response/browse'; + +angular.module('portainer.agent').factory('Browse', BrowseFactory); + +function BrowseFactory($resource, API_ENDPOINT_ENDPOINTS, StateManager) { + return $resource( + `${API_ENDPOINT_ENDPOINTS}/:endpointId/docker/v:version/browse/:action`, + { + version: StateManager.getAgentApiVersion, + }, + { + ls: { + method: 'GET', + isArray: true, + params: { action: 'ls' }, + }, + get: { + method: 'GET', + params: { action: 'get' }, + transformResponse: browseGetResponse, + responseType: 'arraybuffer', + }, + delete: { + method: 'DELETE', + params: { action: 'delete' }, + }, + rename: { + method: 'PUT', + params: { action: 'rename' }, + }, + } + ); +} diff --git a/app/agent/rest/dockerhub.js b/app/agent/rest/dockerhub.js new file mode 100644 index 0000000..369feda --- /dev/null +++ b/app/agent/rest/dockerhub.js @@ -0,0 +1,13 @@ +import angular from 'angular'; + +angular.module('portainer.agent').factory('AgentDockerhub', AgentDockerhub); + +function AgentDockerhub($resource, API_ENDPOINT_ENDPOINTS) { + return $resource( + `${API_ENDPOINT_ENDPOINTS}/:endpointId/agent/:endpointType/v2/dockerhub/:registryId`, + {}, + { + limits: { method: 'GET', params: { registryId: '@registryId' } }, + } + ); +} diff --git a/app/agent/rest/host.js b/app/agent/rest/host.js new file mode 100644 index 0000000..daed21c --- /dev/null +++ b/app/agent/rest/host.js @@ -0,0 +1,15 @@ +import angular from 'angular'; + +angular.module('portainer.agent').factory('Host', HostFactory); + +function HostFactory($resource, API_ENDPOINT_ENDPOINTS, StateManager) { + return $resource( + `${API_ENDPOINT_ENDPOINTS}/:endpointId/docker/v:version/host/:action`, + { + version: StateManager.getAgentApiVersion, + }, + { + info: { method: 'GET', params: { action: 'info' } }, + } + ); +} diff --git a/app/agent/rest/ping.js b/app/agent/rest/ping.js new file mode 100644 index 0000000..91cb559 --- /dev/null +++ b/app/agent/rest/ping.js @@ -0,0 +1,30 @@ +import angular from 'angular'; + +angular.module('portainer.agent').factory('AgentPing', AgentPingFactory); + +function AgentPingFactory($resource, API_ENDPOINT_ENDPOINTS, $q) { + return $resource( + `${API_ENDPOINT_ENDPOINTS}/:endpointId/docker/ping`, + {}, + { + 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 new file mode 100644 index 0000000..b88f321 --- /dev/null +++ b/app/agent/rest/response/browse.js @@ -0,0 +1,9 @@ +// The get action of the Browse service returns a file. +// ngResource will transform it as an array of chars. +// This functions simply creates a response object and assign +// the data to a field. +export function browseGetResponse(data) { + const response = {}; + response.file = data; + return response; +} diff --git a/app/agent/rest/v1/agent.js b/app/agent/rest/v1/agent.js new file mode 100644 index 0000000..2b69ea5 --- /dev/null +++ b/app/agent/rest/v1/agent.js @@ -0,0 +1,13 @@ +import angular from 'angular'; + +angular.module('portainer.agent').factory('AgentVersion1', AgentFactory); + +function AgentFactory($resource, API_ENDPOINT_ENDPOINTS) { + return $resource( + `${API_ENDPOINT_ENDPOINTS}/:endpointId/docker/agents`, + {}, + { + query: { method: 'GET', isArray: true }, + } + ); +} diff --git a/app/agent/rest/v1/browse.js b/app/agent/rest/v1/browse.js new file mode 100644 index 0000000..f192185 --- /dev/null +++ b/app/agent/rest/v1/browse.js @@ -0,0 +1,33 @@ +import angular from 'angular'; + +import { browseGetResponse } from '../response/browse'; + +angular.module('portainer.agent').factory('BrowseVersion1', BrowseFactory); + +function BrowseFactory($resource, API_ENDPOINT_ENDPOINTS) { + return $resource( + `${API_ENDPOINT_ENDPOINTS}/:endpointId/docker/browse/:volumeID/:action`, + {}, + { + ls: { + method: 'GET', + isArray: true, + params: { action: 'ls' }, + }, + get: { + method: 'GET', + params: { action: 'get' }, + transformResponse: browseGetResponse, + responseType: 'arraybuffer', + }, + delete: { + method: 'DELETE', + params: { action: 'delete' }, + }, + rename: { + method: 'PUT', + params: { action: 'rename' }, + }, + } + ); +} diff --git a/app/agent/services/agentService.js b/app/agent/services/agentService.js new file mode 100644 index 0000000..03a6de4 --- /dev/null +++ b/app/agent/services/agentService.js @@ -0,0 +1,39 @@ +import angular from 'angular'; + +import { AgentViewModel } from '../models/agent'; + +angular.module('portainer.agent').factory('AgentService', AgentServiceFactory); + +function AgentServiceFactory(Agent, AgentVersion1, HttpRequestHelper, Host, StateManager) { + return { + agents, + hostInfo, + }; + + function getAgentApiVersion() { + const state = StateManager.getState(); + return state.endpoint.agentApiVersion; + } + + function hostInfo(endpointId, nodeName) { + HttpRequestHelper.setPortainerAgentTargetHeader(nodeName); + return Host.info({ endpointId }).$promise; + } + + /** + * @deprecated + * useAgentNodes instead + */ + async function agents(endpointId) { + const agentVersion = getAgentApiVersion(); + const service = agentVersion > 1 ? Agent : AgentVersion1; + try { + const agents = await service.query({ version: agentVersion, endpointId }).$promise; + return agents.map(function (item) { + return new AgentViewModel(item); + }); + } catch (err) { + throw { msg: 'Unable to retrieve agents', err }; + } + } +} diff --git a/app/agent/services/hostBrowserService.js b/app/agent/services/hostBrowserService.js new file mode 100644 index 0000000..1c369fc --- /dev/null +++ b/app/agent/services/hostBrowserService.js @@ -0,0 +1,40 @@ +import angular from 'angular'; + +angular.module('portainer.agent').factory('HostBrowserService', HostBrowserServiceFactory); + +/* @ngInject */ +function HostBrowserServiceFactory(Browse, Upload, API_ENDPOINT_ENDPOINTS, StateManager) { + return { ls, get, delete: deletePath, rename, upload }; + + function ls(endpointId, path) { + return Browse.ls({ endpointId, path: path }).$promise; + } + + function get(endpointId, path) { + return Browse.get({ endpointId, path: path }).$promise; + } + + function deletePath(endpointId, path) { + return Browse.delete({ endpointId, path: path }).$promise; + } + + function rename(endpointId, path, newPath) { + const payload = { + CurrentFilePath: path, + NewFilePath: newPath, + }; + return Browse.rename({ endpointId }, payload).$promise; + } + + function upload(endpointId, Path, file, onProgress) { + const agentVersion = StateManager.getAgentApiVersion(); + const url = `${API_ENDPOINT_ENDPOINTS}/${endpointId}/docker${agentVersion > 1 ? '/v' + agentVersion : ''}/browse/put`; + + return new Promise((resolve, reject) => { + Upload.upload({ + url: url, + data: { file, Path }, + }).then(resolve, reject, onProgress); + }); + } +} diff --git a/app/agent/services/pingService.js b/app/agent/services/pingService.js new file mode 100644 index 0000000..02d0f57 --- /dev/null +++ b/app/agent/services/pingService.js @@ -0,0 +1,11 @@ +import angular from 'angular'; + +angular.module('portainer.agent').service('AgentPingService', AgentPingService); + +function AgentPingService(AgentPing) { + return { ping }; + + function ping(endpointId) { + return AgentPing.ping({ endpointId }).$promise; + } +} diff --git a/app/agent/services/volumeBrowserService.js b/app/agent/services/volumeBrowserService.js new file mode 100644 index 0000000..575696e --- /dev/null +++ b/app/agent/services/volumeBrowserService.js @@ -0,0 +1,60 @@ +import angular from 'angular'; + +angular.module('portainer.agent').factory('VolumeBrowserService', VolumeBrowserServiceFactory); + +/* @ngInject */ +function VolumeBrowserServiceFactory(StateManager, Browse, BrowseVersion1, API_ENDPOINT_ENDPOINTS, 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(endpointId, volumeId, path) { + return getBrowseService().ls({ endpointId, volumeID: volumeId, path, version: getAgentApiVersion() }).$promise; + } + + function get(endpointId, volumeId, path) { + return getBrowseService().get({ endpointId, volumeID: volumeId, path, version: getAgentApiVersion() }).$promise; + } + + function deletePath(endpointId, volumeId, path) { + return getBrowseService().delete({ endpointId, volumeID: volumeId, path, version: getAgentApiVersion() }).$promise; + } + + function rename(endpointId, volumeId, path, newPath) { + const payload = { + CurrentFilePath: path, + NewFilePath: newPath, + }; + return getBrowseService().rename({ endpointId, volumeID: volumeId, version: getAgentApiVersion() }, payload).$promise; + } + + function upload(endpointId, path, file, volumeId, onProgress) { + const agentVersion = StateManager.getAgentApiVersion(); + if (agentVersion < 2) { + throw new Error('upload is not supported on this agent version'); + } + + const url = `${API_ENDPOINT_ENDPOINTS}/${endpointId}/docker/v${agentVersion}/browse/put?volumeID=${volumeId}`; + + return new Promise((resolve, reject) => { + Upload.upload({ + url: url, + data: { file, Path: path }, + }).then(resolve, reject, onProgress); + }); + } +} diff --git a/app/app.js b/app/app.js new file mode 100644 index 0000000..f5286f5 --- /dev/null +++ b/app/app.js @@ -0,0 +1,37 @@ +import $ from 'jquery'; + +/* @ngInject */ +export function onStartupAngular($rootScope, $state, cfpLoadingBar, $transitions, HttpRequestHelper) { + $rootScope.$state = $state; + + // Workaround to prevent the loading bar from going backward + // https://github.com/chieffancypants/angular-loading-bar/issues/273 + const originalSet = cfpLoadingBar.set; + cfpLoadingBar.set = function overrideSet(n) { + if (n > cfpLoadingBar.status()) { + originalSet.apply(cfpLoadingBar, arguments); + } + }; + + $transitions.onBefore({}, () => { + HttpRequestHelper.resetAgentHeaders(); + }); + + // EE-6751: screens not loading when switching quickly between side menu options + // Known bug of @uirouter/angularjs + // Fix found at https://github.com/angular-ui/ui-router/issues/3652#issuecomment-574499009 + // This hook is cleaning the internal viewConfigs list, removing leftover data unrelated to the current transition + $transitions.onStart({}, (transition) => { + const toList = transition.treeChanges().to.map((t) => t.state.name); + const toConfigs = transition.router.viewService._viewConfigs.filter((vc) => toList.includes(vc.viewDecl.$context.name)); + transition.router.viewService._viewConfigs = toConfigs; + }); + + $(document).ajaxSend((event, jqXhr, jqOpts) => { + const type = jqOpts.type === 'POST' || jqOpts.type === 'PUT' || jqOpts.type === 'PATCH'; + const hasNoContentType = jqOpts.contentType !== 'application/json' && jqOpts.headers && !jqOpts.headers['Content-Type']; + if (type && hasNoContentType) { + jqXhr.setRequestHeader('Content-Type', 'application/json'); + } + }); +} diff --git a/app/assets/css/app.css b/app/assets/css/app.css new file mode 100644 index 0000000..93c176d --- /dev/null +++ b/app/assets/css/app.css @@ -0,0 +1,730 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@font-face { + font-family: 'Inter'; + src: url('../fonts/Inter-VariableFont.ttf') format('truetype'); + font-weight: 100 900; + font-style: normal; +} +@media screen and (-webkit-min-device-pixel-ratio: 0) { + select { + font-family: Inter, Arial, Helvetica, sans-serif; + } +} + +html { + font-size: 16px; + overflow-y: scroll; + scroll-behavior: smooth; +} + +html[theme='dark'], +html[theme='highcontrast'] { + /* make the scrollbars dark theme when in the dark modes */ + color-scheme: dark; +} + +body { + background: var(--bg-body-color); + font-family: 'Inter'; + color: var(--text-body-color) !important; +} + +.bg-widget-color { + background: var(--bg-widget-color); +} + +html, +body, +#page-wrapper, +#content-wrapper, +.page-content, +#view { + height: 100%; + width: 100%; + overflow-y: initial; +} + +#view { + position: relative; +} + +.logo { + display: inline; + max-width: 155px; + max-height: 55px; +} + +.white-space-normal { + white-space: normal !important; +} + +.legend .title { + padding: 0 0.3em; + margin: 0.5em; + border-style: solid; + border-width: 0 0 0 1em; +} +.messages { + max-height: 50px; + overflow-x: hidden; + overflow-y: scroll; +} +.containerNameInput { + width: 85%; + border: none; + background: none; + border-bottom: 1px solid black; +} + +.containerNameInput:active, +.containerNameInput:focus { + outline: none; +} + +#network-legend { + text-align: center; +} + +#network-legend span { + display: inline; + font-size: 18px; +} + +.form-section-title { + @apply text-gray-9; + @apply th-dark:text-white; + @apply th-highcontrast:text-white; + + margin-top: 20px; + margin-bottom: 10px; + padding-left: 0; + font-weight: 500; + font-size: 16px; +} + +.form-section-title:first-child { + margin-top: 5px; +} + +.form-horizontal .control-label.text-left, +.form-row .control-label.text-left { + text-align: left; + font-size: 0.9em; +} + +input[type='checkbox'] { + margin-top: 1px; + vertical-align: middle; +} + +.md-checkbox input[type='checkbox'] { + width: 16px; + height: 16px; + margin-top: -1px; +} + +.md-checkbox input[type='checkbox']:indeterminate + label:before { + content: '-'; + align-content: center; + line-height: 16px; + text-align: center; +} + +.space-right { + margin-right: 5px; +} + +.space-left { + margin-left: 5px; +} + +.portainer-disabled-link { + color: gray; + pointer-events: none; +} + +.portainer-disabled-datatable { + color: gray; +} + +.portainer-disabled-datatable a { + color: gray; + pointer-events: none; +} + +.datatable-highlighted { + background-color: var(--bg-item-highlighted-color); +} + +.datatable-unhighlighted { + background-color: var(--bg-item-highlighted-null-color); +} + +.text-warning { + @apply text-warning-9 th-highcontrast:text-warning-1 th-dark:text-warning-7; +} + +.widget .widget-body table tbody .image-tag { + font-size: 90% !important; + 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; +} + +.widget .widget-body table thead th { + font-weight: normal; +} + +.widget .widget-body .nowrap-cells tbody td { + white-space: nowrap; +} + +.widget .widget-body table tbody td { + white-space: normal; + word-break: break-word; +} + +.widget .widget-body table.container-details-table > tbody > tr > td:first-child { + word-break: normal; + text-transform: uppercase; +} + +.widget .widget-body table.description-table { + table-layout: fixed; +} + +.template-widget { + height: 100%; +} + +.template-widget-body { + max-height: 86%; + overflow-y: auto; +} + +.blocklist { + display: flex; + flex-direction: column; + padding: 10px; +} + +.blocklist-item { + padding: 10px; + margin-bottom: 10px; + border: 1px solid var(--border-blocklist); + border-radius: 8px; + margin-right: 10px; +} + +.blocklist-item--selected { + background-color: var(--bg-blocklist-item-selected-color); + border-color: var(--border-blocklist-item-selected-color); + color: var(--text-blocklist-item-selected-color); +} + +.blocklist-item:not(.blocklist-item-not-interactive):hover { + @apply border border-blue-7; + cursor: pointer; + + background-color: var(--bg-blocklist-hover-color); + color: var(--text-blocklist-hover-color); +} + +.blocklist-item-box { + display: flex; +} + +.blocklist-item-line { + display: flex; + justify-content: space-between; +} + +.blocklist-item-logo { + width: 100%; + max-width: 60px; + max-height: 60px; +} + +.blocklist-item-title { + font-size: 1.8em; + font-weight: bold; +} + +.blocklist-item-subtitle { + font-size: 0.9em; + padding-right: 1em; +} + +.blocklist-item-desc { + font-size: 0.9em; + padding-right: 1em; +} + +.template-note { + padding: 0.5em; + font-size: 0.9em; +} + +.nopadding { + padding: 0 !important; +} + +.padding-top { + padding-top: 15px !important; +} + +.nomargin { + margin: 0 !important; +} + +.interactive { + cursor: pointer; +} + +.custom-header-ico { + max-width: 32px; + max-height: 32px; +} + +.btn-responsive { + padding: 5px 10px; + font-size: 12px; + line-height: 1.5; + border-radius: 3px; +} + +.btn-datatable { + padding: 2.6px 7.8px 3.9px; + line-height: 1; + margin: 0px 0px 0px 0px; +} + +@media screen and (min-width: 1107px) { + .btn-responsive { + padding: 6px 12px; + font-size: 14px; + line-height: 1.42857143; + border-radius: 4px; + } +} + +.page-wrapper { + height: 100%; + width: 100%; + display: flex; + align-items: center; +} + +.simple-box { + margin-bottom: 80px; +} + +.simple-box > div:first-child { + padding-bottom: 10px; +} + +.simple-box-logo { + display: block; + margin: auto; + position: relative; + width: 240px; + margin-bottom: 10px; +} + +.simple-box-form > div { + margin-bottom: 25px; +} + +.simple-box-form > div:last-child { + margin-top: 10px; + margin-bottom: 10px; +} + +.panel-body { + padding: 20px 25px; + background-color: var(--white-color); + border-radius: 8px; +} + +.user-box { + margin-right: 15px; +} + +.select-endpoint { + width: 80%; + margin: 0 auto; +} + +#image-layers .btn { + padding: 0; +} + +#image-layers .expand { + padding-right: 0; +} + +.createResource { + margin-left: 5px; + font-size: 90%; +} + +@media (min-width: 768px) { + .margin-sm-top { + margin-top: 5px; + } +} +@media (min-width: 768px) { + .pull-sm-left { + float: left !important; + } + .pull-sm-right { + float: right !important; + } + .pull-sm-none { + float: none !important; + } +} +@media (min-width: 992px) { + .pull-md-left { + float: left !important; + } + .pull-md-right { + float: right !important; + } + .pull-md-none { + float: none !important; + } +} +@media (min-width: 1200px) { + .pull-lg-left { + float: left !important; + } + .pull-lg-right { + float: right !important; + } + .pull-lg-none { + float: none !important; + } +} +.pull-none { + float: none !important; +} + +.small-select { + display: inline-block; + padding: 0px 6px; + margin-left: 10px; + color: var(--text-small-select-color); + background-color: var(--bg-small-select-color); + background-image: none; + border-radius: 4px; + font-size: 14px; +} + +.visualizer_container { + display: flex; + flex-direction: row; + flex-wrap: wrap; + justify-content: space-around; +} + +.visualizer_container .node { + border: 1px dashed var(--blue-2); + background-color: rgb(51, 122, 183); + background-color: rgba(51, 122, 183, 0.1); + border-radius: 4px; + box-shadow: 0 3px 10px -2px var(--grey-50); + padding: 15px; + margin: 5px; +} + +.visualizer_container .node .node_info { + display: flex; + flex-direction: column; + justify-content: center; + text-align: center; + border-bottom: 1px solid var(--grey-26); + padding-bottom: 10px; +} + +.visualizer_container .node .node_info .node_platform { + margin-left: 2px; + font-size: 16px; +} + +.visualizer_container .node .node_info .node_labels { + border-top: 1px solid var(--grey-26); + padding-top: 10px; + margin-top: 10px; +} + +.visualizer_container .node .node_info .node_label { + font-style: italic; + color: #787878; +} + +.visualizer_container .node .tasks { + display: flex; + flex-direction: column; + margin-top: 5px; +} + +.visualizer_container .node .tasks .task { + border: 1px solid var(--grey-6); + border-radius: 2px; + box-shadow: 0 3px 10px -2px var(--grey-50); + padding: 10px; + margin: 5px; + font-size: 10px; +} + +.visualizer_container .node .tasks .task div { + padding: 2px; +} + +.visualizer_container .node .tasks .task_running { + border-radius: 4px; + background-color: rgb(35, 174, 137); + background-color: rgba(35, 174, 137, 0.2); +} + +.visualizer_container .node .tasks .task_stopped { + border-radius: 4px; + background-color: rgb(174, 35, 35); + background-color: rgba(174, 35, 35, 0.2); +} + +.visualizer_container .node .tasks .task_warning { + border-radius: 4px; + background-color: rgb(240, 173, 78); + background-color: rgba(240, 173, 78, 0.2); +} + +.visualizer_container .node .tasks .task_info { + border-radius: 4px; + background-color: rgb(70, 184, 218); + background-color: rgba(70, 184, 218, 0.2); +} + +.visualizer_container .node .tasks .task .service_name { + text-align: center; + margin-bottom: 5px; +} + +.log_viewer { + height: 100%; + overflow-y: scroll; + color: var(--text-log-viewer-color); + font-size: 0.85em; + background-color: var(--bg-log-viewer-color); +} + +.log_viewer.wrap_lines { + white-space: pre-wrap; +} + +.log_viewer .inner_line { + padding: 0 15px; + cursor: pointer; + margin-bottom: 0; +} + +.log_viewer .inner_line:empty::after { + content: '.'; + visibility: hidden; +} + +.log_viewer .line_selected { + background-color: var(--bg-log-line-selected-color); +} + +.tag:not(.token) { + padding: 2px 6px; + color: white; + background-color: var(--blue-2); + border: 1px solid #2e6da4; + border-radius: 4px; +} + +.template-envvar { + padding-bottom: 5px; +} + +.line-separator { + border-bottom: 1px solid var(--grey-26); + width: 50%; + margin: 20px auto 10px auto; +} + +.modal { + text-align: center; + padding: 0 !important; +} + +.striked::after { + border-bottom: 0.2em solid var(--grey-26); + content: ''; + left: 0; + margin-top: calc(0.2em / 2 * -1); + position: absolute; + right: 0; + top: 50%; + z-index: 2; +} + +.striketext:before, +.striketext:after { + background-color: var(--grey-26); + content: ''; + display: inline-block; + height: 1px; + position: relative; + vertical-align: middle; + width: 50%; +} + +.striketext:before { + right: 0.5em; + margin-left: -50%; +} + +.striketext:after { + left: 0.5em; + margin-right: -50%; +} + +/*toaster override*/ +#toast-container > div { + opacity: 0.9; +} +/*!toaster override*/ + +.monospaced { + font-family: monospace; + font-weight: 600; +} + +/* uib-progressbar override */ +.progress-bar { + color: var(--text-progress-bar-color); +} +/* !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: var(--blue-2); +} +/* !spinkit override */ + +/* uib-typeahead override */ +#scrollable-dropdown-menu .dropdown-menu { + max-height: 300px; + overflow-y: auto; +} +/* !uib-typeahead override */ + +.no-margin { + margin: 0 !important; +} + +.no-border { + border: none; +} + +.text-wrap { + word-break: break-all; + white-space: normal; +} + +.icon-disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.web-editor { + background-color: var(--bg-webeditor-color); + border-radius: 8px; + padding: 10px; +} + +.web-editor a { + color: var(--text-link-color); +} + +.web-editor a:hover { + color: var(--text-link-hover-color); + text-decoration-line: underline; +} + +reach-portal > div { + z-index: 10; +} + +input[style*='background-image: url("data:image/png'] + [data-cy='auth-passwordInputToggle'] { + right: 20px; +} + +input[style*='background-image: url("data:image/png'] { + padding-right: 60px; +} + +.web-editor .trancluded-item:empty { + display: none; +} + +input:-webkit-autofill { + @apply caret-[--grey-25] th-highcontrast:caret-white th-dark:caret-white; +} + +/* +rules for styling the progress bar on both chrome and firefox +first rule is for firefox and the second rule is for chrome + +use the `.progress-filled` tailwind variant util to style the filled value of the progress bar, +and the usual styles to style the unfilled value. + +see app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/DeploymentCounter.tsx for an example +*/ + +progress { + appearance: none; +} + +progress::-webkit-progress-bar { + background-color: transparent; +} diff --git a/app/assets/css/bootstrap-override.css b/app/assets/css/bootstrap-override.css new file mode 100644 index 0000000..c177fa9 --- /dev/null +++ b/app/assets/css/bootstrap-override.css @@ -0,0 +1,375 @@ +/* Label, Section Title */ +.label { + border-radius: 5px; +} + +.label-success { + background-color: var(--ui-success-7); +} + +.label-danger { + background-color: var(--ui-error-6); +} + +.control-label { + @apply inline-flex items-center; + @apply font-medium; + @apply text-gray-7; + @apply th-dark:text-gray-warm-3; + @apply th-highcontrast:text-white; +} + +.vertical-center { + @apply inline-flex items-center gap-1; +} + +.flex-center { + display: flex; + align-items: center; + justify-content: center; +} + +.blue { + background: var(--bg-dashboard-item) !important; +} + +.form-control { + border-radius: 5px; +} + +/* Input Group Addon */ +.input-group-addon:first-child { + border-top-left-radius: 5px; + border-bottom-left-radius: 5px; +} + +.input-group .form-control:not(:first-child):not(:last-child) { + border-top-right-radius: 5px; + border-bottom-right-radius: 5px; +} + +.input-group-btn:last-child .btn { + margin-left: 5px; + border-radius: 5px; +} + +/* Toggle switch */ +.switch { + position: relative; + display: inline-block; + width: 42px; + height: 25px; +} + +.switch input { + opacity: 0; + width: 0; + height: 0; +} + +.switch input[type='checkbox']:disabled + .slider { + opacity: 0.3; + @apply th-dark:bg-gray-warm-6 th-dark:opacity-40; + @apply th-highcontrast:bg-gray-warm-6 th-highcontrast:opacity-40; +} + +.switch input[type='checkbox']:disabled:is(:checked) + .slider { + @apply th-dark:!bg-blue-8 th-dark:!opacity-60; + @apply th-highcontrast:!bg-blue-8 th-highcontrast:!opacity-60; +} + +.switch-values { + font-style: normal; + font-weight: 500; + margin-left: 5px; +} + +/* Toggle */ + +.slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + -webkit-transition: 0.4s; + transition: 0.4s; + /* speed up the hover color changes to 200ms, while keeping the movement transition 400ms */ + @apply transition-colors duration-200; + @apply bg-gray-6; + @apply th-dark:bg-gray-warm-8; + @apply th-highcontrast:bg-gray-warm-8; +} + +.slider:hover { + @apply bg-gray-7; + @apply th-dark:bg-gray-warm-7; + @apply th-highcontrast:bg-gray-warm-7; +} + +input:checked + .slider:hover { + background-color: var(--ui-blue-9); + @apply th-dark:bg-blue-10; + @apply th-highcontrast:bg-blue-10; +} + +.slider:before { + position: absolute; + content: ''; + height: 19px; + width: 19px; + left: 3px; + bottom: 3px; + background-color: var(--white-color); + -webkit-transition: 0.4s; + transition: 0.4s; +} + +input:checked + .slider { + background-color: var(--ui-blue-8); + @apply th-dark:bg-blue-9; + @apply th-highcontrast:bg-blue-9; +} + +input:focus + .slider { + box-shadow: 0 0 2px var(--ui-blue-8); +} + +input:checked + .slider:before { + -webkit-transform: translateX(17px); + -ms-transform: translateX(17px); + transform: translateX(17px); +} + +.slider.round { + border-radius: 25px; +} + +.slider.round:before { + border-radius: 50%; +} + +/* Checkbox */ + +.md-checkbox input[type='checkbox']:enabled + label:before { + background-color: var(--bg-checkbox) !important; + border: 1px solid var(--border-checkbox) !important; + border-radius: 5px; + color: var(--black-color); +} + +.md-checkbox input[type='checkbox']:disabled + label:before { + border-radius: 5px; +} + +.md-checkbox input[type='checkbox']:checked + label:before { + background-color: var(--ui-blue-8) !important; + color: var(--ui-blue-8) !important; + border: 1px solid var(--ui-blue-8) !important; +} + +.md-checkbox input[type='checkbox']:checked + .checkmark { + border-color: var(--grey-6); + background-color: var(--bg-checkbox); +} + +/* Slider */ + +.rzslider .rz-pointer { + background-color: var(--white-color); + border: 3px solid var(--ui-blue-8); + width: 25px; + height: 25px; + top: -10px; +} + +.rzslider .rz-bar { + background-color: var(--ui-gray-5); + height: 8px; + border-radius: 5px; +} + +.rzslider .rz-selection { + background-color: var(--ui-blue-8); +} + +.rzslider .rz-pointer:after { + display: none; +} + +/* Widget */ + +.widget .widget-icon { + @apply mr-1 flex-none !p-2 text-lg; + @apply bg-blue-3 text-blue-8; + @apply th-dark:bg-gray-9 th-dark:text-blue-3; + + border-radius: 50%; + display: inline-flex; + justify-content: center; + align-items: center; + padding: 1.5%; +} + +.widget .widget-icon .icon { + display: flex !important; +} + +.widget .widget-body table thead { + border-top: 1px solid var(--border-table-color); +} + +/* Toaster */ + +#toast-container > .toast-success { + background-image: url(../images/icon-success.svg) !important; + background-size: 40px 40px; + background-position: top 12px left 12px; +} + +#toast-container > .toast-error { + background-image: url(../images/icon-error.svg) !important; + background-size: 40px 40px; + background-position: top 12px left 12px; +} + +#toast-container > .toast-warning { + background-image: url(../images/icon-warning.svg) !important; + background-size: 40px 40px; + background-position: top 12px left 12px; +} + +.toast-success .toast-progress { + background-color: var(--ui-success-7); +} +.toast-warning .toast-progress { + background-color: var(--ui-warning-6); +} +.toast-error .toast-progress { + background-color: var(--ui-error-8); +} + +#toast-container > div { + color: var(--ui-gray-7); + background-color: var(--white-color); + border-radius: 8px; + padding: 18px 20px 18px 68px; + width: 300px; + opacity: 1; + -ms-filter: progid:DXImageTransform.Microsoft.Alpha(Opacity=100); + filter: alpha(opacity=100); +} + +#toast-container > div:hover { + -moz-box-shadow: 0 0 12px var(--ui-gray-7); + -webkit-box-shadow: 0 0 12px var(--ui-gray-7); + box-shadow: 0 0 12px var(--ui-gray-7); +} + +.toast-close-button { + color: var(--black-color); + text-decoration: none; + margin-top: 5px; + cursor: pointer; + opacity: 0.4; + -ms-filter: progid:DXImageTransform.Microsoft.Alpha(Opacity=40); + filter: alpha(opacity=40); +} + +.toast-close-button:hover, +.toast-close-button:focus { + color: var(--black-color); + text-decoration: none; + cursor: pointer; + opacity: 0.6; + -ms-filter: progid:DXImageTransform.Microsoft.Alpha(Opacity=60); + filter: alpha(opacity=60); +} + +.toast-title { + font-weight: 500; + color: var(--black-color); + padding-right: 10px; + margin-bottom: 4px; +} + +/* Modal */ + +.modal-dialog { + width: 450px; +} + +/* !Modal */ + +/* Status Indicator Inside Table Section Label Style */ +.table .label { + border-radius: 8px !important; + display: inline-flex; + align-items: center; +} + +.table .label .label-danger { + background-color: var(--ui-error-8); +} + +.table .label .label-warn { + background-color: var(--ui-warning-9); +} + +.table .label .label-success { + background-color: var(--ui-success-7); +} + +/* Required Label with asterisk */ + +.required:after { + content: '*'; + color: var(--ui-error-9); +} + +.progress { + height: 8px; + border-radius: 4px; + width: 50%; + display: inline-block; + margin-bottom: 0; +} + +.progress .progress-bar { + background-color: var(--ui-blue-8); +} + +.progress + span { + display: inline-block; + font-size: 85%; + margin-left: 10px; +} + +/* Pagination */ +.datatable .footer .paginationControls .pagination { + border: 1px solid var(--border-pagination-color); +} + +.paginationControls > form { + @apply inline-flex items-center; +} + +.pagination li button { + color: var(--ui-gray-9) !important; +} + +.pagination li:active button, +.pagination li:focus button { + border: 1px solid var(--ui-gray-5) !important; +} + +.pagination li a { + text-decoration: none !important; + cursor: pointer; + color: var(--ui-gray-9) !important; +} + +.widget-header { + font-size: 16px; +} diff --git a/app/assets/css/button.css b/app/assets/css/button.css new file mode 100644 index 0000000..4aa79e1 --- /dev/null +++ b/app/assets/css/button.css @@ -0,0 +1,193 @@ +.btn { + @apply !outline-none; + @apply border border-solid border-transparent; + + border-radius: 8px; + display: inline-flex; + justify-content: center; + align-items: center; + gap: 5px; +} + +.btn.disabled, +.btn[disabled], +fieldset[disabled] .btn { + @apply opacity-40; + touch-action: none; +} + +.btn:hover { + color: var(--text-button-hover-color); +} + +.btn.active { + box-shadow: none; +} + +.btn-icon { + @apply !border-none !bg-transparent p-0; +} + +.btn.btn-primary { + @apply border-graphite-700 bg-graphite-700 text-mist-100; + @apply hover:border-graphite-700/90 hover:bg-graphite-700/90 hover:text-mist-100; + @apply focus:border-blue-5 focus:text-mist-100 focus:shadow-graphite-700/80; + + @apply th-dark:border-mist-100 th-dark:bg-mist-100 th-dark:text-graphite-700; + @apply th-dark:hover:border-mist-100/90 th-dark:hover:bg-mist-100/90 th-dark:hover:text-graphite-700; + @apply th-dark:focus:border-blue-5 th-dark:focus:text-graphite-700 th-dark:focus:shadow-white/80; + + @apply th-highcontrast:border-mist-100 th-highcontrast:bg-mist-100 th-highcontrast:text-graphite-700; + @apply th-highcontrast:hover:border-mist-100/90 th-highcontrast:hover:bg-mist-100/90 th-highcontrast:hover:text-graphite-700; + @apply th-highcontrast:focus:border-blue-5 th-highcontrast:focus:text-graphite-700 th-highcontrast:focus:shadow-white/80; +} + +/* Sidebar background is always dark, so we need to override the primary button styles */ +.btn.btn-primary.sidebar { + @apply border-mist-100 bg-mist-100 text-graphite-700; + @apply hover:border-mist-100/90 hover:bg-mist-100/90 hover:text-graphite-700; + @apply focus:border-blue-5 focus:text-graphite-700 focus:shadow-white/80; +} + +.btn.btn-primary:active, +.btn.btn-primary.active, +.open > .dropdown-toggle.btn-primary { + @apply border-graphite-700/80 bg-graphite-700 text-mist-100; + @apply th-dark:border-white/80 th-dark:bg-mist-100 th-dark:text-graphite-700; + @apply th-highcontrast:border-white/80 th-highcontrast:bg-mist-100 th-highcontrast:text-graphite-700; +} + +.nav-pills > li.active > a, +.nav-pills > li.active > a:hover, +.nav-pills > li.active > a:focus { + @apply bg-graphite-700 text-mist-100; + @apply th-dark:bg-mist-100 th-dark:text-graphite-700; + @apply th-highcontrast:bg-mist-100 th-highcontrast:text-graphite-700; +} + +/* Button Secondary */ +.btn.btn-secondary { + @apply border border-solid; + + @apply border-graphite-700 bg-mist-100 text-graphite-700; + @apply hover:border-graphite-700 hover:bg-graphite-700/10 hover:text-graphite-700; + @apply focus:border-blue-5 focus:text-graphite-700 focus:shadow-graphite-700/20; + + @apply th-dark:border-mist-100 th-dark:bg-graphite-700 th-dark:text-mist-100; + @apply th-dark:hover:border-mist-100 th-dark:hover:bg-mist-100/20 th-dark:hover:text-mist-100; + @apply th-dark:focus:border-blue-5 th-dark:focus:text-mist-100 th-dark:focus:shadow-white/80; + + @apply th-highcontrast:border-mist-100 th-highcontrast:bg-graphite-700 th-highcontrast:text-mist-100; + @apply th-highcontrast:hover:border-mist-100 th-highcontrast:hover:bg-mist-100/20 th-highcontrast:hover:text-mist-100; + @apply th-highcontrast:focus:border-blue-5 th-highcontrast:focus:text-mist-100 th-highcontrast:focus:shadow-white/80; +} + +.btn.btn-secondary:active, +.btn.btn-secondary.active, +.open > .dropdown-toggle.btn-secondary { + @apply border-graphite-700 bg-graphite-700/10 text-graphite-700; + @apply th-dark:border-mist-100 th-dark:bg-mist-100/20 th-dark:text-mist-100; + @apply th-highcontrast:border-mist-100 th-highcontrast:bg-mist-100/20 th-highcontrast:text-mist-100; +} + +.btn.btn-danger { + @apply border-error-8 bg-error-8; + @apply hover:border-error-7 hover:bg-error-7 hover:text-white; + @apply focus:border-blue-5 focus:text-white focus:shadow-error-8/20; + @apply th-dark:focus:border-blue-5 th-dark:focus:text-white th-dark:focus:shadow-white/80; + @apply th-highcontrast:focus:border-blue-5 th-highcontrast:focus:text-white th-highcontrast:focus:shadow-white/80; +} + +.btn.btn-danger:active, +.btn.btn-danger.active, +.open > .dropdown-toggle.btn-danger { + @apply border-error-5 bg-error-8 text-white; +} + +.btn.btn-dangerlight { + @apply text-error-9 th-dark:text-white; + @apply bg-error-3 th-dark:bg-error-9; + @apply hover:bg-error-2 th-dark:hover:bg-error-11; + @apply border-error-5 th-highcontrast:border-error-7 th-dark:border-error-7; + @apply border border-solid; + + @apply focus:border-blue-5 focus:text-error-9 focus:shadow-error-8/20; + @apply th-dark:focus:border-blue-5 th-dark:focus:text-white th-dark:focus:shadow-white/80; + @apply th-highcontrast:focus:border-blue-5 th-highcontrast:focus:shadow-white/80; +} +.btn.btn-icon.btn-dangerlight { + @apply hover:text-error-11 th-dark:hover:text-error-7; +} + +.btn.btn-success { + background-color: var(--ui-success-7); +} + +.btn.btn-success:hover { + color: var(--white-color); +} + +/* secondary-grey */ +.btn.btn-default, +.btn.btn-light { + @apply border-gray-5 bg-white text-gray-8; + @apply hover:border-gray-5 hover:bg-gray-3 hover:text-gray-10; + @apply focus:border-blue-5 focus:text-gray-8 focus:shadow-graphite-700/20; + + /* dark mode */ + @apply th-dark:border-gray-warm-7 th-dark:bg-gray-iron-10 th-dark:text-gray-warm-4; + @apply th-dark:hover:border-gray-6 th-dark:hover:bg-gray-iron-9 th-dark:hover:text-gray-warm-4; + @apply th-dark:focus:border-blue-5 th-dark:focus:text-gray-warm-4 th-dark:focus:shadow-white/80; + + @apply th-highcontrast:border-gray-2 th-highcontrast:bg-black th-highcontrast:text-white; + @apply th-highcontrast:hover:border-gray-6 th-highcontrast:hover:bg-gray-9 th-highcontrast:hover:text-gray-warm-4; + @apply th-highcontrast:focus:border-blue-5 th-highcontrast:focus:text-white th-highcontrast:focus:shadow-white/80; +} + +.btn.btn-light:active, +.btn.btn-light.active, +.open > .dropdown-toggle.btn-light { + background-color: var(--ui-gray-3); +} + +.btn.btn-link { + @apply text-blue-8 hover:text-blue-9 disabled:text-gray-5; + @apply th-dark:text-blue-7 th-dark:hover:text-blue-8; + @apply th-highcontrast:text-blue-5 th-highcontrast:hover:text-blue-6; +} + +.btn-group { + display: inline-flex; +} + +.input-group-btn .btn.active, +.btn-group .btn.active { + @apply border-graphite-700/80 bg-graphite-700 text-mist-100; + @apply th-dark:border-white/80 th-dark:bg-mist-100 th-dark:text-graphite-700; + @apply th-highcontrast:border-white/80 th-highcontrast:bg-mist-100 th-highcontrast:text-graphite-700; +} + +.btn.btn-icon:focus { + box-shadow: none !important; +} + +.btn:focus { + box-shadow: 0px 0px 0px 2px var(--tw-shadow-color); +} + +a.no-link, +a[ng-click] { + @apply text-current th-highcontrast:text-current th-dark:text-current; + @apply hover:text-current hover:no-underline; + @apply focus:text-current focus:no-underline; +} + +a, +a.hyperlink { + @apply text-blue-8 hover:text-blue-9 th-highcontrast:text-blue-5 th-highcontrast:hover:text-blue-6 th-dark:text-blue-7 th-dark:hover:text-blue-8; + @apply cursor-pointer hover:underline; +} + +a.no-decoration { + text-decoration: none; +} diff --git a/app/assets/css/colors.json b/app/assets/css/colors.json new file mode 100644 index 0000000..94d3c20 --- /dev/null +++ b/app/assets/css/colors.json @@ -0,0 +1,380 @@ +{ + "black": "#000000", + "white": "#ffffff", + "graphite": { + "10": "#f5f5f6", + "50": "#e5e6e8", + "100": "#ced0d3", + "200": "#abafb5", + "300": "#7b8089", + "400": "#5c6066", + "500": "#484a4e", + "600": "#3a3b3f", + "700": "#2e2f33", + "800": "#222326", + "900": "#161719" + }, + "mist": { + "50": "#fcfbfa", + "100": "#f7f6f3", + "200": "#f0f0ec", + "300": "#e8e7e2", + "400": "#e2e1db", + "500": "#d9d8d2", + "600": "#ceccc4", + "700": "#bebcb4", + "800": "#a7a6a0", + "900": "#8b8983" + }, + "gray": { + "1": "#fcfcfd", + "2": "#f9fafb", + "3": "#f2f4f7", + "4": "#eaecf0", + "5": "#d0d5dd", + "6": "#98a2b3", + "7": "#667085", + "8": "#475467", + "9": "#344054", + "10": "#1d2939", + "11": "#101828" + }, + "blue": { + "1": "#f5fbff", + "2": "#f0f9ff", + "3": "#e0f2fe", + "4": "#b9e6fe", + "5": "#7cd4fd", + "6": "#36bffa", + "7": "#0ba5ec", + "8": "#0086c9", + "9": "#026aa2", + "10": "#065986", + "11": "#0b4a6f" + }, + "error": { + "1": "#fffbfa", + "2": "#fef3f2", + "3": "#fee4e2", + "4": "#fecdca", + "5": "#fda29b", + "6": "#f97066", + "7": "#f04438", + "8": "#d92d20", + "9": "#b42318", + "10": "#912018", + "11": "#7a271a" + }, + "warning": { + "1": "#fffcf5", + "2": "#fffaeb", + "3": "#fef0c7", + "4": "#fedf89", + "5": "#fec84b", + "6": "#fdb022", + "7": "#f79009", + "8": "#dc6803", + "9": "#b54708", + "10": "#93370d", + "11": "#7a2e0e" + }, + "success": { + "1": "#f6fef9", + "2": "#ecfdf3", + "3": "#d1fadf", + "4": "#a6f4c5", + "5": "#6ce9a6", + "6": "#32d583", + "7": "#12b76a", + "8": "#039855", + "9": "#027a48", + "10": "#05603a", + "11": "#054f31" + }, + "gray-blue": { + "1": "#fcfcfd", + "2": "#f8f9fc", + "3": "#eaecf5", + "4": "#d5d9eb", + "5": "#b3b8db", + "6": "#717bbc", + "7": "#4e5ba6", + "8": "#3e4784", + "9": "#363f72", + "10": "#293056", + "11": "#293056" + }, + "gray-cool": { + "1": "#fcfcfd", + "2": "#f9f9fb", + "3": "#eff1f5", + "4": "#dcdfea", + "5": "#b9c0d4", + "6": "#7d89b0", + "7": "#5d6b98", + "8": "#4a5578", + "9": "#404968", + "10": "#30374f", + "11": "#111322" + }, + "gray-modern": { + "1": "#fcfcfd", + "2": "#f8fafc", + "3": "#eef2f6", + "4": "#e3e8ef", + "5": "#cdd5df", + "6": "#9aa4b2", + "7": "#697586", + "8": "#4b5565", + "9": "#364152", + "10": "#202939", + "11": "#121926" + }, + "gray-neutral": { + "1": "#fcfcfd", + "2": "#f9fafb", + "3": "#f3f4f6", + "4": "#e5e7eb", + "5": "#d2d6db", + "6": "#9da4ae", + "7": "#6c737f", + "8": "#4d5761", + "9": "#384250", + "10": "#1f2a37", + "11": "#111927" + }, + "gray-iron": { + "1": "#fcfcfc", + "2": "#fafafa", + "3": "#f4f4f5", + "4": "#e4e4e7", + "5": "#d1d1d6", + "6": "#d1d1d6", + "7": "#70707b", + "8": "#51525c", + "9": "#3f3f46", + "10": "#26272b", + "11": "#18181b" + }, + "gray-true": { + "1": "#fcfcfc", + "2": "#fafafa", + "3": "#f5f5f5", + "4": "#e5e5e5", + "5": "#d6d6d6", + "6": "#a3a3a3", + "7": "#737373", + "8": "#525252", + "9": "#424242", + "10": "#292929", + "11": "#141414" + }, + "gray-warm": { + "1": "#fdfdfc", + "2": "#fafaf9", + "3": "#f5f5f4", + "4": "#e7e5e4", + "5": "#d7d3d0", + "6": "#a9a29d", + "7": "#79716b", + "8": "#57534e", + "9": "#44403c", + "10": "#292524", + "11": "#1c1917" + }, + "moss": { + "1": "#fafdf7", + "2": "#f5fbee", + "3": "#e6f4d7", + "4": "#ceeab0", + "5": "#acdc79", + "6": "#86cb3c", + "7": "#669f2a", + "8": "#4f7a21", + "9": "#3f621a", + "10": "#335015", + "11": "#2b4212" + }, + "green-light": { + "1": "#fafef5", + "2": "#f3fee7", + "3": "#e4fbcc", + "4": "#d0f8ab", + "5": "#a6ef67", + "6": "#85e13a", + "7": "#66c61c", + "8": "#4ca30d", + "9": "#3b7c0f", + "10": "#326212", + "11": "#2b5314" + }, + "green": { + "1": "#f6fef9", + "2": "#edfcf2", + "3": "#d3f8df", + "4": "#aaf0c4", + "5": "#73e2a3", + "6": "#73e2a3", + "7": "#16b364", + "8": "#099250", + "9": "#087443", + "10": "#095c37", + "11": "#084c2e" + }, + "teal": { + "1": "#f6fefc", + "2": "#f0fdf9", + "3": "#ccfbef", + "4": "#99f6e0", + "5": "#5fe9d0", + "6": "#2ed3b7", + "7": "#15b79e", + "8": "#0e9384", + "9": "#107569", + "10": "#125d56", + "11": "#134e48" + }, + "cyan": { + "1": "#f5feff", + "2": "#ecfdff", + "3": "#cff9fe", + "4": "#a5f0fc", + "5": "#67e3f9", + "6": "#22ccee", + "7": "#06aed4", + "8": "#088ab2", + "9": "#0e7090", + "10": "#155b75", + "11": "#164c63" + }, + "blue-dark": { + "1": "#f5f8ff", + "2": "#eff4ff", + "3": "#d1e0ff", + "4": "#b2ccff", + "5": "#84adff", + "6": "#528bff", + "7": "#2970ff", + "8": "#155eef", + "9": "#004eeb", + "10": "#0040c1", + "11": "#00359e" + }, + "indigo": { + "1": "#f5f8ff", + "2": "#eef4ff", + "3": "#e0eaff", + "4": "#c7d7fe", + "5": "#a4bcfd", + "6": "#8098f9", + "7": "#8098f9", + "8": "#444ce7", + "9": "#3538cd", + "10": "#2d31a6", + "11": "#2d3282" + }, + "violet": { + "1": "#fbfaff", + "2": "#f5f3ff", + "3": "#ece9fe", + "4": "#ddd6fe", + "5": "#c3b5fd", + "6": "#a48afb", + "7": "#875bf7", + "8": "#7839ee", + "9": "#6927da", + "10": "#5720b7", + "11": "#491c96" + }, + "purple": { + "1": "#fafaff", + "2": "#f4f3ff", + "3": "#ebe9fe", + "4": "#d9d6fe", + "5": "#bdb4fe", + "6": "#9b8afb", + "7": "#7a5af8", + "8": "#6938ef", + "9": "#5925dc", + "10": "#4a1fb8", + "11": "#3e1c96" + }, + "fuchsia": { + "1": "#fefaff", + "2": "#fdf4ff", + "3": "#fbe8ff", + "4": "#f6d0fe", + "5": "#eeaafd", + "6": "#e478fa", + "7": "#d444f1", + "8": "#ba24d5", + "9": "#9f1ab1", + "10": "#821890", + "11": "#6f1877" + }, + "pink": { + "1": "#fef6fb", + "2": "#fdf2fa", + "3": "#fce7f6", + "4": "#fce7f6", + "5": "#faa7e0", + "6": "#f670c7", + "7": "#ee46bc", + "8": "#dd2590", + "9": "#c11574", + "10": "#9e165f", + "11": "#851651" + }, + "rose": { + "1": "#fff5f6", + "2": "#fff1f3", + "3": "#ffe4e8", + "4": "#fecdd6", + "5": "#fea3b4", + "6": "#fd6f8e", + "7": "#f63d68", + "8": "#e31b54", + "9": "#c01048", + "10": "#a11043", + "11": "#89123e" + }, + "orange-dark": { + "1": "#fff9f5", + "2": "#fff4ed", + "3": "#ffe6d5", + "4": "#ffd6ae", + "5": "#ff9c66", + "6": "#ff692e", + "7": "#ff4405", + "8": "#e62e05", + "9": "#bc1b06", + "10": "#97180c", + "11": "#771a0d" + }, + "orange": { + "1": "#fefaf5", + "2": "#fef6ee", + "3": "#fdead7", + "4": "#f9dbaf", + "5": "#f7b27a", + "6": "#f38744", + "7": "#ef6820", + "8": "#e04f16", + "9": "#b93815", + "10": "#932f19", + "11": "#772917" + }, + "yellow": { + "1": "#fefdf0", + "2": "#fefbe8", + "3": "#fef7c3", + "4": "#feee95", + "5": "#feee95", + "6": "#fac515", + "7": "#eaaa08", + "8": "#ca8504", + "9": "#a15c07", + "10": "#854a0e", + "11": "#713b12" + } +} diff --git a/app/assets/css/colors.ts b/app/assets/css/colors.ts new file mode 100644 index 0000000..5505c87 --- /dev/null +++ b/app/assets/css/colors.ts @@ -0,0 +1,19 @@ +import colors from './colors.json'; + +const element = document.createElement('style'); + +element.innerHTML = `:root { + ${Object.entries(colors) + .map(([color, hex]) => { + if (typeof hex === 'string') { + return `--ui-${color}: ${hex}`; + } + + return Object.entries(hex) + .map(([key, value]) => `--ui-${color}-${key}: ${value}`) + .join(';\n'); + }) + .join(';\n')} +}`; + +document.head.prepend(element); diff --git a/app/assets/css/icon.css b/app/assets/css/icon.css new file mode 100644 index 0000000..9008b29 --- /dev/null +++ b/app/assets/css/icon.css @@ -0,0 +1,129 @@ +.lucide { + display: block; + height: 1em; + width: 1em; + color: inherit; +} + +pr-icon { + display: inline-flex; +} + +.icon { + color: currentColor; + margin: 0; + + font-size: var(--icon-size); + height: var(--icon-size); + width: var(--icon-size); + + --icon-size: 1em; +} + +.icon-xs { + --icon-size: 10px; +} + +.icon-sm { + --icon-size: 14px; +} + +.icon-md { + --icon-size: 16px; +} + +.icon-lg { + --icon-size: 22px; +} + +.icon-xl { + --icon-size: 26px; +} + +.icon.icon-alt { + fill: var(--black-color); + stroke: var(--white-color); +} + +.icon-primary, +.icon-blue { + color: var(--ui-blue-8); +} + +.icon.icon-primary-alt { + fill: var(--ui-blue-8); + stroke: var(--white-color); +} + +.icon-secondary { + color: var(--ui-gray-8); +} + +.icon.icon-secondary-alt { + fill: var(--ui-gray-8); + stroke: var(--black-color); +} + +.icon-warning, +.icon-orange { + @apply text-warning-9 th-highcontrast:text-warning-1 th-dark:text-warning-7; +} + +.icon.icon-warning-alt { + fill: var(--ui-warning-9); + stroke: var(--white-color); +} + +.icon-danger { + color: var(--ui-error-9); +} + +.icon.icon-danger-alt { + fill: var(--ui-error-9); + stroke: var(--white-color); +} + +.icon-success { + color: var(--ui-success-6); +} + +.icon.icon-success-alt { + fill: var(--ui-success-8); + stroke: var(--white-color); +} + +.icon-badge { + border-radius: 50%; + display: flex; + justify-content: center; + align-items: center; + padding: 1.5%; +} + +.icon-nested-blue { + @apply bg-blue-3 text-blue-8; + @apply th-dark:bg-gray-9 th-dark:text-blue-3; + @apply th-highcontrast:bg-gray-9 th-highcontrast:text-blue-3; + display: flex; + justify-content: center; + align-items: center; + height: 30px; + width: 30px; + padding: 5px; + text-align: center; + border-radius: 50%; + margin-right: 5px; +} + +.icon-nested-blue > svg { + height: 20px; + width: 20px; +} + +.btn-only-icon { + padding: 6px; +} + +.btn-only-icon pr-icon { + margin-top: 0; +} diff --git a/app/assets/css/index.js b/app/assets/css/index.js new file mode 100644 index 0000000..0b9264e --- /dev/null +++ b/app/assets/css/index.js @@ -0,0 +1,18 @@ +import 'bootstrap/dist/css/bootstrap.css'; +import 'toastr/build/toastr.css'; +import 'angularjs-slider/dist/rzslider.css'; +import 'angular-loading-bar/build/loading-bar.css'; +import 'spinkit/spinkit.min.css'; // ViewLoading, index.html, internal-auth. auth, +import '@reach/menu-button/styles.css'; + +import './colors'; + +import './rdash.css'; +import './app.css'; + +import './theme.css'; +import './vendor-override.css'; +import './bootstrap-override.css'; +import './icon.css'; +import './button.css'; +import './react-datetime-picker-override.css'; diff --git a/app/assets/css/rdash.css b/app/assets/css/rdash.css new file mode 100644 index 0000000..832b25a --- /dev/null +++ b/app/assets/css/rdash.css @@ -0,0 +1,154 @@ +#content-wrapper { + padding-left: 0; + margin-left: 0; + width: 100%; + height: auto; +} + +.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); + } +} + +.row { + margin-left: 0 !important; + margin-right: 0 !important; +} +.row > div { + margin-bottom: 15px; +} +.alerts-container .alert:last-child { + margin-bottom: 0; +} + +.green { + background: #23ae89 !important; +} +.blue { + background: var(--blue-color) !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; +} + +/** + * 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: var(--bg-widget-color); + border: 1px solid var(--border-widget); + border-radius: 12px; +} +.widget .widget-header .pagination, +.widget .widget-footer .pagination { + margin: 0; +} +.widget .widget-header { + color: var(--text-widget-header-color); + padding: 20px 20px 10px 20px; + line-height: 30px; + font-weight: 500; +} + +.widget .widget-body { + padding: 20px; + border-radius: 12px; +} +.widget .widget-body table thead { + background: var(--bg-widget-table-color); +} +.widget .widget-body table thead * { + font-size: 14px; +} +.widget .widget-body table tbody * { + font-size: 13px; +} +.widget .widget-body .error { + color: #ff0000; +} + +.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: 20px; +} +.widget .widget-title .pagination, +.widget .widget-footer .pagination { + margin: 0; +} +.widget .widget-content .title { + font-size: 28px; + display: block; +} diff --git a/app/assets/css/react-datetime-picker-override.css b/app/assets/css/react-datetime-picker-override.css new file mode 100644 index 0000000..dbbea47 --- /dev/null +++ b/app/assets/css/react-datetime-picker-override.css @@ -0,0 +1,125 @@ +/* react-datetime-picker */ +/* https://github.com/wojtekmaj/react-datetime-picker#custom-styling */ + +/* + library css for buttons is overriden by `.widget .widget-body button` + so we have to force margin: 0 +*/ +.react-daterange-picker__calendar .react-calendar button { + margin: 0 !important; +} + +/* + Extending Calendar.css from react-daterange-picker__calendar +*/ +.react-calendar { + background: var(--bg-calendar-color); + color: var(--text-main-color); + @apply th-dark:bg-gray-iron-10; +} + +/* calendar nav buttons */ +.react-calendar__navigation button:disabled { + background: var(--bg-calendar-color); + @apply opacity-60; + @apply brightness-95 th-dark:brightness-110; + @apply th-dark:bg-gray-iron-7; +} +.react-calendar__navigation button:enabled:hover, +.react-calendar__navigation button:enabled:focus { + background: var(--bg-daterangepicker-color); + @apply th-dark:bg-gray-iron-7; +} + +/* date tile */ +.react-calendar__tile:disabled { + @apply opacity-60; + @apply brightness-95 th-dark:brightness-110; + @apply th-dark:bg-gray-iron-7; +} + +.react-calendar__tile:enabled:hover, +.react-calendar__tile:enabled:focus { + background: var(--bg-daterangepicker-hover); + @apply th-dark:bg-gray-iron-7; +} + +/* today's date tile */ +.react-calendar__tile--now { + @apply th-highcontrast:text-[color:var(--bg-calendar-color)] th-dark:text-[color:var(--bg-calendar-color)]; + border-radius: 0.25rem !important; +} +.react-daterange-picker__calendar .react-calendar__tile--now:enabled:hover, +.react-daterange-picker__calendar .react-calendar__tile--now:enabled:focus { + background: var(--bg-daterangepicker-hover); + color: var(--text-daterangepicker-hover); + @apply th-dark:bg-gray-iron-7; +} + +/* probably date tile in range */ +.react-calendar__tile--hasActive { + background: var(--bg-daterangepicker-end-date); + color: var(--text-daterangepicker-end-date); + @apply th-dark:bg-gray-iron-7; +} +.react-calendar__tile--hasActive:enabled:hover, +.react-calendar__tile--hasActive:enabled:focus { + background: var(--bg-daterangepicker-hover); + color: var(--text-daterangepicker-hover); + @apply th-dark:bg-gray-iron-7; +} + +.react-calendar__tile--active:enabled:hover, +.react-calendar__tile--active:enabled:focus { + background: var(--bg-daterangepicker-hover); + color: var(--text-daterangepicker-hover); + @apply th-dark:bg-gray-iron-7; +} + +.react-daterange-picker__calendar + .react-calendar__month-view__days__day:hover:not(.react-daterange-picker__calendar .react-calendar__tile--hoverEnd):not( + .react-daterange-picker__calendar .react-calendar__tile--hoverStart + ):not(.react-calendar__tile--active) { + border-radius: 0.25rem !important; +} + +/* on range select hover */ +.react-calendar--selectRange .react-calendar__tile--hover { + background: var(--bg-daterangepicker-in-range); + color: var(--text-daterangepicker-in-range); + @apply th-dark:bg-gray-iron-7; +} + +/* + Extending DateTimePicker.css from react-daterange-picker__calendar +*/ +.react-daterange-picker__calendar .react-daterange-picker__calendar--disabled { + @apply opacity-40; +} + +/* selected date tile */ +.react-daterange-picker__calendar .react-calendar__tile--active { + background: var(--bg-daterangepicker-active) !important; + color: var(--text-daterangepicker-active) !important; +} + +.react-daterange-picker__calendar .react-calendar__tile--rangeStart:not(.react-calendar__tile--rangeEnd), +.react-daterange-picker__calendar .react-calendar__tile--hoverStart { + border-top-left-radius: 0.25rem; + border-bottom-left-radius: 0.25rem; +} + +.react-daterange-picker__calendar .react-calendar__tile--rangeEnd:not(.react-calendar__tile--rangeStart), +.react-daterange-picker__calendar .react-calendar__tile--hoverEnd { + border-top-right-radius: 0.25rem; + border-bottom-right-radius: 0.25rem; +} + +.react-daterange-picker__calendar .react-calendar__month-view__days__day--weekend { + color: inherit; +} + +.react-calendar__tile--active.react-calendar__month-view__days__day--weekend { + color: var(--text-daterangepicker-active); + @apply th-dark:bg-gray-iron-7; +} diff --git a/app/assets/css/theme.css b/app/assets/css/theme.css new file mode 100644 index 0000000..318e0d9 --- /dev/null +++ b/app/assets/css/theme.css @@ -0,0 +1,604 @@ +/* Color Variable */ +:root { + --black-color: var(--ui-black); + --white-color: var(--ui-white); + + --graphite-600: #3a3b3f; + --graphite-700: #2e2f33; + --graphite-800: #222326; + --graphite-900: #161719; + + --mist-50: #fcfbfa; + --mist-100: #f7f6f3; + --mist-200: #f0f0ec; + --mist-300: #e8e7e2; + + --grey-1: #212121; + --grey-2: #181818; + --grey-3: #383838; + --grey-4: #585858; + --grey-6: #333333; + --grey-7: #767676; + --grey-8: #aaa; + --grey-9: #f3f3f3; + --grey-10: #f6f6f6; + --grey-11: #eeeeee; + --grey-12: #ececec; + --grey-13: #fafafa; + --grey-14: #f5f5f5; + --grey-15: #f9f2f4; + --grey-16: #eee; + --grey-17: #f7f7f7; + --grey-18: #c5cae9; + --grey-19: #ddd; + --grey-20: #dae3f3; + --grey-21: #d5e8f3; + --grey-22: #c3c3e4; + --grey-23: #e7f6ff; + --grey-24: #f1f9fd; + --grey-25: #555555; + --grey-26: #777777; + --grey-27: #4e4e4e; + --grey-28: #262626; + --grey-29: #555; + --grey-30: #444; + --grey-31: #868686; + --grey-32: #65798e; + --grey-35: #546477; + --grey-36: #55637d; + --grey-37: #2d3e63; + --grey-38: #434343; + --grey-39: #194973; + --grey-40: #cfddfc; + --grey-41: #b4b4b4; + --grey-42: #d2d1d1; + --grey-43: #e9e9e9; + --grey-44: #ccc; + --grey-45: #e5e5e5; + --grey-46: #bbbbbb; + --grey-47: #d4d4d5; + --grey-48: #c6c6c6; + --grey-49: rgba(0, 0, 0, 0.54); + --grey-50: rgba(161, 170, 166, 0.5); + --grey-51: rgba(0, 0, 0, 0.15); + --grey-53: rgba(255, 255, 255, 0.6); + --grey-54: rgb(54, 54, 54); + --grey-55: rgba(255, 255, 255, 0.8); + --grey-57: #999; + --grey-58: #ebf4f8; + --grey-59: #e6e6e6; + --grey-61: rgb(231, 231, 231); + --grey-62: #fdfdfd; + --grey-63: #121212; + + --blue-1: #219; + --blue-2: #337ab7; + --blue-3: #738bc0; + --blue-4: #23527c; + --blue-5: #30426a; + --blue-6: #577bc9; + --blue-7: #6b9aff; + --blue-8: #90ccff; + --blue-9: #3ea6ff; + --blue-10: #61b6ff; + --blue-11: #3ea5ff; + --blue-12: #41a6ff; + --blue-14: #357ebd; + --blue-15: #36bffa; + + --red-1: #a94442; + --red-2: #c7254e; + --red-3: #a11; + --red-4: #d9534f; + --red-5: #ff2727; + --red-7: #f00; + + --green-1: #164; + --green-2: #1ec863; + --green-3: #23ae89; + + --orange-1: #e86925; + + --BE-only: var(--ui-gray-6); + + --text-log-viewer-color-json-grey: var(--text-log-viewer-color); + --text-log-viewer-color-json-magenta: var(--text-log-viewer-color); + --text-log-viewer-color-json-yellow: var(--text-log-viewer-color); + --text-log-viewer-color-json-green: var(--text-log-viewer-color); + --text-log-viewer-color-json-red: var(--text-log-viewer-color); + --text-log-viewer-color-json-blue: var(--text-log-viewer-color); + + /* Default Theme */ + --bg-card-color: var(--white-color); + --bg-main-color: var(--white-color); + --bg-body-color: var(--grey-62); + --bg-checkbox-border-color: var(--grey-49); + --bg-sidebar-color: var(--graphite-700); + --bg-sidebar-nav-color: var(--graphite-600); + --bg-widget-color: var(--white-color); + --bg-widget-header-color: var(--grey-10); + --bg-widget-table-color: var(--ui-gray-3); + --bg-header-color: var(--white-color); + --bg-hover-table-color: var(--grey-14); + --bg-input-group-addon-color: var(--ui-gray-3); + --bg-blocklist-hover-color: var(--ui-blue-2); + --bg-table-color: var(--white-color); + --bg-md-checkbox-color: var(--grey-12); + --bg-form-control-disabled-color: var(--grey-11); + --bg-modal-content-color: var(--white-color); + --bg-navtabs-hover-color: var(--grey-16); + --bg-nav-tab-active-color: var(--ui-gray-4); + --bg-table-selected-color: var(--grey-14); + --bg-dropdown-menu-color: var(--white-color); + --bg-log-viewer-color: var(--white-color); + --bg-log-line-selected-color: var(--grey-18); + --bg-pre-color: var(--grey-14); + --bg-blocklist-item-selected-color: var(--grey-12); + --bg-progress-color: var(--grey-14); + --bg-pagination-color: var(--ui-blue-3); + --border-pagination-color: var(--ui-white); + --bg-pagination-span-color: var(--white-color); + --bg-pagination-hover-color: var(--ui-blue-3); + --bg-motd-body-color: var(--mist-50); + --bg-motd-btn-color: var(--graphite-700); + --bg-item-highlighted-color: var(--grey-21); + --bg-item-highlighted-null-color: var(--grey-14); + --bg-panel-body-color: var(--white-color); + --bg-tooltip-color: var(--ui-gray-11); + --bg-input-sm-color: var(--white-color); + --bg-app-datatable-thead: var(--grey-23); + --bg-app-datatable-tbody: var(--grey-24); + --bg-daterangepicker-color: var(--white-color); + --bg-calendar-color: var(--white-color); + --bg-calendar-table-color: var(--white-color); + --bg-daterangepicker-end-date: var(--white-color); + --bg-daterangepicker-hover: var(--grey-16); + --bg-daterangepicker-in-range: var(--grey-58); + --bg-daterangepicker-active: var(--blue-14); + --bg-input-autofill-color: var(--bg-inputbox); + --bg-small-select-color: var(--white-color); + --bg-stepper-item-active: var(--white-color); + --bg-stepper-item-counter: var(--grey-61); + --bg-sortbutton-color: var(--white-color); + --bg-dashboard-item: var(--ui-blue-3); + --bg-searchbar: var(--ui-gray-2); + --bg-inputbox: var(--ui-gray-2); + --bg-dropdown-hover: var(--ui-gray-3); + --bg-webeditor-color: var(--ui-gray-3); + --bg-button-group-color: var(--ui-white); + --bg-pagination-disabled-color: var(--ui-white); + --bg-code-script-color: var(--ui-white); + --bg-stepper-color: var(--ui-white); + --bg-stepper-active-color: var(--ui-blue-1); + --bg-code-color: var(--ui-white); + + --text-main-color: var(--grey-7); + --text-body-color: var(--grey-6); + --text-widget-header-color: var(--ui-gray-11); + --text-form-control-color: var(--grey-25); + --text-muted-color: var(--grey-26); + --text-link-color: var(--blue-2); + --text-link-hover-color: var(--blue-4); + --text-input-group-addon-color: var(--grey-25); + --text-blocklist-hover-color: var(--grey-37); + --text-dashboard-item-color: var(--grey-32); + --text-danger-color: var(--red-1); + --text-code-color: var(--ui-gray-9); + --text-navtabs-color: var(--grey-7); + --text-navtabs-hover-color: var(--grey-6); + --text-nav-tab-active-color: var(--grey-25); + --text-dropdown-menu-color: var(--grey-6); + --text-log-viewer-color: var(--black-color); + --text-json-tree-color: var(--blue-3); + --text-json-tree-leaf-color: var(--blue-5); + --text-json-tree-branch-preview-color: var(--blue-5); + --text-pre-color: var(--grey-6); + --text-blocklist-item-selected-color: var(--grey-37); + --text-progress-bar-color: var(--grey-27); + --text-pagination-color: var(--grey-26); + --text-pagination-span-color: var(--grey-3); + --text-pagination-span-hover-color: var(--grey-3); + --text-motd-body-color: var(--black-color); + --text-motd-btn-color: var(--mist-100); + --text-summary-color: var(--black-color); + --text-tooltip-color: var(--white-color); + --text-rzslider-color: var(--grey-36); + --text-rzslider-limit-color: var(--grey-36); + --text-daterangepicker-end-date: var(--grey-57); + --text-daterangepicker-in-range: var(--black-color); + --text-daterangepicker-active: var(--white-color); + --text-input-autofill-color: var(--text-form-control-color); + --text-button-hover-color: var(--grey-6); + --text-small-select-color: var(--grey-25); + --text-bootbox: var(--ui-gray-7); + --text-button-group-color: var(--ui-gray-9); + --text-button-dangerlight-color: var(--ui-error-5); + --text-stepper-active-color: var(--ui-blue-8); + + --border-color: var(--grey-42); + --border-widget-color: var(--grey-43); + --border-sidebar-color: var(--ui-blue-9); + --border-form-control-color: var(--grey-44); + --border-table-color: var(--grey-19); + --border-table-top-color: var(--grey-19); + --border-datatable-top-color: var(--grey-10); + --border-input-group-addon-color: var(--grey-44); + --border-btn-default-color: var(--grey-44); + --border-md-checkbox-color: var(--grey-19); + --border-modal-header-color: var(--grey-45); + --border-navtabs-color: var(--ui-white); + --border-pre-color: var(--grey-43); + --border-pagination-span-color: var(--ui-white); + --border-pagination-hover-color: var(--ui-white); + --border-motd-body-color: var(--mist-300); + --border-panel-color: var(--mist-300); + --border-input-sm-color: var(--grey-47); + --border-daterangepicker-color: var(--grey-19); + --border-calendar-table: var(--white-color); + --border-daterangepicker: var(--grey-19); + --border-pre-next-month: var(--black-color); + --border-daterangepicker-after: var(--white-color); + --border-modal: 0px; + --border-sortbutton: var(--grey-8); + --border-bootbox: var(--ui-gray-5); + --border-blocklist: var(--ui-gray-5); + --border-blocklist-item-selected-color: var(--grey-46); + --border-widget: var(--ui-gray-5); + --border-stepper-color: var(--ui-gray-4); + + --shadow-box-color: 0 3px 10px -2px var(--grey-50); + --shadow-boxselector-color: 0 3px 10px -2px var(--grey-50); + --blue-color: var(--blue-13); + --button-close-color: var(--black-color); + --button-opacity: 0.2; + --button-opacity-hover: 0.5; + + --text-input-textarea: var(--white-color); + + --user-menu-icon-color: var(--ui-gray-4); + --sort-icon-muted: var(--ui-gray-5); + --sort-icon-hover: var(--ui-gray-6); + --sort-icon: var(--ui-gray-9); + --border-checkbox: var(--ui-gray-5); + --bg-checkbox: var(--white-color); + --border-searchbar: var(--grey-44); + --bg-button-group: var(--white-color); + --border-button-group: var(--ui-gray-5); + --text-button-group: var(--ui-gray-9); +} + +/* Dark Theme */ +[theme='dark'] { + --BE-only: var(--ui-gray-6); + + --text-log-viewer-color-json-grey: var(--text-log-viewer-color); + --text-log-viewer-color-json-magenta: var(--text-log-viewer-color); + --text-log-viewer-color-json-yellow: var(--text-log-viewer-color); + --text-log-viewer-color-json-green: var(--text-log-viewer-color); + --text-log-viewer-color-json-red: var(--text-log-viewer-color); + --text-log-viewer-color-json-blue: var(--text-log-viewer-color); + + --bg-body-color: var(--grey-63); + --bg-blocklist-hover-color: var(--ui-gray-iron-10); + --bg-blocklist-item-selected-color: var(--ui-gray-iron-10); + --bg-card-color: var(--grey-1); + --bg-checkbox-border-color: var(--grey-8); + --bg-code-color: var(--grey-2); + --bg-dropdown-menu-color: var(--ui-gray-warm-8); + --bg-main-color: var(--grey-2); + --bg-widget-color: var(--grey-1); + --bg-widget-header-color: var(--grey-3); + --bg-widget-table-color: var(--grey-3); + --bg-header-color: var(--grey-2); + --bg-hover-table-color: var(--grey-3); + --bg-table-color: var(--grey-1); + --bg-md-checkbox-color: var(--grey-31); + --bg-form-control-disabled-color: var(--grey-3); + --bg-modal-content-color: var(--grey-1); + + --bg-navtabs-hover-color: var(--grey-3); + --bg-nav-tab-active-color: var(--grey-2); + --bg-table-selected-color: var(--grey-3); + --bg-log-viewer-color: var(--grey-2); + --bg-log-line-selected-color: var(--grey-3); + --bg-pre-color: var(--grey-2); + --bg-progress-color: var(--grey-3); + --bg-pagination-color: var(--grey-3); + --bg-pagination-span-color: var(--grey-1); + --bg-pagination-hover-color: var(--grey-3); + --bg-motd-body-color: var(--graphite-800); + --bg-motd-btn-color: var(--mist-100); + --bg-item-highlighted-color: var(--grey-2); + --bg-item-highlighted-null-color: var(--grey-2); + --bg-panel-body-color: var(--grey-1); + --bg-input-group-addon-color: var(--grey-3); + --bg-tooltip-color: var(--grey-3); + --bg-input-sm-color: var(--grey-1); + --bg-service-datatable-thead: var(--grey-1); + --bg-inner-datatable-thead: var(--grey-1); + --bg-app-datatable-thead: var(--grey-1); + --bg-service-datatable-tbody: var(--grey-1); + --bg-app-datatable-tbody: var(--grey-1); + --bg-daterangepicker-color: var(--grey-3); + --bg-calendar-color: var(--grey-3); + --bg-calendar-table-color: var(--grey-3); + --bg-daterangepicker-end-date: var(--grey-4); + --bg-daterangepicker-hover: var(--grey-4); + --bg-daterangepicker-in-range: var(--ui-gray-warm-11); + --bg-daterangepicker-active: var(--blue-14); + --bg-input-autofill-color: var(--bg-inputbox); + --bg-small-select-color: var(--grey-2); + --bg-stepper-item-active: var(--grey-1); + --bg-stepper-item-counter: var(--grey-7); + --bg-sortbutton-color: var(--grey-1); + --bg-dashboard-item: var(--grey-3); + --bg-searchbar: var(--ui-grey-warm-11); + --bg-inputbox: var(--grey-2); + --bg-dropdown-hover: var(--grey-3); + --bg-webeditor-color: var(--ui-gray-iron-10); + --bg-button-group-color: var(--ui-black); + --bg-pagination-disabled-color: var(--grey-1); + --bg-code-script-color: var(--ui-gray-warm-11); + --bg-stepper-color: var(--ui-gray-iron-10); + --bg-stepper-active-color: var(--ui-blue-8); + + --text-main-color: var(--white-color); + --text-body-color: var(--white-color); + --text-widget-header-color: var(--white-color); + --text-form-control-color: var(--white-color); + --text-muted-color: var(--grey-8); + --text-link-color: var(--blue-9); + --text-link-hover-color: var(--blue-2); + --text-input-group-addon-color: var(--grey-8); + --text-blocklist-hover-color: var(--white-color); + --text-dashboard-item-color: var(--blue-2); + --text-danger-color: var(--red-4); + --text-code-color: var(--white-color); + --text-navtabs-color: var(--grey-8); + --text-navtabs-hover-color: var(--grey-9); + --text-nav-tab-active-color: var(--white-color); + --text-dropdown-menu-color: var(--white-color); + --text-log-viewer-color: var(--white-color); + --text-json-tree-color: var(--grey-40); + --text-json-tree-leaf-color: var(--blue-6); + --text-json-tree-branch-preview-color: var(--blue-7); + --text-pre-color: var(--white-color); + --text-blocklist-item-selected-color: var(--white-color); + --text-progress-bar-color: var(--white-color); + --text-pagination-color: var(--white-color); + --text-pagination-span-color: var(--ui-white); + --text-pagination-span-hover-color: var(--ui-white); + --text-motd-body-color: var(--mist-100); + --text-motd-btn-color: var(--graphite-700); + --text-summary-color: var(--white-color); + --text-tooltip-color: var(--white-color); + --text-rzslider-color: var(--white-color); + --text-rzslider-limit-color: var(--white-color); + --text-daterangepicker-end-date: var(--grey-7); + --text-daterangepicker-in-range: var(--white-color); + --text-daterangepicker-active: var(--white-color); + --text-input-autofill-color: var(--text-form-control-color); + --text-button-hover-color: var(--white-color); + --text-small-select-color: var(--white-color); + --text-bootbox: var(--white-color); + --text-button-group-color: var(--ui-white); + --text-button-dangerlight-color: var(--ui-error-7); + --text-stepper-active-color: var(--ui-white); + + --border-color: var(--grey-3); + --border-widget-color: var(--grey-1); + --border-sidebar-color: var(--ui-gray-8); + --border-form-control-color: var(--grey-54); + --border-table-color: var(--grey-3); + --border-table-top-color: var(--grey-3); + --border-datatable-top-color: var(--grey-3); + --border-input-group-addon-color: var(--grey-38); + --border-btn-default-color: var(--grey-38); + --border-md-checkbox-color: var(--grey-41); + --border-modal-header-color: var(--grey-1); + --border-navtabs-color: var(--grey-38); + --border-pre-color: var(--grey-3); + --border-blocklist: var(--ui-gray-9); + --border-blocklist-item-selected-color: var(--grey-31); + --border-pagination-span-color: var(--grey-1); + --border-pagination-hover-color: var(--grey-3); + --border-motd-body-color: var(--graphite-800); + --border-panel-color: var(--grey-2); + --border-input-sm-color: var(--grey-3); + --border-daterangepicker-color: var(--grey-3); + --border-calendar-table: var(--grey-3); + --border-daterangepicker: var(--grey-4); + --border-pre-next-month: var(--white-color); + --border-daterangepicker-after: var(--grey-3); + --border-modal: 0px; + --border-sortbutton: var(--grey-3); + --border-bootbox: var(--ui-gray-9); + --border-widget: var(--grey-3); + --border-pagination-color: var(--grey-1); + --border-stepper-color: var(--ui-gray-warm-9); + + --blue-color: var(--blue-2); + --button-close-color: var(--white-color); + --button-opacity: 0.6; + --button-opacity-hover: 0.3; + --shadow-box-color: none; + --shadow-boxselector-color: none; + + --text-input-textarea: var(--grey-1); + + --user-menu-icon-color: var(--grey-3); + --sort-icon-muted: var(--ui-gray-7); + --sort-icon-hover: var(--ui-gray-6); + --sort-icon: var(--ui-gray-3); + --border-checkbox: var(--ui-gray-5); + --bg-checkbox: var(--white-color); + --border-searchbar: var(--grey-54); + --bg-button-group: var(--white-color); + --border-button-group: var(--ui-gray-5); + --text-button-group: var(--ui-gray-9); +} + +/* High Contrast Theme */ +[theme='highcontrast'] { + --BE-only: var(--ui-gray-6); + --text-log-viewer-color-json-grey: var(--text-log-viewer-color); + --text-log-viewer-color-json-magenta: var(--text-log-viewer-color); + --text-log-viewer-color-json-yellow: var(--text-log-viewer-color); + --text-log-viewer-color-json-green: var(--text-log-viewer-color); + --text-log-viewer-color-json-red: var(--text-log-viewer-color); + --text-log-viewer-color-json-blue: var(--text-log-viewer-color); + + --bg-card-color: var(--black-color); + --bg-main-color: var(--black-color); + --bg-body-color: var(--black-color); + --bg-checkbox-border-color: var(--grey-8); + --bg-sidebar-color: var(--black-color); + --bg-sidebar-nav-color: var(--black-color); + --bg-widget-color: var(--black-color); + --bg-widget-header-color: var(--black-color); + --bg-widget-table-color: var(--black-color); + --bg-header-color: var(--black-color); + --bg-hover-table-color: var(--grey-3); + --bg-panel-body-color: var(--black-color); + --bg-dropdown-menu-color: var(--ui-gray-warm-8); + --bg-motd-body-color: var(--black-color); + --bg-motd-btn-color: var(--white-color); + --bg-blocklist-hover-color: var(--black-color); + --bg-blocklist-item-selected-color: var(--black-color); + --bg-input-group-addon-color: var(--grey-3); + --bg-table-color: var(--black-color); + + --bg-log-viewer-color: var(--black-color); + --bg-log-line-selected-color: var(--grey-3); + --bg-modal-content-color: var(--black-color); + --bg-form-control-disabled-color: var(--grey-1); + --bg-input-sm-color: var(--black-color); + --bg-item-highlighted-color: var(--black-color); + --bg-service-datatable-thead: var(--black-color); + --bg-inner-datatable-thead: var(--black-color); + --bg-app-datatable-thead: var(--black-color); + --bg-service-datatable-tbody: var(--black-color); + --bg-app-datatable-tbody: var(--black-color); + --bg-pagination-color: var(--grey-3); + --bg-pagination-span-color: var(--ui-black); + --bg-daterangepicker-color: var(--black-color); + --bg-calendar-color: var(--black-color); + --bg-calendar-table-color: var(--black-color); + --bg-daterangepicker-end-date: var(--grey-3); + --bg-daterangepicker-hover: var(--grey-3); + --bg-daterangepicker-in-range: var(--grey-2); + --bg-daterangepicker-active: var(--blue-14); + --bg-tooltip-color: var(--black-color); + --bg-table-selected-color: var(--grey-3); + --bg-pre-color: var(--grey-2); + + --bg-navtabs-hover-color: var(--grey-3); + --bg-nav-tab-active-color: var(--ui-black); + --bg-input-autofill-color: var(--bg-inputbox); + --bg-code-color: var(--ui-black); + --bg-small-select-color: var(--black-color); + --bg-stepper-item-active: var(--black-color); + --bg-stepper-item-counter: var(--grey-3); + --bg-sortbutton-color: var(--grey-1); + --bg-inputbox: var(--black-color); + --bg-searchbar: var(--black-color); + --bg-dropdown-hover: var(--black-color); + --bg-webeditor-color: var(--ui-gray-warm-9); + --bg-pagination-disabled-color: var(--ui-black); + --bg-pagination-hover-color: var(--ui-black); + --bg-code-script-color: var(--ui-black); + --bg-stepper-active-color: var(--ui-blue-8); + --bg-stepper-color: var(--ui-black); + + --text-main-color: var(--white-color); + --text-body-color: var(--white-color); + --text-widget-header-color: var(--white-color); + --text-link-color: var(--blue-9); + --text-link-hover-color: var(--blue-9); + --text-danger-color: var(--red-7); + --text-code-color: var(--red-7); + --text-form-control-color: var(--white-color); + --text-blocklist-hover-color: var(--blue-11); + --text-dashboard-item-color: var(--blue-12); + --text-muted-color: var(--white-color); + --text-tooltip-color: var(--white-color); + --text-blocklist-item-selected-color: var(--blue-9); + --text-input-group-addon-color: var(--white-color); + --text-dropdown-menu-color: var(--white-color); + --text-log-viewer-color: var(--white-color); + --text-summary-color: var(--white-color); + --text-rzslider-color: var(--white-color); + --text-rzslider-limit-color: var(--white-color); + --text-pagination-color: var(--white-color); + --text-daterangepicker-end-date: var(--ui-white); + --text-daterangepicker-in-range: var(--white-color); + --text-daterangepicker-active: var(--white-color); + --text-motd-body-color: var(--white-color); + --text-motd-btn-color: var(--black-color); + --text-json-tree-color: var(--white-color); + --text-json-tree-leaf-color: var(--white-color); + --text-json-tree-branch-preview-color: var(--white-color); + --text-pre-color: var(--white-color); + --text-navtabs-color: var(--white-color); + --text-navtabs-hover-color: var(--white-color); + --text-nav-tab-active-color: var(--white-color); + --text-input-autofill-color: var(--text-form-control-color); + --text-button-hover-color: var(--white-color); + --text-small-select-color: var(--white-color); + --text-pagination-span-color: var(--ui-white); + --text-bootbox: var(--white-color); + --text-pagination-span-hover-color: var(--ui-white); + --text-stepper-active-color: var(--ui-white); + + --border-color: var(--grey-55); + --border-widget-color: var(--white-color); + --border-sidebar-color: var(--white-color); + --border-form-control-color: var(--grey-54); + --border-table-color: var(--grey-55); + --border-table-top-color: var(--grey-55); + --border-datatable-top-color: var(--grey-55); + --border-sidebar-high-contrast: 1px solid var(--blue-9); + --border-code-high-contrast: 1px solid var(--white-color); + --border-panel-color: var(--white-color); + --border-input-group-addon-color: var(--grey-54); + --border-modal-header-color: var(--grey-3); + --border-input-sm-color: var(--white-color); + --border-pagination-color: var(--grey-1); + --border-pagination-span-color: var(--grey-1); + --border-motd-body-color: var(--white-color); + --border-daterangepicker-color: var(--white-color); + --border-calendar-table: var(--black-color); + --border-daterangepicker: var(--black-color); + --border-pre-next-month: var(--white-color); + --border-daterangepicker-after: var(--black-color); + --border-pre-color: var(--grey-3); + --border-modal: 1px solid var(--white-color); + --border-sortbutton: var(--black-color); + --border-bootbox: var(--black-color); + --border-blocklist: var(--white-color); + --border-widget: var(--white-color); + --border-stepper-color: var(--ui-gray-warm-9); + + --button-close-color: var(--white-color); + --button-opacity: 1; + --button-opacity-hover: 0.7; + + --shadow-box-color: none; + --shadow-boxselector-color: none; + + --text-input-textarea: var(--black-color); + --bg-item-highlighted-null-color: var(--grey-2); + + --text-progress-bar-color: var(--black-color); + + --user-menu-icon-color: var(--white-color); + --sort-icon-muted: var(--ui-gray-7); + --sort-icon-hover: var(--ui-gray-6); + --sort-icon: var(--ui-gray-3); + --border-checkbox: var(--ui-gray-5); + --bg-checkbox: var(--white-color); + --border-searchbar: var(--ui-gray-5); + --bg-button-group: var(--white-color); + --border-button-group: var(--ui-gray-5); + --text-button-group: var(--ui-gray-9); +} diff --git a/app/assets/css/vendor-override.css b/app/assets/css/vendor-override.css new file mode 100644 index 0000000..4cf5c1d --- /dev/null +++ b/app/assets/css/vendor-override.css @@ -0,0 +1,436 @@ +/* Overide Vendor CSS */ +.form-control { + border: 1px solid var(--border-form-control-color); + background-color: var(--bg-inputbox); + color: var(--text-form-control-color); +} + +.text-muted { + color: var(--text-muted-color); +} + +.table > thead > tr > th { + border-bottom: 1px solid var(--border-table-color); +} + +.table-hover > tbody > tr:hover { + background-color: var(--bg-hover-table-color); +} + +.switch i { + @apply bg-gray-6; + @apply th-dark:bg-gray-warm-8; + @apply th-highcontrast:bg-gray-warm-8; +} + +.table > thead > tr > th, +.table > tbody > tr > th, +.table > tfoot > tr > th, +.table > thead > tr > td, +.table > tbody > tr > td, +.table > tfoot > tr > td { + border-top: 1px solid var(--border-table-top-color); +} + +/* the first cell in the table should have 20px padding instead of 5px to match all other widgets */ +.widget .table:not(td .table) > thead > tr > th:first-child, +.widget .table:not(td .table) > tbody > tr > th:first-child, +.widget .table:not(td .table) > tfoot > tr > th:first-child, +.widget .table:not(td .table) > thead > tr > td:first-child, +.widget .table:not(td .table) > tbody > tr > td:first-child, +.widget .table:not(td .table) > tfoot > tr > td:first-child { + padding-left: 20px; +} + +/* the last cell in the table should have 20px padding instead of 5px to match all other widgets */ +.widget .table:not(td .table) > thead > tr > th:last-child, +.widget .table:not(td .table) > tbody > tr > th:last-child, +.widget .table:not(td .table) > tfoot > tr > th:last-child, +.widget .table:not(td .table) > thead > tr > td:last-child, +.widget .table:not(td .table) > tbody > tr > td:last-child, +.widget .table:not(td .table) > tfoot > tr > td:last-child { + padding-right: 20px; +} + +/* tables inside widgets should extend the full width of the widget, with 20px table cell padding padding on the left and right */ +.widget-body:not(.no-padding) > .table, +.widget-body:not(.no-padding) > .widget-content > .table, +.widget-body:not(.no-padding) > .widget-content > form > .table { + margin: 0 -20px; + width: calc(100% + 40px); + max-width: calc(100% + 40px); +} + +.input-group-addon { + color: var(--text-input-group-addon-color); + background-color: var(--bg-input-group-addon-color); + border: 1px solid var(--border-input-group-addon-color); +} + +.input-group .form-control { + z-index: unset; +} + +.input-group-sm > .input-group-addon { + line-height: 1; +} + +.text-danger { + color: var(--ui-error-9); +} + +.table .table { + background-color: initial; +} + +.table-bordered { + border-color: var(--border-table-top-color); +} + +.table-bordered > thead > tr > th, +.table-bordered > tbody > tr > th, +.table-bordered > tfoot > tr > th, +.table-bordered > thead > tr > td, +.table-bordered > tbody > tr > td, +.table-bordered > tfoot > tr > td { + border-color: var(--border-table-top-color); +} + +.md-checkbox input[type='checkbox']:disabled + label:before { + background: var(--bg-md-checkbox-color) !important; + border-color: var(--border-md-checkbox-color) !important; +} + +.form-control[disabled], +.form-control[readonly], +fieldset[disabled] .form-control { + background-color: var(--bg-form-control-disabled-color) !important; +} + +.modal.in .modal-dialog { + border: var(--border-modal); +} + +.modal-content { + background-color: var(--bg-modal-content-color); +} + +code { + color: var(--text-code-color); + background-color: var(--bg-code-color); +} + +.nav-tabs { + border-bottom: 1px solid var(--border-navtabs-color); +} + +.nav-tabs > li { + background-color: var(--bg-nav-tabs-active-color); + border-top-right-radius: 8px; +} + +.nav-tabs > li.active > a { + border: 1px solid transparent; +} + +.nav-tabs > li.active > a, +.nav-tabs > li.active > a:hover, +.nav-tabs > li.active > a:focus { + color: var(--text-nav-tab-active-color); + background-color: var(--bg-nav-tab-active-color); + border: 1px solid var(--border-navtabs-color); +} + +.nav-tabs > li.disabled > a { + border-color: transparent; + pointer-events: none; +} + +.nav-tabs > li > a:hover { + border-color: var(--border-navtabs-color); +} + +.nav > li > a { + color: var(--text-navtabs-color); +} + +.nav > li > a:hover, +.nav > li > a:focus { + color: var(--text-navtabs-hover-color); + background-color: var(--bg-navtabs-hover-color); +} + +.table > thead > tr > td.active, +.table > tbody > tr > td.active, +.table > tfoot > tr > td.active, +.table > thead > tr > th.active, +.table > tbody > tr > th.active, +.table > tfoot > tr > th.active, +.table > thead > tr.active > td, +.table > tbody > tr.active > td, +.table > tfoot > tr.active > td, +.table > thead > tr.active > th, +.table > tbody > tr.active > th, +.table > tfoot > tr.active > th { + background-color: var(--bg-table-selected-color); +} +.table-hover > tbody > tr > td.active:hover, +.table-hover > tbody > tr > th.active:hover, +.table-hover > tbody > tr.active:hover > td, +.table-hover > tbody > tr:hover > .active, +.table-hover > tbody > tr.active:hover > th { + background-color: var(--bg-table-selected-color); +} + +.dropdown-menu { + background: var(--bg-dropdown-menu-color); + border-radius: 8px; +} + +.dropdown-menu > li > a { + color: var(--text-dropdown-menu-color); +} + +pre { + border: 1px solid var(--border-pre-color); + border-radius: 8px; + background-color: var(--bg-pre-color); + color: var(--text-pre-color); +} + +.progress { + background-color: var(--bg-progress-color); +} + +.widget-body.motd-body { + border: 1px solid var(--border-motd-body-color); + color: var(--text-motd-body-color); + background: var(--bg-motd-body-color) url(../images/purple-gradient.svg) top right / 40% no-repeat; +} + +.widget-body.motd-body .btn.btn-link, +.widget-body.motd-body .btn.btn-link:hover { + padding: 0 5px 0 4px; + border-radius: 4px; + background-color: var(--bg-motd-btn-color); + color: var(--text-motd-btn-color); +} + +.panel-body { + background-color: var(--bg-panel-body-color) !important; +} + +.panel { + border: 1px solid var(--border-panel-color); + background-color: var(--bg-panel-body-color); + border-radius: 8px; + -webkit-box-shadow: 0 4px 4px rgba(0, 0, 0, 0.05); + -moz-box-shadow: 0 4px 4px rgba(0, 0, 0, 0.05); + box-shadow: 0 4px 4px rgba(0, 0, 0, 0.05); +} + +.theme-information .col-sm-12 { + padding-left: 0px; + padding-right: 0px; + margin-top: 15px; +} + +.theme-panel { + margin-top: 15px; +} + +.bold { + color: var(--text-summary-color); + font-weight: 700; +} + +.input-sm { + background-color: var(--bg-input-sm-color); + border: 1px solid var(--border-input-sm-color); +} + +.rzslider .rz-bubble { + color: var(--text-rzslider-color); +} + +.rzslider .rz-bubble.rz-limit { + color: var(--text-rzslider-limit-color); +} + +.rz-bubble.rz-limit.rz-ceil { + position: absolute; + right: 0; + left: auto !important; + top: -26px; +} + +input, +select, +textarea { + background: var(--text-input-textarea); +} + +[theme='highcontrast'] input, +[theme='highcontrast'] select, +[theme='highcontrast'] textarea { + border: 1px solid var(--white-color); +} + +.daterangepicker { + background-color: var(--bg-daterangepicker-color); + border: 1px solid var(--border-daterangepicker-color); +} + +.daterangepicker .drp-calendar.left { + background: var(--bg-calendar-color); +} + +.daterangepicker .drp-calendar.left .calendar-table { + background: var(--bg-calendar-table-color); +} + +.daterangepicker .drp-calendar.right { + background: var(--bg-calendar-color); +} + +.daterangepicker .drp-calendar.right .calendar-table { + background: var(--bg-calendar-table-color); +} + +.daterangepicker .calendar-table { + border: 1px solid var(--border-calendar-table); +} + +.daterangepicker td.off, +.daterangepicker td.off.in-range, +.daterangepicker td.off.start-date, +.daterangepicker td.off.end-date { + background-color: var(--bg-daterangepicker-end-date); + color: var(--text-daterangepicker-end-date); +} + +.daterangepicker td.available:hover, +.daterangepicker th.available:hover { + background-color: var(--bg-daterangepicker-hover); +} + +.daterangepicker td.in-range { + background-color: var(--bg-daterangepicker-in-range); + color: var(--text-daterangepicker-in-range); +} + +.daterangepicker td.active, +.daterangepicker td.active:hover { + background-color: var(--bg-daterangepicker-active); + color: var(--text-daterangepicker-active); +} + +.daterangepicker .drp-buttons { + border-top: 1px solid var(--border-daterangepicker); +} + +.daterangepicker .calendar-table .next span, +.daterangepicker .calendar-table .prev span { + border-color: var(--border-pre-next-month); +} + +.daterangepicker:after { + border-bottom: 6px solid var(--border-daterangepicker-after); +} + +input:-webkit-autofill, +input:-webkit-autofill:hover, +input:-webkit-autofill:focus, +input:-webkit-autofill:active { + -webkit-box-shadow: 0 0 0 30px var(--bg-input-autofill-color) inset !important; + box-shadow: 0 0 0 30px var(--bg-input-autofill-color) inset !important; +} + +input:-webkit-autofill { + -webkit-text-fill-color: var(--text-input-autofill-color) !important; +} + +/* Overide Vendor CSS */ +.multiSelect.inlineBlock button { + margin: 0; +} + +.label-default { + line-height: 11px; +} + +/* Code Script Style */ +.code-script { + background-color: var(--bg-code-script-color); + border-bottom-left-radius: 8px; + border-bottom-right-radius: 8px; + padding: 5px; +} + +/* Tippy */ +/* For triangle arrow (Sharp) */ +/* Tippy */ +/* For triangle arrow (Sharp) */ +.tippy-box[data-placement^='top'] > .tippy-arrow:before { + border-top: 8px solid var(--bg-tooltip-color); +} + +.tippy-box[data-placement^='bottom'] > .tippy-arrow:before { + border-bottom: 8px solid var(--bg-tooltip-color); +} + +.tippy-box[data-placement^='right'] > .tippy-arrow:before { + border-right: 8px solid var(--bg-tooltip-color); +} + +.tippy-box[data-placement^='left'] > .tippy-arrow:before { + border-left: 8px solid var(--bg-tooltip-color); +} + +[theme='highcontrast'] .tippy-box[data-placement^='top'] > .tippy-arrow:before { + border-top: 8px solid var(--white-color); + margin-bottom: -1px; +} + +[theme='highcontrast'] .tippy-box[data-placement^='bottom'] > .tippy-arrow:before { + border-bottom: 8px solid var(--white-color); + margin-top: -1px; +} + +[theme='highcontrast'] .tippy-box[data-placement^='right'] > .tippy-arrow:before { + border-right: 8px solid var(--white-color); + margin-left: -1px; +} + +[theme='highcontrast'] .tippy-box[data-placement^='left'] > .tippy-arrow:before { + border-left: 8px solid var(--white-color); + margin-right: -1px; +} + +/* Sidebar */ +.sidebar .tippy-box { + font-size: 12px; +} + +.sidebar.tippy-box[data-placement^='right'] > .tippy-arrow { + width: 12px; + height: 12px; + margin-left: -1px; +} + +.sidebar.tippy-box[data-placement^='right'] > .tippy-arrow:before { + border-right: 8px solid var(--graphite-600); + border-width: 6px 8px 6px 0; +} + +[theme='highcontrast'] .sidebar.tippy-box[data-placement^='right'] > .tippy-arrow:before { + border-right: 8px solid var(--ui-white); +} + +.sidebar .tippy-content { + white-space: nowrap; +} + +.tippy-content { + padding: 0; +} diff --git a/app/assets/fonts/Inter-VariableFont.ttf b/app/assets/fonts/Inter-VariableFont.ttf new file mode 100644 index 0000000..969a990 Binary files /dev/null and b/app/assets/fonts/Inter-VariableFont.ttf differ diff --git a/app/assets/ico/android-chrome-192x192.png b/app/assets/ico/android-chrome-192x192.png new file mode 100644 index 0000000..236db0e Binary files /dev/null and b/app/assets/ico/android-chrome-192x192.png differ diff --git a/app/assets/ico/android-chrome-256x256.png b/app/assets/ico/android-chrome-256x256.png new file mode 100644 index 0000000..52848e0 Binary files /dev/null and b/app/assets/ico/android-chrome-256x256.png differ diff --git a/app/assets/ico/apple-touch-icon.png b/app/assets/ico/apple-touch-icon.png new file mode 100644 index 0000000..f05e9c1 Binary files /dev/null and b/app/assets/ico/apple-touch-icon.png differ diff --git a/app/assets/ico/beta.svg b/app/assets/ico/beta.svg new file mode 100644 index 0000000..23261c7 --- /dev/null +++ b/app/assets/ico/beta.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/ico/browserconfig.xml b/app/assets/ico/browserconfig.xml new file mode 100644 index 0000000..44751e9 --- /dev/null +++ b/app/assets/ico/browserconfig.xml @@ -0,0 +1,9 @@ + + + + + + #2d89ef + + + diff --git a/app/assets/ico/dataflow-1.svg b/app/assets/ico/dataflow-1.svg new file mode 100644 index 0000000..4e9f506 --- /dev/null +++ b/app/assets/ico/dataflow-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/ico/docker-edge-environment.svg b/app/assets/ico/docker-edge-environment.svg new file mode 100644 index 0000000..b533838 --- /dev/null +++ b/app/assets/ico/docker-edge-environment.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/assets/ico/favicon-16x16.png b/app/assets/ico/favicon-16x16.png new file mode 100644 index 0000000..e224635 Binary files /dev/null and b/app/assets/ico/favicon-16x16.png differ diff --git a/app/assets/ico/favicon-32x32.png b/app/assets/ico/favicon-32x32.png new file mode 100644 index 0000000..2b94e3a Binary files /dev/null and b/app/assets/ico/favicon-32x32.png differ diff --git a/app/assets/ico/favicon.ico b/app/assets/ico/favicon.ico new file mode 100644 index 0000000..d8c1d7f Binary files /dev/null and b/app/assets/ico/favicon.ico differ diff --git a/app/assets/ico/filter-lines.svg b/app/assets/ico/filter-lines.svg new file mode 100644 index 0000000..03081d5 --- /dev/null +++ b/app/assets/ico/filter-lines.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/ico/flask.svg b/app/assets/ico/flask.svg new file mode 100644 index 0000000..23e84d1 --- /dev/null +++ b/app/assets/ico/flask.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/ico/git.svg b/app/assets/ico/git.svg new file mode 100644 index 0000000..32e300e --- /dev/null +++ b/app/assets/ico/git.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/ico/heartbeat-down.svg b/app/assets/ico/heartbeat-down.svg new file mode 100644 index 0000000..eafa69d --- /dev/null +++ b/app/assets/ico/heartbeat-down.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/assets/ico/heartbeat-up.svg b/app/assets/ico/heartbeat-up.svg new file mode 100644 index 0000000..87f7f68 --- /dev/null +++ b/app/assets/ico/heartbeat-up.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/assets/ico/helm.svg b/app/assets/ico/helm.svg new file mode 100644 index 0000000..49e4704 --- /dev/null +++ b/app/assets/ico/helm.svg @@ -0,0 +1 @@ +Helm \ No newline at end of file diff --git a/app/assets/ico/icon_up-to-date.svg b/app/assets/ico/icon_up-to-date.svg new file mode 100644 index 0000000..82fc747 --- /dev/null +++ b/app/assets/ico/icon_up-to-date.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/app/assets/ico/icon_updates-available.svg b/app/assets/ico/icon_updates-available.svg new file mode 100644 index 0000000..2b653c2 --- /dev/null +++ b/app/assets/ico/icon_updates-available.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/app/assets/ico/icon_updates-unknown.svg b/app/assets/ico/icon_updates-unknown.svg new file mode 100644 index 0000000..d64bb36 --- /dev/null +++ b/app/assets/ico/icon_updates-unknown.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/ico/kube.svg b/app/assets/ico/kube.svg new file mode 100644 index 0000000..17c2765 --- /dev/null +++ b/app/assets/ico/kube.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/ico/kubernetes-edge-environment.svg b/app/assets/ico/kubernetes-edge-environment.svg new file mode 100644 index 0000000..880d85d --- /dev/null +++ b/app/assets/ico/kubernetes-edge-environment.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/app/assets/ico/laptop-code.svg b/app/assets/ico/laptop-code.svg new file mode 100644 index 0000000..18975e7 --- /dev/null +++ b/app/assets/ico/laptop-code.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/ico/ldap.svg b/app/assets/ico/ldap.svg new file mode 100644 index 0000000..4ce73f7 --- /dev/null +++ b/app/assets/ico/ldap.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/assets/ico/linux.svg b/app/assets/ico/linux.svg new file mode 100644 index 0000000..cf9c866 --- /dev/null +++ b/app/assets/ico/linux.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/ico/lock-2.svg b/app/assets/ico/lock-2.svg new file mode 100644 index 0000000..cfcaa8c --- /dev/null +++ b/app/assets/ico/lock-2.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/app/assets/ico/lock.svg b/app/assets/ico/lock.svg new file mode 100644 index 0000000..d28402e --- /dev/null +++ b/app/assets/ico/lock.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/assets/ico/logomark.svg b/app/assets/ico/logomark.svg new file mode 100644 index 0000000..140c1b4 --- /dev/null +++ b/app/assets/ico/logomark.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/app/assets/ico/manifest.json b/app/assets/ico/manifest.json new file mode 100644 index 0000000..f70fd66 --- /dev/null +++ b/app/assets/ico/manifest.json @@ -0,0 +1,18 @@ +{ + "name": "Portainer", + "icons": [ + { + "src": "ico/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "ico/android-chrome-256x256.png", + "sizes": "256x256", + "type": "image/png" + } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone" +} diff --git a/app/assets/ico/memory.svg b/app/assets/ico/memory.svg new file mode 100644 index 0000000..18ec358 --- /dev/null +++ b/app/assets/ico/memory.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/ico/mstile-150x150.png b/app/assets/ico/mstile-150x150.png new file mode 100644 index 0000000..f483745 Binary files /dev/null and b/app/assets/ico/mstile-150x150.png differ diff --git a/app/assets/ico/oauth.svg b/app/assets/ico/oauth.svg new file mode 100644 index 0000000..09faac7 --- /dev/null +++ b/app/assets/ico/oauth.svg @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/assets/ico/object-group.svg b/app/assets/ico/object-group.svg new file mode 100644 index 0000000..7cdd808 --- /dev/null +++ b/app/assets/ico/object-group.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/ico/placeholder.svg b/app/assets/ico/placeholder.svg new file mode 100644 index 0000000..e71f462 --- /dev/null +++ b/app/assets/ico/placeholder.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/app/assets/ico/plug.svg b/app/assets/ico/plug.svg new file mode 100644 index 0000000..a43d6ff --- /dev/null +++ b/app/assets/ico/plug.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/ico/podman-edge-environment.svg b/app/assets/ico/podman-edge-environment.svg new file mode 100644 index 0000000..1157684 --- /dev/null +++ b/app/assets/ico/podman-edge-environment.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/app/assets/ico/restore-window.svg b/app/assets/ico/restore-window.svg new file mode 100644 index 0000000..55e7379 --- /dev/null +++ b/app/assets/ico/restore-window.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/ico/route.svg b/app/assets/ico/route.svg new file mode 100644 index 0000000..ed08380 --- /dev/null +++ b/app/assets/ico/route.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/ico/safari-pinned-tab.svg b/app/assets/ico/safari-pinned-tab.svg new file mode 100644 index 0000000..d0509a5 --- /dev/null +++ b/app/assets/ico/safari-pinned-tab.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/assets/ico/sort.svg b/app/assets/ico/sort.svg new file mode 100644 index 0000000..337094b --- /dev/null +++ b/app/assets/ico/sort.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/ico/subscription.svg b/app/assets/ico/subscription.svg new file mode 100644 index 0000000..0e8c96c --- /dev/null +++ b/app/assets/ico/subscription.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/ico/user-lock.svg b/app/assets/ico/user-lock.svg new file mode 100644 index 0000000..afc79bf --- /dev/null +++ b/app/assets/ico/user-lock.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/app/assets/ico/vendor/akamai.svg b/app/assets/ico/vendor/akamai.svg new file mode 100644 index 0000000..5a08c58 --- /dev/null +++ b/app/assets/ico/vendor/akamai.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/ico/vendor/aws.svg b/app/assets/ico/vendor/aws.svg new file mode 100644 index 0000000..4715937 --- /dev/null +++ b/app/assets/ico/vendor/aws.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + diff --git a/app/assets/ico/vendor/azure.svg b/app/assets/ico/vendor/azure.svg new file mode 100644 index 0000000..f60d954 --- /dev/null +++ b/app/assets/ico/vendor/azure.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/assets/ico/vendor/civo.svg b/app/assets/ico/vendor/civo.svg new file mode 100644 index 0000000..96f90d6 --- /dev/null +++ b/app/assets/ico/vendor/civo.svg @@ -0,0 +1,24 @@ + + + + + + + + + + diff --git a/app/assets/ico/vendor/digitalocean.svg b/app/assets/ico/vendor/digitalocean.svg new file mode 100644 index 0000000..5a81f24 --- /dev/null +++ b/app/assets/ico/vendor/digitalocean.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/ico/vendor/docker-compose.svg b/app/assets/ico/vendor/docker-compose.svg new file mode 100644 index 0000000..27559f0 --- /dev/null +++ b/app/assets/ico/vendor/docker-compose.svg @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/assets/ico/vendor/docker-icon.svg b/app/assets/ico/vendor/docker-icon.svg new file mode 100644 index 0000000..0e2cbe5 --- /dev/null +++ b/app/assets/ico/vendor/docker-icon.svg @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/assets/ico/vendor/docker.svg b/app/assets/ico/vendor/docker.svg new file mode 100644 index 0000000..083a3e3 --- /dev/null +++ b/app/assets/ico/vendor/docker.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/app/assets/ico/vendor/ecr.svg b/app/assets/ico/vendor/ecr.svg new file mode 100644 index 0000000..5119fbc --- /dev/null +++ b/app/assets/ico/vendor/ecr.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/assets/ico/vendor/github.svg b/app/assets/ico/vendor/github.svg new file mode 100644 index 0000000..a8d1174 --- /dev/null +++ b/app/assets/ico/vendor/github.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/ico/vendor/gitlab.svg b/app/assets/ico/vendor/gitlab.svg new file mode 100644 index 0000000..5e5cb91 --- /dev/null +++ b/app/assets/ico/vendor/gitlab.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/assets/ico/vendor/google.svg b/app/assets/ico/vendor/google.svg new file mode 100644 index 0000000..c652a5a --- /dev/null +++ b/app/assets/ico/vendor/google.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/assets/ico/vendor/googlecloud.svg b/app/assets/ico/vendor/googlecloud.svg new file mode 100644 index 0000000..b8478f1 --- /dev/null +++ b/app/assets/ico/vendor/googlecloud.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/assets/ico/vendor/helm.svg b/app/assets/ico/vendor/helm.svg new file mode 100644 index 0000000..fbc279f --- /dev/null +++ b/app/assets/ico/vendor/helm.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/assets/ico/vendor/install-kubernetes.svg b/app/assets/ico/vendor/install-kubernetes.svg new file mode 100644 index 0000000..075065f --- /dev/null +++ b/app/assets/ico/vendor/install-kubernetes.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/assets/ico/vendor/internal.svg b/app/assets/ico/vendor/internal.svg new file mode 100644 index 0000000..5766e13 --- /dev/null +++ b/app/assets/ico/vendor/internal.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/ico/vendor/kaas-icon.svg b/app/assets/ico/vendor/kaas-icon.svg new file mode 100644 index 0000000..37d427e --- /dev/null +++ b/app/assets/ico/vendor/kaas-icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/assets/ico/vendor/kubernetes.svg b/app/assets/ico/vendor/kubernetes.svg new file mode 100644 index 0000000..2eb65a2 --- /dev/null +++ b/app/assets/ico/vendor/kubernetes.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/app/assets/ico/vendor/kubesolo.svg b/app/assets/ico/vendor/kubesolo.svg new file mode 100644 index 0000000..ecda7b7 --- /dev/null +++ b/app/assets/ico/vendor/kubesolo.svg @@ -0,0 +1,985 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/assets/ico/vendor/microsoft-icon.svg b/app/assets/ico/vendor/microsoft-icon.svg new file mode 100644 index 0000000..4a3c87d --- /dev/null +++ b/app/assets/ico/vendor/microsoft-icon.svg @@ -0,0 +1,10 @@ + + + + + + + diff --git a/app/assets/ico/vendor/microsoft.svg b/app/assets/ico/vendor/microsoft.svg new file mode 100644 index 0000000..92eed2d --- /dev/null +++ b/app/assets/ico/vendor/microsoft.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/assets/ico/vendor/openldap.svg b/app/assets/ico/vendor/openldap.svg new file mode 100644 index 0000000..71f61b2 --- /dev/null +++ b/app/assets/ico/vendor/openldap.svg @@ -0,0 +1,550 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + Openclipart + + + Caterpillar 4 LDAP + 2012-04-08T15:30:21 + The OpenLDAP caterpillar mascot . This is a Vector inspired by the "low res" bitmap at the homepage (as of 2012/04) + https://openclipart.org/detail/169427/caterpillar-4-ldap-by-dynnamitt + + + dynnamitt + + + + + LDAP + caterpillar + green + happy + + + + + + + + + + + diff --git a/app/assets/ico/vendor/podman-icon.svg b/app/assets/ico/vendor/podman-icon.svg new file mode 100644 index 0000000..05d3144 --- /dev/null +++ b/app/assets/ico/vendor/podman-icon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/app/assets/ico/vendor/podman.svg b/app/assets/ico/vendor/podman.svg new file mode 100644 index 0000000..4b0af51 --- /dev/null +++ b/app/assets/ico/vendor/podman.svg @@ -0,0 +1 @@ + diff --git a/app/assets/ico/vendor/proget.svg b/app/assets/ico/vendor/proget.svg new file mode 100644 index 0000000..8daaec0 --- /dev/null +++ b/app/assets/ico/vendor/proget.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/assets/ico/vendor/quay.svg b/app/assets/ico/vendor/quay.svg new file mode 100644 index 0000000..3de9c16 --- /dev/null +++ b/app/assets/ico/vendor/quay.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/assets/ico/wizard/agent.svg b/app/assets/ico/wizard/agent.svg new file mode 100644 index 0000000..31274e7 --- /dev/null +++ b/app/assets/ico/wizard/agent.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/app/assets/ico/wizard/api.svg b/app/assets/ico/wizard/api.svg new file mode 100644 index 0000000..2ed7748 --- /dev/null +++ b/app/assets/ico/wizard/api.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/assets/ico/wizard/edge-agent.svg b/app/assets/ico/wizard/edge-agent.svg new file mode 100644 index 0000000..a3e45f3 --- /dev/null +++ b/app/assets/ico/wizard/edge-agent.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/app/assets/ico/wizard/import.svg b/app/assets/ico/wizard/import.svg new file mode 100644 index 0000000..570da8e --- /dev/null +++ b/app/assets/ico/wizard/import.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/app/assets/ico/wizard/socket.svg b/app/assets/ico/wizard/socket.svg new file mode 100644 index 0000000..b3974e2 --- /dev/null +++ b/app/assets/ico/wizard/socket.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/assets/images/extensions_overview_diagram.png b/app/assets/images/extensions_overview_diagram.png new file mode 100644 index 0000000..91f7ee1 Binary files /dev/null and b/app/assets/images/extensions_overview_diagram.png differ diff --git a/app/assets/images/icon-error.svg b/app/assets/images/icon-error.svg new file mode 100644 index 0000000..564b5fa --- /dev/null +++ b/app/assets/images/icon-error.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/app/assets/images/icon-success.svg b/app/assets/images/icon-success.svg new file mode 100644 index 0000000..890fe91 --- /dev/null +++ b/app/assets/images/icon-success.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/app/assets/images/icon-warning.svg b/app/assets/images/icon-warning.svg new file mode 100644 index 0000000..8832458 --- /dev/null +++ b/app/assets/images/icon-warning.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/app/assets/images/ingress-explanatory-diagram.png b/app/assets/images/ingress-explanatory-diagram.png new file mode 100644 index 0000000..20b5599 Binary files /dev/null and b/app/assets/images/ingress-explanatory-diagram.png differ diff --git a/app/assets/images/kubernetes_endpoint.png b/app/assets/images/kubernetes_endpoint.png new file mode 100644 index 0000000..3a85817 Binary files /dev/null and b/app/assets/images/kubernetes_endpoint.png differ diff --git a/app/assets/images/logo_alt.svg b/app/assets/images/logo_alt.svg new file mode 100644 index 0000000..8d254e4 --- /dev/null +++ b/app/assets/images/logo_alt.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/app/assets/images/logo_alt_black.svg b/app/assets/images/logo_alt_black.svg new file mode 100644 index 0000000..d9243b4 --- /dev/null +++ b/app/assets/images/logo_alt_black.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/app/assets/images/portainer-github-banner.png b/app/assets/images/portainer-github-banner.png new file mode 100644 index 0000000..75871e3 Binary files /dev/null and b/app/assets/images/portainer-github-banner.png differ diff --git a/app/assets/images/purple-gradient.svg b/app/assets/images/purple-gradient.svg new file mode 100644 index 0000000..0b3bc71 --- /dev/null +++ b/app/assets/images/purple-gradient.svg @@ -0,0 +1,522 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/assets/images/support_1.png b/app/assets/images/support_1.png new file mode 100644 index 0000000..9b92a10 Binary files /dev/null and b/app/assets/images/support_1.png differ diff --git a/app/assets/images/support_2.png b/app/assets/images/support_2.png new file mode 100644 index 0000000..8bac085 Binary files /dev/null and b/app/assets/images/support_2.png differ diff --git a/app/azure/index.ts b/app/azure/index.ts new file mode 100644 index 0000000..0034565 --- /dev/null +++ b/app/azure/index.ts @@ -0,0 +1,93 @@ +import angular from 'angular'; +import { StateRegistry, StateService } from '@uirouter/angularjs'; + +import { Environment } from '@/react/portainer/environments/types'; +import { notifyError } from '@/portainer/services/notifications'; +import { StateManager } from '@/portainer/services/types'; + +import { reactModule } from './react'; + +export const azureModule = angular + .module('portainer.azure', [reactModule]) + .config(config).name; + +/* @ngInject */ +function config($stateRegistryProvider: StateRegistry) { + const azure = { + name: 'azure', + url: '/azure', + parent: 'endpoint', + abstract: true, + onEnter: /* @ngInject */ function onEnter( + $async: (fn: () => Promise) => Promise, + $state: StateService, + endpoint: Environment, + StateManager: StateManager + ) { + return $async(async () => { + if (endpoint.Type !== 3) { + $state.go('portainer.home'); + return; + } + try { + await StateManager.updateEndpointState(endpoint); + } catch (e) { + notifyError('Failed loading environment', e as Error); + $state.go('portainer.home', {}, { reload: true }); + } + }); + }, + }; + + const containerInstances = { + name: 'azure.containerinstances', + url: '/containerinstances', + views: { + 'content@': { + component: 'containerInstancesView', + }, + }, + data: { + docs: '/user/aci/containers', + }, + }; + + const containerInstance = { + name: 'azure.containerinstances.container', + url: '/:id', + views: { + 'content@': { + component: 'containerInstanceView', + }, + }, + }; + + const containerInstanceCreation = { + name: 'azure.containerinstances.new', + url: '/new/', + views: { + 'content@': { + component: 'createContainerInstanceView', + }, + }, + }; + + const dashboard = { + name: 'azure.dashboard', + url: '/dashboard', + views: { + 'content@': { + component: 'dashboardView', + }, + }, + data: { + docs: '/user/aci/dashboard', + }, + }; + + $stateRegistryProvider.register(azure); + $stateRegistryProvider.register(containerInstances); + $stateRegistryProvider.register(containerInstance); + $stateRegistryProvider.register(containerInstanceCreation); + $stateRegistryProvider.register(dashboard); +} diff --git a/app/azure/react/components/index.ts b/app/azure/react/components/index.ts new file mode 100644 index 0000000..de9063e --- /dev/null +++ b/app/azure/react/components/index.ts @@ -0,0 +1,6 @@ +import angular from 'angular'; + +export const componentsModule = angular.module( + 'portainer.azure.react.components', + [] +).name; diff --git a/app/azure/react/index.ts b/app/azure/react/index.ts new file mode 100644 index 0000000..f84c1f7 --- /dev/null +++ b/app/azure/react/index.ts @@ -0,0 +1,9 @@ +import angular from 'angular'; + +import { componentsModule } from './components'; +import { viewsModule } from './views'; + +export const reactModule = angular.module('portainer.azure.react', [ + viewsModule, + componentsModule, +]).name; diff --git a/app/azure/react/views/index.ts b/app/azure/react/views/index.ts new file mode 100644 index 0000000..44c9f81 --- /dev/null +++ b/app/azure/react/views/index.ts @@ -0,0 +1,29 @@ +import angular from 'angular'; + +import { r2a } from '@/react-tools/react2angular'; +import { CreateView } from '@/react/azure/container-instances/CreateView'; +import { ItemView } from '@/react/azure/container-instances/ItemView'; +import { ListView } from '@/react/azure/container-instances/ListView'; +import { DashboardView } from '@/react/azure/DashboardView'; +import { withCurrentUser } from '@/react-tools/withCurrentUser'; +import { withReactQuery } from '@/react-tools/withReactQuery'; +import { withUIRouter } from '@/react-tools/withUIRouter'; + +export const viewsModule = angular + .module('portainer.azure.react.views', []) + .component( + 'containerInstanceView', + r2a(withUIRouter(withReactQuery(withCurrentUser(ItemView))), []) + ) + .component( + 'createContainerInstanceView', + r2a(withUIRouter(withReactQuery(withCurrentUser(CreateView))), []) + ) + .component( + 'containerInstancesView', + r2a(withUIRouter(withReactQuery(withCurrentUser(ListView))), []) + ) + .component( + 'dashboardView', + r2a(withUIRouter(withReactQuery(withCurrentUser(DashboardView))), []) + ).name; diff --git a/app/config.js b/app/config.js new file mode 100644 index 0000000..fbad8d4 --- /dev/null +++ b/app/config.js @@ -0,0 +1,40 @@ +import { agentInterceptor } from '@/react/portainer/services/axios/axios'; +import { dispatchCacheRefreshEventIfNeeded } from './portainer/services/http-request.helper'; + +/* @ngInject */ +export function configApp($urlRouterProvider, $httpProvider, localStorageServiceProvider, $uibTooltipProvider, $compileProvider, cfpLoadingBarProvider) { + if (process.env.NODE_ENV === 'testing') { + $compileProvider.debugInfoEnabled(false); + } + + // ask to clear cache on mutation + $httpProvider.interceptors.push(() => ({ + request: (reqConfig) => { + dispatchCacheRefreshEventIfNeeded(reqConfig); + return reqConfig; + }, + })); + + localStorageServiceProvider.setPrefix('portainer'); + + $httpProvider.defaults.headers.post['Content-Type'] = 'application/json'; + $httpProvider.defaults.headers.put['Content-Type'] = 'application/json'; + $httpProvider.defaults.headers.patch['Content-Type'] = 'application/json'; + + $httpProvider.interceptors.push(() => ({ + request: agentInterceptor, + })); + + $uibTooltipProvider.setTriggers({ + mouseenter: 'mouseleave', + click: 'click', + focus: 'blur', + outsideClick: 'outsideClick', + }); + + cfpLoadingBarProvider.includeSpinner = false; + cfpLoadingBarProvider.parentSelector = '#loadingbar-placeholder'; + cfpLoadingBarProvider.latencyThreshold = 600; + + $urlRouterProvider.otherwise('/auth'); +} diff --git a/app/constants.ts b/app/constants.ts new file mode 100644 index 0000000..e61172d --- /dev/null +++ b/app/constants.ts @@ -0,0 +1,28 @@ +// try to declare constant where it's needed, not in a global file +export const API_ENDPOINT_AUTH = 'api/auth'; +export const API_ENDPOINT_BACKUP = 'api/backup'; +export const API_ENDPOINT_CUSTOM_TEMPLATES = 'api/custom_templates'; +export const API_ENDPOINT_EDGE_GROUPS = 'api/edge_groups'; +export const API_ENDPOINT_EDGE_JOBS = 'api/edge_jobs'; +export const API_ENDPOINT_EDGE_STACKS = 'api/edge_stacks'; +export const API_ENDPOINT_ENDPOINTS = 'api/endpoints'; +export const API_ENDPOINT_ENDPOINT_GROUPS = 'api/endpoint_groups'; +export const API_ENDPOINT_KUBERNETES = 'api/kubernetes'; +export const API_ENDPOINT_MOTD = 'api/motd'; +export const API_ENDPOINT_REGISTRIES = 'api/registries'; +export const API_ENDPOINT_RESOURCE_CONTROLS = 'api/resource_controls'; +export const API_ENDPOINT_SETTINGS = 'api/settings'; +export const API_ENDPOINT_STACKS = 'api/stacks'; +export const API_ENDPOINT_SUPPORT = 'api/support'; +export const API_ENDPOINT_USERS = 'api/users'; +export const API_ENDPOINT_TAGS = 'api/tags'; +export const API_ENDPOINT_TEAMS = 'api/teams'; +export const API_ENDPOINT_TEAM_MEMBERSHIPS = 'api/team_memberships'; +export const API_ENDPOINT_TEMPLATES = 'api/templates'; +export const API_ENDPOINT_WEBHOOKS = 'api/webhooks'; +export const PAGINATION_MAX_ITEMS = 10; +export const APPLICATION_CACHE_VALIDITY = 3600; +export const CONSOLE_COMMANDS_LABEL_PREFIX = 'io.portainer.commands.'; +export const PREDEFINED_NETWORKS = ['host', 'bridge', 'ingress', 'nat', 'none']; +export const PORTAINER_FADEOUT = 1500; +export const STACK_NAME_VALIDATION_REGEX = '^[-_a-z0-9]+$'; diff --git a/app/docker/__module.js b/app/docker/__module.js new file mode 100644 index 0000000..8b20235 --- /dev/null +++ b/app/docker/__module.js @@ -0,0 +1,689 @@ +import angular from 'angular'; + +import { PortainerEndpointTypes } from '@/portainer/models/endpoint/models'; + +import { EnvironmentStatus } from '@/react/portainer/environments/types'; + +import { reactModule } from './react'; + +angular.module('portainer.docker', ['portainer.app', reactModule]).config([ + '$stateRegistryProvider', + function ($stateRegistryProvider) { + 'use strict'; + + var docker = { + name: 'docker', + parent: 'endpoint', + url: '/docker', + abstract: true, + onEnter: /* @ngInject */ function onEnter(endpoint, $async, $state, EndpointService, Notifications, StateManager, SystemService, EndpointProvider) { + return $async(async () => { + const dockerTypes = [PortainerEndpointTypes.DockerEnvironment, PortainerEndpointTypes.AgentOnDockerEnvironment, PortainerEndpointTypes.EdgeAgentOnDockerEnvironment]; + + if (!dockerTypes.includes(endpoint.Type)) { + $state.go('portainer.home'); + return; + } + + try { + const { status, error } = await checkEndpointStatus(endpoint); + + if (endpoint.Type !== PortainerEndpointTypes.EdgeAgentOnDockerEnvironment) { + await updateEndpointStatus(endpoint, status); + } + endpoint.Status = status; + + if (status === EnvironmentStatus.Down) { + throw error || new Error(`The environment named ${endpoint.Name} is unreachable.`); + } + + await StateManager.updateEndpointState(endpoint); + } catch (e) { + let params = {}; + + if (endpoint.Type == PortainerEndpointTypes.EdgeAgentOnDockerEnvironment) { + params = { redirect: true, environmentId: endpoint.Id, environmentName: endpoint.Name, route: 'docker.dashboard' }; + } else { + EndpointProvider.clean(); + Notifications.error('Failed loading environment', e); + } + $state.go('portainer.home', params, { reload: true, inherit: false }); + return false; + } + + async function checkEndpointStatus(endpoint) { + try { + await SystemService.ping(endpoint.Id); + return { status: EnvironmentStatus.Up }; + } catch (e) { + return { status: EnvironmentStatus.Down, error: e }; + } + } + + async function updateEndpointStatus(endpoint, status) { + if (endpoint.Status === status) { + return; + } + await EndpointService.updateEndpoint(endpoint.Id, { Status: status }); + } + }); + }, + }; + + var configs = { + name: 'docker.configs', + url: '/configs', + views: { + 'content@': { + component: 'configsListView', + }, + }, + data: { + docs: '/user/docker/configs', + }, + }; + + var config = { + name: 'docker.configs.config', + url: '/:id', + views: { + 'content@': { + templateUrl: './views/configs/edit/config.html', + controller: 'ConfigController', + }, + }, + }; + + var configCreation = { + name: 'docker.configs.new', + url: '/new?id', + views: { + 'content@': { + templateUrl: './views/configs/create/createconfig.html', + controller: 'CreateConfigController', + controllerAs: 'ctrl', + }, + }, + data: { + docs: '/user/docker/configs/add', + }, + }; + + const customTemplates = { + name: 'docker.templates.custom', + url: '/custom', + + views: { + 'content@': { + component: 'customTemplatesView', + }, + }, + data: { + docs: '/user/docker/templates/custom', + }, + }; + + const customTemplatesNew = { + name: 'docker.templates.custom.new', + url: '/new?fileContent&appTemplateId&type', + + views: { + 'content@': { + component: 'createCustomTemplatesView', + }, + }, + }; + + const customTemplatesEdit = { + name: 'docker.templates.custom.edit', + url: '/:id', + + views: { + 'content@': { + component: 'editCustomTemplatesView', + }, + }, + }; + + var dashboard = { + name: 'docker.dashboard', + url: '/dashboard', + views: { + 'content@': { + component: 'dockerDashboardView', + }, + }, + data: { + docs: '/user/docker/dashboard', + }, + }; + + var host = { + name: 'docker.host', + url: '/host', + views: { + 'content@': { + component: 'hostView', + }, + }, + data: { + docs: '/user/docker/host/details', + }, + }; + + var hostBrowser = { + name: 'docker.host.browser', + url: '/browser', + views: { + 'content@': { + component: 'hostBrowserView', + }, + }, + }; + + var events = { + name: 'docker.events', + url: '/events', + views: { + 'content@': { + component: 'eventsListView', + }, + }, + data: { + docs: '/user/docker/events', + }, + }; + + var images = { + name: 'docker.images', + url: '/images', + views: { + 'content@': { + component: 'imagesListView', + }, + }, + data: { + docs: '/user/docker/images', + }, + }; + + var image = { + name: 'docker.images.image', + url: '/:id?nodeName', + views: { + 'content@': { + templateUrl: './views/images/edit/image.html', + controller: 'ImageController', + }, + }, + }; + + var imageBuild = { + name: 'docker.images.build', + url: '/build', + views: { + 'content@': { + templateUrl: './views/images/build/buildimage.html', + controller: 'BuildImageController', + }, + }, + data: { + docs: '/user/docker/images/build', + }, + }; + + var imageImport = { + name: 'docker.images.import', + url: '/import', + views: { + 'content@': { + templateUrl: './views/images/import/importimage.html', + controller: 'ImportImageController', + }, + }, + data: { + docs: '/user/docker/images/import', + }, + }; + + var networks = { + name: 'docker.networks', + url: '/networks', + views: { + 'content@': { + component: 'networksListView', + }, + }, + data: { + docs: '/user/docker/networks', + }, + }; + + var network = { + name: 'docker.networks.network', + url: '/:id?nodeName', + views: { + 'content@': { + component: 'networkDetailsView', + }, + }, + }; + + var networkCreation = { + name: 'docker.networks.new', + url: '/new', + views: { + 'content@': { + templateUrl: './views/networks/create/createnetwork.html', + controller: 'CreateNetworkController', + }, + }, + data: { + docs: '/user/docker/networks/add', + }, + }; + + var nodes = { + name: 'docker.nodes', + url: '/nodes', + abstract: true, + data: { + docs: '/user/docker/swarm/details', + }, + }; + + var node = { + name: 'docker.nodes.node', + url: '/:id', + views: { + 'content@': { + component: 'nodeDetailsView', + }, + }, + }; + + var nodeBrowser = { + name: 'docker.nodes.node.browse', + url: '/browse', + views: { + 'content@': { + component: 'nodeBrowserView', + }, + }, + }; + + var secrets = { + name: 'docker.secrets', + url: '/secrets', + views: { + 'content@': { + templateUrl: './views/secrets/secrets.html', + controller: 'SecretsController', + }, + }, + data: { + docs: '/user/docker/secrets', + }, + }; + + var secret = { + name: 'docker.secrets.secret', + url: '/:id', + views: { + 'content@': { + templateUrl: './views/secrets/edit/secret.html', + controller: 'SecretController', + }, + }, + }; + + var secretCreation = { + name: 'docker.secrets.new', + url: '/new', + views: { + 'content@': { + templateUrl: './views/secrets/create/createsecret.html', + controller: 'CreateSecretController', + }, + }, + data: { + docs: '/user/docker/secrets/add', + }, + }; + + var services = { + name: 'docker.services', + url: '/services', + views: { + 'content@': { + templateUrl: './views/services/services.html', + controller: 'ServicesController', + }, + }, + data: { + docs: '/user/docker/services', + }, + }; + + var service = { + name: 'docker.services.service', + url: '/:id', + views: { + 'content@': { + templateUrl: './views/services/edit/service.html', + controller: 'ServiceController', + }, + }, + }; + + var serviceCreation = { + name: 'docker.services.new', + url: '/new', + views: { + 'content@': { + templateUrl: './views/services/create/createservice.html', + controller: 'CreateServiceController', + }, + }, + data: { + docs: '/user/docker/stacks/add', + }, + }; + + var serviceLogs = { + name: 'docker.services.service.logs', + url: '/logs', + views: { + 'content@': { + templateUrl: './views/services/logs/servicelogs.html', + controller: 'ServiceLogsController', + }, + }, + }; + + var stacks = { + name: 'docker.stacks', + url: '/stacks', + views: { + 'content@': { + templateUrl: '~@/portainer/views/stacks/stacks.html', + controller: 'StacksController', + }, + }, + data: { + docs: '/user/docker/stacks', + }, + }; + + var stack = { + name: 'docker.stacks.stack', + url: '/:name?id&type®ular&external&orphaned&orphanedRunning&tab', + views: { + 'content@': { + component: 'stackItemView', + }, + }, + }; + + var stackContainer = { + name: 'docker.stacks.stack.container', + url: '/:id?nodeName', + views: { + 'content@': { + component: 'containerItemView', + }, + }, + }; + + var stackCreation = { + name: 'docker.stacks.newstack', + url: '/newstack', + views: { + 'content@': { + component: 'createStackView', + }, + }, + }; + + var swarm = { + name: 'docker.swarm', + url: '/swarm', + views: { + 'content@': { + templateUrl: './views/swarm/swarm.html', + controller: 'SwarmController', + }, + }, + data: { + docs: '/user/docker/swarm/details', + }, + }; + + var swarmVisualizer = { + name: 'docker.swarm.visualizer', + url: '/visualizer', + views: { + 'content@': { + templateUrl: './views/swarm/visualizer/swarmvisualizer.html', + controller: 'SwarmVisualizerController', + }, + }, + data: { + docs: '/user/docker/swarm/cluster-visualizer', + }, + }; + + var tasks = { + name: 'docker.tasks', + url: '/tasks', + abstract: true, + }; + + var task = { + name: 'docker.tasks.task', + url: '/:id', + views: { + 'content@': { + templateUrl: './views/tasks/edit/task.html', + controller: 'TaskController', + }, + }, + }; + + var taskLogs = { + name: 'docker.tasks.task.logs', + url: '/logs', + views: { + 'content@': { + templateUrl: './views/tasks/logs/tasklogs.html', + controller: 'TaskLogsController', + }, + }, + }; + + var templates = { + name: 'docker.templates', + url: '/templates?template', + views: { + 'content@': { + component: 'appTemplatesView', + }, + }, + data: { + docs: '/user/docker/templates/application', + }, + }; + + var volumes = { + name: 'docker.volumes', + url: '/volumes', + views: { + 'content@': { + templateUrl: './views/volumes/volumes.html', + controller: 'VolumesController', + }, + }, + data: { + docs: '/user/docker/volumes', + }, + }; + + var volume = { + name: 'docker.volumes.volume', + url: '/:id?nodeName', + views: { + 'content@': { + templateUrl: './views/volumes/edit/volume.html', + controller: 'VolumeController', + }, + }, + }; + + var volumeBrowse = { + name: 'docker.volumes.volume.browse', + url: '/browse', + views: { + 'content@': { + templateUrl: './views/volumes/browse/browsevolume.html', + controller: 'BrowseVolumeController', + }, + }, + }; + + var volumeCreation = { + name: 'docker.volumes.new', + url: '/new', + views: { + 'content@': { + templateUrl: './views/volumes/create/createvolume.html', + controller: 'CreateVolumeController', + }, + }, + data: { + docs: '/user/docker/volumes/add', + }, + }; + + const dockerFeaturesConfiguration = { + name: 'docker.host.featuresConfiguration', + url: '/feat-config', + views: { + 'content@': { + component: 'dockerFeaturesConfigurationView', + }, + }, + data: { + docs: '/user/docker/host/setup', + }, + }; + + const swarmFeaturesConfiguration = { + name: 'docker.swarm.featuresConfiguration', + url: '/feat-config', + views: { + 'content@': { + component: 'dockerFeaturesConfigurationView', + }, + }, + data: { + docs: '/user/docker/swarm/setup', + }, + }; + + const dockerRegistries = { + name: 'docker.host.registries', + url: '/registries', + views: { + 'content@': { + component: 'environmentRegistriesView', + }, + }, + data: { + docs: '/user/docker/host/registries', + }, + }; + + const swarmRegistries = { + name: 'docker.swarm.registries', + url: '/registries', + views: { + 'content@': { + component: 'environmentRegistriesView', + }, + }, + data: { + docs: '/user/docker/swarm/registries', + }, + }; + + const dockerRegistryAccess = { + name: 'docker.host.registries.access', + url: '/:id/access', + views: { + 'content@': { + component: 'dockerRegistryAccessView', + }, + }, + }; + + const swarmRegistryAccess = { + name: 'docker.swarm.registries.access', + url: '/:id/access', + views: { + 'content@': { + component: 'dockerRegistryAccessView', + }, + }, + }; + + $stateRegistryProvider.register(configs); + $stateRegistryProvider.register(config); + $stateRegistryProvider.register(configCreation); + + $stateRegistryProvider.register(customTemplates); + $stateRegistryProvider.register(customTemplatesNew); + $stateRegistryProvider.register(customTemplatesEdit); + $stateRegistryProvider.register(docker); + $stateRegistryProvider.register(dashboard); + $stateRegistryProvider.register(host); + $stateRegistryProvider.register(hostBrowser); + $stateRegistryProvider.register(events); + $stateRegistryProvider.register(images); + $stateRegistryProvider.register(image); + $stateRegistryProvider.register(imageBuild); + $stateRegistryProvider.register(imageImport); + $stateRegistryProvider.register(networks); + $stateRegistryProvider.register(network); + $stateRegistryProvider.register(networkCreation); + $stateRegistryProvider.register(nodes); + $stateRegistryProvider.register(node); + $stateRegistryProvider.register(nodeBrowser); + $stateRegistryProvider.register(secrets); + $stateRegistryProvider.register(secret); + $stateRegistryProvider.register(secretCreation); + $stateRegistryProvider.register(services); + $stateRegistryProvider.register(service); + $stateRegistryProvider.register(serviceCreation); + $stateRegistryProvider.register(serviceLogs); + $stateRegistryProvider.register(stacks); + $stateRegistryProvider.register(stack); + $stateRegistryProvider.register(stackContainer); + $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); + $stateRegistryProvider.register(volumeCreation); + $stateRegistryProvider.register(dockerFeaturesConfiguration); + $stateRegistryProvider.register(swarmFeaturesConfiguration); + $stateRegistryProvider.register(dockerRegistries); + $stateRegistryProvider.register(swarmRegistries); + $stateRegistryProvider.register(dockerRegistryAccess); + $stateRegistryProvider.register(swarmRegistryAccess); + }, +]); diff --git a/app/docker/components/host-overview/host-overview.html b/app/docker/components/host-overview/host-overview.html new file mode 100644 index 0000000..f193d80 --- /dev/null +++ b/app/docker/components/host-overview/host-overview.html @@ -0,0 +1,15 @@ + + + + + + + + + + diff --git a/app/docker/components/host-overview/host-overview.js b/app/docker/components/host-overview/host-overview.js new file mode 100644 index 0000000..5798994 --- /dev/null +++ b/app/docker/components/host-overview/host-overview.js @@ -0,0 +1,17 @@ +angular.module('portainer.docker').component('hostOverview', { + templateUrl: './host-overview.html', + bindings: { + hostDetails: '<', + engineDetails: '<', + devices: '<', + disks: '<', + isAgent: '<', + agentApiVersion: '<', + refreshUrl: '@', + browseUrl: '@', + hostFeaturesEnabled: '<', + isAdmin: '<', + endpointId: '<', + }, + transclude: true, +}); diff --git a/app/docker/components/host-view-panels/devices-panel/devices-panel.html b/app/docker/components/host-view-panels/devices-panel/devices-panel.html new file mode 100644 index 0000000..63bda40 --- /dev/null +++ b/app/docker/components/host-view-panels/devices-panel/devices-panel.html @@ -0,0 +1,32 @@ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
NameVendor
{{ device.Name }}{{ device.Vendor }}
Loading...
Failed to load devices.
No device available.
+
+
+
+
diff --git a/app/docker/components/host-view-panels/devices-panel/devices-panel.js b/app/docker/components/host-view-panels/devices-panel/devices-panel.js new file mode 100644 index 0000000..a7d19bc --- /dev/null +++ b/app/docker/components/host-view-panels/devices-panel/devices-panel.js @@ -0,0 +1,6 @@ +angular.module('portainer.docker').component('devicesPanel', { + templateUrl: './devices-panel.html', + bindings: { + devices: '<', + }, +}); diff --git a/app/docker/components/host-view-panels/disks-panel/disks-panel.html b/app/docker/components/host-view-panels/disks-panel/disks-panel.html new file mode 100644 index 0000000..9653cd4 --- /dev/null +++ b/app/docker/components/host-view-panels/disks-panel/disks-panel.html @@ -0,0 +1,32 @@ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
VendorSize
{{ disk.Vendor }}{{ disk.Size | humansize }}
Loading...
Failed to load devices.
No disks available.
+
+
+
+
diff --git a/app/docker/components/host-view-panels/disks-panel/disks-panel.js b/app/docker/components/host-view-panels/disks-panel/disks-panel.js new file mode 100644 index 0000000..4b755a6 --- /dev/null +++ b/app/docker/components/host-view-panels/disks-panel/disks-panel.js @@ -0,0 +1,6 @@ +angular.module('portainer.docker').component('disksPanel', { + templateUrl: './disks-panel.html', + bindings: { + disks: '<', + }, +}); diff --git a/app/docker/components/host-view-panels/engine-details-panel/engine-details-panel.html b/app/docker/components/host-view-panels/engine-details-panel/engine-details-panel.html new file mode 100644 index 0000000..0ea5fd9 --- /dev/null +++ b/app/docker/components/host-view-panels/engine-details-panel/engine-details-panel.html @@ -0,0 +1,43 @@ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Version{{ $ctrl.engine.releaseVersion }} (API: {{ $ctrl.engine.apiVersion }})
Root directory{{ $ctrl.engine.rootDirectory }}
Storage Driver{{ $ctrl.engine.storageDriver }}
Logging Driver{{ $ctrl.engine.loggingDriver }}
Volume Plugins{{ $ctrl.engine.volumePlugins | arraytostr: ', ' }}
Network Plugins{{ $ctrl.engine.networkPlugins | arraytostr: ', ' }}
Engine Labels{{ $ctrl.engine.engineLabels | labelsToStr: ', ' }}
+
+
+
+
diff --git a/app/docker/components/host-view-panels/engine-details-panel/engine-details-panel.js b/app/docker/components/host-view-panels/engine-details-panel/engine-details-panel.js new file mode 100644 index 0000000..543a590 --- /dev/null +++ b/app/docker/components/host-view-panels/engine-details-panel/engine-details-panel.js @@ -0,0 +1,6 @@ +angular.module('portainer.docker').component('engineDetailsPanel', { + templateUrl: './engine-details-panel.html', + bindings: { + engine: '<', + }, +}); diff --git a/app/docker/components/host-view-panels/node-availability-select/node-availability-select-controller.js b/app/docker/components/host-view-panels/node-availability-select/node-availability-select-controller.js new file mode 100644 index 0000000..2ebd29e --- /dev/null +++ b/app/docker/components/host-view-panels/node-availability-select/node-availability-select-controller.js @@ -0,0 +1,9 @@ +angular.module('portainer.docker').controller('NodeAvailabilitySelectController', [ + function NodeAvailabilitySelectController() { + this.onChange = onChange; + + function onChange() { + this.onSave({ availability: this.availability }); + } + }, +]); diff --git a/app/docker/components/host-view-panels/node-availability-select/node-availability-select.html b/app/docker/components/host-view-panels/node-availability-select/node-availability-select.html new file mode 100644 index 0000000..fd910da --- /dev/null +++ b/app/docker/components/host-view-panels/node-availability-select/node-availability-select.html @@ -0,0 +1,8 @@ +
+ +
diff --git a/app/docker/components/host-view-panels/node-availability-select/node-availability-select.js b/app/docker/components/host-view-panels/node-availability-select/node-availability-select.js new file mode 100644 index 0000000..b9af163 --- /dev/null +++ b/app/docker/components/host-view-panels/node-availability-select/node-availability-select.js @@ -0,0 +1,9 @@ +angular.module('portainer.docker').component('nodeAvailabilitySelect', { + templateUrl: './node-availability-select.html', + controller: 'NodeAvailabilitySelectController', + bindings: { + availability: '<', + originalValue: '<', + onSave: '&', + }, +}); diff --git a/app/docker/components/host-view-panels/node-labels-table/node-labels-table-controller.js b/app/docker/components/host-view-panels/node-labels-table/node-labels-table-controller.js new file mode 100644 index 0000000..4296d4b --- /dev/null +++ b/app/docker/components/host-view-panels/node-labels-table/node-labels-table-controller.js @@ -0,0 +1,20 @@ +angular.module('portainer.docker').controller('NodeLabelsTableController', [ + function NodeLabelsTableController() { + var ctrl = this; + ctrl.removeLabel = removeLabel; + ctrl.updateLabel = updateLabel; + + function removeLabel(index) { + var label = ctrl.labels.splice(index, 1); + if (label !== null) { + ctrl.onChangedLabels({ labels: ctrl.labels }); + } + } + + function updateLabel(label) { + if (label.value !== label.originalValue || label.key !== label.originalKey) { + ctrl.onChangedLabels({ labels: ctrl.labels }); + } + } + }, +]); 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 new file mode 100644 index 0000000..b08d4a3 --- /dev/null +++ b/app/docker/components/host-view-panels/node-labels-table/node-labels-table.html @@ -0,0 +1,24 @@ +
There are no labels for this node.
+ +
+
+
+ name + +
+
+ value + +
+ +
+
diff --git a/app/docker/components/host-view-panels/node-labels-table/node-labels-table.js b/app/docker/components/host-view-panels/node-labels-table/node-labels-table.js new file mode 100644 index 0000000..045a575 --- /dev/null +++ b/app/docker/components/host-view-panels/node-labels-table/node-labels-table.js @@ -0,0 +1,8 @@ +angular.module('portainer.docker').component('nodeLabelsTable', { + templateUrl: './node-labels-table.html', + controller: 'NodeLabelsTableController', + bindings: { + labels: '<', + onChangedLabels: '&', + }, +}); diff --git a/app/docker/components/host-view-panels/swarm-node-details-panel/swarm-node-details-panel-controller.js b/app/docker/components/host-view-panels/swarm-node-details-panel/swarm-node-details-panel-controller.js new file mode 100644 index 0000000..acf17b7 --- /dev/null +++ b/app/docker/components/host-view-panels/swarm-node-details-panel/swarm-node-details-panel-controller.js @@ -0,0 +1,91 @@ +angular.module('portainer.docker').controller('SwarmNodeDetailsPanelController', [ + 'NodeService', + 'LabelHelper', + 'Notifications', + '$state', + function SwarmNodeDetailsPanelController(NodeService, LabelHelper, Notifications, $state) { + var ctrl = this; + ctrl.state = { + managerAddress: '', + hasChanges: false, + }; + ctrl.$onChanges = $onChanges; + ctrl.addLabel = addLabel; + ctrl.updateNodeLabels = updateNodeLabels; + ctrl.updateNodeAvailability = updateNodeAvailability; + ctrl.saveChanges = saveChanges; + ctrl.cancelChanges = cancelChanges; + + var managerRole = 'manager'; + + function $onChanges() { + if (!ctrl.details) { + return; + } + if (ctrl.details.role === managerRole) { + ctrl.state.managerAddress = '(' + ctrl.details.managerAddress + ')'; + } + } + + function addLabel() { + ctrl.details.nodeLabels.push({ + key: '', + value: '', + originalValue: '', + originalKey: '', + }); + } + + function updateNodeLabels(labels) { + ctrl.details.nodeLabels = labels; + ctrl.state.hasChanges = true; + } + + function updateNodeAvailability(availability) { + ctrl.details.availability = availability; + ctrl.state.hasChanges = true; + } + + function saveChanges() { + var originalNode = ctrl.originalNode; + var config = { + Name: originalNode.Name, + Availability: ctrl.details.availability, + Role: originalNode.Role, + Labels: LabelHelper.fromKeyValueToLabelHash(ctrl.details.nodeLabels), + Id: originalNode.Id, + Version: originalNode.Version, + }; + + NodeService.updateNode(config).then(onUpdateSuccess).catch(notifyOnError); + + function onUpdateSuccess() { + Notifications.success('Node successfully updated', 'Node updated'); + $state.go('docker.nodes.node', { id: originalNode.Id }, { reload: true }); + } + + function notifyOnError(error) { + Notifications.error('Failure', error, 'Failed to update node'); + } + } + + function cancelChanges() { + cancelLabelChanges(); + ctrl.details.availability = ctrl.originalNode.Availability; + ctrl.state.hasChanges = false; + } + + function cancelLabelChanges() { + ctrl.details.nodeLabels = ctrl.details.nodeLabels + .filter(function (label) { + return label.originalValue || label.originalKey; + }) + .map(function (label) { + return Object.assign(label, { + value: label.originalValue, + key: label.originalKey, + }); + }); + } + }, +]); 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 new file mode 100644 index 0000000..5e431af --- /dev/null +++ b/app/docker/components/host-view-panels/swarm-node-details-panel/swarm-node-details-panel.html @@ -0,0 +1,67 @@ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Node name{{ $ctrl.details.name }}
Role{{ $ctrl.details.role }} {{ $ctrl.state.managerAddress }}
Availability + + +
Status{{ $ctrl.details.status }}
+ + Node Labels +
+ +
+ +
+
+
+
+
diff --git a/app/docker/components/host-view-panels/swarm-node-details-panel/swarm-node-details-panel.js b/app/docker/components/host-view-panels/swarm-node-details-panel/swarm-node-details-panel.js new file mode 100644 index 0000000..751e4ae --- /dev/null +++ b/app/docker/components/host-view-panels/swarm-node-details-panel/swarm-node-details-panel.js @@ -0,0 +1,8 @@ +angular.module('portainer.docker').component('swarmNodeDetailsPanel', { + templateUrl: './swarm-node-details-panel.html', + controller: 'SwarmNodeDetailsPanelController', + bindings: { + details: '<', + originalNode: '<', + }, +}); diff --git a/app/docker/components/imageRegistry/por-image-registry-rate-limits.controller.js b/app/docker/components/imageRegistry/por-image-registry-rate-limits.controller.js new file mode 100644 index 0000000..05c80ec --- /dev/null +++ b/app/docker/components/imageRegistry/por-image-registry-rate-limits.controller.js @@ -0,0 +1,42 @@ +import { isAgentEnvironment, isLocalEnvironment } from '@/react/portainer/environments/utils'; + +export default class porImageRegistryContainerController { + /* @ngInject */ + constructor(DockerHubService, Notifications) { + this.DockerHubService = DockerHubService; + this.Notifications = Notifications; + + this.pullRateLimits = null; + } + + $onChanges({ registryId }) { + if (registryId) { + this.fetchRateLimits(); + } + } + + $onInit() { + this.setValidity = + this.setValidity || + (() => { + /* noop */ + }); + } + + async fetchRateLimits() { + this.pullRateLimits = null; + if (!isAgentEnvironment(this.endpoint.Type) && !isLocalEnvironment(this.endpoint)) { + this.setValidity(true); + return; + } + + try { + this.pullRateLimits = await this.DockerHubService.checkRateLimits(this.endpoint, this.registryId || 0); + this.setValidity(!this.pullRateLimits.limit || (this.pullRateLimits.limit && this.pullRateLimits.remaining >= 0)); + } catch (e) { + // eslint-disable-next-line no-console + console.error('Failed loading DockerHub pull rate limits', e); + this.setValidity(true); + } + } +} diff --git a/app/docker/components/imageRegistry/por-image-registry-rate-limits.html b/app/docker/components/imageRegistry/por-image-registry-rate-limits.html new file mode 100644 index 0000000..a50f0c7 --- /dev/null +++ b/app/docker/components/imageRegistry/por-image-registry-rate-limits.html @@ -0,0 +1,34 @@ +
+
+
+ + + You are currently using a free account to pull images from DockerHub and will be limited to 200 pulls every 6 hours. Remaining pulls: + {{ $ctrl.pullRateLimits.remaining }}/{{ $ctrl.pullRateLimits.limit }} + + + + You are currently using an anonymous account to pull images from DockerHub and will be limited to 100 pulls every 6 hours. You can configure DockerHub authentication in + the + Registries View. Remaining pulls: + {{ $ctrl.pullRateLimits.remaining }}/{{ $ctrl.pullRateLimits.limit }} + + + You are currently using an anonymous account to pull images from DockerHub and will be limited to 100 pulls every 6 hours. Contact your administrator to configure + DockerHub authentication. Remaining pulls: {{ $ctrl.pullRateLimits.remaining }}/{{ $ctrl.pullRateLimits.limit }} + + +
+
+ + + Your authorized pull count quota as a free user is now exceeded. + You will not be able to pull any image from the DockerHub registry. + + + Your authorized pull count quota as an anonymous user is now exceeded. + You will not be able to pull any image from the DockerHub registry. + +
+
+
diff --git a/app/docker/components/imageRegistry/por-image-registry-rate-limits.js b/app/docker/components/imageRegistry/por-image-registry-rate-limits.js new file mode 100644 index 0000000..b87835a --- /dev/null +++ b/app/docker/components/imageRegistry/por-image-registry-rate-limits.js @@ -0,0 +1,20 @@ +import angular from 'angular'; + +import controller from './por-image-registry-rate-limits.controller'; + +angular.module('portainer.docker').component('porImageRegistryRateLimits', { + bindings: { + endpoint: '<', + registry: '<', + setValidity: '<', + isAdmin: '<', + isDockerHubRegistry: '<', + isAuthenticated: '<', + registryId: '<', + }, + controller, + transclude: { + rateLimitExceeded: '?porImageRegistryRateLimitExceeded', + }, + templateUrl: './por-image-registry-rate-limits.html', +}); diff --git a/app/docker/components/imageRegistry/por-image-registry.controller.js b/app/docker/components/imageRegistry/por-image-registry.controller.js new file mode 100644 index 0000000..8bde1d9 --- /dev/null +++ b/app/docker/components/imageRegistry/por-image-registry.controller.js @@ -0,0 +1,137 @@ +import angular from 'angular'; +import _ from 'lodash-es'; +import { DockerHubViewModel } from '@/portainer/models/dockerhub'; +import { RegistryTypes } from '@/portainer/models/registryTypes'; + +class porImageRegistryController { + /* @ngInject */ + constructor($async, $scope, ImageHelper, RegistryService, EndpointService, ImageService, Notifications) { + this.$async = $async; + this.$scope = $scope; + this.ImageHelper = ImageHelper; + this.RegistryService = RegistryService; + this.EndpointService = EndpointService; + this.ImageService = ImageService; + this.Notifications = Notifications; + + this.onRegistryChange = this.onRegistryChange.bind(this); + this.onImageChange = this.onImageChange.bind(this); + + this.registries = []; + this.images = []; + this.defaultRegistry = new DockerHubViewModel(); + + this.$scope.$watch(() => this.model.Registry, this.onRegistryChange); + this.$scope.$watch(() => this.model.Image, this.onImageChange); + } + + isKnownRegistry(registry) { + return registry && !(registry instanceof DockerHubViewModel) && registry.URL; + } + + getRegistryURL(registry) { + if (!registry) { + return; + } + let url = registry.URL; + if (registry.Type === RegistryTypes.GITLAB) { + url = registry.URL + '/' + registry.Gitlab.ProjectPath; + } + return url; + } + + prepareAutocomplete() { + let images = []; + const registry = this.model.Registry; + if (this.isKnownRegistry(registry)) { + const url = this.getRegistryURL(registry); + const registryImages = _.filter(this.images, (image) => _.includes(image, url)); + images = _.map(registryImages, (image) => _.replace(image, new RegExp(url + '/?'), '')); + } else { + const registries = _.filter(this.registries, (reg) => this.isKnownRegistry(reg)); + const registryImages = _.flatMap(registries, (registry) => _.filter(this.images, (image) => _.includes(image, registry.URL))); + const imagesWithoutKnown = _.difference(this.images, registryImages); + images = _.filter(imagesWithoutKnown, (image) => !this.ImageHelper.imageContainsURL(image)); + } + this.availableImages = images; + } + + isDockerHubRegistry() { + return this.model.UseRegistry && this.model.Registry && (this.model.Registry.Type === RegistryTypes.DOCKERHUB || this.model.Registry.Type === RegistryTypes.ANONYMOUS); + } + + async onRegistryChange() { + this.prepareAutocomplete(); + if (this.model.Registry && this.model.Registry.Type === RegistryTypes.GITLAB && this.model.Image) { + this.model.Image = _.replace(this.model.Image, this.model.Registry.Gitlab.ProjectPath, ''); + } + } + + async onImageChange() { + if (!this.isDockerHubRegistry()) { + this.setValidity(true); + } + } + + displayedRegistryURL() { + return (this.model.Registry && this.getRegistryURL(this.model.Registry)) || 'docker.io'; + } + + async reloadRegistries() { + return this.$async(async () => { + try { + let showDefaultRegistry = false; + this.registries = await this.EndpointService.registries(this.endpoint.Id, this.namespace); + + // Sort the registries by Name + this.registries.sort((a, b) => a.Name.localeCompare(b.Name)); + + // hide default(anonymous) dockerhub registry if user has an authenticated one + if (!this.registries.some((registry) => registry.Type === RegistryTypes.DOCKERHUB)) { + showDefaultRegistry = true; + // Add dockerhub on top + this.registries.splice(0, 0, this.defaultRegistry); + } + + const id = this.model.Registry && this.model.Registry.Id; + const registry = _.find(this.registries, { Id: id }); + if (!registry) { + this.model.Registry = showDefaultRegistry ? this.defaultRegistry : this.registries[0]; + } + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to retrieve registries'); + } + }); + } + + async loadImages() { + return this.$async(async () => { + try { + if (!this.autoComplete) { + this.images = []; + return; + } + + const images = await this.ImageService.images(); + this.images = this.ImageService.getUniqueTagListFromImages(images); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to retrieve images'); + } + }); + } + + $onChanges({ namespace, endpoint }) { + if ((namespace || endpoint) && this.endpoint.Id) { + this.reloadRegistries(); + } + } + + $onInit() { + return this.$async(async () => { + await this.loadImages(); + }); + } +} + +export default porImageRegistryController; +angular.module('portainer.docker').controller('porImageRegistryController', porImageRegistryController); diff --git a/app/docker/components/imageRegistry/por-image-registry.html b/app/docker/components/imageRegistry/por-image-registry.html new file mode 100644 index 0000000..7da5776 --- /dev/null +++ b/app/docker/components/imageRegistry/por-image-registry.html @@ -0,0 +1,104 @@ + +
+
+ +
+ +
+
+
+ +
+
+ {{ $ctrl.displayedRegistryURL() }} + + + + Search + + +
+
+
+ + +
+
+ +

When using advanced mode, image and repository must be publicly available.

+
+ +
+ +
+
+
+ + +
+
+
+
+

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

+
+
+
+ +
+
+ + +
+
+ +
+ + + +
diff --git a/app/docker/components/imageRegistry/por-image-registry.js b/app/docker/components/imageRegistry/por-image-registry.js new file mode 100644 index 0000000..5c39704 --- /dev/null +++ b/app/docker/components/imageRegistry/por-image-registry.js @@ -0,0 +1,20 @@ +angular.module('portainer.docker').component('porImageRegistry', { + templateUrl: './por-image-registry.html', + controller: 'porImageRegistryController', + bindings: { + model: '=', // must be of type PorImageRegistryModel + autoComplete: '<', + labelClass: '@', + inputClass: '@', + endpoint: '<', + isAdmin: '<', + checkRateLimits: '<', + onImageChange: '&', + setValidity: '<', + namespace: '<', + }, + require: { + form: '^form', + }, + transclude: true, +}); diff --git a/app/docker/components/log-viewer/log-viewer.js b/app/docker/components/log-viewer/log-viewer.js new file mode 100644 index 0000000..ed2f9aa --- /dev/null +++ b/app/docker/components/log-viewer/log-viewer.js @@ -0,0 +1,12 @@ +angular.module('portainer.docker').component('logViewer', { + templateUrl: './logViewer.html', + controller: 'LogViewerController', + bindings: { + data: '=', + displayTimestamps: '=', + logCollectionChange: '<', + sinceTimestamp: '=', + lineCount: '=', + resourceName: '<', + }, +}); diff --git a/app/docker/components/log-viewer/logViewer.html b/app/docker/components/log-viewer/logViewer.html new file mode 100644 index 0000000..e98e905 --- /dev/null +++ b/app/docker/components/log-viewer/logViewer.html @@ -0,0 +1,94 @@ +
+
+ + + +
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ + + + + + + +
+
+
+
+
+
+
+ +
+
+
+      

{{ span.text }}

+

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

+

No logs available

+
+
+
diff --git a/app/docker/components/log-viewer/logViewerController.js b/app/docker/components/log-viewer/logViewerController.js new file mode 100644 index 0000000..632187e --- /dev/null +++ b/app/docker/components/log-viewer/logViewerController.js @@ -0,0 +1,82 @@ +import moment from 'moment'; + +import { concatLogsToString, NEW_LINE_BREAKER } from '@/docker/helpers/logHelper'; + +angular.module('portainer.docker').controller('LogViewerController', [ + '$scope', + 'clipboard', + 'Blob', + 'FileSaver', + function ($scope, clipboard, Blob, FileSaver) { + this.state = { + availableSinceDatetime: [ + { desc: 'Last day', value: moment().subtract(1, 'days').format() }, + { desc: 'Last 4 hours', value: moment().subtract(4, 'hours').format() }, + { desc: 'Last hour', value: moment().subtract(1, 'hours').format() }, + { desc: 'Last 10 minutes', value: moment().subtract(10, 'minutes').format() }, + ], + copySupported: clipboard.supported, + logCollection: true, + autoScroll: true, + wrapLines: true, + search: '', + filteredLogs: [], + selectedLines: [], + }; + + this.handleLogsCollectionChange = handleLogsCollectionChange.bind(this); + this.handleLogsWrapLinesChange = handleLogsWrapLinesChange.bind(this); + this.handleDisplayTimestampsChange = handleDisplayTimestampsChange.bind(this); + + function handleLogsCollectionChange(enabled) { + $scope.$evalAsync(() => { + this.state.logCollection = enabled; + this.state.autoScroll = enabled; + this.logCollectionChange(enabled); + }); + } + + function handleLogsWrapLinesChange(enabled) { + $scope.$evalAsync(() => { + this.state.wrapLines = enabled; + }); + } + + function handleDisplayTimestampsChange(enabled) { + $scope.$evalAsync(() => { + this.displayTimestamps = enabled; + }); + } + + this.copy = function () { + clipboard.copyText(this.state.filteredLogs.map((log) => log.line).join(NEW_LINE_BREAKER)); + $('#refreshRateChange').show(); + $('#refreshRateChange').fadeOut(2000); + }; + + this.copySelection = function () { + clipboard.copyText(this.state.selectedLines.join(NEW_LINE_BREAKER)); + $('#refreshRateChange').show(); + $('#refreshRateChange').fadeOut(2000); + }; + + this.clearSelection = function () { + this.state.selectedLines = []; + }; + + this.selectLine = function (line) { + var idx = this.state.selectedLines.indexOf(line); + if (idx === -1) { + this.state.selectedLines.push(line); + } else { + this.state.selectedLines.splice(idx, 1); + } + }; + + this.downloadLogs = function () { + const logsAsString = concatLogsToString(this.state.filteredLogs); + const data = new Blob([logsAsString]); + FileSaver.saveAs(data, this.resourceName + '_logs.txt'); + }; + }, +]); diff --git a/app/docker/components/network-macvlan-form/network-macvlan-form.js b/app/docker/components/network-macvlan-form/network-macvlan-form.js new file mode 100644 index 0000000..3ae345c --- /dev/null +++ b/app/docker/components/network-macvlan-form/network-macvlan-form.js @@ -0,0 +1,8 @@ +angular.module('portainer.docker').component('networkMacvlanForm', { + templateUrl: './networkMacvlanForm.html', + controller: 'NetworkMacvlanFormController', + bindings: { + data: '=', + applicationState: '<', + }, +}); diff --git a/app/docker/components/network-macvlan-form/networkMacvlanForm.html b/app/docker/components/network-macvlan-form/networkMacvlanForm.html new file mode 100644 index 0000000..0bcb634 --- /dev/null +++ b/app/docker/components/network-macvlan-form/networkMacvlanForm.html @@ -0,0 +1,91 @@ +
+
Macvlan configuration
+ +
+ + + To create a MACVLAN network you need to create a configuration, then create the network from this configuration. + +
+ + + + + +
+ +
+ +
+ +
+
+
+
+
+

Parent network card must be specified.

+
+
+
+ + +
+ + +
+
+
+

At least one node must be selected.

+
+
+
+
+ +
+ + +
+ + +
+ +
+ +
+
+ +
+
+
+

Select a configuration network.

+
+
+
+ +
+ +
+
diff --git a/app/docker/components/network-macvlan-form/networkMacvlanFormController.js b/app/docker/components/network-macvlan-form/networkMacvlanFormController.js new file mode 100644 index 0000000..3080095 --- /dev/null +++ b/app/docker/components/network-macvlan-form/networkMacvlanFormController.js @@ -0,0 +1,69 @@ +import { getOptions } from '@/react/docker/networks/CreateView/macvlanOptions'; + +angular.module('portainer.docker').controller('NetworkMacvlanFormController', [ + '$q', + 'NodeService', + 'NetworkService', + 'Notifications', + '$scope', + 'Authentication', + function ($q, NodeService, NetworkService, Notifications, $scope, Authentication) { + var ctrl = this; + + this.options = []; + + ctrl.onChangeSelectedNodes = onChangeSelectedNodes.bind(ctrl); + function onChangeSelectedNodes(nodes) { + return $scope.$evalAsync(() => { + ctrl.data.DatatableState.selectedItems = nodes; + }); + } + + ctrl.requiredNodeSelection = function () { + if (ctrl.data.Scope !== 'local' || ctrl.data.DatatableState === undefined) { + return false; + } + return ctrl.data.DatatableState.selectedItems.length; + }; + + ctrl.requiredConfigSelection = function () { + if (ctrl.data.Scope !== 'swarm') { + return false; + } + return !ctrl.data.SelectedNetworkConfig; + }; + + this.onChangeScope = onChangeScope.bind(this); + function onChangeScope(value) { + return $scope.$evalAsync(() => { + this.data.Scope = value; + }); + } + + this.$onInit = $onInit; + function $onInit() { + var isAdmin = Authentication.isAdmin(); + ctrl.isAdmin = isAdmin; + + var provider = ctrl.applicationState.endpoint.mode.provider; + var apiVersion = ctrl.applicationState.endpoint.apiVersion; + $q.all({ + nodes: provider !== 'DOCKER_SWARM_MODE' || NodeService.nodes(), + networks: NetworkService.networks(provider === 'DOCKER_STANDALONE' || provider === 'DOCKER_SWARM_MODE', false, provider === 'DOCKER_SWARM_MODE' && apiVersion >= 1.25), + }) + .then(function success(data) { + if (data.nodes !== true) { + ctrl.nodes = data.nodes; + } + ctrl.availableNetworks = data.networks.filter(function (item) { + return item.ConfigOnly === true; + }); + + ctrl.options = getOptions(ctrl.availableNetworks.length > 0); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve informations for macvlan'); + }); + } + }, +]); diff --git a/app/docker/components/network-macvlan-form/networkMacvlanFormModel.js b/app/docker/components/network-macvlan-form/networkMacvlanFormModel.js new file mode 100644 index 0000000..e790d80 --- /dev/null +++ b/app/docker/components/network-macvlan-form/networkMacvlanFormModel.js @@ -0,0 +1,8 @@ +export function MacvlanFormData() { + this.Scope = 'local'; + this.SelectedNetworkConfig = null; + this.DatatableState = { + selectedItems: [], + }; + this.ParentNetworkCard = ''; +} diff --git a/app/docker/components/volumesCIFSForm/volumesCifsForm.html b/app/docker/components/volumesCIFSForm/volumesCifsForm.html new file mode 100644 index 0000000..7a749f7 --- /dev/null +++ b/app/docker/components/volumesCIFSForm/volumesCifsForm.html @@ -0,0 +1,97 @@ + +
+ +
CIFS Settings
+ +
+ +
+ +
+
+
+
+
+

This field is required.

+
+
+
+ + +
+ +
+ +
+
+
+
+
+

This field is required.

+
+
+
+ + +
+ +
+ +
+
+
+
+
+

This field is required.

+
+
+
+ + +
+ +
+ +
+
+
+
+
+

This field is required.

+
+
+
+ + +
+ +
+ +
+
+
+
+
+

This field is required.

+
+
+
+ +
+
+ diff --git a/app/docker/components/volumesCIFSForm/volumesCifsForm.js b/app/docker/components/volumesCIFSForm/volumesCifsForm.js new file mode 100644 index 0000000..5d34957 --- /dev/null +++ b/app/docker/components/volumesCIFSForm/volumesCifsForm.js @@ -0,0 +1,6 @@ +angular.module('portainer.docker').component('volumesCifsForm', { + templateUrl: './volumesCifsForm.html', + bindings: { + data: '=', + }, +}); diff --git a/app/docker/components/volumesCIFSForm/volumesCifsFormModel.js b/app/docker/components/volumesCIFSForm/volumesCifsFormModel.js new file mode 100644 index 0000000..db51e41 --- /dev/null +++ b/app/docker/components/volumesCIFSForm/volumesCifsFormModel.js @@ -0,0 +1,15 @@ +export function VolumesCIFSFormData() { + this.useCIFS = false; + this.serverAddress = ''; + this.share = ''; + this.version = 'CIFS v2.0 (Used by Windows Vista / Server 2008)'; + this.versions = [ + 'CIFS v1.0 (Used by Windows XP / Server 2003 and earlier)', + 'CIFS v2.0 (Used by Windows Vista / Server 2008)', + 'CIFS v2.1 (Used by Windows 7 / Server 2008 R2)', + 'CIFS 3.0 (Used by Windows 8 / Server 2012 and newer)', + ]; + this.versionsNumber = ['1.0', '2.0', '2.1', '3.0']; + this.username = ''; + this.password = ''; +} diff --git a/app/docker/components/volumesNFSForm/volumes-nfs-form.js b/app/docker/components/volumesNFSForm/volumes-nfs-form.js new file mode 100644 index 0000000..cece7b6 --- /dev/null +++ b/app/docker/components/volumesNFSForm/volumes-nfs-form.js @@ -0,0 +1,6 @@ +angular.module('portainer.docker').component('volumesNfsForm', { + templateUrl: './volumesnfsForm.html', + bindings: { + data: '=', + }, +}); diff --git a/app/docker/components/volumesNFSForm/volumesNFSFormModel.js b/app/docker/components/volumesNFSForm/volumesNFSFormModel.js new file mode 100644 index 0000000..594c818 --- /dev/null +++ b/app/docker/components/volumesNFSForm/volumesNFSFormModel.js @@ -0,0 +1,8 @@ +export function VolumesNFSFormData() { + this.useNFS = false; + this.serverAddress = ''; + this.mountPoint = ''; + this.version = 'NFS4'; + this.options = 'rw,noatime,rsize=8192,wsize=8192,tcp,timeo=14'; + this.versions = ['NFS4', 'NFS']; +} diff --git a/app/docker/components/volumesNFSForm/volumesnfsForm.html b/app/docker/components/volumesNFSForm/volumesnfsForm.html new file mode 100644 index 0000000..23db95b --- /dev/null +++ b/app/docker/components/volumesNFSForm/volumesnfsForm.html @@ -0,0 +1,93 @@ + +
+ +
NFS Settings
+ +
+ +
+ +
+
+
+
+
+

This field is required.

+
+
+
+ + +
+ +
+ +
+
+
+
+
+

This field is required.

+
+
+
+ + +
+ +
+ +
+
+
+
+
+

This field is required.

+
+
+
+ + +
+ +
+ +
+
+
+
+
+

This field is required.

+
+
+
+ +
+
+ diff --git a/app/docker/filters/filters.js b/app/docker/filters/filters.js new file mode 100644 index 0000000..0bef356 --- /dev/null +++ b/app/docker/filters/filters.js @@ -0,0 +1,154 @@ +import _ from 'lodash-es'; + +import { strToHash } from '@/react/utils/hash'; + +import { hideShaSum, joinCommand, nodeStatusBadge, taskStatusBadge, trimContainerName, trimSHA, trimVersionTag } from './utils'; + +function includeString(text, values) { + return values.some(function (val) { + return text.indexOf(val) !== -1; + }); +} + +function hashToHexColor(hash) { + var color = '#'; + for (var i = 0; i < 3; ) { + color += ('00' + ((hash >> (i++ * 8)) & 0xff).toString(16)).slice(-2); + } + return color; +} + +angular + .module('portainer.docker') + .filter('visualizerTask', function () { + 'use strict'; + return function (text) { + var status = _.toLower(text); + if (includeString(status, ['new', 'allocated', 'assigned', 'accepted', 'complete', 'preparing'])) { + return 'info'; + } else if (includeString(status, ['pending'])) { + return 'warning'; + } else if (includeString(status, ['shutdown', 'failed', 'rejected'])) { + return 'stopped'; + } + return 'running'; + }; + }) + .filter('visualizerTaskBorderColor', function () { + 'use strict'; + return function (str) { + var hash = strToHash(str); + var color = hashToHexColor(hash); + return color; + }; + }) + .filter('taskstatusbadge', function () { + 'use strict'; + return taskStatusBadge; + }) + .filter('taskhaslogs', function () { + 'use strict'; + return function (state) { + var validState = ['running', 'complete', 'failed', 'shutdown']; + if (validState.indexOf(state) > -1) { + return true; + } + return false; + }; + }) + .filter('containerstatusbadge', function () { + 'use strict'; + return function (text) { + var status = _.toLower(text); + if (includeString(status, ['paused', 'starting', 'unhealthy'])) { + return 'warning'; + } else if (includeString(status, ['created'])) { + return 'info'; + } else if (includeString(status, ['stopped', 'dead', 'exited'])) { + return 'danger'; + } + return 'success'; + }; + }) + .filter('nodestatusbadge', () => nodeStatusBadge) + .filter('trimcontainername', () => trimContainerName) + .filter('getstatelabel', function () { + 'use strict'; + return function (state) { + if (state === undefined) { + return 'label-default'; + } + if (state.Ghost && state.Running) { + return 'label-important'; + } + if (state.Running) { + return 'label-success'; + } + return 'label-default'; + }; + }) + .filter('containername', function () { + 'use strict'; + return function (container) { + return container.Names[0]; + }; + }) + .filter('swarmversion', function () { + 'use strict'; + return function (text) { + return _.split(text, '/')[1]; + }; + }) + .filter('swarmhostname', function () { + 'use strict'; + return function (container) { + return container.Names[0]; + }; + }) + .filter('repotags', function () { + 'use strict'; + return function (image) { + if (image.RepoTags && image.RepoTags.length > 0) { + var tag = image.RepoTags[0]; + if (tag === ':') { + return []; + } + return image.RepoTags; + } + return []; + }; + }) + .filter('command', function () { + return joinCommand; + }) + .filter('hideshasum', () => hideShaSum) + .filter('tasknodename', function () { + 'use strict'; + return function (nodeId, nodes) { + var node = _.find(nodes, { Id: nodeId }); + if (node) { + return node.Hostname; + } + return ''; + }; + }) + .filter('imagelayercommand', function () { + 'use strict'; + return function (createdBy) { + if (!createdBy) { + return ''; + } + return createdBy.replace('/bin/sh -c #(nop) ', '').replace('/bin/sh -c ', 'RUN '); + }; + }) + .filter('trimshasum', function () { + 'use strict'; + return trimSHA; + }) + .filter('trimversiontag', function () { + 'use strict'; + return trimVersionTag; + }) + .filter('unique', function () { + return _.uniqBy; + }); diff --git a/app/docker/filters/utils.ts b/app/docker/filters/utils.ts new file mode 100644 index 0000000..ef5766a --- /dev/null +++ b/app/docker/filters/utils.ts @@ -0,0 +1,92 @@ +import { NodeStatus, TaskState } from 'docker-types'; +import _ from 'lodash'; + +export function trimVersionTag(fullName: string) { + if (!fullName) { + return fullName; + } + + const versionIdx = fullName.lastIndexOf(':'); + if (versionIdx < 0) { + return fullName; + } + + const hostIdx = fullName.indexOf('/'); + if (hostIdx > versionIdx) { + return fullName; + } + + return fullName.substring(0, versionIdx); +} + +export function trimSHA(imageName: string) { + if (!imageName) { + return ''; + } + if (imageName.indexOf('sha256:') === 0) { + return imageName.substring(7, 19); + } + return _.split(imageName, '@sha256')[0]; +} + +export function joinCommand(command: null | Array = []) { + if (!command) { + return ''; + } + + return command.join(' '); +} + +export function taskStatusBadge(text?: TaskState) { + const status = _.toLower(text); + if ( + [ + 'new', + 'allocated', + 'assigned', + 'accepted', + 'preparing', + 'ready', + 'starting', + 'remove', + ].includes(status) + ) { + return 'info'; + } + + if (['pending'].includes(status)) { + return 'warning'; + } + + if (['shutdown', 'failed', 'rejected', 'orphaned'].includes(status)) { + return 'danger'; + } + + if (['complete'].includes(status)) { + return 'primary'; + } + + if (['running'].includes(status)) { + return 'success'; + } + return 'default'; +} + +export function nodeStatusBadge(text: NodeStatus['State']) { + if (text === 'down' || text === 'unknown' || text === 'disconnected') { + return 'danger'; + } + + return 'success'; +} + +export function hideShaSum(imageName = '') { + return imageName.split('@sha')[0]; +} + +export function trimContainerName(name?: string) { + if (name) { + return name.indexOf('/') === 0 ? name.slice(1) : name; + } + return ''; +} diff --git a/app/docker/helpers/configHelper.js b/app/docker/helpers/configHelper.js new file mode 100644 index 0000000..4822096 --- /dev/null +++ b/app/docker/helpers/configHelper.js @@ -0,0 +1,36 @@ +angular.module('portainer.docker').factory('ConfigHelper', [ + function ConfigHelperFactory() { + 'use strict'; + return { + flattenConfig: function (config) { + if (config) { + return { + Id: config.ConfigID, + Name: config.ConfigName, + ...(config.File ? { FileName: config.File.Name, Uid: config.File.UID, Gid: config.File.GID, Mode: config.File.Mode } : {}), + credSpec: !!config.Runtime, + }; + } + return {}; + }, + configConfig: function (config) { + if (config) { + return { + ConfigID: config.Id, + ConfigName: config.Name, + File: config.credSpec + ? null + : { + Name: config.FileName || config.Name, + UID: config.Uid || '0', + GID: config.Gid || '0', + Mode: config.Mode || 292, + }, + Runtime: config.credSpec ? {} : null, + }; + } + return {}; + }, + }; + }, +]); diff --git a/app/docker/helpers/containerHelper.js b/app/docker/helpers/containerHelper.js new file mode 100644 index 0000000..90446c1 --- /dev/null +++ b/app/docker/helpers/containerHelper.js @@ -0,0 +1,256 @@ +import _ from 'lodash-es'; + +const portPattern = /^([1-9]|[1-5]?[0-9]{2,4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])$/m; + +function parsePort(port) { + if (portPattern.test(port)) { + return parseInt(port, 10); + } else { + return 0; + } +} + +function parsePortRange(portRange) { + if (typeof portRange !== 'string') { + portRange = portRange.toString(); + } + + // Split the range and convert to integers + const stringPorts = _.split(portRange, '-', 2); + const intPorts = _.map(stringPorts, parsePort); + + // If it's not a range, we still make sure that we return two ports (start & end) + if (intPorts.length == 1) { + intPorts.push(intPorts[0]); + } + + return intPorts; +} + +function isValidPortRange(portRange) { + if (typeof portRange === 'string') { + portRange = parsePortRange(); + } + + return Array.isArray(portRange) && portRange.length === 2 && portRange[0] > 0 && portRange[1] >= portRange[0]; +} + +function createPortRange(portRangeText, port) { + if (typeof portRangeText !== 'string') { + portRangeText = portRangeText.toString(); + } + + let hostIp = null; + const colonIndex = portRangeText.indexOf(':'); + if (colonIndex >= 0) { + hostIp = portRangeText.substr(0, colonIndex); + portRangeText = portRangeText.substr(colonIndex + 1); + } + + port = typeof port === 'number' ? port : parsePort(port); + const portRange = parsePortRange(portRangeText); + const startPort = Math.min(portRange[0], port); + const endPort = Math.max(portRange[1], port); + + if (hostIp) { + return hostIp + ':' + startPort + '-' + endPort; + } else { + return startPort + '-' + endPort; + } +} + +angular.module('portainer.docker').factory('ContainerHelper', [ + function ContainerHelperFactory() { + 'use strict'; + var helper = {}; + + helper.configFromContainer = function (container) { + var config = container.Config; + // HostConfig + config.HostConfig = container.HostConfig; + // Name + config.name = container.Name.replace(/^\//g, ''); + // Network + var mode = config.HostConfig.NetworkMode; + config.NetworkingConfig = { + EndpointsConfig: {}, + }; + config.NetworkingConfig.EndpointsConfig = container.NetworkSettings.Networks; + + if (config.ExposedPorts === undefined) { + config.ExposedPorts = {}; + } + + if (mode.indexOf('container:') !== -1) { + delete config.Hostname; + delete config.ExposedPorts; + } + // Set volumes + var binds = []; + var volumes = {}; + for (var v in container.Mounts) { + if ({}.hasOwnProperty.call(container.Mounts, v)) { + var mount = container.Mounts[v]; + var name = mount.Name || mount.Source; + var containerPath = mount.Destination; + if (name && containerPath) { + var bind = name + ':' + containerPath; + volumes[containerPath] = {}; + if (mount.RW === false) { + bind += ':ro'; + } + binds.push(bind); + } + } + } + config.HostConfig.Mounts = null; + config.HostConfig.Binds = binds; + config.Volumes = volumes; + return config; + }; + + helper.preparePortBindings = function (portBindings) { + const bindings = {}; + _.forEach(portBindings, (portBinding) => { + if (!portBinding.containerPort) { + return; + } + + let hostPort = portBinding.hostPort; + const containerPortRange = parsePortRange(portBinding.containerPort); + if (!isValidPortRange(containerPortRange)) { + throw new Error('Invalid port specification: ' + portBinding.containerPort); + } + + const startPort = containerPortRange[0]; + const endPort = containerPortRange[1]; + let hostIp = undefined; + let startHostPort = 0; + let endHostPort = 0; + if (hostPort) { + if (hostPort.indexOf('[') > -1) { + const hostAndPort = _.split(hostPort, ']:'); + + if (hostAndPort.length < 2) { + throw new Error('Invalid port specification: ' + portBinding.containerPort); + } + + hostIp = hostAndPort[0].replace('[', ''); + hostPort = hostAndPort[1]; + } else { + if (hostPort.indexOf(':') > -1) { + const hostAndPort = _.split(hostPort, ':'); + hostIp = hostAndPort[0]; + hostPort = hostAndPort[1]; + } + } + + const hostPortRange = parsePortRange(hostPort); + if (!isValidPortRange(hostPortRange)) { + throw new Error('Invalid port specification: ' + hostPort); + } + + startHostPort = hostPortRange[0]; + endHostPort = hostPortRange[1]; + if (endPort !== startPort && endPort - startPort !== endHostPort - startHostPort) { + throw new Error('Invalid port specification: ' + hostPort); + } + } + + for (let i = 0; i <= endPort - startPort; i++) { + const containerPort = (startPort + i).toString(); + if (startHostPort > 0) { + hostPort = (startHostPort + i).toString(); + } + if (startPort === endPort && startHostPort !== endHostPort) { + hostPort += '-' + endHostPort.toString(); + } + + const bindKey = containerPort + '/' + portBinding.protocol; + if (bindings[bindKey]) { + bindings[bindKey].push({ HostIp: hostIp, HostPort: hostPort }); + } else { + bindings[bindKey] = [{ HostIp: hostIp, HostPort: hostPort }]; + } + } + }); + return bindings; + }; + + helper.sortAndCombinePorts = function (portBindings) { + const bindings = []; + const portBindingKeys = _.keys(portBindings); + + // Group the port bindings by protocol + const portBindingKeysByProtocol = _.groupBy(portBindingKeys, (portKey) => { + return _.split(portKey, '/')[1]; + }); + + _.forEach(portBindingKeysByProtocol, (portBindingKeys, protocol) => { + // Group the port bindings by host IP + const portBindingKeysByHostIp = {}; + for (const portKey of portBindingKeys) { + for (const portBinding of portBindings[portKey]) { + portBindingKeysByHostIp[portBinding.HostIp] = portBindingKeysByHostIp[portBinding.HostIp] || []; + portBindingKeysByHostIp[portBinding.HostIp].push(portKey); + } + } + + _.forEach(portBindingKeysByHostIp, (portBindingKeys, ip) => { + // Sort by host port + const sortedPortBindingKeys = _.orderBy(portBindingKeys, (portKey) => { + return parseInt(_.split(portKey, '/')[0], 10); + }); + + let previousHostPort = -1; + let previousContainerPort = -1; + _.forEach(sortedPortBindingKeys, (portKey) => { + const portKeySplit = _.split(portKey, '/'); + const containerPort = parseInt(portKeySplit[0], 10); + const portBinding = portBindings[portKey][0]; + portBindings[portKey].shift(); + const hostPort = parsePort(portBinding.HostPort); + + // We only combine single ports, and skip the host port ranges on one container port + if (hostPort > 0) { + // If we detect consecutive ports, we create a range of them + if (bindings.length > 0 && previousHostPort === hostPort - 1 && previousContainerPort === containerPort - 1) { + bindings[bindings.length - 1].hostPort = createPortRange(bindings[bindings.length - 1].hostPort, hostPort); + bindings[bindings.length - 1].containerPort = createPortRange(bindings[bindings.length - 1].containerPort, containerPort); + previousHostPort = hostPort; + previousContainerPort = containerPort; + return; + } + + previousHostPort = hostPort; + previousContainerPort = containerPort; + } else { + previousHostPort = -1; + previousContainerPort = -1; + } + + let bindingHostPort = portBinding.HostPort.toString(); + if (ip) { + bindingHostPort = `${ip}:${bindingHostPort}`; + } + + const binding = { + hostPort: bindingHostPort, + containerPort: containerPort, + protocol: protocol, + }; + bindings.push(binding); + }); + }); + }); + + return bindings; + }; + + helper.getContainerNames = function (containers) { + return _.map(_.flatten(_.map(containers, 'Names')), (name) => _.trimStart(name, '/')); + }; + + return helper; + }, +]); diff --git a/app/docker/helpers/containers.ts b/app/docker/helpers/containers.ts new file mode 100644 index 0000000..9650b50 --- /dev/null +++ b/app/docker/helpers/containers.ts @@ -0,0 +1,21 @@ +import { splitargs } from './splitargs'; + +export function commandStringToArray(command: string) { + return splitargs(command); +} + +export function commandArrayToString(array: string[]) { + return array.map((elem) => `'${elem}'`).join(' '); +} + +export function getSwarmService(container: { + Config?: { Labels?: Record }; +}) { + return container.Config?.Labels?.['com.docker.swarm.service.id']; +} + +export function isPartOfSwarmService( + ...params: Parameters +) { + return !!getSwarmService(...params); +} diff --git a/app/docker/helpers/imageHelper.js b/app/docker/helpers/imageHelper.js new file mode 100644 index 0000000..3ce3d4a --- /dev/null +++ b/app/docker/helpers/imageHelper.js @@ -0,0 +1,33 @@ +import { buildImageFullURIFromModel, imageContainsURL, fullURIIntoRepoAndTag } from '@/react/docker/images/utils'; + +angular.module('portainer.docker').factory('ImageHelper', ImageHelperFactory); +function ImageHelperFactory() { + return { + isValidTag, + createImageConfigForContainer, + removeDigestFromRepository, + imageContainsURL, + }; + + function isValidTag(tag) { + return tag.match(/^(?![\.\-])([a-zA-Z0-9\_\.\-])+$/g); + } + + /** + * + * @param {PorImageRegistryModel} registry + */ + function createImageConfigForContainer(imageModel) { + const fromImage = buildImageFullURIFromModel(imageModel); + const { tag, repo } = fullURIIntoRepoAndTag(fromImage); + return { + fromImage, + tag, + repo, + }; + } + + function removeDigestFromRepository(repository) { + return repository.split('@sha')[0]; + } +} diff --git a/app/docker/helpers/infoHelper.js b/app/docker/helpers/infoHelper.js new file mode 100644 index 0000000..7bd7bcf --- /dev/null +++ b/app/docker/helpers/infoHelper.js @@ -0,0 +1,35 @@ +import _ from 'lodash-es'; + +angular.module('portainer.docker').factory('InfoHelper', [ + function InfoHelperFactory() { + 'use strict'; + + var helper = {}; + + helper.determineEndpointMode = function (info, type) { + var mode = { + provider: '', + role: '', + agentProxy: false, + }; + + if (type === 2 || type === 4) { + mode.agentProxy = true; + } + + if (!info.Swarm || _.isEmpty(info.Swarm.NodeID)) { + mode.provider = 'DOCKER_STANDALONE'; + } else { + mode.provider = 'DOCKER_SWARM_MODE'; + if (info.Swarm.ControlAvailable) { + mode.role = 'MANAGER'; + } else { + mode.role = 'WORKER'; + } + } + return mode; + }; + + return helper; + }, +]); diff --git a/app/docker/helpers/labelHelper.js b/app/docker/helpers/labelHelper.js new file mode 100644 index 0000000..b19c552 --- /dev/null +++ b/app/docker/helpers/labelHelper.js @@ -0,0 +1,26 @@ +angular.module('portainer.docker').factory('LabelHelper', [ + function LabelHelperFactory() { + 'use strict'; + return { + fromLabelHashToKeyValue: function (labels) { + if (labels) { + return Object.keys(labels).map(function (key) { + return { key: key, value: labels[key], originalKey: key, originalValue: labels[key], added: true }; + }); + } + return []; + }, + fromKeyValueToLabelHash: function (labelKV) { + var labels = {}; + if (labelKV) { + labelKV.forEach(function (label) { + if (label.key) { + labels[label.key] = label.value; + } + }); + } + return labels; + }, + }; + }, +]); diff --git a/app/docker/helpers/logHelper/colors/colors.ts b/app/docker/helpers/logHelper/colors/colors.ts new file mode 100644 index 0000000..f9183c2 --- /dev/null +++ b/app/docker/helpers/logHelper/colors/colors.ts @@ -0,0 +1,63 @@ +// original code comes from https://www.npmjs.com/package/x256 +// only picking the used parts as there is no type definition +// package is unmaintained and repository doesn't exist anymore + +// colors scraped from +// http://www.calmar.ws/vim/256-xterm-24bit-rgb-color-chart.html +// %s/ *\d\+ \+#\([^ ]\+\)/\1\r/g + +import rawColors from './rawColors.json'; + +export type RGBColor = [number, number, number]; +export type TextColor = string | undefined; + +function hexToRGB(hex: string): RGBColor { + const r = parseInt(hex.slice(0, 2), 16); + const g = parseInt(hex.slice(2, 4), 16); + const b = parseInt(hex.slice(4, 6), 16); + return [r, g, b]; +} + +export const colors = rawColors.map(hexToRGB); + +export const FOREGROUND_COLORS_BY_ANSI: { + [k: string]: RGBColor; +} = { + black: colors[0], + red: colors[1], + green: colors[2], + yellow: colors[3], + blue: colors[4], + magenta: colors[5], + cyan: colors[6], + white: colors[7], + brightBlack: colors[8], + brightRed: colors[9], + brightGreen: colors[10], + brightYellow: colors[11], + brightBlue: colors[12], + brightMagenta: colors[13], + brightCyan: colors[14], + brightWhite: colors[15], +}; + +export const BACKGROUND_COLORS_BY_ANSI: { + [k: string]: RGBColor; +} = { + bgBlack: colors[0], + bgRed: colors[1], + bgGreen: colors[2], + bgYellow: colors[3], + bgBlue: colors[4], + bgMagenta: colors[5], + bgCyan: colors[6], + bgWhite: colors[7], + bgBrightBlack: colors[8], + bgBrightRed: colors[9], + bgBrightGreen: colors[10], + bgBrightYellow: colors[11], + bgBrightBlue: colors[12], + bgBrightMagenta: colors[13], + bgBrightCyan: colors[14], + bgBrightWhite: colors[15], +}; diff --git a/app/docker/helpers/logHelper/colors/index.ts b/app/docker/helpers/logHelper/colors/index.ts new file mode 100644 index 0000000..25eed22 --- /dev/null +++ b/app/docker/helpers/logHelper/colors/index.ts @@ -0,0 +1,7 @@ +export { + type RGBColor, + type TextColor, + colors, + FOREGROUND_COLORS_BY_ANSI, + BACKGROUND_COLORS_BY_ANSI, +} from './colors'; diff --git a/app/docker/helpers/logHelper/colors/rawColors.json b/app/docker/helpers/logHelper/colors/rawColors.json new file mode 100644 index 0000000..b9bba82 --- /dev/null +++ b/app/docker/helpers/logHelper/colors/rawColors.json @@ -0,0 +1,258 @@ +[ + "000000", + "800000", + "008000", + "808000", + "000080", + "800080", + "008080", + "c0c0c0", + "808080", + "ff0000", + "00ff00", + "ffff00", + "0000ff", + "ff00ff", + "00ffff", + "ffffff", + "000000", + "00005f", + "000087", + "0000af", + "0000d7", + "0000ff", + "005f00", + "005f5f", + "005f87", + "005faf", + "005fd7", + "005fff", + "008700", + "00875f", + "008787", + "0087af", + "0087d7", + "0087ff", + "00af00", + "00af5f", + "00af87", + "00afaf", + "00afd7", + "00afff", + "00d700", + "00d75f", + "00d787", + "00d7af", + "00d7d7", + "00d7ff", + "00ff00", + "00ff5f", + "00ff87", + "00ffaf", + "00ffd7", + "00ffff", + "5f0000", + "5f005f", + "5f0087", + "5f00af", + "5f00d7", + "5f00ff", + "5f5f00", + "5f5f5f", + "5f5f87", + "5f5faf", + "5f5fd7", + "5f5fff", + "5f8700", + "5f875f", + "5f8787", + "5f87af", + "5f87d7", + "5f87ff", + "5faf00", + "5faf5f", + "5faf87", + "5fafaf", + "5fafd7", + "5fafff", + "5fd700", + "5fd75f", + "5fd787", + "5fd7af", + "5fd7d7", + "5fd7ff", + "5fff00", + "5fff5f", + "5fff87", + "5fffaf", + "5fffd7", + "5fffff", + "870000", + "87005f", + "870087", + "8700af", + "8700d7", + "8700ff", + "875f00", + "875f5f", + "875f87", + "875faf", + "875fd7", + "875fff", + "878700", + "87875f", + "878787", + "8787af", + "8787d7", + "8787ff", + "87af00", + "87af5f", + "87af87", + "87afaf", + "87afd7", + "87afff", + "87d700", + "87d75f", + "87d787", + "87d7af", + "87d7d7", + "87d7ff", + "87ff00", + "87ff5f", + "87ff87", + "87ffaf", + "87ffd7", + "87ffff", + "af0000", + "af005f", + "af0087", + "af00af", + "af00d7", + "af00ff", + "af5f00", + "af5f5f", + "af5f87", + "af5faf", + "af5fd7", + "af5fff", + "af8700", + "af875f", + "af8787", + "af87af", + "af87d7", + "af87ff", + "afaf00", + "afaf5f", + "afaf87", + "afafaf", + "afafd7", + "afafff", + "afd700", + "afd75f", + "afd787", + "afd7af", + "afd7d7", + "afd7ff", + "afff00", + "afff5f", + "afff87", + "afffaf", + "afffd7", + "afffff", + "d70000", + "d7005f", + "d70087", + "d700af", + "d700d7", + "d700ff", + "d75f00", + "d75f5f", + "d75f87", + "d75faf", + "d75fd7", + "d75fff", + "d78700", + "d7875f", + "d78787", + "d787af", + "d787d7", + "d787ff", + "d7af00", + "d7af5f", + "d7af87", + "d7afaf", + "d7afd7", + "d7afff", + "d7d700", + "d7d75f", + "d7d787", + "d7d7af", + "d7d7d7", + "d7d7ff", + "d7ff00", + "d7ff5f", + "d7ff87", + "d7ffaf", + "d7ffd7", + "d7ffff", + "ff0000", + "ff005f", + "ff0087", + "ff00af", + "ff00d7", + "ff00ff", + "ff5f00", + "ff5f5f", + "ff5f87", + "ff5faf", + "ff5fd7", + "ff5fff", + "ff8700", + "ff875f", + "ff8787", + "ff87af", + "ff87d7", + "ff87ff", + "ffaf00", + "ffaf5f", + "ffaf87", + "ffafaf", + "ffafd7", + "ffafff", + "ffd700", + "ffd75f", + "ffd787", + "ffd7af", + "ffd7d7", + "ffd7ff", + "ffff00", + "ffff5f", + "ffff87", + "ffffaf", + "ffffd7", + "ffffff", + "080808", + "121212", + "1c1c1c", + "262626", + "303030", + "3a3a3a", + "444444", + "4e4e4e", + "585858", + "606060", + "666666", + "767676", + "808080", + "8a8a8a", + "949494", + "9e9e9e", + "a8a8a8", + "b2b2b2", + "bcbcbc", + "c6c6c6", + "d0d0d0", + "dadada", + "e4e4e4", + "eeeeee" +] diff --git a/app/docker/helpers/logHelper/concatLogsToString.ts b/app/docker/helpers/logHelper/concatLogsToString.ts new file mode 100644 index 0000000..1640895 --- /dev/null +++ b/app/docker/helpers/logHelper/concatLogsToString.ts @@ -0,0 +1,14 @@ +import { NEW_LINE_BREAKER } from './constants'; +import { FormattedLine } from './types'; + +type FormatFunc = (line: FormattedLine) => string; + +export function concatLogsToString( + logs: FormattedLine[], + formatFunc: FormatFunc = (line) => line.line +) { + return logs.reduce( + (acc, formattedLine) => acc + formatFunc(formattedLine) + NEW_LINE_BREAKER, + '' + ); +} diff --git a/app/docker/helpers/logHelper/constants.ts b/app/docker/helpers/logHelper/constants.ts new file mode 100644 index 0000000..c0e22d2 --- /dev/null +++ b/app/docker/helpers/logHelper/constants.ts @@ -0,0 +1,3 @@ +import { BROWSER_OS_PLATFORM } from '@/react/constants'; + +export const NEW_LINE_BREAKER = BROWSER_OS_PLATFORM === 'win' ? '\r\n' : '\n'; diff --git a/app/docker/helpers/logHelper/formatJSONLogs.ts b/app/docker/helpers/logHelper/formatJSONLogs.ts new file mode 100644 index 0000000..7d0d74c --- /dev/null +++ b/app/docker/helpers/logHelper/formatJSONLogs.ts @@ -0,0 +1,55 @@ +import { without } from 'lodash'; + +import { FormattedLine, Span, JSONLogs, TIMESTAMP_LENGTH } from './types'; +import { + formatCaller, + formatKeyValuePair, + formatLevel, + formatMessage, + formatStackTrace, + formatTime, +} from './formatters'; + +function removeKnownKeys(keys: string[]) { + return without(keys, 'time', 'level', 'caller', 'message', 'stack_trace'); +} + +export function formatJSONLine( + rawText: string, + withTimestamps?: boolean +): FormattedLine[] { + const spans: Span[] = []; + const lines: FormattedLine[] = []; + let line = ''; + + const text = withTimestamps ? rawText.substring(TIMESTAMP_LENGTH) : rawText; + + const json: JSONLogs = JSON.parse(text); + const { time, level, caller, message, stack_trace: stackTrace } = json; + const keys = removeKnownKeys(Object.keys(json)); + + if (withTimestamps) { + const timestamp = rawText.substring(0, TIMESTAMP_LENGTH); + spans.push({ text: timestamp }); + line += `${timestamp} `; + } + line += formatTime(time, spans, line); + line += formatLevel(level, spans, line); + line += formatCaller(caller, spans, line); + line += formatMessage(message, spans, line, !!keys.length); + + keys.forEach((key, idx) => { + line += formatKeyValuePair( + key, + json[key], + spans, + line, + idx === keys.length - 1 + ); + }); + + lines.push({ line, spans }); + formatStackTrace(stackTrace, lines); + + return lines; +} diff --git a/app/docker/helpers/logHelper/formatLogs.ts b/app/docker/helpers/logHelper/formatLogs.ts new file mode 100644 index 0000000..04d67aa --- /dev/null +++ b/app/docker/helpers/logHelper/formatLogs.ts @@ -0,0 +1,156 @@ +import tokenize from '@nxmix/tokenize-ansi'; +import { FontWeight } from '@xterm/xterm'; + +import { + colors, + BACKGROUND_COLORS_BY_ANSI, + FOREGROUND_COLORS_BY_ANSI, + RGBColor, +} from './colors'; +import { formatJSONLine } from './formatJSONLogs'; +import { formatZerologLogs, ZerologRegex } from './formatZerologLogs'; +import { Token, Span, TIMESTAMP_LENGTH, FormattedLine } from './types'; + +type FormatOptions = { + stripHeaders?: boolean; + withTimestamps?: boolean; + splitter?: string; +}; + +const defaultOptions: FormatOptions = { + splitter: '\n', +}; + +export function formatLogs( + rawLogs: string, + { + stripHeaders, + withTimestamps, + splitter = '\n', + }: FormatOptions = defaultOptions +) { + let logs = rawLogs; + if (stripHeaders) { + logs = stripHeadersFunc(logs); + } + // if JSON logs come serialized 2 times, parse them once to unwrap them + if (logs.startsWith('"')) { + try { + logs = JSON.parse(logs); + } catch (error) { + // noop, throw error away if logs cannot be parsed + } + } + + const tokens: Token[][] = tokenize(logs); + const formattedLogs: FormattedLine[] = []; + + let fgColor: string | undefined; + let bgColor: string | undefined; + let fontWeight: FontWeight | undefined; + let line = ''; + let spans: Span[] = []; + + tokens.forEach((token) => { + const [type] = token; + + const fgAnsi = FOREGROUND_COLORS_BY_ANSI[type]; + const bgAnsi = BACKGROUND_COLORS_BY_ANSI[type]; + + if (fgAnsi) { + fgColor = cssColorFromRgb(fgAnsi); + } else if (type === 'moreColor') { + fgColor = extendedColorForToken(token); + } else if (type === 'fgDefault') { + fgColor = undefined; + } else if (bgAnsi) { + bgColor = cssColorFromRgb(bgAnsi); + } else if (type === 'bgMoreColor') { + bgColor = extendedColorForToken(token); + } else if (type === 'bgDefault') { + bgColor = undefined; + } else if (type === 'reset') { + fgColor = undefined; + bgColor = undefined; + fontWeight = undefined; + } else if (type === 'bold') { + fontWeight = 'bold'; + } else if (type === 'normal') { + fontWeight = 'normal'; + } else if (type === 'text') { + const tokenLines = (token[1] as string).split(splitter); + + tokenLines.forEach((tokenLine, idx) => { + if (idx && line) { + formattedLogs.push({ line, spans }); + line = ''; + spans = []; + } + + const text = stripEscapeCodes(tokenLine); + try { + if ( + (!withTimestamps && text.startsWith('{')) || + (withTimestamps && text.substring(TIMESTAMP_LENGTH).startsWith('{')) + ) { + const lines = formatJSONLine(text, withTimestamps); + formattedLogs.push(...lines); + } else if ( + (!withTimestamps && ZerologRegex.test(text)) || + (withTimestamps && + ZerologRegex.test(text.substring(TIMESTAMP_LENGTH))) + ) { + const lines = formatZerologLogs(text, withTimestamps); + formattedLogs.push(...lines); + } else { + spans.push({ fgColor, bgColor, text, fontWeight }); + line += text; + } + } catch (error) { + // in case parsing fails for whatever reason, push the raw logs and continue + spans.push({ fgColor, bgColor, text, fontWeight }); + line += text; + } + }); + } + }); + if (line) { + formattedLogs.push({ line, spans }); + } + + return formattedLogs; +} + +function stripHeadersFunc(logs: string) { + return logs.substring(8).replace(/\r?\n(.{8})/g, '\n'); +} + +function stripEscapeCodes(logs: string) { + return logs.replace( + // eslint-disable-next-line no-control-regex + /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, + '' + ); +} + +function cssColorFromRgb(rgb: RGBColor) { + const [r, g, b] = rgb; + + return `rgb(${r}, ${g}, ${b})`; +} + +// assuming types based on original JS implementation +// there is not much type definitions for the tokenize library +function extendedColorForToken(token: Token[]) { + const [, colorMode, colorRef] = token as [undefined, number, number]; + + if (colorMode === 2) { + return cssColorFromRgb(token.slice(2) as RGBColor); + } + + if (colorMode === 5 && colors[colorRef]) { + return cssColorFromRgb(colors[colorRef]); + } + + return ''; +} diff --git a/app/docker/helpers/logHelper/formatZerologLogs.ts b/app/docker/helpers/logHelper/formatZerologLogs.ts new file mode 100644 index 0000000..f43aeb3 --- /dev/null +++ b/app/docker/helpers/logHelper/formatZerologLogs.ts @@ -0,0 +1,125 @@ +import { + formatCaller, + formatKeyValuePair, + formatLevel, + formatMessage, + formatStackTrace, + formatTime, +} from './formatters'; +import { + FormattedLine, + JSONStackTrace, + Level, + Span, + TIMESTAMP_LENGTH, +} from './types'; + +const dateRegex = /(\d{4}\/\d{2}\/\d{2} \d{2}:\d{2}[AP]M) /; // "2022/02/01 04:30AM " +const levelRegex = /(\w{3}) /; // "INF " or "ERR " +const callerRegex = /(.+?.go:\d+) /; // "path/to/file.go:line " +const chevRegex = /> /; // "> " +const messageAndPairsRegex = /(.*)/; // include the rest of the string in a separate group + +const keyRegex = /(\S+=)/g; // "" + +export const ZerologRegex = concatRegex( + dateRegex, + levelRegex, + callerRegex, + chevRegex, + messageAndPairsRegex +); + +function concatRegex(...regs: RegExp[]) { + const flags = Array.from( + new Set( + regs + .map((r) => r.flags) + .join('') + .split('') + ) + ).join(''); + const source = regs.map((r) => r.source).join(''); + return new RegExp(source, flags); +} + +type Pair = { + key: string; + value: string; +}; + +export function formatZerologLogs(rawText: string, withTimestamps?: boolean) { + const spans: Span[] = []; + const lines: FormattedLine[] = []; + let line = ''; + + const text = withTimestamps ? rawText.substring(TIMESTAMP_LENGTH) : rawText; + + if (withTimestamps) { + const timestamp = rawText.substring(0, TIMESTAMP_LENGTH); + spans.push({ text: timestamp }); + line += `${timestamp} `; + } + + const [, date, level, caller, messageAndPairs] = + text.match(ZerologRegex) || []; + + const [message, pairs] = extractPairs(messageAndPairs); + + line += formatTime(date, spans, line); + line += formatLevel(level as Level, spans, line); + line += formatCaller(caller, spans, line); + line += formatMessage(message, spans, line, !!pairs.length); + + let stackTrace: JSONStackTrace | undefined; + const stackTraceIndex = pairs.findIndex((p) => p.key === 'stack_trace'); + + if (stackTraceIndex !== -1) { + stackTrace = JSON.parse(pairs[stackTraceIndex].value); + pairs.splice(stackTraceIndex); + } + + pairs.forEach(({ key, value }, idx) => { + line += formatKeyValuePair( + key, + value, + spans, + line, + idx === pairs.length - 1 + ); + }); + lines.push({ line, spans }); + + formatStackTrace(stackTrace, lines); + + return lines; +} + +function extractPairs(messageAndPairs: string): [string, Pair[]] { + const pairs: Pair[] = []; + let [message, rawPairs] = messageAndPairs.split('|'); + + if (!messageAndPairs.includes('|') && !rawPairs) { + rawPairs = message; + message = ''; + } + message = message.trim(); + rawPairs = rawPairs.trim(); + + const matches = [...rawPairs.matchAll(keyRegex)]; + + matches.forEach((m, idx) => { + const rawKey = m[0]; + const key = rawKey.slice(0, -1); + const start = m.index || 0; + const end = idx !== matches.length - 1 ? matches[idx + 1].index : undefined; + const value = ( + end + ? rawPairs.slice(start + rawKey.length, end) + : rawPairs.slice(start + rawKey.length) + ).trim(); + pairs.push({ key, value }); + }); + + return [message, pairs]; +} diff --git a/app/docker/helpers/logHelper/formatters.ts b/app/docker/helpers/logHelper/formatters.ts new file mode 100644 index 0000000..f48caae --- /dev/null +++ b/app/docker/helpers/logHelper/formatters.ts @@ -0,0 +1,161 @@ +import { format } from 'date-fns'; +import { takeRight } from 'lodash'; + +import { Span, Level, Colors, JSONStackTrace, FormattedLine } from './types'; + +const spaceSpan: Span = { text: ' ' }; + +function logLevelToSpan(level: Level): Span { + switch (level) { + case 'debug': + case 'DBG': + return { + fgColor: Colors.Grey, + text: 'DBG', + fontWeight: 'bold', + }; + case 'info': + case 'INF': + return { + fgColor: Colors.Green, + text: 'INF', + fontWeight: 'bold', + }; + case 'warn': + case 'WRN': + return { + fgColor: Colors.Yellow, + text: 'WRN', + fontWeight: 'bold', + }; + case 'error': + case 'ERR': + return { + fgColor: Colors.Red, + text: 'ERR', + fontWeight: 'bold', + }; + default: + return { text: level }; + } +} + +export function formatTime( + time: number | string | undefined, + spans: Span[], + line: string +) { + let nl = line; + if (time) { + let date; + if (typeof time === 'number') { + // time is a number, so it is the number of seconds OR milliseconds since Unix Epoch (1970-01-01T00:00:00.000Z) + // we need to know if time's unit is second or millisecond + // 253402214400 is the numer of seconds between Unix Epoch and 9999-12-31T00:00:00.000Z + // if time is greater than 253402214400, then time unit cannot be second, so it is millisecond + const timestampInMilliseconds = time > 253402214400 ? time : time * 1000; + date = format(new Date(timestampInMilliseconds), 'Y/MM/dd hh:mmaa'); + } else { + date = time; + } + spans.push({ fgColor: Colors.Grey, text: date }, spaceSpan); + nl += `${date} `; + } + return nl; +} + +export function formatLevel( + level: Level | undefined, + spans: Span[], + line: string +) { + let nl = line; + if (level) { + const levelSpan = logLevelToSpan(level); + spans.push(levelSpan, spaceSpan); + nl += `${levelSpan.text} `; + } + return nl; +} + +export function formatCaller( + caller: string | undefined, + spans: Span[], + line: string +) { + let nl = line; + if (caller) { + const trim = takeRight(caller.split('/'), 2).join('/'); + spans.push( + { fgColor: Colors.Magenta, text: trim, fontWeight: 'bold' }, + spaceSpan + ); + spans.push({ fgColor: Colors.Blue, text: '>' }, spaceSpan); + nl += `${trim} > `; + } + return nl; +} + +export function formatMessage( + message: string, + spans: Span[], + line: string, + hasKeys: boolean +) { + let nl = line; + if (message) { + spans.push({ fgColor: Colors.Magenta, text: `${message}` }, spaceSpan); + nl += `${message} `; + + if (hasKeys) { + spans.push({ fgColor: Colors.Magenta, text: `|` }, spaceSpan); + nl += '| '; + } + } + return nl; +} + +export function formatKeyValuePair( + key: string, + value: unknown, + spans: Span[], + line: string, + isLastKey: boolean +) { + let nl = line; + + const strValue = typeof value !== 'string' ? JSON.stringify(value) : value; + + spans.push( + { fgColor: Colors.Blue, text: `${key}=` }, + { + fgColor: key === 'error' || key === 'ERR' ? Colors.Red : Colors.Magenta, + text: strValue, + } + ); + if (!isLastKey) spans.push(spaceSpan); + nl += `${key}=${strValue}${!isLastKey ? ' ' : ''}`; + + return nl; +} + +export function formatStackTrace( + stackTrace: JSONStackTrace | undefined, + lines: FormattedLine[] +) { + if (stackTrace) { + stackTrace.forEach(({ func, line: lineNumber, source }) => { + const line = ` at ${func} (${source}:${lineNumber})`; + const spans: Span[] = [ + spaceSpan, + spaceSpan, + spaceSpan, + spaceSpan, + { text: 'at ', fgColor: Colors.Grey }, + { text: func, fgColor: Colors.Red }, + { text: `(${source}:${lineNumber})`, fgColor: Colors.Grey }, + ]; + lines.push({ line, spans }); + }); + } +} diff --git a/app/docker/helpers/logHelper/index.ts b/app/docker/helpers/logHelper/index.ts new file mode 100644 index 0000000..9b9bfe1 --- /dev/null +++ b/app/docker/helpers/logHelper/index.ts @@ -0,0 +1,3 @@ +export { formatLogs } from './formatLogs'; +export { concatLogsToString } from './concatLogsToString'; +export { NEW_LINE_BREAKER } from './constants'; diff --git a/app/docker/helpers/logHelper/types.ts b/app/docker/helpers/logHelper/types.ts new file mode 100644 index 0000000..4cf11f8 --- /dev/null +++ b/app/docker/helpers/logHelper/types.ts @@ -0,0 +1,53 @@ +import { FontWeight } from '@xterm/xterm'; + +import { type TextColor } from './colors'; + +export type Token = string | number; + +export type Level = + | 'debug' + | 'info' + | 'warn' + | 'error' + | 'DBG' + | 'INF' + | 'WRN' + | 'ERR'; + +export type JSONStackTrace = { + func: string; + line: string; + source: string; +}[]; + +export type JSONLogs = { + [k: string]: unknown; + time: number; + level: Level; + caller: string; + message: string; + stack_trace?: JSONStackTrace; +}; + +export type Span = { + fgColor?: TextColor; + bgColor?: TextColor; + text: string; + fontWeight?: FontWeight; +}; + +export type FormattedLine = { + spans: Span[]; + line: string; +}; + +export const TIMESTAMP_LENGTH = 31; // 30 for timestamp + 1 for trailing space + +export const Colors = { + Grey: 'var(--text-log-viewer-color-json-grey)', + Magenta: 'var(--text-log-viewer-color-json-magenta)', + Yellow: 'var(--text-log-viewer-color-json-yellow)', + Green: 'var(--text-log-viewer-color-json-green)', + Red: 'var(--text-log-viewer-color-json-red)', + Blue: 'var(--text-log-viewer-color-json-blue)', +}; diff --git a/app/docker/helpers/networkHelper.js b/app/docker/helpers/networkHelper.js new file mode 100644 index 0000000..09e4abe --- /dev/null +++ b/app/docker/helpers/networkHelper.js @@ -0,0 +1,13 @@ +import _ from 'lodash-es'; + +class DockerNetworkHelper { + static getIPV4Configs(configs) { + return _.filter(configs, (config) => /^([0-9]{1,3}\.){3}[0-9]{1,3}(\/([0-9]|[1-2][0-9]|3[0-2]))?$/.test(config.Subnet)); + } + + static getIPV6Configs(configs) { + return _.without(configs, ...DockerNetworkHelper.getIPV4Configs(configs)); + } +} + +export default DockerNetworkHelper; diff --git a/app/docker/helpers/nodeHelper.js b/app/docker/helpers/nodeHelper.js new file mode 100644 index 0000000..a97a7b8 --- /dev/null +++ b/app/docker/helpers/nodeHelper.js @@ -0,0 +1,15 @@ +angular.module('portainer.docker').factory('NodeHelper', [ + function NodeHelperFactory() { + 'use strict'; + return { + nodeToConfig: function (node) { + return { + Name: node.Spec.Name, + Role: node.Spec.Role, + Labels: node.Spec.Labels, + Availability: node.Spec.Availability, + }; + }, + }; + }, +]); diff --git a/app/docker/helpers/secretHelper.js b/app/docker/helpers/secretHelper.js new file mode 100644 index 0000000..9948f84 --- /dev/null +++ b/app/docker/helpers/secretHelper.js @@ -0,0 +1,35 @@ +angular.module('portainer.docker').factory('SecretHelper', [ + function SecretHelperFactory() { + 'use strict'; + return { + flattenSecret: function (secret) { + if (secret) { + return { + Id: secret.SecretID, + Name: secret.SecretName, + FileName: secret.File.Name, + Uid: secret.File.UID, + Gid: secret.File.GID, + Mode: secret.File.Mode, + }; + } + return {}; + }, + secretConfig: function (secret) { + if (secret) { + return { + SecretID: secret.Id, + SecretName: secret.Name, + File: { + Name: secret.FileName, + UID: secret.Uid || '0', + GID: secret.Gid || '0', + Mode: secret.Mode || 444, + }, + }; + } + return {}; + }, + }; + }, +]); diff --git a/app/docker/helpers/serviceHelper.js b/app/docker/helpers/serviceHelper.js new file mode 100644 index 0000000..3ef5d8e --- /dev/null +++ b/app/docker/helpers/serviceHelper.js @@ -0,0 +1,208 @@ +import moment from 'moment'; + +angular.module('portainer.docker').factory('ServiceHelper', [ + function ServiceHelperFactory() { + 'use strict'; + + var helper = {}; + + helper.associateTasksToService = associateTasksToServiceAJS; + + function associateTasksToServiceAJS(service, tasks) { + service.Tasks = []; + var otherServicesTasks = []; + for (var i = 0; i < tasks.length; i++) { + var task = tasks[i]; + if (task.ServiceId === service.Id) { + service.Tasks.push(task); + task.ServiceName = service.Name; + } else { + otherServicesTasks.push(task); + } + } + tasks = otherServicesTasks; + } + + helper.translateKeyValueToPlacementPreferences = function (keyValuePreferences) { + if (keyValuePreferences) { + var preferences = []; + keyValuePreferences.forEach(function (preference) { + if (preference.strategy && preference.strategy !== '' && preference.value && preference.value !== '') { + switch (preference.strategy.toLowerCase()) { + case 'spread': + preferences.push({ + Spread: { + SpreadDescriptor: preference.value, + }, + }); + break; + } + } + }); + return preferences; + } + return []; + }; + + helper.translateKeyValueToPlacementConstraints = function (keyValueConstraints) { + if (keyValueConstraints) { + var constraints = []; + keyValueConstraints.forEach(function (constraint) { + if (constraint.key && constraint.key !== '' && constraint.value && constraint.value !== '') { + constraints.push(constraint.key + constraint.operator + constraint.value); + } + }); + return constraints; + } + return []; + }; + + helper.translatePreferencesToKeyValue = function (preferences) { + if (preferences) { + var keyValuePreferences = []; + preferences.forEach(function (preference) { + if (preference.Spread) { + keyValuePreferences.push({ + strategy: 'Spread', + value: preference.Spread.SpreadDescriptor, + }); + } + }); + return keyValuePreferences; + } + return []; + }; + + helper.translateConstraintsToKeyValue = function (constraints) { + function getOperator(constraint) { + var indexEquals = constraint.indexOf('=='); + if (indexEquals >= 0) { + return [indexEquals, '==']; + } + return [constraint.indexOf('!='), '!=']; + } + if (constraints) { + var keyValueConstraints = []; + constraints.forEach(function (constraint) { + var operatorIndices = getOperator(constraint); + + var key = constraint.slice(0, operatorIndices[0]); + var operator = operatorIndices[1]; + var value = constraint.slice(operatorIndices[0] + 2); + + keyValueConstraints.push({ + key: key, + value: value, + operator: operator, + originalKey: key, + originalValue: value, + }); + }); + return keyValueConstraints; + } + }; + + helper.translateHumanDurationToNanos = function (humanDuration) { + var nanos; + var regex = /^([0-9]+)(h|m|s|ms|us|ns)$/i; + var matches = humanDuration.match(regex); + + if (matches !== null && matches.length === 3) { + var duration = parseInt(matches[1], 10); + var unit = matches[2]; + // Moment.js cannot use micro or nanoseconds + switch (unit) { + case 'ns': + nanos = duration; + break; + case 'us': + nanos = duration * 1000; + break; + default: + nanos = moment.duration(duration, unit).asMilliseconds() * 1000000; + } + } + return nanos; + }; + + // Convert nanoseconds to the higher unit possible + // e.g 1840 nanoseconds = 1804ns + // e.g 300000000000 nanoseconds = 5m + // e.g 3510000000000 nanoseconds = 3510s + // e.g 3540000000000 nanoseconds = 59m + // e.g 3600000000000 nanoseconds = 1h + + helper.translateNanosToHumanDuration = function (nanos) { + var humanDuration = '0s'; + var conversionFromNano = {}; + conversionFromNano['ns'] = 1; + conversionFromNano['us'] = conversionFromNano['ns'] * 1000; + conversionFromNano['ms'] = conversionFromNano['us'] * 1000; + conversionFromNano['s'] = conversionFromNano['ms'] * 1000; + conversionFromNano['m'] = conversionFromNano['s'] * 60; + conversionFromNano['h'] = conversionFromNano['m'] * 60; + + Object.keys(conversionFromNano).forEach(function (unit) { + if (nanos % conversionFromNano[unit] === 0 && nanos / conversionFromNano[unit] > 0) { + humanDuration = nanos / conversionFromNano[unit] + unit; + } + }); + return humanDuration; + }; + + helper.translateLogDriverOptsToKeyValue = function (logOptions) { + var options = []; + if (logOptions) { + Object.keys(logOptions).forEach(function (key) { + options.push({ + key: key, + value: logOptions[key], + originalKey: key, + originalValue: logOptions[key], + added: true, + }); + }); + } + return options; + }; + + helper.translateKeyValueToLogDriverOpts = function (keyValueLogDriverOpts) { + var options = {}; + if (keyValueLogDriverOpts) { + keyValueLogDriverOpts.forEach(function (option) { + if (option.key && option.key !== '' && option.value && option.value !== '') { + options[option.key] = option.value; + } + }); + } + return options; + }; + + helper.translateHostsEntriesToHostnameIP = function (entries) { + var ipHostEntries = []; + if (entries) { + entries.forEach(function (entry) { + if (entry.indexOf(' ') && entry.split(' ').length === 2) { + var keyValue = entry.split(' '); + ipHostEntries.push({ hostname: keyValue[1], ip: keyValue[0] }); + } + }); + } + return ipHostEntries; + }; + + helper.translateHostnameIPToHostsEntries = function (entries) { + var ipHostEntries = []; + if (entries) { + entries.forEach(function (entry) { + if (entry.ip && entry.hostname) { + ipHostEntries.push(entry.ip + ' ' + entry.hostname); + } + }); + } + return ipHostEntries; + }; + + return helper; + }, +]); diff --git a/app/docker/helpers/splitargs.test.ts b/app/docker/helpers/splitargs.test.ts new file mode 100644 index 0000000..dbdcc79 --- /dev/null +++ b/app/docker/helpers/splitargs.test.ts @@ -0,0 +1,68 @@ +/** + * Created by elgs on 7/2/14. + */ + +import { splitargs } from './splitargs'; + +describe('splitargs Suite', () => { + beforeEach(() => {}); + afterEach(() => {}); + + it('should split double quoted string', () => { + const i = " I said 'I am sorry.', and he said \"it doesn't matter.\" "; + const o = splitargs(i); + expect(7).toBe(o.length); + expect(o[0]).toBe('I'); + expect(o[1]).toBe('said'); + expect(o[2]).toBe('I am sorry.,'); + expect(o[3]).toBe('and'); + expect(o[4]).toBe('he'); + expect(o[5]).toBe('said'); + expect(o[6]).toBe("it doesn't matter."); + }); + + it('should split pure double quoted string', () => { + const i = 'I said "I am sorry.", and he said "it doesn\'t matter."'; + const o = splitargs(i); + expect(o).toHaveLength(7); + expect(o[0]).toBe('I'); + expect(o[1]).toBe('said'); + expect(o[2]).toBe('I am sorry.,'); + expect(o[3]).toBe('and'); + expect(o[4]).toBe('he'); + expect(o[5]).toBe('said'); + expect(o[6]).toBe("it doesn't matter."); + }); + + it('should split single quoted string', () => { + const i = 'I said "I am sorry.", and he said "it doesn\'t matter."'; + const o = splitargs(i); + expect(o).toHaveLength(7); + expect(o[0]).toBe('I'); + expect(o[1]).toBe('said'); + expect(o[2]).toBe('I am sorry.,'); + expect(o[3]).toBe('and'); + expect(o[4]).toBe('he'); + expect(o[5]).toBe('said'); + expect(o[6]).toBe("it doesn't matter."); + }); + + it('should split pure single quoted string', () => { + const i = "I said 'I am sorry.', and he said \"it doesn't matter.\""; + const o = splitargs(i); + expect(o).toHaveLength(7); + expect(o[0]).toBe('I'); + expect(o[1]).toBe('said'); + expect(o[2]).toBe('I am sorry.,'); + expect(o[3]).toBe('and'); + expect(o[4]).toBe('he'); + expect(o[5]).toBe('said'); + expect(o[6]).toBe("it doesn't matter."); + }); + + it('should split to 4 empty strings', () => { + const i = ',,,'; + const o = splitargs(i, ',', true); + expect(o).toHaveLength(4); + }); +}); diff --git a/app/docker/helpers/splitargs.ts b/app/docker/helpers/splitargs.ts new file mode 100644 index 0000000..9cbcc8c --- /dev/null +++ b/app/docker/helpers/splitargs.ts @@ -0,0 +1,114 @@ +/** + +Splits strings into tokens by given separator except treating quoted part as a single token. + + +#Usage +```javascript +var splitargs = require('splitargs'); + +var i1 = "I said 'I am sorry.', and he said \"it doesn't matter.\""; +var o1 = splitargs(i1); +console.log(o1); + +[ 'I', + 'said', + 'I am sorry.,', + 'and', + 'he', + 'said', + 'it doesn\'t matter.' ] + + +var i2 = "I said \"I am sorry.\", and he said \"it doesn't matter.\""; +var o2 = splitargs(i2); +console.log(o2); + +[ 'I', + 'said', + 'I am sorry.,', + 'and', + 'he', + 'said', + 'it doesn\'t matter.' ] + + +var i3 = 'I said "I am sorry.", and he said "it doesn\'t matter."'; +var o3 = splitargs(i3); +console.log(o3); + +[ 'I', + 'said', + 'I am sorry.,', + 'and', + 'he', + 'said', + 'it doesn\'t matter.' ] + + +var i4 = 'I said \'I am sorry.\', and he said "it doesn\'t matter."'; +var o4 = splitargs(i4); +console.log(o4); + +[ 'I', + 'said', + 'I am sorry.,', + 'and', + 'he', + 'said', + 'it doesn\'t matter.' ] + ``` + */ + +export function splitargs( + input: string, + sep?: RegExp | string, + keepQuotes = false +) { + const separator = sep || /\s/g; + let singleQuoteOpen = false; + let doubleQuoteOpen = false; + let tokenBuffer = []; + const ret = []; + + const arr = input.split(''); + for (let i = 0; i < arr.length; ++i) { + const element = arr[i]; + const matches = element.match(separator); + // TODO rewrite without continue + /* eslint-disable no-continue */ + if (element === "'" && !doubleQuoteOpen) { + if (keepQuotes) { + tokenBuffer.push(element); + } + singleQuoteOpen = !singleQuoteOpen; + continue; + } else if (element === '"' && !singleQuoteOpen) { + if (keepQuotes) { + tokenBuffer.push(element); + } + doubleQuoteOpen = !doubleQuoteOpen; + continue; + } + /* eslint-enable no-continue */ + + if (!singleQuoteOpen && !doubleQuoteOpen && matches) { + if (tokenBuffer.length > 0) { + ret.push(tokenBuffer.join('')); + tokenBuffer = []; + } else if (sep) { + ret.push(element); + } + } else { + tokenBuffer.push(element); + } + } + + if (tokenBuffer.length > 0) { + ret.push(tokenBuffer.join('')); + } else if (sep) { + ret.push(''); + } + + return ret; +} diff --git a/app/docker/helpers/taskHelper.js b/app/docker/helpers/taskHelper.js new file mode 100644 index 0000000..6b7c7a9 --- /dev/null +++ b/app/docker/helpers/taskHelper.js @@ -0,0 +1,18 @@ +angular.module('portainer.docker').factory('TaskHelper', [ + function TaskHelperFactory() { + 'use strict'; + + var helper = {}; + + helper.associateContainerToTask = associateContainerToTaskAJS; + + return helper; + + function associateContainerToTaskAJS(task, containers) { + const container = containers.find((c) => c.Id === task.ContainerId); + if (container) { + task.Container = container; + } + } + }, +]); diff --git a/app/docker/helpers/volumeHelper.js b/app/docker/helpers/volumeHelper.js new file mode 100644 index 0000000..b856129 --- /dev/null +++ b/app/docker/helpers/volumeHelper.js @@ -0,0 +1,23 @@ +angular.module('portainer.docker').factory('VolumeHelper', [ + function VolumeHelperFactory() { + 'use strict'; + var helper = {}; + + helper.isVolumeUsedByAService = function (volume, services) { + for (var i = 0; i < services.length; i++) { + var service = services[i]; + var mounts = service.Mounts; + for (var j = 0; j < mounts.length; j++) { + var mount = mounts[j]; + if (mount.Source === volume.Id) { + return true; + } + } + } + + return false; + }; + + return helper; + }, +]); diff --git a/app/docker/models/build.ts b/app/docker/models/build.ts new file mode 100644 index 0000000..ac480f5 --- /dev/null +++ b/app/docker/models/build.ts @@ -0,0 +1,33 @@ +type Data = { + stream: string; + errorDetail: { message: string }; +}; + +export class ImageBuildModel { + hasError: boolean = false; + + buildLogs: string[]; + + constructor(data: Data[]) { + const buildLogs: string[] = []; + + data.forEach((line) => { + if (line.stream) { + // convert unicode chars to readable chars + const logLine = line.stream.replace( + // eslint-disable-next-line no-control-regex + /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, + '' + ); + buildLogs.push(logLine); + } + + if (line.errorDetail) { + buildLogs.push(line.errorDetail.message); + this.hasError = true; + } + }); + + this.buildLogs = buildLogs; + } +} diff --git a/app/docker/models/config.ts b/app/docker/models/config.ts new file mode 100644 index 0000000..13214ee --- /dev/null +++ b/app/docker/models/config.ts @@ -0,0 +1,54 @@ +import { Config } from 'docker-types'; + +import { IResource } from '@/react/docker/components/datatable/createOwnershipColumn'; +import { PortainerResponse } from '@/react/docker/types'; +import { ResourceControlViewModel } from '@/react/portainer/access-control/models/ResourceControlViewModel'; + +export class ConfigViewModel implements IResource { + Id: string; + + CreatedAt: string; + + UpdatedAt: string; + + Version: number; + + Name: string; + + Labels: Record; + + Data: string; + + ResourceControl?: ResourceControlViewModel; + + constructor(data: PortainerResponse) { + this.Id = data.ID || ''; + this.CreatedAt = data.CreatedAt || ''; + this.UpdatedAt = data.UpdatedAt || ''; + this.Version = data.Version?.Index || 0; + this.Name = data.Spec?.Name || ''; + this.Labels = data.Spec?.Labels || {}; + this.Data = b64DecodeUnicode(data.Spec?.Data || ''); + + if (data.Portainer && data.Portainer.ResourceControl) { + this.ResourceControl = new ResourceControlViewModel( + data.Portainer.ResourceControl + ); + } + } +} + +function b64DecodeUnicode(str: string) { + try { + return decodeURIComponent( + window + .atob(str) + .toString() + .split('') + .map((c) => `%${`00${c.charCodeAt(0).toString(16)}`.slice(-2)}`) + .join('') + ); + } catch (err) { + return window.atob(str); + } +} diff --git a/app/docker/models/containerDetails.ts b/app/docker/models/containerDetails.ts new file mode 100644 index 0000000..e0f50dd --- /dev/null +++ b/app/docker/models/containerDetails.ts @@ -0,0 +1,56 @@ +import { IResource } from '@/react/docker/components/datatable/createOwnershipColumn'; +import { ContainerDetailsResponse } from '@/react/docker/containers/queries/useContainer'; +import { PortainerResponse } from '@/react/docker/types'; +import { ResourceControlViewModel } from '@/react/portainer/access-control/models/ResourceControlViewModel'; + +export class ContainerDetailsViewModel + implements IResource, Pick, 'IsPortainer'> +{ + Model: ContainerDetailsResponse; + + Id: ContainerDetailsResponse['Id']; + + State: ContainerDetailsResponse['State']; + + Created: ContainerDetailsResponse['Created']; + + Name: ContainerDetailsResponse['Name']; + + NetworkSettings: ContainerDetailsResponse['NetworkSettings']; + + Args: ContainerDetailsResponse['Args']; + + Image: ContainerDetailsResponse['Image']; + + Config: ContainerDetailsResponse['Config']; + + HostConfig: ContainerDetailsResponse['HostConfig']; + + Mounts: ContainerDetailsResponse['Mounts']; + + // IResource + ResourceControl?: ResourceControlViewModel; + + // PortainerResponse + IsPortainer?: ContainerDetailsResponse['IsPortainer']; + + constructor(data: ContainerDetailsResponse) { + this.Model = data; + this.Id = data.Id; + this.State = data.State; + this.Created = data.Created; + this.Name = data.Name; + this.NetworkSettings = data.NetworkSettings; + this.Args = data.Args; + this.Image = data.Image; + this.Config = data.Config; + this.HostConfig = data.HostConfig; + this.Mounts = data.Mounts; + if (data.Portainer && data.Portainer.ResourceControl) { + this.ResourceControl = new ResourceControlViewModel( + data.Portainer.ResourceControl + ); + } + this.IsPortainer = data.IsPortainer; + } +} diff --git a/app/docker/models/containerStats.ts b/app/docker/models/containerStats.ts new file mode 100644 index 0000000..5f0d9cf --- /dev/null +++ b/app/docker/models/containerStats.ts @@ -0,0 +1,121 @@ +import { values } from 'lodash'; + +import { ContainerStats } from '@/react/docker/containers/queries/useContainerStats'; +import { ValueOf } from '@/types'; + +/** + * This type is arbitrary and only defined based on what we use / observed from the API responses. + */ +export class ContainerStatsViewModel { + read: string; + + preread: string; + + MemoryUsage: number; + + MemoryCache: number = 0; + + NumProcs: number = 0; + + isWindows: boolean = false; + + PreviousCPUTotalUsage: number; + + PreviousCPUSystemUsage: number; + + CurrentCPUTotalUsage: number; + + CurrentCPUSystemUsage: number; + + CPUCores: number; + + Networks: ValueOf>[]; + + BytesRead: number = 0; + + BytesWrite: number = 0; + + noIOdata: boolean = false; + + constructor(data: ContainerStats) { + this.read = data.read || ''; + this.preread = data.preread || ''; + if (data?.memory_stats?.privateworkingset !== undefined) { + // Windows + this.MemoryUsage = data?.memory_stats?.privateworkingset; + this.MemoryCache = 0; + this.NumProcs = data.num_procs || 0; + this.isWindows = true; + } + // Podman has memory limit and usage but not stats + else if ( + data?.memory_stats?.usage !== undefined && + data?.memory_stats?.stats === undefined + ) { + this.MemoryUsage = data.memory_stats.usage || 0; + this.MemoryCache = 0; + } + // Linux + else if ( + data?.memory_stats?.stats === undefined || + data?.memory_stats?.usage === undefined + ) { + this.MemoryUsage = 0; + this.MemoryCache = 0; + } else { + this.MemoryCache = 0; + if (data?.memory_stats?.stats?.cache !== undefined) { + // cgroups v1 + this.MemoryCache = data.memory_stats.stats.cache; + } + this.MemoryUsage = data.memory_stats.usage - this.MemoryCache; + } + this.PreviousCPUTotalUsage = + data?.precpu_stats?.cpu_usage?.total_usage || 0; + this.PreviousCPUSystemUsage = data?.precpu_stats?.system_cpu_usage || 0; + this.CurrentCPUTotalUsage = data?.cpu_stats?.cpu_usage?.total_usage || 0; + this.CurrentCPUSystemUsage = data?.cpu_stats?.system_cpu_usage || 0; + this.CPUCores = 1; + + this.CPUCores = + data?.cpu_stats?.cpu_usage?.percpu_usage?.length ?? + data?.cpu_stats?.online_cpus ?? + 1; + + this.Networks = values(data.networks); + + if ( + data.blkio_stats !== undefined && + data.blkio_stats.io_service_bytes_recursive !== null + ) { + // TODO: take care of multiple block devices + let readData = data?.blkio_stats?.io_service_bytes_recursive?.find( + (d) => d.op === 'Read' + ); + if (readData === undefined) { + // try the cgroups v2 version + readData = data?.blkio_stats?.io_service_bytes_recursive?.find( + (d) => d.op === 'read' + ); + } + if (readData !== undefined) { + this.BytesRead = readData.value; + } + let writeData = data?.blkio_stats?.io_service_bytes_recursive?.find( + (d) => d.op === 'Write' + ); + if (writeData === undefined) { + // try the cgroups v2 version + writeData = data?.blkio_stats?.io_service_bytes_recursive?.find( + (d) => d.op === 'write' + ); + } + if (writeData !== undefined) { + this.BytesWrite = writeData.value; + } + } else { + // no IO related data is available + this.noIOdata = true; + } + } +} diff --git a/app/docker/models/image.ts b/app/docker/models/image.ts new file mode 100644 index 0000000..7142a52 --- /dev/null +++ b/app/docker/models/image.ts @@ -0,0 +1,47 @@ +import { ImageSummary } from 'docker-types'; + +import { PortainerResponse } from '@/react/docker/types'; + +export type ImageId = ImageSummary['Id']; +export type ImageName = string; + +/** + * Partial copy of ImageSummary + */ +export class ImageViewModel { + Id: ImageId; + + Created: ImageSummary['Created']; + + RepoTags: ImageSummary['RepoTags']; + + Size: ImageSummary['Size']; + + Labels: ImageSummary['Labels']; + + // internal + + NodeName: string; + + Used: boolean = false; + + constructor(data: PortainerResponse, used: boolean = false) { + this.Id = data.Id; + // this.Tag = data.Tag; // doesn't seem to be used? + // this.Repository = data.Repository; // doesn't seem to be used? + this.Created = data.Created; + this.RepoTags = data.RepoTags; + if ((!this.RepoTags || this.RepoTags.length === 0) && data.RepoDigests) { + this.RepoTags = []; + data.RepoDigests.forEach((digest) => { + const repository = digest.substring(0, digest.indexOf('@')); + this.RepoTags.push(`${repository}:`); + }); + } + + this.Size = data.Size; + this.NodeName = data.Portainer?.Agent?.NodeName || ''; + this.Labels = data.Labels; + this.Used = used; + } +} diff --git a/app/docker/models/imageDetails.ts b/app/docker/models/imageDetails.ts new file mode 100644 index 0000000..9115311 --- /dev/null +++ b/app/docker/models/imageDetails.ts @@ -0,0 +1,69 @@ +import { ImageInspect } from 'docker-types'; + +type ImageInspectConfig = NonNullable; + +export class ImageDetailsViewModel { + Id: ImageInspect['Id']; + + Parent: ImageInspect['Parent']; + + Created: ImageInspect['Created']; + + RepoTags: ImageInspect['RepoTags']; + + Size: ImageInspect['Size']; + + DockerVersion: ImageInspect['DockerVersion']; + + Os: ImageInspect['Os']; + + Architecture: ImageInspect['Architecture']; + + Author: ImageInspect['Author']; + + // Config sub fields + + Command: ImageInspectConfig['Cmd']; + + Entrypoint: Required; + + ExposedPorts: Required; + + Volumes: Required['Volumes']; + + Env: Required['Env']; + + Labels: ImageInspectConfig['Labels']; + + // computed fields + + Used: boolean = false; + + constructor(data: ImageInspect) { + this.Id = data.Id; + // this.Tag = data.Tag; // doesn't seem to be used? + this.Parent = data.Parent; + this.Created = data.Created; + // this.Repository = data.Repository; // doesn't seem to be used? + this.RepoTags = data.RepoTags; + this.Size = data.Size; + this.DockerVersion = data.DockerVersion; + this.Os = data.Os; + this.Architecture = data.Architecture; + this.Author = data.Author; + this.Command = data.Config?.Cmd; + + let config: ImageInspect['Config'] = {}; + if (data.Config) { + config = data.Config; // this is part of OCI images-spec + } + + this.Entrypoint = config.Entrypoint ?? ['']; + this.ExposedPorts = config.ExposedPorts + ? Object.keys(config.ExposedPorts) + : []; + this.Volumes = config.Volumes ? Object.keys(config.Volumes) : []; + this.Env = config.Env ?? []; + this.Labels = config.Labels; + } +} diff --git a/app/docker/models/imageLayer.ts b/app/docker/models/imageLayer.ts new file mode 100644 index 0000000..956ed36 --- /dev/null +++ b/app/docker/models/imageLayer.ts @@ -0,0 +1,27 @@ +import { ImageLayer } from '@/react/docker/proxy/queries/images/useImageHistory'; + +export class ImageLayerViewModel implements ImageLayer { + Id: ImageLayer['Id']; + + Created: ImageLayer['Created']; + + CreatedBy: ImageLayer['CreatedBy']; + + Size: ImageLayer['Size']; + + Comment: ImageLayer['Comment']; + + Tags: ImageLayer['Tags']; + + constructor( + public Order: number, + data: ImageLayer + ) { + this.Id = data.Id; + this.Created = data.Created; + this.CreatedBy = data.CreatedBy; + this.Size = data.Size; + this.Comment = data.Comment; + this.Tags = data.Tags; + } +} diff --git a/app/docker/models/network.ts b/app/docker/models/network.ts new file mode 100644 index 0000000..eded479 --- /dev/null +++ b/app/docker/models/network.ts @@ -0,0 +1,91 @@ +import { IPAM, Network, NetworkContainer } from 'docker-types'; + +import { ResourceControlViewModel } from '@/react/portainer/access-control/models/ResourceControlViewModel'; +import { IResource } from '@/react/docker/components/datatable/createOwnershipColumn'; +import { PortainerResponse } from '@/react/docker/types'; + +// TODO later: aggregate NetworkViewModel and DockerNetwork types +// +// type MacvlanNetwork = { +// ConfigFrom?: { Network: string }; +// ConfigOnly?: boolean; +// }; +// +// type NetworkViewModel = Network & { +// StackName?: string; +// NodeName?: string; +// ResourceControl?: ResourceControlViewModel; +// } & MacvlanNetwork; + +export class NetworkViewModel implements IResource { + Id: string; + + Name: string; + + Scope: string; + + Driver: string; + + Attachable: boolean; + + Internal: boolean; + + IPAM?: IPAM; + + Containers?: Record; + + Options?: Record; + + Ingress: boolean; + + Labels: Record; + + StackName?: string; + + NodeName?: string; + + ConfigFrom?: { Network: string }; + + ConfigOnly?: boolean; + + ResourceControl?: ResourceControlViewModel; + + constructor( + data: PortainerResponse & { + ConfigFrom?: { Network: string }; + ConfigOnly?: boolean; + } + ) { + this.Id = data.Id || ''; + this.Name = data.Name || ''; + this.Scope = data.Scope || ''; + this.Driver = data.Driver || ''; + this.Attachable = data.Attachable || false; + this.Internal = data.Internal || false; + this.IPAM = data.IPAM; + this.Containers = data.Containers; + this.Options = data.Options; + this.Ingress = data.Ingress || false; + + this.Labels = data.Labels || {}; + if (this.Labels && this.Labels['com.docker.compose.project']) { + this.StackName = this.Labels['com.docker.compose.project']; + } else if (this.Labels && this.Labels['com.docker.stack.namespace']) { + this.StackName = this.Labels['com.docker.stack.namespace']; + } + + if (data.Portainer) { + if (data.Portainer.ResourceControl) { + this.ResourceControl = new ResourceControlViewModel( + data.Portainer.ResourceControl + ); + } + if (data.Portainer.Agent && data.Portainer.Agent.NodeName) { + this.NodeName = data.Portainer.Agent.NodeName; + } + } + + this.ConfigFrom = data.ConfigFrom; + this.ConfigOnly = data.ConfigOnly; + } +} diff --git a/app/docker/models/node.ts b/app/docker/models/node.ts new file mode 100644 index 0000000..ad84011 --- /dev/null +++ b/app/docker/models/node.ts @@ -0,0 +1,116 @@ +import { + Node, + EngineDescription, + ManagerStatus, + NodeDescription, + NodeSpec, + NodeStatus, + ObjectVersion, + Platform, + ResourceObject, +} from 'docker-types'; + +export class NodeViewModel { + Model: Node; + + Id: Node['ID']; + + Version: ObjectVersion['Index']; + + Name: NodeSpec['Name']; + + Role: NodeSpec['Role']; + + CreatedAt: Node['CreatedAt']; + + UpdatedAt: Node['UpdatedAt']; + + Availability: NodeSpec['Availability']; + + Labels: Array<{ + key: string; + value: string; + originalKey: string; + originalValue: string; + added: boolean; + }>; + + EngineLabels: Array<{ key: string; value: string }>; + + Hostname: NodeDescription['Hostname']; + + PlatformArchitecture: Platform['Architecture']; + + PlatformOS: Platform['OS']; + + CPUs: ResourceObject['NanoCPUs']; + + Memory: ResourceObject['MemoryBytes']; + + EngineVersion: EngineDescription['EngineVersion']; + + Plugins: EngineDescription['Plugins']; + + Status: NodeStatus['State']; + + Addr: Required['Addr'] = ''; + + Leader: ManagerStatus['Leader']; + + Reachability: ManagerStatus['Reachability']; + + ManagerAddr: ManagerStatus['Addr']; + + constructor(data: Node) { + this.Model = data; + this.Id = data.ID; + this.Version = data.Version?.Index; + this.Name = data.Spec?.Name; + this.Role = data.Spec?.Role; + this.CreatedAt = data.CreatedAt; + this.UpdatedAt = data.UpdatedAt; + this.Availability = data.Spec?.Availability; + + const labels = data.Spec?.Labels; + if (labels) { + this.Labels = Object.keys(labels).map((key) => ({ + key, + value: labels[key], + originalKey: key, + originalValue: labels[key], + added: true, + })); + } else { + this.Labels = []; + } + + const engineLabels = data.Description?.Engine?.Labels; + if (engineLabels) { + this.EngineLabels = Object.keys(engineLabels).map((key) => ({ + key, + value: engineLabels[key], + })); + } else { + this.EngineLabels = []; + } + + this.Hostname = data.Description?.Hostname; + this.PlatformArchitecture = data.Description?.Platform?.Architecture; + this.PlatformOS = data.Description?.Platform?.OS; + this.CPUs = data.Description?.Resources?.NanoCPUs; + this.Memory = data.Description?.Resources?.MemoryBytes; + this.EngineVersion = data.Description?.Engine?.EngineVersion; + this.Plugins = data.Description?.Engine?.Plugins; + this.Status = data.Status?.State; + + if (data.Status?.Addr) { + this.Addr = data.Status?.Addr; + } + + if (data.ManagerStatus) { + this.Leader = data.ManagerStatus.Leader; + this.Reachability = data.ManagerStatus.Reachability; + this.ManagerAddr = data.ManagerStatus.Addr; + } + } +} diff --git a/app/docker/models/porImageRegistry.js b/app/docker/models/porImageRegistry.js new file mode 100644 index 0000000..c34c318 --- /dev/null +++ b/app/docker/models/porImageRegistry.js @@ -0,0 +1,16 @@ +/** + * This model should be used with por-image-registry component + * And bound to the 'model' attribute + * + * // viewController.js + * + * this.imageModel = new PorImageRegistryModel(); + * + * // view.html + * + */ +export function PorImageRegistryModel() { + this.UseRegistry = true; + this.Registry = {}; + this.Image = ''; +} diff --git a/app/docker/models/secret.ts b/app/docker/models/secret.ts new file mode 100644 index 0000000..4d0c042 --- /dev/null +++ b/app/docker/models/secret.ts @@ -0,0 +1,36 @@ +import { Secret } from 'docker-types'; + +import { ResourceControlViewModel } from '@/react/portainer/access-control/models/ResourceControlViewModel'; +import { PortainerResponse } from '@/react/docker/types'; +import { IResource } from '@/react/docker/components/datatable/createOwnershipColumn'; + +export class SecretViewModel implements IResource { + Id: string; + + CreatedAt: string; + + UpdatedAt: string; + + Version: number; + + Name: string; + + Labels: Record; + + ResourceControl?: ResourceControlViewModel; + + constructor(data: PortainerResponse) { + this.Id = data.ID || ''; + this.CreatedAt = data.CreatedAt || ''; + this.UpdatedAt = data.UpdatedAt || ''; + this.Version = data.Version?.Index || 0; + this.Name = data.Spec?.Name || ''; + this.Labels = data.Spec?.Labels || {}; + + if (data.Portainer?.ResourceControl) { + this.ResourceControl = new ResourceControlViewModel( + data.Portainer.ResourceControl + ); + } + } +} diff --git a/app/docker/models/service.ts b/app/docker/models/service.ts new file mode 100644 index 0000000..3dd72e9 --- /dev/null +++ b/app/docker/models/service.ts @@ -0,0 +1,267 @@ +import { + EndpointPortConfig, + HealthConfig, + Mount, + Platform, + Service, + ServiceSpec, + TaskSpec, +} from 'docker-types'; + +import { ResourceControlViewModel } from '@/react/portainer/access-control/models/ResourceControlViewModel'; +import { PortainerResponse } from '@/react/docker/types'; + +import { TaskViewModel } from './task'; + +type ContainerSpec = Required['ContainerSpec']; + +export type ServiceId = string; + +export class ServiceViewModel { + Model: Service; + + Id: string; + + Tasks: TaskViewModel[]; + + Name: string; + + CreatedAt: string | undefined; + + UpdatedAt: string | undefined; + + Image: string | undefined; + + Version: number | undefined; + + Mode: string; + + Replicas: number | undefined; + + Running?: number; + + LimitNanoCPUs: number | undefined; + + LimitMemoryBytes: number | undefined; + + ReservationNanoCPUs: number | undefined; + + ReservationMemoryBytes: number | undefined; + + RestartCondition: string; + + RestartDelay: number; + + RestartMaxAttempts: number; + + RestartWindow: number; + + LogDriverName: string; + + LogDriverOpts: never[] | Record; + + Constraints: string[]; + + Preferences: { + Spread?: { SpreadDescriptor?: string | undefined } | undefined; + }[]; + + Platforms?: Array; + + Labels: Record | undefined; + + StackName?: string; + + ContainerLabels: Record | undefined; + + Command: string[] | undefined; + + Arguments: string[] | undefined; + + Hostname: string | undefined; + + Env: string[] | undefined; + + Dir: string | undefined; + + User: string | undefined; + + Groups: string[] | undefined; + + TTY: boolean | undefined; + + OpenStdin: boolean | undefined; + + ReadOnly: boolean | undefined; + + Mounts?: Array; + + StopSignal: string | undefined; + + StopGracePeriod: number | undefined; + + HealthCheck?: HealthConfig; + + Hosts: string[] | undefined; + + DNSConfig?: ContainerSpec['DNSConfig']; + + Secrets?: ContainerSpec['Secrets']; + + Configs: ContainerSpec['Configs']; + + Ports?: Array; + + LogDriver: TaskSpec['LogDriver']; + + Runtime: string | undefined; + + VirtualIPs: + | { NetworkID?: string | undefined; Addr?: string | undefined }[] + | undefined; + + UpdateParallelism: number; + + UpdateDelay: number; + + UpdateFailureAction: string; + + UpdateOrder: string; + + RollbackConfig: ServiceSpec['RollbackConfig']; + + Checked: boolean; + + Scale: boolean; + + EditName: boolean; + + ResourceControl?: ResourceControlViewModel; + + constructor(data: PortainerResponse) { + this.Model = data; + this.Id = data.ID || ''; + this.Tasks = []; + this.Name = data.Spec?.Name || ''; + this.CreatedAt = data.CreatedAt; + this.UpdatedAt = data.UpdatedAt; + this.Image = data.Spec?.TaskTemplate?.ContainerSpec?.Image; + this.Version = data.Version?.Index; + if (data.Spec?.Mode?.Replicated) { + this.Mode = 'replicated'; + this.Replicas = data.Spec.Mode.Replicated.Replicas; + } else { + this.Mode = 'global'; + } + + if (data.Spec?.TaskTemplate?.Resources) { + if (data.Spec.TaskTemplate.Resources.Limits) { + this.LimitNanoCPUs = data.Spec.TaskTemplate.Resources.Limits.NanoCPUs; + this.LimitMemoryBytes = + data.Spec.TaskTemplate.Resources.Limits.MemoryBytes; + } + if (data.Spec.TaskTemplate.Resources.Reservations) { + this.ReservationNanoCPUs = + data.Spec.TaskTemplate.Resources.Reservations.NanoCPUs; + this.ReservationMemoryBytes = + data.Spec.TaskTemplate.Resources.Reservations.MemoryBytes; + } + } + + if (data.Spec?.TaskTemplate?.RestartPolicy) { + this.RestartCondition = + data.Spec.TaskTemplate.RestartPolicy.Condition || 'any'; + this.RestartDelay = + data.Spec.TaskTemplate.RestartPolicy.Delay || 5000000000; + this.RestartMaxAttempts = + data.Spec.TaskTemplate.RestartPolicy.MaxAttempts || 0; + this.RestartWindow = data.Spec.TaskTemplate.RestartPolicy.Window || 0; + } else { + this.RestartCondition = 'any'; + this.RestartDelay = 5000000000; + this.RestartMaxAttempts = 0; + this.RestartWindow = 0; + } + + if (data.Spec?.TaskTemplate?.LogDriver) { + this.LogDriverName = data.Spec.TaskTemplate.LogDriver.Name || ''; + this.LogDriverOpts = data.Spec.TaskTemplate.LogDriver.Options || []; + } else { + this.LogDriverName = ''; + this.LogDriverOpts = []; + } + + this.Constraints = data.Spec?.TaskTemplate?.Placement + ? data.Spec.TaskTemplate.Placement.Constraints || [] + : []; + this.Preferences = data.Spec?.TaskTemplate?.Placement + ? data.Spec.TaskTemplate.Placement.Preferences || [] + : []; + this.Platforms = data.Spec?.TaskTemplate?.Placement?.Platforms || []; + this.Labels = data.Spec?.Labels; + if (this.Labels && this.Labels['com.docker.stack.namespace']) { + this.StackName = this.Labels['com.docker.stack.namespace']; + } + + const containerSpec = data.Spec?.TaskTemplate?.ContainerSpec; + if (containerSpec) { + this.ContainerLabels = containerSpec.Labels; + this.Command = containerSpec.Command; + this.Arguments = containerSpec.Args; + this.Hostname = containerSpec.Hostname; + this.Env = containerSpec.Env; + this.Dir = containerSpec.Dir; + this.User = containerSpec.User; + this.Groups = containerSpec.Groups; + this.TTY = containerSpec.TTY; + this.OpenStdin = containerSpec.OpenStdin; + this.ReadOnly = containerSpec.ReadOnly; + this.Mounts = containerSpec.Mounts || []; + this.StopSignal = containerSpec.StopSignal; + this.StopGracePeriod = containerSpec.StopGracePeriod; + this.HealthCheck = containerSpec.HealthCheck || {}; + this.Hosts = containerSpec.Hosts; + this.DNSConfig = containerSpec.DNSConfig; + this.Secrets = containerSpec.Secrets; + this.Configs = containerSpec.Configs; + } + if (data.Endpoint) { + this.Ports = data.Endpoint.Ports; + } + + this.LogDriver = data.Spec?.TaskTemplate?.LogDriver; + this.Runtime = data.Spec?.TaskTemplate?.Runtime; + + this.VirtualIPs = data.Endpoint ? data.Endpoint.VirtualIPs : []; + + if (data.Spec?.UpdateConfig) { + this.UpdateParallelism = + typeof data.Spec.UpdateConfig.Parallelism !== 'undefined' + ? data.Spec.UpdateConfig.Parallelism || 0 + : 1; + this.UpdateDelay = data.Spec.UpdateConfig.Delay || 0; + this.UpdateFailureAction = + data.Spec.UpdateConfig.FailureAction || 'pause'; + this.UpdateOrder = data.Spec.UpdateConfig.Order || 'stop-first'; + } else { + this.UpdateParallelism = 1; + this.UpdateDelay = 0; + this.UpdateFailureAction = 'pause'; + this.UpdateOrder = 'stop-first'; + } + + this.RollbackConfig = data.Spec?.RollbackConfig; + + this.Checked = false; + this.Scale = false; + this.EditName = false; + + if (data.Portainer) { + if (data.Portainer.ResourceControl) { + this.ResourceControl = new ResourceControlViewModel( + data.Portainer.ResourceControl + ); + } + } + } +} diff --git a/app/docker/models/task.ts b/app/docker/models/task.ts new file mode 100644 index 0000000..889560e --- /dev/null +++ b/app/docker/models/task.ts @@ -0,0 +1,38 @@ +import { Task } from 'docker-types'; + +import { DeepPick } from '@/types/deepPick'; + +export class TaskViewModel { + Id: NonNullable; + + Created: NonNullable; + + Updated: NonNullable; + + Slot: NonNullable; + + Spec?: Task['Spec']; + + Status?: Task['Status']; + + DesiredState: NonNullable; + + ServiceId: NonNullable; + + NodeId: NonNullable; + + ContainerId: DeepPick; + + constructor(data: Task) { + this.Id = data.ID || ''; + this.Created = data.CreatedAt || ''; + this.Updated = data.UpdatedAt || ''; + this.Slot = data.Slot || 0; + this.Spec = data.Spec; + this.Status = data.Status; + this.DesiredState = data.DesiredState || 'pending'; + this.ServiceId = data.ServiceID || ''; + this.NodeId = data.NodeID || ''; + this.ContainerId = data.Status?.ContainerStatus?.ContainerID || ''; + } +} diff --git a/app/docker/models/volume.ts b/app/docker/models/volume.ts new file mode 100644 index 0000000..4b9fe84 --- /dev/null +++ b/app/docker/models/volume.ts @@ -0,0 +1,63 @@ +import { Volume } from 'docker-types'; + +import { ResourceControlViewModel } from '@/react/portainer/access-control/models/ResourceControlViewModel'; +import { IResource } from '@/react/docker/components/datatable/createOwnershipColumn'; +import { PortainerResponse } from '@/react/docker/types'; + +export class VolumeViewModel implements IResource { + Id: string; + + Name: Volume['Name']; + + CreatedAt?: Volume['CreatedAt']; + + Driver: Volume['Driver']; + + Options: Volume['Options']; + + Labels: Volume['Labels']; + + Mountpoint: Volume['Mountpoint']; + + // Portainer properties + + ResourceId?: string; + + NodeName?: string; + + StackName?: string; + + ResourceControl?: ResourceControlViewModel; + + constructor(data: PortainerResponse & { ResourceID?: string }) { + this.Name = data.Name; + this.CreatedAt = data.CreatedAt; + this.Driver = data.Driver; + this.Options = data.Options; + this.Labels = data.Labels; + if (this.Labels && this.Labels['com.docker.compose.project']) { + this.StackName = this.Labels['com.docker.compose.project']; + } else if (this.Labels && this.Labels['com.docker.stack.namespace']) { + this.StackName = this.Labels['com.docker.stack.namespace']; + } + this.Mountpoint = data.Mountpoint; + + this.ResourceId = data.ResourceID; + + if (data.Portainer) { + if (data.Portainer.ResourceControl) { + this.ResourceControl = new ResourceControlViewModel( + data.Portainer.ResourceControl + ); + } + if (data.Portainer.Agent && data.Portainer.Agent.NodeName) { + this.NodeName = data.Portainer.Agent.NodeName; + } + } + + this.Id = data.Name; + if (this.NodeName) { + this.Id = `${data.Name}-${this.NodeName}`; + } + } +} diff --git a/app/docker/react/components/containers.ts b/app/docker/react/components/containers.ts new file mode 100644 index 0000000..31da495 --- /dev/null +++ b/app/docker/react/components/containers.ts @@ -0,0 +1,6 @@ +import angular from 'angular'; + +export const containersModule = angular.module( + 'portainer.docker.react.components.containers', + [] +).name; diff --git a/app/docker/react/components/index.ts b/app/docker/react/components/index.ts new file mode 100644 index 0000000..df6aa7c --- /dev/null +++ b/app/docker/react/components/index.ts @@ -0,0 +1,114 @@ +import angular from 'angular'; + +import { r2a } from '@/react-tools/react2angular'; +import { withControlledInput } from '@/react-tools/withControlledInput'; +import { withCurrentUser } from '@/react-tools/withCurrentUser'; +import { withReactQuery } from '@/react-tools/withReactQuery'; +import { withUIRouter } from '@/react-tools/withUIRouter'; +import { DockerfileDetails } from '@/react/docker/images/ItemView/DockerfileDetails'; +import { HealthStatus } from '@/react/docker/containers/ItemView/HealthStatus'; +import { GpusList } from '@/react/docker/host/SetupView/GpusList'; +import { InsightsBox } from '@/react/components/InsightsBox'; +import { BetaAlert } from '@/react/portainer/environments/update-schedules/common/BetaAlert'; +import { EventsDatatable } from '@/react/docker/events/EventsDatatables'; +import { AgentHostBrowser } from '@/react/docker/host/BrowseView/AgentHostBrowser'; +import { AgentVolumeBrowser } from '@/react/docker/volumes/BrowseView/AgentVolumeBrowser'; +import { ProcessesDatatable } from '@/react/docker/containers/StatsView/ProcessesDatatable'; +import { SecretsDatatable } from '@/react/docker/secrets/ListView/SecretsDatatable'; +import { StacksDatatable } from '@/react/docker/stacks/ListView/StacksDatatable'; +import { HostDetailsPanel } from '@/react/docker/host/HostDetailsPanel/HostDetailsPanel'; + +import { containersModule } from './containers'; +import { servicesModule } from './services'; +import { networksModule } from './networks'; +import { swarmModule } from './swarm'; +import { volumesModule } from './volumes'; +import { templatesModule } from './templates'; + +const ngModule = angular + .module('portainer.docker.react.components', [ + containersModule, + servicesModule, + networksModule, + swarmModule, + volumesModule, + templatesModule, + ]) + .component('dockerfileDetails', r2a(DockerfileDetails, ['image'])) + .component('dockerHealthStatus', r2a(HealthStatus, ['health'])) + .component( + 'gpusList', + r2a(withControlledInput(GpusList), ['value', 'onChange']) + ) + .component( + 'insightsBox', + r2a(InsightsBox, [ + 'header', + 'content', + 'insightCloseId', + 'type', + 'className', + ]) + ) + .component('betaAlert', r2a(BetaAlert, ['className', 'message', 'isHtml'])) + .component( + 'agentHostBrowserReact', + r2a(withUIRouter(withCurrentUser(AgentHostBrowser)), [ + 'dataset', + 'isRoot', + 'onBrowse', + 'onDelete', + 'onDownload', + 'onFileSelectedForUpload', + 'onGoToParent', + 'onRename', + 'relativePath', + ]) + ) + .component( + 'agentVolumeBrowserReact', + r2a(withUIRouter(withCurrentUser(AgentVolumeBrowser)), [ + 'dataset', + 'isRoot', + 'isUploadAllowed', + 'onBrowse', + 'onDelete', + 'onDownload', + 'onFileSelectedForUpload', + 'onGoToParent', + 'onRename', + 'relativePath', + ]) + ) + .component( + 'dockerContainerProcessesDatatable', + r2a(withUIRouter(withReactQuery(withCurrentUser(ProcessesDatatable))), []) + ) + .component('dockerEventsDatatable', r2a(EventsDatatable, ['dataset'])) + .component( + 'dockerSecretsDatatable', + r2a(withUIRouter(withCurrentUser(SecretsDatatable)), [ + 'dataset', + 'onRefresh', + 'onRemove', + ]) + ) + .component( + 'dockerStacksDatatable', + r2a(withUIRouter(withCurrentUser(StacksDatatable)), [ + 'dataset', + 'isImageNotificationEnabled', + 'onReload', + 'onRemove', + ]) + ) + .component( + 'hostDetailsPanel', + r2a(withUIRouter(withReactQuery(HostDetailsPanel)), [ + 'host', + 'isBrowseEnabled', + 'browseUrl', + 'endpointId', + ]) + ); +export const componentsModule = ngModule.name; diff --git a/app/docker/react/components/networks.ts b/app/docker/react/components/networks.ts new file mode 100644 index 0000000..eb6b4e7 --- /dev/null +++ b/app/docker/react/components/networks.ts @@ -0,0 +1,18 @@ +import angular from 'angular'; + +import { r2a } from '@/react-tools/react2angular'; +import { MacvlanNodesSelector } from '@/react/docker/networks/CreateView/MacvlanNodesSelector/MacvlanNodesSelector'; +import { withUIRouter } from '@/react-tools/withUIRouter'; + +export const networksModule = angular + .module('portainer.docker.react.components.networks', []) + .component( + 'macvlanNodesSelector', + r2a(withUIRouter(MacvlanNodesSelector), [ + 'dataset', + 'isIpColumnVisible', + 'haveAccessToNode', + 'value', + 'onChange', + ]) + ).name; diff --git a/app/docker/react/components/services.ts b/app/docker/react/components/services.ts new file mode 100644 index 0000000..d5abd06 --- /dev/null +++ b/app/docker/react/components/services.ts @@ -0,0 +1,46 @@ +import angular from 'angular'; +import { SchemaOf } from 'yup'; + +import { r2a } from '@/react-tools/react2angular'; +import { withUIRouter } from '@/react-tools/withUIRouter'; +import { withCurrentUser } from '@/react-tools/withCurrentUser'; +import { ServicesDatatable } from '@/react/docker/services/ListView/ServicesDatatable'; +import { TasksDatatable } from '@/react/docker/services/ItemView/TasksDatatable'; +import { + PortsMappingField, + portsMappingUtils, + PortsMappingValues, +} from '@/react/docker/services/ItemView/PortMappingField'; +import { withFormValidation } from '@/react-tools/withFormValidation'; + +const ngModule = angular + .module('portainer.docker.react.components.services', []) + .component( + 'dockerServiceTasksDatatable', + r2a(withUIRouter(withCurrentUser(TasksDatatable)), [ + 'serviceName', + 'dataset', + 'isSlotColumnVisible', + ]) + ) + .component( + 'dockerServicesDatatable', + r2a(withUIRouter(withCurrentUser(ServicesDatatable)), [ + 'dataset', + 'isAddActionVisible', + 'isStackColumnVisible', + 'onRefresh', + 'titleIcon', + 'tableKey', + ]) + ); + +export const servicesModule = ngModule.name; + +withFormValidation( + ngModule, + withUIRouter(withCurrentUser(PortsMappingField)), + 'dockerServicePortsMappingField', + ['disabled', 'readOnly', 'hasChanges', 'onReset', 'onSubmit'], + portsMappingUtils.validation as unknown as () => SchemaOf +); diff --git a/app/docker/react/components/swarm.ts b/app/docker/react/components/swarm.ts new file mode 100644 index 0000000..b96f9a6 --- /dev/null +++ b/app/docker/react/components/swarm.ts @@ -0,0 +1,17 @@ +import angular from 'angular'; + +import { r2a } from '@/react-tools/react2angular'; +import { withUIRouter } from '@/react-tools/withUIRouter'; +import { NodesDatatable } from '@/react/docker/swarm/SwarmView/NodesDatatable'; + +export const swarmModule = angular + .module('portainer.docker.react.components.swarm', []) + .component( + 'nodesDatatable', + r2a(withUIRouter(NodesDatatable), [ + 'dataset', + 'isIpColumnVisible', + 'haveAccessToNode', + 'onRefresh', + ]) + ).name; diff --git a/app/docker/react/components/templates.ts b/app/docker/react/components/templates.ts new file mode 100644 index 0000000..f92e1a5 --- /dev/null +++ b/app/docker/react/components/templates.ts @@ -0,0 +1,6 @@ +import angular from 'angular'; + +export const templatesModule = angular.module( + 'portainer.docker.react.components.templates', + [] +).name; diff --git a/app/docker/react/components/volumes.ts b/app/docker/react/components/volumes.ts new file mode 100644 index 0000000..994e9ec --- /dev/null +++ b/app/docker/react/components/volumes.ts @@ -0,0 +1,18 @@ +import angular from 'angular'; + +import { r2a } from '@/react-tools/react2angular'; +import { withUIRouter } from '@/react-tools/withUIRouter'; +import { VolumesDatatable } from '@/react/docker/volumes/ListView/VolumesDatatable'; +import { withCurrentUser } from '@/react-tools/withCurrentUser'; + +export const volumesModule = angular + .module('portainer.docker.react.components.volumes', []) + .component( + 'volumesDatatable', + r2a(withUIRouter(withCurrentUser(VolumesDatatable)), [ + 'dataset', + 'onRemove', + 'onRefresh', + 'isBrowseVisible', + ]) + ).name; diff --git a/app/docker/react/index.ts b/app/docker/react/index.ts new file mode 100644 index 0000000..47b1009 --- /dev/null +++ b/app/docker/react/index.ts @@ -0,0 +1,9 @@ +import angular from 'angular'; + +import { componentsModule } from './components'; +import { viewsModule } from './views'; + +export const reactModule = angular.module('portainer.docker.react', [ + viewsModule, + componentsModule, +]).name; diff --git a/app/docker/react/views/configs.ts b/app/docker/react/views/configs.ts new file mode 100644 index 0000000..17ef0c0 --- /dev/null +++ b/app/docker/react/views/configs.ts @@ -0,0 +1,14 @@ +import angular from 'angular'; + +import { r2a } from '@/react-tools/react2angular'; +import { withCurrentUser } from '@/react-tools/withCurrentUser'; +import { withReactQuery } from '@/react-tools/withReactQuery'; +import { withUIRouter } from '@/react-tools/withUIRouter'; +import { ListView } from '@/react/docker/configs/ListView/ListView'; + +export const configsModule = angular + .module('portainer.docker.react.views.configs', []) + .component( + 'configsListView', + r2a(withUIRouter(withReactQuery(withCurrentUser(ListView))), []) + ).name; diff --git a/app/docker/react/views/containers.ts b/app/docker/react/views/containers.ts new file mode 100644 index 0000000..442a726 --- /dev/null +++ b/app/docker/react/views/containers.ts @@ -0,0 +1,132 @@ +import { StateRegistry } from '@uirouter/angularjs'; +import angular from 'angular'; + +import { r2a } from '@/react-tools/react2angular'; +import { ListView } from '@/react/docker/containers/ListView'; +import { withCurrentUser } from '@/react-tools/withCurrentUser'; +import { withReactQuery } from '@/react-tools/withReactQuery'; +import { withUIRouter } from '@/react-tools/withUIRouter'; +import { LogView } from '@/react/docker/containers/LogView'; +import { CreateView } from '@/react/docker/containers/CreateView'; +import { InspectView } from '@/react/docker/containers/InspectView/InspectView'; +import { ItemView } from '@/react/docker/containers/ItemView/ItemView'; + +export const containersModule = angular + .module('portainer.docker.react.views.containers', []) + .component( + 'createContainerView', + r2a(withUIRouter(withCurrentUser(CreateView)), []) + ) + .component( + 'containersView', + r2a(withUIRouter(withReactQuery(withCurrentUser(ListView))), ['endpoint']) + ) + .component( + 'containerItemView', + r2a(withUIRouter(withCurrentUser(ItemView)), []) + ) + // the view only contains the information panel when logging is disabled + // this is a temporary solution to avoid creating a publicly exposed component + // or an AngularJS component until the logs view is migrated to React + .component( + 'containerLogView', + r2a(withUIRouter(withReactQuery(withCurrentUser(LogView))), []) + ) + .component( + 'dockerContainerInspectView', + r2a(withUIRouter(withReactQuery(withCurrentUser(InspectView))), []) + ) + .config(config).name; + +/* @ngInject */ +function config($stateRegistryProvider: StateRegistry) { + $stateRegistryProvider.register({ + name: 'docker.containers', + url: '/containers', + views: { + 'content@': { + component: 'containersView', + }, + }, + data: { + docs: '/user/docker/containers', + }, + }); + + $stateRegistryProvider.register({ + name: 'docker.containers.container', + url: '/:id?nodeName', + views: { + 'content@': { + component: 'containerItemView', + }, + }, + }); + + $stateRegistryProvider.register({ + name: 'docker.containers.container.attach', + url: '/attach', + views: { + 'content@': { + templateUrl: '~@/docker/views/containers/console/attach.html', + controller: 'ContainerConsoleController', + }, + }, + }); + + $stateRegistryProvider.register({ + name: 'docker.containers.container.exec', + url: '/exec', + views: { + 'content@': { + templateUrl: '~@/docker/views/containers/console/exec.html', + controller: 'ContainerConsoleController', + }, + }, + }); + + $stateRegistryProvider.register({ + name: 'docker.containers.new', + url: '/new?nodeName&from', + views: { + 'content@': { + component: 'createContainerView', + }, + }, + data: { + docs: '/user/docker/containers/add', + }, + }); + + $stateRegistryProvider.register({ + name: 'docker.containers.container.inspect', + url: '/inspect', + views: { + 'content@': { + component: 'dockerContainerInspectView', + }, + }, + }); + + $stateRegistryProvider.register({ + name: 'docker.containers.container.logs', + url: '/logs', + views: { + 'content@': { + templateUrl: '~@/docker/views/containers/logs/containerlogs.html', + controller: 'ContainerLogsController', + }, + }, + }); + + $stateRegistryProvider.register({ + name: 'docker.containers.container.stats', + url: '/stats', + views: { + 'content@': { + templateUrl: '~@/docker/views/containers/stats/containerstats.html', + controller: 'ContainerStatsController', + }, + }, + }); +} diff --git a/app/docker/react/views/images.ts b/app/docker/react/views/images.ts new file mode 100644 index 0000000..c486c11 --- /dev/null +++ b/app/docker/react/views/images.ts @@ -0,0 +1,13 @@ +import angular from 'angular'; + +import { r2a } from '@/react-tools/react2angular'; +import { withCurrentUser } from '@/react-tools/withCurrentUser'; +import { withUIRouter } from '@/react-tools/withUIRouter'; +import { ListView } from '@/react/docker/images/ListView/ListView'; + +export const imagesModule = angular + .module('portainer.docker.react.views.images', []) + .component( + 'imagesListView', + r2a(withUIRouter(withCurrentUser(ListView)), []) + ).name; diff --git a/app/docker/react/views/index.ts b/app/docker/react/views/index.ts new file mode 100644 index 0000000..67d3ac6 --- /dev/null +++ b/app/docker/react/views/index.ts @@ -0,0 +1,32 @@ +import angular from 'angular'; + +import { ItemView as NetworksItemView } from '@/react/docker/networks/ItemView'; +import { r2a } from '@/react-tools/react2angular'; +import { withCurrentUser } from '@/react-tools/withCurrentUser'; +import { withUIRouter } from '@/react-tools/withUIRouter'; +import { DashboardView } from '@/react/docker/DashboardView/DashboardView'; +import { ListView } from '@/react/docker/events/ListView'; + +import { containersModule } from './containers'; +import { configsModule } from './configs'; +import { imagesModule } from './images'; +import { stacksModule } from './stacks'; +import { networksModule } from './networks'; + +export const viewsModule = angular + .module('portainer.docker.react.views', [ + containersModule, + configsModule, + imagesModule, + stacksModule, + networksModule, + ]) + .component( + 'dockerDashboardView', + r2a(withUIRouter(withCurrentUser(DashboardView)), []) + ) + .component('eventsListView', r2a(withUIRouter(withCurrentUser(ListView)), [])) + .component( + 'networkDetailsView', + r2a(withUIRouter(withCurrentUser(NetworksItemView)), []) + ).name; diff --git a/app/docker/react/views/networks.ts b/app/docker/react/views/networks.ts new file mode 100644 index 0000000..d7d6c11 --- /dev/null +++ b/app/docker/react/views/networks.ts @@ -0,0 +1,13 @@ +import angular from 'angular'; + +import { r2a } from '@/react-tools/react2angular'; +import { withCurrentUser } from '@/react-tools/withCurrentUser'; +import { withUIRouter } from '@/react-tools/withUIRouter'; +import { ListView } from '@/react/docker/networks/ListView/ListView'; + +export const networksModule = angular + .module('portainer.docker.react.views.networks', []) + .component( + 'networksListView', + r2a(withUIRouter(withCurrentUser(ListView)), []) + ).name; diff --git a/app/docker/react/views/stacks.ts b/app/docker/react/views/stacks.ts new file mode 100644 index 0000000..741a95f --- /dev/null +++ b/app/docker/react/views/stacks.ts @@ -0,0 +1,19 @@ +import angular from 'angular'; + +import { r2a } from '@/react-tools/react2angular'; +import { withCurrentUser } from '@/react-tools/withCurrentUser'; +import { withReactQuery } from '@/react-tools/withReactQuery'; +import { withUIRouter } from '@/react-tools/withUIRouter'; +import { ItemView } from '@/react/docker/stacks/ItemView/ItemView'; +import { CreateView } from '@/react/docker/stacks/CreateView/CreateView'; + +export const stacksModule = angular + .module('portainer.docker.stacks', []) + .component( + 'stackItemView', + r2a(withUIRouter(withReactQuery(withCurrentUser(ItemView))), []) + ) + .component( + 'createStackView', + r2a(withUIRouter(withReactQuery(withCurrentUser(CreateView))), []) + ).name; diff --git a/app/docker/services/buildService.js b/app/docker/services/buildService.js new file mode 100644 index 0000000..7cf41a9 --- /dev/null +++ b/app/docker/services/buildService.js @@ -0,0 +1,65 @@ +import { + buildImageFromDockerfileContent, + buildImageFromDockerfileContentAndFiles, + buildImageFromURL, + buildImageFromUpload, +} from '@/react/docker/images/queries/useBuildImageMutation'; + +import { ImageBuildModel } from '../models/build'; + +angular.module('portainer.docker').factory('BuildService', BuildServiceFactory); + +/* @ngInject */ +function BuildServiceFactory(AngularToReact) { + const { useAxios } = AngularToReact; + + return { + buildImageFromUpload: useAxios(buildImageFromUploadAngularJS), // build image + buildImageFromURL: useAxios(buildImageFromURLAngularJS), // build image + buildImageFromDockerfileContent: useAxios(buildImageFromDockerfileContentAngularJS), // build image + buildImageFromDockerfileContentAndFiles: useAxios(buildImageFromDockerfileContentAndFilesAngularJS), // build image + }; + + /** + * @param {EnvironmentId} environmentId + * @param {string[]} names + * @param {File} file + * @param {string} path + */ + async function buildImageFromUploadAngularJS(environmentId, names, file, path) { + const data = await buildImageFromUpload(environmentId, names, file, path); + return new ImageBuildModel(data); + } + + /** + * @param {EnvironmentId} environmentId + * @param {string[]} names + * @param {string} url + * @param {string} path + */ + async function buildImageFromURLAngularJS(environmentId, names, url, path) { + const data = await buildImageFromURL(environmentId, names, url, path); + return new ImageBuildModel(data); + } + + /** + * @param {EnvironmentId} environmentId + * @param {string[]} names + * @param {string} content + */ + async function buildImageFromDockerfileContentAngularJS(environmentId, names, content) { + const data = await buildImageFromDockerfileContent(environmentId, names, content); + return new ImageBuildModel(data); + } + + /** + * @param {EnvironmentId} environmentId + * @param {string[]} names + * @param {string} content + * @param {File[]} files + */ + async function buildImageFromDockerfileContentAndFilesAngularJS(environmentId, names, content, files) { + const data = await buildImageFromDockerfileContentAndFiles(environmentId, names, content, files); + return new ImageBuildModel(data); + } +} diff --git a/app/docker/services/configService.js b/app/docker/services/configService.js new file mode 100644 index 0000000..215841d --- /dev/null +++ b/app/docker/services/configService.js @@ -0,0 +1,37 @@ +import { getConfig } from '@/react/docker/configs/queries/useConfig'; +import { getConfigs } from '@/react/docker/configs/queries/useConfigs'; + +import { deleteConfig } from '@/react/docker/configs/queries/useDeleteConfigMutation'; +import { createConfig } from '@/react/docker/configs/queries/useCreateConfigMutation'; +import { ConfigViewModel } from '@/react/docker/configs/model'; + +angular.module('portainer.docker').factory('ConfigService', ConfigServiceFactory); + +/* @ngInspect */ +function ConfigServiceFactory(AngularToReact) { + const { useAxios } = AngularToReact; + + return { + configs: useAxios(listConfigsAngularJS), // config list + service create + service edit + config: useAxios(getConfigAngularJS), // config create + config edit + remove: useAxios(deleteConfig), // config list + config edit + create: useAxios(createConfig), // config create + }; + + /** + * @param {EnvironmentId} environmentId + */ + async function listConfigsAngularJS(environmentId) { + const data = await getConfigs(environmentId); + return data.map((c) => new ConfigViewModel(c)); + } + + /** + * @param {EnvironmentId} environmentId + * @param {ConfigId} configId + */ + async function getConfigAngularJS(environmentId, configId) { + const data = await getConfig(environmentId, configId); + return new ConfigViewModel(data); + } +} diff --git a/app/docker/services/containerService.js b/app/docker/services/containerService.js new file mode 100644 index 0000000..2e284be --- /dev/null +++ b/app/docker/services/containerService.js @@ -0,0 +1,118 @@ +import angular from 'angular'; +import { + killContainer, + pauseContainer, + removeContainer, + renameContainer, + restartContainer, + resumeContainer, + startContainer, + stopContainer, + recreateContainer, + getContainerLogs, +} from '@/react/docker/containers/containers.service'; +import { getContainers } from '@/react/docker/containers/queries/useContainers'; +import { getContainer } from '@/react/docker/containers/queries/useContainer'; +import { resizeTTY } from '@/react/docker/containers/queries/useContainerResizeTTYMutation'; +import { updateContainer } from '@/react/docker/containers/queries/useUpdateContainer'; +import { createExec } from '@/react/docker/containers/queries/useCreateExecMutation'; +import { containerStats } from '@/react/docker/containers/queries/useContainerStats'; +import { getContainerTop } from '@/react/docker/containers/queries/useContainerTop'; + +import { ContainerDetailsViewModel } from '../models/containerDetails'; +import { ContainerStatsViewModel } from '../models/containerStats'; +import { formatLogs } from '../helpers/logHelper'; + +angular.module('portainer.docker').factory('ContainerService', ContainerServiceFactory); + +/* @ngInject */ +function ContainerServiceFactory(AngularToReact) { + const { useAxios } = AngularToReact; + + return { + killContainer: useAxios(killContainer), // container edit + pauseContainer: useAxios(pauseContainer), // container edit + renameContainer: useAxios(renameContainer), // container edit + restartContainer: useAxios(restartContainer), // container edit + resumeContainer: useAxios(resumeContainer), // container edit + startContainer: useAxios(startContainer), // container edit + stopContainer: useAxios(stopContainer), // container edit + recreateContainer: useAxios(recreateContainer), // container edit + remove: useAxios(removeContainer), // container edit + container: useAxios(getContainerAngularJS), // container console + container edit + container stats + containers: useAxios(getContainers), // dashboard + services list + service edit + voluem edit + stackservice + stack create + stack edit + resizeTTY: useAxios(resizeTTYAngularJS), // container console + updateRestartPolicy: useAxios(updateRestartPolicyAngularJS), // container edit + createExec: useAxios(createExec), // container console + containerStats: useAxios(containerStatsAngularJS), // container stats + containerTop: useAxios(getContainerTop), // container stats + inspect: useAxios(getContainer), // container inspect + logs: useAxios(containerLogsAngularJS), // container logs + }; + + /** + * @param {EnvironmentId} environmentId + * @param {ContainerId} id + * @param {*} param2 + */ + async function getContainerAngularJS(environmentId, id, { nodeName } = {}) { + const data = await getContainer(environmentId, id, { nodeName }); + return new ContainerDetailsViewModel(data); + } + + /** + * @param {EnvironmentId} environmentId + * @param {string} containerId + * @param {number} width + * @param {number} height + * @param timeout DEPRECATED: Previously used in pure AJS implementation + */ + async function resizeTTYAngularJS(environmentId, containerId, width, height) { + return resizeTTY(environmentId, containerId, { width, height }); + } + + /** + * @param {EnvironmentId} environmentId + * @param {ContainerId} id + * @param {RestartPolicy['Name']} restartPolicy + * @param {RestartPolicy['MaximumRetryCount']} maximumRetryCounts + */ + async function updateRestartPolicyAngularJS(environmentId, id, restartPolicy, maximumRetryCounts) { + return updateContainer(environmentId, id, { + RestartPolicy: { + Name: restartPolicy, + MaximumRetryCount: maximumRetryCounts, + }, + }); + } + + /** + * @param {EnvironmentId} environmentId + * @param {ContainerId} id + */ + async function containerStatsAngularJS(environmentId, id) { + const data = await containerStats(environmentId, id); + return new ContainerStatsViewModel(data); + } + + /** + * @param {EnvironmentId} environmentId + * @param {Containerid} id + * @param {boolean?} stdout + * @param {boolean?} stderr + * @param {boolean?} timestamps + * @param {number?} since + * @param {number?} tail + * @param {boolean?} stripHeaders + */ + async function containerLogsAngularJS(environmentId, id, stdout = false, stderr = false, timestamps = false, since = 0, tail = 'all', stripHeaders) { + const data = await getContainerLogs(environmentId, id, { + since, + stderr, + stdout, + tail, + timestamps, + }); + return formatLogs(data, { stripHeaders, withTimestamps: !!timestamps }); + } +} diff --git a/app/docker/services/execService.js b/app/docker/services/execService.js new file mode 100644 index 0000000..8bc6cd4 --- /dev/null +++ b/app/docker/services/execService.js @@ -0,0 +1,23 @@ +import { resizeTTY } from '@/react/docker/proxy/queries/useExecResizeTTYMutation'; + +angular.module('portainer.docker').factory('ExecService', ExecServiceFactory); + +/* @ngInject */ +function ExecServiceFactory(AngularToReact) { + const { useAxios, injectEnvironmentId } = AngularToReact; + + return { + resizeTTY: useAxios(injectEnvironmentId(resizeTTYAngularJS)), + }; + + /** + * @param {EnvironmentId} environmentId Injected + * @param {string} execId + * @param {number} width + * @param {number} height + * @param timeout DEPRECATED: Previously used in pure AJS implementation + */ + async function resizeTTYAngularJS(environmentId, execId, width, height) { + return resizeTTY(environmentId, execId, { width, height }); + } +} diff --git a/app/docker/services/imageService.js b/app/docker/services/imageService.js new file mode 100644 index 0000000..539fe64 --- /dev/null +++ b/app/docker/services/imageService.js @@ -0,0 +1,91 @@ +import { groupBy } from 'lodash'; + +import { getUniqueTagListFromImages } from '@/react/docker/images/utils'; +import { getImage } from '@/react/docker/proxy/queries/images/useImage'; +import { parseAxiosError } from '@/react/portainer/services/axios/axios'; +import { getImages } from '@/react/docker/proxy/queries/images/useImages'; +import { getContainers } from '@/react/docker/containers/queries/useContainers'; +import { getImageHistory } from '@/react/docker/proxy/queries/images/useImageHistory'; +import { pullImage } from '@/react/docker/images/queries/usePullImageMutation'; +import { pushImage } from '@/react/docker/images/queries/usePushImageMutation'; +import { removeImage } from '@/react/docker/proxy/queries/images/useRemoveImageMutation'; +import { tagImage } from '@/react/docker/proxy/queries/images/useTagImageMutation'; +import { downloadImages } from '@/react/docker/proxy/queries/images/useDownloadImages'; +import { uploadImages } from '@/react/docker/proxy/queries/images/useUploadImageMutation'; + +import { ImageViewModel } from '../models/image'; +import { ImageDetailsViewModel } from '../models/imageDetails'; +import { ImageLayerViewModel } from '../models/imageLayer'; + +angular.module('portainer.docker').factory('ImageService', ImageServiceFactory); + +/* @ngInject */ +function ImageServiceFactory(AngularToReact) { + const { useAxios, injectEnvironmentId } = AngularToReact; + + return { + image: useAxios(injectEnvironmentId(imageAngularJS)), // container console + image edit + images: useAxios(injectEnvironmentId(imagesAngularJS)), // por image registry controller + dashboard + service edit + history: useAxios(injectEnvironmentId(historyAngularJS)), // image edit + pushImage: useAxios(injectEnvironmentId(pushImageAngularJS)), // image edit + pullImage: useAxios(injectEnvironmentId(pullImageAngularJS)), // images list + image edit + templates list + tagImage: useAxios(injectEnvironmentId(tagImage)), // image edit + image import + downloadImages: useAxios(injectEnvironmentId(downloadImages)), // image list + image edit + uploadImage: useAxios(injectEnvironmentId(uploadImages)), // image import + deleteImage: useAxios(injectEnvironmentId(removeImage)), // image list + image edit + getUniqueTagListFromImages, // por image registry controller + service edit + }; + + async function imageAngularJS(environmentId, imageId) { + const image = await getImage(environmentId, imageId); + return new ImageDetailsViewModel(image); + } + + async function imagesAngularJS(environmentId, withUsage) { + try { + const [containers, images] = await Promise.all([withUsage ? getContainers(environmentId) : [], getImages(environmentId)]); + const containerByImageId = groupBy(containers, 'ImageID'); + return images.map((item) => new ImageViewModel(item, !!containerByImageId[item.Id] && containerByImageId[item.Id].length > 0)); + } catch (e) { + throw parseAxiosError(e, 'Unable to retrieve images'); + } + } + + async function historyAngularJS(environmentId, imageId) { + try { + const layers = await getImageHistory(environmentId, imageId); + return layers.reverse().map((layer, idx) => new ImageLayerViewModel(idx, layer)); + } catch (e) { + throw parseAxiosError(e, 'Unable to retrieve image history'); + } + } + + /** + * type PorImageRegistryModel = { + * UseRegistry: bool; + * Registry?: Registry; + * Image: string; + * } + */ + + /** + * @param {EnvironmentId} environmentId Autofilled by AngularToReact + * @param {PorImageRegistryModel} registryModel + */ + async function pushImageAngularJS(environmentId, registryModel) { + const { UseRegistry, Registry, Image } = registryModel; + const registry = UseRegistry ? Registry : undefined; + return pushImage({ environmentId, image: Image, registry }); + } + + /** + * @param {EnvironmentId} environmentId Autofilled by AngularToReact + * @param {PorImageRegistryModel} registryModel + * @param {string?} nodeName + */ + async function pullImageAngularJS(environmentId, registryModel, nodeName) { + const { UseRegistry, Registry, Image } = registryModel; + const registry = UseRegistry ? Registry : undefined; + return pullImage({ environmentId, image: Image, nodeName, registry }); + } +} diff --git a/app/docker/services/networkService.js b/app/docker/services/networkService.js new file mode 100644 index 0000000..dc407d4 --- /dev/null +++ b/app/docker/services/networkService.js @@ -0,0 +1,55 @@ +import { createNetwork } from '@/react/docker/networks/queries/useCreateNetworkMutation'; +import { getNetwork } from '@/react/docker/networks/queries/useNetwork'; +import { getNetworks } from '@/react/docker/networks/queries/useNetworks'; +import { deleteNetwork } from '@/react/docker/networks/queries/useDeleteNetworkMutation'; +import { connectContainer } from '@/react/docker/networks/queries/useConnectContainerMutation'; + +import { NetworkViewModel } from '../models/network'; + +angular.module('portainer.docker').factory('NetworkService', NetworkServiceFactory); + +/* @ngInject */ +function NetworkServiceFactory(AngularToReact) { + const { useAxios, injectEnvironmentId } = AngularToReact; + + return { + create: useAxios(injectEnvironmentId(createNetwork)), // create network + network: useAxios(injectEnvironmentId(networkAngularJS)), // service edit + networks: useAxios(injectEnvironmentId(networksAngularJS)), // macvlan form + container edit + dashboard + service create + service edit + custom templates list + templates list + remove: useAxios(injectEnvironmentId(deleteNetwork)), // networks list + connectContainer: useAxios(injectEnvironmentId(connectContainerAngularJS)), // container edit + }; + + /** + * @param {EnvironmentId} environmentId filled by AngularToReact + * @param {NetworkId} networkId + * @param {string?} nodeName + * @returns NetworkViewModel + */ + async function networkAngularJS(environmentId, networkId, nodeName) { + const data = await getNetwork(environmentId, networkId, { nodeName }); + return new NetworkViewModel(data); + } + + /** + * @param {EnvironmentId} environmentId filled by AngularToReact + * @param {boolean?} localNetworks + * @param {boolean?} swarmNetworks + * @param {boolean?} swarmAttachableNetworks + * @param {*} filters + * @returns NetworkViewModel[] + */ + async function networksAngularJS(environmentId, local, swarm, swarmAttachable, filters) { + const data = await getNetworks(environmentId, { local, swarm, swarmAttachable, filters }); + return data.map((n) => new NetworkViewModel(n)); + } + + /** + * @param {EnvironmentId} environmentId filled by AngularToReact + * @param {NetworkId} networkId + * @param {ContainerId} containerId + */ + async function connectContainerAngularJS(environmentId, networkId, containerId) { + return connectContainer({ environmentId, containerId, networkId }); + } +} diff --git a/app/docker/services/nodeService.js b/app/docker/services/nodeService.js new file mode 100644 index 0000000..201045e --- /dev/null +++ b/app/docker/services/nodeService.js @@ -0,0 +1,43 @@ +import { getNode } from '@/react/docker/proxy/queries/nodes/useNode'; +import { getNodes } from '@/react/docker/proxy/queries/nodes/useNodes'; +import { updateNode } from '@/react/docker/proxy/queries/nodes/useUpdateNodeMutation'; + +import { NodeViewModel } from '../models/node'; + +angular.module('portainer.docker').factory('NodeService', NodeServiceFactory); + +/* @ngInject */ +function NodeServiceFactory(AngularToReact) { + const { useAxios, injectEnvironmentId } = AngularToReact; + + return { + nodes: useAxios(injectEnvironmentId(nodesAngularJS)), // macvlan form + services list + service create + service edit + swarm visualizer + stack edit + node: useAxios(injectEnvironmentId(nodeAngularJS)), // node browser + node details + updateNode: useAxios(injectEnvironmentId(updateNodeAngularJS)), // swarm node details panel + }; + + /** + * @param {EnvironmentId} environmentId + * @param {NodeId} id + */ + async function nodeAngularJS(environmentId, id) { + const data = await getNode(environmentId, id); + return new NodeViewModel(data); + } + + /** + * @param {EnvironmentId} environmentId + */ + async function nodesAngularJS(environmentId) { + const data = await getNodes(environmentId); + return data.map((n) => new NodeViewModel(n)); + } + + /** + * @param {EnvironmentId} environmentId + * @param {NodeSpec & { Id: string; Version: number }} nodeConfig + */ + async function updateNodeAngularJS(environmentId, nodeConfig) { + return updateNode(environmentId, nodeConfig.Id, nodeConfig, nodeConfig.Version); + } +} diff --git a/app/docker/services/pluginService.js b/app/docker/services/pluginService.js new file mode 100644 index 0000000..e1df43a --- /dev/null +++ b/app/docker/services/pluginService.js @@ -0,0 +1,51 @@ +import { isFulfilled } from '@/portainer/helpers/promise-utils'; +import { getInfo } from '@/react/docker/proxy/queries/useInfo'; +import { aggregateData, getPlugins } from '@/react/docker/proxy/queries/usePlugins'; + +angular.module('portainer.docker').factory('PluginService', PluginServiceFactory); + +/* @ngInject */ +function PluginServiceFactory(AngularToReact) { + const { useAxios, injectEnvironmentId } = AngularToReact; + + return { + volumePlugins: useAxios(injectEnvironmentId(volumePlugins)), // volume create + networkPlugins: useAxios(injectEnvironmentId(networksPlugins)), // network create + loggingPlugins: useAxios(injectEnvironmentId(loggingPlugins)), // service create + service edit + }; +} + +/** + * @param {EnvironmentId} environmentId Injected + * @param {boolean} systemOnly + */ +async function volumePlugins(environmentId, systemOnly) { + const { systemPluginsData, pluginsData } = await getAllPlugins(environmentId); + return aggregateData(systemPluginsData, pluginsData, systemOnly, 'Volume'); +} + +/** + * @param {EnvironmentId} environmentId Injected + * @param {boolean} systemOnly + */ +async function networksPlugins(environmentId, systemOnly) { + const { systemPluginsData, pluginsData } = await getAllPlugins(environmentId); + return aggregateData(systemPluginsData, pluginsData, systemOnly, 'Network'); +} + +/** + * @param {EnvironmentId} environmentId Injected + * @param {boolean} systemOnly + */ +async function loggingPlugins(environmentId, systemOnly) { + const { systemPluginsData, pluginsData } = await getAllPlugins(environmentId); + return aggregateData(systemPluginsData, pluginsData, systemOnly, 'Log'); +} + +async function getAllPlugins(environmentId) { + const [system, plugins] = await Promise.allSettled([getInfo(environmentId), getPlugins(environmentId)]); + const systemPluginsData = isFulfilled(system) ? system.value.Plugins : undefined; + const pluginsData = isFulfilled(plugins) ? plugins.value : undefined; + + return { systemPluginsData, pluginsData }; +} diff --git a/app/docker/services/secretService.js b/app/docker/services/secretService.js new file mode 100644 index 0000000..48b72c6 --- /dev/null +++ b/app/docker/services/secretService.js @@ -0,0 +1,37 @@ +import { getSecret } from '@/react/docker/proxy/queries/secrets/useSecret'; +import { getSecrets } from '@/react/docker/proxy/queries/secrets/useSecrets'; +import { removeSecret } from '@/react/docker/proxy/queries/secrets/useRemoveSecretMutation'; +import { createSecret } from '@/react/docker/proxy/queries/secrets/useCreateSecretMutation'; + +import { SecretViewModel } from '../models/secret'; + +angular.module('portainer.docker').factory('SecretService', SecretServiceFactory); + +/* @ngInject */ +function SecretServiceFactory(AngularToReact) { + const { useAxios, injectEnvironmentId } = AngularToReact; + + return { + secret: useAxios(injectEnvironmentId(secretAngularJS)), // secret edit + secrets: useAxios(injectEnvironmentId(secretsAngularJS)), // secret list + service create + service edit + remove: useAxios(injectEnvironmentId(removeSecret)), // secret list + secret edit + create: useAxios(injectEnvironmentId(createSecret)), // secret create + }; + + /** + * @param {EnvironmentId} environmentId Injected + * @param {SecretId} id + */ + async function secretAngularJS(environmentId, id) { + const data = await getSecret(environmentId, id); + return new SecretViewModel(data); + } + + /** + * @param {EnvironmentId} environmentId Injected + */ + async function secretsAngularJS(environmentId) { + const data = await getSecrets(environmentId); + return data.map((s) => new SecretViewModel(s)); + } +} diff --git a/app/docker/services/serviceService.js b/app/docker/services/serviceService.js new file mode 100644 index 0000000..a3146b7 --- /dev/null +++ b/app/docker/services/serviceService.js @@ -0,0 +1,98 @@ +import { removeService } from '@/react/docker/services/ListView/ServicesDatatable/useRemoveServicesMutation'; +import { createService } from '@/react/docker/services/queries/useCreateServiceMutation'; +import { getService } from '@/react/docker/services/queries/useService'; +import { getServices } from '@/react/docker/services/queries/useServices'; +import { updateService } from '@/react/docker/services/queries/useUpdateServiceMutation'; +import { getServiceLogs } from '@/react/docker/services/queries/useServiceLogs'; + +import { ServiceViewModel } from '../models/service'; +import { formatLogs } from '../helpers/logHelper'; + +angular.module('portainer.docker').factory('ServiceService', ServiceServiceFactory); + +/* @ngInject */ +function ServiceServiceFactory(AngularToReact) { + const { useAxios, injectEnvironmentId } = AngularToReact; + + return { + services: useAxios(injectEnvironmentId(getServicesAngularJS)), // dashboard + service list + swarm visualizer + volume list + stackservice + stack edit + service: useAxios(injectEnvironmentId(getServiceAngularJS)), // service edit + task edit + remove: useAxios(injectEnvironmentId(removeServiceAngularJS)), // service edit + update: useAxios(injectEnvironmentId(updateServiceAngularJS)), // service edit + create: useAxios(injectEnvironmentId(createServiceAngularJS)), // service create + logs: useAxios(injectEnvironmentId(serviceLogsAngularJS)), // service logs + }; + + /** + * @param {EnvironmentId} environmentId Injected + * @param {*} filters + */ + async function getServicesAngularJS(environmentId, filters) { + const data = await getServices(environmentId, filters); + return data.map((s) => new ServiceViewModel(s)); + } + + /** + * @param {EnvironmentId} environmentId Injected + * @param {ServiceId} serviceId + */ + async function getServiceAngularJS(environmentId, serviceId) { + const data = await getService(environmentId, serviceId); + return new ServiceViewModel(data); + } + + /** + * @param {EnvironmentId} environmentId Injected + * @param {ServiceViewModel} service + */ + async function removeServiceAngularJS(environmentId, service) { + return removeService(environmentId, service.Id); + } + + /** + * @param {EnvironmentId} environmentId Injected + * @param {ServiceViewModel} service + * @param {ServiceUpdateConfig} config + * @param {string?} rollback + */ + async function updateServiceAngularJS(environmentId, service, config, rollback) { + const data = await getServiceAngularJS(environmentId, service.Id); + return updateService({ + environmentId, + config, + serviceId: service.Id, + version: data.Version, + registryId: config.registryId, + rollback, + }); + } + + /** + * @param {EnvironmentId} environmentId Injected + * @param {Service} config + * @param {RegistryId} registryId + */ + async function createServiceAngularJS(environmentId, config, registryId) { + return createService({ environmentId, config, registryId }); + } + + /** + * @param {EnvironmentId} environmentId Injected + * @param {ServiceId} id + * @param {boolean?} stdout + * @param {boolean?} stderr + * @param {boolean?} timestamps + * @param {number?} since + * @param {number?} tail + */ + async function serviceLogsAngularJS(environmentId, id, stdout = false, stderr = false, timestamps = false, since = 0, tail = 'all') { + const data = await getServiceLogs(environmentId, id, { + since, + stderr, + stdout, + tail, + timestamps, + }); + return formatLogs(data, { stripHeaders: true, withTimestamps: !!timestamps }); + } +} diff --git a/app/docker/services/swarmService.js b/app/docker/services/swarmService.js new file mode 100644 index 0000000..b6269b3 --- /dev/null +++ b/app/docker/services/swarmService.js @@ -0,0 +1,12 @@ +import { getSwarm } from '@/react/docker/proxy/queries/useSwarm'; + +angular.module('portainer.docker').factory('SwarmService', SwarmServiceFactory); + +/* @ngInject */ +function SwarmServiceFactory(AngularToReact) { + const { useAxios } = AngularToReact; + + return { + swarm: useAxios(getSwarm), // stack service + }; +} diff --git a/app/docker/services/systemService.js b/app/docker/services/systemService.js new file mode 100644 index 0000000..9c94552 --- /dev/null +++ b/app/docker/services/systemService.js @@ -0,0 +1,16 @@ +import { ping } from '@/react/docker/proxy/queries/usePing'; +import { getInfo } from '@/react/docker/proxy/queries/useInfo'; +import { getVersion } from '@/react/docker/proxy/queries/useVersion'; + +angular.module('portainer.docker').factory('SystemService', SystemServiceFactory); + +/* @ngInject */ +function SystemServiceFactory(AngularToReact) { + const { useAxios, injectEnvironmentId } = AngularToReact; + + return { + info: useAxios(injectEnvironmentId(getInfo)), // dashboard + docker host view + docker host browser + swarm inspect views + stateManager (update endpoint state) + ping: useAxios(ping), // docker/__module onEnter abstract /docker subpath + version: useAxios(injectEnvironmentId(getVersion)), // docker host view + swarm inspect view + stateManager (update endpoint state) + }; +} diff --git a/app/docker/services/taskService.js b/app/docker/services/taskService.js new file mode 100644 index 0000000..f779a11 --- /dev/null +++ b/app/docker/services/taskService.js @@ -0,0 +1,57 @@ +import { getTask } from '@/react/docker/tasks/queries/useTask'; +import { getTasks } from '@/react/docker/proxy/queries/tasks/useTasks'; +import { getTaskLogs } from '@/react/docker/tasks/queries/useTaskLogs'; + +import { TaskViewModel } from '../models/task'; +import { formatLogs } from '../helpers/logHelper'; + +angular.module('portainer.docker').factory('TaskService', TaskServiceFactory); + +/* @ngInject */ +function TaskServiceFactory(AngularToReact) { + const { useAxios, injectEnvironmentId } = AngularToReact; + + return { + task: useAxios(injectEnvironmentId(taskAngularJS)), // task edit + tasks: useAxios(injectEnvironmentId(tasksAngularJS)), // services list + service edit + swarm visualizer + stack edit + logs: useAxios(injectEnvironmentId(taskLogsAngularJS)), // task logs + }; + + /** + * @param {EnvironmentId} environmentId Injected + * @param {TaskId} id + */ + async function taskAngularJS(environmentId, id) { + const data = await getTask(environmentId, id); + return new TaskViewModel(data); + } + + /** + * @param {EnvironmentId} environmentId Injected + * @param {*} filters + */ + async function tasksAngularJS(environmentId, filters) { + const data = await getTasks(environmentId, filters); + return data.map((t) => new TaskViewModel(t)); + } + + /** + * @param {EnvironmentId} environmentId + * @param {TaskId} id + * @param {boolean?} stdout + * @param {boolean?} stderr + * @param {boolean?} timestamps + * @param {number?} since + * @param {number?} tail + */ + async function taskLogsAngularJS(environmentId, id, stdout = false, stderr = false, timestamps = false, since = 0, tail = 'all') { + const data = await getTaskLogs(environmentId, id, { + since, + stderr, + stdout, + tail, + timestamps, + }); + return formatLogs(data, { stripHeaders: true, withTimestamps: !!timestamps }); + } +} diff --git a/app/docker/services/volumeService.js b/app/docker/services/volumeService.js new file mode 100644 index 0000000..475b32d --- /dev/null +++ b/app/docker/services/volumeService.js @@ -0,0 +1,79 @@ +import { getVolumes } from '@/react/docker/volumes/queries/useVolumes'; +import { getVolume } from '@/react/docker/volumes/queries/useVolume'; +import { removeVolume } from '@/react/docker/volumes/queries/useRemoveVolumeMutation'; +import { createVolume } from '@/react/docker/volumes/queries/useCreateVolumeMutation'; + +import { VolumeViewModel } from '../models/volume'; + +angular.module('portainer.docker').factory('VolumeService', VolumeServiceFactory); + +/* @ngInject */ +function VolumeServiceFactory(AngularToReact) { + const { useAxios, injectEnvironmentId } = AngularToReact; + + return { + volumes: useAxios(injectEnvironmentId(volumesAngularJS)), // dashboard + service create + service edit + volume list + volume: useAxios(injectEnvironmentId(volumeAngularJS)), // volume edit + getVolumes: useAxios(injectEnvironmentId(getVolumesAngularJS)), // template list + remove: useAxios(injectEnvironmentId(removeAngularJS)), // volume list + volume edit + createVolume: useAxios(injectEnvironmentId(createAngularJS)), // volume create + createVolumeConfiguration, // volume create + }; + + /** + * @param {EnvironmentId} environmentId Injected + * @param {Filters} filters + */ + async function volumesAngularJS(environmentId, filters) { + const data = await getVolumes(environmentId, filters); + return data.map((v) => new VolumeViewModel(v)); + } + + /** + * @param {EnvironmentId} environmentId Injected + * @param {string} id + */ + async function volumeAngularJS(environmentId, id) { + const data = await getVolume(environmentId, id); + return new VolumeViewModel(data); + } + + /** + * @param {EnvironmentId} environmentId Injected + */ + async function getVolumesAngularJS(environmentId) { + return getVolumes(environmentId); + } + + /** + * @param {EnvironmentId} environmentId Injected + * @param {string} name + * @param {string?} nodeName + */ + async function removeAngularJS(environmentId, name, nodeName) { + return removeVolume(environmentId, name, { nodeName }); + } + + /** + * @param {string} name + * @param {string} driver + * @param {{name: string; value: string;}[]} driverOptions + */ + function createVolumeConfiguration(name, driver, driverOptions) { + return { + Name: name, + Driver: driver, + DriverOpts: driverOptions.reduce((res, { name, value }) => ({ ...res, [name]: value }), {}), + }; + } + + /** + * @param {EnvironmentId} environmentId Injected + * @param {VolumeConfiguration} volumeConfiguration + * @param {string?} nodeName + */ + async function createAngularJS(environmentId, volumeConfiguration, nodeName) { + const data = await createVolume(environmentId, volumeConfiguration, { nodeName }); + return new VolumeViewModel(data); + } +} diff --git a/app/docker/views/configs/create/createConfigController.js b/app/docker/views/configs/create/createConfigController.js new file mode 100644 index 0000000..edd74b0 --- /dev/null +++ b/app/docker/views/configs/create/createConfigController.js @@ -0,0 +1,157 @@ +import _ from 'lodash-es'; +import angular from 'angular'; +import { AccessControlFormData } from '@/portainer/components/accessControlForm/porAccessControlFormModel'; +import { confirmWebEditorDiscard } from '@@/modals/confirm'; + +class CreateConfigController { + /* @ngInject */ + constructor($async, $state, $transition$, $window, Notifications, ConfigService, Authentication, FormValidator, ResourceControlService, endpoint) { + this.$state = $state; + this.$transition$ = $transition$; + this.$window = $window; + this.Notifications = Notifications; + this.ConfigService = ConfigService; + this.Authentication = Authentication; + this.FormValidator = FormValidator; + this.ResourceControlService = ResourceControlService; + this.$async = $async; + this.endpoint = endpoint; + + this.formValues = { + Name: '', + Labels: [], + AccessControlData: new AccessControlFormData(), + ConfigContent: '', + }; + + this.state = { + formValidationError: '', + isEditorDirty: false, + }; + + this.editorUpdate = this.editorUpdate.bind(this); + this.createAsync = this.createAsync.bind(this); + } + + async $onInit() { + this.$window.onbeforeunload = () => { + if (this.formValues.displayCodeEditor && this.formValues.ConfigContent && this.state.isEditorDirty) { + return ''; + } + }; + + if (!this.$transition$.params().id) { + this.formValues.displayCodeEditor = true; + return; + } + + try { + let data = await this.ConfigService.config(this.endpoint.Id, this.$transition$.params().id); + this.formValues.Name = data.Name + '_copy'; + this.formValues.ConfigContent = data.Data; + let labels = _.keys(data.Labels); + for (let i = 0; i < labels.length; i++) { + let labelName = labels[i]; + let labelValue = data.Labels[labelName]; + this.formValues.Labels.push({ name: labelName, value: labelValue }); + } + this.formValues.displayCodeEditor = true; + } catch (err) { + this.formValues.displayCodeEditor = true; + this.Notifications.error('Failure', err, 'Unable to clone config'); + } + } + + $onDestroy() { + this.state.isEditorDirty = false; + } + + async uiCanExit() { + if (this.formValues.displayCodeEditor && this.formValues.ConfigContent && this.state.isEditorDirty) { + return confirmWebEditorDiscard(); + } + } + + addLabel() { + this.formValues.Labels.push({ name: '', value: '' }); + } + + removeLabel(index) { + this.formValues.Labels.splice(index, 1); + } + + prepareLabelsConfig(config) { + let labels = {}; + this.formValues.Labels.forEach(function (label) { + if (label.name && label.value) { + labels[label.name] = label.value; + } + }); + config.Labels = labels; + } + + prepareConfigData(config) { + let configData = this.formValues.ConfigContent; + config.Data = btoa(unescape(encodeURIComponent(configData))); + } + + prepareConfiguration() { + let config = {}; + config.Name = this.formValues.Name; + this.prepareConfigData(config); + this.prepareLabelsConfig(config); + return config; + } + + validateForm(accessControlData, isAdmin) { + this.state.formValidationError = ''; + let error = this.FormValidator.validateAccessControl(accessControlData, isAdmin); + + if (error) { + this.state.formValidationError = error; + return false; + } + return true; + } + + create() { + return this.$async(this.createAsync); + } + + async createAsync() { + const accessControlData = this.formValues.AccessControlData; + const userDetails = this.Authentication.getUserDetails(); + const isAdmin = this.Authentication.isAdmin(); + + if (this.formValues.ConfigContent === '') { + this.state.formValidationError = 'Config content must not be empty'; + return; + } + + if (!this.validateForm(accessControlData, isAdmin)) { + return; + } + + const config = this.prepareConfiguration(); + + try { + const data = await this.ConfigService.create(this.endpoint.Id, config); + const resourceControl = data.Portainer.ResourceControl; + const userId = userDetails.ID; + await this.ResourceControlService.applyResourceControl(userId, accessControlData, resourceControl); + this.Notifications.success('Success', 'Configuration successfully created'); + this.state.isEditorDirty = false; + this.$state.go('docker.configs', {}, { reload: true }); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to create config'); + } + } + + editorUpdate(value) { + this.formValues.ConfigContent = value; + this.state.isEditorDirty = true; + } +} + +export default CreateConfigController; +angular.module('portainer.docker').controller('CreateConfigController', CreateConfigController); diff --git a/app/docker/views/configs/create/createconfig.html b/app/docker/views/configs/create/createconfig.html new file mode 100644 index 0000000..6ac0bef --- /dev/null +++ b/app/docker/views/configs/create/createconfig.html @@ -0,0 +1,72 @@ + + +
+
+ + +
+ +
+ +
+ +
+
+ + +
+
+ +
+
+ + +
+
+ + add label +
+ +
+
+
+ name + +
+
+ value + + + + +
+
+
+ +
+ + + + + +
Actions
+
+
+ + {{ ctrl.state.formValidationError }} +
+
+ +
+
+
+
+
diff --git a/app/docker/views/configs/edit/config.html b/app/docker/views/configs/edit/config.html new file mode 100644 index 0000000..fe84f0f --- /dev/null +++ b/app/docker/views/configs/edit/config.html @@ -0,0 +1,79 @@ + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Name{{ config.Name }}
ID + {{ config.Id }} + + +
Created{{ config.CreatedAt | getisodate }}
Last updated{{ config.UpdatedAt | getisodate }}
Labels + + + + + +
{{ k }}{{ v }}
+
+
+
+
+
+ + + + + + +
+
+ + + +
+
+
+ +
+
+
+
+
+
+
diff --git a/app/docker/views/configs/edit/configController.js b/app/docker/views/configs/edit/configController.js new file mode 100644 index 0000000..3de5432 --- /dev/null +++ b/app/docker/views/configs/edit/configController.js @@ -0,0 +1,46 @@ +import { ResourceControlType } from '@/react/portainer/access-control/types'; +import { confirmDelete } from '@@/modals/confirm'; + +angular.module('portainer.docker').controller('ConfigController', [ + '$scope', + '$transition$', + '$state', + 'ConfigService', + 'Notifications', + 'endpoint', + function ($scope, $transition$, $state, ConfigService, Notifications, endpoint) { + $scope.resourceType = ResourceControlType.Config; + $scope.endpoint = endpoint; + + $scope.onUpdateResourceControlSuccess = function () { + $state.reload(); + }; + + $scope.removeConfig = async function removeConfig(configId) { + if (!(await confirmDelete('Are you sure you want to delete this config?'))) { + return; + } + + ConfigService.remove({ environmentId: endpoint.Id, configId }) + .then(function success() { + Notifications.success('Success', 'Configuration successfully removed'); + $state.go('docker.configs', {}); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to remove config'); + }); + }; + + function initView() { + ConfigService.config(endpoint.Id, $transition$.params().id) + .then(function success(data) { + $scope.config = data; + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve config details'); + }); + } + + initView(); + }, +]); diff --git a/app/docker/views/containers/console/attach.html b/app/docker/views/containers/console/attach.html new file mode 100644 index 0000000..6d3713b --- /dev/null +++ b/app/docker/views/containers/console/attach.html @@ -0,0 +1,58 @@ + + + +
+
+ + + +
+

+ + The interactive-flag is not set. You might not be able to use the console properly. +

+
+ +
+

+ + The TTY-flag is not set. You might not be able to use the console properly. +

+
+ +
+

+ + The container is not running. +

+
+ + +
+
+
+
+ +
+
+ +
+
diff --git a/app/docker/views/containers/console/containerConsoleController.js b/app/docker/views/containers/console/containerConsoleController.js new file mode 100644 index 0000000..da64d7c --- /dev/null +++ b/app/docker/views/containers/console/containerConsoleController.js @@ -0,0 +1,176 @@ +import { baseHref } from '@/portainer/helpers/pathHelper'; +import { commandStringToArray } from '@/docker/helpers/containers'; +import { isLinuxTerminalCommand, LINUX_SHELL_INIT_COMMANDS } from '@@/Terminal/Terminal'; + +angular.module('portainer.docker').controller('ContainerConsoleController', [ + '$scope', + '$state', + '$transition$', + 'ContainerService', + 'ExecService', + 'ImageService', + 'Notifications', + 'HttpRequestHelper', + 'CONSOLE_COMMANDS_LABEL_PREFIX', + 'endpoint', + function ($scope, $state, $transition$, ContainerService, ExecService, ImageService, Notifications, HttpRequestHelper, CONSOLE_COMMANDS_LABEL_PREFIX, endpoint) { + const states = Object.freeze({ + disconnected: 0, + connecting: 1, + connected: 2, + }); + + $scope.loaded = false; + $scope.states = states; + $scope.state = states.disconnected; + $scope.formValues = {}; + $scope.containerCommands = []; + + $scope.shellUrl = ''; + $scope.shellConnect = false; + $scope.onShellResize = null; + $scope.shellInitCommands = null; + + $scope.$on('$destroy', function () { + $scope.disconnect(); + }); + + $scope.onShellStateChange = function (state) { + $scope.$evalAsync(function () { + if (state === 'connected') { + $scope.state = states.connected; + } else if (state === 'disconnected') { + $scope.state = states.disconnected; + $scope.shellConnect = false; + } + }); + }; + + $scope.connectAttach = function () { + if ($scope.state > states.disconnected) { + return; + } + + $scope.state = states.connecting; + + const attachId = $transition$.params().id; + + ContainerService.container(endpoint.Id, attachId) + .then((details) => { + if (!details.State.Running) { + Notifications.error('Failure', details, 'Container ' + attachId + ' is not running!'); + $scope.disconnect(); + return; + } + + $scope.onShellResize = function ({ rows, cols }) { + ContainerService.resizeTTY(endpoint.Id, attachId, cols, rows); + }; + $scope.shellUrl = buildShellUrl('api/websocket/attach', { endpointId: $state.params.endpointId, id: attachId }); + $scope.shellConnect = true; + }) + .catch(function (err) { + Notifications.error('Error', err, 'Unable to retrieve container details'); + $scope.disconnect(); + }); + }; + + $scope.connectExec = function () { + if ($scope.state > states.disconnected) { + return; + } + + $scope.state = states.connecting; + + const command = $scope.formValues.isCustomCommand ? $scope.formValues.customCommand : $scope.formValues.command; + const execConfig = { + AttachStdin: true, + AttachStdout: true, + AttachStderr: true, + Tty: true, + User: $scope.formValues.user, + Cmd: commandStringToArray(command), + }; + + ContainerService.createExec(endpoint.Id, $transition$.params().id, execConfig) + .then(function (data) { + $scope.onShellResize = function ({ rows, cols }) { + ExecService.resizeTTY(data.Id, cols, rows); + }; + if (isLinuxTerminalCommand(execConfig.Cmd[0])) { + $scope.shellInitCommands = LINUX_SHELL_INIT_COMMANDS; + } + $scope.shellUrl = buildShellUrl('api/websocket/exec', { endpointId: $state.params.endpointId, id: data.Id }); + $scope.shellConnect = true; + }) + .catch(function (err) { + Notifications.error('Failure', err, 'Unable to exec into container'); + $scope.disconnect(); + }); + }; + + $scope.disconnect = function () { + $scope.shellConnect = false; + $scope.state = states.disconnected; + $scope.onShellResize = null; + $scope.shellInitCommands = null; + }; + + $scope.autoconnectAttachView = function () { + return $scope.initView().then(function () { + if ($scope.container.State.Running) { + $scope.connectAttach(); + } + }); + }; + + $scope.initView = function () { + HttpRequestHelper.setPortainerAgentTargetHeader($transition$.params().nodeName); + return ContainerService.container(endpoint.Id, $transition$.params().id) + .then(function (data) { + $scope.container = data; + return ImageService.image(data.Image); + }) + .then(function (data) { + const containerLabels = $scope.container.Config.Labels; + $scope.imageOS = data.Os; + $scope.formValues.command = data.Os === 'windows' ? 'powershell' : 'bash'; + $scope.containerCommands = Object.keys(containerLabels) + .filter(function (label) { + return label.indexOf(CONSOLE_COMMANDS_LABEL_PREFIX) === 0; + }) + .map(function (label) { + return { + title: label.replace(CONSOLE_COMMANDS_LABEL_PREFIX, ''), + command: containerLabels[label], + }; + }); + $scope.loaded = true; + }) + .catch(function (err) { + Notifications.error('Error', err, 'Unable to retrieve container details'); + }); + }; + + $scope.handleIsCustomCommandChange = function (enabled) { + $scope.$evalAsync(() => { + $scope.formValues.isCustomCommand = enabled; + }); + }; + + function buildShellUrl(path, params) { + const base = window.location.origin.startsWith('http') ? `${window.location.origin}${baseHref()}` : baseHref(); + let url = + base + + path + + '?' + + Object.keys(params) + .map((k) => k + '=' + params[k]) + .join('&'); + if ($transition$.params().nodeName) { + url += '&nodeName=' + $transition$.params().nodeName; + } + return url.startsWith('https') ? url.replace('https://', 'wss://') : url.replace('http://', 'ws://'); + } + }, +]); diff --git a/app/docker/views/containers/console/exec.html b/app/docker/views/containers/console/exec.html new file mode 100644 index 0000000..025549f --- /dev/null +++ b/app/docker/views/containers/console/exec.html @@ -0,0 +1,101 @@ + + + +
+
+ + + +
+
+ +
+ +
+
+ + + + + +
+ +
+
+ +
+ +
+
+ +
+ +
+
+
+
+ + + + The container is not running. + +
+
+
+
+ + +
+
+
+
+
+
+ +
+
+ +
+
diff --git a/app/docker/views/containers/logs/containerLogsController.js b/app/docker/views/containers/logs/containerLogsController.js new file mode 100644 index 0000000..789d473 --- /dev/null +++ b/app/docker/views/containers/logs/containerLogsController.js @@ -0,0 +1,103 @@ +import moment from 'moment'; + +angular.module('portainer.docker').controller('ContainerLogsController', [ + '$scope', + '$transition$', + '$interval', + 'ContainerService', + 'Notifications', + 'HttpRequestHelper', + 'endpoint', + function ($scope, $transition$, $interval, ContainerService, Notifications, HttpRequestHelper, endpoint) { + $scope.state = { + refreshRate: 3, + lineCount: 100, + sinceTimestamp: '', + displayTimestamps: false, + }; + + $scope.changeLogCollection = function (logCollectionStatus) { + if (!logCollectionStatus) { + stopRepeater(); + } else { + setUpdateRepeater(!$scope.container.Config.Tty); + } + }; + + $scope.$on('$destroy', function () { + stopRepeater(); + }); + + function stopRepeater() { + var repeater = $scope.repeater; + if (angular.isDefined(repeater)) { + $interval.cancel(repeater); + } + } + + function setUpdateRepeater(skipHeaders) { + var refreshRate = $scope.state.refreshRate; + $scope.repeater = $interval(function () { + ContainerService.logs( + endpoint.Id, + $transition$.params().id, + 1, + 1, + $scope.state.displayTimestamps ? 1 : 0, + moment($scope.state.sinceTimestamp).unix(), + $scope.state.lineCount, + skipHeaders + ) + .then(function success(data) { + $scope.logs = data; + }) + .catch(function error(err) { + stopRepeater(); + Notifications.error('Failure', err, 'Unable to retrieve container logs'); + }); + }, refreshRate * 1000); + } + + function startLogPolling(skipHeaders) { + ContainerService.logs( + endpoint.Id, + $transition$.params().id, + 1, + 1, + $scope.state.displayTimestamps ? 1 : 0, + moment($scope.state.sinceTimestamp).unix(), + $scope.state.lineCount, + skipHeaders + ) + .then(function success(data) { + $scope.logs = data; + setUpdateRepeater(skipHeaders); + }) + .catch(function error(err) { + stopRepeater(); + Notifications.error('Failure', err, 'Unable to retrieve container logs'); + }); + } + + function initView() { + HttpRequestHelper.setPortainerAgentTargetHeader($transition$.params().nodeName); + ContainerService.container(endpoint.Id, $transition$.params().id) + .then(function success(data) { + var container = data; + $scope.container = container; + + const logsEnabled = container.HostConfig && container.HostConfig.LogConfig && container.HostConfig.LogConfig.Type && container.HostConfig.LogConfig.Type !== 'none'; + $scope.logsEnabled = logsEnabled; + + if (logsEnabled) { + startLogPolling(!container.Config.Tty); + } + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve container information'); + }); + } + + initView(); + }, +]); diff --git a/app/docker/views/containers/logs/containerlogs.html b/app/docker/views/containers/logs/containerlogs.html new file mode 100644 index 0000000..3e824e2 --- /dev/null +++ b/app/docker/views/containers/logs/containerlogs.html @@ -0,0 +1,23 @@ + + + + + + diff --git a/app/docker/views/containers/stats/containerStatsController.js b/app/docker/views/containers/stats/containerStatsController.js new file mode 100644 index 0000000..d9889cd --- /dev/null +++ b/app/docker/views/containers/stats/containerStatsController.js @@ -0,0 +1,177 @@ +import moment from 'moment'; + +angular.module('portainer.docker').controller('ContainerStatsController', [ + '$q', + '$scope', + '$transition$', + '$document', + '$interval', + 'ContainerService', + 'ChartService', + 'Notifications', + 'HttpRequestHelper', + 'endpoint', + function ($q, $scope, $transition$, $document, $interval, ContainerService, ChartService, Notifications, HttpRequestHelper, endpoint) { + $scope.state = { + refreshRate: '5', + networkStatsUnavailable: false, + ioStatsUnavailable: false, + }; + + $scope.$on('$destroy', function () { + stopRepeater(); + }); + + function stopRepeater() { + var repeater = $scope.repeater; + if (angular.isDefined(repeater)) { + $interval.cancel(repeater); + } + } + + function updateNetworkChart(stats, chart) { + if (stats.Networks.length > 0) { + var rx = stats.Networks[0].rx_bytes; + var tx = stats.Networks[0].tx_bytes; + var label = moment(stats.read).format('HH:mm:ss'); + + ChartService.UpdateNetworkChart(label, rx, tx, chart); + } + } + + function updateMemoryChart(stats, chart) { + var label = moment(stats.read).format('HH:mm:ss'); + + ChartService.UpdateMemoryChart(label, stats.MemoryUsage, stats.MemoryCache, chart); + } + + function updateIOChart(stats, chart) { + var label = moment(stats.read).format('HH:mm:ss'); + if (stats.noIOData !== true) { + ChartService.UpdateIOChart(label, stats.BytesRead, stats.BytesWrite, chart); + } + } + + function updateCPUChart(stats, chart) { + var label = moment(stats.read).format('HH:mm:ss'); + var value = stats.isWindows ? calculateCPUPercentWindows(stats) : calculateCPUPercentUnix(stats); + + ChartService.UpdateCPUChart(label, value, chart); + } + + function calculateCPUPercentUnix(stats) { + var cpuPercent = 0.0; + var cpuDelta = stats.CurrentCPUTotalUsage - stats.PreviousCPUTotalUsage; + var systemDelta = stats.CurrentCPUSystemUsage - stats.PreviousCPUSystemUsage; + + if (systemDelta > 0.0 && cpuDelta > 0.0) { + cpuPercent = (cpuDelta / systemDelta) * stats.CPUCores * 100.0; + } + + return cpuPercent; + } + + function calculateCPUPercentWindows(stats) { + var possIntervals = + stats.NumProcs * parseFloat(moment(stats.read, 'YYYY-MM-DDTHH:mm:ss.SSSSSSSSSZ').valueOf() - moment(stats.preread, 'YYYY-MM-DDTHH:mm:ss.SSSSSSSSSZ').valueOf()); + var windowsCpuUsage = 0.0; + if (possIntervals > 0) { + windowsCpuUsage = parseFloat(stats.CurrentCPUTotalUsage - stats.PreviousCPUTotalUsage) / parseFloat(possIntervals * 100); + } + return windowsCpuUsage; + } + + $scope.changeUpdateRepeater = function () { + var networkChart = $scope.networkChart; + var cpuChart = $scope.cpuChart; + var memoryChart = $scope.memoryChart; + var ioChart = $scope.ioChart; + + stopRepeater(); + setUpdateRepeater(networkChart, cpuChart, memoryChart, ioChart); + $('#refreshRateChange').show(); + $('#refreshRateChange').fadeOut(1500); + }; + + function startChartUpdate(networkChart, cpuChart, memoryChart, ioChart) { + $q.all({ + stats: ContainerService.containerStats(endpoint.Id, $transition$.params().id), + }) + .then(function success(data) { + var stats = data.stats; + if (stats.Networks.length === 0) { + $scope.state.networkStatsUnavailable = true; + } + if (stats.noIOData === true) { + $scope.state.ioStatsUnavailable = true; + } + updateNetworkChart(stats, networkChart); + updateMemoryChart(stats, memoryChart); + updateCPUChart(stats, cpuChart); + updateIOChart(stats, ioChart); + setUpdateRepeater(networkChart, cpuChart, memoryChart, ioChart); + }) + .catch(function error(err) { + stopRepeater(); + Notifications.error('Failure', err, 'Unable to retrieve container statistics'); + }); + } + + function setUpdateRepeater(networkChart, cpuChart, memoryChart, ioChart) { + var refreshRate = $scope.state.refreshRate; + $scope.repeater = $interval(function () { + $q.all({ + stats: ContainerService.containerStats(endpoint.Id, $transition$.params().id), + }) + .then(function success(data) { + var stats = data.stats; + updateNetworkChart(stats, networkChart); + updateMemoryChart(stats, memoryChart); + updateCPUChart(stats, cpuChart); + updateIOChart(stats, ioChart); + }) + .catch(function error(err) { + stopRepeater(); + Notifications.error('Failure', err, 'Unable to retrieve container statistics'); + }); + }, refreshRate * 1000); + } + + function initCharts() { + var networkChartCtx = $('#networkChart'); + var networkChart = ChartService.CreateNetworkChart(networkChartCtx); + $scope.networkChart = networkChart; + + var cpuChartCtx = $('#cpuChart'); + var cpuChart = ChartService.CreateCPUChart(cpuChartCtx); + $scope.cpuChart = cpuChart; + + var memoryChartCtx = $('#memoryChart'); + var memoryChart = ChartService.CreateMemoryChart(memoryChartCtx); + $scope.memoryChart = memoryChart; + + var ioChartCtx = $('#ioChart'); + var ioChart = ChartService.CreateIOChart(ioChartCtx); + $scope.ioChart = ioChart; + + startChartUpdate(networkChart, cpuChart, memoryChart, ioChart); + } + + function initView() { + HttpRequestHelper.setPortainerAgentTargetHeader($transition$.params().nodeName); + ContainerService.container(endpoint.Id, $transition$.params().id) + .then(function success(data) { + $scope.container = data; + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve container information'); + }); + + $document.ready(function () { + initCharts(); + }); + } + + initView(); + }, +]); diff --git a/app/docker/views/containers/stats/containerstats.html b/app/docker/views/containers/stats/containerstats.html new file mode 100644 index 0000000..6429e31 --- /dev/null +++ b/app/docker/views/containers/stats/containerstats.html @@ -0,0 +1,111 @@ + + + +
+
+ + + +
+
+
+ + This view displays real-time statistics about the container {{ container.Name | trimcontainername }} as well as a list of the running processes inside this + container. + +
+
+
+ +
+ +
+ + + +
+
+
+ + + Network stats are unavailable for this container. + +
+
+
+
+ + + I/O stats are unavailable for this container. + +
+
+
+
+
+
+
+ +
+
+ + + +
+ +
+
+
+
+ +
+ + + +
+ +
+
+
+
+ +
+ + + +
+ +
+
+
+
+ +
+ + + +
+ +
+
+
+
+
+ + diff --git a/app/docker/views/docker-features-configuration/docker-features-configuration.controller.js b/app/docker/views/docker-features-configuration/docker-features-configuration.controller.js new file mode 100644 index 0000000..e339626 --- /dev/null +++ b/app/docker/views/docker-features-configuration/docker-features-configuration.controller.js @@ -0,0 +1,176 @@ +import { FeatureId } from '@/react/portainer/feature-flags/enums'; + +export default class DockerFeaturesConfigurationController { + /* @ngInject */ + constructor($async, $scope, $state, EndpointService, SettingsService, Notifications, StateManager) { + this.$async = $async; + this.$scope = $scope; + this.$state = $state; + this.EndpointService = EndpointService; + this.SettingsService = SettingsService; + this.Notifications = Notifications; + this.StateManager = StateManager; + + this.limitedFeatureAutoUpdate = FeatureId.HIDE_AUTO_UPDATE_WINDOW; + this.limitedFeatureUpToDateImage = FeatureId.IMAGE_UP_TO_DATE_INDICATOR; + + this.formValues = { + enableHostManagementFeatures: false, + allowVolumeBrowserForRegularUsers: false, + disableBindMountsForRegularUsers: false, + disablePrivilegedModeForRegularUsers: false, + disableHostNamespaceForRegularUsers: false, + disableStackManagementForRegularUsers: false, + disableDeviceMappingForRegularUsers: false, + disableContainerCapabilitiesForRegularUsers: false, + disableSysctlSettingForRegularUsers: false, + disableSecurityOptForRegularUsers: false, + }; + + this.isAgent = false; + + this.state = { + actionInProgress: false, + autoUpdateSettings: { Enabled: false }, + timeZone: '', + }; + + this.save = this.save.bind(this); + this.onChangeField = this.onChangeField.bind(this); + this.onToggleAutoUpdate = this.onToggleAutoUpdate.bind(this); + this.onToggleGPUManagement = this.onToggleGPUManagement.bind(this); + this.onGpusChange = this.onGpusChange.bind(this); + this.onChangeEnableHostManagementFeatures = this.onChangeField('enableHostManagementFeatures'); + this.onChangeAllowVolumeBrowserForRegularUsers = this.onChangeField('allowVolumeBrowserForRegularUsers'); + this.onChangeDisableBindMountsForRegularUsers = this.onChangeField('disableBindMountsForRegularUsers'); + this.onChangeDisablePrivilegedModeForRegularUsers = this.onChangeField('disablePrivilegedModeForRegularUsers'); + this.onChangeDisableHostNamespaceForRegularUsers = this.onChangeField('disableHostNamespaceForRegularUsers'); + this.onChangeDisableStackManagementForRegularUsers = this.onChangeField('disableStackManagementForRegularUsers'); + this.onChangeDisableDeviceMappingForRegularUsers = this.onChangeField('disableDeviceMappingForRegularUsers'); + this.onChangeDisableContainerCapabilitiesForRegularUsers = this.onChangeField('disableContainerCapabilitiesForRegularUsers'); + this.onChangeDisableSysctlSettingForRegularUsers = this.onChangeField('disableSysctlSettingForRegularUsers'); + this.onChangeDisableSecurityOptForRegularUsers = this.onChangeField('disableSecurityOptForRegularUsers'); + } + + onToggleAutoUpdate(value) { + return this.$scope.$evalAsync(() => { + this.state.autoUpdateSettings.Enabled = value; + }); + } + + onToggleGPUManagement(checked) { + this.$scope.$evalAsync(() => { + this.state.enableGPUManagement = checked; + }); + } + + onChange(values) { + return this.$scope.$evalAsync(() => { + this.formValues = { + ...this.formValues, + ...values, + }; + }); + } + + onChangeField(field) { + return (value) => { + this.onChange({ + [field]: value, + }); + }; + } + + onGpusChange(value) { + return this.$async(async () => { + this.endpoint.Gpus = value; + }); + } + + isContainerEditDisabled() { + const { + disableBindMountsForRegularUsers, + disableHostNamespaceForRegularUsers, + disablePrivilegedModeForRegularUsers, + disableDeviceMappingForRegularUsers, + disableContainerCapabilitiesForRegularUsers, + disableSysctlSettingForRegularUsers, + disableSecurityOptForRegularUsers, + } = this.formValues; + return ( + disableBindMountsForRegularUsers || + disableHostNamespaceForRegularUsers || + disablePrivilegedModeForRegularUsers || + disableDeviceMappingForRegularUsers || + disableContainerCapabilitiesForRegularUsers || + disableSysctlSettingForRegularUsers || + disableSecurityOptForRegularUsers + ); + } + + async save() { + return this.$async(async () => { + try { + this.state.actionInProgress = true; + + const validGpus = this.endpoint.Gpus.filter((gpu) => gpu.name && gpu.value); + const gpus = this.state.enableGPUManagement ? validGpus : []; + + const settings = { + enableHostManagementFeatures: this.formValues.enableHostManagementFeatures, + allowBindMountsForRegularUsers: !this.formValues.disableBindMountsForRegularUsers, + allowPrivilegedModeForRegularUsers: !this.formValues.disablePrivilegedModeForRegularUsers, + allowVolumeBrowserForRegularUsers: this.formValues.allowVolumeBrowserForRegularUsers, + allowHostNamespaceForRegularUsers: !this.formValues.disableHostNamespaceForRegularUsers, + allowDeviceMappingForRegularUsers: !this.formValues.disableDeviceMappingForRegularUsers, + allowStackManagementForRegularUsers: !this.formValues.disableStackManagementForRegularUsers, + allowContainerCapabilitiesForRegularUsers: !this.formValues.disableContainerCapabilitiesForRegularUsers, + allowSysctlSettingForRegularUsers: !this.formValues.disableSysctlSettingForRegularUsers, + allowSecurityOptForRegularUsers: !this.formValues.disableSecurityOptForRegularUsers, + enableGPUManagement: this.state.enableGPUManagement, + gpus, + }; + + this.initialGPUs = gpus; + this.initialEnableGPUManagement = this.state.enableGPUManagement; + + await this.EndpointService.updateSecuritySettings(this.endpoint.Id, settings); + + this.endpoint.SecuritySettings = settings; + this.Notifications.success('Success', 'Saved settings successfully'); + } catch (e) { + this.Notifications.error('Failure', e, 'Failed saving settings'); + } + this.state.actionInProgress = false; + this.$state.reload(); + }); + } + + $onInit() { + const securitySettings = this.endpoint.SecuritySettings; + + const applicationState = this.StateManager.getState(); + this.isAgent = applicationState.endpoint.mode.agentProxy; + + this.isDockerStandaloneEnv = applicationState.endpoint.mode.provider === 'DOCKER_STANDALONE'; + + this.formValues = { + enableHostManagementFeatures: this.isAgent && securitySettings.enableHostManagementFeatures, + allowVolumeBrowserForRegularUsers: this.isAgent && securitySettings.allowVolumeBrowserForRegularUsers, + disableBindMountsForRegularUsers: !securitySettings.allowBindMountsForRegularUsers, + disablePrivilegedModeForRegularUsers: !securitySettings.allowPrivilegedModeForRegularUsers, + disableHostNamespaceForRegularUsers: !securitySettings.allowHostNamespaceForRegularUsers, + disableDeviceMappingForRegularUsers: !securitySettings.allowDeviceMappingForRegularUsers, + disableStackManagementForRegularUsers: !securitySettings.allowStackManagementForRegularUsers, + disableContainerCapabilitiesForRegularUsers: !securitySettings.allowContainerCapabilitiesForRegularUsers, + disableSysctlSettingForRegularUsers: !securitySettings.allowSysctlSettingForRegularUsers, + disableSecurityOptForRegularUsers: !securitySettings.allowSecurityOptForRegularUsers, + }; + + // this.endpoint.Gpus could be null as it is Gpus: []Pair in the API + this.endpoint.Gpus = this.endpoint.Gpus || []; + this.state.enableGPUManagement = this.isDockerStandaloneEnv && (this.endpoint.EnableGPUManagement || this.endpoint.Gpus.length > 0); + this.initialGPUs = this.endpoint.Gpus; + this.initialEnableGPUManagement = this.endpoint.EnableGPUManagement; + } +} diff --git a/app/docker/views/docker-features-configuration/docker-features-configuration.html b/app/docker/views/docker-features-configuration/docker-features-configuration.html new file mode 100644 index 0000000..ac325b4 --- /dev/null +++ b/app/docker/views/docker-features-configuration/docker-features-configuration.html @@ -0,0 +1,232 @@ + + +
+
+ + +
+
Host and filesystem
+
+ + + The environment must be running the Portainer Agent to use this functionality, and the root of the host must be + bind-mounted to /host in the agent deployment. Check + our documentation for more information. + +
+
+
+ +
+
+
+
+ +
+
+ +
Change window setting
+ +
+
+ + +
+
+ + +
Docker security settings
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+ +
+ + + Note: The recreate/duplicate/edit feature is currently hidden (for non-admin users) by one or more security settings. + +
+ + + +
Other
+
+
+ +
+
+ +
+
+ +
+ +
+
+ +
+
+
+ +
+
+
+
+ +
+
+ +
+
+ + + +
Actions
+
+
+ +
+
+ +
+
+
+
+
diff --git a/app/docker/views/docker-features-configuration/index.js b/app/docker/views/docker-features-configuration/index.js new file mode 100644 index 0000000..5aec215 --- /dev/null +++ b/app/docker/views/docker-features-configuration/index.js @@ -0,0 +1,11 @@ +import angular from 'angular'; + +import controller from './docker-features-configuration.controller'; + +angular.module('portainer.docker').component('dockerFeaturesConfigurationView', { + templateUrl: './docker-features-configuration.html', + controller, + bindings: { + endpoint: '<', + }, +}); diff --git a/app/docker/views/host/host-browser-view/host-browser-view-controller.js b/app/docker/views/host/host-browser-view/host-browser-view-controller.js new file mode 100644 index 0000000..4024fd1 --- /dev/null +++ b/app/docker/views/host/host-browser-view/host-browser-view-controller.js @@ -0,0 +1,18 @@ +angular.module('portainer.docker').controller('HostBrowserViewController', [ + 'SystemService', + 'Notifications', + function HostBrowserViewController(SystemService, Notifications) { + var ctrl = this; + ctrl.$onInit = $onInit; + + function $onInit() { + SystemService.info() + .then(function onInfoLoaded(host) { + ctrl.host = host; + }) + .catch(function onError(err) { + Notifications.error('Unable to retrieve host information', err); + }); + } + }, +]); diff --git a/app/docker/views/host/host-browser-view/host-browser-view.html b/app/docker/views/host/host-browser-view/host-browser-view.html new file mode 100644 index 0000000..2f4958d --- /dev/null +++ b/app/docker/views/host/host-browser-view/host-browser-view.html @@ -0,0 +1,3 @@ + + + diff --git a/app/docker/views/host/host-browser-view/host-browser-view.js b/app/docker/views/host/host-browser-view/host-browser-view.js new file mode 100644 index 0000000..94095ec --- /dev/null +++ b/app/docker/views/host/host-browser-view/host-browser-view.js @@ -0,0 +1,7 @@ +angular.module('portainer.docker').component('hostBrowserView', { + templateUrl: './host-browser-view.html', + controller: 'HostBrowserViewController', + bindings: { + endpoint: '<', + }, +}); diff --git a/app/docker/views/host/host-view-controller.js b/app/docker/views/host/host-view-controller.js new file mode 100644 index 0000000..759e7d3 --- /dev/null +++ b/app/docker/views/host/host-view-controller.js @@ -0,0 +1,79 @@ +angular.module('portainer.docker').controller('HostViewController', [ + '$q', + 'SystemService', + 'Notifications', + 'StateManager', + 'AgentService', + 'Authentication', + function HostViewController($q, SystemService, Notifications, StateManager, AgentService, Authentication) { + var ctrl = this; + + this.$onInit = initView; + + ctrl.state = { + isAgent: false, + isAdmin: false, + }; + + this.engineDetails = {}; + this.hostDetails = {}; + this.devices = undefined; + this.disks = null; + + function initView() { + var applicationState = StateManager.getState(); + ctrl.state.isAgent = applicationState.endpoint.mode.agentProxy; + ctrl.state.isAdmin = Authentication.isAdmin(); + var agentApiVersion = applicationState.endpoint.agentApiVersion; + ctrl.state.agentApiVersion = agentApiVersion; + ctrl.state.enableHostManagementFeatures = ctrl.endpoint.SecuritySettings.enableHostManagementFeatures; + + $q.all({ + version: SystemService.version(), + info: SystemService.info(), + }) + .then(function success(data) { + ctrl.engineDetails = buildEngineDetails(data); + ctrl.hostDetails = buildHostDetails(data.info); + + if (ctrl.state.isAgent && agentApiVersion > 1 && ctrl.state.enableHostManagementFeatures) { + return AgentService.hostInfo(ctrl.endpoint.Id).then(function onHostInfoLoad(agentHostInfo) { + ctrl.devices = agentHostInfo.PCIDevices; + ctrl.disks = agentHostInfo.PhysicalDisks; + }); + } + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve engine details'); + }); + } + + function buildEngineDetails(data) { + var versionDetails = data.version; + var info = data.info; + return { + releaseVersion: versionDetails.Version, + apiVersion: versionDetails.ApiVersion, + rootDirectory: info.DockerRootDir, + storageDriver: info.Driver, + loggingDriver: info.LoggingDriver, + volumePlugins: info.Plugins.Volume, + networkPlugins: info.Plugins.Network, + }; + } + + function buildHostDetails(info) { + return { + os: { + arch: info.Architecture, + type: info.OSType, + name: info.OperatingSystem, + }, + name: info.Name, + kernelVersion: info.KernelVersion, + totalCPU: info.NCPU, + totalMemory: info.MemTotal, + }; + } + }, +]); diff --git a/app/docker/views/host/host-view.html b/app/docker/views/host/host-view.html new file mode 100644 index 0000000..ba39e51 --- /dev/null +++ b/app/docker/views/host/host-view.html @@ -0,0 +1,13 @@ + diff --git a/app/docker/views/host/host-view.js b/app/docker/views/host/host-view.js new file mode 100644 index 0000000..db3904b --- /dev/null +++ b/app/docker/views/host/host-view.js @@ -0,0 +1,7 @@ +angular.module('portainer.docker').component('hostView', { + templateUrl: './host-view.html', + controller: 'HostViewController', + bindings: { + endpoint: '<', + }, +}); diff --git a/app/docker/views/images/build/buildImageController.js b/app/docker/views/images/build/buildImageController.js new file mode 100644 index 0000000..553b150 --- /dev/null +++ b/app/docker/views/images/build/buildImageController.js @@ -0,0 +1,165 @@ +import { confirmWebEditorDiscard } from '@@/modals/confirm'; +import { editor, upload, url } from '@@/BoxSelector/common-options/build-methods'; + +angular.module('portainer.docker').controller('BuildImageController', BuildImageController); + +/* @ngInject */ +function BuildImageController($scope, $async, $window, BuildService, Notifications, HttpRequestHelper, endpoint) { + $scope.endpoint = endpoint; + $scope.options = [editor, upload, url]; + + $scope.state = { + BuildType: 'editor', + actionInProgress: false, + activeTab: 0, + isEditorDirty: false, + }; + + $scope.formValues = { + ImageNames: [{ Name: '', Valid: false, Unique: true }], + UploadFile: null, + DockerFileContent: '', + AdditionalFiles: [], + URL: '', + Path: 'Dockerfile', + NodeName: null, + }; + + $window.onbeforeunload = () => { + if ($scope.state.BuildType === 'editor' && $scope.formValues.DockerFileContent && $scope.state.isEditorDirty) { + return ''; + } + }; + + $scope.$on('$destroy', function () { + $scope.state.isEditorDirty = false; + }); + + $scope.onChangeBuildType = function (type) { + $scope.$evalAsync(() => { + $scope.state.BuildType = type; + }); + }; + + $scope.checkName = function (index) { + var item = $scope.formValues.ImageNames[index]; + item.Valid = true; + item.Unique = true; + if (item.Name !== '') { + // Check unique + $scope.formValues.ImageNames.forEach((element, idx) => { + if (idx != index && element.Name == item.Name) { + item.Valid = false; + item.Unique = false; + } + }); + if (!item.Valid) { + return; + } + } + // Validation + const parts = item.Name.split('/'); + const repository = parts[parts.length - 1]; + const repositoryRegExp = RegExp('^[a-z0-9-_.]{2,255}(:[A-Za-z0-9-_.]{1,128})?$'); + item.Valid = repositoryRegExp.test(repository); + }; + + $scope.addImageName = function () { + $scope.formValues.ImageNames.push({ Name: '', Valid: false, Unique: true }); + }; + + $scope.removeImageName = function (index) { + $scope.formValues.ImageNames.splice(index, 1); + for (var i = 0; i < $scope.formValues.ImageNames.length; i++) { + $scope.checkName(i); + } + }; + + function buildImageBasedOnBuildType(method, names) { + var buildType = $scope.state.BuildType; + var dockerfilePath = $scope.formValues.Path; + + if (buildType === 'upload') { + var file = $scope.formValues.UploadFile; + return BuildService.buildImageFromUpload(endpoint.Id, names, file, dockerfilePath); + } else if (buildType === 'url') { + var URL = $scope.formValues.URL; + return BuildService.buildImageFromURL(endpoint.Id, names, URL, dockerfilePath); + } else { + var dockerfileContent = $scope.formValues.DockerFileContent; + if ($scope.formValues.AdditionalFiles.length === 0) { + return BuildService.buildImageFromDockerfileContent(endpoint.Id, names, dockerfileContent); + } else { + var additionalFiles = $scope.formValues.AdditionalFiles; + return BuildService.buildImageFromDockerfileContentAndFiles(endpoint.Id, names, dockerfileContent, additionalFiles); + } + } + } + + $scope.buildImage = buildImage; + + async function buildImage() { + return $async(async () => { + var buildType = $scope.state.BuildType; + + if (buildType === 'editor' && $scope.formValues.DockerFileContent === '') { + $scope.state.formValidationError = 'Dockerfile content must not be empty'; + return; + } + + $scope.state.actionInProgress = true; + + var imageNames = $scope.formValues.ImageNames.filter(function filterNull(x) { + return x.Name; + }).map(function getNames(x) { + return x.Name; + }); + + var nodeName = $scope.formValues.NodeName; + HttpRequestHelper.setPortainerAgentTargetHeader(nodeName); + + try { + const data = await buildImageBasedOnBuildType(buildType, imageNames); + $scope.buildLogs = data.buildLogs; + $scope.state.activeTab = 1; + if (data.hasError) { + Notifications.error('An error occurred during build', { msg: 'Please check build logs output' }); + } else { + Notifications.success('Image successfully built'); + $scope.state.isEditorDirty = false; + } + } catch (err) { + Notifications.error('Failure', err, 'Unable to build image'); + } finally { + $scope.state.actionInProgress = false; + } + }); + } + + $scope.validImageNames = function () { + if ($scope.formValues.ImageNames.length == 0) { + return false; + } + for (var i = 0; i < $scope.formValues.ImageNames.length; i++) { + if (!$scope.formValues.ImageNames[i].Valid) { + return false; + } + } + return true; + }; + + $scope.editorUpdate = function (value) { + $scope.formValues.DockerFileContent = value; + $scope.state.isEditorDirty = true; + }; + + this.uiCanExit = async function () { + if ($scope.state.BuildType === 'editor' && $scope.formValues.DockerFileContent && $scope.state.isEditorDirty) { + return confirmWebEditorDiscard(); + } + }; + + $scope.selectAdditionalFiles = function (files) { + $scope.formValues.AdditionalFiles = files; + }; +} diff --git a/app/docker/views/images/build/buildimage.html b/app/docker/views/images/build/buildimage.html new file mode 100644 index 0000000..20c5773 --- /dev/null +++ b/app/docker/views/images/build/buildimage.html @@ -0,0 +1,228 @@ + + +
+
+ + + + + Builder +
+
Naming
+ +
+ You can specify multiple names to your image. +
+
+
+ + add additional name +
+
+ +
+ +

You must specify at least one name for the image.

+
+
+ +
+
+ + A name must be specified in one of the following formats: name:tag, repository/name:tag or + registry:port/repository/name:tag format. If you omit the tag the default latest value is assumed. + +
+
+
+
+
+ +
+ name + + + + + + + +
+ + +
+ +
+ +
+ + The image name must be unique + The image name must consist of between 2 and 255 lowercase alphanumeric characters, '.', '_', or '-' (e.g. 'my-name', or 'abc-123'). +
+
+
+
+
+
+ +
Build method
+ + + + +
+
Web editor
+
+ + You can get more information about Dockerfile format in the + official documentation. + +
+
+
+ +
+
+
Upload
+
+
+
+ + You can upload files from your local computer for referencing in your Dockerfile (using ADD filename) so they are included in your built image. + +
+ + {{ item.name }} +
+
+
+ + +
+
Upload
+
+ + You can upload a Dockerfile or a tar archive containing a Dockerfile from your computer. When using a tarball, the root folder will be used as the build + context. + +
+
+
+ + + {{ formValues.UploadFile.name }} + + +
+
+
+
+ Indicate the path to the Dockerfile within the tarball. +
+
+ +
+ +
+
+
+
+ + +
+
URL
+
+ + + Specify the URL to a Dockerfile, a tarball or a public Git repository (suffixed by .git). When using a Git repository URL, build contexts can be + specified as in the Docker documentation. + +
+
+ +
+ +
+
+
+ + + Indicate the path to the Dockerfile within the tarball/repository (ignored when using a Dockerfile). + +
+
+ +
+ +
+
+
+ +
+
Deployment
+ + + +
+ +
Actions
+
+
+ + {{ state.formValidationError }} +
+
+ +
+
+ + Output +
+              

{{ line }}

+

No build output available.

+
+
+
+
+
+
+
diff --git a/app/docker/views/images/edit/image.html b/app/docker/views/images/edit/image.html new file mode 100644 index 0000000..af37cd0 --- /dev/null +++ b/app/docker/views/images/edit/image.html @@ -0,0 +1,236 @@ + + +
+
+ + + +
+
+
+
+
+ {{ + tag + }} + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+ + Note: you can click on the upload icon to push an image or on the download icon to pull an + image or on the trash icon to delete a tag. + +
+
+ + +
+
+
+
+
+
+
+ +
+
+ + + +
+ + + + +
+
+ Note: if you don't specify the tag in the image name, latest will be used. +
+
+ +
+
+ +
+
+
+
+
+
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ID + {{ image.Id }} + + +
Parent{{ image.Parent }}
Size{{ image.Size | humansize }}
Created{{ image.Created | getisodate }}
BuildDocker {{ image.DockerVersion }} on {{ image.Os }}, {{ image.Architecture }}
Labels + + + + + +
{{ k }}{{ v }}
+
Author{{ image.Author }}
+
+
+
+
+ + + +
+
+ + + + + + + + + + + + + + + + +
+ + + + + +
+ {{ layer.Order }} + + {{ layer.Size | humansize }} + +
+ + + {{ layer.CreatedBy | imagelayercommand | truncate: 130 }} + + + + + + +
+
+ + {{ layer.CreatedBy | imagelayercommand }} + +
+
+
+
+
+
diff --git a/app/docker/views/images/edit/imageController.js b/app/docker/views/images/edit/imageController.js new file mode 100644 index 0000000..aee6827 --- /dev/null +++ b/app/docker/views/images/edit/imageController.js @@ -0,0 +1,219 @@ +import _ from 'lodash-es'; +import { PorImageRegistryModel } from '@/docker/models/porImageRegistry'; +import { confirmImageExport } from '@/react/docker/images/common/ConfirmExportModal'; +import { confirmDelete } from '@@/modals/confirm'; + +angular.module('portainer.docker').controller('ImageController', [ + '$async', + '$q', + '$scope', + '$transition$', + '$state', + 'Authentication', + 'ImageService', + 'ImageHelper', + 'RegistryService', + 'Notifications', + 'HttpRequestHelper', + 'FileSaver', + 'Blob', + 'endpoint', + 'RegistryModalService', + function ( + $async, + $q, + $scope, + $transition$, + $state, + Authentication, + ImageService, + ImageHelper, + RegistryService, + Notifications, + HttpRequestHelper, + FileSaver, + Blob, + endpoint, + RegistryModalService + ) { + $scope.endpoint = endpoint; + $scope.isAdmin = Authentication.isAdmin(); + + $scope.formValues = { + RegistryModel: new PorImageRegistryModel(), + }; + + $scope.state = { + exportInProgress: false, + pullImageValidity: false, + }; + + $scope.sortType = 'Order'; + $scope.sortReverse = false; + + $scope.order = function (sortType) { + $scope.sortReverse = $scope.sortType === sortType ? !$scope.sortReverse : false; + $scope.sortType = sortType; + }; + + $scope.toggleLayerCommand = function (layerId) { + $('#layer-command-expander' + layerId + ' span').toggleClass('glyphicon-plus-sign glyphicon-minus-sign'); + $('#layer-command-' + layerId + '-short').toggle(); + $('#layer-command-' + layerId + '-full').toggle(); + }; + + $scope.setPullImageValidity = setPullImageValidity; + function setPullImageValidity(validity) { + $scope.state.pullImageValidity = validity; + } + + $scope.tagImage = function () { + const registryModel = $scope.formValues.RegistryModel; + + const { repo, tag } = ImageHelper.createImageConfigForContainer(registryModel); + + ImageService.tagImage($transition$.params().id, repo, tag) + .then(function success() { + Notifications.success('Success', 'Image successfully tagged'); + $state.go('docker.images.image', { id: $transition$.params().id }, { reload: true }); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to tag image'); + }); + }; + + $scope.pushTag = pushTag; + + async function pushTag(repository) { + return $async(async () => { + try { + const registryModel = await RegistryModalService.registryModal(repository, $scope.registries); + + if (registryModel) { + $('#uploadResourceHint').show(); + await ImageService.pushImage(registryModel); + Notifications.success('Image successfully pushed', repository); + } + } catch (err) { + Notifications.error('Failure', err, 'Unable to push image to repository'); + } finally { + $('#uploadResourceHint').hide(); + } + }); + } + + $scope.pullTag = pullTag; + async function pullTag(repository) { + return $async(async () => { + try { + const registryModel = await RegistryModalService.registryModal(repository, $scope.registries); + if (registryModel) { + $('#downloadResourceHint').show(); + await ImageService.pullImage(registryModel); + Notifications.success('Image successfully pulled', repository); + } + } catch (err) { + Notifications.error('Failure', err, 'Unable to pull image from repository'); + } finally { + $('#downloadResourceHint').hide(); + } + }); + } + + $scope.removeTag = function (repository) { + return $async(async () => { + if (!(await confirmDelete('Are you sure you want to delete this tag?'))) { + return; + } + + ImageService.deleteImage(repository, false) + .then(function success() { + if ($scope.image.RepoTags.length === 1) { + Notifications.success('Image successfully deleted', repository); + $state.go('docker.images', {}, { reload: true }); + } else { + Notifications.success('Tag successfully deleted', repository); + $state.go('docker.images.image', { id: $transition$.params().id }, { reload: true }); + } + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to remove image'); + }); + }); + }; + + $scope.removeImage = function (id) { + return $async(async () => { + if (!(await confirmDelete('Deleting this image will also delete all associated tags. Are you sure you want to delete this image?'))) { + return; + } + + ImageService.deleteImage(id, false) + .then(function success() { + Notifications.success('Image successfully deleted', id); + $state.go('docker.images', {}, { reload: true }); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to remove image'); + }); + }); + }; + + function exportImage(image) { + HttpRequestHelper.setPortainerAgentTargetHeader(image.NodeName); + $scope.state.exportInProgress = true; + ImageService.downloadImages([{ tags: image.RepoTags, id: image.Id }]) + .then(function success(data) { + var downloadData = new Blob([data], { type: 'application/x-tar' }); + FileSaver.saveAs(downloadData, 'images.tar'); + Notifications.success('Success', 'Image successfully downloaded'); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to download image'); + }) + .finally(function final() { + $scope.state.exportInProgress = false; + }); + } + + $scope.exportImage = function (image) { + if (image.RepoTags.length === 0 || _.includes(image.RepoTags, '')) { + Notifications.warning('', 'Cannot download a untagged image'); + return; + } + + confirmImageExport().then(function (confirmed) { + if (!confirmed) { + return; + } + exportImage(image); + }); + }; + + async function initView() { + HttpRequestHelper.setPortainerAgentTargetHeader($transition$.params().nodeName); + + try { + $scope.registries = await RegistryService.loadRegistriesForDropdown(endpoint.Id); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to load registries'); + } + + $q.all({ + image: ImageService.image($transition$.params().id), + history: ImageService.history($transition$.params().id), + }) + .then(function success(data) { + $scope.image = data.image; + $scope.history = data.history; + $scope.image.Env = _.sortBy($scope.image.Env, _.toLower); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve image details'); + $state.go('docker.images'); + }); + } + + initView(); + }, +]); diff --git a/app/docker/views/images/import/importImageController.js b/app/docker/views/images/import/importImageController.js new file mode 100644 index 0000000..b59c38c --- /dev/null +++ b/app/docker/views/images/import/importImageController.js @@ -0,0 +1,78 @@ +import { PorImageRegistryModel } from '@/docker/models/porImageRegistry'; + +angular.module('portainer.docker').controller('ImportImageController', [ + '$scope', + '$state', + '$async', + 'ImageService', + 'Notifications', + 'HttpRequestHelper', + 'Authentication', + 'ImageHelper', + 'endpoint', + function ($scope, $state, $async, ImageService, Notifications, HttpRequestHelper, Authentication, ImageHelper, endpoint) { + $scope.state = { + actionInProgress: false, + }; + + $scope.endpoint = endpoint; + + $scope.isAdmin = Authentication.isAdmin(); + + $scope.formValues = { + UploadFile: null, + NodeName: null, + RegistryModel: new PorImageRegistryModel(), + }; + + $scope.setPullImageValidity = setPullImageValidity; + function setPullImageValidity(validity) { + $scope.state.pullImageValidity = validity; + } + + async function tagImage(id) { + const registryModel = $scope.formValues.RegistryModel; + if (registryModel.Image) { + const { repo, tag } = ImageHelper.createImageConfigForContainer(registryModel); + try { + await ImageService.tagImage(id, repo, tag); + } catch (err) { + Notifications.error('Failure', err, 'Unable to tag image'); + } + } + } + + $scope.uploadImage = function () { + return $async(uploadImageAsync); + }; + + async function uploadImageAsync() { + $scope.state.actionInProgress = true; + + var nodeName = $scope.formValues.NodeName; + HttpRequestHelper.setPortainerAgentTargetHeader(nodeName); + var file = $scope.formValues.UploadFile; + try { + const { data } = await ImageService.uploadImage(file); + if (data.error) { + Notifications.error('Failure', data.error, 'Unable to upload image'); + } else if (data.stream) { + // docker has /n at the end of the stream, podman doesn't + var regex = /Loaded.*?: (.*?)(?:\n|$)/g; + var imageIds = regex.exec(data.stream); + if (imageIds && imageIds.length == 2) { + await tagImage(imageIds[1]); + $state.go('docker.images.image', { id: imageIds[1] }, { reload: true }); + } + Notifications.success('Success', 'Images successfully uploaded'); + } else { + Notifications.success('Success', 'The uploaded tar file contained multiple images. The provided tag therefore has been ignored.'); + } + } catch (err) { + Notifications.error('Failure', err, 'Unable to upload image'); + } finally { + $scope.state.actionInProgress = false; + } + } + }, +]); diff --git a/app/docker/views/images/import/importimage.html b/app/docker/views/images/import/importimage.html new file mode 100644 index 0000000..9780dc9 --- /dev/null +++ b/app/docker/views/images/import/importimage.html @@ -0,0 +1,70 @@ + + +
+
+ + +
+ +
Upload
+
+ You can upload a tar archive containing your images. +
+
+
+ + + {{ formValues.UploadFile.name }} + + +
+
+ +
+
Deployment
+ + + +
+
+
+ + + + + + + +
+
+ +
Actions
+
+
+ + {{ state.formValidationError }} +
+
+ +
+
+
+
+
diff --git a/app/docker/views/networks/create/createNetworkController.js b/app/docker/views/networks/create/createNetworkController.js new file mode 100644 index 0000000..5ef3dd4 --- /dev/null +++ b/app/docker/views/networks/create/createNetworkController.js @@ -0,0 +1,327 @@ +import _ from 'lodash-es'; +import { AccessControlFormData } from '../../../../portainer/components/accessControlForm/porAccessControlFormModel'; +import { MacvlanFormData } from '../../../components/network-macvlan-form/networkMacvlanFormModel'; + +angular.module('portainer.docker').controller('CreateNetworkController', [ + '$q', + '$scope', + '$state', + 'PluginService', + 'Notifications', + 'NetworkService', + 'LabelHelper', + 'Authentication', + 'ResourceControlService', + 'FormValidator', + 'HttpRequestHelper', + 'endpoint', + function ($q, $scope, $state, PluginService, Notifications, NetworkService, LabelHelper, Authentication, ResourceControlService, FormValidator, HttpRequestHelper, endpoint) { + $scope.endpoint = endpoint; + + $scope.formValues = { + DriverOptions: [], + IPV4: { + Subnet: '', + Gateway: '', + IPRange: '', + AuxiliaryAddresses: [], + }, + IPV6: { + Subnet: '', + Gateway: '', + IPRange: '', + AuxiliaryAddresses: [], + }, + Labels: [], + AccessControlData: new AccessControlFormData(), + NodeName: null, + Macvlan: new MacvlanFormData(), + }; + + $scope.state = { + formValidationError: '', + actionInProgress: false, + }; + + $scope.availableNetworkDrivers = []; + + $scope.config = { + Driver: 'bridge', + CheckDuplicate: true, + Internal: false, + Attachable: false, + EnableIPv6: false, + // Force IPAM Driver to 'default', should not be required. + // See: https://github.com/docker/docker/issues/25735 + IPAM: { + Driver: 'default', + Config: [], + }, + Labels: {}, + }; + + $scope.addDriverOption = function () { + $scope.formValues.DriverOptions.push({ + name: '', + value: '', + }); + }; + + $scope.removeDriverOption = function (index) { + $scope.formValues.DriverOptions.splice(index, 1); + }; + + $scope.addLabel = function () { + $scope.formValues.Labels.push({ + key: '', + value: '', + }); + }; + + $scope.removeLabel = function (index) { + $scope.formValues.Labels.splice(index, 1); + }; + + $scope.addIPV4AuxAddress = function () { + $scope.formValues.IPV4.AuxiliaryAddresses.push(''); + }; + + $scope.addIPV6AuxAddress = function () { + $scope.formValues.IPV6.AuxiliaryAddresses.push(''); + }; + + $scope.removeIPV4AuxAddress = function (index) { + $scope.formValues.IPV4.AuxiliaryAddresses.splice(index, 1); + $scope.state.IPV4AuxiliaryAddressesError.splice(index, 1); + }; + + $scope.removeIPV6AuxAddress = function (index) { + $scope.formValues.IPV6.AuxiliaryAddresses.splice(index, 1); + $scope.state.IPV6AuxiliaryAddressesError.splice(index, 1); + }; + + function checkAuxiliaryAddress(excludedIP, gateway) { + const split = _.split(excludedIP, '='); + + if (split.length === 2) { + return split[1] === gateway; + } + return excludedIP === gateway; + } + + $scope.checkIPV4AuxiliaryAddress = function (index) { + $scope.state.IPV4AuxiliaryAddressesError[index] = checkAuxiliaryAddress($scope.formValues.IPV4.AuxiliaryAddresses[index], $scope.formValues.IPV4.Gateway); + }; + + $scope.checkIPV6AuxiliaryAddress = function (index) { + $scope.state.IPV6AuxiliaryAddressesError[index] = checkAuxiliaryAddress($scope.formValues.IPV6.AuxiliaryAddresses[index], $scope.formValues.IPV6.Gateway); + }; + + $scope.isValid = function () { + const validIPV4 = !_.reduce($scope.state.IPV4AuxiliaryAddressesError, (acc, item) => acc || item, false); + const validIPV6 = !_.reduce($scope.state.IPV6AuxiliaryAddressesError, (acc, item) => acc || item, false); + return validIPV4 && validIPV6; + }; + + function prepareAuxiliaryAddresses(ipamConfig, ipFormValues) { + ipamConfig.AuxiliaryAddresses = {}; + _.forEach(ipFormValues.AuxiliaryAddresses, (auxAddress, index) => { + const split = _.split(auxAddress, '='); + if (split.length === 2) { + ipamConfig.AuxiliaryAddresses[split[0]] = split[1]; + } else { + ipamConfig.AuxiliaryAddresses['device' + index] = auxAddress; + } + }); + } + + function prepareIPAMConfiguration(config) { + if ($scope.formValues.IPV4.Subnet) { + let ipamConfig = {}; + ipamConfig.Subnet = $scope.formValues.IPV4.Subnet; + if ($scope.formValues.IPV4.Gateway) { + ipamConfig.Gateway = $scope.formValues.IPV4.Gateway; + } + if ($scope.formValues.IPV4.IPRange) { + ipamConfig.IPRange = $scope.formValues.IPV4.IPRange; + } + if ($scope.formValues.IPV4.AuxiliaryAddresses.length) { + prepareAuxiliaryAddresses(ipamConfig, $scope.formValues.IPV4); + } + config.IPAM.Config.push(ipamConfig); + } + if ($scope.formValues.IPV6.Subnet) { + let ipamConfig = {}; + ipamConfig.Subnet = $scope.formValues.IPV6.Subnet; + if ($scope.formValues.IPV6.Gateway) { + ipamConfig.Gateway = $scope.formValues.IPV6.Gateway; + } + if ($scope.formValues.IPV6.IPRange) { + ipamConfig.IPRange = $scope.formValues.IPV6.IPRange; + } + if ($scope.formValues.IPV6.AuxiliaryAddresses.length) { + prepareAuxiliaryAddresses(ipamConfig, $scope.formValues.IPV6); + } + config.EnableIPv6 = true; + config.IPAM.Config.push(ipamConfig); + } + } + + function prepareDriverOptions(config) { + var options = {}; + $scope.formValues.DriverOptions.forEach(function (option) { + options[option.name] = option.value; + }); + config.Options = options; + } + + function prepareLabelsConfig(config) { + config.Labels = LabelHelper.fromKeyValueToLabelHash($scope.formValues.Labels); + } + + function prepareConfiguration() { + var config = angular.copy($scope.config); + prepareIPAMConfiguration(config); + prepareDriverOptions(config); + prepareLabelsConfig(config); + return config; + } + + function modifyNetworkConfigurationForMacvlanConfigOnly(config) { + config.Internal = null; + config.Attachable = null; + config.ConfigOnly = true; + config.Options.parent = $scope.formValues.Macvlan.ParentNetworkCard; + } + + function modifyNetworkConfigurationForMacvlanConfigFrom(config, selectedNetworkConfig) { + config.ConfigFrom = { + Network: selectedNetworkConfig.Name, + }; + if ($scope.applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE') { + config.Scope = 'swarm'; + } else { + config.Scope = 'local'; + } + } + + $scope.onChangeInternal = function (enable) { + $scope.$evalAsync(() => { + $scope.config.Internal = enable; + }); + }; + + $scope.onChangeAttachable = function (enable) { + $scope.$evalAsync(() => { + $scope.config.Attachable = enable; + }); + }; + + function validateForm(accessControlData, isAdmin) { + $scope.state.formValidationError = ''; + var error = FormValidator.validateAccessControl(accessControlData, isAdmin); + + if (error) { + $scope.state.formValidationError = error; + return false; + } + return true; + } + + function createNetwork(context) { + $scope.state.actionInProgress = true; + NetworkService.create(context.networkConfiguration, { nodeName: context.nodeName, agentManagerOperation: context.managerOperation }) + .then(function success(data) { + const userId = context.userDetails.ID; + const accessControlData = context.accessControlData; + const resourceControl = data.Portainer.ResourceControl; + return ResourceControlService.applyResourceControl(userId, accessControlData, resourceControl); + }) + .then(function success() { + Notifications.success('Success', 'Network successfully created'); + if (context.reload) { + $state.go( + 'docker.networks', + {}, + { + reload: true, + } + ); + } + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'An error occurred during network creation'); + }) + .finally(function final() { + $scope.state.actionInProgress = false; + }); + } + + $scope.create = function () { + var networkConfiguration = prepareConfiguration(); + var accessControlData = $scope.formValues.AccessControlData; + var userDetails = Authentication.getUserDetails(); + var isAdmin = Authentication.isAdmin(); + + if (!validateForm(accessControlData, isAdmin)) { + return; + } + + var creationContext = { + nodeName: $scope.formValues.NodeName, + managerOperation: false, + networkConfiguration: networkConfiguration, + userDetails: userDetails, + accessControlData: accessControlData, + reload: true, + }; + + if ($scope.applicationState.endpoint.mode.agentProxy && $scope.applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE' && $scope.config.Driver === 'overlay') { + creationContext.managerOperation = true; + } + + if ($scope.config.Driver === 'macvlan') { + if ($scope.formValues.Macvlan.Scope === 'local') { + modifyNetworkConfigurationForMacvlanConfigOnly(networkConfiguration); + } else if ($scope.formValues.Macvlan.Scope === 'swarm') { + var selectedNetworkConfig = $scope.formValues.Macvlan.SelectedNetworkConfig; + modifyNetworkConfigurationForMacvlanConfigFrom(networkConfiguration, selectedNetworkConfig); + creationContext.nodeName = selectedNetworkConfig.NodeName; + } + } + + if ( + $scope.config.Driver === 'macvlan' && + $scope.formValues.Macvlan.Scope === 'local' && + $scope.applicationState.endpoint.mode.agentProxy && + $scope.applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE' + ) { + var selectedNodes = $scope.formValues.Macvlan.DatatableState.selectedItems; + selectedNodes.forEach(function (node, idx) { + creationContext.nodeName = node.Hostname; + creationContext.reload = idx === selectedNodes.length - 1 ? true : false; + createNetwork(creationContext); + }); + } else { + createNetwork(creationContext); + } + }; + + function initView() { + var apiVersion = $scope.applicationState.endpoint.apiVersion; + $scope.state.IPV4AuxiliaryAddressesError = []; + $scope.state.IPV6AuxiliaryAddressesError = []; + + PluginService.networkPlugins(apiVersion < 1.25) + .then(function success(data) { + $scope.availableNetworkDrivers = data; + $scope.availableNetworkDrivers = _.filter($scope.availableNetworkDrivers, (driver) => driver !== 'host' && driver !== 'null'); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve network drivers'); + }); + } + + initView(); + }, +]); diff --git a/app/docker/views/networks/create/createnetwork.html b/app/docker/views/networks/create/createnetwork.html new file mode 100644 index 0000000..71bf696 --- /dev/null +++ b/app/docker/views/networks/create/createnetwork.html @@ -0,0 +1,308 @@ + + +
+
+ + +
+ +
+ +
+ +
+
+ +
Driver configuration
+ +
+ +
+ + +
+
+ + +
+
+ +
+ +
+
+
+ name + +
+
+ value + + + + +
+
+
Add driver option
+
+ +
+ + + + +
+
IPV4 Network configuration
+ +
+ +
+ +
+ +
+ +
+
+ + +
+ +
+ +
+
+
+ +
+ +
+ +
+

Exclude ip cannot be the same as gateway.

+
+
+ +
+
+ Add excluded IP +
+
+
+
+
IPV6 Network configuration
+ +
+ +
+ +
+ +
+ +
+
+ + +
+ +
+ +
+
+
+ +
+ +
+ +
+

Exclude ip cannot be the same as gateway.

+
+
+ +
+
+ Add excluded IP +
+
+
+
Advanced configuration
+ +
+ +
+
+
+ name + +
+
+ value + + + +
+
+
+
Add label
+
+
+ +
+ + +
+
+ +
+
+ + +
+
+ +
+
+ +
+
Deployment
+ + + +
+ + + + +
Actions
+
+
+ + {{ state.formValidationError }} +
+
+ +
+
+
+
+
diff --git a/app/docker/views/nodes/node-browser/node-browser-controller.js b/app/docker/views/nodes/node-browser/node-browser-controller.js new file mode 100644 index 0000000..e34fcef --- /dev/null +++ b/app/docker/views/nodes/node-browser/node-browser-controller.js @@ -0,0 +1,23 @@ +angular.module('portainer.docker').controller('NodeBrowserController', [ + '$stateParams', + 'NodeService', + 'HttpRequestHelper', + 'Notifications', + function NodeBrowserController($stateParams, NodeService, HttpRequestHelper, Notifications) { + var ctrl = this; + ctrl.$onInit = $onInit; + + function $onInit() { + ctrl.nodeId = $stateParams.id; + + NodeService.node(ctrl.nodeId) + .then(function onNodeLoaded(node) { + HttpRequestHelper.setPortainerAgentTargetHeader(node.Hostname); + ctrl.node = node; + }) + .catch(function onError(err) { + Notifications.error('Unable to retrieve host information', err); + }); + } + }, +]); diff --git a/app/docker/views/nodes/node-browser/node-browser.html b/app/docker/views/nodes/node-browser/node-browser.html new file mode 100644 index 0000000..d73f4d9 --- /dev/null +++ b/app/docker/views/nodes/node-browser/node-browser.html @@ -0,0 +1,18 @@ + + + + diff --git a/app/docker/views/nodes/node-browser/node-browser.js b/app/docker/views/nodes/node-browser/node-browser.js new file mode 100644 index 0000000..dc7d60b --- /dev/null +++ b/app/docker/views/nodes/node-browser/node-browser.js @@ -0,0 +1,7 @@ +angular.module('portainer.docker').component('nodeBrowserView', { + templateUrl: './node-browser.html', + controller: 'NodeBrowserController', + bindings: { + endpoint: '<', + }, +}); diff --git a/app/docker/views/nodes/node-details/node-details-view-controller.js b/app/docker/views/nodes/node-details/node-details-view-controller.js new file mode 100644 index 0000000..d6002d4 --- /dev/null +++ b/app/docker/views/nodes/node-details/node-details-view-controller.js @@ -0,0 +1,95 @@ +angular.module('portainer.docker').controller('NodeDetailsViewController', [ + '$q', + '$stateParams', + 'NodeService', + 'StateManager', + 'AgentService', + 'Authentication', + 'Notifications', + function NodeDetailsViewController($q, $stateParams, NodeService, StateManager, AgentService, Authentication, Notifications) { + var ctrl = this; + + ctrl.$onInit = initView; + + ctrl.state = { + isAgent: false, + isAdmin: false, + }; + + function initView() { + var applicationState = StateManager.getState(); + ctrl.state.isAgent = applicationState.endpoint.mode.agentProxy; + ctrl.state.isAdmin = Authentication.isAdmin(); + ctrl.state.enableHostManagementFeatures = ctrl.endpoint.SecuritySettings.enableHostManagementFeatures; + + var nodeId = $stateParams.id; + $q.all({ + node: NodeService.node(nodeId), + }) + .then(function (data) { + var node = data.node; + ctrl.originalNode = node; + ctrl.hostDetails = buildHostDetails(node); + ctrl.engineDetails = buildEngineDetails(node); + ctrl.nodeDetails = buildNodeDetails(node); + if (ctrl.state.isAgent) { + var agentApiVersion = applicationState.endpoint.agentApiVersion; + ctrl.state.agentApiVersion = agentApiVersion; + if (agentApiVersion < 2 || !ctrl.state.enableHostManagementFeatures) { + return; + } + + AgentService.hostInfo(ctrl.endpoint.Id, node.Hostname).then(function onHostInfoLoad(agentHostInfo) { + ctrl.devices = agentHostInfo.PCIDevices; + ctrl.disks = agentHostInfo.PhysicalDisks; + }); + } + }) + .catch(function (err) { + Notifications.error('Failure', err, 'Unable to retrieve node details'); + }); + } + + function buildHostDetails(node) { + return { + os: { + arch: node.PlatformArchitecture, + type: node.PlatformOS, + }, + name: node.Hostname, + totalCPU: node.CPUs / 1e9, + totalMemory: node.Memory, + }; + } + + function buildEngineDetails(node) { + return { + releaseVersion: node.EngineVersion, + volumePlugins: transformPlugins(node.Plugins, 'Volume'), + networkPlugins: transformPlugins(node.Plugins, 'Network'), + engineLabels: node.EngineLabels, + }; + } + + function buildNodeDetails(node) { + return { + name: node.Name, + role: node.Role, + managerAddress: node.ManagerAddr, + availability: node.Availability, + status: node.Status, + nodeLabels: node.Labels, + }; + } + + function transformPlugins(pluginsList, type) { + return pluginsList + .filter(function (plugin) { + return plugin.Type === type; + }) + .map(function (plugin) { + return plugin.Name; + }); + } + }, +]); diff --git a/app/docker/views/nodes/node-details/node-details-view.html b/app/docker/views/nodes/node-details/node-details-view.html new file mode 100644 index 0000000..b868fd7 --- /dev/null +++ b/app/docker/views/nodes/node-details/node-details-view.html @@ -0,0 +1,14 @@ + + + diff --git a/app/docker/views/nodes/node-details/node-details-view.js b/app/docker/views/nodes/node-details/node-details-view.js new file mode 100644 index 0000000..be55e2a --- /dev/null +++ b/app/docker/views/nodes/node-details/node-details-view.js @@ -0,0 +1,7 @@ +angular.module('portainer.docker').component('nodeDetailsView', { + templateUrl: './node-details-view.html', + controller: 'NodeDetailsViewController', + bindings: { + endpoint: '<', + }, +}); diff --git a/app/docker/views/registries/access/registryAccess.html b/app/docker/views/registries/access/registryAccess.html new file mode 100644 index 0000000..cdedd13 --- /dev/null +++ b/app/docker/views/registries/access/registryAccess.html @@ -0,0 +1,15 @@ +
+ + + + + + +
diff --git a/app/docker/views/registries/access/registryAccess.js b/app/docker/views/registries/access/registryAccess.js new file mode 100644 index 0000000..7ee0814 --- /dev/null +++ b/app/docker/views/registries/access/registryAccess.js @@ -0,0 +1,7 @@ +angular.module('portainer.docker').component('dockerRegistryAccessView', { + templateUrl: './registryAccess.html', + controller: 'DockerRegistryAccessController', + bindings: { + endpoint: '<', + }, +}); diff --git a/app/docker/views/registries/access/registryAccessController.js b/app/docker/views/registries/access/registryAccessController.js new file mode 100644 index 0000000..51e8d6b --- /dev/null +++ b/app/docker/views/registries/access/registryAccessController.js @@ -0,0 +1,70 @@ +import { TeamAccessViewModel, UserAccessViewModel } from '@/portainer/models/access'; + +class DockerRegistryAccessController { + /* @ngInject */ + constructor($async, $state, Notifications, EndpointService, GroupService, RegistryService) { + this.$async = $async; + this.$state = $state; + this.Notifications = Notifications; + this.EndpointService = EndpointService; + this.GroupService = GroupService; + this.RegistryService = RegistryService; + + this.updateAccess = this.updateAccess.bind(this); + this.filterUsers = this.filterUsers.bind(this); + } + + updateAccess() { + return this.$async(async () => { + this.state.actionInProgress = true; + try { + await this.EndpointService.updateRegistryAccess(this.state.endpointId, this.state.registryId, this.registryEndpointAccesses); + this.Notifications.success('Success', 'Access successfully updated'); + this.$state.reload(); + } catch (err) { + this.state.actionInProgress = false; + this.Notifications.error('Failure', err, 'Unable to update accesses'); + } + }); + } + + filterUsers(users) { + const endpointUsers = this.endpoint.UserAccessPolicies || {}; + const endpointTeams = this.endpoint.TeamAccessPolicies || {}; + + const endpointGroupUsers = this.endpointGroup.UserAccessPolicies || {}; + const endpointGroupTeams = this.endpointGroup.TeamAccessPolicies || {}; + + return users.filter((userOrTeam) => { + const userAccess = userOrTeam instanceof UserAccessViewModel && (endpointUsers[userOrTeam.Id] || endpointGroupUsers[userOrTeam.Id]); + const teamAccess = userOrTeam instanceof TeamAccessViewModel && (endpointTeams[userOrTeam.Id] || endpointGroupTeams[userOrTeam.Id]); + + return userAccess || teamAccess; + }); + } + + $onInit() { + return this.$async(async () => { + this.registryTo = window.location.hash.match(/#!\/\d+\/docker\/swarm\/registries/) ? 'docker.swarm.registries' : 'docker.host.registries'; + + try { + this.state = { + viewReady: false, + actionInProgress: false, + endpointId: this.$state.params.endpointId, + registryId: this.$state.params.id, + }; + this.registry = await this.RegistryService.registry(this.state.registryId, this.state.endpointId); + this.registryEndpointAccesses = this.registry.RegistryAccesses[this.state.endpointId] || {}; + this.endpointGroup = await this.GroupService.group(this.endpoint.GroupId); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to retrieve registry details'); + } finally { + this.state.viewReady = true; + } + }); + } +} + +export default DockerRegistryAccessController; +angular.module('portainer.docker').controller('DockerRegistryAccessController', DockerRegistryAccessController); diff --git a/app/docker/views/secrets/create/createSecretController.js b/app/docker/views/secrets/create/createSecretController.js new file mode 100644 index 0000000..3e79046 --- /dev/null +++ b/app/docker/views/secrets/create/createSecretController.js @@ -0,0 +1,102 @@ +import { AccessControlFormData } from '../../../../portainer/components/accessControlForm/porAccessControlFormModel'; + +angular.module('portainer.docker').controller('CreateSecretController', [ + '$scope', + '$state', + 'Notifications', + 'SecretService', + 'LabelHelper', + 'Authentication', + 'ResourceControlService', + 'FormValidator', + function ($scope, $state, Notifications, SecretService, LabelHelper, Authentication, ResourceControlService, FormValidator) { + $scope.formValues = { + Name: '', + Data: '', + Labels: [], + encodeSecret: true, + AccessControlData: new AccessControlFormData(), + }; + + $scope.state = { + formValidationError: '', + actionInProgress: false, + }; + + $scope.handleEncodeSecretChange = handleEncodeSecretChange; + + function handleEncodeSecretChange(checked) { + return $scope.$evalAsync(() => { + $scope.formValues.encodeSecret = checked; + }); + } + + $scope.addLabel = function () { + $scope.formValues.Labels.push({ key: '', value: '' }); + }; + + $scope.removeLabel = function (index) { + $scope.formValues.Labels.splice(index, 1); + }; + + function prepareLabelsConfig(config) { + config.Labels = LabelHelper.fromKeyValueToLabelHash($scope.formValues.Labels); + } + + function prepareSecretData(config) { + if ($scope.formValues.encodeSecret) { + config.Data = btoa(unescape(encodeURIComponent($scope.formValues.Data))); + } else { + config.Data = $scope.formValues.Data; + } + } + + function prepareConfiguration() { + var config = {}; + config.Name = $scope.formValues.Name; + prepareSecretData(config); + prepareLabelsConfig(config); + return config; + } + + function validateForm(accessControlData, isAdmin) { + $scope.state.formValidationError = ''; + var error = FormValidator.validateAccessControl(accessControlData, isAdmin); + + if (error) { + $scope.state.formValidationError = error; + return false; + } + return true; + } + + $scope.create = function () { + const accessControlData = $scope.formValues.AccessControlData; + const userDetails = Authentication.getUserDetails(); + const isAdmin = Authentication.isAdmin(); + + if (!validateForm(accessControlData, isAdmin)) { + return; + } + + $scope.state.actionInProgress = true; + var secretConfiguration = prepareConfiguration(); + SecretService.create(secretConfiguration) + .then(function success(data) { + const userId = userDetails.ID; + const resourceControl = data.Portainer.ResourceControl; + return ResourceControlService.applyResourceControl(userId, accessControlData, resourceControl); + }) + .then(function success() { + Notifications.success('Success', 'Secret successfully created'); + $state.go('docker.secrets', {}, { reload: true }); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to create secret'); + }) + .finally(function final() { + $scope.state.actionInProgress = false; + }); + }; + }, +]); diff --git a/app/docker/views/secrets/create/createsecret.html b/app/docker/views/secrets/create/createsecret.html new file mode 100644 index 0000000..886db49 --- /dev/null +++ b/app/docker/views/secrets/create/createsecret.html @@ -0,0 +1,89 @@ + + +
+
+ + +
+ +
+ +
+ +
+
+ + +
+ +
+ +
+
+ + +
+
+ +
+
+ + +
+
+ + add label +
+ +
+
+
+ name + +
+
+ value + + + + +
+
+
+ +
+ + + + + +
Actions
+
+
+ + {{ state.formValidationError }} +
+
+ +
+
+
+
+
diff --git a/app/docker/views/secrets/edit/secret.html b/app/docker/views/secrets/edit/secret.html new file mode 100644 index 0000000..524f481 --- /dev/null +++ b/app/docker/views/secrets/edit/secret.html @@ -0,0 +1,59 @@ + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Name{{ secret.Name }}
ID + {{ secret.Id }} + +
Created{{ secret.CreatedAt | getisodate }}
Last updated{{ secret.UpdatedAt | getisodate }}
Labels + + + + + +
{{ k }}{{ v }}
+
+
+
+
+
+ + + + + diff --git a/app/docker/views/secrets/edit/secretController.js b/app/docker/views/secrets/edit/secretController.js new file mode 100644 index 0000000..cc527f4 --- /dev/null +++ b/app/docker/views/secrets/edit/secretController.js @@ -0,0 +1,35 @@ +import { ResourceControlType } from '@/react/portainer/access-control/types'; + +angular.module('portainer.docker').controller('SecretController', SecretController); + +/* @ngInject */ +function SecretController($scope, $transition$, $state, SecretService, Notifications, endpoint) { + $scope.resourceType = ResourceControlType.Secret; + $scope.endpoint = endpoint; + $scope.onUpdateResourceControlSuccess = function () { + $state.reload(); + }; + + $scope.removeSecret = function removeSecret(secretId) { + SecretService.remove(secretId) + .then(function success() { + Notifications.success('Success', 'Secret successfully removed'); + $state.go('docker.secrets', {}); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to remove secret'); + }); + }; + + function initView() { + SecretService.secret($transition$.params().id) + .then(function success(data) { + $scope.secret = data; + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve secret details'); + }); + } + + initView(); +} diff --git a/app/docker/views/secrets/secrets.html b/app/docker/views/secrets/secrets.html new file mode 100644 index 0000000..d286ee2 --- /dev/null +++ b/app/docker/views/secrets/secrets.html @@ -0,0 +1,17 @@ + + + + +
+
+ +
+
diff --git a/app/docker/views/secrets/secretsController.js b/app/docker/views/secrets/secretsController.js new file mode 100644 index 0000000..f1d0696 --- /dev/null +++ b/app/docker/views/secrets/secretsController.js @@ -0,0 +1,45 @@ +import { processItemsInBatches } from '@/react/common/processItemsInBatches'; + +angular.module('portainer.docker').controller('SecretsController', [ + '$scope', + '$state', + 'SecretService', + 'Notifications', + function ($scope, $state, SecretService, Notifications) { + $scope.removeAction = async function (selectedItems) { + async function doRemove(secret) { + return SecretService.remove(secret.Id) + .then(function success() { + Notifications.success('Secret successfully removed', secret.Name); + var index = $scope.secrets.indexOf(secret); + $scope.secrets.splice(index, 1); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to remove secret'); + }); + } + + await processItemsInBatches(selectedItems, doRemove); + $state.reload(); + }; + + $scope.getSecrets = getSecrets; + + function getSecrets() { + SecretService.secrets() + .then(function success(data) { + $scope.secrets = data; + }) + .catch(function error(err) { + $scope.secrets = []; + Notifications.error('Failure', err, 'Unable to retrieve secrets'); + }); + } + + function initView() { + getSecrets(); + } + + initView(); + }, +]); diff --git a/app/docker/views/services/create/createServiceController.js b/app/docker/views/services/create/createServiceController.js new file mode 100644 index 0000000..f95969d --- /dev/null +++ b/app/docker/views/services/create/createServiceController.js @@ -0,0 +1,634 @@ +import _ from 'lodash-es'; + +import * as envVarsUtils from '@/react/components/form-components/EnvironmentVariablesFieldset/utils'; +import { PorImageRegistryModel } from '@/docker/models/porImageRegistry'; +import { AccessControlFormData } from '../../../../portainer/components/accessControlForm/porAccessControlFormModel'; + +require('./includes/update-restart.html'); +require('./includes/secret.html'); +require('./includes/config.html'); +require('./includes/resources-placement.html'); + +angular.module('portainer.docker').controller('CreateServiceController', [ + '$q', + '$scope', + '$state', + '$timeout', + 'ServiceService', + 'ServiceHelper', + 'ConfigService', + 'ConfigHelper', + 'SecretHelper', + 'SecretService', + 'VolumeService', + 'NetworkService', + 'ImageHelper', + 'LabelHelper', + 'Authentication', + 'ResourceControlService', + 'Notifications', + 'FormValidator', + 'PluginService', + 'NodeService', + 'WebhookService', + 'endpoint', + function ( + $q, + $scope, + $state, + $timeout, + ServiceService, + ServiceHelper, + ConfigService, + ConfigHelper, + SecretHelper, + SecretService, + VolumeService, + NetworkService, + ImageHelper, + LabelHelper, + Authentication, + ResourceControlService, + Notifications, + FormValidator, + PluginService, + NodeService, + WebhookService, + endpoint + ) { + $scope.endpoint = endpoint; + + $scope.formValues = { + Name: '', + RegistryModel: new PorImageRegistryModel(), + Mode: 'replicated', + Replicas: 1, + Command: '', + EntryPoint: '', + WorkingDir: '', + User: '', + Env: [], + Labels: [], + ContainerLabels: [], + Volumes: [], + Network: '', + ExtraNetworks: [], + HostsEntries: [], + Ports: [], + Parallelism: 1, + PlacementConstraints: [], + PlacementPreferences: [], + UpdateDelay: '0s', + UpdateOrder: 'stop-first', + FailureAction: 'pause', + Secrets: [], + Configs: [], + AccessControlData: new AccessControlFormData(), + CpuLimit: 0, + CpuReservation: 0, + MemoryLimit: 0, + MemoryReservation: 0, + MemoryLimitUnit: 'MB', + MemoryReservationUnit: 'MB', + RestartCondition: 'any', + RestartDelay: '5s', + RestartMaxAttempts: 0, + RestartWindow: '0s', + LogDriverName: '', + LogDriverOpts: [], + Webhook: false, + }; + + $scope.state = { + formValidationError: '', + actionInProgress: false, + pullImageValidity: false, + }; + + $scope.allowBindMounts = false; + + $scope.handleWebHookChange = handleWebHookChange; + + function handleWebHookChange(checked) { + return $scope.$evalAsync(() => { + $scope.formValues.Webhook = checked; + }); + } + + $scope.handleEnvVarChange = handleEnvVarChange; + function handleEnvVarChange(value) { + $scope.formValues.Env = value; + } + + $scope.refreshSlider = function () { + $timeout(function () { + $scope.$broadcast('rzSliderForceRender'); + }); + }; + + $scope.setPullImageValidity = setPullImageValidity; + function setPullImageValidity(validity) { + $scope.state.pullImageValidity = validity; + } + + $scope.addPortBinding = function () { + $scope.formValues.Ports.push({ PublishedPort: '', TargetPort: '', Protocol: 'tcp', PublishMode: 'ingress' }); + }; + + $scope.removePortBinding = function (index) { + $scope.formValues.Ports.splice(index, 1); + }; + + $scope.addExtraNetwork = function () { + $scope.formValues.ExtraNetworks.push({ Name: '' }); + }; + + $scope.removeExtraNetwork = function (index) { + $scope.formValues.ExtraNetworks.splice(index, 1); + }; + + $scope.addHostsEntry = function () { + $scope.formValues.HostsEntries.push({}); + }; + + $scope.removeHostsEntry = function (index) { + $scope.formValues.HostsEntries.splice(index, 1); + }; + + $scope.addVolume = function () { + $scope.formValues.Volumes.push({ Source: null, Target: '', ReadOnly: false, Type: 'volume' }); + }; + + $scope.removeVolume = function (index) { + $scope.formValues.Volumes.splice(index, 1); + }; + + $scope.addConfig = function () { + $scope.formValues.Configs.push({}); + }; + + $scope.removeConfig = function (index) { + $scope.formValues.Configs.splice(index, 1); + $scope.checkIfConfigDuplicated(); + }; + + $scope.addSecret = function () { + $scope.formValues.Secrets.push({ overrideTarget: false }); + }; + + $scope.removeSecret = function (index) { + $scope.formValues.Secrets.splice(index, 1); + $scope.checkIfSecretDuplicated(); + }; + + $scope.addPlacementConstraint = function () { + $scope.formValues.PlacementConstraints.push({ key: '', operator: '==', value: '' }); + }; + + $scope.removePlacementConstraint = function (index) { + $scope.formValues.PlacementConstraints.splice(index, 1); + }; + + $scope.addPlacementPreference = function () { + $scope.formValues.PlacementPreferences.push({ strategy: 'spread', value: '' }); + }; + + $scope.removePlacementPreference = function (index) { + $scope.formValues.PlacementPreferences.splice(index, 1); + }; + + $scope.addLabel = function () { + $scope.formValues.Labels.push({ key: '', value: '' }); + }; + + $scope.removeLabel = function (index) { + $scope.formValues.Labels.splice(index, 1); + }; + + $scope.addContainerLabel = function () { + $scope.formValues.ContainerLabels.push({ key: '', value: '' }); + }; + + $scope.removeContainerLabel = function (index) { + $scope.formValues.ContainerLabels.splice(index, 1); + }; + + $scope.addLogDriverOpt = function () { + $scope.formValues.LogDriverOpts.push({ name: '', value: '' }); + }; + + $scope.removeLogDriverOpt = function (index) { + $scope.formValues.LogDriverOpts.splice(index, 1); + }; + + $scope.checkIfSecretDuplicated = function () { + $scope.formValues.Secrets.$invalid = false; + [...$scope.formValues.Secrets] + .sort((a, b) => a.model.Id.localeCompare(b.model.Id)) + .sort((a, b) => { + if (a.model.Id === b.model.Id) { + $scope.formValues.Secrets.$invalid = true; + $scope.formValues.Secrets.$error = 'Secret ' + a.model.Name + ' cannot be assigned multiple times.'; + } + }); + if (!$scope.formValues.Secrets.$invalid) { + $scope.formValues.Secrets.$error = ''; + } + }; + + $scope.checkIfConfigDuplicated = function () { + $scope.formValues.Configs.$invalid = false; + [...$scope.formValues.Configs] + .sort((a, b) => a.model.Id.localeCompare(b.model.Id)) + .sort((a, b) => { + if (a.model.Id === b.model.Id) { + $scope.formValues.Configs.$invalid = true; + $scope.formValues.Configs.$error = 'Config ' + a.model.Name + ' cannot be assigned multiple times.'; + } + }); + if (!$scope.formValues.Configs.$invalid) { + $scope.formValues.Configs.$error = ''; + } + }; + + function prepareImageConfig(config, input) { + var imageConfig = ImageHelper.createImageConfigForContainer(input.RegistryModel); + config.TaskTemplate.ContainerSpec.Image = imageConfig.fromImage; + } + + function preparePortsConfig(config, input) { + let ports = []; + input.Ports.forEach(function (binding) { + const port = { + Protocol: binding.Protocol, + PublishMode: binding.PublishMode, + }; + if (binding.TargetPort) { + port.TargetPort = +binding.TargetPort; + if (binding.PublishedPort) { + port.PublishedPort = +binding.PublishedPort; + } + ports.push(port); + } + }); + config.EndpointSpec.Ports = ports; + } + + function prepareSchedulingConfig(config, input) { + if (input.Mode === 'replicated') { + config.Mode.Replicated = { + Replicas: input.Replicas, + }; + } else { + config.Mode.Global = {}; + } + } + + function commandToArray(cmd) { + var tokens = [].concat + .apply( + [], + cmd.split("'").map(function (v, i) { + return i % 2 ? v : v.split(' '); + }) + ) + .filter(Boolean); + return tokens; + } + + function prepareCommandConfig(config, input) { + if (input.EntryPoint) { + config.TaskTemplate.ContainerSpec.Command = commandToArray(input.EntryPoint); + } + if (input.Command) { + config.TaskTemplate.ContainerSpec.Args = commandToArray(input.Command); + } + if (input.User) { + config.TaskTemplate.ContainerSpec.User = input.User; + } + if (input.WorkingDir) { + config.TaskTemplate.ContainerSpec.Dir = input.WorkingDir; + } + } + + function prepareEnvConfig(config, input) { + config.TaskTemplate.ContainerSpec.Env = envVarsUtils.convertToArrayOfStrings(input.Env); + } + + function prepareLabelsConfig(config, input) { + config.Labels = LabelHelper.fromKeyValueToLabelHash(input.Labels); + config.TaskTemplate.ContainerSpec.Labels = LabelHelper.fromKeyValueToLabelHash(input.ContainerLabels); + } + + function createMountObjectFromVolume(volumeObject, target, readonly) { + return { + Target: target, + Source: volumeObject.Id, + Type: 'volume', + ReadOnly: readonly, + VolumeOptions: { + Labels: volumeObject.Labels, + DriverConfig: { + Name: volumeObject.Driver, + Options: volumeObject.Options, + }, + }, + }; + } + + function prepareVolumes(config, input) { + input.Volumes.forEach(function (volume) { + if (volume.Source && volume.Target) { + if (volume.Type !== 'volume') { + config.TaskTemplate.ContainerSpec.Mounts.push(volume); + } else { + var volumeObject = volume.Source; + var mount = createMountObjectFromVolume(volumeObject, volume.Target, volume.ReadOnly); + config.TaskTemplate.ContainerSpec.Mounts.push(mount); + } + } + }); + } + + function prepareNetworks(config, input) { + var networks = []; + if (input.Network) { + networks.push({ Target: input.Network }); + } + input.ExtraNetworks.forEach(function (network) { + networks.push({ Target: network.Name }); + }); + config.TaskTemplate.Networks = _.uniqWith(networks, _.isEqual); + } + + function prepareHostsEntries(config, input) { + var hostsEntries = []; + if (input.HostsEntries) { + input.HostsEntries.forEach(function (host_ip) { + if (host_ip.value && host_ip.value.indexOf(':') && host_ip.value.split(':').length === 2) { + var keyVal = host_ip.value.split(':'); + // Hosts file format, IP_address canonical_hostname + hostsEntries.push(keyVal[1] + ' ' + keyVal[0]); + } + }); + if (hostsEntries.length > 0) { + config.TaskTemplate.ContainerSpec.Hosts = hostsEntries; + } + } + } + + function prepareUpdateConfig(config, input) { + config.UpdateConfig = { + Parallelism: input.Parallelism || 0, + Delay: ServiceHelper.translateHumanDurationToNanos(input.UpdateDelay) || 0, + FailureAction: input.FailureAction, + Order: input.UpdateOrder, + }; + } + + function prepareRestartPolicy(config, input) { + config.TaskTemplate.RestartPolicy = { + Condition: input.RestartCondition || 'any', + Delay: ServiceHelper.translateHumanDurationToNanos(input.RestartDelay) || 5000000000, + MaxAttempts: input.RestartMaxAttempts || 0, + Window: ServiceHelper.translateHumanDurationToNanos(input.RestartWindow) || 0, + }; + } + + function preparePlacementConfig(config, input) { + config.TaskTemplate.Placement.Constraints = ServiceHelper.translateKeyValueToPlacementConstraints(input.PlacementConstraints); + config.TaskTemplate.Placement.Preferences = ServiceHelper.translateKeyValueToPlacementPreferences(input.PlacementPreferences); + } + + function prepareConfigConfig(config, input) { + if (input.Configs) { + var configs = []; + angular.forEach(input.Configs, function (config) { + if (config.model) { + var s = ConfigHelper.configConfig(config.model); + s.File.Name = config.FileName || s.File.Name; + configs.push(s); + } + }); + config.TaskTemplate.ContainerSpec.Configs = configs; + } + } + + function prepareSecretConfig(config, input) { + if (input.Secrets) { + var secrets = []; + angular.forEach(input.Secrets, function (secret) { + if (secret.model) { + var s = SecretHelper.secretConfig(secret.model); + s.File.Name = s.SecretName; + if (secret.overrideTarget && secret.target && secret.target !== '') { + s.File.Name = secret.target; + } + secrets.push(s); + } + }); + config.TaskTemplate.ContainerSpec.Secrets = secrets; + } + } + + function prepareResourcesCpuConfig(config, input) { + // CPU Limit + if (input.CpuLimit > 0) { + config.TaskTemplate.Resources.Limits.NanoCPUs = input.CpuLimit * 1000000000; + } + // CPU Reservation + if (input.CpuReservation > 0) { + config.TaskTemplate.Resources.Reservations.NanoCPUs = input.CpuReservation * 1000000000; + } + } + + function prepareResourcesMemoryConfig(config, input) { + // Memory Limit - Round to 0.125 + var memoryLimit = (Math.round(input.MemoryLimit * 8) / 8).toFixed(3); + memoryLimit *= 1024 * 1024; + if (input.MemoryLimitUnit === 'GB') { + memoryLimit *= 1024; + } + if (memoryLimit > 0) { + config.TaskTemplate.Resources.Limits.MemoryBytes = memoryLimit; + } + // Memory Resevation - Round to 0.125 + var memoryReservation = (Math.round(input.MemoryReservation * 8) / 8).toFixed(3); + memoryReservation *= 1024 * 1024; + if (input.MemoryReservationUnit === 'GB') { + memoryReservation *= 1024; + } + if (memoryReservation > 0) { + config.TaskTemplate.Resources.Reservations.MemoryBytes = memoryReservation; + } + } + + function prepareLogDriverConfig(config, input) { + var logOpts = {}; + if (input.LogDriverName) { + config.TaskTemplate.LogDriver = { Name: input.LogDriverName }; + if (input.LogDriverName !== 'none') { + input.LogDriverOpts.forEach(function (opt) { + if (opt.name) { + logOpts[opt.name] = opt.value; + } + }); + if (Object.keys(logOpts).length !== 0 && logOpts.constructor === Object) { + config.TaskTemplate.LogDriver.Options = logOpts; + } + } + } + } + + function prepareConfiguration() { + var input = $scope.formValues; + var config = { + Name: input.Name, + TaskTemplate: { + ContainerSpec: { + Mounts: [], + }, + Placement: {}, + Resources: { + Limits: {}, + Reservations: {}, + }, + }, + Mode: {}, + EndpointSpec: {}, + }; + prepareSchedulingConfig(config, input); + prepareImageConfig(config, input); + preparePortsConfig(config, input); + prepareCommandConfig(config, input); + prepareEnvConfig(config, input); + prepareLabelsConfig(config, input); + prepareVolumes(config, input); + prepareNetworks(config, input); + prepareHostsEntries(config, input); + prepareUpdateConfig(config, input); + prepareConfigConfig(config, input); + prepareSecretConfig(config, input); + preparePlacementConfig(config, input); + prepareResourcesCpuConfig(config, input); + prepareResourcesMemoryConfig(config, input); + prepareRestartPolicy(config, input); + prepareLogDriverConfig(config, input); + return config; + } + + function createNewService(config, accessControlData) { + const registryModel = $scope.formValues.RegistryModel; + + ServiceService.create(config, registryModel.Registry.Authentication ? registryModel.Registry.Id : 0) + .then(function success(data) { + const serviceId = data.ID; + const resourceControl = data.Portainer.ResourceControl; + const userId = Authentication.getUserDetails().ID; + const rcPromise = ResourceControlService.applyResourceControl(userId, accessControlData, resourceControl); + const registryID = $scope.formValues.RegistryModel.Registry.Id; + const webhookPromise = $q.when(endpoint.Type !== 4 && $scope.formValues.Webhook && WebhookService.createServiceWebhook(serviceId, endpoint.Id, registryID)); + return $q.all([rcPromise, webhookPromise]); + }) + .then(function success() { + Notifications.success('Success', 'Service successfully created'); + $state.go('docker.services', {}, { reload: true }); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to create service'); + }) + .finally(function final() { + $scope.state.actionInProgress = false; + }); + } + + function validateForm(accessControlData, isAdmin) { + $scope.state.formValidationError = ''; + var error = FormValidator.validateAccessControl(accessControlData, isAdmin) || $scope.formValues.Secrets.$error || $scope.formValues.Configs.$error; + + if (error) { + $scope.state.formValidationError = error; + return false; + } + return true; + } + + $scope.volumesAreValid = volumesAreValid; + function volumesAreValid() { + const volumes = $scope.formValues.Volumes; + return volumes.every((volume) => volume.Target && volume.Source); + } + + $scope.create = function createService() { + var accessControlData = $scope.formValues.AccessControlData; + + if (!validateForm(accessControlData, $scope.isAdmin)) { + return; + } + + $scope.state.actionInProgress = true; + var config = prepareConfiguration(); + createNewService(config, accessControlData); + }; + + function initSlidersMaxValuesBasedOnNodeData(nodes) { + var maxCpus = 0; + var maxMemory = 0; + for (var n in nodes) { + if (nodes[n].CPUs && nodes[n].CPUs > maxCpus) { + maxCpus = nodes[n].CPUs; + } + if (nodes[n].Memory && nodes[n].Memory > maxMemory) { + maxMemory = nodes[n].Memory; + } + } + if (maxCpus > 0) { + $scope.state.sliderMaxCpu = maxCpus / 1000000000; + } else { + $scope.state.sliderMaxCpu = 32; + } + if (maxMemory > 0) { + $scope.state.sliderMaxMemory = Math.floor(maxMemory / 1000 / 1000); + } else { + $scope.state.sliderMaxMemory = 32768; + } + } + + function initView() { + var apiVersion = $scope.applicationState.endpoint.apiVersion; + + $q.all({ + volumes: VolumeService.volumes(), + networks: NetworkService.networks(true, true, false), + secrets: apiVersion >= 1.25 ? SecretService.secrets() : [], + configs: apiVersion >= 1.3 ? ConfigService.configs(endpoint.Id) : [], + nodes: NodeService.nodes(), + availableLoggingDrivers: PluginService.loggingPlugins(apiVersion < 1.25), + allowBindMounts: checkIfAllowedBindMounts(), + }) + .then(function success(data) { + $scope.availableVolumes = data.volumes; + $scope.availableNetworks = data.networks; + $scope.availableSecrets = data.secrets; + $scope.availableConfigs = data.configs; + $scope.availableLoggingDrivers = data.availableLoggingDrivers; + initSlidersMaxValuesBasedOnNodeData(data.nodes); + $scope.isAdmin = Authentication.isAdmin(); + $scope.allowBindMounts = data.allowBindMounts; + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to initialize view'); + }); + } + + initView(); + + async function checkIfAllowedBindMounts() { + const isAdmin = Authentication.isAdmin(); + + const { allowBindMountsForRegularUsers } = endpoint.SecuritySettings; + + return isAdmin || allowBindMountsForRegularUsers; + } + }, +]); diff --git a/app/docker/views/services/create/createservice.html b/app/docker/views/services/create/createservice.html new file mode 100644 index 0000000..cbaf5c6 --- /dev/null +++ b/app/docker/views/services/create/createservice.html @@ -0,0 +1,505 @@ + + +
+
+ + +
+ +
+ +
+ +
+
+ +
Image configuration
+ + + + +
Scheduling
+ +
+
+ +
+ + +
+
+
+
+
+ +
+ +
+
+
+ +
Ports configuration
+ +
+ +
+ map additional port +
+
+
+ +
+ host + +
+ + + + + +
+ container + +
+ + +
+
+ + +
+
+ + +
+ +
+ +
+
+ +
+ + +
+
Webhooks
+
+
+ +
+
+
+ + + + + +
Actions
+
+
+ + {{ state.formValidationError }} +
+
+ +
+
+
+
+
+ +
+
+ + + + +
+ +
+
+
Command
+ +
+ +
+ +
+
+ + +
+ +
+ +
+
+ + +
+ +
+ +
+ +
+ +
+
+ +
Logging
+ +
+ +
+ +
+
+

+ Logging driver for service that will override the default docker daemon driver. Select Default logging driver if you don't want to override it. Supported + logging drivers can be found + in the Docker documentation. +

+
+
+ + +
+
+ + + add logging driver option + +
+ +
+
+
+ option + +
+
+ value + +
+ +
+
+ +
+ +
+
+ + +
+
+ +
+
+ + + map additional volume + +
+ +
+
+
+ +
+ +
+
+ container + +
+
Target is required.
+
+ + +
+
+ + +
+ +
+ +
+ + +
+
+ +
+ +
+
+ volume + +
+
Source is required.
+
+ + +
+
+ host + +
+
Source is required.
+
+ + +
+
+ + +
+
+ +
+ +
+
+
+ +
+ +
+
+ + +
+
+ +
+ +
+ +
+
+
+ + +
+
+ + + add extra network + +
+ +
+
+ + +
+
+ +
+ + +
+
+ + + add additional entry + +
+ +
+
+
+ value + +
+ +
+
+ +
+ +
+
+ + +
+
+ +
+
+ + +
+
+ +
+
+ + + add service label + +
+ +
+
+
+ name + +
+
+ value + +
+ +
+
+ +
+ + +
+
+ + + add container label + +
+ +
+
+
+ name + +
+
+ value + +
+ +
+
+ +
+ +
+
+ + +
+ + +
+ + +
+ + +
+ +
+
+
+
+
diff --git a/app/docker/views/services/create/includes/config.html b/app/docker/views/services/create/includes/config.html new file mode 100644 index 0000000..3352690 --- /dev/null +++ b/app/docker/views/services/create/includes/config.html @@ -0,0 +1,37 @@ +
+
+
+ + add a config +
+ +
+
+ {{ formValues.Configs.$error }} +
+
+
+
+
+ config + +
+
+ Path in container + +
+ +
+
+
+
diff --git a/app/docker/views/services/create/includes/resources-placement.html b/app/docker/views/services/create/includes/resources-placement.html new file mode 100644 index 0000000..16cd4c7 --- /dev/null +++ b/app/docker/views/services/create/includes/resources-placement.html @@ -0,0 +1,152 @@ +
+
Resources
+ +
+ +
+ +
+
+ +
+
+

Minimum memory available on a node to run a task (MB)

+
+
+ + +
+ +
+ +
+
+ +
+
+

Maximum memory usage per task (MB)

+
+
+ + +
+ +
+ +
+
+

Minimum CPU available on a node to run a task

+
+
+ + +
+ +
+ +
+
+

Maximum CPU usage per task

+
+
+ +
Placement
+ +
+
+ + + placement constraint + +
+
+
+
+ name + +
+
+ +
+
+ value + +
+ +
+
+
+ + +
+
+ + + placement preference + +
+
+
+
+ strategy + +
+
+ value + +
+ +
+
+
+ +
diff --git a/app/docker/views/services/create/includes/secret.html b/app/docker/views/services/create/includes/secret.html new file mode 100644 index 0000000..a82cf25 --- /dev/null +++ b/app/docker/views/services/create/includes/secret.html @@ -0,0 +1,49 @@ +
+
+
+ By default, secrets will be available under /run/secrets/$SECRET_NAME in containers (Linux) or + C:\ProgramData\Docker\secrets\$SECRET_NAME (Windows).
+
+
+
+ + add a secret +
+ +
+
+ {{ formValues.Secrets.$error }} +
+
+
+
+
+ secret + +
+
+ target + +
+
+
+ + +
+ +
+
+
+
+
diff --git a/app/docker/views/services/create/includes/update-restart.html b/app/docker/views/services/create/includes/update-restart.html new file mode 100644 index 0000000..c9f944f --- /dev/null +++ b/app/docker/views/services/create/includes/update-restart.html @@ -0,0 +1,145 @@ +
+
Update config
+ +
+ +
+ +
+
+

Maximum number of tasks to be updated simultaneously (0 to update all at once).

+
+
+ + +
+ +
+ +
+
+

Amount of time between updates expressed by a number followed by unit (ns|us|ms|s|m|h). Default value is 0s, 0 seconds.

+
+
+ + +
+ +
+
+ + +
+
+
+

Action taken on failure to start after update.

+
+
+ + +
+ +
+
+ + +
+
+
+

Operation order on failure.

+
+
+ + +
Restart policy
+ +
+ +
+
+ + + +
+
+
+

Restart when condition is met (default condition "any").

+
+
+ + +
+ +
+ +
+
+

Delay between restart attempts expressed by a number followed by unit (ns|us|ms|s|m|h). Default value is 5s, 5 seconds.

+
+
+ + +
+ +
+ +
+
+

Maximum attempts to restart a given task before giving up (default value is 0, which means unlimited).

+
+
+ + +
+ +
+ +
+
+

+ Time window to evaluate restart attempts expressed by a number followed by unit (ns|us|ms|s|m|h). Default value is 0 seconds, which is unbounded. +

+
+
+ +
diff --git a/app/docker/views/services/edit/includes/configs.html b/app/docker/views/services/edit/includes/configs.html new file mode 100644 index 0000000..435e00b --- /dev/null +++ b/app/docker/views/services/edit/includes/configs.html @@ -0,0 +1,75 @@ +
+ + + +
+ Add a config: + + Add config +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
NamePath in containerUIDGIDMode
+ {{ config.Name }} + + + {{ config.Uid }}{{ config.Gid }}{{ config.Mode }}Credential Spec + +
No configs associated to this service.
+
+ + + +
+
diff --git a/app/docker/views/services/edit/includes/constraints.html b/app/docker/views/services/edit/includes/constraints.html new file mode 100644 index 0000000..b076558 --- /dev/null +++ b/app/docker/views/services/edit/includes/constraints.html @@ -0,0 +1,92 @@ +
+ + + + + +

There are no placement constraints for this service.

+
+ + + + + + + + + + + + + + + + +
NameOperatorValue
+
+ +
+
+
+ +
+
+
+ + + + +
+
+
+ + + +
+
diff --git a/app/docker/views/services/edit/includes/container-specs.html b/app/docker/views/services/edit/includes/container-specs.html new file mode 100644 index 0000000..2b51e69 --- /dev/null +++ b/app/docker/views/services/edit/includes/container-specs.html @@ -0,0 +1,52 @@ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CMD{{ service.Command | command }} +

Command to execute.

+
Args{{ service.Arguments }} +

Arguments passed to command in container.

+
User{{ service.User }} +

Username or UID.

+
Working directory{{ service.Dir }} +

Working directory inside the container.

+
Stop grace period{{ service.StopGracePeriod }} +

+ The duration to wait before forcefully terminating a container (default: 10 seconds as defined by the Docker engine). +

+
+
+
+
diff --git a/app/docker/views/services/edit/includes/containerlabels.html b/app/docker/views/services/edit/includes/containerlabels.html new file mode 100644 index 0000000..a999fa2 --- /dev/null +++ b/app/docker/views/services/edit/includes/containerlabels.html @@ -0,0 +1,79 @@ +
+ + + + + +

There are no container labels for this service.

+
+ + + + + + + + + + + + + + +
LabelValue
+
+ name + +
+
+
+ value + + + + +
+
+
+ + + +
+
diff --git a/app/docker/views/services/edit/includes/environmentvariables.html b/app/docker/views/services/edit/includes/environmentvariables.html new file mode 100644 index 0000000..396ec6c --- /dev/null +++ b/app/docker/views/services/edit/includes/environmentvariables.html @@ -0,0 +1,38 @@ + + + + + + +

There are no environment variables for this service.

+
+ + + + + + +
+
diff --git a/app/docker/views/services/edit/includes/hosts.html b/app/docker/views/services/edit/includes/hosts.html new file mode 100644 index 0000000..5f12b79 --- /dev/null +++ b/app/docker/views/services/edit/includes/hosts.html @@ -0,0 +1,75 @@ +
+ + + + + +

The Hosts file has no extra entries.

+
+ + + + + + + + + + + + + + +
HostnameIP
+
+ +
+
+
+ + + + +
+
+
+ + + +
+
diff --git a/app/docker/views/services/edit/includes/image.html b/app/docker/views/services/edit/includes/image.html new file mode 100644 index 0000000..39abe6b --- /dev/null +++ b/app/docker/views/services/edit/includes/image.html @@ -0,0 +1,37 @@ +
+ + + +
+ + +
+
+ +

Image modification is disabled while service is updating.

+
+ + + +
+
diff --git a/app/docker/views/services/edit/includes/logging.html b/app/docker/views/services/edit/includes/logging.html new file mode 100644 index 0000000..4f17720 --- /dev/null +++ b/app/docker/views/services/edit/includes/logging.html @@ -0,0 +1,88 @@ +
+ + + +
+ Driver: + + + Add logging driver option + +
+ + + + + + + + + + + + + + + + +
OptionValue
+
+ name + +
+
+
+ value + + + + +
+
No options associated to this logging driver.
+
+ + + +
+
diff --git a/app/docker/views/services/edit/includes/mounts.html b/app/docker/views/services/edit/includes/mounts.html new file mode 100644 index 0000000..1676bec --- /dev/null +++ b/app/docker/views/services/edit/includes/mounts.html @@ -0,0 +1,113 @@ +
+ + + + + +

There are no mounts for this service.

+
+ + + + + + + + + + + + + + + + + + + + +
TypeSourceTargetRead onlyActions
+ + +
+ + +
+
+
Source is required.
+
+ +
+
Target is required.
+
+ + + + + +
+
+ + + +
+
diff --git a/app/docker/views/services/edit/includes/networks.html b/app/docker/views/services/edit/includes/networks.html new file mode 100644 index 0000000..c8c1540 --- /dev/null +++ b/app/docker/views/services/edit/includes/networks.html @@ -0,0 +1,71 @@ +
+ + + + + +

This service is not connected to any networks.

+
+ + + + + + + + + + + + + + + + + + + +
NameIDIP addressActions
+ + {{ network.Name }} + + {{ network.Id }} + {{ network.Addr }} + + + +
+
+ + + +
+
diff --git a/app/docker/views/services/edit/includes/placementPreferences.html b/app/docker/views/services/edit/includes/placementPreferences.html new file mode 100644 index 0000000..856dd6a --- /dev/null +++ b/app/docker/views/services/edit/includes/placementPreferences.html @@ -0,0 +1,75 @@ +
+ + + + + +

There are no placement preferences for this service.

+
+ + + + + + + + + + + + + + +
StrategyValue
+
+ +
+
+
+ + + + +
+
+
+ + + +
+
diff --git a/app/docker/views/services/edit/includes/resources.html b/app/docker/views/services/edit/includes/resources.html new file mode 100644 index 0000000..edd7ce4 --- /dev/null +++ b/app/docker/views/services/edit/includes/resources.html @@ -0,0 +1,109 @@ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Memory reservation (MB) + + +

Minimum memory available on a node to run a task (set to 0 for unlimited)

+
Memory limit (MB) + + +

Maximum memory usage per task (set to 0 for unlimited)

+
+
CPU reservation
+
+ + +

Minimum CPU available on a node to run a task

+
+
CPU limit
+
+ + +

Maximum CPU usage per task

+
+
+ + + +
+
diff --git a/app/docker/views/services/edit/includes/restart.html b/app/docker/views/services/edit/includes/restart.html new file mode 100644 index 0000000..566977d --- /dev/null +++ b/app/docker/views/services/edit/includes/restart.html @@ -0,0 +1,106 @@ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Restart condition +
+ +
+
+

Condition for restart.

+
Restart delay + + +

+ Delay between restart attempts expressed by a number followed by unit (ns|us|ms|s|m|h). Default value is 5s, 5 seconds. +

+
Restart max attempts + + +

Maximum attempts to restart a given task before giving up (default value is 0, which means unlimited).

+
Restart window + + +

+ Time window to evaluate restart attempts expressed by a number followed by unit (ns|us|ms|s|m|h). Default value is 0 seconds, which is unbounded. +

+
+
+ + + +
+
diff --git a/app/docker/views/services/edit/includes/secrets.html b/app/docker/views/services/edit/includes/secrets.html new file mode 100644 index 0000000..8f85e92 --- /dev/null +++ b/app/docker/views/services/edit/includes/secrets.html @@ -0,0 +1,72 @@ +
+ + + +
+ Add a secret: + +
+ Target: + +
+
+ + +
+ Add secret +
+ + + + + + + + + + + + + + + + + + + + + + + + +
NameFile nameUIDGIDMode
{{ secret.Name }}{{ secret.FileName }}{{ secret.Uid }}{{ secret.Gid }}{{ secret.Mode }} + +
No secrets associated to this service.
+
+ + + +
+
diff --git a/app/docker/views/services/edit/includes/servicelabels.html b/app/docker/views/services/edit/includes/servicelabels.html new file mode 100644 index 0000000..6a1a66d --- /dev/null +++ b/app/docker/views/services/edit/includes/servicelabels.html @@ -0,0 +1,75 @@ +
+ + + + + +

There are no labels for this service.

+
+ + + + + + + + + + + + + + +
Label Value
+
+ name + +
+
+
+ value + + + + +
+
+
+ + + +
+
diff --git a/app/docker/views/services/edit/includes/tasks.html b/app/docker/views/services/edit/includes/tasks.html new file mode 100644 index 0000000..8a64277 --- /dev/null +++ b/app/docker/views/services/edit/includes/tasks.html @@ -0,0 +1,6 @@ + diff --git a/app/docker/views/services/edit/includes/updateconfig.html b/app/docker/views/services/edit/includes/updateconfig.html new file mode 100644 index 0000000..13ba971 --- /dev/null +++ b/app/docker/views/services/edit/includes/updateconfig.html @@ -0,0 +1,132 @@ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Update Parallelism + + +

Maximum number of tasks to be updated simultaneously (0 to update all at once).

+
Update Delay + + +

Amount of time between updates expressed by a number followed by unit (ns|us|ms|s|m|h). Example: 1m.

+
Update Failure Action +
+ + +
+
+

Action taken on failure to start after update.

+
Order +
+ + +
+
+

Operation order on failure.

+
+
+ + + +
+
diff --git a/app/docker/views/services/edit/service.html b/app/docker/views/services/edit/service.html new file mode 100644 index 0000000..b5bb5d5 --- /dev/null +++ b/app/docker/views/services/edit/service.html @@ -0,0 +1,281 @@ + + +
+
+
+ +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Name + + {{ service.Name }}
ID {{ service.Id }}
Created at{{ service.CreatedAt | getisodate }}
Last updated at{{ service.UpdatedAt | getisodate }}
Version{{ service.Version }}
Scheduling mode{{ service.Mode }}
Replicas + + + +
Image{{ service.Image }}
+
+
Service webhook
+ + +
+
+
+ + + {{ webhookURL | truncatelr }} + + + + + +
+
+

+ Note: you can only rollback one level of changes. Clicking the rollback button without making a new change will undo your previous rollback

+ + Service logs + + + +
+
+
+ +

+ Do you need help? View the Docker Service documentation here. +

+ +
+
+
+ + +
+ + + + + + +
+
+
+

Container specification

+
+
+
+
+
+
+
+ +
+
+
+

Networks & ports

+
+ + + +
+
+
+ +
+
+
+

Service specification

+
+
+
+
+
+
+
+
+
+
+
+ +
+
diff --git a/app/docker/views/services/edit/serviceController.js b/app/docker/views/services/edit/serviceController.js new file mode 100644 index 0000000..227d47b --- /dev/null +++ b/app/docker/views/services/edit/serviceController.js @@ -0,0 +1,893 @@ +require('./includes/configs.html'); +require('./includes/constraints.html'); +require('./includes/container-specs.html'); +require('./includes/containerlabels.html'); +require('./includes/environmentvariables.html'); +require('./includes/hosts.html'); +require('./includes/image.html'); +require('./includes/logging.html'); +require('./includes/mounts.html'); +require('./includes/networks.html'); +require('./includes/placementPreferences.html'); +require('./includes/resources.html'); +require('./includes/restart.html'); +require('./includes/secrets.html'); +require('./includes/servicelabels.html'); +require('./includes/tasks.html'); +require('./includes/updateconfig.html'); + +import _ from 'lodash-es'; + +import * as envVarsUtils from '@/react/components/form-components/EnvironmentVariablesFieldset/utils'; +import { PorImageRegistryModel } from '@/docker/models/porImageRegistry'; +import { ResourceControlType } from '@/react/portainer/access-control/types'; +import { confirmServiceForceUpdate } from '@/react/docker/services/common/update-service-modal'; +import { confirm, confirmDelete } from '@@/modals/confirm'; +import { ModalType } from '@@/modals'; +import { buildConfirmButton } from '@@/modals/utils'; +import { convertServiceToConfig } from '@/react/docker/services/common/convertServiceToConfig'; +import { portsMappingUtils } from '@/react/docker/services/ItemView/PortMappingField'; + +angular.module('portainer.docker').controller('ServiceController', [ + '$q', + '$scope', + '$transition$', + '$state', + '$location', + '$timeout', + '$anchorScroll', + 'ServiceService', + 'ConfigService', + 'ConfigHelper', + 'SecretService', + 'ImageService', + 'SecretHelper', + 'ServiceHelper', + 'LabelHelper', + 'TaskService', + 'NodeService', + 'ContainerService', + 'TaskHelper', + 'Notifications', + 'PluginService', + 'Authentication', + 'VolumeService', + 'ImageHelper', + 'WebhookService', + 'clipboard', + 'WebhookHelper', + 'NetworkService', + 'RegistryService', + 'endpoint', + function ( + $q, + $scope, + $transition$, + $state, + $location, + $timeout, + $anchorScroll, + ServiceService, + ConfigService, + ConfigHelper, + SecretService, + ImageService, + SecretHelper, + ServiceHelper, + LabelHelper, + TaskService, + NodeService, + ContainerService, + TaskHelper, + Notifications, + PluginService, + Authentication, + VolumeService, + ImageHelper, + WebhookService, + clipboard, + WebhookHelper, + NetworkService, + RegistryService, + endpoint + ) { + $scope.resourceType = ResourceControlType.Service; + $scope.WebhookExists = false; + + $scope.onUpdateResourceControlSuccess = function () { + $state.reload(); + }; + + $scope.endpoint = endpoint; + + $scope.state = { + updateInProgress: false, + deletionInProgress: false, + rollbackInProgress: false, + }; + + $scope.formValues = { + RegistryModel: new PorImageRegistryModel(), + ports: [], + }; + + $scope.tasks = []; + $scope.availableImages = []; + + $scope.lastVersion = 0; + + var originalService = {}; + var previousServiceValues = []; + + $scope.goToItem = function (hash) { + if ($location.hash() !== hash) { + $location.hash(hash); + } else { + $anchorScroll(); + } + }; + + $scope.addEnvironmentVariable = function addEnvironmentVariable(service) { + $scope.$evalAsync(() => { + service.EnvironmentVariables = service.EnvironmentVariables.concat({ name: '', value: '' }); + updateServiceArray(service, 'EnvironmentVariables', service.EnvironmentVariables); + }); + }; + + $scope.onChangeEnvVars = onChangeEnvVars; + + function onChangeEnvVars(env) { + const service = $scope.service; + + const orgEnv = service.EnvironmentVariables; + service.EnvironmentVariables = env.map((v) => { + const orgVar = orgEnv.find(({ name }) => v.name === name); + const added = orgVar && orgVar.added; + return { ...v, added }; + }); + + updateServiceArray(service, 'EnvironmentVariables', service.EnvironmentVariables); + } + + $scope.addConfig = function addConfig(service, config) { + if ( + config && + service.ServiceConfigs.filter(function (serviceConfig) { + return serviceConfig.Id === config.Id; + }).length === 0 + ) { + service.ServiceConfigs.push({ Id: config.Id, Name: config.Name, FileName: config.Name, Uid: '0', Gid: '0', Mode: 292 }); + updateServiceArray(service, 'ServiceConfigs', service.ServiceConfigs); + } + }; + $scope.removeConfig = function removeSecret(service, index) { + var removedElement = service.ServiceConfigs.splice(index, 1); + if (removedElement !== null) { + updateServiceArray(service, 'ServiceConfigs', service.ServiceConfigs); + } + }; + $scope.updateConfig = function updateConfig(service) { + updateServiceArray(service, 'ServiceConfigs', service.ServiceConfigs); + }; + $scope.addSecret = function addSecret(service, newSecret) { + if (newSecret.secret) { + var filename = newSecret.secret.Name; + if (newSecret.override) { + filename = newSecret.target; + } + if ( + service.ServiceSecrets.filter(function (serviceSecret) { + return serviceSecret.Id === newSecret.secret.Id && serviceSecret.FileName === filename; + }).length === 0 + ) { + service.ServiceSecrets.push({ Id: newSecret.secret.Id, Name: newSecret.secret.Name, FileName: filename, Uid: '0', Gid: '0', Mode: 444 }); + updateServiceArray(service, 'ServiceSecrets', service.ServiceSecrets); + } + } + }; + $scope.removeSecret = function removeSecret(service, index) { + var removedElement = service.ServiceSecrets.splice(index, 1); + if (removedElement !== null) { + updateServiceArray(service, 'ServiceSecrets', service.ServiceSecrets); + } + }; + $scope.addLabel = function addLabel(service) { + service.ServiceLabels.push({ key: '', value: '', originalValue: '' }); + updateServiceArray(service, 'ServiceLabels', service.ServiceLabels); + }; + $scope.removeLabel = function removeLabel(service, index) { + var removedElement = service.ServiceLabels.splice(index, 1); + if (removedElement !== null) { + updateServiceArray(service, 'ServiceLabels', service.ServiceLabels); + } + }; + $scope.updateLabel = function updateLabel(service, label) { + if (label.value !== label.originalValue || label.key !== label.originalKey) { + updateServiceArray(service, 'ServiceLabels', service.ServiceLabels); + } + }; + $scope.addContainerLabel = function addContainerLabel(service) { + service.ServiceContainerLabels.push({ key: '', value: '', originalValue: '' }); + updateServiceArray(service, 'ServiceContainerLabels', service.ServiceContainerLabels); + }; + $scope.removeContainerLabel = function removeLabel(service, index) { + var removedElement = service.ServiceContainerLabels.splice(index, 1); + if (removedElement !== null) { + updateServiceArray(service, 'ServiceContainerLabels', service.ServiceContainerLabels); + } + }; + $scope.updateContainerLabel = function updateLabel(service, label) { + if (label.value !== label.originalValue || label.key !== label.originalKey) { + updateServiceArray(service, 'ServiceContainerLabels', service.ServiceContainerLabels); + } + }; + $scope.addMount = function addMount(service) { + service.ServiceMounts.push({ Type: 'volume', Source: null, Target: '', ReadOnly: false }); + updateServiceArray(service, 'ServiceMounts', service.ServiceMounts); + }; + $scope.removeMount = function removeMount(service, index) { + var removedElement = service.ServiceMounts.splice(index, 1); + if (removedElement !== null) { + updateServiceArray(service, 'ServiceMounts', service.ServiceMounts); + } + }; + + $scope.onChangeMountType = function onChangeMountType(service, mount) { + mount.Source = null; + $scope.updateMount(service, mount); + }; + + $scope.updateMount = function updateMount(service) { + updateServiceArray(service, 'ServiceMounts', service.ServiceMounts); + }; + + $scope.toggleMountReadOnly = function toggleMountReadOnly(isReadOnly, index) { + $scope.$evalAsync(function () { + updateServiceArray($scope.service, 'ServiceMounts', $scope.service.ServiceMounts); + $scope.service.ServiceMounts[index].ReadOnly = isReadOnly; + }); + }; + + $scope.addNetwork = function addNetwork(service) { + if (!service.Networks) { + service.Networks = []; + } + service.Networks.push({ Editable: true }); + }; + + $scope.removeNetwork = function removeNetwork(service, index) { + var removedElement = service.Networks.splice(index, 1); + if (removedElement && removedElement.length && removedElement[0].Id) { + updateServiceArray(service, 'Networks', service.Networks); + } + }; + + $scope.updateNetwork = function updateNetwork(service) { + updateServiceArray(service, 'Networks', service.Networks); + }; + + $scope.addPlacementConstraint = function addPlacementConstraint(service) { + service.ServiceConstraints.push({ key: '', operator: '==', value: '' }); + updateServiceArray(service, 'ServiceConstraints', service.ServiceConstraints); + }; + $scope.removePlacementConstraint = function removePlacementConstraint(service, index) { + var removedElement = service.ServiceConstraints.splice(index, 1); + if (removedElement !== null) { + updateServiceArray(service, 'ServiceConstraints', service.ServiceConstraints); + } + }; + $scope.updatePlacementConstraint = function (service) { + updateServiceArray(service, 'ServiceConstraints', service.ServiceConstraints); + }; + + $scope.addPlacementPreference = function (service) { + service.ServicePreferences.push({ strategy: 'spread', value: '' }); + updateServiceArray(service, 'ServicePreferences', service.ServicePreferences); + }; + $scope.removePlacementPreference = function (service, index) { + var removedElement = service.ServicePreferences.splice(index, 1); + if (removedElement !== null) { + updateServiceArray(service, 'ServicePreferences', service.ServicePreferences); + } + }; + $scope.updatePlacementPreference = function (service) { + updateServiceArray(service, 'ServicePreferences', service.ServicePreferences); + }; + + $scope.addPublishedPort = function addPublishedPort(service) { + if (!service.Ports) { + service.Ports = []; + } + service.Ports.push({ PublishedPort: '', TargetPort: '', Protocol: 'tcp', PublishMode: 'ingress' }); + }; + $scope.updatePublishedPort = function updatePublishedPort(service) { + updateServiceArray(service, 'Ports', service.Ports); + }; + $scope.removePortPublishedBinding = function removePortPublishedBinding(service, index) { + var removedElement = service.Ports.splice(index, 1); + if (removedElement !== null) { + updateServiceArray(service, 'Ports', service.Ports); + } + }; + + $scope.addLogDriverOpt = function addLogDriverOpt(service) { + service.LogDriverOpts.push({ key: '', value: '', originalValue: '' }); + updateServiceArray(service, 'LogDriverOpts', service.LogDriverOpts); + }; + $scope.removeLogDriverOpt = function removeLogDriverOpt(service, index) { + var removedElement = service.LogDriverOpts.splice(index, 1); + if (removedElement !== null) { + updateServiceArray(service, 'LogDriverOpts', service.LogDriverOpts); + } + }; + $scope.updateLogDriverOpt = function updateLogDriverOpt(service, variable) { + if (variable.value !== variable.originalValue || variable.key !== variable.originalKey) { + updateServiceArray(service, 'LogDriverOpts', service.LogDriverOpts); + } + }; + $scope.updateLogDriverName = function updateLogDriverName(service) { + updateServiceArray(service, 'LogDriverName', service.LogDriverName); + }; + + $scope.addHostsEntry = function (service) { + if (!service.Hosts) { + service.Hosts = []; + } + service.Hosts.push({ hostname: '', ip: '' }); + }; + $scope.removeHostsEntry = function (service, index) { + var removedElement = service.Hosts.splice(index, 1); + if (removedElement !== null) { + updateServiceArray(service, 'Hosts', service.Hosts); + } + }; + $scope.updateHostsEntry = function (service) { + updateServiceArray(service, 'Hosts', service.Hosts); + }; + + $scope.onWebhookChange = function (enabled) { + enabled = enabled | ''; + $scope.$evalAsync(() => { + $scope.updateWebhook($scope.service); + $scope.WebhookExists = enabled; + updateServiceAttribute($scope.service, 'Webhooks', enabled); + }); + }; + + $scope.updateWebhook = function updateWebhook(service) { + if ($scope.WebhookExists) { + WebhookService.deleteWebhook($scope.webhookID) + .then(function success() { + $scope.webhookURL = null; + $scope.webhookID = null; + $scope.WebhookExists = false; + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to delete webhook'); + }); + } else { + WebhookService.createServiceWebhook(service.Id, endpoint.Id, $scope.initialRegistryID) + .then(function success(data) { + $scope.WebhookExists = true; + $scope.webhookID = data.Id; + $scope.webhookURL = WebhookHelper.returnWebhookUrl(data.Token); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to create webhook'); + }); + } + }; + + $scope.updateWebhookRegistryId = function () { + const newRegistryID = _.get($scope.formValues.RegistryModel, 'Registry.Id', 0); + const registryChanged = $scope.initialRegistryID != newRegistryID; + + if ($scope.WebhookExists && registryChanged) { + WebhookService.updateServiceWebhook($scope.webhookID, newRegistryID).catch(function error(err) { + Notifications.error('Failure', err, 'Unable to update webhook'); + }); + } + }; + + $scope.copyWebhook = function copyWebhook() { + clipboard.copyText($scope.webhookURL); + $('#copyNotification').show(); + $('#copyNotification').fadeOut(2000); + }; + + $scope.cancelChanges = async function cancelChanges(service, keys) { + if (keys) { + // clean out the keys only from the list of modified keys + for (const key of keys) { + if (key === 'Image') { + $scope.formValues.RegistryModel = await RegistryService.retrievePorRegistryModelFromRepository(originalService.Image, endpoint.Id); + } else { + var index = previousServiceValues.indexOf(key); + if (index >= 0) { + previousServiceValues.splice(index, 1); + } + } + } + } else { + // clean out all changes + $scope.formValues.RegistryModel = await RegistryService.retrievePorRegistryModelFromRepository(originalService.Image, endpoint.Id); + keys = Object.keys(service); + previousServiceValues = []; + } + keys.forEach(function (attribute) { + service[attribute] = originalService[attribute]; // reset service values + }); + service.hasChanges = false; + }; + + $scope.hasChanges = function (service, elements) { + var hasChanges = false; + elements.forEach(function (key) { + if (key === 'Image') { + const originalImage = service ? service.Model.Spec.TaskTemplate.ContainerSpec.Image : null; + const currentImage = ImageHelper.createImageConfigForContainer($scope.formValues.RegistryModel).fromImage; + hasChanges = hasChanges || originalImage !== currentImage; + } else { + hasChanges = hasChanges || previousServiceValues.indexOf(key) >= 0; + } + }); + return hasChanges; + }; + + $scope.mountsAreValid = mountsAreValid; + function mountsAreValid() { + const mounts = $scope.service.ServiceMounts; + return mounts.every((mount) => mount.Source && mount.Target); + } + + function buildChanges(service) { + var config = convertServiceToConfig(service.Model); + config.Name = service.Name; + config.Labels = LabelHelper.fromKeyValueToLabelHash(service.ServiceLabels); + config.TaskTemplate.ContainerSpec.Env = envVarsUtils.convertToArrayOfStrings(service.EnvironmentVariables); + config.TaskTemplate.ContainerSpec.Labels = LabelHelper.fromKeyValueToLabelHash(service.ServiceContainerLabels); + + if ($scope.hasChanges(service, ['Image'])) { + const image = ImageHelper.createImageConfigForContainer($scope.formValues.RegistryModel); + config.TaskTemplate.ContainerSpec.Image = image.fromImage; + config.registryId = $scope.formValues.RegistryModel.Registry.Id; + } else { + config.TaskTemplate.ContainerSpec.Image = service.Image; + } + + if ($scope.hasChanges(service, ['Networks'])) { + config.Networks = _.map( + _.filter(service.Networks, (item) => item.Id && item.Editable), + (item) => ({ Target: item.Id }) + ); + config.TaskTemplate.Networks = config.Networks; + } + + config.TaskTemplate.ContainerSpec.Secrets = service.ServiceSecrets ? service.ServiceSecrets.map(SecretHelper.secretConfig) : []; + config.TaskTemplate.ContainerSpec.Configs = service.ServiceConfigs ? service.ServiceConfigs.map(ConfigHelper.configConfig) : []; + + // support removal and (future) editing of credential specs + const credSpec = service.ServiceConfigs.find((config) => config.credSpec); + const credSpecId = credSpec ? credSpec.Id : ''; + const oldCredSpecId = + (config.TaskTemplate.ContainerSpec.Privileges && + config.TaskTemplate.ContainerSpec.Privileges.CredentialSpec && + config.TaskTemplate.ContainerSpec.Privileges.CredentialSpec.Config) || + ''; + if (oldCredSpecId && !credSpecId) { + delete config.TaskTemplate.ContainerSpec.Privileges.CredentialSpec; + } else if (credSpec && oldCredSpecId !== credSpec) { + config.TaskTemplate.ContainerSpec.Privileges = { + ...(config.TaskTemplate.ContainerSpec.Privileges || {}), + CredentialSpec: { + ...((config.TaskTemplate.ContainerSpec.Privileges && config.TaskTemplate.ContainerSpec.Privileges.CredentialSpec) || {}), + Config: credSpec, + }, + }; + } + + config.TaskTemplate.ContainerSpec.Hosts = service.Hosts ? ServiceHelper.translateHostnameIPToHostsEntries(service.Hosts) : []; + + if (service.Mode === 'replicated') { + config.Mode.Replicated.Replicas = service.Replicas; + } + config.TaskTemplate.ContainerSpec.Mounts = service.ServiceMounts; + if (typeof config.TaskTemplate.Placement === 'undefined') { + config.TaskTemplate.Placement = {}; + } + config.TaskTemplate.Placement.Constraints = ServiceHelper.translateKeyValueToPlacementConstraints(service.ServiceConstraints); + config.TaskTemplate.Placement.Preferences = ServiceHelper.translateKeyValueToPlacementPreferences(service.ServicePreferences); + + if ($scope.hasChanges(service, ['LimitNanoCPUs', 'LimitMemoryBytes', 'ReservationNanoCPUs', 'ReservationMemoryBytes'])) { + // Round memory values to 0.125 and convert MB to B + var memoryLimit = (Math.round(service.LimitMemoryBytes * 8) / 8).toFixed(3); + memoryLimit *= 1024 * 1024; + var memoryReservation = (Math.round(service.ReservationMemoryBytes * 8) / 8).toFixed(3); + memoryReservation *= 1024 * 1024; + config.TaskTemplate.Resources = { + Limits: { + NanoCPUs: service.LimitNanoCPUs * 1000000000, + MemoryBytes: memoryLimit, + }, + Reservations: { + NanoCPUs: service.ReservationNanoCPUs * 1000000000, + MemoryBytes: memoryReservation, + }, + }; + } + + if ($scope.hasChanges(service, ['UpdateFailureAction', 'UpdateDelay', 'UpdateParallelism', 'UpdateOrder'])) { + config.UpdateConfig = { + Parallelism: service.UpdateParallelism, + Delay: ServiceHelper.translateHumanDurationToNanos(service.UpdateDelay) || 0, + FailureAction: service.UpdateFailureAction, + Order: service.UpdateOrder, + }; + } + + if ($scope.hasChanges(service, ['RestartCondition', 'RestartDelay', 'RestartMaxAttempts', 'RestartWindow'])) { + config.TaskTemplate.RestartPolicy = { + Condition: service.RestartCondition, + Delay: ServiceHelper.translateHumanDurationToNanos(service.RestartDelay) || 5000000000, + MaxAttempts: service.RestartMaxAttempts, + Window: ServiceHelper.translateHumanDurationToNanos(service.RestartWindow) || 0, + }; + } + + config.TaskTemplate.LogDriver = null; + if (service.LogDriverName) { + config.TaskTemplate.LogDriver = { Name: service.LogDriverName }; + if (service.LogDriverName !== 'none') { + var logOpts = ServiceHelper.translateKeyValueToLogDriverOpts(service.LogDriverOpts); + if (Object.keys(logOpts).length !== 0 && logOpts.constructor === Object) { + config.TaskTemplate.LogDriver.Options = logOpts; + } + } + } + + if ($scope.hasChanges(service, ['Ports'])) { + service.Ports = portsMappingUtils.toRequest($scope.formValues.ports); + } + + config.EndpointSpec = { + Mode: (config.EndpointSpec && config.EndpointSpec.Mode) || 'vip', + Ports: service.Ports, + }; + return config; + } + + function rollbackService(service) { + $scope.state.rollbackInProgress = true; + const config = buildChanges(service); + ServiceService.update(service, config, 'previous') + .then(function (data) { + if (data.message && data.message.match(/^rpc error:/)) { + Notifications.error('Failure', data, 'Error'); + } else { + Notifications.success('Success', 'Service successfully rolled back'); + $scope.cancelChanges({}); + initView(); + } + }) + .catch(function (e) { + if (e.data.message && e.data.message.includes('does not have a previous spec')) { + Notifications.error('Failure', { message: 'No previous config to rollback to.' }); + } else { + Notifications.error('Failure', e, 'Unable to rollback service'); + } + }) + .finally(function () { + $scope.state.rollbackInProgress = false; + }); + } + + $scope.rollbackService = function (service) { + confirm({ + title: 'Rollback service', + message: 'Are you sure you want to rollback?', + modalType: ModalType.Warn, + confirmButton: buildConfirmButton('Yes', 'danger'), + }).then((confirmed) => { + if (!confirmed) { + return; + } + rollbackService(service); + }); + }; + + $scope.setPullImageValidity = setPullImageValidity; + function setPullImageValidity(validity) { + $scope.state.pullImageValidity = validity; + } + + $scope.updateService = function updateService(service) { + const config = buildChanges(service); + ServiceService.update(service, config).then( + function (data) { + if (data.message && data.message.match(/^rpc error:/)) { + Notifications.error('Failure', data, 'Error'); + } else { + Notifications.success('Service successfully updated', 'Service updated'); + $scope.updateWebhookRegistryId(); + } + $scope.cancelChanges({}); + initView(); + }, + function (e) { + Notifications.error('Failure', e, 'Unable to update service'); + } + ); + }; + + $scope.removeService = function () { + confirmDelete('Do you want to remove this service? All the containers associated to this service will be removed too.').then((confirmed) => { + if (!confirmed) { + return; + } + removeService(); + }); + }; + + function removeService() { + $scope.state.deletionInProgress = true; + ServiceService.remove($scope.service) + .then(function success() { + return $q.when($scope.webhookID && WebhookService.deleteWebhook($scope.webhookID)); + }) + .then(function success() { + Notifications.success('Success', 'Service successfully deleted'); + $state.go('docker.services', {}); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to remove service'); + }) + .finally(function final() { + $scope.state.deletionInProgress = false; + }); + } + + $scope.forceUpdateService = function (service) { + confirmServiceForceUpdate('Do you want to force an update of the service? All the tasks associated to the service will be recreated.').then(function (result) { + if (!result) { + return; + } + + forceUpdateService(service, result.pullLatest); + }); + }; + + function forceUpdateService(service, pullImage) { + var config = convertServiceToConfig(service.Model); + if (pullImage) { + config.TaskTemplate.ContainerSpec.Image = ImageHelper.removeDigestFromRepository(config.TaskTemplate.ContainerSpec.Image); + } + + // As explained in https://github.com/docker/swarmkit/issues/2364 ForceUpdate can accept a random + // value or an increment of the counter value to force an update. + config.TaskTemplate.ForceUpdate++; + $scope.state.updateInProgress = true; + ServiceService.update(service, config) + .then(function success() { + Notifications.success('Service successfully updated', service.Name); + $scope.cancelChanges({}); + initView(); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to force update service', service.Name); + }) + .finally(function final() { + $scope.state.updateInProgress = false; + }); + } + + function translateServiceArrays(service) { + service.ServiceSecrets = service.Secrets ? service.Secrets.map(SecretHelper.flattenSecret) : []; + service.ServiceConfigs = service.Configs ? service.Configs.map(ConfigHelper.flattenConfig) : []; + service.EnvironmentVariables = envVarsUtils + .parseArrayOfStrings(service.Env) + .map((v) => ({ ...v, added: true })) + .sort((v1, v2) => (v1.name > v2.name ? 1 : -1)); + service.LogDriverOpts = ServiceHelper.translateLogDriverOptsToKeyValue(service.LogDriverOpts); + service.ServiceLabels = LabelHelper.fromLabelHashToKeyValue(service.Labels); + service.ServiceContainerLabels = LabelHelper.fromLabelHashToKeyValue(service.ContainerLabels); + service.ServiceMounts = angular.copy(service.Mounts); + service.ServiceConstraints = ServiceHelper.translateConstraintsToKeyValue(service.Constraints); + service.ServicePreferences = ServiceHelper.translatePreferencesToKeyValue(service.Preferences); + service.Hosts = ServiceHelper.translateHostsEntriesToHostnameIP(service.Hosts); + } + + function transformResources(service) { + service.LimitNanoCPUs = service.LimitNanoCPUs / 1000000000 || 0; + service.ReservationNanoCPUs = service.ReservationNanoCPUs / 1000000000 || 0; + service.LimitMemoryBytes = service.LimitMemoryBytes / 1024 / 1024 || 0; + service.ReservationMemoryBytes = service.ReservationMemoryBytes / 1024 / 1024 || 0; + } + + function transformDurations(service) { + service.RestartDelay = ServiceHelper.translateNanosToHumanDuration(service.RestartDelay) || '5s'; + service.RestartWindow = ServiceHelper.translateNanosToHumanDuration(service.RestartWindow) || '0s'; + service.UpdateDelay = ServiceHelper.translateNanosToHumanDuration(service.UpdateDelay) || '0s'; + service.StopGracePeriod = service.StopGracePeriod ? ServiceHelper.translateNanosToHumanDuration(service.StopGracePeriod) : ''; + } + + $scope.onChangePorts = function (ports) { + $scope.$evalAsync(() => { + $scope.formValues.ports = ports; + updateServiceArray($scope.service, 'Ports'); + }); + }; + + $scope.onResetPorts = function (all = false) { + $scope.$evalAsync(() => { + $scope.formValues.ports = portsMappingUtils.toViewModel($scope.service.Model.Spec.EndpointSpec?.Ports); + + $scope.cancelChanges($scope.service, all ? undefined : ['Ports']); + }); + }; + + $scope.onSubmit = function () { + $scope.updateService($scope.service); + }; + + function initView() { + $scope.isLoading = true; + var apiVersion = $scope.applicationState.endpoint.apiVersion; + var agentProxy = $scope.applicationState.endpoint.mode.agentProxy; + + var service = null; + ServiceService.service($transition$.params().id) + .then(function success(data) { + service = data; + $scope.isUpdating = $scope.lastVersion >= service.Version; + if (!$scope.isUpdating) { + $scope.lastVersion = service.Version; + } + + $scope.formValues.ports = portsMappingUtils.toViewModel(service.Model.Spec.EndpointSpec?.Ports); + + transformResources(service); + translateServiceArrays(service); + transformDurations(service); + $scope.service = service; + originalService = angular.copy(service); + + return $q.all({ + volumes: VolumeService.volumes(), + tasks: TaskService.tasks({ service: [service.Name] }), + containers: agentProxy ? ContainerService.containers(endpoint.Id) : [], + nodes: NodeService.nodes(), + secrets: apiVersion >= 1.25 ? SecretService.secrets() : [], + configs: apiVersion >= 1.3 ? ConfigService.configs(endpoint.Id) : [], + availableImages: ImageService.images(), + availableLoggingDrivers: PluginService.loggingPlugins(apiVersion < 1.25), + availableNetworks: NetworkService.networks(true, true, apiVersion >= 1.25), + webhooks: WebhookService.webhooks(service.Id, endpoint.Id), + }); + }) + .then(async function success(data) { + $scope.nodes = data.nodes; + $scope.configs = data.configs; + $scope.secrets = data.secrets; + $scope.availableImages = ImageService.getUniqueTagListFromImages(data.availableImages); + $scope.availableLoggingDrivers = data.availableLoggingDrivers; + $scope.availableVolumes = data.volumes; + $scope.allowBindMounts = endpoint.SecuritySettings.allowBindMountsForRegularUsers; + $scope.isAdmin = Authentication.isAdmin(); + $scope.availableNetworks = data.availableNetworks; + $scope.swarmNetworks = _.filter($scope.availableNetworks, (network) => network.Scope === 'swarm'); + + const serviceNetworks = _.uniqBy(_.concat($scope.service.Model.Spec.Networks || [], $scope.service.Model.Spec.TaskTemplate.Networks || []), 'Target'); + const networks = _.filter( + _.map(serviceNetworks, ({ Target }) => _.find(data.availableNetworks, { Id: Target })), + Boolean + ); + + if (_.some($scope.service.Ports, (port) => port.PublishMode === 'ingress')) { + const ingressNetwork = _.find($scope.availableNetworks, (network) => network.Ingress); + if (ingressNetwork) { + networks.unshift(ingressNetwork); + } + } + + $scope.service.Networks = await Promise.all( + _.map(networks, async (item) => { + let addr = ''; + if (item.IPAM.Config.length) { + addr = item.IPAM.Config[0].Subnet; + } else { + const network = await NetworkService.network(item.Id); + addr = (network && network.IPAM && network.IPAM.Config && network.IPAM.Config.length && network.IPAM.Config[0].Subnet) || ''; + } + return { Id: item.Id, Name: item.Name, Addr: addr, Editable: !item.Ingress }; + }) + ); + + originalService.Networks = angular.copy($scope.service.Networks); + + if (data.webhooks.length > 0) { + var webhook = data.webhooks[0]; + $scope.WebhookExists = true; + $scope.webhookID = webhook.Id; + $scope.webhookURL = WebhookHelper.returnWebhookUrl(webhook.Token); + } + + var tasks = data.tasks; + + if (agentProxy) { + var containers = data.containers; + for (var i = 0; i < tasks.length; i++) { + var task = tasks[i]; + TaskHelper.associateContainerToTask(task, containers); + } + } + + ServiceHelper.associateTasksToService(service, tasks); + + $scope.tasks = data.tasks; + + // Set max cpu value + var maxCpus = 0; + for (var n in data.nodes) { + if (data.nodes[n].CPUs && data.nodes[n].CPUs > maxCpus) { + maxCpus = data.nodes[n].CPUs; + } + } + if (maxCpus > 0) { + $scope.state.sliderMaxCpu = maxCpus / 1000000000; + } else { + $scope.state.sliderMaxCpu = 32; + } + + const image = $scope.service.Model.Spec.TaskTemplate.ContainerSpec.Image; + RegistryService.retrievePorRegistryModelFromRepository(image, endpoint.Id).then((model) => { + $scope.formValues.RegistryModel = model; + $scope.initialRegistryID = _.get(model, 'Registry.Id', 0); + }); + + // Default values + $scope.state.addSecret = { override: false }; + + $timeout(function () { + $anchorScroll(); + }); + }) + .catch(function error(err) { + $scope.secrets = []; + $scope.configs = []; + Notifications.error('Failure', err, 'Unable to retrieve service details'); + }) + .finally(() => { + $scope.isLoading = false; + }); + } + + $scope.updateServiceAttribute = updateServiceAttribute; + function updateServiceAttribute(service, name) { + if (service[name] !== originalService[name] || !(name in originalService)) { + service.hasChanges = true; + } + previousServiceValues.push(name); + } + + $scope.filterNetworks = filterNetworks; + function filterNetworks(networks, current) { + return networks.filter((network) => !network.Ingress && (network.Id === current.Id || $scope.service.Networks.every((serviceNetwork) => network.Id !== serviceNetwork.Id))); + } + + $scope.filterConfigs = filterConfigs; + function filterConfigs(configs) { + if (!configs) { + return []; + } + + return configs.filter((config) => $scope.service.ServiceConfigs.every((serviceConfig) => config.Id !== serviceConfig.Id)); + } + + function updateServiceArray(service, name) { + previousServiceValues.push(name); + service.hasChanges = true; + } + + initView(); + }, +]); diff --git a/app/docker/views/services/logs/serviceLogsController.js b/app/docker/views/services/logs/serviceLogsController.js new file mode 100644 index 0000000..e0c56b7 --- /dev/null +++ b/app/docker/views/services/logs/serviceLogsController.js @@ -0,0 +1,75 @@ +import moment from 'moment'; + +angular.module('portainer.docker').controller('ServiceLogsController', [ + '$scope', + '$transition$', + '$interval', + 'ServiceService', + 'Notifications', + function ($scope, $transition$, $interval, ServiceService, Notifications) { + $scope.state = { + refreshRate: 3, + lineCount: 100, + sinceTimestamp: '', + displayTimestamps: false, + }; + + $scope.changeLogCollection = function (logCollectionStatus) { + if (!logCollectionStatus) { + stopRepeater(); + } else { + setUpdateRepeater(); + } + }; + + $scope.$on('$destroy', function () { + stopRepeater(); + }); + + function stopRepeater() { + var repeater = $scope.repeater; + if (angular.isDefined(repeater)) { + $interval.cancel(repeater); + } + } + + function setUpdateRepeater() { + var refreshRate = $scope.state.refreshRate; + $scope.repeater = $interval(function () { + ServiceService.logs($transition$.params().id, 1, 1, $scope.state.displayTimestamps ? 1 : 0, moment($scope.state.sinceTimestamp).unix(), $scope.state.lineCount) + .then(function success(data) { + $scope.logs = data; + }) + .catch(function error(err) { + stopRepeater(); + Notifications.error('Failure', err, 'Unable to retrieve service logs'); + }); + }, refreshRate * 1000); + } + + function startLogPolling() { + ServiceService.logs($transition$.params().id, 1, 1, $scope.state.displayTimestamps ? 1 : 0, moment($scope.state.sinceTimestamp).unix(), $scope.state.lineCount) + .then(function success(data) { + $scope.logs = data; + setUpdateRepeater(); + }) + .catch(function error(err) { + stopRepeater(); + Notifications.error('Failure', err, 'Unable to retrieve service logs'); + }); + } + + function initView() { + ServiceService.service($transition$.params().id) + .then(function success(data) { + $scope.service = data; + startLogPolling(); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve service information'); + }); + } + + initView(); + }, +]); diff --git a/app/docker/views/services/logs/servicelogs.html b/app/docker/views/services/logs/servicelogs.html new file mode 100644 index 0000000..896b2a4 --- /dev/null +++ b/app/docker/views/services/logs/servicelogs.html @@ -0,0 +1,26 @@ + + + + diff --git a/app/docker/views/services/services.html b/app/docker/views/services/services.html new file mode 100644 index 0000000..e1a7b8a --- /dev/null +++ b/app/docker/views/services/services.html @@ -0,0 +1,10 @@ + + + diff --git a/app/docker/views/services/servicesController.js b/app/docker/views/services/servicesController.js new file mode 100644 index 0000000..bb4d192 --- /dev/null +++ b/app/docker/views/services/servicesController.js @@ -0,0 +1,59 @@ +angular.module('portainer.docker').controller('ServicesController', [ + '$q', + '$scope', + 'ServiceService', + 'ServiceHelper', + 'Notifications', + 'TaskService', + 'TaskHelper', + 'NodeService', + 'ContainerService', + 'endpoint', + function ($q, $scope, ServiceService, ServiceHelper, Notifications, TaskService, TaskHelper, NodeService, ContainerService, endpoint) { + $scope.getServices = getServices; + $scope.endpoint = endpoint; + + function getServices() { + var agentProxy = $scope.applicationState.endpoint.mode.agentProxy; + + return $q + .all({ + services: ServiceService.services(), + tasks: TaskService.tasks(), + containers: agentProxy ? ContainerService.containers(endpoint.Id, 1) : [], + nodes: NodeService.nodes(), + }) + .then(function success(data) { + var services = data.services; + var tasks = data.tasks; + + if (agentProxy) { + var containers = data.containers; + for (var j = 0; j < tasks.length; j++) { + var task = tasks[j]; + TaskHelper.associateContainerToTask(task, containers); + } + } + + for (var i = 0; i < services.length; i++) { + var service = services[i]; + ServiceHelper.associateTasksToService(service, tasks); + } + + $scope.nodes = data.nodes; + $scope.tasks = tasks; + $scope.services = services; + }) + .catch(function error(err) { + $scope.services = []; + Notifications.error('Failure', err, 'Unable to retrieve services'); + }); + } + + function initView() { + getServices(); + } + + initView(); + }, +]); diff --git a/app/docker/views/swarm/swarm.html b/app/docker/views/swarm/swarm.html new file mode 100644 index 0000000..0aab190 --- /dev/null +++ b/app/docker/views/swarm/swarm.html @@ -0,0 +1,40 @@ + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
Nodes{{ info.Swarm.Nodes }}
Docker API version{{ docker.ApiVersion }}
Total CPU{{ totalCPU }}
Total memory{{ totalMemory | humansize: 2 }}
+ +
+
+
+
+
+ + diff --git a/app/docker/views/swarm/swarmController.js b/app/docker/views/swarm/swarmController.js new file mode 100644 index 0000000..ff4f7c4 --- /dev/null +++ b/app/docker/views/swarm/swarmController.js @@ -0,0 +1,108 @@ +angular.module('portainer.docker').controller('SwarmController', [ + '$q', + '$scope', + 'SystemService', + 'NodeService', + 'Notifications', + 'StateManager', + 'Authentication', + function ($q, $scope, SystemService, NodeService, Notifications, StateManager, Authentication) { + $scope.info = {}; + $scope.docker = {}; + $scope.swarm = {}; + $scope.totalCPU = 0; + $scope.totalMemory = 0; + + function extractSwarmInfo(info) { + // Swarm info is available in SystemStatus object + var systemStatus = info.SystemStatus; + // Swarm strategy + $scope.swarm[systemStatus[1][0]] = systemStatus[1][1]; + // Swarm filters + $scope.swarm[systemStatus[2][0]] = systemStatus[2][1]; + // Swarm node count + var nodes = systemStatus[0][1] === 'primary' ? systemStatus[3][1] : systemStatus[4][1]; + var node_count = parseInt(nodes, 10); + $scope.swarm[systemStatus[3][0]] = node_count; + + $scope.swarm.Status = []; + extractNodesInfo(systemStatus, node_count); + } + + function extractNodesInfo(info, node_count) { + // First information for node1 available at element #4 of SystemStatus if connected to a primary + // If connected to a replica, information for node1 is available at element #5 + // The next 10 elements are information related to the node + var node_offset = info[0][1] === 'primary' ? 4 : 5; + for (let i = 0; i < node_count; i++) { + extractNodeInfo(info, node_offset); + node_offset += 9; + } + } + + function extractNodeInfo(info, offset) { + var node = {}; + node.name = info[offset][0]; + node.ip = info[offset][1]; + node.Id = info[offset + 1][1]; + node.status = info[offset + 2][1]; + node.containers = info[offset + 3][1]; + node.cpu = info[offset + 4][1].split('/')[1]; + node.memory = info[offset + 5][1].split('/')[1]; + node.labels = info[offset + 6][1]; + node.version = info[offset + 8][1]; + $scope.swarm.Status.push(node); + } + + function processTotalCPUAndMemory(nodes) { + var CPU = 0, + memory = 0; + angular.forEach(nodes, function (node) { + CPU += node.CPUs; + memory += node.Memory; + }); + $scope.totalCPU = CPU / 1000000000; + $scope.totalMemory = memory; + } + + $scope.getNodes = getNodes; + function getNodes() { + var provider = $scope.applicationState.endpoint.mode.provider; + if (provider === 'DOCKER_SWARM_MODE') { + NodeService.nodes() + .then(function (data) { + var nodes = data; + processTotalCPUAndMemory(nodes); + $scope.nodes = nodes; + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve cluster details'); + }); + } + } + + function initView() { + $scope.isAdmin = Authentication.isAdmin(); + + var provider = $scope.applicationState.endpoint.mode.provider; + $q.all({ + version: SystemService.version(), + info: SystemService.info(), + }) + .then(function success(data) { + $scope.docker = data.version; + $scope.info = data.info; + if (provider === 'DOCKER_SWARM_MODE') { + getNodes(); + } else { + extractSwarmInfo(data.info); + } + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve cluster details'); + }); + } + + initView(); + }, +]); diff --git a/app/docker/views/swarm/visualizer/swarmVisualizerController.js b/app/docker/views/swarm/visualizer/swarmVisualizerController.js new file mode 100644 index 0000000..8293123 --- /dev/null +++ b/app/docker/views/swarm/visualizer/swarmVisualizerController.js @@ -0,0 +1,169 @@ +angular.module('portainer.docker').controller('SwarmVisualizerController', [ + '$q', + '$scope', + '$document', + '$interval', + 'NodeService', + 'ServiceService', + 'TaskService', + 'Notifications', + 'LocalStorage', + function ($q, $scope, $document, $interval, NodeService, ServiceService, TaskService, Notifications, LocalStorage) { + $scope.state = { + ShowInformationPanel: true, + DisplayOnlyRunningTasks: false, + DisplayNodeLabels: false, + refreshRate: '5', + }; + + $scope.handleChangeDisplayOnlyRunningTasks = function handleChangeDisplayOnlyRunningTasks(enabled) { + $scope.$evalAsync(() => { + $scope.state.DisplayOnlyRunningTasks = enabled; + $scope.changeDisplayOnlyRunningTasks(); + }); + }; + + $scope.handleChangeDisplayNodeLabels = function handleChangeDisplayNodeLabels(enabled) { + $scope.$evalAsync(() => { + $scope.state.DisplayNodeLabels = enabled; + $scope.changeDisplayNodeLabels(); + }); + }; + + $scope.$on('$destroy', function () { + stopRepeater(); + }); + + $scope.changeShowInformationPanel = function (value) { + $scope.state.ShowInformationPanel = value; + LocalStorage.storeSwarmVisualizerSettings('show_info_panel', value); + }; + + $scope.changeDisplayOnlyRunningTasks = function () { + var value = $scope.state.DisplayOnlyRunningTasks; + LocalStorage.storeSwarmVisualizerSettings('display_only_running_tasks', value); + }; + + $scope.changeDisplayNodeLabels = function () { + var value = $scope.state.DisplayNodeLabels; + LocalStorage.storeSwarmVisualizerSettings('display_node_labels', value); + }; + + $scope.changeUpdateRepeater = function () { + stopRepeater(); + setUpdateRepeater(); + $('#refreshRateChange').show(); + $('#refreshRateChange').fadeOut(1500); + LocalStorage.storeSwarmVisualizerSettings('refresh_rate', $scope.state.refreshRate); + }; + + function stopRepeater() { + var repeater = $scope.repeater; + if (angular.isDefined(repeater)) { + $interval.cancel(repeater); + } + } + + function setUpdateRepeater() { + var refreshRate = $scope.state.refreshRate; + $scope.repeater = $interval(function () { + $q.all({ + nodes: NodeService.nodes(), + services: ServiceService.services(), + tasks: TaskService.tasks(), + }) + .then(function success(data) { + var nodes = data.nodes; + $scope.nodes = nodes; + var services = data.services; + $scope.services = services; + var tasks = data.tasks; + $scope.tasks = tasks; + prepareVisualizerData(nodes, services, tasks); + }) + .catch(function error(err) { + stopRepeater(); + Notifications.error('Failure', err, 'Unable to retrieve cluster information'); + }); + }, refreshRate * 1000); + } + + function assignServiceInfo(services, tasks) { + for (var i = 0; i < services.length; i++) { + var service = services[i]; + + for (var j = 0; j < tasks.length; j++) { + var task = tasks[j]; + + if (task.ServiceId === service.Id) { + task.ServiceName = service.Name; + } + } + } + } + + function assignTasksToNode(nodes, tasks) { + for (var i = 0; i < nodes.length; i++) { + var node = nodes[i]; + node.Tasks = []; + + for (var j = 0; j < tasks.length; j++) { + var task = tasks[j]; + + if (task.NodeId === node.Id) { + node.Tasks.push(task); + } + } + } + } + + function prepareVisualizerData(nodes, services, tasks) { + var visualizerData = {}; + + assignServiceInfo(services, tasks); + assignTasksToNode(nodes, tasks); + + visualizerData.nodes = nodes; + $scope.visualizerData = visualizerData; + } + + function loadState() { + var showInfoPanel = LocalStorage.getSwarmVisualizerSettings('show_info_panel'); + if (showInfoPanel !== undefined && showInfoPanel !== null) $scope.state.ShowInformationPanel = showInfoPanel; + + var displayOnlyRunningTasks = LocalStorage.getSwarmVisualizerSettings('display_only_running_tasks'); + if (displayOnlyRunningTasks !== undefined && displayOnlyRunningTasks !== null) $scope.state.DisplayOnlyRunningTasks = displayOnlyRunningTasks; + + var displayNodeLabels = LocalStorage.getSwarmVisualizerSettings('display_node_labels'); + if (displayNodeLabels !== undefined && displayNodeLabels !== null) $scope.state.DisplayNodeLabels = displayNodeLabels; + + var refreshRate = LocalStorage.getSwarmVisualizerSettings('refresh_rate'); + if (refreshRate !== undefined && refreshRate !== null) $scope.state.refreshRate = refreshRate; + } + + function initView() { + $q.all({ + nodes: NodeService.nodes(), + services: ServiceService.services(), + tasks: TaskService.tasks(), + }) + .then(function success(data) { + var nodes = data.nodes; + $scope.nodes = nodes; + var services = data.services; + $scope.services = services; + var tasks = data.tasks; + $scope.tasks = tasks; + prepareVisualizerData(nodes, services, tasks); + setUpdateRepeater(); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to initialize cluster visualizer'); + }); + + loadState(); + } + + initView(); + }, +]); diff --git a/app/docker/views/swarm/visualizer/swarmvisualizer.html b/app/docker/views/swarm/visualizer/swarmvisualizer.html new file mode 100644 index 0000000..cd1a1d2 --- /dev/null +++ b/app/docker/views/swarm/visualizer/swarmvisualizer.html @@ -0,0 +1,124 @@ + + + +
+
+ + +
+ + +
+
+ + + + + + + + + + + + + + + + +
Nodes{{ nodes.length }}
Services{{ services.length }}
Tasks{{ tasks.length }}
+
+
Options
+
+
+ +
+
+
+
+ +
+
+
+
+
Refresh rate
+
+ +
+ +
+
+
+
+
+
+
+ +
+
+ + + +
+
+
+
+
+ {{ node.Name || node.Hostname }} + + + + +
+
+
{{ node.Role }}
+
CPU: {{ node.CPUs / 1000000000 }}
+
Memory: {{ node.Memory | humansize: 2 }}
+
{{ node.Status }}
+
+
Labels
+
+ {{ label.key }} + = {{ label.value }} +
+
+
+
+
+
{{ task.ServiceName }}
+
Image: {{ task.Spec.ContainerSpec.Image | hideshasum }}
+
Status: {{ task.Status.State }}
+
Update: {{ task.Updated | getisodate }}
+
Memory limit: {{ task.Spec.Resources.Limits.MemoryBytes | humansize: 2 : 2 }}
+
CPU limit: {{ task.Spec.Resources.Limits.NanoCPUs / 1000000000 }}
+
+
+
+
+
+
+
+
diff --git a/app/docker/views/tasks/edit/task.html b/app/docker/views/tasks/edit/task.html new file mode 100644 index 0000000..c808ad6 --- /dev/null +++ b/app/docker/views/tasks/edit/task.html @@ -0,0 +1,69 @@ + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ID{{ task.Id }}
State{{ task.Status.State }}
State Message{{ task.Status.Message }}
Error message{{ task.Status.Err }}
Image{{ task.Spec.ContainerSpec.Image | hideshasum }}
Slot{{ task.Slot }}
Created{{ task.Created | getisodate }}
Container ID{{ task.Status.ContainerStatus.ContainerID }}
+ + Task logs
+
+
+
+
diff --git a/app/docker/views/tasks/edit/taskController.js b/app/docker/views/tasks/edit/taskController.js new file mode 100644 index 0000000..53a31cb --- /dev/null +++ b/app/docker/views/tasks/edit/taskController.js @@ -0,0 +1,26 @@ +angular.module('portainer.docker').controller('TaskController', [ + '$scope', + '$transition$', + 'TaskService', + 'ServiceService', + 'Notifications', + function ($scope, $transition$, TaskService, ServiceService, Notifications) { + function initView() { + TaskService.task($transition$.params().id) + .then(function success(data) { + var task = data; + $scope.task = task; + return ServiceService.service(task.ServiceId); + }) + .then(function success(data) { + var service = data; + $scope.service = service; + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve task details'); + }); + } + + initView(); + }, +]); diff --git a/app/docker/views/tasks/logs/taskLogsController.js b/app/docker/views/tasks/logs/taskLogsController.js new file mode 100644 index 0000000..6112ed6 --- /dev/null +++ b/app/docker/views/tasks/logs/taskLogsController.js @@ -0,0 +1,82 @@ +import moment from 'moment'; + +angular.module('portainer.docker').controller('TaskLogsController', [ + '$scope', + '$transition$', + '$interval', + 'TaskService', + 'ServiceService', + 'Notifications', + function ($scope, $transition$, $interval, TaskService, ServiceService, Notifications) { + $scope.state = { + refreshRate: 3, + lineCount: 100, + sinceTimestamp: '', + displayTimestamps: false, + }; + + $scope.changeLogCollection = function (logCollectionStatus) { + if (!logCollectionStatus) { + stopRepeater(); + } else { + setUpdateRepeater(); + } + }; + + $scope.$on('$destroy', function () { + stopRepeater(); + }); + + function stopRepeater() { + var repeater = $scope.repeater; + if (angular.isDefined(repeater)) { + $interval.cancel(repeater); + } + } + + function setUpdateRepeater() { + var refreshRate = $scope.state.refreshRate; + $scope.repeater = $interval(function () { + TaskService.logs($transition$.params().id, 1, 1, $scope.state.displayTimestamps ? 1 : 0, moment($scope.state.sinceTimestamp).unix(), $scope.state.lineCount) + .then(function success(data) { + $scope.logs = data; + }) + .catch(function error(err) { + stopRepeater(); + Notifications.error('Failure', err, 'Unable to retrieve task logs'); + }); + }, refreshRate * 1000); + } + + function startLogPolling() { + TaskService.logs($transition$.params().id, 1, 1, $scope.state.displayTimestamps ? 1 : 0, moment($scope.state.sinceTimestamp).unix(), $scope.state.lineCount) + .then(function success(data) { + $scope.logs = data; + setUpdateRepeater(); + }) + .catch(function error(err) { + stopRepeater(); + Notifications.error('Failure', err, 'Unable to retrieve task logs'); + }); + } + + function initView() { + TaskService.task($transition$.params().id) + .then(function success(data) { + var task = data; + $scope.task = task; + return ServiceService.service(task.ServiceId); + }) + .then(function success(data) { + var service = data; + $scope.service = service; + startLogPolling(); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve task details'); + }); + } + + initView(); + }, +]); diff --git a/app/docker/views/tasks/logs/tasklogs.html b/app/docker/views/tasks/logs/tasklogs.html new file mode 100644 index 0000000..1c09ea9 --- /dev/null +++ b/app/docker/views/tasks/logs/tasklogs.html @@ -0,0 +1,27 @@ + + + + diff --git a/app/docker/views/volumes/browse/browseVolumeController.js b/app/docker/views/volumes/browse/browseVolumeController.js new file mode 100644 index 0000000..6a53ce8 --- /dev/null +++ b/app/docker/views/volumes/browse/browseVolumeController.js @@ -0,0 +1,13 @@ +angular.module('portainer.docker').controller('BrowseVolumeController', BrowseVolumeController); + +/* @ngInject */ +function BrowseVolumeController($scope, $transition$, StateManager, endpoint) { + function initView() { + $scope.volumeId = $transition$.params().id; + $scope.nodeName = $transition$.params().nodeName; + $scope.agentApiVersion = StateManager.getAgentApiVersion(); + $scope.endpointId = endpoint.Id; + } + + initView(); +} diff --git a/app/docker/views/volumes/browse/browsevolume.html b/app/docker/views/volumes/browse/browsevolume.html new file mode 100644 index 0000000..4f431b1 --- /dev/null +++ b/app/docker/views/volumes/browse/browsevolume.html @@ -0,0 +1,13 @@ + + + + diff --git a/app/docker/views/volumes/create/createVolumeController.js b/app/docker/views/volumes/create/createVolumeController.js new file mode 100644 index 0000000..6e19484 --- /dev/null +++ b/app/docker/views/volumes/create/createVolumeController.js @@ -0,0 +1,160 @@ +import { AccessControlFormData } from '../../../../portainer/components/accessControlForm/porAccessControlFormModel'; +import { VolumesNFSFormData } from '../../../components/volumesNFSForm/volumesNFSFormModel'; +import { VolumesCIFSFormData } from '../../../components/volumesCIFSForm/volumesCifsFormModel'; + +angular.module('portainer.docker').controller('CreateVolumeController', [ + '$scope', + '$state', + 'VolumeService', + 'PluginService', + 'ResourceControlService', + 'Authentication', + 'Notifications', + 'FormValidator', + 'endpoint', + function ($scope, $state, VolumeService, PluginService, ResourceControlService, Authentication, Notifications, FormValidator, endpoint) { + $scope.endpoint = endpoint; + + $scope.formValues = { + Driver: 'local', + DriverOptions: [], + AccessControlData: new AccessControlFormData(), + NodeName: null, + NFSData: new VolumesNFSFormData(), + CIFSData: new VolumesCIFSFormData(), + }; + + $scope.state = { + formValidationError: '', + actionInProgress: false, + }; + + $scope.availableVolumeDrivers = []; + + $scope.addDriverOption = function () { + $scope.formValues.DriverOptions.push({ name: '', value: '' }); + }; + + $scope.removeDriverOption = function (index) { + $scope.formValues.DriverOptions.splice(index, 1); + }; + + $scope.onUseNFSChange = onUseNFSChange; + + function onUseNFSChange(checked) { + return $scope.$evalAsync(() => { + $scope.formValues.NFSData.useNFS = checked; + $scope.formValues.CIFSData.useCIFS = false; + }); + } + + $scope.onUseCIFSChange = onUseCIFSChange; + + function onUseCIFSChange(checked) { + return $scope.$evalAsync(() => { + $scope.formValues.CIFSData.useCIFS = checked; + $scope.formValues.NFSData.useNFS = false; + }); + } + + function validateForm(accessControlData, isAdmin) { + $scope.state.formValidationError = ''; + var error = FormValidator.validateAccessControl(accessControlData, isAdmin); + + if (error) { + $scope.state.formValidationError = error; + return false; + } + return true; + } + + function prepareCIFSConfiguration(driverOptions) { + const data = $scope.formValues.CIFSData; + + driverOptions.push({ name: 'type', value: 'cifs' }); + + let share = data.share.replace('\\', '/'); + if (share[0] !== '/') { + share = '/' + share; + } + const device = '//' + data.serverAddress + share; + driverOptions.push({ name: 'device', value: device }); + + const versionNumber = data.versionsNumber[data.versions.indexOf(data.version)]; + const options = 'addr=' + data.serverAddress + ',username=' + data.username + ',password=' + data.password + ',vers=' + versionNumber; + driverOptions.push({ name: 'o', value: options }); + } + + function prepareNFSConfiguration(driverOptions) { + var data = $scope.formValues.NFSData; + + driverOptions.push({ name: 'type', value: 'nfs' }); + + var options = 'addr=' + data.serverAddress + ',' + data.options; + if (data.version === 'NFS4') { + options = options + ',nfsvers=4'; + } + driverOptions.push({ name: 'o', value: options }); + + var mountPoint = data.mountPoint.indexOf(':') === -1 ? ':' + data.mountPoint : data.mountPoint; + driverOptions.push({ name: 'device', value: mountPoint }); + } + + $scope.create = function () { + var name = $scope.formValues.Name; + var driver = $scope.formValues.Driver; + var driverOptions = $scope.formValues.DriverOptions; + + if ($scope.formValues.NFSData.useNFS) { + prepareNFSConfiguration(driverOptions); + } + + if ($scope.formValues.CIFSData.useCIFS) { + prepareCIFSConfiguration(driverOptions); + } + + var volumeConfiguration = VolumeService.createVolumeConfiguration(name, driver, driverOptions); + var accessControlData = $scope.formValues.AccessControlData; + var userDetails = Authentication.getUserDetails(); + var isAdmin = Authentication.isAdmin(); + + if (!validateForm(accessControlData, isAdmin)) { + return; + } + + var nodeName = $scope.formValues.NodeName; + + $scope.state.actionInProgress = true; + VolumeService.createVolume(volumeConfiguration, nodeName) + .then(function success(data) { + const userId = userDetails.ID; + const resourceControl = data.ResourceControl; + return ResourceControlService.applyResourceControl(userId, accessControlData, resourceControl); + }) + .then(function success() { + Notifications.success('Success', 'Volume successfully created'); + $state.go('docker.volumes', {}, { reload: true }); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'An error occurred during volume creation'); + }) + .finally(function final() { + $scope.state.actionInProgress = false; + }); + }; + + function initView() { + var apiVersion = $scope.applicationState.endpoint.apiVersion; + + PluginService.volumePlugins(apiVersion < 1.25) + .then(function success(data) { + $scope.availableVolumeDrivers = data; + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve volume drivers'); + }); + } + + initView(); + }, +]); diff --git a/app/docker/views/volumes/create/createvolume.html b/app/docker/views/volumes/create/createvolume.html new file mode 100644 index 0000000..38686aa --- /dev/null +++ b/app/docker/views/volumes/create/createvolume.html @@ -0,0 +1,117 @@ + + +
+
+ + +
+ +
+ +
+ +
+
+ +
Driver configuration
+ +
+ +
+ + +
+
+ + +
+
+ + + add driver option + +
+ +
+
+
+ name + +
+
+ value + +
+ +
+
+ +
+ + +
+ +
Ensure nfs-utils are installed on your hosts.
+
+ + + +
+ +
Ensure cifs-utils are installed on your hosts.
+
+ + +
+
Deployment
+ + + +
+ + + + +
Actions
+
+
+ + {{ state.formValidationError }} +
+
+ +
+
+
+
+
diff --git a/app/docker/views/volumes/edit/volume.html b/app/docker/views/volumes/edit/volume.html new file mode 100644 index 0000000..eb4a6a7 --- /dev/null +++ b/app/docker/views/volumes/edit/volume.html @@ -0,0 +1,111 @@ + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
ID + {{ volume.Id }} + + +
Created{{ volume.CreatedAt | getisodate }}
Mount path{{ volume.Mountpoint }}
Driver{{ volume.Driver }}
Labels + + + + + +
{{ k }}{{ v }}
+
+
+
+
+
+ + + + + + +
+
+ + + + + + + + + + +
{{ key }}{{ value }}
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + +
Container NameMounted AtRead-only
{{ container | containername }}{{ container.volumeData.Destination }}{{ !container.volumeData.RW }}
+
+
+
+
diff --git a/app/docker/views/volumes/edit/volumeController.js b/app/docker/views/volumes/edit/volumeController.js new file mode 100644 index 0000000..84ced26 --- /dev/null +++ b/app/docker/views/volumes/edit/volumeController.js @@ -0,0 +1,74 @@ +import { ResourceControlType } from '@/react/portainer/access-control/types'; +import { confirmDelete } from '@@/modals/confirm'; + +angular.module('portainer.docker').controller('VolumeController', [ + '$scope', + '$state', + '$transition$', + 'VolumeService', + 'ContainerService', + 'Notifications', + 'HttpRequestHelper', + 'Authentication', + 'endpoint', + function ($scope, $state, $transition$, VolumeService, ContainerService, Notifications, HttpRequestHelper, Authentication, endpoint) { + $scope.resourceType = ResourceControlType.Volume; + $scope.endpoint = endpoint; + $scope.showBrowseAction = false; + + $scope.onUpdateResourceControlSuccess = function () { + $state.reload(); + }; + + $scope.removeVolume = function removeVolume() { + confirmDelete('Do you want to remove this volume?').then((confirmed) => { + if (confirmed) { + VolumeService.remove($scope.volume.Id) + .then(function success() { + Notifications.success('Volume successfully removed', $transition$.params().id); + $state.go('docker.volumes', {}); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to remove volume'); + }); + } + }); + }; + + function getVolumeDataFromContainer(container, volumeId) { + return container.Mounts.find(function (volume) { + return volume.Name === volumeId; + }); + } + + function initView() { + HttpRequestHelper.setPortainerAgentTargetHeader($transition$.params().nodeName); + $scope.showBrowseAction = $scope.applicationState.endpoint.mode.agentProxy && (Authentication.isAdmin() || endpoint.SecuritySettings.allowVolumeBrowserForRegularUsers); + + VolumeService.volume($transition$.params().id) + .then(function success(data) { + var volume = data; + $scope.volume = volume; + var containerFilter = { volume: [volume.Id] }; + + return ContainerService.containers(endpoint.Id, 1, containerFilter); + }) + .then(function success(data) { + var dataContainers = $scope.isCioDriver ? data.containers : data; + + var containers = dataContainers.map(function (container) { + container.volumeData = getVolumeDataFromContainer(container, $scope.volume.Id); + + $scope.volume.NodeName = container.NodeName || ''; + return container; + }); + $scope.containersUsingVolume = containers; + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve volume details'); + }); + } + + initView(); + }, +]); diff --git a/app/docker/views/volumes/volumes.html b/app/docker/views/volumes/volumes.html new file mode 100644 index 0000000..ffa186a --- /dev/null +++ b/app/docker/views/volumes/volumes.html @@ -0,0 +1,3 @@ + + + diff --git a/app/docker/views/volumes/volumesController.js b/app/docker/views/volumes/volumesController.js new file mode 100644 index 0000000..b37a012 --- /dev/null +++ b/app/docker/views/volumes/volumesController.js @@ -0,0 +1,71 @@ +import { processItemsInBatches } from '@/react/common/processItemsInBatches'; + +angular.module('portainer.docker').controller('VolumesController', [ + '$q', + '$scope', + '$state', + 'VolumeService', + 'ServiceService', + 'VolumeHelper', + 'Notifications', + 'Authentication', + 'endpoint', + function ($q, $scope, $state, VolumeService, ServiceService, VolumeHelper, Notifications, Authentication, endpoint) { + $scope.removeAction = async function (selectedItems) { + async function doRemove(volume) { + return VolumeService.remove(volume.Name, volume.NodeName) + .then(function success() { + Notifications.success('Volume successfully removed', volume.Name); + var index = $scope.volumes.indexOf(volume); + $scope.volumes.splice(index, 1); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to remove volume'); + }); + } + + await processItemsInBatches(selectedItems, doRemove); + $state.reload(); + }; + + $scope.getVolumes = getVolumes; + function getVolumes() { + var endpointProvider = $scope.applicationState.endpoint.mode.provider; + var endpointRole = $scope.applicationState.endpoint.mode.role; + + $q.all({ + attached: VolumeService.volumes({ dangling: ['false'] }), + dangling: VolumeService.volumes({ dangling: ['true'] }), + services: endpointProvider === 'DOCKER_SWARM_MODE' && endpointRole === 'MANAGER' ? ServiceService.services() : [], + }) + .then(function success(data) { + var services = data.services; + $scope.volumes = data.attached + .map(function (volume) { + volume.dangling = false; + return volume; + }) + .concat( + data.dangling.map(function (volume) { + volume.dangling = true; + if (VolumeHelper.isVolumeUsedByAService(volume, services)) { + volume.dangling = false; + } + return volume; + }) + ); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve volumes'); + }); + } + + function initView() { + getVolumes(); + + $scope.showBrowseAction = $scope.applicationState.endpoint.mode.agentProxy && (Authentication.isAdmin() || endpoint.SecuritySettings.allowVolumeBrowserForRegularUsers); + } + + initView(); + }, +]); diff --git a/app/edge/__module.js b/app/edge/__module.js new file mode 100644 index 0000000..b5666a7 --- /dev/null +++ b/app/edge/__module.js @@ -0,0 +1,213 @@ +import angular from 'angular'; + +import { AccessHeaders } from '@/portainer/authorization-guard'; +import { reactModule } from './react'; + +angular + .module('portainer.edge', [reactModule]) + + .config(function config($stateRegistryProvider) { + const edge = { + name: 'edge', + url: '/edge', + parent: 'root', + abstract: true, + data: { + access: AccessHeaders.EdgeAdmin, + }, + }; + + const groups = { + name: 'edge.groups', + url: '/groups', + views: { + 'content@': { + component: 'edgeGroupsView', + }, + }, + data: { + docs: '/user/edge/groups', + }, + }; + + const groupsNew = { + name: 'edge.groups.new', + url: '/new', + views: { + 'content@': { + component: 'edgeGroupsCreateView', + }, + }, + }; + + const groupsEdit = { + name: 'edge.groups.edit', + url: '/:groupId', + views: { + 'content@': { + component: 'edgeGroupsItemView', + }, + }, + }; + + const stacks = { + name: 'edge.stacks', + url: '/stacks', + views: { + 'content@': { + component: 'edgeStacksView', + }, + }, + data: { + docs: '/user/edge/stacks', + }, + }; + + const stacksNew = { + name: 'edge.stacks.new', + url: '/new?templateId&templateType', + views: { + 'content@': { + component: 'edgeStacksCreateView', + }, + }, + data: { + docs: '/user/edge/stacks/add', + }, + params: { + templateId: { dynamic: true }, + templateType: { dynamic: true }, + }, + }; + + const stacksEdit = { + name: 'edge.stacks.edit', + url: '/:stackId?tab&status', + views: { + 'content@': { + component: 'edgeStacksItemView', + }, + }, + params: { + status: { + dynamic: true, + }, + }, + }; + + const edgeJobs = { + name: 'edge.jobs', + url: '/jobs', + views: { + 'content@': { + component: 'edgeJobsView', + }, + }, + data: { + docs: '/user/edge/jobs', + }, + }; + + const edgeJob = { + name: 'edge.jobs.job', + url: '/:id?tab', + views: { + 'content@': { + component: 'edgeJobsItemView', + }, + }, + }; + + const edgeJobCreation = { + name: 'edge.jobs.new', + url: '/new', + views: { + 'content@': { + component: 'edgeJobsCreateView', + }, + }, + }; + + $stateRegistryProvider.register({ + name: 'edge.devices', + url: '/devices', + abstract: true, + }); + + if (process.env.PORTAINER_EDITION === 'BE') { + $stateRegistryProvider.register({ + name: 'edge.devices.waiting-room', + url: '/waiting-room', + views: { + 'content@': { + component: 'waitingRoomView', + }, + }, + data: { + docs: '/user/edge/waiting-room', + }, + }); + } + + $stateRegistryProvider.register({ + name: 'edge.templates', + url: '/templates?template', + views: { + 'content@': { + component: 'appTemplatesView', + }, + }, + data: { + docs: '/user/edge/templates/application', + }, + }); + + $stateRegistryProvider.register({ + name: 'edge.templates.custom', + url: '/custom', + views: { + 'content@': { + component: 'customTemplatesView', + }, + }, + data: { + docs: '/user/edge/templates/custom', + }, + }); + + $stateRegistryProvider.register({ + name: 'edge.templates.custom.new', + url: '/new?appTemplateId&type', + + views: { + 'content@': { + component: 'createCustomTemplatesView', + }, + }, + }); + + $stateRegistryProvider.register({ + name: 'edge.templates.custom.edit', + url: '/:id', + + views: { + 'content@': { + component: 'editCustomTemplatesView', + }, + }, + }); + + $stateRegistryProvider.register(edge); + + $stateRegistryProvider.register(groups); + $stateRegistryProvider.register(groupsNew); + $stateRegistryProvider.register(groupsEdit); + + $stateRegistryProvider.register(stacks); + $stateRegistryProvider.register(stacksNew); + $stateRegistryProvider.register(stacksEdit); + + $stateRegistryProvider.register(edgeJobs); + $stateRegistryProvider.register(edgeJob); + $stateRegistryProvider.register(edgeJobCreation); + }); diff --git a/app/edge/components/edge-stack-status/edgeStackStatus.css b/app/edge/components/edge-stack-status/edgeStackStatus.css new file mode 100644 index 0000000..95778ac --- /dev/null +++ b/app/edge/components/edge-stack-status/edgeStackStatus.css @@ -0,0 +1,25 @@ +.status:not(:last-child) { + margin-right: 1em; +} + +.status .icon { + padding: 0 !important; + margin-right: 1ch; + border-radius: 50%; + background-color: grey; + height: 10px; + width: 10px; + display: inline-block; +} + +.status .error { + background-color: #ae2323; +} + +.status .acknowledged { + background-color: #337ab7; +} + +.status .ok { + background-color: #23ae89; +} diff --git a/app/edge/components/edge-stack-status/edgeStackStatus.html b/app/edge/components/edge-stack-status/edgeStackStatus.html new file mode 100644 index 0000000..02c8a07 --- /dev/null +++ b/app/edge/components/edge-stack-status/edgeStackStatus.html @@ -0,0 +1,3 @@ +{{ $ctrl.status.acknowledged || 0 }} +{{ $ctrl.status.ok || 0 }} +{{ $ctrl.status.error || 0 }} diff --git a/app/edge/components/edge-stack-status/edgeStackStatusController.js b/app/edge/components/edge-stack-status/edgeStackStatusController.js new file mode 100644 index 0000000..002af88 --- /dev/null +++ b/app/edge/components/edge-stack-status/edgeStackStatusController.js @@ -0,0 +1,20 @@ +const statusMap = { + 1: 'ok', + 2: 'error', + 3: 'acknowledged', +}; + +export class EdgeStackStatusController { + $onChanges({ stackStatus }) { + if (!stackStatus || !stackStatus.currentValue) { + return; + } + const aggregateStatus = { ok: 0, error: 0, acknowledged: 0 }; + for (let endpointId in stackStatus.currentValue) { + const endpoint = stackStatus.currentValue[endpointId]; + const endpointStatusKey = statusMap[endpoint.Type]; + aggregateStatus[endpointStatusKey]++; + } + this.status = aggregateStatus; + } +} diff --git a/app/edge/components/edge-stack-status/index.js b/app/edge/components/edge-stack-status/index.js new file mode 100644 index 0000000..11af093 --- /dev/null +++ b/app/edge/components/edge-stack-status/index.js @@ -0,0 +1,12 @@ +import angular from 'angular'; + +import { EdgeStackStatusController } from './edgeStackStatusController'; +import './edgeStackStatus.css'; + +angular.module('portainer.edge').component('edgeStackStatus', { + templateUrl: './edgeStackStatus.html', + controller: EdgeStackStatusController, + bindings: { + stackStatus: '<', + }, +}); diff --git a/app/edge/react/components/index.ts b/app/edge/react/components/index.ts new file mode 100644 index 0000000..1902af6 --- /dev/null +++ b/app/edge/react/components/index.ts @@ -0,0 +1,76 @@ +import angular from 'angular'; + +import { r2a } from '@/react-tools/react2angular'; +import { withReactQuery } from '@/react-tools/withReactQuery'; +import { withUIRouter } from '@/react-tools/withUIRouter'; +import { AssociatedEdgeEnvironmentsSelector } from '@/react/edge/components/AssociatedEdgeEnvironmentsSelector'; +import { EdgeAsyncIntervalsForm } from '@/react/edge/components/EdgeAsyncIntervalsForm'; +import { EdgeCheckinIntervalField } from '@/react/edge/components/EdgeCheckInIntervalField'; +import { EdgeScriptForm } from '@/react/edge/components/EdgeScriptForm'; +import { EdgeGroupsSelector } from '@/react/edge/edge-stacks/components/EdgeGroupsSelector'; +import { AssociatedEdgeGroupEnvironmentsSelector } from '@/react/edge/components/AssociatedEdgeGroupEnvironmentsSelector'; + +const ngModule = angular + .module('portainer.edge.react.components', []) + + .component( + 'edgeGroupsSelector', + r2a(withUIRouter(withReactQuery(EdgeGroupsSelector)), [ + 'onChange', + 'value', + 'error', + 'horizontal', + 'isGroupVisible', + 'required', + ]) + ) + .component( + 'edgeScriptForm', + r2a(withReactQuery(EdgeScriptForm), [ + 'edgeInfo', + 'commands', + 'asyncMode', + 'showMetaFields', + ]) + ) + .component( + 'edgeCheckinIntervalField', + r2a(withReactQuery(EdgeCheckinIntervalField), [ + 'value', + 'onChange', + 'isDefaultHidden', + 'tooltip', + 'label', + 'readonly', + 'size', + ]) + ) + .component( + 'edgeAsyncIntervalsForm', + r2a(withReactQuery(EdgeAsyncIntervalsForm), [ + 'values', + 'onChange', + 'isDefaultHidden', + 'readonly', + 'fieldSettings', + ]) + ) + .component( + 'associatedEdgeEnvironmentsSelector', + r2a(withReactQuery(AssociatedEdgeEnvironmentsSelector), [ + 'onChange', + 'value', + 'error', + ]) + ) + .component( + 'associatedEdgeGroupEnvironmentsSelector', + r2a(withReactQuery(AssociatedEdgeGroupEnvironmentsSelector), [ + 'onChange', + 'value', + 'error', + 'edgeGroupId', + ]) + ); + +export const componentsModule = ngModule.name; diff --git a/app/edge/react/index.ts b/app/edge/react/index.ts new file mode 100644 index 0000000..802b971 --- /dev/null +++ b/app/edge/react/index.ts @@ -0,0 +1,9 @@ +import angular from 'angular'; + +import { componentsModule } from './components'; +import { viewsModule } from './views'; + +export const reactModule = angular.module('portainer.edge.react', [ + viewsModule, + componentsModule, +]).name; diff --git a/app/edge/react/views/edge-stacks.ts b/app/edge/react/views/edge-stacks.ts new file mode 100644 index 0000000..41cbeed --- /dev/null +++ b/app/edge/react/views/edge-stacks.ts @@ -0,0 +1,23 @@ +import angular from 'angular'; + +import { r2a } from '@/react-tools/react2angular'; +import { withCurrentUser } from '@/react-tools/withCurrentUser'; +import { withUIRouter } from '@/react-tools/withUIRouter'; +import { CreateView } from '@/react/edge/edge-stacks/CreateView/CreateView'; +import { ItemView } from '@/react/edge/edge-stacks/ItemView/ItemView'; +import { ListView } from '@/react/edge/edge-stacks/ListView'; + +export const stacksModule = angular + .module('portainer.edge.react.views.stacks', []) + .component( + 'edgeStacksCreateView', + r2a(withCurrentUser(withUIRouter(CreateView)), []) + ) + .component( + 'edgeStacksItemView', + r2a(withCurrentUser(withUIRouter(ItemView)), []) + ) + .component( + 'edgeStacksView', + r2a(withUIRouter(withCurrentUser(ListView)), []) + ).name; diff --git a/app/edge/react/views/groups.ts b/app/edge/react/views/groups.ts new file mode 100644 index 0000000..deb51b0 --- /dev/null +++ b/app/edge/react/views/groups.ts @@ -0,0 +1,20 @@ +import angular from 'angular'; + +import { r2a } from '@/react-tools/react2angular'; +import { withCurrentUser } from '@/react-tools/withCurrentUser'; +import { withUIRouter } from '@/react-tools/withUIRouter'; +import { ListView } from '@/react/edge/edge-groups/ListView'; +import { CreateView } from '@/react/edge/edge-groups/CreateView/CreateView'; +import { ItemView } from '@/react/edge/edge-groups/ItemView/ItemView'; + +export const groupsModule = angular + .module('portainer.edge.react.views.groups', []) + .component('edgeGroupsView', r2a(withUIRouter(withCurrentUser(ListView)), [])) + .component( + 'edgeGroupsCreateView', + r2a(withUIRouter(withCurrentUser(CreateView)), []) + ) + .component( + 'edgeGroupsItemView', + r2a(withUIRouter(withCurrentUser(ItemView)), []) + ).name; diff --git a/app/edge/react/views/index.ts b/app/edge/react/views/index.ts new file mode 100644 index 0000000..9b35040 --- /dev/null +++ b/app/edge/react/views/index.ts @@ -0,0 +1,24 @@ +import angular from 'angular'; + +import { r2a } from '@/react-tools/react2angular'; +import { withCurrentUser } from '@/react-tools/withCurrentUser'; +import { withReactQuery } from '@/react-tools/withReactQuery'; +import { withUIRouter } from '@/react-tools/withUIRouter'; +import { WaitingRoomView } from '@/react/edge/edge-devices/WaitingRoomView'; + +import { templatesModule } from './templates'; +import { jobsModule } from './jobs'; +import { stacksModule } from './edge-stacks'; +import { groupsModule } from './groups'; + +export const viewsModule = angular + .module('portainer.edge.react.views', [ + templatesModule, + jobsModule, + stacksModule, + groupsModule, + ]) + .component( + 'waitingRoomView', + r2a(withUIRouter(withReactQuery(withCurrentUser(WaitingRoomView))), []) + ).name; diff --git a/app/edge/react/views/jobs.ts b/app/edge/react/views/jobs.ts new file mode 100644 index 0000000..1b3aa26 --- /dev/null +++ b/app/edge/react/views/jobs.ts @@ -0,0 +1,20 @@ +import angular from 'angular'; + +import { r2a } from '@/react-tools/react2angular'; +import { withCurrentUser } from '@/react-tools/withCurrentUser'; +import { withUIRouter } from '@/react-tools/withUIRouter'; +import { ListView } from '@/react/edge/edge-jobs/ListView'; +import { CreateView } from '@/react/edge/edge-jobs/CreateView/CreateView'; +import { ItemView } from '@/react/edge/edge-jobs/ItemView/ItemView'; + +export const jobsModule = angular + .module('portainer.edge.react.views.jobs', []) + .component('edgeJobsView', r2a(withUIRouter(withCurrentUser(ListView)), [])) + .component( + 'edgeJobsItemView', + r2a(withUIRouter(withCurrentUser(ItemView)), []) + ) + .component( + 'edgeJobsCreateView', + r2a(withUIRouter(withCurrentUser(CreateView)), []) + ).name; diff --git a/app/edge/react/views/templates.ts b/app/edge/react/views/templates.ts new file mode 100644 index 0000000..b86345b --- /dev/null +++ b/app/edge/react/views/templates.ts @@ -0,0 +1,6 @@ +import angular from 'angular'; + +export const templatesModule = angular.module( + 'portainer.edge.react.views.templates', + [] +).name; diff --git a/app/edge/rest/edge-groups.js b/app/edge/rest/edge-groups.js new file mode 100644 index 0000000..9c0a6cb --- /dev/null +++ b/app/edge/rest/edge-groups.js @@ -0,0 +1,11 @@ +import angular from 'angular'; + +angular.module('portainer.edge').factory('EdgeGroups', function EdgeGroupsFactory($resource, API_ENDPOINT_EDGE_GROUPS) { + return $resource( + API_ENDPOINT_EDGE_GROUPS + '/:id/:action', + {}, + { + query: { method: 'GET', isArray: true }, + } + ); +}); diff --git a/app/edge/rest/edge-stacks.js b/app/edge/rest/edge-stacks.js new file mode 100644 index 0000000..9ae34b4 --- /dev/null +++ b/app/edge/rest/edge-stacks.js @@ -0,0 +1,16 @@ +import angular from 'angular'; + +angular.module('portainer.edge').factory('EdgeStacks', function EdgeStacksFactory($resource, API_ENDPOINT_EDGE_STACKS) { + return $resource( + API_ENDPOINT_EDGE_STACKS + '/:id/:action', + {}, + { + create: { method: 'POST', ignoreLoadingBar: true, params: { id: 'create', action: '@method' } }, + query: { method: 'GET', isArray: true }, + get: { method: 'GET', params: { id: '@id' } }, + update: { method: 'PUT', params: { id: '@id' } }, + remove: { method: 'DELETE', params: { id: '@id' } }, + file: { method: 'GET', params: { id: '@id', action: 'file' } }, + } + ); +}); diff --git a/app/edge/services/edge-group.js b/app/edge/services/edge-group.js new file mode 100644 index 0000000..62f87db --- /dev/null +++ b/app/edge/services/edge-group.js @@ -0,0 +1,11 @@ +import angular from 'angular'; + +angular.module('portainer.edge').factory('EdgeGroupService', function EdgeGroupServiceFactory(EdgeGroups) { + var service = {}; + + service.groups = function groups() { + return EdgeGroups.query({}).$promise; + }; + + return service; +}); diff --git a/app/edge/services/edge-stack.js b/app/edge/services/edge-stack.js new file mode 100644 index 0000000..8210266 --- /dev/null +++ b/app/edge/services/edge-stack.js @@ -0,0 +1,69 @@ +import angular from 'angular'; + +angular.module('portainer.edge').factory('EdgeStackService', function EdgeStackServiceFactory(EdgeStacks, FileUploadService) { + var service = {}; + + service.stack = function stack(id) { + return EdgeStacks.get({ id }).$promise; + }; + + service.stacks = function stacks() { + return EdgeStacks.query({}).$promise; + }; + + service.remove = function remove(id) { + return EdgeStacks.remove({ id }).$promise; + }; + + service.stackFile = async function stackFile(id) { + try { + const { StackFileContent } = await EdgeStacks.file({ id }).$promise; + return StackFileContent; + } catch (err) { + throw { msg: 'Unable to retrieve stack content', err }; + } + }; + + service.updateStack = async function updateStack(id, stack) { + return EdgeStacks.update({ id }, stack).$promise; + }; + + service.createStackFromFileContent = async function createStackFromFileContent(payload) { + try { + return await EdgeStacks.create({}, { method: 'string', ...payload }).$promise; + } catch (err) { + throw { msg: 'Unable to create the stack', err }; + } + }; + + service.createStackFromFileUpload = async function createStackFromFileUpload(payload, file) { + try { + return await FileUploadService.createEdgeStack(payload, file); + } catch (err) { + throw { msg: 'Unable to create the stack', err }; + } + }; + + service.createStackFromGitRepository = async function createStackFromGitRepository(payload, repositoryOptions) { + try { + return await EdgeStacks.create( + {}, + { + ...payload, + method: 'repository', + RepositoryURL: repositoryOptions.RepositoryURL, + RepositoryReferenceName: repositoryOptions.RepositoryReferenceName, + FilePathInRepository: repositoryOptions.FilePathInRepository, + RepositoryAuthentication: repositoryOptions.RepositoryAuthentication, + RepositoryUsername: repositoryOptions.RepositoryUsername, + RepositoryPassword: repositoryOptions.RepositoryPassword, + TLSSkipVerify: repositoryOptions.TLSSkipVerify, + } + ).$promise; + } catch (err) { + throw { msg: 'Unable to create the stack', err }; + } + }; + + return service; +}); diff --git a/app/global.d.ts b/app/global.d.ts new file mode 100644 index 0000000..2b1a8c6 --- /dev/null +++ b/app/global.d.ts @@ -0,0 +1,88 @@ +declare module '*.jpg' { + export default '' as string; +} +declare module '*.png' { + export default '' as string; +} + +declare module '*.svg' { + export default '' as string; +} + +type SvgrComponent = React.StatelessComponent>; + +declare module '*.svg?c' { + const value: SvgrComponent; + export default value; +} + +declare module '*.css'; + +declare module 'axios-progress-bar' { + import { AxiosInstance } from 'axios'; + import { NProgressOptions } from 'nprogress'; + + export function loadProgressBar( + config?: Partial, + instance?: AxiosInstance + ): void; +} + +interface HubSpotCreateFormOptions { + /** User's portal ID */ + portalId: string; + /** Unique ID of the form you wish to build */ + formId: string; + + region: string; + /** + * jQuery style selector specifying an existing element on the page into which the form will be placed once built. + * + * NOTE: If you're including multiple forms on the page, it is strongly recommended that you include a separate, specific target for each form. + */ + target: string; + /** + * Callback that executes after form is validated, just before the data is actually sent. + * This is for any logic that needs to execute during the submit. + * Any changes will not be validated. + * Takes the jQuery form object as the argument: onFormSubmit($form). + * + * Note: Performing a browser redirect in this callback is not recommended and could prevent the form submission + */ + onFormSubmit?: (form: JQuery) => void; + /** + * Callback when the data is actually sent. + * This allows you to perform an action when the submission is fully complete, + * such as displaying a confirmation or thank you message. + */ + onFormSubmitted?: (form: JQuery) => void; + /** + * Callback that executes after form is built, placed in the DOM, and validation has been initialized. + * This is perfect for any logic that needs to execute when the form is on the page. + * + * Takes the jQuery form object as the argument: onFormReady($form) + */ + onFormReady?: (form: JQuery) => void; +} + +interface Window { + /** + * will be true if portainer is run as a Docker Desktop Extension + */ + ddExtension?: boolean; + hbspt?: { + forms: { + create: (options: HubSpotCreateFormOptions) => void; + }; + }; +} + +declare module 'process' { + global { + namespace NodeJS { + interface ProcessEnv { + PORTAINER_EDITION: 'BE' | 'CE'; + } + } + } +} diff --git a/app/i18n.ts b/app/i18n.ts new file mode 100644 index 0000000..5f00d03 --- /dev/null +++ b/app/i18n.ts @@ -0,0 +1,18 @@ +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import LanguageDetector from 'i18next-browser-languagedetector'; +import Backend from 'i18next-http-backend'; + +i18n + .use(Backend) + .use(LanguageDetector) + .use(initReactI18next) + .init({ + debug: true, + fallbackLng: 'en', + interpolation: { + escapeValue: false, + }, + }); + +export default i18n; diff --git a/app/index.html b/app/index.html new file mode 100644 index 0000000..a0d0b66 --- /dev/null +++ b/app/index.html @@ -0,0 +1,93 @@ + + + + + Portainer + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+
+
+ +
+
+ +
+ +
+ + +
+
+ + +
+ Loading Portainer... + + + + + +
+ +
+
+ +
+ + +
+
+ +
+ +
+ + + diff --git a/app/index.js b/app/index.js new file mode 100644 index 0000000..6b6a5c8 --- /dev/null +++ b/app/index.js @@ -0,0 +1,66 @@ +import './assets/css'; + +import './i18n'; + +import angular from 'angular'; +import { UI_ROUTER_REACT_HYBRID } from '@uirouter/react-hybrid'; + +import { Edition } from '@/react/portainer/feature-flags/enums'; +import { init as initFeatureService } from '@/react/portainer/feature-flags/feature-flags.service'; + +import './agent'; +import { azureModule } from './azure'; +import './docker/__module'; +import './edge/__module'; +import './portainer/__module'; + +import { onStartupAngular } from './app'; +import { configApp } from './config'; +import { constantsModule } from './ng-constants'; + +// http://localhost:49000 is a docker extension specific url (see /build/docker-extension/docker-compose.yml) +if (window.origin == 'http://localhost:49000') { + // we are loading the app from a local file as in docker extension + document.getElementById('base').href = 'http://localhost:49000/'; + + window.ddExtension = true; +} else { + var path = window.location.pathname.replace(/^\/+|\/+$/g, ''); + var basePath = path ? '/' + path + '/' : '/'; + document.getElementById('base').href = basePath; +} + +initFeatureService(Edition[process.env.PORTAINER_EDITION]); + +angular + .module('portainer', [ + 'ui.bootstrap', + 'ui.router', + UI_ROUTER_REACT_HYBRID, + 'ngSanitize', + 'ngFileUpload', + 'ngMessages', + 'ngResource', + 'LocalStorageModule', + 'angular-loading-bar', + 'angular-clipboard', + 'ngFileSaver', + 'luegg.directives', + 'portainer.app', + 'portainer.agent', + azureModule, + 'portainer.docker', + 'portainer.kubernetes', + 'portainer.edge', + 'rzModule', + constantsModule, + ]) + .run(onStartupAngular) + .config(configApp); + +if (require) { + const req = require.context('./', true, /^(?!.*\.test\.js$).*\.js$/im); + req.keys().forEach(function (key) { + req(key); + }); +} diff --git a/app/kubernetes/__module.js b/app/kubernetes/__module.js new file mode 100644 index 0000000..63838f2 --- /dev/null +++ b/app/kubernetes/__module.js @@ -0,0 +1,967 @@ +import { EnvironmentStatus } from '@/react/portainer/environments/types'; + +import { updateAxiosAdapter } from '@/react/portainer/services/axios/axios'; +import { PortainerEndpointTypes } from '@/portainer/models/endpoint/models'; +import { cache } from '@/react/portainer/services/axios/axios'; +import { CACHE_REFRESH_EVENT, CACHE_DURATION } from '../portainer/services/http-request.helper'; + +import registriesModule from './registries'; +import customTemplateModule from './custom-templates'; +import { reactModule } from './react'; +import './views/kubernetes.css'; + +// The angular-cache npm package didn't have exclude options, so implement a custom cache +// with an added check to only cache kubernetes requests +class ExpirationCache { + constructor() { + this.store = new Map(); + this.timeout = CACHE_DURATION; + } + + get(key) { + return this.store.get(key); + } + + put(key, val) { + // only cache requests with 'kubernetes' in the url + if (key.includes('kubernetes')) { + this.store.set(key, val); + // remove it once it's expired + setTimeout(() => { + this.remove(key); + }, this.timeout); + } + } + + remove(key) { + this.store.delete(key); + } + + removeAll() { + this.store = new Map(); + } + + delete() { + // skip because this is standalone, not a part of $cacheFactory + } +} + +angular.module('portainer.kubernetes', ['portainer.app', registriesModule, customTemplateModule, reactModule]).config([ + '$stateRegistryProvider', + function ($stateRegistryProvider) { + 'use strict'; + + const kubernetes = { + name: 'kubernetes', + url: '/kubernetes', + parent: 'endpoint', + abstract: true, + + onEnter: /* @ngInject */ function onEnter( + $async, + $state, + endpoint, + KubernetesHealthService, + Notifications, + StateManager, + $http, + Authentication, + UserService, + EndpointService, + EndpointProvider + ) { + return $async(async () => { + // if the user wants to use front end cache for performance, set the angular caching settings + const userDetails = Authentication.getUserDetails(); + const user = await UserService.user(userDetails.ID); + updateAxiosAdapter(user.UseCache); + if (user.UseCache) { + $http.defaults.cache = new ExpirationCache(); + window.addEventListener(CACHE_REFRESH_EVENT, () => { + $http.defaults.cache.removeAll(); + cache.store.clear(); + }); + } + + // EE-5842: do not redirect shell views when the env is removed + const nextTransition = $state.transition && $state.transition.to(); + const nextTransitionName = nextTransition ? nextTransition.name : ''; + if (nextTransitionName === 'kubernetes.kubectlshell' && !endpoint) { + return; + } + + const kubeTypes = [ + PortainerEndpointTypes.KubernetesLocalEnvironment, + PortainerEndpointTypes.AgentOnKubernetesEnvironment, + PortainerEndpointTypes.EdgeAgentOnKubernetesEnvironment, + ]; + + if (!kubeTypes.includes(endpoint.Type)) { + $state.go('portainer.home'); + return; + } + + try { + const status = await checkEndpointStatus(endpoint); + + if (endpoint.Type !== PortainerEndpointTypes.EdgeAgentOnKubernetesEnvironment) { + await updateEndpointStatus(endpoint, status); + } + endpoint.Status = status; + + if (endpoint.Status === EnvironmentStatus.Down) { + throw new Error( + endpoint.Type === PortainerEndpointTypes.EdgeAgentOnKubernetesEnvironment + ? 'Unable to contact Edge agent, please ensure that the agent is properly running on the remote environment.' + : `The environment named ${endpoint.Name} is unreachable.` + ); + } + + await StateManager.updateEndpointState(endpoint); + } catch (e) { + let params = {}; + + if (endpoint.Type == PortainerEndpointTypes.EdgeAgentOnKubernetesEnvironment) { + params = { redirect: true, environmentId: endpoint.Id, environmentName: endpoint.Name, route: 'kubernetes.dashboard' }; + } else { + EndpointProvider.clean(); + Notifications.error('Failed loading environment', e); + } + // Prevent redirect to home for shell views when environment is unreachable + // Show toast error instead (handled above in Notifications.error) + if (nextTransitionName === 'kubernetes.kubectlshell') { + return; + } + $state.go('portainer.home', params, { reload: true, inherit: false }); + return false; + } + + async function checkEndpointStatus(endpoint) { + try { + await KubernetesHealthService.ping(endpoint.Id); + return EnvironmentStatus.Up; + } catch (e) { + return EnvironmentStatus.Down; + } + } + + async function updateEndpointStatus(endpoint, status) { + if (endpoint.Status === status) { + return; + } + await EndpointService.updateEndpoint(endpoint.Id, { Status: status }); + } + }); + }, + }; + + const helmApplication = { + name: 'kubernetes.helm', + url: '/helm/:namespace/:name?revision&tab', + views: { + 'content@': { + component: 'kubernetesHelmApplicationView', + }, + }, + data: { + docs: '/user/kubernetes/inspect-helm', + }, + }; + + const helmInstall = { + name: 'kubernetes.helminstall', + url: '/helm?referrer', + views: { + 'content@': { + component: 'helmInstallView', + }, + }, + params: { + yaml: '', + }, + data: { + docs: '/user/kubernetes/applications/manifest/helm', + }, + }; + + const services = { + name: 'kubernetes.services', + url: '/services', + views: { + 'content@': { + component: 'kubernetesServicesView', + }, + }, + data: { + docs: '/user/kubernetes/networking/services', + }, + }; + const service = { + name: 'kubernetes.services.service', + url: '/:namespace/:name?tab', + views: { + 'content@': { + component: 'kubernetesResourceDetailsYAMLView', + }, + }, + data: { + resourceConfig: { + title: 'Service details', + breadcrumbLabel: 'Services', + breadcrumbLink: 'kubernetes.services', + resourceType: 'service', + apiVersion: 'v1', + resourcePlural: 'services', + namespaced: true, + yamlIdentifier: 'service-yaml', + dataCy: 'service-yaml', + }, + }, + }; + + const ingresses = { + name: 'kubernetes.ingresses', + url: '/ingresses', + views: { + 'content@': { + component: 'kubernetesIngressesView', + }, + }, + data: { + docs: '/user/kubernetes/networking/ingresses', + }, + }; + + const ingressesCreate = { + name: 'kubernetes.ingresses.create', + url: '/add', + views: { + 'content@': { + component: 'kubernetesIngressesCreateView', + }, + }, + data: { + docs: '/user/kubernetes/networking/ingresses/add', + }, + }; + + const ingressesEdit = { + name: 'kubernetes.ingresses.edit', + url: '/:namespace/:name/edit', + views: { + 'content@': { + component: 'kubernetesIngressesCreateView', + }, + }, + }; + + const applications = { + name: 'kubernetes.applications', + url: '/applications?tab', + views: { + 'content@': { + component: 'kubernetesApplicationsView', + }, + }, + data: { + docs: '/user/kubernetes/applications', + }, + }; + + const applicationCreation = { + name: 'kubernetes.applications.new', + url: '/new', + views: { + 'content@': { + component: 'kubernetesCreateApplicationView', + }, + }, + data: { + docs: '/user/kubernetes/applications/add', + }, + }; + + const application = { + name: 'kubernetes.applications.application', + url: '/:namespace/:name?resource-type', + params: { + openGitSettings: { value: null, dynamic: true }, + }, + views: { + 'content@': { + component: 'applicationDetailsView', + }, + }, + data: { + docs: '/user/kubernetes/applications/inspect', + }, + }; + + const applicationEdit = { + name: 'kubernetes.applications.application.edit', + url: '/edit', + views: { + 'content@': { + component: 'kubernetesCreateApplicationView', + }, + }, + data: { + docs: '/user/kubernetes/applications/edit', + }, + }; + + const applicationConsole = { + name: 'kubernetes.applications.application.console', + url: '/:pod/:container/console', + views: { + 'content@': { + component: 'kubernetesConsoleView', + }, + }, + }; + + const applicationLogs = { + name: 'kubernetes.applications.application.logs', + url: '/:pod/:container/logs', + views: { + 'content@': { + component: 'kubernetesApplicationLogsView', + }, + }, + }; + + const applicationStats = { + name: 'kubernetes.applications.application.stats', + url: '/:pod/:container/stats', + views: { + 'content@': { + component: 'kubernetesApplicationStatsView', + }, + }, + }; + + const stacks = { + name: 'kubernetes.stacks', + url: '/stacks', + abstract: true, + }; + + const stack = { + name: 'kubernetes.stacks.stack', + url: '/:namespace/:name', + abstract: true, + }; + + const stackLogs = { + name: 'kubernetes.stacks.stack.logs', + url: '/logs', + views: { + 'content@': { + component: 'kubernetesStackLogsView', + }, + }, + }; + + const configurations = { + name: 'kubernetes.configurations', + url: '/configurations?tab', + views: { + 'content@': { + component: 'kubernetesConfigMapsAndSecretsView', + }, + }, + params: { + tab: null, + }, + data: { + docs: '/user/kubernetes/configurations', + }, + }; + const configmaps = { + name: 'kubernetes.configmaps', + url: '/configmaps', + abstract: true, + data: { + docs: '/user/kubernetes/configurations', + }, + }; + + const configMapCreation = { + name: 'kubernetes.configmaps.new', + url: '/new', + views: { + 'content@': { + component: 'kubernetesCreateConfigMapView', + }, + }, + data: { + docs: '/user/kubernetes/configurations/add-configmap', + }, + }; + + const configMap = { + name: 'kubernetes.configmaps.configmap', + url: '/:namespace/:name', + views: { + 'content@': { + component: 'kubernetesConfigMapView', + }, + }, + }; + + const secrets = { + name: 'kubernetes.secrets', + url: '/secrets', + abstract: true, + data: { + docs: '/user/kubernetes/configurations', + }, + }; + + const secretCreation = { + name: 'kubernetes.secrets.new', + url: '/new', + views: { + 'content@': { + component: 'kubernetesCreateSecretView', + }, + }, + data: { + docs: '/user/kubernetes/configurations/add-secret', + }, + }; + + const secret = { + name: 'kubernetes.secrets.secret', + url: '/:namespace/:name?tab', + params: { + tab: { dynamic: true }, + }, + views: { + 'content@': { + component: 'kubernetesSecretView', + }, + }, + }; + + const cluster = { + name: 'kubernetes.cluster', + url: '/cluster', + views: { + 'content@': { + component: 'kubernetesClusterView', + }, + }, + data: { + docs: '/user/kubernetes/cluster/details', + }, + }; + + const node = { + name: 'kubernetes.cluster.node', + url: '/:nodeName?tab', + views: { + 'content@': { + component: 'kubernetesNodeViewReact', + }, + }, + }; + + const nodeStats = { + name: 'kubernetes.cluster.node.stats', + url: '/stats', + views: { + 'content@': { + component: 'kubernetesNodeStatsView', + }, + }, + }; + + const kubectlShell = { + name: 'kubernetes.kubectlshell', + url: '/kubectl-shell', + views: { + 'content@': { + component: 'kubectlShellView', + }, + 'sidebar@': {}, + }, + }; + + const dashboard = { + name: 'kubernetes.dashboard', + url: '/dashboard', + views: { + 'content@': { + component: 'kubernetesDashboardView', + }, + }, + data: { + docs: '/user/kubernetes/dashboard', + }, + }; + + const deploy = { + name: 'kubernetes.deploy', + url: '/deploy?templateId&referrer&tab&buildMethod&chartName', + views: { + 'content@': { + component: 'kubernetesDeployView', + }, + }, + data: { + docs: '/user/kubernetes/applications/manifest', + }, + }; + + const namespaces = { + name: 'kubernetes.resourcePools', + url: '/namespaces', + views: { + 'content@': { + component: 'kubernetesNamespacesView', + }, + }, + data: { + docs: '/user/kubernetes/namespaces', + }, + }; + + const namespaceCreation = { + name: 'kubernetes.resourcePools.new', + url: '/new', + views: { + 'content@': { + component: 'kubernetesCreateNamespaceView', + }, + }, + data: { + docs: '/user/kubernetes/namespaces/add', + }, + }; + + const namespace = { + name: 'kubernetes.resourcePools.resourcePool', + url: '/:id?tab', + views: { + 'content@': { + component: 'namespaceView', + }, + }, + data: { + docs: '/user/kubernetes/namespaces/manage', + }, + }; + + const namespaceAccess = { + name: 'kubernetes.resourcePools.resourcePool.access', + url: '/access', + views: { + 'content@': { + component: 'kubernetesNamespaceAccessView', + }, + }, + data: { + docs: '/user/kubernetes/namespaces/access', + }, + }; + + const volumesBase = { + name: 'kubernetes.volumes', + url: '/volumes', + abstract: true, + }; + + const volumes = { + name: 'kubernetes.volumes.index', + url: '?tab', + views: { + 'content@': { + component: 'kubernetesVolumesView', + }, + }, + data: { + docs: '/user/kubernetes/volumes', + }, + params: { + tab: null, + }, + }; + + const volume = { + name: 'kubernetes.volumes.volume', + url: '/:namespace/:name', + views: { + 'content@': { + component: 'kubernetesVolumeView', + }, + }, + }; + + const persistentVolume = { + name: 'kubernetes.volumes.persistentVolume', + url: '/persistent-volumes/:name?tab', + views: { + 'content@': { + component: 'kubernetesResourceDetailsYAMLView', + }, + }, + data: { + resourceConfig: { + title: 'Persistent Volume details', + breadcrumbLabel: 'Volumes', + breadcrumbLink: 'kubernetes.volumes.index', + breadcrumbTab: 'volumes', + resourceType: 'persistentvolume', + apiVersion: 'v1', + resourcePlural: 'persistentvolumes', + namespaced: false, + yamlIdentifier: 'persistent-volume-yaml', + dataCy: 'persistent-volume-yaml', + }, + }, + }; + + const storageClass = { + name: 'kubernetes.volumes.storageClass', + url: '/storage-classes/:name?tab', + views: { + 'content@': { + component: 'kubernetesResourceDetailsYAMLView', + }, + }, + data: { + resourceConfig: { + title: 'Storage Class details', + breadcrumbLabel: 'Volumes', + breadcrumbLink: 'kubernetes.volumes.index', + breadcrumbTab: 'storage', + resourceType: 'storageclass', + apiVersion: 'storage.k8s.io/v1', + resourcePlural: 'storageclasses', + namespaced: false, + yamlIdentifier: 'storage-class-yaml', + dataCy: 'storage-class-yaml', + }, + }, + }; + + const registries = { + name: 'kubernetes.registries', + url: '/registries', + views: { + 'content@': { + component: 'environmentRegistriesView', + }, + }, + data: { + docs: '/user/kubernetes/cluster/registries', + }, + }; + + const registriesAccess = { + name: 'kubernetes.registries.access', + url: '/:id/access', + views: { + 'content@': { + component: 'kubernetesRegistryAccessView', + }, + }, + }; + + const endpointKubernetesConfiguration = { + name: 'kubernetes.cluster.setup', + url: '/configure', + views: { + 'content@': { + component: 'kubernetesConfigureView', + }, + }, + data: { + docs: '/user/kubernetes/cluster/setup', + }, + }; + + const endpointKubernetesSecurityConstraint = { + name: 'kubernetes.cluster.securityConstraint', + url: '/securityConstraint', + views: { + 'content@': { + templateUrl: '../kubernetes/views/security-constraint/constraint.html', + controller: 'KubernetesSecurityConstraintController', + }, + }, + data: { + docs: '/user/kubernetes/cluster/security', + }, + }; + + const moreResources = { + name: 'kubernetes.moreResources', + url: '/moreResources', + abstract: true, + }; + + const jobs = { + name: 'kubernetes.moreResources.jobs', + url: '/jobs?tab', + views: { + 'content@': { + component: 'jobsView', + }, + }, + data: { + docs: '/user/kubernetes/more-resources/jobs', + }, + }; + const job = { + name: 'kubernetes.moreResources.job', + url: '/jobs/:namespace/:name?tab', + views: { + 'content@': { + component: 'kubernetesResourceDetailsYAMLView', + }, + }, + data: { + resourceConfig: { + title: 'Job details', + breadcrumbLabel: 'Cron Jobs & Jobs', + breadcrumbLink: 'kubernetes.moreResources.jobs', + breadcrumbTab: 'jobs', + resourceType: 'job', + apiVersion: 'batch/v1', + resourcePlural: 'jobs', + namespaced: true, + yamlIdentifier: 'job-yaml', + dataCy: 'job-yaml', + }, + }, + }; + const cronJob = { + name: 'kubernetes.moreResources.cronJob', + url: '/cronjobs/:namespace/:name?tab', + views: { + 'content@': { + component: 'kubernetesResourceDetailsYAMLView', + }, + }, + data: { + resourceConfig: { + title: 'Cron Job details', + breadcrumbLabel: 'Cron Jobs & Jobs', + breadcrumbLink: 'kubernetes.moreResources.jobs', + breadcrumbTab: 'cronJobs', + resourceType: 'cronjob', + apiVersion: 'batch/v1', + resourcePlural: 'cronjobs', + namespaced: true, + yamlIdentifier: 'cronjob-yaml', + dataCy: 'cronjob-yaml', + }, + }, + }; + + const serviceAccounts = { + name: 'kubernetes.moreResources.serviceAccounts', + url: '/serviceAccounts', + views: { + 'content@': { + component: 'serviceAccountsView', + }, + }, + data: { + docs: '/user/kubernetes/more-resources/service-accounts', + }, + }; + + const serviceAccount = { + name: 'kubernetes.moreResources.serviceAccounts.serviceAccount', + url: '/serviceAccounts/:namespace/:name?tab', + views: { + 'content@': { + component: 'serviceAccountView', + }, + }, + data: { + docs: '/user/kubernetes/more-resources/service-accounts', + }, + }; + + const clusterRoles = { + name: 'kubernetes.moreResources.clusterRoles', + url: '/clusterRoles?tab', + views: { + 'content@': { + component: 'clusterRolesView', + }, + }, + data: { + docs: '/user/kubernetes/more-resources/cluster-roles', + }, + }; + const clusterRole = { + name: 'kubernetes.moreResources.clusterRole', + url: '/clusterRoles/:name?tab', + views: { + 'content@': { + component: 'kubernetesResourceDetailsYAMLView', + }, + }, + data: { + resourceConfig: { + title: 'Cluster Role details', + breadcrumbLabel: 'Cluster Roles', + breadcrumbLink: 'kubernetes.moreResources.clusterRoles', + breadcrumbTab: 'clusterRoles', + resourceType: 'clusterrole', + apiVersion: 'rbac.authorization.k8s.io/v1', + resourcePlural: 'clusterroles', + namespaced: false, + yamlIdentifier: 'cluster-role-yaml', + dataCy: 'cluster-role-yaml', + }, + }, + }; + const clusterRoleBinding = { + name: 'kubernetes.moreResources.clusterRoleBinding', + url: '/clusterRoleBindings/:name?tab', + views: { + 'content@': { + component: 'kubernetesResourceDetailsYAMLView', + }, + }, + data: { + resourceConfig: { + title: 'Cluster Role Binding details', + breadcrumbLabel: 'Cluster Roles', + breadcrumbLink: 'kubernetes.moreResources.clusterRoles', + breadcrumbTab: 'clusterRoleBindings', + resourceType: 'clusterrolebinding', + apiVersion: 'rbac.authorization.k8s.io/v1', + resourcePlural: 'clusterrolebindings', + namespaced: false, + yamlIdentifier: 'cluster-role-binding-yaml', + dataCy: 'cluster-role-binding-yaml', + }, + }, + }; + + const roles = { + name: 'kubernetes.moreResources.roles', + url: '/roles?tab', + views: { + 'content@': { + component: 'k8sRolesView', + }, + }, + data: { + docs: '/user/kubernetes/more-resources/namespace-roles', + }, + }; + const role = { + name: 'kubernetes.moreResources.role', + url: '/roles/:namespace/:name?tab', + views: { + 'content@': { + component: 'kubernetesResourceDetailsYAMLView', + }, + }, + data: { + resourceConfig: { + title: 'Role details', + breadcrumbLabel: 'Roles', + breadcrumbLink: 'kubernetes.moreResources.roles', + breadcrumbTab: 'roles', + resourceType: 'role', + apiVersion: 'rbac.authorization.k8s.io/v1', + resourcePlural: 'roles', + namespaced: true, + yamlIdentifier: 'role-yaml', + dataCy: 'role-yaml', + }, + }, + }; + const roleBinding = { + name: 'kubernetes.moreResources.roleBinding', + url: '/roleBindings/:namespace/:name?tab', + views: { + 'content@': { + component: 'kubernetesResourceDetailsYAMLView', + }, + }, + data: { + resourceConfig: { + title: 'Role Binding details', + breadcrumbLabel: 'Roles', + breadcrumbLink: 'kubernetes.moreResources.roles', + breadcrumbTab: 'roleBindings', + resourceType: 'rolebinding', + apiVersion: 'rbac.authorization.k8s.io/v1', + resourcePlural: 'rolebindings', + namespaced: true, + yamlIdentifier: 'role-binding-yaml', + dataCy: 'role-binding-yaml', + }, + }, + }; + + $stateRegistryProvider.register(kubernetes); + $stateRegistryProvider.register(helmApplication); + $stateRegistryProvider.register(applications); + $stateRegistryProvider.register(applicationCreation); + $stateRegistryProvider.register(application); + $stateRegistryProvider.register(applicationEdit); + $stateRegistryProvider.register(applicationConsole); + $stateRegistryProvider.register(applicationLogs); + $stateRegistryProvider.register(applicationStats); + $stateRegistryProvider.register(stacks); + $stateRegistryProvider.register(stack); + $stateRegistryProvider.register(stackLogs); + $stateRegistryProvider.register(configurations); + $stateRegistryProvider.register(configmaps); + $stateRegistryProvider.register(configMapCreation); + $stateRegistryProvider.register(secrets); + $stateRegistryProvider.register(secretCreation); + $stateRegistryProvider.register(configMap); + $stateRegistryProvider.register(secret); + $stateRegistryProvider.register(cluster); + $stateRegistryProvider.register(dashboard); + $stateRegistryProvider.register(deploy); + $stateRegistryProvider.register(helmInstall); + $stateRegistryProvider.register(node); + $stateRegistryProvider.register(nodeStats); + $stateRegistryProvider.register(kubectlShell); + $stateRegistryProvider.register(namespaces); + $stateRegistryProvider.register(namespaceCreation); + $stateRegistryProvider.register(namespace); + $stateRegistryProvider.register(namespaceAccess); + $stateRegistryProvider.register(volumesBase); + $stateRegistryProvider.register(volumes); + $stateRegistryProvider.register(volume); + $stateRegistryProvider.register(persistentVolume); + $stateRegistryProvider.register(storageClass); + $stateRegistryProvider.register(registries); + $stateRegistryProvider.register(registriesAccess); + $stateRegistryProvider.register(endpointKubernetesConfiguration); + $stateRegistryProvider.register(endpointKubernetesSecurityConstraint); + + $stateRegistryProvider.register(services); + $stateRegistryProvider.register(service); + $stateRegistryProvider.register(ingresses); + $stateRegistryProvider.register(ingressesCreate); + $stateRegistryProvider.register(ingressesEdit); + + $stateRegistryProvider.register(moreResources); + $stateRegistryProvider.register(jobs); + $stateRegistryProvider.register(job); + $stateRegistryProvider.register(cronJob); + $stateRegistryProvider.register(serviceAccounts); + $stateRegistryProvider.register(serviceAccount); + $stateRegistryProvider.register(clusterRoles); + $stateRegistryProvider.register(clusterRole); + $stateRegistryProvider.register(clusterRoleBinding); + $stateRegistryProvider.register(roles); + $stateRegistryProvider.register(role); + $stateRegistryProvider.register(roleBinding); + }, +]); diff --git a/app/kubernetes/components/kubernetes-configuration-data/kubernetesConfigurationData.html b/app/kubernetes/components/kubernetes-configuration-data/kubernetesConfigurationData.html new file mode 100644 index 0000000..193ce8b --- /dev/null +++ b/app/kubernetes/components/kubernetes-configuration-data/kubernetesConfigurationData.html @@ -0,0 +1,172 @@ + +
Data
+ +
+
+ + +
+
+ + Switch to advanced mode to copy and paste multiple key/values +
+
+ + Generate a ConfigMap entry per line, use YAML format +
+
+ + Generate a Secret entry per line, use YAML format +
+
+ +
+
+ + + + + +
+
+ +
+
+ +
+ +
+ +

This field is required.

+
+
+

+ This key is already defined. +

+
+

+ This key is invalid. A valid key must consist of alphanumeric characters, '-', '_' + or '.' +

+
+
+
+
+ +
+ +
+ +
+ +

This field is required.

+
+
+
+
+ +
+ +
Binary data
+
+ +
+
+
+ + + + This key is currently used by one or more applications + +
+
+
+ +
+ + + + +
+
diff --git a/app/kubernetes/components/kubernetes-configuration-data/kubernetesConfigurationData.js b/app/kubernetes/components/kubernetes-configuration-data/kubernetesConfigurationData.js new file mode 100644 index 0000000..8c9289f --- /dev/null +++ b/app/kubernetes/components/kubernetes-configuration-data/kubernetesConfigurationData.js @@ -0,0 +1,13 @@ +angular.module('portainer.kubernetes').component('kubernetesConfigurationData', { + templateUrl: './kubernetesConfigurationData.html', + controller: 'KubernetesConfigurationDataController', + bindings: { + formValues: '=', + isDockerConfig: '=', + onChangeValidation: '&', + isValid: '=', + isCreation: '=', + isEditorDirty: '=', + type: '<', + }, +}); diff --git a/app/kubernetes/components/kubernetes-configuration-data/kubernetesConfigurationDataController.js b/app/kubernetes/components/kubernetes-configuration-data/kubernetesConfigurationDataController.js new file mode 100644 index 0000000..5203766 --- /dev/null +++ b/app/kubernetes/components/kubernetes-configuration-data/kubernetesConfigurationDataController.js @@ -0,0 +1,243 @@ +import { Buffer } from 'buffer'; +import angular from 'angular'; +import _ from 'lodash-es'; +import chardet from 'chardet'; +import { Base64 } from 'js-base64'; +import KubernetesFormValidationHelper from '@/kubernetes/helpers/formValidationHelper'; +import KubernetesConfigurationHelper from '@/kubernetes/helpers/configurationHelper'; +import { KubernetesConfigurationFormValuesEntry } from '@/kubernetes/models/configuration/formvalues'; +import { KubernetesConfigurationKinds, KubernetesSecretTypeOptions } from '@/kubernetes/models/configuration/models'; + +class KubernetesConfigurationDataController { + /* @ngInject */ + constructor($async, Notifications) { + Object.assign(this, { $async, Notifications }); + + this.editorUpdate = this.editorUpdate.bind(this); + this.editorUpdateAsync = this.editorUpdateAsync.bind(this); + this.onFileLoad = this.onFileLoad.bind(this); + this.onFileLoadAsync = this.onFileLoadAsync.bind(this); + this.showSimpleMode = this.showSimpleMode.bind(this); + this.showAdvancedMode = this.showAdvancedMode.bind(this); + this.KubernetesConfigurationKinds = KubernetesConfigurationKinds; + this.KubernetesSecretTypeOptions = KubernetesSecretTypeOptions; + } + + onChangeKey(entry) { + if (entry && entry.Used) { + return; + } + + this.onChangeValidation(); + + this.state.duplicateKeys = KubernetesFormValidationHelper.getDuplicates(_.map(this.formValues.Data, (data) => data.Key)); + this.state.invalidKeys = KubernetesFormValidationHelper.getInvalidKeys(_.map(this.formValues.Data, (data) => data.Key)); + this.isValid = Object.keys(this.state.duplicateKeys).length === 0 && Object.keys(this.state.invalidKeys).length === 0; + } + + addEntry() { + this.formValues.Data.push(new KubernetesConfigurationFormValuesEntry()); + + // logic for setting required keys for new entries, based on the secret type + if (this.formValues.Kind === this.KubernetesConfigurationKinds.SECRET) { + const newDataIndex = this.formValues.Data.length - 1; + switch (this.formValues.Type) { + case this.KubernetesSecretTypeOptions.DOCKERCFG.value: + this.addMissingKeys(['dockercfg'], newDataIndex); + break; + case this.KubernetesSecretTypeOptions.DOCKERCONFIGJSON.value: + this.addMissingKeys(['.dockerconfigjson'], newDataIndex); + break; + case this.KubernetesSecretTypeOptions.BASICAUTH.value: + // only add a required key if there is no required key out of username and password + if (!this.formValues.Data.some((entry) => entry.Key === 'username' || entry.Key === 'password')) { + this.addMissingKeys(['username', 'password'], newDataIndex); + } + break; + case this.KubernetesSecretTypeOptions.SSHAUTH.value: + this.addMissingKeys(['ssh-privatekey'], newDataIndex); + break; + case this.KubernetesSecretTypeOptions.TLS.value: + this.addMissingKeys(['tls.crt', 'tls.key'], newDataIndex); + break; + case this.KubernetesSecretTypeOptions.BOOTSTRAPTOKEN.value: + this.addMissingKeys(['token-id', 'token-secret'], newDataIndex); + break; + default: + break; + } + } + + this.onChangeValidation(); + } + + // addMissingKeys adds the keys in the keys array to the entry at the index provided and stops when the first one is added + addMissingKeys(keys, newDataIndex) { + for (let key of keys) { + if (this.formValues.Data.every((entry) => entry.Key !== key)) { + this.formValues.Data[newDataIndex].Key = key; + return; + } + } + } + + isRequiredKey(key) { + if (this.formValues.Kind === this.KubernetesConfigurationKinds.SECRET) { + switch (this.formValues.Type) { + case this.KubernetesSecretTypeOptions.DOCKERCONFIGJSON.value: + if (key === '.dockerconfigjson') { + return true; + } + break; + case this.KubernetesSecretTypeOptions.DOCKERCFG.value: + if (key === '.dockercfg') { + return true; + } + break; + case this.KubernetesSecretTypeOptions.SSHAUTH.value: + if (key === 'ssh-privatekey') { + return true; + } + break; + case this.KubernetesSecretTypeOptions.TLS.value: + if (key === 'tls.crt' || key === 'tls.key') { + return true; + } + break; + case this.KubernetesSecretTypeOptions.BOOTSTRAPTOKEN.value: + if (key === 'token-id' || key === 'token-secret') { + return true; + } + break; + default: + break; + } + } + return false; + } + + removeEntry(index, entry) { + if (entry.Used) { + return; + } + + this.formValues.Data.splice(index, 1); + this.onChangeKey(); + } + + async editorUpdateAsync(value) { + if (this.formValues.DataYaml !== value) { + this.formValues.DataYaml = value; + this.isEditorDirty = true; + } + } + + editorUpdate(value) { + return this.$async(this.editorUpdateAsync, value); + } + + async onFileLoadAsync(event) { + // exit if the file is too big + const maximumFileSizeBytes = 1024 * 1024; // 1MB + if (event.target.result.byteLength > maximumFileSizeBytes) { + this.Notifications.error('File size is too big', 'File size is too big', 'Select a file that is 1MB or smaller.'); + return; + } + + const entry = new KubernetesConfigurationFormValuesEntry(); + try { + const encoding = chardet.detect(Buffer.from(event.target.result)); + const decoder = new TextDecoder(encoding); + + entry.IsBinary = KubernetesConfigurationHelper.isBinary(encoding); + + if (!entry.IsBinary) { + entry.Value = decoder.decode(event.target.result); + } else { + const stringValue = decoder.decode(event.target.result); + entry.Value = Base64.encode(stringValue); + } + } catch (error) { + this.Notifications.error('Failed to upload file', error, 'Failed to upload file'); + return; + } + + entry.Key = event.target.fileName; + + if (this.formValues.Kind === this.KubernetesConfigurationKinds.SECRET) { + if (this.isDockerConfig) { + if (this.formValues.Type === this.KubernetesSecretTypeOptions.DOCKERCFG.value) { + entry.Key = '.dockercfg'; + } else { + entry.Key = '.dockerconfigjson'; + } + } + + if (this.formValues.Type === this.KubernetesSecretTypeOptions.TLS.value) { + const isCrt = entry.Value.indexOf('BEGIN CERTIFICATE') !== -1; + if (isCrt) { + entry.Key = 'tls.crt'; + } + const isKey = entry.Value.indexOf('PRIVATE KEY') !== -1; + if (isKey) { + entry.Key = 'tls.key'; + } + } + } + + // if this.formValues.Data has a key that matches an existing key, then replace it + const existingEntryIndex = this.formValues.Data.findIndex((data) => data.Key === entry.Key || (data.Value === '' && data.Key === '')); + if (existingEntryIndex !== -1) { + this.formValues.Data[existingEntryIndex] = entry; + } else { + this.formValues.Data.push(entry); + } + + this.onChangeKey(); + } + + isEntryRequired() { + if (this.formValues.Kind === this.KubernetesConfigurationKinds.SECRET) { + if (this.formValues.Data.length === 1) { + if (this.formValues.Type !== this.KubernetesSecretTypeOptions.SERVICEACCOUNTTOKEN.value) { + return true; + } + } + } + return false; + } + + onFileLoad(event) { + return this.$async(this.onFileLoadAsync, event); + } + + addEntryFromFile(file) { + if (file) { + const temporaryFileReader = new FileReader(); + temporaryFileReader.fileName = file.name; + temporaryFileReader.onload = this.onFileLoad; + temporaryFileReader.readAsArrayBuffer(file); + } + } + + showSimpleMode() { + this.formValues.IsSimple = true; + this.formValues.Data = KubernetesConfigurationHelper.parseYaml(this.formValues); + this.onChangeKey(); + } + + showAdvancedMode() { + this.formValues.IsSimple = false; + this.formValues.DataYaml = KubernetesConfigurationHelper.parseData(this.formValues); + } + + $onInit() { + this.state = { + duplicateKeys: [], + invalidKeys: {}, + }; + } +} + +export default KubernetesConfigurationDataController; +angular.module('portainer.kubernetes').controller('KubernetesConfigurationDataController', KubernetesConfigurationDataController); diff --git a/app/kubernetes/components/resource-reservation/resourceReservation.html b/app/kubernetes/components/resource-reservation/resourceReservation.html new file mode 100644 index 0000000..013ca27 --- /dev/null +++ b/app/kubernetes/components/resource-reservation/resourceReservation.html @@ -0,0 +1,37 @@ +
+
Resource reservation
+
+ + + {{ $ctrl.description }} + +
+
+ +
+ + {{ $ctrl.memoryReservation }} / {{ $ctrl.memoryLimit }} MB - {{ $ctrl.memoryReservationPercent }}% +
+
+
+ +
+ + {{ $ctrl.memoryUsage }} / {{ $ctrl.memoryLimit }} MB - {{ $ctrl.memoryUsagePercent }}% +
+
+
+ +
+ + {{ $ctrl.cpuReservation | kubernetesApplicationCPUValue }} / {{ $ctrl.cpuLimit }} - {{ $ctrl.cpuReservationPercent }}% +
+
+
+ +
+ + {{ $ctrl.cpuUsage | kubernetesApplicationCPUValue }} / {{ $ctrl.cpuLimit }} - {{ $ctrl.cpuUsagePercent }}% +
+
+
diff --git a/app/kubernetes/components/resource-reservation/resourceReservation.js b/app/kubernetes/components/resource-reservation/resourceReservation.js new file mode 100644 index 0000000..d104fda --- /dev/null +++ b/app/kubernetes/components/resource-reservation/resourceReservation.js @@ -0,0 +1,14 @@ +angular.module('portainer.kubernetes').component('kubernetesResourceReservation', { + templateUrl: './resourceReservation.html', + controller: 'KubernetesResourceReservationController', + bindings: { + description: '@', + cpuReservation: '<', + cpuUsage: '<', + cpuLimit: '<', + memoryReservation: '<', + memoryUsage: '<', + memoryLimit: '<', + displayUsage: '<', + }, +}); diff --git a/app/kubernetes/components/view-loading/viewLoading.html b/app/kubernetes/components/view-loading/viewLoading.html new file mode 100644 index 0000000..edf23b8 --- /dev/null +++ b/app/kubernetes/components/view-loading/viewLoading.html @@ -0,0 +1,8 @@ +
+
+
+
+
+
+
+
diff --git a/app/kubernetes/components/view-loading/viewLoading.js b/app/kubernetes/components/view-loading/viewLoading.js new file mode 100644 index 0000000..0c4724b --- /dev/null +++ b/app/kubernetes/components/view-loading/viewLoading.js @@ -0,0 +1,6 @@ +angular.module('portainer.kubernetes').component('kubernetesViewLoading', { + templateUrl: './viewLoading.html', + bindings: { + viewReady: '<', + }, +}); diff --git a/app/kubernetes/converters/application.js b/app/kubernetes/converters/application.js new file mode 100644 index 0000000..fb25417 --- /dev/null +++ b/app/kubernetes/converters/application.js @@ -0,0 +1,380 @@ +import _ from 'lodash-es'; +import filesizeParser from 'filesize-parser'; + +import { KubernetesApplicationDataAccessPolicies, KubernetesApplicationDeploymentTypes, KubernetesApplicationTypes } from '@/kubernetes/models/application/models/appConstants'; +import { + KubernetesApplication, + KubernetesApplicationConfigurationVolume, + KubernetesApplicationPersistedFolder, + KubernetesApplicationPort, + KubernetesPortainerApplicationNameLabel, + KubernetesPortainerApplicationNote, + KubernetesPortainerApplicationOwnerLabel, + KubernetesPortainerApplicationStackNameLabel, + KubernetesPortainerApplicationStackIdLabel, + KubernetesPortainerApplicationKindLabel, +} from '@/kubernetes/models/application/models'; +import { KubernetesServiceTypes } from '@/kubernetes/models/service/models'; +import KubernetesResourceReservationHelper from '@/kubernetes/helpers/resourceReservationHelper'; +import { KubernetesApplicationFormValues } from '@/kubernetes/models/application/formValues'; +import KubernetesApplicationHelper from '@/kubernetes/helpers/application'; +import KubernetesDeploymentConverter from '@/kubernetes/converters/deployment'; +import KubernetesDaemonSetConverter from '@/kubernetes/converters/daemonSet'; +import KubernetesStatefulSetConverter from '@/kubernetes/converters/statefulSet'; +import KubernetesPodConverter from '@/kubernetes/pod/converter'; +import KubernetesServiceConverter from '@/kubernetes/converters/service'; +import KubernetesPersistentVolumeClaimConverter from '@/kubernetes/converters/persistentVolumeClaim'; +import PortainerError from '@/portainer/error'; +import { KubernetesIngressHelper } from '@/kubernetes/ingress/helper'; +import KubernetesCommonHelper from '@/kubernetes/helpers/commonHelper'; +import { KubernetesConfigurationKinds } from '@/kubernetes/models/configuration/models'; +import { parseCPU } from '@/react/kubernetes/utils'; + +function _apiPortsToPublishedPorts(pList, pRefs) { + const ports = _.map(pList, (item) => { + const res = new KubernetesApplicationPort(); + res.Port = item.port; + res.TargetPort = item.targetPort; + res.NodePort = item.nodePort; + res.Protocol = item.protocol; + return res; + }); + _.forEach(ports, (port) => { + if (isNaN(port.TargetPort)) { + const targetPort = _.find(pRefs, { name: port.TargetPort }); + if (targetPort) { + port.TargetPort = targetPort.containerPort; + } + } + }); + return ports; +} + +class KubernetesApplicationConverter { + static applicationCommon(res, data, pods, service, ingresses) { + const containers = data.spec.template ? _.without(_.concat(data.spec.template.spec.containers, data.spec.template.spec.initContainers), undefined) : data.spec.containers; + res.Id = data.metadata.uid; + res.Name = data.metadata.name; + res.Metadata = data.metadata; + res.ApplicationType = data.kind; + res.Labels = data.metadata.labels || {}; + + if (data.metadata.labels) { + const { labels } = data.metadata; + res.StackId = labels[KubernetesPortainerApplicationStackIdLabel] ? parseInt(labels[KubernetesPortainerApplicationStackIdLabel], 10) : null; + res.StackName = labels[KubernetesPortainerApplicationStackNameLabel] || ''; + res.ApplicationKind = labels[KubernetesPortainerApplicationKindLabel] || ''; + res.ApplicationOwner = labels[KubernetesPortainerApplicationOwnerLabel] || ''; + res.ApplicationName = labels[KubernetesPortainerApplicationNameLabel] || res.Name; + } + + res.Note = data.metadata.annotations ? data.metadata.annotations[KubernetesPortainerApplicationNote] || '' : ''; + res.ResourcePool = data.metadata.namespace; + if (containers.length) { + res.Image = containers[0].image; + } + if (data.spec.template && data.spec.template.spec && data.spec.template.spec.imagePullSecrets && data.spec.template.spec.imagePullSecrets.length) { + res.RegistryId = parseInt(data.spec.template.spec.imagePullSecrets[0].name.replace('registry-', ''), 10); + } + res.CreationDate = data.metadata.creationTimestamp; + res.Env = _.without(_.flatMap(_.map(containers, 'env')), undefined); + res.Pods = data.spec.selector ? KubernetesApplicationHelper.associatePodsAndApplication(pods, data.spec.selector) : [data]; + + const limits = { + Cpu: 0, + Memory: 0, + }; + res.Limits = _.reduce( + containers, + (acc, item) => { + if (item.resources.limits && item.resources.limits.cpu) { + acc.Cpu += parseCPU(item.resources.limits.cpu); + } + if (item.resources.limits && item.resources.limits.memory) { + acc.Memory += filesizeParser(item.resources.limits.memory, { base: 10 }); + } + return acc; + }, + limits + ); + + const requests = { + Cpu: 0, + Memory: 0, + }; + res.Requests = _.reduce( + containers, + (acc, item) => { + if (item.resources.requests && item.resources.requests.cpu) { + acc.Cpu += parseCPU(item.resources.requests.cpu); + } + if (item.resources.requests && item.resources.requests.memory) { + acc.Memory += filesizeParser(item.resources.requests.memory, { base: 10 }); + } + return acc; + }, + requests + ); + + if (service) { + const serviceType = service.spec.type; + res.ServiceType = serviceType; + res.ServiceId = service.metadata.uid; + res.ServiceName = service.metadata.name; + res.ClusterIp = service.spec.clusterIP; + res.ExternalIp = service.spec.externalIP; + + if (serviceType === KubernetesServiceTypes.LOAD_BALANCER) { + if (service.status.loadBalancer.ingress && service.status.loadBalancer.ingress.length > 0) { + res.LoadBalancerIPAddress = service.status.loadBalancer.ingress[0].ip || service.status.loadBalancer.ingress[0].hostname; + } + } + + const portsRefs = _.concat(..._.map(containers, (container) => container.ports)); + const ports = _apiPortsToPublishedPorts(service.spec.ports, portsRefs); + const rules = KubernetesIngressHelper.findSBoundServiceIngressesRules(ingresses, service.metadata.name); + _.forEach(ports, (port) => (port.IngressRules = _.filter(rules, (rule) => rule.Port === port.Port))); + res.PublishedPorts = ports; + } + + if (data.spec.template) { + res.Volumes = data.spec.template.spec.volumes ? data.spec.template.spec.volumes : []; + } else { + res.Volumes = data.spec.volumes; + } + + // TODO: review + // this if() fixs direct use of PVC reference inside spec.template.spec.containers[0].volumeMounts + // instead of referencing the PVC the "good way" using spec.template.spec.volumes array + // Basically it creates an "in-memory" reference for the PVC, as if it was saved in + // spec.template.spec.volumes and retrieved from here. + // + // FIX FOR SFS ONLY ; as far as we know it's not possible to do this with DEPLOYMENTS/DAEMONSETS + // + // This may lead to destructing behaviours when we will allow external apps to be edited. + // E.G. if we try to generate the formValues and patch the app, SFS reference will be created under + // spec.template.spec.volumes and not be referenced directly inside spec.template.spec.containers[0].volumeMounts + // As we preserve original SFS name and try to build around it, it SHOULD be fine, but we definitely need to test this + // before allowing external apps modification + if (data.spec.volumeClaimTemplates) { + const vcTemplates = _.map(data.spec.volumeClaimTemplates, (vc) => { + return { + name: vc.metadata.name, + persistentVolumeClaim: { claimName: vc.metadata.name }, + }; + }); + const inexistingPVC = _.filter(vcTemplates, (vc) => { + return !_.find(res.Volumes, { persistentVolumeClaim: { claimName: vc.persistentVolumeClaim.claimName } }); + }); + res.Volumes = _.concat(res.Volumes, inexistingPVC); + } + + const persistedFolders = _.filter(res.Volumes, (volume) => volume.persistentVolumeClaim || volume.hostPath); + + res.PersistedFolders = _.map(persistedFolders, (volume) => { + const volumeMounts = _.uniq(_.flatMap(_.map(containers, 'volumeMounts')), 'name'); + const matchingVolumeMount = _.find(volumeMounts, { name: volume.name }); + + if (matchingVolumeMount) { + const persistedFolder = new KubernetesApplicationPersistedFolder(); + persistedFolder.MountPath = matchingVolumeMount.mountPath; + + if (volume.persistentVolumeClaim) { + persistedFolder.persistentVolumeClaimName = volume.persistentVolumeClaim.claimName; + } else { + persistedFolder.HostPath = volume.hostPath.path; + } + + return persistedFolder; + } + }); + + res.PersistedFolders = _.without(res.PersistedFolders, undefined); + + res.ConfigurationVolumes = _.reduce( + res.Volumes, + (acc, volume) => { + if (volume.configMap || volume.secret) { + const matchingVolumeMount = _.find(_.flatMap(_.map(containers, 'volumeMounts')), { name: volume.name }); + + if (matchingVolumeMount) { + let items = []; + let configurationName = ''; + + if (volume.configMap) { + items = volume.configMap.items; + configurationName = volume.configMap.name; + } else { + items = volume.secret.items; + configurationName = volume.secret.secretName; + } + + if (!items) { + const configurationVolume = new KubernetesApplicationConfigurationVolume(); + configurationVolume.fileMountPath = matchingVolumeMount.mountPath; + configurationVolume.rootMountPath = matchingVolumeMount.mountPath; + configurationVolume.configurationName = configurationName; + configurationVolume.configurationType = volume.configMap ? KubernetesConfigurationKinds.CONFIGMAP : KubernetesConfigurationKinds.SECRET; + + acc.push(configurationVolume); + } else { + _.forEach(items, (item) => { + const configurationVolume = new KubernetesApplicationConfigurationVolume(); + configurationVolume.fileMountPath = matchingVolumeMount.mountPath + '/' + item.path; + configurationVolume.rootMountPath = matchingVolumeMount.mountPath; + configurationVolume.configurationKey = item.key; + configurationVolume.configurationName = configurationName; + configurationVolume.configurationType = volume.configMap ? KubernetesConfigurationKinds.CONFIGMAP : KubernetesConfigurationKinds.SECRET; + + acc.push(configurationVolume); + }); + } + } + } + + return acc; + }, + [] + ); + } + + static apiPodToApplication(data, pods, service, ingresses) { + const res = new KubernetesApplication(); + KubernetesApplicationConverter.applicationCommon(res, data, pods, service, ingresses); + res.ApplicationType = KubernetesApplicationTypes.Pod; + return res; + } + + static apiDeploymentToApplication(data, pods, service, ingresses) { + const res = new KubernetesApplication(); + KubernetesApplicationConverter.applicationCommon(res, data, pods, service, ingresses); + res.ApplicationType = KubernetesApplicationTypes.Deployment; + res.DeploymentType = KubernetesApplicationDeploymentTypes.Replicated; + res.DataAccessPolicy = KubernetesApplicationDataAccessPolicies.Shared; + res.RunningPodsCount = data.status.availableReplicas || data.status.replicas - data.status.unavailableReplicas || 0; + res.TotalPodsCount = data.spec.replicas; + return res; + } + + static apiDaemonSetToApplication(data, pods, service, ingresses) { + const res = new KubernetesApplication(); + KubernetesApplicationConverter.applicationCommon(res, data, pods, service, ingresses); + res.ApplicationType = KubernetesApplicationTypes.DaemonSet; + res.DeploymentType = KubernetesApplicationDeploymentTypes.Global; + res.DataAccessPolicy = KubernetesApplicationDataAccessPolicies.Shared; + res.RunningPodsCount = data.status.numberAvailable || data.status.desiredNumberScheduled - data.status.numberUnavailable || 0; + res.TotalPodsCount = data.status.desiredNumberScheduled; + return res; + } + + static apiStatefulSetToapplication(data, pods, service, ingresses) { + const res = new KubernetesApplication(); + KubernetesApplicationConverter.applicationCommon(res, data, pods, service, ingresses); + res.ApplicationType = KubernetesApplicationTypes.StatefulSet; + res.DeploymentType = KubernetesApplicationDeploymentTypes.Replicated; + res.DataAccessPolicy = KubernetesApplicationDataAccessPolicies.Isolated; + res.RunningPodsCount = data.status.readyReplicas || 0; + res.TotalPodsCount = data.spec.replicas; + res.HeadlessServiceName = data.spec.serviceName; + return res; + } + + static applicationToFormValues(app, resourcePools, configurations, persistentVolumeClaims, nodesLabels, ingresses) { + const res = new KubernetesApplicationFormValues(); + res.ApplicationType = app.ApplicationType; + res.ResourcePool = _.find(resourcePools, ['Namespace.Name', app.ResourcePool]); + res.Name = app.Name; + res.Labels = app.Labels; + res.Services = KubernetesApplicationHelper.generateServicesFormValuesFromServices(app, ingresses); + res.Selector = KubernetesApplicationHelper.generateSelectorFromService(app); + res.StackName = app.StackName; + res.ApplicationOwner = app.ApplicationOwner; + res.ImageModel.Image = app.Image; + res.ImageModel.Registry.Id = app.RegistryId; + res.ReplicaCount = app.TotalPodsCount; + res.MemoryLimit = KubernetesResourceReservationHelper.megaBytesValue(app.Limits.Memory); + res.CpuLimit = app.Limits.Cpu; + res.DeploymentType = app.DeploymentType; + res.DataAccessPolicy = app.DataAccessPolicy; + res.EnvironmentVariables = KubernetesApplicationHelper.generateEnvVariablesFromEnv(app.Env); + res.PersistedFolders = KubernetesApplicationHelper.generatePersistedFoldersFormValuesFromPersistedFolders(app.PersistedFolders, persistentVolumeClaims); // generate from PVC and app.PersistedFolders + res.Secrets = KubernetesApplicationHelper.generateConfigurationFormValuesFromEnvAndVolumes( + app.Env, + app.ConfigurationVolumes, + configurations, + KubernetesConfigurationKinds.SECRET + ); + res.ConfigMaps = KubernetesApplicationHelper.generateConfigurationFormValuesFromEnvAndVolumes( + app.Env, + app.ConfigurationVolumes, + configurations, + KubernetesConfigurationKinds.CONFIGMAP + ); + res.AutoScaler = KubernetesApplicationHelper.generateAutoScalerFormValueFromHorizontalPodAutoScaler(app.AutoScaler, res.ReplicaCount); + res.PublishedPorts = KubernetesApplicationHelper.generatePublishedPortsFormValuesFromPublishedPorts(app.ServiceType, app.PublishedPorts, ingresses); + res.Containers = app.Containers; + + res.PublishingType = app.ServiceType; + + if (app.Pods && app.Pods.length) { + KubernetesApplicationHelper.generatePlacementsFormValuesFromAffinity(res, app.Pods[0].Affinity); + } + + return res; + } + + static applicationFormValuesToApplication(formValues) { + formValues.ApplicationOwner = KubernetesCommonHelper.ownerToLabel(formValues.ApplicationOwner); + + const claims = KubernetesPersistentVolumeClaimConverter.applicationFormValuesToVolumeClaims(formValues); + const rwx = KubernetesApplicationHelper.hasRWX(claims); + + const deployment = + (formValues.DeploymentType === KubernetesApplicationDeploymentTypes.Replicated && + (claims.length === 0 || (claims.length > 0 && formValues.DataAccessPolicy === KubernetesApplicationDataAccessPolicies.Shared))) || + formValues.ApplicationType === KubernetesApplicationTypes.Deployment; + + const statefulSet = + (formValues.DeploymentType === KubernetesApplicationDeploymentTypes.Replicated && + claims.length > 0 && + formValues.DataAccessPolicy === KubernetesApplicationDataAccessPolicies.Isolated) || + formValues.ApplicationType === KubernetesApplicationTypes.StatefulSet; + + const daemonSet = + (formValues.DeploymentType === KubernetesApplicationDeploymentTypes.Global && + (claims.length === 0 || (claims.length > 0 && formValues.DataAccessPolicy === KubernetesApplicationDataAccessPolicies.Shared && rwx))) || + formValues.ApplicationType === KubernetesApplicationTypes.DaemonSet; + + const pod = formValues.ApplicationType === KubernetesApplicationTypes.Pod; + + let app; + if (deployment) { + app = KubernetesDeploymentConverter.applicationFormValuesToDeployment(formValues, claims); + } else if (statefulSet) { + app = KubernetesStatefulSetConverter.applicationFormValuesToStatefulSet(formValues, claims); + } else if (daemonSet) { + app = KubernetesDaemonSetConverter.applicationFormValuesToDaemonSet(formValues, claims); + } else if (pod) { + app = KubernetesPodConverter.applicationFormValuesToPod(formValues, claims); + } else { + throw new PortainerError('Unable to determine which association to use to convert form'); + } + app.ApplicationType = formValues.ApplicationType; + + let headlessService; + if (statefulSet) { + headlessService = KubernetesServiceConverter.applicationFormValuesToHeadlessService(formValues); + } + + let service = KubernetesServiceConverter.applicationFormValuesToService(formValues); + if (!service.Ports.length) { + service = undefined; + } + + let services = KubernetesServiceConverter.applicationFormValuesToServices(formValues); + + return [app, headlessService, services, service, claims]; + } +} + +export default KubernetesApplicationConverter; diff --git a/app/kubernetes/converters/configMap.js b/app/kubernetes/converters/configMap.js new file mode 100644 index 0000000..b1d1c5b --- /dev/null +++ b/app/kubernetes/converters/configMap.js @@ -0,0 +1,124 @@ +import _ from 'lodash-es'; +import { KubernetesConfigMap, KubernetesPortainerAccessConfigMap } from '@/kubernetes/models/config-map/models'; +import { KubernetesConfigMapCreatePayload, KubernetesConfigMapUpdatePayload } from '@/kubernetes/models/config-map/payloads'; +import { KubernetesConfigurationFormValuesEntry } from '@/kubernetes/models/configuration/formvalues'; +import { ConfigurationOwnerUsernameLabel } from '@/react/kubernetes/configs/constants'; +class KubernetesConfigMapConverter { + static apiToPortainerAccessConfigMap(data) { + const res = new KubernetesPortainerAccessConfigMap(); + res.Id = data.metadata.uid; + res.Data = data.data; + return res; + } + + static createAccessPayload(data) { + const res = new KubernetesConfigMapCreatePayload(); + _.unset(res, 'binaryData'); + res.metadata.name = data.Name; + res.metadata.namespace = data.Namespace; + res.data = data.Data; + return res; + } + + static updateAccessPayload(data) { + const res = KubernetesConfigMapConverter.createAccessPayload(data); + res.metadata.uid = data.Id; + return res; + } + + /** + * API ConfigMap to front ConfigMap + */ + static apiToConfigMap(data, yaml) { + const res = new KubernetesConfigMap(); + res.Id = data.metadata.uid; + res.Name = data.metadata.name; + res.Namespace = data.metadata.namespace; + res.ConfigurationOwner = data.metadata.labels ? data.metadata.labels[ConfigurationOwnerUsernameLabel] : ''; + res.CreationDate = data.metadata.creationTimestamp; + res.Yaml = yaml ? yaml.data : ''; + res.Labels = data.metadata.labels; + + res.Data = _.concat( + _.map(data.data, (value, key) => { + const entry = new KubernetesConfigurationFormValuesEntry(); + entry.Key = key; + entry.Value = value; + return entry; + }), + _.map(data.binaryData, (value, key) => { + const entry = new KubernetesConfigurationFormValuesEntry(); + entry.Key = key; + entry.Value = value; + entry.IsBinary = true; + return entry; + }) + ); + + return res; + } + + /** + * Generate a default ConfigMap Model + * with ID = 0 (showing it's a default) + * but setting his Namespace and Name + */ + static defaultConfigMap(namespace, name) { + const res = new KubernetesConfigMap(); + res.Name = name; + res.Namespace = namespace; + return res; + } + + /** + * CREATE payload + */ + static createPayload(data) { + const res = new KubernetesConfigMapCreatePayload(); + res.metadata.name = data.Name; + res.metadata.namespace = data.Namespace.Namespace.Name; + const configurationOwner = _.truncate(data.ConfigurationOwner, { length: 63, omission: '' }); + res.metadata.labels[ConfigurationOwnerUsernameLabel] = configurationOwner; + + _.forEach(data.Data, (entry) => { + if (entry.IsBinary) { + res.binaryData[entry.Key] = entry.Value; + } else { + res.data[entry.Key] = entry.Value; + } + }); + return res; + } + + /** + * UPDATE payload + */ + static updatePayload(data) { + const res = new KubernetesConfigMapUpdatePayload(); + res.metadata.uid = data.Id; + res.metadata.name = data.Name; + res.metadata.namespace = data.Namespace; + res.metadata.labels = data.Labels || {}; + res.metadata.labels[ConfigurationOwnerUsernameLabel] = data.ConfigurationOwner; + _.forEach(data.Data, (entry) => { + if (entry.IsBinary) { + res.binaryData[entry.Key] = entry.Value; + } else { + res.data[entry.Key] = entry.Value; + } + }); + return res; + } + + static configurationFormValuesToConfigMap(formValues) { + const res = new KubernetesConfigMap(); + res.Id = formValues.Id; + res.Name = formValues.Name; + res.Namespace = formValues.ResourcePool; + res.ConfigurationOwner = formValues.ConfigurationOwner; + res.Data = formValues.Data; + return res; + } +} + +export default KubernetesConfigMapConverter; diff --git a/app/kubernetes/converters/configuration.js b/app/kubernetes/converters/configuration.js new file mode 100644 index 0000000..a10010f --- /dev/null +++ b/app/kubernetes/converters/configuration.js @@ -0,0 +1,57 @@ +import _ from 'lodash-es'; +import { KubernetesConfiguration, KubernetesConfigurationKinds } from '@/kubernetes/models/configuration/models'; + +class KubernetesConfigurationConverter { + static secretToConfiguration(secret) { + const res = new KubernetesConfiguration(); + res.Kind = KubernetesConfigurationKinds.SECRET; + res.kind = 'Secret'; + res.Id = secret.Id; + res.Name = secret.Name; + res.Type = secret.Type; + res.Namespace = secret.Namespace; + res.CreationDate = secret.CreationDate; + res.Yaml = secret.Yaml; + _.forEach(secret.Data, (entry) => { + res.Data[entry.Key] = entry.Value; + }); + res.data = res.Data; + res.ConfigurationOwner = secret.ConfigurationOwner; + res.IsRegistrySecret = secret.IsRegistrySecret; + res.SecretType = secret.SecretType; + if (secret.Annotations) { + const serviceAccountKey = 'kubernetes.io/service-account.name'; + if (typeof secret.Annotations === 'object') { + res.ServiceAccountName = secret.Annotations[serviceAccountKey]; + } else if (Array.isArray(secret.Annotations)) { + const serviceAccountAnnotation = secret.Annotations.find((a) => a.key === 'kubernetes.io/service-account.name'); + res.ServiceAccountName = serviceAccountAnnotation ? serviceAccountAnnotation.value : undefined; + } else { + res.ServiceAccountName = undefined; + } + } + res.Annotations = secret.Annotations; + res.Labels = secret.Labels; + return res; + } + + static configMapToConfiguration(configMap) { + const res = new KubernetesConfiguration(); + res.Kind = KubernetesConfigurationKinds.CONFIGMAP; + res.kind = 'ConfigMap'; + res.Id = configMap.Id; + res.Name = configMap.Name; + res.Namespace = configMap.Namespace; + res.CreationDate = configMap.CreationDate; + res.Yaml = configMap.Yaml; + _.forEach(configMap.Data, (entry) => { + res.Data[entry.Key] = entry.Value; + }); + res.data = res.Data; + res.ConfigurationOwner = configMap.ConfigurationOwner; + res.Labels = configMap.Labels; + return res; + } +} + +export default KubernetesConfigurationConverter; diff --git a/app/kubernetes/converters/daemonSet.js b/app/kubernetes/converters/daemonSet.js new file mode 100644 index 0000000..fb90622 --- /dev/null +++ b/app/kubernetes/converters/daemonSet.js @@ -0,0 +1,88 @@ +import * as JsonPatch from 'fast-json-patch'; +import { KubernetesDaemonSet } from '@/kubernetes/models/daemon-set/models'; +import { KubernetesDaemonSetCreatePayload } from '@/kubernetes/models/daemon-set/payloads'; +import { + KubernetesPortainerApplicationStackNameLabel, + KubernetesPortainerApplicationNameLabel, + KubernetesPortainerApplicationNote, + KubernetesPortainerApplicationOwnerLabel, +} from '@/kubernetes/models/application/models'; +import KubernetesApplicationHelper from '@/kubernetes/helpers/application'; +import KubernetesResourceReservationHelper from '@/kubernetes/helpers/resourceReservationHelper'; +import KubernetesCommonHelper from '@/kubernetes/helpers/commonHelper'; +import { buildImageFullURIFromModel } from '@/react/docker/images/utils'; + +class KubernetesDaemonSetConverter { + /** + * Generate KubernetesDaemonSet from KubernetesApplicationFormValues + * @param {KubernetesApplicationFormValues} formValues + */ + static applicationFormValuesToDaemonSet(formValues, volumeClaims) { + const res = new KubernetesDaemonSet(); + res.Namespace = formValues.ResourcePool.Namespace.Name; + res.Name = formValues.Name; + if (formValues.StackName) { + res.StackName = formValues.StackName; + } + res.ApplicationOwner = formValues.ApplicationOwner; + res.ApplicationName = formValues.Name; + res.ImageModel = formValues.ImageModel; + res.CpuLimit = formValues.CpuLimit; + res.MemoryLimit = KubernetesResourceReservationHelper.bytesValue(formValues.MemoryLimit); + res.Env = KubernetesApplicationHelper.generateEnvFromEnvVariables(formValues.EnvironmentVariables); + KubernetesApplicationHelper.generateVolumesFromPersistentVolumClaims(res, volumeClaims); + KubernetesApplicationHelper.generateEnvOrVolumesFromConfigurations(res, formValues.ConfigMaps, formValues.Secrets); + KubernetesApplicationHelper.generateAffinityFromPlacements(res, formValues); + return res; + } + + /** + * Generate CREATE payload from DaemonSet + * @param {KubernetesDaemonSetPayload} model DaemonSet to generate payload from + */ + static createPayload(daemonSet) { + const payload = new KubernetesDaemonSetCreatePayload(); + payload.metadata.name = daemonSet.Name; + payload.metadata.namespace = daemonSet.Namespace; + if (daemonSet.StackName) { + payload.metadata.labels[KubernetesPortainerApplicationStackNameLabel] = daemonSet.StackName; + } + payload.metadata.labels[KubernetesPortainerApplicationNameLabel] = daemonSet.ApplicationName; + payload.metadata.labels[KubernetesPortainerApplicationOwnerLabel] = daemonSet.ApplicationOwner; + payload.metadata.annotations[KubernetesPortainerApplicationNote] = daemonSet.Note; + payload.spec.replicas = daemonSet.ReplicaCount; + payload.spec.selector.matchLabels.app = daemonSet.Name; + payload.spec.template.metadata.labels.app = daemonSet.Name; + payload.spec.template.metadata.labels[KubernetesPortainerApplicationNameLabel] = daemonSet.ApplicationName; + payload.spec.template.spec.containers[0].name = daemonSet.Name; + payload.spec.template.spec.containers[0].image = buildImageFullURIFromModel(daemonSet.ImageModel); + if (daemonSet.ImageModel.Registry && daemonSet.ImageModel.Registry.Authentication) { + payload.spec.template.spec.imagePullSecrets = [{ name: `registry-${daemonSet.ImageModel.Registry.Id}` }]; + } + payload.spec.template.spec.affinity = daemonSet.Affinity; + KubernetesCommonHelper.assignOrDeleteIfEmpty(payload, 'spec.template.spec.containers[0].env', daemonSet.Env); + KubernetesCommonHelper.assignOrDeleteIfEmpty(payload, 'spec.template.spec.containers[0].volumeMounts', daemonSet.VolumeMounts); + KubernetesCommonHelper.assignOrDeleteIfEmpty(payload, 'spec.template.spec.volumes', daemonSet.Volumes); + if (daemonSet.MemoryLimit) { + payload.spec.template.spec.containers[0].resources.limits.memory = daemonSet.MemoryLimit; + payload.spec.template.spec.containers[0].resources.requests.memory = daemonSet.MemoryLimit; + } + if (daemonSet.CpuLimit) { + payload.spec.template.spec.containers[0].resources.limits.cpu = daemonSet.CpuLimit; + payload.spec.template.spec.containers[0].resources.requests.cpu = daemonSet.CpuLimit; + } + if (!daemonSet.CpuLimit && !daemonSet.MemoryLimit) { + delete payload.spec.template.spec.containers[0].resources; + } + return payload; + } + + static patchPayload(oldDaemonSet, newDaemonSet) { + const oldPayload = KubernetesDaemonSetConverter.createPayload(oldDaemonSet); + const newPayload = KubernetesDaemonSetConverter.createPayload(newDaemonSet); + const payload = JsonPatch.compare(oldPayload, newPayload); + return payload; + } +} + +export default KubernetesDaemonSetConverter; diff --git a/app/kubernetes/converters/deployment.js b/app/kubernetes/converters/deployment.js new file mode 100644 index 0000000..145f163 --- /dev/null +++ b/app/kubernetes/converters/deployment.js @@ -0,0 +1,96 @@ +import * as JsonPatch from 'fast-json-patch'; +import { KubernetesDeployment } from '@/kubernetes/models/deployment/models'; +import { KubernetesDeploymentCreatePayload } from '@/kubernetes/models/deployment/payloads'; +import { + KubernetesPortainerApplicationStackNameLabel, + KubernetesPortainerApplicationNameLabel, + KubernetesPortainerApplicationOwnerLabel, + KubernetesPortainerApplicationNote, +} from '@/kubernetes/models/application/models'; + +import KubernetesApplicationHelper from '@/kubernetes/helpers/application'; +import KubernetesResourceReservationHelper from '@/kubernetes/helpers/resourceReservationHelper'; +import KubernetesCommonHelper from '@/kubernetes/helpers/commonHelper'; +import { buildImageFullURIFromModel } from '@/react/docker/images/utils'; + +class KubernetesDeploymentConverter { + /** + * Generate KubernetesDeployment from KubernetesApplicationFormValues + * @param {KubernetesApplicationFormValues} formValues + */ + static applicationFormValuesToDeployment(formValues, volumeClaims) { + const res = new KubernetesDeployment(); + res.Namespace = formValues.ResourcePool.Namespace.Name; + res.Name = formValues.Name; + if (formValues.StackName) { + res.StackName = formValues.StackName; + } + res.ApplicationOwner = formValues.ApplicationOwner; + res.ApplicationName = formValues.Name; + res.ReplicaCount = formValues.ReplicaCount; + res.ImageModel = formValues.ImageModel; + res.CpuLimit = formValues.CpuLimit; + res.MemoryLimit = KubernetesResourceReservationHelper.bytesValue(formValues.MemoryLimit); + res.Env = KubernetesApplicationHelper.generateEnvFromEnvVariables(formValues.EnvironmentVariables); + res.Containers = formValues.Containers; + KubernetesApplicationHelper.generateVolumesFromPersistentVolumClaims(res, volumeClaims); + KubernetesApplicationHelper.generateEnvOrVolumesFromConfigurations(res, formValues.ConfigMaps, formValues.Secrets); + KubernetesApplicationHelper.generateAffinityFromPlacements(res, formValues); + return res; + } + + /** + * Generate CREATE payload from Deployment + * @param {KubernetesDeploymentPayload} model Deployment to genereate payload from + */ + static createPayload(deployment) { + const payload = new KubernetesDeploymentCreatePayload(); + payload.metadata.name = deployment.Name; + payload.metadata.namespace = deployment.Namespace; + if (deployment.StackName) { + payload.metadata.labels[KubernetesPortainerApplicationStackNameLabel] = deployment.StackName; + } + payload.metadata.labels[KubernetesPortainerApplicationNameLabel] = deployment.ApplicationName; + payload.metadata.labels[KubernetesPortainerApplicationOwnerLabel] = deployment.ApplicationOwner; + payload.metadata.annotations[KubernetesPortainerApplicationNote] = deployment.Note; + payload.spec.replicas = deployment.ReplicaCount; + payload.spec.selector.matchLabels.app = deployment.Name; + payload.spec.template.metadata.labels.app = deployment.Name; + payload.spec.template.metadata.labels[KubernetesPortainerApplicationNameLabel] = deployment.ApplicationName; + payload.spec.template.spec.containers[0].name = deployment.Name; + + if (deployment.ImageModel) { + payload.spec.template.spec.containers[0].image = buildImageFullURIFromModel(deployment.ImageModel); + + if (deployment.ImageModel.Registry && deployment.ImageModel.Registry.Authentication) { + payload.spec.template.spec.imagePullSecrets = [{ name: `registry-${deployment.ImageModel.Registry.Id}` }]; + } + } + + payload.spec.template.spec.affinity = deployment.Affinity; + KubernetesCommonHelper.assignOrDeleteIfEmpty(payload, 'spec.template.spec.containers[0].env', deployment.Env); + KubernetesCommonHelper.assignOrDeleteIfEmpty(payload, 'spec.template.spec.containers[0].volumeMounts', deployment.VolumeMounts); + KubernetesCommonHelper.assignOrDeleteIfEmpty(payload, 'spec.template.spec.volumes', deployment.Volumes); + if (deployment.MemoryLimit) { + payload.spec.template.spec.containers[0].resources.limits.memory = deployment.MemoryLimit; + payload.spec.template.spec.containers[0].resources.requests.memory = deployment.MemoryLimit; + } + if (deployment.CpuLimit) { + payload.spec.template.spec.containers[0].resources.limits.cpu = deployment.CpuLimit; + payload.spec.template.spec.containers[0].resources.requests.cpu = deployment.CpuLimit; + } + if (!deployment.CpuLimit && !deployment.MemoryLimit) { + delete payload.spec.template.spec.containers[0].resources; + } + return payload; + } + + static patchPayload(oldDeployment, newDeployment) { + const oldPayload = KubernetesDeploymentConverter.createPayload(oldDeployment); + const newPayload = KubernetesDeploymentConverter.createPayload(newDeployment); + const payload = JsonPatch.compare(oldPayload, newPayload); + return payload; + } +} + +export default KubernetesDeploymentConverter; diff --git a/app/kubernetes/converters/event.js b/app/kubernetes/converters/event.js new file mode 100644 index 0000000..fdc17e4 --- /dev/null +++ b/app/kubernetes/converters/event.js @@ -0,0 +1,15 @@ +import { KubernetesEvent } from '@/kubernetes/models/event/models'; + +class KubernetesEventConverter { + static apiToEvent(data) { + const res = new KubernetesEvent(); + res.Id = data.metadata.uid; + res.Date = data.lastTimestamp || data.eventTime; + res.Type = data.type; + res.Message = data.message; + res.Involved = data.involvedObject; + return res; + } +} + +export default KubernetesEventConverter; diff --git a/app/kubernetes/converters/namespace.js b/app/kubernetes/converters/namespace.js new file mode 100644 index 0000000..1d85be8 --- /dev/null +++ b/app/kubernetes/converters/namespace.js @@ -0,0 +1,43 @@ +import _ from 'lodash-es'; +import { KubernetesNamespace } from '@/kubernetes/models/namespace/models'; +import { KubernetesNamespaceCreatePayload } from '@/kubernetes/models/namespace/payloads'; +import { + KubernetesPortainerResourcePoolNameLabel, + KubernetesPortainerResourcePoolOwnerLabel, + KubernetesPortainerNamespaceSystemLabel, +} from '@/kubernetes/models/resource-pool/models'; +import KubernetesNamespaceHelper from '@/kubernetes/helpers/namespaceHelper'; + +export default class KubernetesNamespaceConverter { + static apiToNamespace(data, yaml) { + const res = new KubernetesNamespace(); + res.Id = data.metadata.uid; + res.Name = data.metadata.name; + res.CreationDate = data.metadata.creationTimestamp; + res.Status = data.status.phase; + res.Yaml = yaml ? yaml.data : ''; + res.ResourcePoolName = data.metadata.labels ? data.metadata.labels[KubernetesPortainerResourcePoolNameLabel] : ''; + res.ResourcePoolOwner = data.metadata.labels ? data.metadata.labels[KubernetesPortainerResourcePoolOwnerLabel] : ''; + + res.IsSystem = KubernetesNamespaceHelper.isDefaultSystemNamespace(data.metadata.name); + if (data.metadata.labels) { + const systemLabel = data.metadata.labels[KubernetesPortainerNamespaceSystemLabel]; + if (!_.isEmpty(systemLabel)) { + res.IsSystem = systemLabel === 'true'; + } + } + return res; + } + + static createPayload(namespace) { + const res = new KubernetesNamespaceCreatePayload(); + res.metadata.name = namespace.Name; + res.metadata.labels[KubernetesPortainerResourcePoolNameLabel] = namespace.ResourcePoolName; + + if (namespace.ResourcePoolOwner) { + const resourcePoolOwner = _.truncate(namespace.ResourcePoolOwner, { length: 63, omission: '' }); + res.metadata.labels[KubernetesPortainerResourcePoolOwnerLabel] = resourcePoolOwner; + } + return res; + } +} diff --git a/app/kubernetes/converters/persistentVolumeClaim.js b/app/kubernetes/converters/persistentVolumeClaim.js new file mode 100644 index 0000000..06b502f --- /dev/null +++ b/app/kubernetes/converters/persistentVolumeClaim.js @@ -0,0 +1,90 @@ +import _ from 'lodash-es'; +import * as JsonPatch from 'fast-json-patch'; + +import { KubernetesPersistentVolumeClaim } from '@/kubernetes/models/volume/models'; +import { KubernetesPersistentVolumClaimCreatePayload } from '@/kubernetes/models/volume/payloads'; +import { KubernetesPortainerApplicationOwnerLabel, KubernetesPortainerApplicationNameLabel } from '@/kubernetes/models/application/models'; + +const storageClassToPVCAccessModes = { + RWO: 'ReadWriteOnce', + RWX: 'ReadWriteMany', +}; + +class KubernetesPersistentVolumeClaimConverter { + static apiToPersistentVolumeClaim(data, storageClasses, yaml) { + const res = new KubernetesPersistentVolumeClaim(); + res.Id = data.metadata.uid; + res.Name = data.metadata.name; + res.Namespace = data.metadata.namespace; + res.CreationDate = data.metadata.creationTimestamp; + res.Storage = `${data.spec.resources.requests.storage}B`; + res.AccessModes = data.spec.accessModes || []; + res.storageClass = _.find(storageClasses, { Name: data.spec.storageClassName }); + res.Yaml = yaml ? yaml.data : ''; + res.ApplicationOwner = data.metadata.labels ? data.metadata.labels[KubernetesPortainerApplicationOwnerLabel] : ''; + res.ApplicationName = data.metadata.labels ? data.metadata.labels[KubernetesPortainerApplicationNameLabel] : ''; + return res; + } + + /** + * Generate KubernetesPersistentVolumeClaim list from KubernetesApplicationFormValues + * @param {KubernetesApplicationFormValues} formValues + */ + static applicationFormValuesToVolumeClaims(formValues) { + _.remove(formValues.PersistedFolders, (item) => item.needsDeletion); + const res = _.map(formValues.PersistedFolders, (item) => { + const pvc = new KubernetesPersistentVolumeClaim(); + if (!_.isEmpty(item.existingVolume)) { + const existantPVC = item.existingVolume.PersistentVolumeClaim; + pvc.Name = existantPVC.Name; + if (item.persistentVolumeClaimName) { + pvc.PreviousName = item.persistentVolumeClaimName; + } + pvc.storageClass = existantPVC.storageClass; + pvc.Storage = existantPVC.Storage.charAt(0); + pvc.CreationDate = existantPVC.CreationDate; + pvc.Id = existantPVC.Id; + } else { + if (item.persistentVolumeClaimName) { + pvc.Name = item.persistentVolumeClaimName; + if (!item.useNewVolume) { + pvc.PreviousName = item.persistentVolumeClaimName; + } + } else { + pvc.Name = formValues.Name + '-' + pvc.Name; + } + pvc.Storage = '' + item.size + item.sizeUnit.charAt(0); + pvc.storageClass = item.storageClass; + } + pvc.MountPath = item.containerPath; + pvc.Namespace = formValues.ResourcePool.Namespace.Name; + pvc.ApplicationOwner = formValues.ApplicationOwner; + pvc.ApplicationName = formValues.Name; + return pvc; + }); + return res; + } + + static createPayload(pvc) { + const res = new KubernetesPersistentVolumClaimCreatePayload(); + res.metadata.name = pvc.Name; + res.metadata.namespace = pvc.Namespace; + res.spec.resources.requests.storage = pvc.Storage; + res.spec.storageClassName = pvc.storageClass ? pvc.storageClass.Name : ''; + const accessModes = pvc.storageClass && pvc.storageClass.AccessModes ? pvc.storageClass.AccessModes.map((accessMode) => storageClassToPVCAccessModes[accessMode]) : []; + res.spec.accessModes = accessModes; + res.metadata.labels.app = pvc.ApplicationName; + res.metadata.labels[KubernetesPortainerApplicationOwnerLabel] = pvc.ApplicationOwner; + res.metadata.labels[KubernetesPortainerApplicationNameLabel] = pvc.ApplicationName; + return res; + } + + static patchPayload(oldPVC, newPVC) { + const oldPayload = KubernetesPersistentVolumeClaimConverter.createPayload(oldPVC); + const newPayload = KubernetesPersistentVolumeClaimConverter.createPayload(newPVC); + const payload = JsonPatch.compare(oldPayload, newPayload); + return payload; + } +} + +export default KubernetesPersistentVolumeClaimConverter; diff --git a/app/kubernetes/converters/resourcePool.js b/app/kubernetes/converters/resourcePool.js new file mode 100644 index 0000000..3c6498c --- /dev/null +++ b/app/kubernetes/converters/resourcePool.js @@ -0,0 +1,45 @@ +import _ from 'lodash-es'; + +import { KubernetesResourcePool } from '@/kubernetes/models/resource-pool/models'; +import { KubernetesNamespace } from '@/kubernetes/models/namespace/models'; +import { KubernetesIngressConverter } from '@/kubernetes/ingress/converter'; +import KubernetesResourceQuotaConverter from './resourceQuota'; + +class KubernetesResourcePoolConverter { + static apiToResourcePool(namespace) { + const res = new KubernetesResourcePool(); + res.Namespace = namespace; + res.Yaml = namespace.Yaml; + return res; + } + + static formValuesToResourcePool(formValues) { + const namespace = new KubernetesNamespace(); + namespace.Name = formValues.Name; + namespace.ResourcePoolName = formValues.Name; + namespace.ResourcePoolOwner = formValues.Owner; + namespace.IsSystem = formValues.IsSystem; + + const quota = KubernetesResourceQuotaConverter.resourcePoolFormValuesToResourceQuota(formValues); + + const ingMap = _.map(formValues.IngressClasses, (c) => { + if (c.Selected) { + c.Namespace = namespace.Name; + return KubernetesIngressConverter.resourcePoolIngressClassFormValueToIngress(c); + } + }); + const ingresses = _.without(ingMap, undefined); + const registries = _.map(formValues.Registries, (r) => { + if (!r.RegistryAccesses[formValues.EndpointId]) { + r.RegistryAccesses[formValues.EndpointId] = { Namespaces: [] }; + } + if (!_.includes(r.RegistryAccesses[formValues.EndpointId].Namespaces, formValues.Name)) { + r.RegistryAccesses[formValues.EndpointId].Namespaces = [...r.RegistryAccesses[formValues.EndpointId].Namespaces, formValues.Name]; + } + return r; + }); + return [namespace, quota, ingresses, registries]; + } +} + +export default KubernetesResourcePoolConverter; diff --git a/app/kubernetes/converters/resourceQuota.js b/app/kubernetes/converters/resourceQuota.js new file mode 100644 index 0000000..c3a0b3e --- /dev/null +++ b/app/kubernetes/converters/resourceQuota.js @@ -0,0 +1,104 @@ +import * as JsonPatch from 'fast-json-patch'; +import filesizeParser from 'filesize-parser'; + +import { + KubernetesResourceQuota, + KubernetesPortainerResourceQuotaCPULimit, + KubernetesPortainerResourceQuotaMemoryLimit, + KubernetesPortainerResourceQuotaCPURequest, + KubernetesPortainerResourceQuotaMemoryRequest, + KubernetesResourceQuotaDefaults, +} from '@/kubernetes/models/resource-quota/models'; +import { KubernetesResourceQuotaCreatePayload } from '@/kubernetes/models/resource-quota/payloads'; +import KubernetesResourceQuotaHelper from '@/kubernetes/helpers/resourceQuotaHelper'; +import { KubernetesPortainerResourcePoolNameLabel, KubernetesPortainerResourcePoolOwnerLabel } from '@/kubernetes/models/resource-pool/models'; +import KubernetesResourceReservationHelper from '@/kubernetes/helpers/resourceReservationHelper'; +import KubernetesCommonHelper from '@/kubernetes/helpers/commonHelper'; +import { KubernetesResourcePoolFormValues } from '@/kubernetes/models/resource-pool/formValues'; +import { parseCPU } from '@/react/kubernetes/utils'; + +class KubernetesResourceQuotaConverter { + static apiToResourceQuota(data, yaml) { + const res = new KubernetesResourceQuota(); + res.Id = data.metadata.uid; + res.Namespace = data.metadata.namespace; + res.Name = data.metadata.name; + res.CpuLimit = 0; + res.MemoryLimit = 0; + if (data.spec.hard && data.spec.hard[KubernetesPortainerResourceQuotaCPULimit]) { + res.CpuLimit = parseCPU(data.spec.hard[KubernetesPortainerResourceQuotaCPULimit]); + } + if (data.spec.hard && data.spec.hard[KubernetesPortainerResourceQuotaMemoryLimit]) { + res.MemoryLimit = filesizeParser(data.spec.hard[KubernetesPortainerResourceQuotaMemoryLimit], { base: 10 }); + } + + res.MemoryLimitUsed = 0; + if (data.status.used && data.status.used[KubernetesPortainerResourceQuotaMemoryLimit]) { + res.MemoryLimitUsed = filesizeParser(data.status.used[KubernetesPortainerResourceQuotaMemoryLimit], { base: 10 }); + } + + res.CpuLimitUsed = 0; + if (data.status.used && data.status.used[KubernetesPortainerResourceQuotaCPULimit]) { + res.CpuLimitUsed = parseCPU(data.status.used[KubernetesPortainerResourceQuotaCPULimit]); + } + res.Yaml = yaml ? yaml.data : ''; + res.ResourcePoolName = data.metadata.labels ? data.metadata.labels[KubernetesPortainerResourcePoolNameLabel] : ''; + res.ResourcePoolOwner = data.metadata.labels ? data.metadata.labels[KubernetesPortainerResourcePoolOwnerLabel] : ''; + return res; + } + + static createPayload(quota) { + const res = new KubernetesResourceQuotaCreatePayload(); + res.metadata.name = KubernetesResourceQuotaHelper.generateResourceQuotaName(quota.Namespace); + res.metadata.namespace = quota.Namespace; + KubernetesCommonHelper.assignOrDeleteIfEmptyOrZero(res, `spec.hard['${KubernetesPortainerResourceQuotaCPURequest}']`, quota.CpuLimit); + KubernetesCommonHelper.assignOrDeleteIfEmptyOrZero(res, `spec.hard['${KubernetesPortainerResourceQuotaMemoryRequest}']`, quota.MemoryLimit); + KubernetesCommonHelper.assignOrDeleteIfEmptyOrZero(res, `spec.hard['${KubernetesPortainerResourceQuotaCPULimit}']`, quota.CpuLimit); + KubernetesCommonHelper.assignOrDeleteIfEmptyOrZero(res, `spec.hard['${KubernetesPortainerResourceQuotaMemoryLimit}']`, quota.MemoryLimit); + res.metadata.labels[KubernetesPortainerResourcePoolNameLabel] = quota.ResourcePoolName; + if (quota.ResourcePoolOwner) { + res.metadata.labels[KubernetesPortainerResourcePoolOwnerLabel] = quota.ResourcePoolOwner; + } + return res; + } + + static updatePayload(quota) { + const res = KubernetesResourceQuotaConverter.createPayload(quota); + res.metadata.uid = quota.Id; + return res; + } + + static patchPayload(oldQuota, newQuota) { + const oldPayload = KubernetesResourceQuotaConverter.createPayload(oldQuota); + const newPayload = KubernetesResourceQuotaConverter.createPayload(newQuota); + const payload = JsonPatch.compare(oldPayload, newPayload); + return payload; + } + + static quotaToResourcePoolFormValues(quota) { + const res = new KubernetesResourcePoolFormValues(KubernetesResourceQuotaDefaults); + res.Name = quota.Namespace; + res.CpuLimit = quota.CpuLimit; + res.MemoryLimit = KubernetesResourceReservationHelper.megaBytesValue(quota.MemoryLimit); + if (res.CpuLimit || res.MemoryLimit) { + res.HasQuota = true; + } + res.StorageClasses = quota.StorageRequests; + return res; + } + + static resourcePoolFormValuesToResourceQuota(formValues) { + if (formValues.HasQuota) { + const quota = new KubernetesResourceQuota(formValues.Name); + if (formValues.HasQuota) { + quota.CpuLimit = formValues.CpuLimit; + quota.MemoryLimit = KubernetesResourceReservationHelper.bytesValue(formValues.MemoryLimit); + } + quota.ResourcePoolName = formValues.Name; + quota.ResourcePoolOwner = formValues.Owner; + return quota; + } + } +} + +export default KubernetesResourceQuotaConverter; diff --git a/app/kubernetes/converters/secret.js b/app/kubernetes/converters/secret.js new file mode 100644 index 0000000..08da23f --- /dev/null +++ b/app/kubernetes/converters/secret.js @@ -0,0 +1,121 @@ +import _ from 'lodash-es'; +import { KubernetesSecretCreatePayload, KubernetesSecretUpdatePayload } from '@/kubernetes/models/secret/payloads'; +import { KubernetesApplicationSecret } from '@/kubernetes/models/secret/models'; +import { KubernetesPortainerConfigurationDataAnnotation } from '@/kubernetes/models/configuration/models'; +import { ConfigurationOwnerUsernameLabel } from '@/react/kubernetes/configs/constants'; +import { KubernetesConfigurationFormValuesEntry } from '@/kubernetes/models/configuration/formvalues'; +import { KubernetesSecretTypeOptions } from '@/kubernetes/models/configuration/models'; +class KubernetesSecretConverter { + static createPayload(secret) { + const res = new KubernetesSecretCreatePayload(); + res.metadata.name = secret.Name; + res.metadata.namespace = secret.Namespace.Namespace.Name; + res.type = secret.Type; + const configurationOwner = _.truncate(secret.ConfigurationOwner, { length: 63, omission: '' }); + res.metadata.labels[ConfigurationOwnerUsernameLabel] = configurationOwner; + + let annotation = ''; + _.forEach(secret.Data, (entry) => { + if (entry.IsBinary) { + res.data[entry.Key] = entry.Value; + annotation += annotation !== '' ? '|' + entry.Key : entry.Key; + } else { + res.stringData[entry.Key] = entry.Value; + } + }); + if (annotation !== '') { + res.metadata.annotations[KubernetesPortainerConfigurationDataAnnotation] = annotation; + } + + _.forEach(secret.Annotations, (entry) => { + res.metadata.annotations[entry.name] = entry.value; + }); + + return res; + } + + static updatePayload(secret) { + const res = new KubernetesSecretUpdatePayload(); + res.metadata.name = secret.Name; + res.metadata.namespace = secret.Namespace; + res.type = secret.Type; + res.metadata.labels = secret.Labels || {}; + res.metadata.labels[ConfigurationOwnerUsernameLabel] = secret.ConfigurationOwner; + + let annotation = ''; + _.forEach(secret.Data, (entry) => { + if (entry.IsBinary) { + res.data[entry.Key] = entry.Value; + annotation += annotation !== '' ? '|' + entry.Key : entry.Key; + } else { + res.stringData[entry.Key] = entry.Value; + } + }); + if (annotation !== '') { + res.metadata.annotations[KubernetesPortainerConfigurationDataAnnotation] = annotation; + } + + _.forEach(secret.Annotations, (entry) => { + res.metadata.annotations[entry.name] = entry.value; + }); + + return res; + } + + static apiToSecret(payload, yaml) { + const res = new KubernetesApplicationSecret(); + res.Id = payload.metadata.uid; + res.Name = payload.metadata.name; + res.Namespace = payload.metadata.namespace; + res.Type = payload.type; + res.Labels = payload.metadata.labels || {}; + res.ConfigurationOwner = payload.metadata.labels ? payload.metadata.labels[ConfigurationOwnerUsernameLabel] : ''; + res.CreationDate = payload.metadata.creationTimestamp; + res.Annotations = payload.metadata.annotations; + + res.IsRegistrySecret = payload.metadata.annotations && !!payload.metadata.annotations['portainer.io/registry.id']; + + res.Yaml = yaml ? yaml.data : ''; + + res.SecretType = payload.type; + + res.Data = _.map(payload.data, (value, key) => { + const annotations = payload.metadata.annotations ? payload.metadata.annotations[KubernetesPortainerConfigurationDataAnnotation] : ''; + const entry = new KubernetesConfigurationFormValuesEntry(); + entry.Key = key; + entry.IsBinary = _.includes(annotations, entry.Key); + + if (!entry.IsBinary) { + entry.Value = atob(value); + } else { + entry.Value = value; + } + return entry; + }); + res.data = res.Data; + + return res; + } + + static configurationFormValuesToSecret(formValues) { + const res = new KubernetesApplicationSecret(); + res.Name = formValues.Name; + res.Namespace = formValues.ResourcePool; + res.Type = formValues.Type; + res.ConfigurationOwner = formValues.ConfigurationOwner; + res.Data = formValues.Data; + + if (formValues.Type === KubernetesSecretTypeOptions.CUSTOM.value) { + res.Type = formValues.customType; + } + if (formValues.Type === KubernetesSecretTypeOptions.SERVICEACCOUNTTOKEN.value) { + const serviceAccountAnnotation = formValues.Annotations.find((a) => a.key === 'kubernetes.io/service-account.name'); + if (!serviceAccountAnnotation) { + res.Annotations.push({ key: 'kubernetes.io/service-account.name', value: formValues.ServiceAccountName }); + } + } + return res; + } +} + +export default KubernetesSecretConverter; diff --git a/app/kubernetes/converters/service.js b/app/kubernetes/converters/service.js new file mode 100644 index 0000000..61a9629 --- /dev/null +++ b/app/kubernetes/converters/service.js @@ -0,0 +1,131 @@ +import _ from 'lodash-es'; +import * as JsonPatch from 'fast-json-patch'; + +import { KubernetesServiceCreatePayload } from '@/kubernetes/models/service/payloads'; +import { + KubernetesPortainerApplicationNameLabel, + KubernetesPortainerApplicationOwnerLabel, + KubernetesPortainerApplicationStackNameLabel, +} from '@/kubernetes/models/application/models'; +import { KubernetesService, KubernetesServiceHeadlessClusterIP, KubernetesServicePort, KubernetesServiceTypes } from '@/kubernetes/models/service/models'; +import KubernetesServiceHelper from '@/kubernetes/helpers/serviceHelper'; + +function _publishedPortToServicePort(formValues, publishedPort, type) { + if (publishedPort.IsNew || !publishedPort.NeedsDeletion) { + const name = formValues.Name; + const res = new KubernetesServicePort(); + res.name = _.toLower(name + '-' + publishedPort.ContainerPort + '-' + publishedPort.Protocol); + res.port = type === KubernetesServiceTypes.LOAD_BALANCER ? publishedPort.LoadBalancerPort : publishedPort.ContainerPort; + res.targetPort = publishedPort.ContainerPort; + res.protocol = publishedPort.Protocol; + if (type === KubernetesServiceTypes.NODE_PORT && publishedPort.NodePort) { + res.nodePort = publishedPort.NodePort; + } else if (type === KubernetesServiceTypes.LOAD_BALANCER && publishedPort.LoadBalancerNodePort) { + res.nodePort = publishedPort.LoadBalancerNodePort; + } else { + delete res.nodePort; + } + return res; + } +} + +class KubernetesServiceConverter { + /** + * Generate KubernetesService from KubernetesApplicationFormValues + * @param {KubernetesApplicationFormValues} formValues + */ + static applicationFormValuesToService(formValues) { + const res = new KubernetesService(); + res.Namespace = formValues.ResourcePool.Namespace.Name; + res.Name = formValues.Name; + res.StackName = formValues.StackName ? formValues.StackName : formValues.Name; + res.ApplicationOwner = formValues.ApplicationOwner; + res.ApplicationName = formValues.Name; + res.Type = formValues.PublishingType; + const ports = _.map(formValues.PublishedPorts, (item) => _publishedPortToServicePort(formValues, item, res.Type)); + res.Ports = _.uniqBy(_.without(ports, undefined), (p) => p.targetPort + p.protocol); + return res; + } + + static applicationFormValuesToServices(formValues) { + let services = []; + formValues.Services.forEach(function (service) { + const res = new KubernetesService(); + res.Namespace = formValues.ResourcePool.Namespace.Name; + res.Name = service.Name; + res.StackName = formValues.StackName ? formValues.StackName : formValues.Name; + res.ApplicationOwner = formValues.ApplicationOwner; + res.ApplicationName = formValues.Name; + res.Type = service.Type; + res.Ingress = service.Ingress; + + if (service.Selector !== undefined) { + res.Selector = service.Selector; + } else { + res.Selector = { + app: formValues.Name, + }; + } + + let ports = []; + service.Ports.forEach(function (port, index) { + const res = new KubernetesServicePort(); + res.name = 'port-' + index; + res.port = port.port; + if (port.nodePort) { + res.nodePort = port.nodePort; + } + res.protocol = port.protocol; + res.targetPort = port.targetPort; + res.ingress = port.ingress; + ports.push(res); + }); + res.Ports = ports; + + services.push(res); + }); + return services; + } + + static applicationFormValuesToHeadlessService(formValues) { + const res = KubernetesServiceConverter.applicationFormValuesToService(formValues); + res.Name = KubernetesServiceHelper.generateHeadlessServiceName(formValues.Name); + res.Headless = true; + res.Selector = { + app: formValues.Name, + }; + return res; + } + + /** + * Generate CREATE payload from Service + * @param {KubernetesService} model Service to genereate payload from + */ + static createPayload(service) { + const payload = new KubernetesServiceCreatePayload(); + payload.metadata.name = service.Name; + payload.metadata.namespace = service.Namespace; + payload.metadata.labels[KubernetesPortainerApplicationStackNameLabel] = service.StackName; + payload.metadata.labels[KubernetesPortainerApplicationNameLabel] = service.ApplicationName; + payload.metadata.labels[KubernetesPortainerApplicationOwnerLabel] = service.ApplicationOwner; + + payload.spec.ports = service.Ports; + payload.spec.selector = service.Selector; + if (service.Headless) { + payload.spec.clusterIP = KubernetesServiceHeadlessClusterIP; + delete payload.spec.ports; + } else if (service.Type) { + payload.spec.type = service.Type; + } + return payload; + } + + static patchPayload(oldService, newService) { + const oldPayload = KubernetesServiceConverter.createPayload(oldService); + const newPayload = KubernetesServiceConverter.createPayload(newService); + const payload = JsonPatch.compare(oldPayload, newPayload); + return payload; + } +} + +export default KubernetesServiceConverter; diff --git a/app/kubernetes/converters/statefulSet.js b/app/kubernetes/converters/statefulSet.js new file mode 100644 index 0000000..06430c6 --- /dev/null +++ b/app/kubernetes/converters/statefulSet.js @@ -0,0 +1,96 @@ +import _ from 'lodash-es'; +import * as JsonPatch from 'fast-json-patch'; + +import { KubernetesStatefulSet } from '@/kubernetes/models/stateful-set/models'; +import { KubernetesStatefulSetCreatePayload } from '@/kubernetes/models/stateful-set/payloads'; +import { + KubernetesPortainerApplicationStackNameLabel, + KubernetesPortainerApplicationNameLabel, + KubernetesPortainerApplicationOwnerLabel, + KubernetesPortainerApplicationNote, +} from '@/kubernetes/models/application/models'; +import KubernetesApplicationHelper from '@/kubernetes/helpers/application'; +import KubernetesResourceReservationHelper from '@/kubernetes/helpers/resourceReservationHelper'; +import KubernetesCommonHelper from '@/kubernetes/helpers/commonHelper'; +import { buildImageFullURIFromModel } from '@/react/docker/images/utils'; +import KubernetesPersistentVolumeClaimConverter from './persistentVolumeClaim'; + +class KubernetesStatefulSetConverter { + /** + * Generate KubernetesStatefulSet from KubernetesApplicationFormValues + * @param {KubernetesApplicationFormValues} formValues + */ + static applicationFormValuesToStatefulSet(formValues, volumeClaims) { + const res = new KubernetesStatefulSet(); + res.Namespace = formValues.ResourcePool.Namespace.Name; + res.Name = formValues.Name; + if (formValues.StackName) { + res.StackName = formValues.StackName; + } + res.ApplicationOwner = formValues.ApplicationOwner; + res.ApplicationName = formValues.Name; + res.ReplicaCount = formValues.ReplicaCount; + res.ImageModel = formValues.ImageModel; + res.CpuLimit = formValues.CpuLimit; + res.MemoryLimit = KubernetesResourceReservationHelper.bytesValue(formValues.MemoryLimit); + res.Env = KubernetesApplicationHelper.generateEnvFromEnvVariables(formValues.EnvironmentVariables); + KubernetesApplicationHelper.generateVolumesFromPersistentVolumClaims(res, volumeClaims); + KubernetesApplicationHelper.generateEnvOrVolumesFromConfigurations(res, formValues.ConfigMaps, formValues.Secrets); + KubernetesApplicationHelper.generateAffinityFromPlacements(res, formValues); + return res; + } + + /** + * Generate CREATE payload from StatefulSet + * @param {KubernetesStatefulSetPayload} model StatefulSet to genereate payload from + */ + static createPayload(statefulSet) { + const payload = new KubernetesStatefulSetCreatePayload(); + payload.metadata.name = statefulSet.Name; + payload.metadata.namespace = statefulSet.Namespace; + if (statefulSet.StackName) { + payload.metadata.labels[KubernetesPortainerApplicationStackNameLabel] = statefulSet.StackName; + } + payload.metadata.labels[KubernetesPortainerApplicationNameLabel] = statefulSet.ApplicationName; + payload.metadata.labels[KubernetesPortainerApplicationOwnerLabel] = statefulSet.ApplicationOwner; + payload.metadata.annotations[KubernetesPortainerApplicationNote] = statefulSet.Note; + payload.spec.replicas = statefulSet.ReplicaCount; + payload.spec.serviceName = statefulSet.ServiceName; + payload.spec.selector.matchLabels.app = statefulSet.Name; + payload.spec.volumeClaimTemplates = _.map(statefulSet.VolumeClaims, (item) => KubernetesPersistentVolumeClaimConverter.createPayload(item)); + payload.spec.template.metadata.labels.app = statefulSet.Name; + payload.spec.template.metadata.labels[KubernetesPortainerApplicationNameLabel] = statefulSet.ApplicationName; + payload.spec.template.spec.containers[0].name = statefulSet.Name; + if (statefulSet.ImageModel.Image) { + payload.spec.template.spec.containers[0].image = buildImageFullURIFromModel(statefulSet.ImageModel); + if (statefulSet.ImageModel.Registry && statefulSet.ImageModel.Registry.Authentication) { + payload.spec.template.spec.imagePullSecrets = [{ name: `registry-${statefulSet.ImageModel.Registry.Id}` }]; + } + } + payload.spec.template.spec.affinity = statefulSet.Affinity; + KubernetesCommonHelper.assignOrDeleteIfEmpty(payload, 'spec.template.spec.containers[0].env', statefulSet.Env); + KubernetesCommonHelper.assignOrDeleteIfEmpty(payload, 'spec.template.spec.containers[0].volumeMounts', statefulSet.VolumeMounts); + KubernetesCommonHelper.assignOrDeleteIfEmpty(payload, 'spec.template.spec.volumes', statefulSet.Volumes); + if (statefulSet.MemoryLimit) { + payload.spec.template.spec.containers[0].resources.limits.memory = statefulSet.MemoryLimit; + payload.spec.template.spec.containers[0].resources.requests.memory = statefulSet.MemoryLimit; + } + if (statefulSet.CpuLimit) { + payload.spec.template.spec.containers[0].resources.limits.cpu = statefulSet.CpuLimit; + payload.spec.template.spec.containers[0].resources.requests.cpu = statefulSet.CpuLimit; + } + if (!statefulSet.CpuLimit && !statefulSet.MemoryLimit) { + delete payload.spec.template.spec.containers[0].resources; + } + return payload; + } + + static patchPayload(oldSFS, newSFS) { + const oldPayload = KubernetesStatefulSetConverter.createPayload(oldSFS); + const newPayload = KubernetesStatefulSetConverter.createPayload(newSFS); + const payload = JsonPatch.compare(oldPayload, newPayload); + return payload; + } +} + +export default KubernetesStatefulSetConverter; diff --git a/app/kubernetes/converters/storageClass.js b/app/kubernetes/converters/storageClass.js new file mode 100644 index 0000000..f2a4daa --- /dev/null +++ b/app/kubernetes/converters/storageClass.js @@ -0,0 +1,34 @@ +import * as JsonPatch from 'fast-json-patch'; + +import { KubernetesStorageClass } from '@/kubernetes/models/storage-class/models'; +import { KubernetesStorageClassCreatePayload } from '@/kubernetes/models/storage-class/payload'; + +class KubernetesStorageClassConverter { + /** + * API storageClass to front storageClass + */ + static apiToStorageClass(data) { + const res = new KubernetesStorageClass(); + res.Name = data.metadata.name; + res.Provisioner = data.provisioner; + res.AllowVolumeExpansion = data.allowVolumeExpansion; + return res; + } + + static createPayload(storageClass) { + const res = new KubernetesStorageClassCreatePayload(); + res.metadata.name = storageClass.Name; + res.provisioner = storageClass.Provisioner; + res.allowVolumeExpansion = storageClass.AllowVolumeExpansion; + return res; + } + + static patchPayload(oldStorageClass, newStorageClass) { + const oldPayload = KubernetesStorageClassConverter.createPayload(oldStorageClass); + const newPayload = KubernetesStorageClassConverter.createPayload(newStorageClass); + const payload = JsonPatch.compare(oldPayload, newPayload); + return payload; + } +} + +export default KubernetesStorageClassConverter; diff --git a/app/kubernetes/converters/volume.js b/app/kubernetes/converters/volume.js new file mode 100644 index 0000000..5edb932 --- /dev/null +++ b/app/kubernetes/converters/volume.js @@ -0,0 +1,12 @@ +import { KubernetesVolume } from '@/kubernetes/models/volume/models'; + +class KubernetesVolumeConverter { + static pvcToVolume(claim, pool) { + const res = new KubernetesVolume(); + res.PersistentVolumeClaim = claim; + res.ResourcePool = pool; + return res; + } +} + +export default KubernetesVolumeConverter; diff --git a/app/kubernetes/custom-templates/index.js b/app/kubernetes/custom-templates/index.js new file mode 100644 index 0000000..01d8a32 --- /dev/null +++ b/app/kubernetes/custom-templates/index.js @@ -0,0 +1,58 @@ +import angular from 'angular'; + +export default angular.module('portainer.kubernetes.custom-templates', []).config(config).name; + +function config($stateRegistryProvider) { + const templates = { + name: 'kubernetes.templates', + url: '/templates', + abstract: true, + }; + + const customTemplates = { + name: 'kubernetes.templates.custom', + url: '/custom', + + views: { + 'content@': { + component: 'customTemplatesView', + }, + }, + data: { + docs: '/user/kubernetes/templates', + }, + }; + + const customTemplatesNew = { + name: 'kubernetes.templates.custom.new', + url: '/new?fileContent', + + views: { + 'content@': { + component: 'createCustomTemplatesView', + }, + }, + params: { + fileContent: '', + }, + data: { + docs: '/user/kubernetes/templates/add', + }, + }; + + const customTemplatesEdit = { + name: 'kubernetes.templates.custom.edit', + url: '/:id', + + views: { + 'content@': { + component: 'editCustomTemplatesView', + }, + }, + }; + + $stateRegistryProvider.register(templates); + $stateRegistryProvider.register(customTemplates); + $stateRegistryProvider.register(customTemplatesNew); + $stateRegistryProvider.register(customTemplatesEdit); +} diff --git a/app/kubernetes/endpoint/converter.js b/app/kubernetes/endpoint/converter.js new file mode 100644 index 0000000..fdbe98b --- /dev/null +++ b/app/kubernetes/endpoint/converter.js @@ -0,0 +1,30 @@ +import _ from 'lodash-es'; +import { KubernetesEndpoint, KubernetesEndpointAnnotationLeader, KubernetesEndpointSubset } from '@/kubernetes/endpoint/models'; + +class KubernetesEndpointConverter { + static apiToEndpoint(data) { + const res = new KubernetesEndpoint(); + res.Id = data.metadata.uid; + res.Name = data.metadata.name; + res.Namespace = data.metadata.namespace; + const leaderAnnotation = data.metadata.annotations ? data.metadata.annotations[KubernetesEndpointAnnotationLeader] : ''; + if (leaderAnnotation) { + const parsedJson = JSON.parse(leaderAnnotation); + const split = _.split(parsedJson.holderIdentity, '_'); + res.HolderIdentity = split[0]; + } + + if (data.subsets) { + res.Subsets = _.map(data.subsets, (item) => { + const subset = new KubernetesEndpointSubset(); + subset.Ips = _.map(item.addresses, 'ip'); + const port = _.find(item.ports, { name: 'https' }); + subset.Port = port ? port.port : undefined; + return subset; + }); + } + return res; + } +} + +export default KubernetesEndpointConverter; diff --git a/app/kubernetes/endpoint/models.js b/app/kubernetes/endpoint/models.js new file mode 100644 index 0000000..506573a --- /dev/null +++ b/app/kubernetes/endpoint/models.js @@ -0,0 +1,29 @@ +export const KubernetesEndpointAnnotationLeader = 'control-plane.alpha.kubernetes.io/leader'; + +/** + * KubernetesEndpoint Model + */ +const _KubernetesEndpoint = Object.freeze({ + Id: '', + Name: '', + Namespace: '', + HolderIdentity: '', + Subsets: [], +}); + +export class KubernetesEndpoint { + constructor() { + Object.assign(this, JSON.parse(JSON.stringify(_KubernetesEndpoint))); + } +} + +const _KubernetesEndpointSubset = Object.freeze({ + Ips: [], + Port: 0, +}); + +export class KubernetesEndpointSubset { + constructor() { + Object.assign(this, JSON.parse(JSON.stringify(_KubernetesEndpointSubset))); + } +} diff --git a/app/kubernetes/endpoint/rest.js b/app/kubernetes/endpoint/rest.js new file mode 100644 index 0000000..68cf483 --- /dev/null +++ b/app/kubernetes/endpoint/rest.js @@ -0,0 +1,24 @@ +angular.module('portainer.kubernetes').factory('KubernetesEndpoints', [ + '$resource', + 'API_ENDPOINT_ENDPOINTS', + 'EndpointProvider', + function KubernetesEndpointsFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { + 'use strict'; + return function (namespace) { + const url = API_ENDPOINT_ENDPOINTS + '/:endpointId/kubernetes/api/v1' + (namespace ? '/namespaces/:namespace' : '') + '/endpoints/:id'; + return $resource( + url, + { + endpointId: EndpointProvider.endpointID, + namespace: namespace, + }, + { + get: { + method: 'GET', + ignoreLoadingBar: true, + }, + } + ); + }; + }, +]); diff --git a/app/kubernetes/endpoint/service.js b/app/kubernetes/endpoint/service.js new file mode 100644 index 0000000..6690feb --- /dev/null +++ b/app/kubernetes/endpoint/service.js @@ -0,0 +1,33 @@ +import _ from 'lodash-es'; +import angular from 'angular'; +import PortainerError from '@/portainer/error'; +import KubernetesEndpointConverter from '@/kubernetes/endpoint/converter'; + +class KubernetesEndpointService { + /* @ngInject */ + constructor($async, KubernetesEndpoints) { + this.$async = $async; + this.KubernetesEndpoints = KubernetesEndpoints; + + this.getAllAsync = this.getAllAsync.bind(this); + } + + /** + * GET + */ + async getAllAsync(namespace) { + try { + const data = await this.KubernetesEndpoints(namespace).get().$promise; + return _.map(data.items, (item) => KubernetesEndpointConverter.apiToEndpoint(item)); + } catch (err) { + throw new PortainerError('Unable to retrieve environments', err); + } + } + + get(namespace) { + return this.$async(this.getAllAsync, namespace); + } +} + +export default KubernetesEndpointService; +angular.module('portainer.kubernetes').service('KubernetesEndpointService', KubernetesEndpointService); diff --git a/app/kubernetes/filters/application.ts b/app/kubernetes/filters/application.ts new file mode 100644 index 0000000..1bb7033 --- /dev/null +++ b/app/kubernetes/filters/application.ts @@ -0,0 +1,33 @@ +import { KubernetesPodNodeAffinityNodeSelectorRequirementOperators } from '../pod/models'; + +export function nodeAffinityValues( + values: string | string[], + operator: KubernetesPodNodeAffinityNodeSelectorRequirementOperators +) { + if ( + operator === KubernetesPodNodeAffinityNodeSelectorRequirementOperators.IN || + operator === + KubernetesPodNodeAffinityNodeSelectorRequirementOperators.NOT_IN + ) { + return values; + } + + if ( + operator === + KubernetesPodNodeAffinityNodeSelectorRequirementOperators.EXISTS || + operator === + KubernetesPodNodeAffinityNodeSelectorRequirementOperators.DOES_NOT_EXIST + ) { + return ''; + } + + if ( + operator === + KubernetesPodNodeAffinityNodeSelectorRequirementOperators.GREATER_THAN || + operator === + KubernetesPodNodeAffinityNodeSelectorRequirementOperators.LOWER_THAN + ) { + return values[0]; + } + return ''; +} diff --git a/app/kubernetes/filters/applicationFilters.js b/app/kubernetes/filters/applicationFilters.js new file mode 100644 index 0000000..c6c3543 --- /dev/null +++ b/app/kubernetes/filters/applicationFilters.js @@ -0,0 +1,56 @@ +import { cpuHumanValue } from '@/react/kubernetes/applications/utils/cpuHumanValue'; +import { nodeAffinityValues } from './application'; + +angular + .module('portainer.kubernetes') + .filter('kubernetesApplicationCPUValue', function () { + 'use strict'; + return cpuHumanValue; + }) + .filter('kubernetesApplicationDataAccessPolicyIcon', function () { + 'use strict'; + return function (value) { + switch (value) { + case 'Isolated': + return 'boxes'; + case 'Shared': + return 'box'; + } + }; + }) + .filter('kubernetesApplicationDataAccessPolicyTooltip', function () { + 'use strict'; + return function (value) { + switch (value) { + case 'Isolated': + return 'All the instances of this application are using their own data.'; + case 'Shared': + return 'All the instances of this application are sharing the same data.'; + } + }; + }) + .filter('kubernetesApplicationConstraintNodeAffinityValue', function () { + 'use strict'; + return nodeAffinityValues; + }) + .filter('kubernetesNodeLabelHumanReadbleText', function () { + 'use strict'; + return function (text) { + const values = { + 'kubernetes.io/os': 'Operating system', + 'kubernetes.io/arch': 'Architecture', + 'kubernetes.io/hostname': 'Node', + }; + return values[text] || text; + }; + }) + .filter('kubernetesApplicationIngressEmptyHostname', function () { + 'use strict'; + return function (value) { + if (value === '') { + return ''; + } else { + return value; + } + }; + }); diff --git a/app/kubernetes/filters/configurationFilters.js b/app/kubernetes/filters/configurationFilters.js new file mode 100644 index 0000000..7758c9a --- /dev/null +++ b/app/kubernetes/filters/configurationFilters.js @@ -0,0 +1,13 @@ +import { KubernetesConfigurationKinds } from '@/kubernetes/models/configuration/models'; + +angular.module('portainer.kubernetes').filter('kubernetesConfigurationKindText', function () { + 'use strict'; + return function (type) { + switch (type) { + case KubernetesConfigurationKinds.SECRET: + return 'Secret'; + case KubernetesConfigurationKinds.CONFIGMAP: + return 'ConfigMap'; + } + }; +}); diff --git a/app/kubernetes/filters/eventFilters.js b/app/kubernetes/filters/eventFilters.js new file mode 100644 index 0000000..8414894 --- /dev/null +++ b/app/kubernetes/filters/eventFilters.js @@ -0,0 +1,16 @@ +import _ from 'lodash-es'; + +angular.module('portainer.kubernetes').filter('kubernetesEventTypeColor', function () { + 'use strict'; + return function (text) { + var status = _.toLower(text); + switch (status) { + case 'normal': + return 'info'; + case 'warning': + return 'warning'; + default: + return 'danger'; + } + }; +}); diff --git a/app/kubernetes/filters/filters.js b/app/kubernetes/filters/filters.js new file mode 100644 index 0000000..6fcb031 --- /dev/null +++ b/app/kubernetes/filters/filters.js @@ -0,0 +1,11 @@ +angular.module('portainer.kubernetes').filter('kubernetesUsageLevelInfo', function () { + return function (usage) { + if (usage >= 80) { + return 'danger'; + } else if (usage > 50 && usage < 80) { + return 'warning'; + } else { + return 'success'; + } + }; +}); diff --git a/app/kubernetes/helm/rest.js b/app/kubernetes/helm/rest.js new file mode 100644 index 0000000..2d87a52 --- /dev/null +++ b/app/kubernetes/helm/rest.js @@ -0,0 +1,50 @@ +import angular from 'angular'; + +angular.module('portainer.kubernetes').factory('HelmFactory', HelmFactory); + +/* @ngInject */ +function HelmFactory($resource, API_ENDPOINT_ENDPOINTS, API_ENDPOINT_USERS) { + const helmUrl = API_ENDPOINT_ENDPOINTS + '/:endpointId/kubernetes/helm'; + const templatesUrl = 'api/templates/helm'; + const userHelmUrl = API_ENDPOINT_USERS + '/:userId/helm'; + + return $resource( + helmUrl, + {}, + { + templates: { + url: templatesUrl, + method: 'GET', + params: { repo: '@repo' }, + cache: true, + }, + show: { + url: `${templatesUrl}/:type`, + method: 'GET', + params: { repo: '@repo', chart: '@chart' }, + transformResponse: function (data) { + return { values: data }; + }, + }, + getHelmRepositories: { + method: 'GET', + url: `${userHelmUrl}/repositories`, + }, + addHelmRepository: { + method: 'POST', + url: `${userHelmUrl}/repositories`, + }, + list: { + method: 'GET', + isArray: true, + params: { namespace: '@namespace', selector: '@selector', filter: '@filter', output: '@output' }, + }, + install: { method: 'POST' }, + uninstall: { + url: `${helmUrl}/:release`, + method: 'DELETE', + params: { namespace: '@namespace' }, + }, + } + ); +} diff --git a/app/kubernetes/helm/service.js b/app/kubernetes/helm/service.js new file mode 100644 index 0000000..c7ff115 --- /dev/null +++ b/app/kubernetes/helm/service.js @@ -0,0 +1,102 @@ +import angular from 'angular'; +import PortainerError from '@/portainer/error'; + +angular.module('portainer.kubernetes').factory('HelmService', HelmService); + +/* @ngInject */ +export function HelmService(HelmFactory) { + return { + search, + values, + getHelmRepositories, + addHelmRepository, + install, + uninstall, + listReleases, + }; + + /** + * @description: Searches for all helm charts in a helm repo + * @param {string} repo - repo url to search charts for + * @returns {Promise} - Resolves with `index.yaml` of helm charts for a repo + * @throws {PortainerError} - Rejects with error if searching for the `index.yaml` fails + */ + async function search(repo) { + try { + return await HelmFactory.templates({ repo }).$promise; + } catch (err) { + throw new PortainerError('Unable to retrieve helm charts', err); + } + } + + /** + * @description: Show values helm of a helm chart, this basically runs `helm show values` + * @param {string} repo - repo url to search charts values for + * @param {string} chart - chart within the repo to retrieve default values + * @returns {Promise} - Resolves with `values.yaml` of helm chart values for a repo + * @throws {PortainerError} - Rejects with error if helm show fails + */ + async function values(repo, chart) { + try { + return await HelmFactory.show({ repo, chart, type: 'values' }).$promise; + } catch (err) { + throw new PortainerError('Unable to retrieve values from chart', err); + } + } + + /** + * @description: Get a list of all the helm repositories available for the current user + * @returns {Promise} - Resolves with an object containing list of user helm repos and default/global settings helm repo + * @throws {PortainerError} - Rejects with error if helm show fails + */ + async function getHelmRepositories(userId) { + return await HelmFactory.getHelmRepositories({ userId }).$promise; + } + + /** + * @description: Adds a helm repo for the calling user + * @param {Object} payload - helm repo url to add for the user + * @returns {Promise} - Resolves with `values.yaml` of helm chart values for a repo + * @throws {PortainerError} - Rejects with error if helm show fails + */ + async function addHelmRepository(endpointId, payload) { + return await HelmFactory.addHelmRepository({ endpointId }, payload).$promise; + } + + /** + * @description: Installs a helm chart, this basically runs `helm install` + * @returns {Promise} - Resolves with `values.yaml` of helm chart values for a repo + * @throws {PortainerError} - Rejects with error if helm show fails + */ + async function install(endpointId, payload) { + return await HelmFactory.install({ endpointId }, payload).$promise; + } + + /** + * @description: Uninstall a helm chart, this basically runs `helm uninstall` + * @param {Object} options - Options object, release `Name` is the only required option + * @throws {PortainerError} - Rejects with error if helm show fails + */ + async function uninstall(endpointId, { Name, ResourcePool }) { + try { + await HelmFactory.uninstall({ endpointId, release: Name, namespace: ResourcePool }).$promise; + } catch (err) { + throw new PortainerError('Unable to delete release', err); + } + } + + /** + * @description: List all helm releases based on passed in options, this basically runs `helm list` + * @param {Object} options - Supported CLI flags to pass to Helm (binary) - flags to `helm list` + * @returns {Promise} - Resolves with list of helm releases + * @throws {PortainerError} - Rejects with error if helm list fails + */ + async function listReleases(endpointId, { namespace, selector, filter, output }) { + try { + const releases = await HelmFactory.list({ endpointId, selector, namespace, filter, output }).$promise; + return releases; + } catch (err) { + throw new PortainerError('Unable to retrieve release list', err); + } + } +} diff --git a/app/kubernetes/helpers/application/index.js b/app/kubernetes/helpers/application/index.js new file mode 100644 index 0000000..276d136 --- /dev/null +++ b/app/kubernetes/helpers/application/index.js @@ -0,0 +1,589 @@ +import _ from 'lodash-es'; +import { KubernetesPortMapping, KubernetesPortMappingPort } from '@/kubernetes/models/port/models'; +import { KubernetesService, KubernetesServicePort, KubernetesServiceTypes } from '@/kubernetes/models/service/models'; +import { KubernetesConfigurationKinds } from '@/kubernetes/models/configuration/models'; +import { + KubernetesApplicationAutoScalerFormValue, + KubernetesApplicationConfigurationFormValue, + KubernetesApplicationConfigurationFormValueOverridenKey, + KubernetesApplicationEnvironmentVariableFormValue, + KubernetesApplicationPersistedFolderFormValue, + KubernetesApplicationPlacementFormValue, + KubernetesApplicationPublishedPortFormValue, +} from '@/kubernetes/models/application/formValues'; +import { + KubernetesApplicationEnvConfigMapPayload, + KubernetesApplicationEnvPayload, + KubernetesApplicationEnvSecretPayload, + KubernetesApplicationVolumeConfigMapPayload, + KubernetesApplicationVolumeEntryPayload, + KubernetesApplicationVolumeMountPayload, + KubernetesApplicationVolumePersistentPayload, + KubernetesApplicationVolumeSecretPayload, +} from '@/kubernetes/models/application/payloads'; +import { generatedApplicationConfigVolumeName } from '@/react/kubernetes/volumes/utils'; +import { HelmApplication } from '@/kubernetes/models/application/models'; +import { KubernetesApplicationDeploymentTypes, KubernetesApplicationTypes } from '@/kubernetes/models/application/models/appConstants'; +import { KubernetesPodAffinity, KubernetesPodNodeAffinityNodeSelectorRequirementOperators } from '@/kubernetes/pod/models'; +import { + KubernetesNodeSelectorRequirementPayload, + KubernetesNodeSelectorTermPayload, + KubernetesPodNodeAffinityPayload, + KubernetesPreferredSchedulingTermPayload, +} from '@/kubernetes/pod/payloads/affinities'; +import { PodKubernetesInstanceLabel, PodManagedByLabel } from '@/react/kubernetes/applications/constants'; + +class KubernetesApplicationHelper { + /* #region UTILITY FUNCTIONS */ + static isExternalApplication(application) { + return !application.ApplicationOwner; + } + + static associatePodsAndApplication(pods, selector) { + return _.filter(pods, ['metadata.labels', selector.matchLabels]); + } + + static associateContainerPersistedFoldersAndConfigurations(app, containers) { + _.forEach(containers, (container) => { + container.PersistedFolders = _.without( + _.map(app.PersistedFolders, (pf) => { + if (pf.MountPath && _.includes(_.map(container.VolumeMounts, 'mountPath'), pf.MountPath)) { + return pf; + } + }), + undefined + ); + + container.ConfigurationVolumes = _.without( + _.map(app.ConfigurationVolumes, (cv) => { + if (cv.rootMountPath && _.includes(_.map(container.VolumeMounts, 'mountPath'), cv.rootMountPath)) { + return cv; + } + }), + undefined + ); + }); + } + + static associateContainersAndApplication(app) { + if (!app.Pods || app.Pods.length === 0) { + return []; + } + const containers = app.Pods[0].Containers; + KubernetesApplicationHelper.associateContainerPersistedFoldersAndConfigurations(app, containers); + return containers; + } + + static portMappingsFromApplications(applications) { + const res = _.reduce( + applications, + (acc, app) => { + if (app.PublishedPorts.length > 0) { + const mapping = new KubernetesPortMapping(); + mapping.Name = app.Name; + mapping.ResourcePool = app.ResourcePool; + mapping.ServiceType = app.ServiceType; + mapping.LoadBalancerIPAddress = app.LoadBalancerIPAddress; + mapping.ApplicationOwner = app.ApplicationOwner; + + mapping.Ports = _.map(app.PublishedPorts, (item) => { + const port = new KubernetesPortMappingPort(); + port.Port = mapping.ServiceType === KubernetesServiceTypes.NODE_PORT ? item.NodePort : item.Port; + port.TargetPort = item.TargetPort; + port.Protocol = item.Protocol; + port.IngressRules = item.IngressRules; + return port; + }); + acc.push(mapping); + } + return acc; + }, + [] + ); + return res; + } + /* #endregion */ + + /* #region ENV VARIABLES FV <> ENV */ + static generateEnvFromEnvVariables(envVariables) { + _.remove(envVariables, (item) => item.needsDeletion); + const env = _.map(envVariables, (item) => { + const res = new KubernetesApplicationEnvPayload(); + res.name = item.name; + if (item.value === undefined) { + delete res.value; + } else { + res.value = item.value; + } + return res; + }); + return env; + } + + static generateEnvVariablesFromEnv(env) { + const envVariables = _.map(env, (item) => { + if (item.valueFrom) { + return; + } + const res = new KubernetesApplicationEnvironmentVariableFormValue(); + res.name = item.name; + res.value = item.value; + res.isNew = false; + res.nameIndex = item.name; + return res; + }); + return _.without(envVariables, undefined); + } + /* #endregion */ + + /* #region CONFIGURATIONS FV <> ENV & VOLUMES */ + static generateConfigurationFormValuesFromEnvAndVolumes(env, volumes, configurations, configurationKind) { + const filterCondition = configurationKind === KubernetesConfigurationKinds.CONFIGMAP ? 'valueFrom.configMapKeyRef.name' : 'valueFrom.secretKeyRef.name'; + const finalRes = _.flatMap(configurations, (cfg) => { + const cfgEnv = _.filter(env, [filterCondition, cfg.Name]); + const cfgVol = volumes.filter((volume) => volume.configurationName === cfg.Name && volume.configurationType === configurationKind); + if (!cfgEnv.length && !cfgVol.length) { + return; + } + const keys = _.reduce( + _.keys(cfg.Data), + (acc, k) => { + const keyEnv = _.filter(cfgEnv, { name: k }); + const keyVol = _.filter(cfgVol, { configurationKey: k }); + const key = { + Key: k, + Count: keyEnv.length + keyVol.length, + Sum: _.concat(keyEnv, keyVol), + EnvCount: keyEnv.length, + VolCount: keyVol.length, + }; + acc.push(key); + return acc; + }, + [] + ); + + const max = _.max(_.map(keys, 'Count')); + const overrideThreshold = max - _.max(_.map(keys, 'VolCount')); + const res = _.map(new Array(max), () => new KubernetesApplicationConfigurationFormValue()); + _.forEach(res, (item, index) => { + item.selectedConfiguration = cfg; + // workaround to load configurations in the app in the select inputs + // this should be removed when the edit parent view is migrated to react + item.selectedConfiguration.metadata = {}; + item.selectedConfiguration.metadata.name = cfg.Name; + const overriden = index >= overrideThreshold; + if (overriden) { + item.overriden = true; + item.overridenKeys = _.map(keys, (k) => { + const fvKey = new KubernetesApplicationConfigurationFormValueOverridenKey(); + fvKey.key = k.Key; + if (!k.Count) { + // !k.Count indicates k.key is new added to the configuration and has not been loaded to the application yet + fvKey.type = 'NONE'; + } else if (index < k.EnvCount) { + fvKey.type = 'ENVIRONMENT'; + } else { + fvKey.type = 'FILESYSTEM'; + fvKey.path = k.Sum[index].rootMountPath; + } + return fvKey; + }); + } + }); + return res; + }); + return _.without(finalRes, undefined); + } + + static generateEnvOrVolumesFromConfigurations(app, configMaps, secrets) { + const configurations = [...configMaps, ...secrets]; + let finalEnv = []; + let finalVolumes = []; + let finalMounts = []; + + _.forEach(configurations, (config) => { + const isBasic = config.selectedConfiguration.kind === 'ConfigMap'; + + if (!config.overriden) { + const envKeys = _.keys(config.selectedConfiguration.data); + _.forEach(envKeys, (item) => { + const res = isBasic ? new KubernetesApplicationEnvConfigMapPayload() : new KubernetesApplicationEnvSecretPayload(); + res.name = item; + if (isBasic) { + res.valueFrom.configMapKeyRef.name = config.selectedConfiguration.metadata.name; + res.valueFrom.configMapKeyRef.key = item; + } else { + res.valueFrom.secretKeyRef.name = config.selectedConfiguration.metadata.name; + res.valueFrom.secretKeyRef.key = item; + } + finalEnv.push(res); + }); + } else { + const envKeys = _.filter(config.overridenKeys, (item) => item.type === 'ENVIRONMENT'); + _.forEach(envKeys, (item) => { + const res = isBasic ? new KubernetesApplicationEnvConfigMapPayload() : new KubernetesApplicationEnvSecretPayload(); + res.name = item.key; + if (isBasic) { + res.valueFrom.configMapKeyRef.name = config.selectedConfiguration.metadata.name; + res.valueFrom.configMapKeyRef.key = item.key; + } else { + res.valueFrom.secretKeyRef.name = config.selectedConfiguration.metadata.name; + res.valueFrom.secretKeyRef.key = item.key; + } + finalEnv.push(res); + }); + + const volKeys = _.filter(config.overridenKeys, (item) => item.type === 'FILESYSTEM'); + const groupedVolKeys = _.groupBy(volKeys, 'path'); + _.forEach(groupedVolKeys, (items, path) => { + const volumeName = generatedApplicationConfigVolumeName(app.Name); + const configurationName = config.selectedConfiguration.metadata.name; + const itemsMap = _.map(items, (item) => { + const entry = new KubernetesApplicationVolumeEntryPayload(); + entry.key = item.key; + entry.path = item.key; + return entry; + }); + + const mount = isBasic ? new KubernetesApplicationVolumeMountPayload() : new KubernetesApplicationVolumeMountPayload(true); + const volume = isBasic ? new KubernetesApplicationVolumeConfigMapPayload() : new KubernetesApplicationVolumeSecretPayload(); + + mount.name = volumeName; + mount.mountPath = path; + volume.name = volumeName; + if (isBasic) { + volume.configMap.name = configurationName; + volume.configMap.items = itemsMap; + } else { + volume.secret.secretName = configurationName; + volume.secret.items = itemsMap; + } + + finalMounts.push(mount); + finalVolumes.push(volume); + }); + } + }); + app.Env = _.concat(app.Env, finalEnv); + app.Volumes = _.concat(app.Volumes, finalVolumes); + app.VolumeMounts = _.concat(app.VolumeMounts, finalMounts); + return app; + } + /* #endregion */ + + /* #region SERVICES -> SERVICES FORM VALUES */ + static generateServicesFormValuesFromServices(app, ingresses) { + let services = []; + if (app.Services) { + app.Services.forEach(function (service) { + //skip generate formValues if service = headless service ( clusterIp === "None" ) + if (service.spec.clusterIP !== 'None') { + const svc = new KubernetesService(); + svc.Namespace = service.metadata.namespace; + svc.Name = service.metadata.name; + svc.StackName = service.StackName; + svc.ApplicationOwner = app.ApplicationOwner; + svc.ApplicationName = app.ApplicationName; + svc.Type = service.spec.type; + + let ports = []; + service.spec.ports.forEach(function (port) { + const svcport = new KubernetesServicePort(); + svcport.name = port.name; + svcport.port = port.port; + svcport.nodePort = port.nodePort || 0; + svcport.protocol = port.protocol; + svcport.targetPort = port.targetPort; + svcport.serviceName = service.metadata.name; + svcport.ingressPaths = []; + + ingresses.forEach((ingress) => { + const matchingIngressPaths = ingress.Paths.filter((ingPath) => ingPath.ServiceName === service.metadata.name && ingPath.Port === port.port); + // only add ingress info to the port if the ingress serviceport and name matches + const newPaths = matchingIngressPaths.map((ingPath) => ({ + IngressName: ingPath.IngressName, + Host: ingPath.Host, + Path: ingPath.Path, + })); + svcport.ingressPaths = [...svcport.ingressPaths, ...newPaths]; + svc.Ingress = matchingIngressPaths.length > 0; + }); + + ports.push(svcport); + }); + svc.Ports = ports; + // if the app is a pod (doesn't have a selector), then get the pod labels + if (app.Raw.spec.selector) { + svc.Selector = app.Raw.spec.selector.matchLabels; + } else { + svc.Selector = app.Raw.metadata.labels || { app: app.Name }; + } + services.push(svc); + } + }); + + return services; + } + } + /* #endregion */ + static generateSelectorFromService(app) { + if (app.Raw.kind !== 'Pod') { + const selector = app.Raw.spec.selector.matchLabels; + return selector; + } + } + + /* #region PUBLISHED PORTS FV <> PUBLISHED PORTS */ + static generatePublishedPortsFormValuesFromPublishedPorts(serviceType, publishedPorts, ingress) { + const generatePort = (port, rule) => { + const res = new KubernetesApplicationPublishedPortFormValue(); + res.IsNew = false; + if (rule) { + res.IngressName = rule.IngressName; + res.IngressRoute = rule.Path; + res.IngressHost = rule.Host; + res.IngressHosts = ingress && ingress.find((i) => i.Name === rule.IngressName).Hosts; + } + res.Protocol = port.Protocol; + res.ContainerPort = port.TargetPort; + if (serviceType === KubernetesServiceTypes.LOAD_BALANCER) { + res.LoadBalancerPort = port.Port; + res.LoadBalancerNodePort = port.NodePort; + } else if (serviceType === KubernetesServiceTypes.NODE_PORT) { + res.NodePort = port.NodePort; + } + return res; + }; + + const finalRes = _.flatMap(publishedPorts, (port) => { + if (port.IngressRules.length) { + return _.map(port.IngressRules, (rule) => generatePort(port, rule)); + } + return generatePort(port); + }); + return finalRes; + } + /* #endregion */ + + /* #region AUTOSCALER FV <> HORIZONTAL POD AUTOSCALER */ + static generateAutoScalerFormValueFromHorizontalPodAutoScaler(autoScaler, replicasCount) { + const res = new KubernetesApplicationAutoScalerFormValue(); + if (autoScaler) { + res.isUsed = true; + res.minReplicas = autoScaler.MinReplicas; + res.maxReplicas = autoScaler.MaxReplicas; + res.targetCpuUtilizationPercentage = autoScaler.TargetCPUUtilization; + res.apiVersion = autoScaler.ApiVersion; + } else { + res.apiVersion = 'apps/v1'; + res.minReplicas = replicasCount; + res.maxReplicas = replicasCount; + } + return res; + } + + /* #endregion */ + + /* #region PERSISTED FOLDERS FV <> VOLUMES */ + static generatePersistedFoldersFormValuesFromPersistedFolders(persistedFolders, persistentVolumeClaims) { + const finalRes = _.map(persistedFolders, (folder) => { + const pvc = _.find(persistentVolumeClaims, (item) => _.startsWith(item.Name, folder.persistentVolumeClaimName)); + const res = new KubernetesApplicationPersistedFolderFormValue(pvc.storageClass); + res.persistentVolumeClaimName = folder.persistentVolumeClaimName; + res.size = pvc.Storage.slice(0, -2); // remove trailing units + res.sizeUnit = pvc.Storage.slice(-2); + res.containerPath = folder.MountPath; + return res; + }); + return finalRes; + } + + static generateVolumesFromPersistentVolumClaims(app, volumeClaims) { + app.VolumeMounts = []; + app.Volumes = []; + _.forEach(volumeClaims, (item) => { + const volumeMount = new KubernetesApplicationVolumeMountPayload(); + const name = item.Name; + volumeMount.name = name; + volumeMount.mountPath = item.MountPath; + app.VolumeMounts.push(volumeMount); + + const volume = new KubernetesApplicationVolumePersistentPayload(); + volume.name = name; + volume.persistentVolumeClaim.claimName = name; + app.Volumes.push(volume); + }); + } + + static hasRWOOnly(formValues) { + return _.find(formValues.PersistedFolders, (item) => item.storageClass && _.isEqual(item.storageClass.AccessModes, ['RWO'])); + } + + static hasRWX(claims) { + return _.find(claims, (item) => item.storageClass && _.includes(item.storageClass.AccessModes, 'RWX')) !== undefined; + } + /* #endregion */ + + /* #region PLACEMENTS FV <> AFFINITY */ + static generatePlacementsFormValuesFromAffinity(formValues, podAffinity) { + let placements = formValues.Placements; + let type = formValues.PlacementType; + const affinity = podAffinity.nodeAffinity; + if (affinity && affinity.requiredDuringSchedulingIgnoredDuringExecution) { + type = 'mandatory'; + _.forEach(affinity.requiredDuringSchedulingIgnoredDuringExecution.nodeSelectorTerms, (term) => { + _.forEach(term.matchExpressions, (exp) => { + const placement = new KubernetesApplicationPlacementFormValue(); + placement.label = exp.key; + placement.value = exp.values[0]; + placement.isNew = false; + placements.push(placement); + }); + }); + } else if (affinity && affinity.preferredDuringSchedulingIgnoredDuringExecution) { + type = 'preferred'; + _.forEach(affinity.preferredDuringSchedulingIgnoredDuringExecution, (term) => { + _.forEach(term.preference.matchExpressions, (exp) => { + const placement = new KubernetesApplicationPlacementFormValue(); + placement.label = exp.key; + placement.value = exp.values[0]; + placement.isNew = false; + placements.push(placement); + }); + }); + } + formValues.Placements = placements; + formValues.PlacementType = type; + } + + static generateAffinityFromPlacements(app, formValues) { + if (formValues.DeploymentType === KubernetesApplicationDeploymentTypes.Replicated) { + const placements = formValues.Placements; + const res = new KubernetesPodNodeAffinityPayload(); + let expressions = _.map(placements, (p) => { + if (!p.needsDeletion) { + const exp = new KubernetesNodeSelectorRequirementPayload(); + exp.key = p.label; + if (p.value) { + exp.operator = KubernetesPodNodeAffinityNodeSelectorRequirementOperators.IN; + exp.values = [p.value]; + } else { + exp.operator = KubernetesPodNodeAffinityNodeSelectorRequirementOperators.EXISTS; + delete exp.values; + } + return exp; + } + }); + expressions = _.without(expressions, undefined); + if (expressions.length) { + if (formValues.PlacementType === 'mandatory') { + const term = new KubernetesNodeSelectorTermPayload(); + term.matchExpressions = expressions; + res.requiredDuringSchedulingIgnoredDuringExecution.nodeSelectorTerms.push(term); + delete res.preferredDuringSchedulingIgnoredDuringExecution; + } else if (formValues.PlacementType === 'preferred') { + const term = new KubernetesPreferredSchedulingTermPayload(); + term.preference = new KubernetesNodeSelectorTermPayload(); + term.preference.matchExpressions = expressions; + res.preferredDuringSchedulingIgnoredDuringExecution.push(term); + delete res.requiredDuringSchedulingIgnoredDuringExecution; + } + app.Affinity = new KubernetesPodAffinity(); + app.Affinity.nodeAffinity = res; + } + } + } + /* #endregion */ + + /** + * Get Helm managed applications + * @param {KubernetesApplication[]} applications Application list + * @returns {Object} { [releaseName]: [app1, app2, ...], [releaseName2]: [app3, app4, ...] } + */ + static getHelmApplications(applications) { + // filter out all the applications that are managed by helm + // to identify the helm managed applications, we need to check if the applications pod labels include + // `app.kubernetes.io/instance` and `app.kubernetes.io/managed-by` = `helm` + const helmManagedApps = applications.filter( + (app) => app.Metadata.labels && app.Metadata.labels[PodKubernetesInstanceLabel] && app.Metadata.labels[PodManagedByLabel] === 'Helm' + ); + + // groups the helm managed applications by helm release name + // the release name is retrieved from the `app.kubernetes.io/instance` label on the pods within the apps + // `namespacedHelmReleases` object structure: + // { + // [namespace1]: { + // [releaseName]: [app1, app2, ...], + // }, + // [namespace2]: { + // [releaseName2]: [app1, app2, ...], + // } + // } + const namespacedHelmReleases = {}; + helmManagedApps.forEach((app) => { + const namespace = app.ResourcePool; + const instanceLabel = app.Metadata.labels[PodKubernetesInstanceLabel]; + if (namespacedHelmReleases[namespace]) { + namespacedHelmReleases[namespace][instanceLabel] = [...(namespacedHelmReleases[namespace][instanceLabel] || []), app]; + } else { + namespacedHelmReleases[namespace] = { [instanceLabel]: [app] }; + } + }); + + // `helmAppsEntriesList` object structure: + // [ + // ["airflow-test", Array(5)], + // ["traefik", Array(1)], + // ["airflow-test", Array(2)], + // ..., + // ] + const helmAppsEntriesList = Object.values(namespacedHelmReleases).flatMap((r) => Object.entries(r)); + const helmAppsList = helmAppsEntriesList.map(([helmInstance, applications]) => { + const helmApp = new HelmApplication(); + helmApp.Name = helmInstance; + helmApp.ApplicationType = KubernetesApplicationTypes.Helm; + helmApp.ApplicationOwner = applications[0].ApplicationOwner; + helmApp.KubernetesApplications = applications; + + // the status of helm app is `Ready` based on whether the underlying RunningPodsCount of the k8s app + // reaches the TotalPodsCount of the app + const appsNotReady = applications.some((app) => app.RunningPodsCount < app.TotalPodsCount); + helmApp.Status = appsNotReady ? 'Not ready' : 'Ready'; + + // use earliest date + helmApp.CreationDate = applications.map((app) => app.CreationDate).sort((a, b) => new Date(a) - new Date(b))[0]; + + // use first app namespace as helm app namespace + helmApp.ResourcePool = applications[0].ResourcePool; + + // required for persisting table expansion state and differenting same named helm apps across different namespaces + helmApp.Id = helmApp.ResourcePool + '-' + helmApp.Name.toLowerCase().replaceAll(' ', '-'); + + return helmApp; + }); + + return helmAppsList; + } + + /** + * Get nested applications - + * @param {KubernetesApplication[]} applications Application list + * @returns {Object} { helmApplications: [app1, app2, ...], nonHelmApplications: [app3, app4, ...] } + */ + static getNestedApplications(applications) { + const helmApplications = KubernetesApplicationHelper.getHelmApplications(applications); + + // filter out helm managed applications + const helmAppNames = [...new Set(helmApplications.map((hma) => hma.Name))]; // distinct helm app names + const nonHelmApplications = applications.filter((app) => { + if (app.Metadata.labels) { + return !helmAppNames.includes(app.Metadata.labels[PodKubernetesInstanceLabel]); + } + return true; + }); + + return { helmApplications, nonHelmApplications }; + } +} +export default KubernetesApplicationHelper; diff --git a/app/kubernetes/helpers/commonHelper.js b/app/kubernetes/helpers/commonHelper.js new file mode 100644 index 0000000..1ad2ba3 --- /dev/null +++ b/app/kubernetes/helpers/commonHelper.js @@ -0,0 +1,28 @@ +import _ from 'lodash-es'; + +class KubernetesCommonHelper { + static assignOrDeleteIfEmpty(obj, path, value) { + if (!value || (value instanceof Array && !value.length)) { + _.unset(obj, path); + } else { + _.set(obj, path, value); + } + } + + static ownerToLabel(owner) { + let label = _.replace(owner, /[^-A-Za-z0-9_.]/g, '.'); + label = _.truncate(label, { length: 63, omission: '' }); + label = _.replace(label, /^[-_.]*/g, ''); + label = _.replace(label, /[-_.]*$/g, ''); + return label; + } + + static assignOrDeleteIfEmptyOrZero(obj, path, value) { + if (!value || value === 0 || (value instanceof Array && !value.length)) { + _.unset(obj, path); + } else { + _.set(obj, path, value); + } + } +} +export default KubernetesCommonHelper; diff --git a/app/kubernetes/helpers/configMapHelper.js b/app/kubernetes/helpers/configMapHelper.js new file mode 100644 index 0000000..a794905 --- /dev/null +++ b/app/kubernetes/helpers/configMapHelper.js @@ -0,0 +1,36 @@ +import _ from 'lodash-es'; + +import { KubernetesPortainerConfigMapAccessKey } from '@/kubernetes/models/config-map/models'; +import { UserAccessViewModel, TeamAccessViewModel } from '@/portainer/models/access'; + +class KubernetesConfigMapHelper { + static parseJSONData(configMap) { + _.forIn(configMap.Data, (value, key) => { + try { + configMap.Data[key] = JSON.parse(value); + } catch (err) { + configMap.Data[key] = value; + } + }); + return configMap; + } + + static modifiyNamespaceAccesses(configMap, namespace, accesses) { + configMap.Data[KubernetesPortainerConfigMapAccessKey][namespace] = { + UserAccessPolicies: {}, + TeamAccessPolicies: {}, + }; + _.forEach(accesses, (item) => { + if (item instanceof UserAccessViewModel) { + configMap.Data[KubernetesPortainerConfigMapAccessKey][namespace].UserAccessPolicies[item.Id] = { RoleId: 0 }; + } else if (item instanceof TeamAccessViewModel) { + configMap.Data[KubernetesPortainerConfigMapAccessKey][namespace].TeamAccessPolicies[item.Id] = { RoleId: 0 }; + } + }); + _.forIn(configMap.Data, (value, key) => { + configMap.Data[key] = JSON.stringify(value); + }); + return configMap; + } +} +export default KubernetesConfigMapHelper; diff --git a/app/kubernetes/helpers/configurationHelper.js b/app/kubernetes/helpers/configurationHelper.js new file mode 100644 index 0000000..6bd7758 --- /dev/null +++ b/app/kubernetes/helpers/configurationHelper.js @@ -0,0 +1,87 @@ +import _ from 'lodash-es'; +import YAML from 'yaml'; +import { KubernetesConfigurationKinds } from '@/kubernetes/models/configuration/models'; +import { KubernetesConfigurationFormValuesEntry } from '@/kubernetes/models/configuration/formvalues'; + +class KubernetesConfigurationHelper { + static getUsingApplications(config, applications) { + return _.filter(applications, (app) => { + let envFind; + let volumeFind; + if (config.Kind === KubernetesConfigurationKinds.CONFIGMAP) { + envFind = _.find(app.Env, { valueFrom: { configMapKeyRef: { name: config.Name } } }); + volumeFind = _.find(app.Volumes, { configMap: { name: config.Name } }); + } else { + envFind = _.find(app.Env, { valueFrom: { secretKeyRef: { name: config.Name } } }); + volumeFind = _.find(app.Volumes, { secret: { secretName: config.Name } }); + } + return envFind || volumeFind; + }); + } + + static isSystemToken(config) { + return _.startsWith(config.Name, 'default-token-'); + } + + static isBinary(encoding) { + return encoding !== '' && !_.includes(encoding, 'ISO') && !_.includes(encoding, 'UTF'); + } + + static setConfigurationUsed(config) { + config.Used = config.Applications && config.Applications.length !== 0; + } + + static setConfigurationsUsed(configurations, applications) { + _.forEach(configurations, (config) => { + config.Applications = KubernetesConfigurationHelper.getUsingApplications(config, applications); + KubernetesConfigurationHelper.setConfigurationUsed(config); + }); + } + + static getApplicationConfigurations(applications, configurations) { + const configurationsUsed = configurations.filter((config) => KubernetesConfigurationHelper.getUsingApplications(config, applications).length !== 0); + // set the configurations used for each application in the list + const configuredApps = applications.map((app) => { + const configMappedByName = configurationsUsed.filter((config) => app.ApplicationName === config.Name && app.ResourcePool === config.Namespace); + const configMappedByVolume = configurationsUsed + .filter((config) => app.ConfigurationVolumes.some((cv) => cv.configurationName === config.Name)) + .filter((config) => !configMappedByName.some((c) => c.Name === config.Name)); // filter out duplicates that are mapped by name + app.Configurations = [...configMappedByName, ...configMappedByVolume]; + return app; + }); + return configuredApps; + } + + static parseYaml(formValues) { + YAML.defaultOptions.customTags = ['binary']; + const data = _.map(YAML.parse(formValues.DataYaml), (value, key) => { + const entry = new KubernetesConfigurationFormValuesEntry(); + entry.Key = key; + entry.Value = value; + const oldEntry = _.find(formValues.Data, { Key: entry.Key }); + entry.IsBinary = oldEntry ? oldEntry.IsBinary : false; + return entry; + }); + return data; + } + + static parseData(formValues) { + if (!formValues.Data.length) return ''; + + const data = _.reduce( + formValues.Data, + (acc, entry) => { + acc[entry.Key] = entry.Value; + return acc; + }, + {} + ); + return YAML.stringify(data); + } + + static isExternalConfiguration(configuration) { + return !configuration.ConfigurationOwner; + } +} + +export default KubernetesConfigurationHelper; diff --git a/app/kubernetes/helpers/eventHelper.js b/app/kubernetes/helpers/eventHelper.js new file mode 100644 index 0000000..7cc0cc8 --- /dev/null +++ b/app/kubernetes/helpers/eventHelper.js @@ -0,0 +1,10 @@ +import _ from 'lodash-es'; + +class KubernetesEventHelper { + static warningCount(events) { + const warnings = _.filter(events, (event) => event.Type === 'Warning'); + return warnings.length; + } +} + +export default KubernetesEventHelper; diff --git a/app/kubernetes/helpers/formValidationHelper.js b/app/kubernetes/helpers/formValidationHelper.js new file mode 100644 index 0000000..01f6277 --- /dev/null +++ b/app/kubernetes/helpers/formValidationHelper.js @@ -0,0 +1,38 @@ +import _ from 'lodash-es'; + +class KubernetesFormValidationHelper { + static getInvalidKeys(names) { + const res = {}; + _.forEach(names, (name, index) => { + if (name) { + const valid = /^[-._a-zA-Z0-9]+$/.test(name); + if (!valid) { + res[index] = true; + } + } + }); + return res; + } + + static getDuplicates(names) { + const grouped = _.groupBy(names); + const res = {}; + _.forEach(names, (name, index) => { + if (name && grouped[name].length > 1) { + res[index] = name; + } + }); + return res; + } + + static getDuplicateNodePorts(serviceNodePorts, allOtherNodePorts) { + const res = {}; + serviceNodePorts.forEach((sNodePort, index) => { + if (allOtherNodePorts.includes(sNodePort) || serviceNodePorts.filter((snp) => snp === sNodePort).length > 1) { + res[index] = sNodePort; + } + }); + return res; + } +} +export default KubernetesFormValidationHelper; diff --git a/app/kubernetes/helpers/namespaceHelper.js b/app/kubernetes/helpers/namespaceHelper.js new file mode 100644 index 0000000..538597d --- /dev/null +++ b/app/kubernetes/helpers/namespaceHelper.js @@ -0,0 +1,34 @@ +import _ from 'lodash-es'; + +import { KUBERNETES_DEFAULT_SYSTEM_NAMESPACES } from '@/kubernetes/models/namespace/models'; +import { isSystem } from '@/kubernetes/store/namespace'; +import { isDefaultNamespace } from '@/react/kubernetes/namespaces/isDefaultNamespace'; + +export default class KubernetesNamespaceHelper { + /** + * Check if namespace is system or not + * @param {String} namespace Namespace (string name) to evaluate + * @returns Boolean + */ + static isSystemNamespace(namespace) { + return isSystem(namespace); + } + + /** + * Check if namespace is default or not + * @param {String} namespace Namespace (string name) to evaluate + * @returns Boolean + */ + static isDefaultNamespace(namespace) { + return isDefaultNamespace(namespace); + } + + /** + * Check if namespace is one of the default system namespaces + * @param {String} namespace Namespace (string name) to evaluate + * @returns Boolean + */ + static isDefaultSystemNamespace(namespace) { + return _.includes(KUBERNETES_DEFAULT_SYSTEM_NAMESPACES, namespace); + } +} diff --git a/app/kubernetes/helpers/resourceQuotaHelper.js b/app/kubernetes/helpers/resourceQuotaHelper.js new file mode 100644 index 0000000..c221e0c --- /dev/null +++ b/app/kubernetes/helpers/resourceQuotaHelper.js @@ -0,0 +1,31 @@ +import { KubernetesPortainerResourceQuotaPrefix } from '@/kubernetes/models/resource-quota/models'; + +class KubernetesResourceQuotaHelper { + static generateResourceQuotaName(name) { + return KubernetesPortainerResourceQuotaPrefix + name; + } + + static formatBytes(bytes, decimals = 0, base10 = true) { + const res = { + size: 0, + sizeUnit: 'B', + }; + + if (bytes === 0) { + return res; + } + + const k = base10 ? 1000 : 1024; + const dm = decimals < 0 ? 0 : decimals; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; + + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return { + size: parseFloat((bytes / Math.pow(k, i)).toFixed(dm)), + sizeUnit: sizes[i], + }; + } +} + +export default KubernetesResourceQuotaHelper; diff --git a/app/kubernetes/helpers/resourceReservationHelper.js b/app/kubernetes/helpers/resourceReservationHelper.js new file mode 100644 index 0000000..d7782da --- /dev/null +++ b/app/kubernetes/helpers/resourceReservationHelper.js @@ -0,0 +1,45 @@ +import _ from 'lodash-es'; +import filesizeParser from 'filesize-parser'; +import { KubernetesResourceReservation } from '@/kubernetes/models/resource-reservation/models'; +import { parseCPU } from '@/react/kubernetes/utils'; + +class KubernetesResourceReservationHelper { + static computeResourceReservation(pods) { + const containers = _.reduce(pods, (acc, pod) => _.concat(acc, pod.Containers), []); + + return _.reduce( + containers, + (acc, container) => { + if (container.Requests) { + if (container.Requests.memory) { + acc.Memory += safeFilesizeParser(container.Requests.memory, { base: 10 }); + } + + if (container.Requests.cpu) { + acc.CPU += parseCPU(container.Requests.cpu); + } + } + + return acc; + }, + new KubernetesResourceReservation() + ); + } + + static megaBytesValue(value) { + return Math.floor(safeFilesizeParser(value) / 1000 / 1000); + } + + static bytesValue(mem) { + return safeFilesizeParser(mem) * 1000 * 1000; + } +} +export default KubernetesResourceReservationHelper; + +function safeFilesizeParser(value, options) { + if (!value || Number.isNaN(value)) { + return 0; + } + + return filesizeParser(value, options); +} diff --git a/app/kubernetes/helpers/serviceHelper.js b/app/kubernetes/helpers/serviceHelper.js new file mode 100644 index 0000000..ea86a6a --- /dev/null +++ b/app/kubernetes/helpers/serviceHelper.js @@ -0,0 +1,25 @@ +import _ from 'lodash-es'; +import { KubernetesServiceHeadlessPrefix } from '@/kubernetes/models/service/models'; + +class KubernetesServiceHelper { + static generateHeadlessServiceName(name) { + return KubernetesServiceHeadlessPrefix + name; + } + + static findApplicationBoundService(services, rawApp) { + if (!rawApp.spec.template) { + return undefined; + } + return _.find(services, (item) => item.spec.selector && _.isMatch(rawApp.spec.template.metadata.labels, item.spec.selector)); + } + + static findApplicationBoundServices(services, rawApp) { + // if the app is a naked pod (doesn't have a template), then get the pod labels + const appLabels = rawApp.spec.template ? rawApp.spec.template.metadata.labels : rawApp.metadata.labels; + if (!appLabels) { + return undefined; + } + return _.filter(services, (item) => item.spec.selector && _.isMatch(appLabels, item.spec.selector)); + } +} +export default KubernetesServiceHelper; diff --git a/app/kubernetes/helpers/volumeHelper.js b/app/kubernetes/helpers/volumeHelper.js new file mode 100644 index 0000000..e30bd24 --- /dev/null +++ b/app/kubernetes/helpers/volumeHelper.js @@ -0,0 +1,24 @@ +import _ from 'lodash-es'; +import { KubernetesApplicationTypes } from '@/kubernetes/models/application/models/appConstants'; + +class KubernetesVolumeHelper { + // TODO: review + // the following condition + // && (app.ApplicationType === KubernetesApplicationTypes.StatefulSet ? _.includes(volume.PersistentVolumeClaim.Name, app.Name) : true); + // is made to enforce finding the good SFS when multiple SFS in the same namespace + // are referencing an internal PVC using the same internal name + // (PVC are not exposed to other apps so they can have the same name in differents SFS) + static getUsingApplications(volume, applications) { + return _.filter(applications, (app) => { + const names = _.without(_.map(app.Volumes, 'persistentVolumeClaim.claimName'), undefined); + const matchingNames = _.filter(names, (name) => _.startsWith(volume.PersistentVolumeClaim.Name, name)); + return ( + volume.ResourcePool.Namespace.Name === app.ResourcePool && + matchingNames.length && + (app.ApplicationType === KubernetesApplicationTypes.StatefulSet ? _.includes(volume.PersistentVolumeClaim.Name, app.Name) : true) + ); + }); + } +} + +export default KubernetesVolumeHelper; diff --git a/app/kubernetes/horizontal-pod-auto-scaler/converter.js b/app/kubernetes/horizontal-pod-auto-scaler/converter.js new file mode 100644 index 0000000..2647cdd --- /dev/null +++ b/app/kubernetes/horizontal-pod-auto-scaler/converter.js @@ -0,0 +1,59 @@ +import * as JsonPatch from 'fast-json-patch'; +import { KubernetesHorizontalPodAutoScaler } from './models'; +import { KubernetesHorizontalPodAutoScalerCreatePayload } from './payload'; + +export class KubernetesHorizontalPodAutoScalerConverter { + /** + * Convert API data to KubernetesHorizontalPodAutoScaler model + */ + static apiToModel(data, yaml) { + const res = new KubernetesHorizontalPodAutoScaler(); + res.Id = data.metadata.uid; + res.Namespace = data.metadata.namespace; + res.Name = data.metadata.name; + res.MinReplicas = data.spec.minReplicas; + res.MaxReplicas = data.spec.maxReplicas; + res.TargetCPUUtilization = data.spec.targetCPUUtilizationPercentage; + + if (data.spec.scaleTargetRef) { + res.TargetEntity.ApiVersion = data.spec.scaleTargetRef.apiVersion; + res.TargetEntity.Kind = data.spec.scaleTargetRef.kind; + res.TargetEntity.Name = data.spec.scaleTargetRef.name; + } + res.Yaml = yaml ? yaml.data : ''; + return res; + } + + static createPayload(data) { + const payload = new KubernetesHorizontalPodAutoScalerCreatePayload(); + payload.metadata.namespace = data.Namespace; + payload.metadata.name = data.TargetEntity.Name; + payload.spec.minReplicas = data.MinReplicas; + payload.spec.maxReplicas = data.MaxReplicas; + payload.spec.targetCPUUtilizationPercentage = data.targetCpuUtilizationPercentage; + payload.spec.scaleTargetRef.apiVersion = data.TargetEntity.ApiVersion; + payload.spec.scaleTargetRef.kind = data.TargetEntity.Kind; + payload.spec.scaleTargetRef.name = data.TargetEntity.Name; + return payload; + } + + static patchPayload(oldScaler, newScaler) { + const oldPayload = KubernetesHorizontalPodAutoScalerConverter.createPayload(oldScaler); + const newPayload = KubernetesHorizontalPodAutoScalerConverter.createPayload(newScaler); + const payload = JsonPatch.compare(oldPayload, newPayload); + return payload; + } + + static applicationFormValuesToModel(formValues, kind) { + const res = new KubernetesHorizontalPodAutoScaler(); + res.Name = formValues.Name; + res.Namespace = formValues.ResourcePool.Namespace.Name; + res.MinReplicas = formValues.AutoScaler.minReplicas; + res.MaxReplicas = formValues.AutoScaler.maxReplicas; + res.targetCpuUtilizationPercentage = formValues.AutoScaler.targetCpuUtilizationPercentage; + res.TargetEntity.Name = formValues.Name; + res.TargetEntity.Kind = kind; + res.TargetEntity.ApiVersion = formValues.AutoScaler.apiVersion; + return res; + } +} diff --git a/app/kubernetes/horizontal-pod-auto-scaler/helper.js b/app/kubernetes/horizontal-pod-auto-scaler/helper.js new file mode 100644 index 0000000..43ce119 --- /dev/null +++ b/app/kubernetes/horizontal-pod-auto-scaler/helper.js @@ -0,0 +1,8 @@ +import _ from 'lodash-es'; + +export class KubernetesHorizontalPodAutoScalerHelper { + static findApplicationBoundScaler(sList, app) { + const kind = app.ApplicationType; + return _.find(sList, (item) => item.TargetEntity.Kind === kind && item.TargetEntity.Name === app.Name); + } +} diff --git a/app/kubernetes/horizontal-pod-auto-scaler/models.js b/app/kubernetes/horizontal-pod-auto-scaler/models.js new file mode 100644 index 0000000..0506183 --- /dev/null +++ b/app/kubernetes/horizontal-pod-auto-scaler/models.js @@ -0,0 +1,23 @@ +/** + * KubernetesHorizontalPodAutoScaler Model + */ +const _KubernetesHorizontalPodAutoScaler = Object.freeze({ + Id: '', + Namespace: '', + Name: '', + MinReplicas: 1, + MaxReplicas: 1, + targetCpuUtilizationPercentage: 0, + TargetEntity: { + ApiVersion: '', + Kind: '', + Name: '', + }, + Yaml: '', +}); + +export class KubernetesHorizontalPodAutoScaler { + constructor() { + Object.assign(this, JSON.parse(JSON.stringify(_KubernetesHorizontalPodAutoScaler))); + } +} diff --git a/app/kubernetes/horizontal-pod-auto-scaler/payload.js b/app/kubernetes/horizontal-pod-auto-scaler/payload.js new file mode 100644 index 0000000..d6c67ed --- /dev/null +++ b/app/kubernetes/horizontal-pod-auto-scaler/payload.js @@ -0,0 +1,86 @@ +/** + * KubernetesHorizontalPodAutoScaler Create Payload Model + */ +const _KubernetesHorizontalPodAutoScalerCreatePayload = Object.freeze({ + metadata: { + namespace: '', + name: '', + }, + spec: { + maxReplicas: 0, + minReplicas: 0, + targetCPUUtilizationPercentage: 0, + scaleTargetRef: { + kind: '', + name: '', + }, + }, +}); + +export class KubernetesHorizontalPodAutoScalerCreatePayload { + constructor() { + Object.assign(this, JSON.parse(JSON.stringify(_KubernetesHorizontalPodAutoScalerCreatePayload))); + } +} + +/** + * KubernetesHorizontalPodAutoScaler Create Payload Model for v2beta2 + * Include support of memory usage + */ + +// const _KubernetesHorizontalPodAutoScalerCreatePayload = Object.freeze({ +// metadata: { +// namespace: '', +// name: '' +// }, +// spec: { +// maxReplicas: 0, +// minReplicas: 0, +// targetCPUUtilizationPercentage: 0, +// scaleTargetRef: { +// kind: '', +// name: '' +// }, +// metrics: [] +// } +// }); + +// export class KubernetesHorizontalPodAutoScalerCreatePayload { +// constructor() { +// Object.assign(this, JSON.parse(JSON.stringify(_KubernetesHorizontalPodAutoScalerCreatePayload))); +// } +// } + +// const _KubernetesHorizontalPodAutoScalerCPUMetric = Object.freeze({ +// type: 'Resource', +// resource: { +// name: 'cpu', +// target: { +// type: 'Utilization', +// averageUtilization: 0 +// } +// } +// }); + +// export class KubernetesHorizontalPodAutoScalerCPUMetric { +// constructor() { +// Object.assign(this, JSON.parse(JSON.stringify(_KubernetesHorizontalPodAutoScalerCPUMetric))); +// } +// } + +// const _KubernetesHorizontalPodAutoScalerMemoryMetric = Object.freeze({ +// type: 'Resource', +// resource: { +// name: 'memory', +// target: { +// type: 'AverageValue', +// averageValue: '' +// } +// } +// }); + +// export class KubernetesHorizontalPodAutoScalerMemoryMetric { +// constructor() { +// Object.assign(this, JSON.parse(JSON.stringify(_KubernetesHorizontalPodAutoScalerMemoryMetric))); +// } +// } diff --git a/app/kubernetes/horizontal-pod-auto-scaler/rest.js b/app/kubernetes/horizontal-pod-auto-scaler/rest.js new file mode 100644 index 0000000..787e2ec --- /dev/null +++ b/app/kubernetes/horizontal-pod-auto-scaler/rest.js @@ -0,0 +1,49 @@ +import { rawResponse } from '@/kubernetes/rest/response/transform'; + +angular.module('portainer.kubernetes').factory('KubernetesHorizontalPodAutoScalers', [ + '$resource', + 'API_ENDPOINT_ENDPOINTS', + 'EndpointProvider', + function KubernetesHorizontalPodAutoScalersFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { + 'use strict'; + return function (namespace) { + const url = API_ENDPOINT_ENDPOINTS + '/:endpointId/kubernetes/apis/autoscaling/v1' + (namespace ? '/namespaces/:namespace' : '') + '/horizontalpodautoscalers/:id/:action'; + return $resource( + url, + { + endpointId: EndpointProvider.endpointID, + namespace: namespace, + }, + { + get: { + method: 'GET', + ignoreLoadingBar: true, + }, + getYaml: { + method: 'GET', + headers: { + Accept: 'application/yaml', + }, + transformResponse: rawResponse, + ignoreLoadingBar: true, + }, + create: { method: 'POST' }, + update: { method: 'PUT' }, + patch: { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json-patch+json', + }, + }, + rollback: { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json-patch+json', + }, + }, + delete: { method: 'DELETE' }, + } + ); + }; + }, +]); diff --git a/app/kubernetes/horizontal-pod-auto-scaler/service.js b/app/kubernetes/horizontal-pod-auto-scaler/service.js new file mode 100644 index 0000000..3df4dd3 --- /dev/null +++ b/app/kubernetes/horizontal-pod-auto-scaler/service.js @@ -0,0 +1,118 @@ +import angular from 'angular'; +import _ from 'lodash-es'; +import PortainerError from '@/portainer/error'; +import { KubernetesCommonParams } from '@/kubernetes/models/common/params'; +import { KubernetesHorizontalPodAutoScalerConverter } from './converter'; + +class KubernetesHorizontalPodAutoScalerService { + /* @ngInject */ + constructor($async, KubernetesHorizontalPodAutoScalers) { + this.$async = $async; + this.KubernetesHorizontalPodAutoScalers = KubernetesHorizontalPodAutoScalers; + + this.getAsync = this.getAsync.bind(this); + this.getAllAsync = this.getAllAsync.bind(this); + this.createAsync = this.createAsync.bind(this); + this.patchAsync = this.patchAsync.bind(this); + // this.rollbackAsync = this.rollbackAsync.bind(this); + this.deleteAsync = this.deleteAsync.bind(this); + } + + /** + * GET + */ + async getAsync(namespace, name) { + try { + const params = new KubernetesCommonParams(); + params.id = name; + const [raw, yaml] = await Promise.all([ + this.KubernetesHorizontalPodAutoScalers(namespace).get(params).$promise, + this.KubernetesHorizontalPodAutoScalers(namespace).getYaml(params).$promise, + ]); + const res = KubernetesHorizontalPodAutoScalerConverter.apiToModel(raw, yaml); + return res; + } catch (err) { + throw new PortainerError('Unable to retrieve HorizontalPodAutoScaler', err); + } + } + + async getAllAsync(namespace) { + try { + const data = await this.KubernetesHorizontalPodAutoScalers(namespace).get().$promise; + const res = _.map(data.items, (item) => KubernetesHorizontalPodAutoScalerConverter.apiToModel(item)); + return res; + } catch (err) { + throw new PortainerError('Unable to retrieve HorizontalPodAutoScalers', err); + } + } + + get(namespace, name) { + if (name) { + return this.$async(this.getAsync, namespace, name); + } + return this.$async(this.getAllAsync, namespace); + } + + /** + * CREATE + */ + async createAsync(horizontalPodAutoScaler) { + try { + const params = {}; + const payload = KubernetesHorizontalPodAutoScalerConverter.createPayload(horizontalPodAutoScaler); + const namespace = payload.metadata.namespace; + const data = await this.KubernetesHorizontalPodAutoScalers(namespace).create(params, payload).$promise; + return data; + } catch (err) { + throw new PortainerError('Unable to create horizontalPodAutoScaler', err); + } + } + + create(horizontalPodAutoScaler) { + return this.$async(this.createAsync, horizontalPodAutoScaler); + } + + /** + * PATCH + */ + async patchAsync(oldHorizontalPodAutoScaler, newHorizontalPodAutoScaler) { + try { + const params = new KubernetesCommonParams(); + params.id = newHorizontalPodAutoScaler.Name; + const namespace = newHorizontalPodAutoScaler.Namespace; + const payload = KubernetesHorizontalPodAutoScalerConverter.patchPayload(oldHorizontalPodAutoScaler, newHorizontalPodAutoScaler); + if (!payload.length) { + return; + } + const data = await this.KubernetesHorizontalPodAutoScalers(namespace).patch(params, payload).$promise; + return data; + } catch (err) { + throw new PortainerError('Unable to patch horizontalPodAutoScaler', err); + } + } + + patch(oldHorizontalPodAutoScaler, newHorizontalPodAutoScaler) { + return this.$async(this.patchAsync, oldHorizontalPodAutoScaler, newHorizontalPodAutoScaler); + } + + /** + * DELETE + */ + async deleteAsync(horizontalPodAutoScaler) { + try { + const params = new KubernetesCommonParams(); + params.id = horizontalPodAutoScaler.Name; + const namespace = horizontalPodAutoScaler.Namespace; + await this.KubernetesHorizontalPodAutoScalers(namespace).delete(params).$promise; + } catch (err) { + throw new PortainerError('Unable to remove horizontalPodAutoScaler', err); + } + } + + delete(horizontalPodAutoScaler) { + return this.$async(this.deleteAsync, horizontalPodAutoScaler); + } +} + +export default KubernetesHorizontalPodAutoScalerService; +angular.module('portainer.kubernetes').service('KubernetesHorizontalPodAutoScalerService', KubernetesHorizontalPodAutoScalerService); diff --git a/app/kubernetes/ingress/constants.js b/app/kubernetes/ingress/constants.js new file mode 100644 index 0000000..0539527 --- /dev/null +++ b/app/kubernetes/ingress/constants.js @@ -0,0 +1,26 @@ +export const KubernetesIngressClassAnnotation = 'kubernetes.io/ingress.class'; + +// keys must match KubernetesIngressClassTypes values to map them quickly using the ingress type +// KubernetesIngressClassRewriteTargetAnnotations[KubernetesIngressClassTypes.NGINX] for example + +export const KubernetesNginxRewriteTargetAnnotations = { + Key: 'nginx.ingress.kubernetes.io/rewrite-target', + Value: '/$1', +}; + +export const KubernetesTraefikRewriteTargetAnnotations = { + Key: 'traefik.ingress.kubernetes.io/rewrite-target', + Value: '/$1', +}; + +export const KubernetesNginxUseregexAnnotations = { + Key: 'nginx.ingress.kubernetes.io/use-regex', + Value: 'true', +}; + +export const KubernetesIngressClassTypes = Object.freeze({ + NGINX: 'nginx', + TRAEFIK: 'traefik', +}); + +export const PortainerIngressClassTypes = 'ingress.portainer.io/ingress-type'; diff --git a/app/kubernetes/ingress/converter.js b/app/kubernetes/ingress/converter.js new file mode 100644 index 0000000..69ee4fe --- /dev/null +++ b/app/kubernetes/ingress/converter.js @@ -0,0 +1,230 @@ +import _ from 'lodash-es'; +import * as JsonPatch from 'fast-json-patch'; + +import KubernetesCommonHelper from '@/kubernetes/helpers/commonHelper'; +import { + KubernetesResourcePoolIngressClassAnnotationFormValue, + KubernetesResourcePoolIngressClassFormValue, + KubernetesResourcePoolIngressClassHostFormValue, +} from '@/kubernetes/models/resource-pool/formValues'; +import { KubernetesIngress, KubernetesIngressRule } from './models'; +import { KubernetesIngressCreatePayload, KubernetesIngressRuleCreatePayload, KubernetesIngressRulePathCreatePayload } from './payloads'; +import { KubernetesIngressClassAnnotation, PortainerIngressClassTypes } from './constants'; + +export class KubernetesIngressConverter { + static apiToModel(data) { + const paths = _.flatMap(data.spec.rules, (rule) => { + return !rule.http + ? [] + : _.map(rule.http.paths, (path) => { + const ingRule = new KubernetesIngressRule(); + ingRule.IngressName = data.metadata.name; + ingRule.ServiceName = path.backend.service.name; + ingRule.Host = rule.host || ''; + ingRule.IP = data.status.loadBalancer.ingress ? data.status.loadBalancer.ingress[0].ip : undefined; + ingRule.Port = path.backend.service.port.number; + ingRule.Path = path.path; + ingRule.TLS = data.spec.tls; + return ingRule; + }); + }); + + const res = new KubernetesIngress(); + res.Name = data.metadata.name; + res.Namespace = data.metadata.namespace; + res.Annotations = data.metadata.annotations || {}; + res.IngressClassName = + data.metadata.annotations && data.metadata.annotations[KubernetesIngressClassAnnotation] + ? data.metadata.annotations[KubernetesIngressClassAnnotation] + : data.spec.ingressClassName; + res.Paths = paths; + res.Hosts = _.uniq(_.map(data.spec.rules, 'host')); + const idx = _.findIndex(res.Hosts, (h) => h === undefined); + if (idx >= 0) { + res.Hosts.splice(idx, 1, ''); + } + res.TLS = data.spec.tls; + return res; + } + + static applicationFormValuesToDeleteIngresses(formValues, application) { + const ingresses = angular.copy(formValues.OriginalIngresses); + application.Services.forEach((service) => { + ingresses.forEach((ingress) => { + const paths = _.filter(ingress.Paths, { ServiceName: service.metadata.name }); + paths.forEach((path) => _.remove(ingress.Paths, path)); + }); + }); + return ingresses; + } + + static removeIngressesPaths(ingresses, services) { + const originalIngress = angular.copy(ingresses); + originalIngress.forEach((ingress) => { + services.forEach((service) => { + _.remove(ingress.Paths, { ServiceName: service.Name }); + }); + }); + return originalIngress; + } + + static generateNewIngresses(ingresses, services) { + const originalIngresses = angular.copy(ingresses); + services + .filter((s) => s.Ingress) + .forEach((service) => { + if (service.Ports.length !== 0) { + const matchedIngress = _.find(originalIngresses, { Name: service.Ports[0].ingress.IngressName }); + if (matchedIngress) { + const rule = new KubernetesIngressRule(); + rule.ServiceName = service.Name; + rule.IngressName = service.Ports[0].ingress.IngressName; + rule.Host = service.Ports[0].ingress.Host; + rule.Path = _.startsWith(service.Ports[0].ingress.Path, '/') ? service.Ports[0].ingress.Path : '/' + service.Ports[0].ingress.Path; + rule.Port = service.Ports[0].port; + + matchedIngress.Paths.push(rule); + } + } + }); + return originalIngresses; + } + + // need this function for [ resource summary ] controller + static newApplicationFormValuesToIngresses(formValues, serviceName, servicePorts) { + const ingresses = angular.copy(formValues.OriginalIngresses); + servicePorts.forEach((port) => { + const ingress = port.ingress && _.find(ingresses, { Name: port.ingress.IngressName }); + if (ingress) { + const rule = new KubernetesIngressRule(); + rule.ServiceName = serviceName; + rule.IngressName = port.ingress.IngressName; + rule.Host = port.ingress.Host; + rule.Path = _.startsWith(port.ingress.Path, '/') ? port.ingress.Path : '/' + port.ingress.Path; + rule.Port = port.port; + + ingress.Paths.push(rule); + } + }); + return ingresses; + } + + /** + * + * @param {KubernetesResourcePoolIngressClassFormValue[]} formValues + * @returns {KubernetesIngress} Ingress + */ + static resourcePoolIngressClassFormValueToIngress(formValues) { + const res = new KubernetesIngress(); + res.Name = formValues.IngressClass.Name; + res.Namespace = formValues.Namespace; + const pairs = _.map(formValues.Annotations, (a) => [a.key, a.value]); + res.Annotations = _.fromPairs(pairs); + res.Annotations[PortainerIngressClassTypes] = formValues.IngressClass.Name; + res.IngressClassName = formValues.IngressClass.Name; + res.Hosts = formValues.Hosts; + res.Paths = formValues.Paths; + return res; + } + + /** + * + * @param {KubernetesIngressClass} ics Ingress classes (saved in Portainer DB) + * @param {KubernetesIngress[]} ingresses Existing Kubernetes ingresses. Must be empty for RP CREATE VIEW and filled for RP EDIT VIEW + */ + static ingressClassesToFormValues(ics, ingresses) { + const res = _.map(ics, (ic) => { + const fv = new KubernetesResourcePoolIngressClassFormValue(ic); + const ingress = _.find(ingresses, { Name: ic.Name }); + if (ingress) { + fv.Selected = true; + fv.WasSelected = true; + fv.Hosts = _.map(ingress.Hosts, (host) => { + const hfv = new KubernetesResourcePoolIngressClassHostFormValue(); + hfv.Host = host; + hfv.PreviousHost = host; + hfv.IsNew = false; + return hfv; + }); + const annotations = _.map(_.toPairs(ingress.Annotations), ([key, value]) => { + if (key !== PortainerIngressClassTypes) { + const annotation = new KubernetesResourcePoolIngressClassAnnotationFormValue(); + annotation.key = key; + annotation.value = value; + return annotation; + } + }); + fv.Annotations = _.without(annotations, undefined); + fv.AdvancedConfig = fv.Annotations.length > 0; + fv.Paths = ingress.Paths; + } + return fv; + }); + return res; + } + + static createPayload(data) { + const res = new KubernetesIngressCreatePayload(); + res.metadata.name = data.Name; + res.metadata.namespace = data.Namespace; + res.metadata.annotations = data.Annotations; + res.spec.ingressClassName = data.IngressClassName; + if (data.Paths && data.Paths.length) { + _.forEach(data.Paths, (p) => { + if (p.Host === 'undefined' || p.Host === undefined) { + p.Host = ''; + } + }); + const hostsWithRules = []; + const groups = _.groupBy(data.Paths, 'Host'); + let rules = _.map(groups, (paths, host) => { + const updatedHost = _.find(data.Hosts, (h) => { + return h === host || h.PreviousHost === host; + }); + host = updatedHost.Host || updatedHost; + if (updatedHost.NeedsDeletion) { + return; + } + const rule = new KubernetesIngressRuleCreatePayload(); + KubernetesCommonHelper.assignOrDeleteIfEmpty(rule, 'host', host); + rule.http.paths = _.map(paths, (p) => { + const path = new KubernetesIngressRulePathCreatePayload(); + path.path = p.Path; + path.backend.service.name = p.ServiceName; + path.backend.service.port.number = p.Port; + return path; + }); + hostsWithRules.push(host); + return rule; + }); + rules = _.without(rules, undefined); + const keptHosts = _.without( + _.map(data.Hosts, (h) => (h.NeedsDeletion ? undefined : h.Host || h)), + undefined + ); + const hostsWithoutRules = _.without(keptHosts, ...hostsWithRules); + const emptyRules = _.map(hostsWithoutRules, (host) => { + return { host: host }; + }); + rules = _.concat(rules, emptyRules); + KubernetesCommonHelper.assignOrDeleteIfEmpty(res, 'spec.rules', rules); + } else if (data.Hosts) { + res.spec.rules = []; + _.forEach(data.Hosts, (host) => { + if (!host.NeedsDeletion) { + res.spec.rules.push({ host: host.Host || host }); + } + }); + } else { + delete res.spec.rules; + } + return res; + } + + static patchPayload(oldData, newData) { + const oldPayload = KubernetesIngressConverter.createPayload(oldData); + const newPayload = KubernetesIngressConverter.createPayload(newData); + const payload = JsonPatch.compare(oldPayload, newPayload); + return payload; + } +} diff --git a/app/kubernetes/ingress/helper.js b/app/kubernetes/ingress/helper.js new file mode 100644 index 0000000..0c3e58c --- /dev/null +++ b/app/kubernetes/ingress/helper.js @@ -0,0 +1,8 @@ +import _ from 'lodash-es'; + +export class KubernetesIngressHelper { + static findSBoundServiceIngressesRules(ingresses, serviceName) { + const rules = _.flatMap(ingresses, 'Paths'); + return _.filter(rules, { ServiceName: serviceName }); + } +} diff --git a/app/kubernetes/ingress/models.js b/app/kubernetes/ingress/models.js new file mode 100644 index 0000000..3b140df --- /dev/null +++ b/app/kubernetes/ingress/models.js @@ -0,0 +1,37 @@ +export function KubernetesIngress() { + return { + Name: '', + Namespace: '', + Annotations: {}, + // Host: undefined, + Hosts: [], + // PreviousHost: undefined, // only use for RP ingress host edit + Paths: [], + IngressClassName: '', + TLS: [], + }; +} + +// TODO: refactor @LP +// rename this model to KubernetesIngressPath (and all it's references) +// as it's conceptually not an ingress rule (element of ingress.spec.rules) +// but a path (element of ingress.spec.rules[].paths) +export function KubernetesIngressRule() { + return { + IngressName: '', + ServiceName: '', + Host: '', + IP: '', + Port: '', + Path: '', + }; +} + +export function KubernetesIngressClass() { + return { + Name: '', + Type: undefined, + NeedsDeletion: false, + IsNew: true, + }; +} diff --git a/app/kubernetes/ingress/payloads.js b/app/kubernetes/ingress/payloads.js new file mode 100644 index 0000000..8767c6d --- /dev/null +++ b/app/kubernetes/ingress/payloads.js @@ -0,0 +1,35 @@ +import { KubernetesCommonMetadataPayload } from '@/kubernetes/models/common/payloads'; + +export function KubernetesIngressCreatePayload() { + return { + metadata: new KubernetesCommonMetadataPayload(), + spec: { + ingressClassName: '', + rules: [], + }, + }; +} + +export function KubernetesIngressRuleCreatePayload() { + return { + host: '', + http: { + paths: [], + }, + }; +} + +export function KubernetesIngressRulePathCreatePayload() { + return { + path: '', + pathType: 'ImplementationSpecific', + backend: { + service: { + name: '', + port: { + number: 0, + }, + }, + }, + }; +} diff --git a/app/kubernetes/ingress/rest.js b/app/kubernetes/ingress/rest.js new file mode 100644 index 0000000..872213d --- /dev/null +++ b/app/kubernetes/ingress/rest.js @@ -0,0 +1,46 @@ +import { rawResponse } from '@/kubernetes/rest/response/transform'; + +angular.module('portainer.kubernetes').factory('KubernetesIngresses', factory); + +function factory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { + 'use strict'; + return function (namespace) { + const url = `${API_ENDPOINT_ENDPOINTS}/:endpointId/kubernetes/apis/networking.k8s.io/v1${namespace ? '/namespaces/:namespace' : ''}/ingresses/:id/:action`; + return $resource( + url, + { + endpointId: EndpointProvider.endpointID, + namespace: namespace, + }, + { + get: { + method: 'GET', + ignoreLoadingBar: true, + }, + getYaml: { + method: 'GET', + headers: { + Accept: 'application/yaml', + }, + transformResponse: rawResponse, + ignoreLoadingBar: true, + }, + create: { method: 'POST' }, + update: { method: 'PUT' }, + patch: { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json-patch+json', + }, + }, + rollback: { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json-patch+json', + }, + }, + delete: { method: 'DELETE' }, + } + ); + }; +} diff --git a/app/kubernetes/ingress/service.js b/app/kubernetes/ingress/service.js new file mode 100644 index 0000000..6d2e2e1 --- /dev/null +++ b/app/kubernetes/ingress/service.js @@ -0,0 +1,93 @@ +import _ from 'lodash-es'; +import angular from 'angular'; +import PortainerError from '@/portainer/error'; +import { KubernetesCommonParams } from '@/kubernetes/models/common/params'; +import { KubernetesIngressConverter } from './converter'; + +/* @ngInject */ +export function KubernetesIngressService($async, KubernetesIngresses) { + return { + get, + create, + patch, + delete: _delete, + }; + + async function getOne(namespace, name) { + try { + const params = new KubernetesCommonParams(); + params.id = name; + const [raw, yaml] = await Promise.all([KubernetesIngresses(namespace).get(params).$promise, KubernetesIngresses(namespace).getYaml(params).$promise]); + const res = { + Raw: KubernetesIngressConverter.apiToModel(raw), + Yaml: yaml.data, + }; + return res; + } catch (err) { + throw new PortainerError('Unable to retrieve Ingress', err); + } + } + + async function getAll(namespace) { + try { + const data = await KubernetesIngresses(namespace).get().$promise; + const res = _.reduce(data.items, (arr, item) => _.concat(arr, KubernetesIngressConverter.apiToModel(item)), []); + return res; + } catch (err) { + throw new PortainerError('Unable to retrieve Ingresses', err); + } + } + + function get(namespace, name) { + if (name) { + return $async(getOne, namespace, name); + } + return $async(getAll, namespace); + } + + function create(ingress) { + return $async(async () => { + try { + const params = {}; + const payload = KubernetesIngressConverter.createPayload(ingress); + const namespace = payload.metadata.namespace; + const data = await KubernetesIngresses(namespace).create(params, payload).$promise; + return data; + } catch (err) { + throw new PortainerError('Unable to create ingress', err); + } + }); + } + + function patch(oldIngress, newIngress) { + return $async(async () => { + try { + const params = new KubernetesCommonParams(); + params.id = newIngress.Name; + const namespace = newIngress.Namespace; + const payload = KubernetesIngressConverter.patchPayload(oldIngress, newIngress); + if (!payload.length) { + return; + } + const data = await KubernetesIngresses(namespace).patch(params, payload).$promise; + return data; + } catch (err) { + throw new PortainerError('Unable to patch ingress', err); + } + }); + } + + function _delete(namespace, ingressClassName) { + return $async(async () => { + try { + const params = new KubernetesCommonParams(); + params.id = ingressClassName; + await KubernetesIngresses(namespace).delete(params).$promise; + } catch (err) { + throw new PortainerError('Unable to delete ingress', err); + } + }); + } +} + +angular.module('portainer.kubernetes').service('KubernetesIngressService', KubernetesIngressService); diff --git a/app/kubernetes/models/application/formValues.js b/app/kubernetes/models/application/formValues.js new file mode 100644 index 0000000..783a36d --- /dev/null +++ b/app/kubernetes/models/application/formValues.js @@ -0,0 +1,151 @@ +import { PorImageRegistryModel } from '@/docker/models/porImageRegistry'; +import { KubernetesApplicationTypes, KubernetesApplicationDeploymentTypes, KubernetesApplicationDataAccessPolicies } from '@/kubernetes/models/application/models/appConstants'; + +/** + * KubernetesApplicationFormValues Model + */ +export function KubernetesApplicationFormValues() { + this.ApplicationType = KubernetesApplicationTypes.Deployment; // will only exist for formValues generated from Application (app edit situation; + this.ResourcePool = {}; + this.Name = ''; + this.StackName = ''; + this.ApplicationOwner = ''; + this.ImageModel = new PorImageRegistryModel(); + this.Note = ''; + this.MemoryLimit = 0; + this.CpuLimit = 0; + this.DeploymentType = KubernetesApplicationDeploymentTypes.Replicated; + this.ReplicaCount = 1; + this.AutoScaler = {}; + this.Containers = []; + this.Services = []; + this.EnvironmentVariables = []; // KubernetesApplicationEnvironmentVariableFormValue lis; + this.DataAccessPolicy = KubernetesApplicationDataAccessPolicies.Isolated; + this.PersistedFolders = []; // KubernetesApplicationPersistedFolderFormValue lis; + this.ConfigMaps = []; + this.Secrets = []; + this.PublishedPorts = []; // KubernetesApplicationPublishedPortFormValue lis; + this.PlacementType = 'preferred'; + this.Placements = []; // KubernetesApplicationPlacementFormValue lis; + this.OriginalIngresses = undefined; +} + +/** + * KubernetesApplicationConfigurationFormValueOverridenKey Model + */ +const _KubernetesApplicationConfigurationFormValueOverridenKey = Object.freeze({ + key: '', + path: '', + type: 'ENVIRONMENT', +}); + +export class KubernetesApplicationConfigurationFormValueOverridenKey { + constructor() { + Object.assign(this, JSON.parse(JSON.stringify(_KubernetesApplicationConfigurationFormValueOverridenKey))); + } +} + +/** + * KubernetesApplicationConfigurationFormValue Model + */ +const _KubernetesApplicationConfigurationFormValue = Object.freeze({ + selectedConfiguration: undefined, + overriden: false, + overridenKeys: [], +}); + +export class KubernetesApplicationConfigurationFormValue { + constructor() { + Object.assign(this, JSON.parse(JSON.stringify(_KubernetesApplicationConfigurationFormValue))); + } +} + +/** + * KubernetesApplicationEnvironmentVariableFormValue Model + */ +const _KubernetesApplicationEnvironmentVariableFormValue = Object.freeze({ + name: '', + value: '', + needsDeletion: false, + isNew: true, + nameIndex: '', // keep the original name for sorting +}); + +export class KubernetesApplicationEnvironmentVariableFormValue { + constructor() { + Object.assign(this, JSON.parse(JSON.stringify(_KubernetesApplicationEnvironmentVariableFormValue))); + } +} + +/** + * KubernetesApplicationPersistedFolderFormValue Model + */ +const _KubernetesApplicationPersistedFolderFormValue = Object.freeze({ + persistentVolumeClaimName: '', // will be empty for new volumes (create/edit app) and filled for existing ones (edit) + needsDeletion: false, + containerPath: '', + size: '', + sizeUnit: 'GB', + storageClass: {}, + existingVolume: null, + useNewVolume: true, +}); + +export class KubernetesApplicationPersistedFolderFormValue { + constructor(storageClass) { + Object.assign(this, JSON.parse(JSON.stringify(_KubernetesApplicationPersistedFolderFormValue))); + this.storageClass = storageClass; + } +} + +/** + * KubernetesApplicationPublishedPortFormValue Model + */ +export function KubernetesApplicationPublishedPortFormValue() { + return { + NeedsDeletion: false, + IsNew: true, + ContainerPort: '', + NodePort: '', + LoadBalancerPort: '', + LoadBalancerNodePort: undefined, // only filled to save existing loadbalancer nodePort and drop it when moving app exposure from LB to Internal/NodePort + Protocol: 'TCP', + IngressName: undefined, + IngressRoute: undefined, + IngressHost: undefined, + IngressHosts: [], + }; +} + +export function KubernetesApplicationPlacementFormValue() { + return { + label: {}, + value: '', + needsDeletion: false, + isNew: true, + }; +} + +/** + * KubernetesApplicationAutoScalerFormValue Model + */ +const _KubernetesApplicationAutoScalerFormValue = Object.freeze({ + minReplicas: 0, + maxReplicas: 0, + targetCpuUtilizationPercentage: 50, + apiVersion: '', + isUsed: false, +}); + +export class KubernetesApplicationAutoScalerFormValue { + constructor() { + Object.assign(this, JSON.parse(JSON.stringify(_KubernetesApplicationAutoScalerFormValue))); + } +} + +export function KubernetesFormValidationReferences() { + return { + refs: {}, + hasRefs: false, + }; +} diff --git a/app/kubernetes/models/application/models/Application.ts b/app/kubernetes/models/application/models/Application.ts new file mode 100644 index 0000000..c8e8aa2 --- /dev/null +++ b/app/kubernetes/models/application/models/Application.ts @@ -0,0 +1,128 @@ +import { ServiceType } from '@/react/kubernetes/applications/CreateView/application-services/types'; +import { + AppType, + DeploymentType, + AppDataAccessPolicy, + AppKind, +} from '@/react/kubernetes/applications/types'; + +import { ConfigurationVolume } from './ConfigurationVolume'; +import { PersistedFolder } from './PersistedFolder'; + +export class Application { + Id: string; + + Name: string; + + StackName: string; + + StackId: string; + + ApplicationKind?: AppKind; + + ApplicationOwner: string; + + ApplicationName: string; + + Annotations: Record = {}; + + ResourcePool: string; + + Image: string; + + CreationDate: 0; + + Pods: []; + + Containers: []; + + Metadata: { + labels?: Record; + annotations?: Record; + }; + + Resource?: { + cpuLimit?: number; + cpuRequest?: number; + memoryLimit?: number; + memoryRequest?: number; + }; + + ServiceType?: ServiceType; + + ServiceId: string; + + ServiceName: string; + + HeadlessServiceName: undefined; // only used for StatefulSet + + LoadBalancerIPAddress: undefined; // only filled when bound service is LoadBalancer and state is available + + PublishedPorts: []; + + Volumes: []; + + Env: []; + + PersistedFolders: Array; + + ConfigurationVolumes: Array; + + DeploymentType?: DeploymentType; + + DataAccessPolicy?: AppDataAccessPolicy; + + ApplicationType?: AppType; + + RunningPodsCount: 0; + + TotalPodsCount: 0; + + Yaml: string; + + Note: string; + + Raw: undefined; // only filled when inspecting app details / create / edit view (never filled in multiple-apps views) + + AutoScaler: undefined; // only filled if the application has an HorizontalPodAutoScaler bound to it + + Conditions: Array<{ + lastTransitionTime: string; + lastUpdateTime: string; + message: string; + reason: string; + status: string; + type: string; + }> = []; + + constructor() { + this.Id = ''; + this.Name = ''; + this.StackName = ''; + this.StackId = ''; + this.ApplicationOwner = ''; + this.ApplicationName = ''; + this.ResourcePool = ''; + this.Image = ''; + this.CreationDate = 0; + this.Pods = []; + this.Containers = []; + this.Metadata = {}; + this.Resource = {}; + this.ServiceId = ''; + this.ServiceName = ''; + this.HeadlessServiceName = undefined; + this.LoadBalancerIPAddress = undefined; + this.PublishedPorts = []; + this.Volumes = []; + this.Env = []; + this.PersistedFolders = []; + this.ConfigurationVolumes = []; + this.RunningPodsCount = 0; + this.TotalPodsCount = 0; + this.Yaml = ''; + this.Note = ''; + this.Raw = undefined; + this.AutoScaler = undefined; + } +} diff --git a/app/kubernetes/models/application/models/ConfigurationVolume.ts b/app/kubernetes/models/application/models/ConfigurationVolume.ts new file mode 100644 index 0000000..5296425 --- /dev/null +++ b/app/kubernetes/models/application/models/ConfigurationVolume.ts @@ -0,0 +1,9 @@ +export class ConfigurationVolume { + fileMountPath = ''; + + rootMountPath = ''; + + configurationKey = ''; + + configurationName = ''; +} diff --git a/app/kubernetes/models/application/models/PersistedFolder.ts b/app/kubernetes/models/application/models/PersistedFolder.ts new file mode 100644 index 0000000..f9cb782 --- /dev/null +++ b/app/kubernetes/models/application/models/PersistedFolder.ts @@ -0,0 +1,7 @@ +export class PersistedFolder { + MountPath = ''; + + persistentVolumeClaimName = ''; + + HostPath = ''; +} diff --git a/app/kubernetes/models/application/models/appConstants.ts b/app/kubernetes/models/application/models/appConstants.ts new file mode 100644 index 0000000..658fc30 --- /dev/null +++ b/app/kubernetes/models/application/models/appConstants.ts @@ -0,0 +1,41 @@ +import { + AppType, + AppDataAccessPolicy, + DeploymentType, +} from '@/react/kubernetes/applications/types'; +import { ServiceType } from '@/react/kubernetes/services/types'; + +// The following constants are used by angular views and can be removed once they are no longer referenced +export const KubernetesApplicationTypes: Record = { + Deployment: 'Deployment', + StatefulSet: 'StatefulSet', + DaemonSet: 'DaemonSet', + Pod: 'Pod', + Helm: 'Helm', +} as const; + +export const KubernetesApplicationDeploymentTypes: Record< + DeploymentType, + DeploymentType +> = { + Global: 'Global', + Replicated: 'Replicated', +} as const; + +export const KubernetesApplicationDataAccessPolicies: Record< + AppDataAccessPolicy, + AppDataAccessPolicy +> = { + Isolated: 'Isolated', + Shared: 'Shared', +} as const; + +export const KubernetesApplicationServiceTypes: Record< + ServiceType, + ServiceType +> = { + ClusterIP: 'ClusterIP', + NodePort: 'NodePort', + LoadBalancer: 'LoadBalancer', + ExternalName: 'ExternalName', +} as const; diff --git a/app/kubernetes/models/application/models/constants.js b/app/kubernetes/models/application/models/constants.js new file mode 100644 index 0000000..74f9767 --- /dev/null +++ b/app/kubernetes/models/application/models/constants.js @@ -0,0 +1,16 @@ +export const KubernetesApplicationQuotaDefaults = { + CpuLimit: 0.1, + MemoryLimit: 128, // MB +}; + +export const KubernetesPortainerApplicationStackNameLabel = 'io.portainer.kubernetes.application.stack'; + +export const KubernetesPortainerApplicationStackIdLabel = 'io.portainer.kubernetes.application.stackid'; + +export const KubernetesPortainerApplicationKindLabel = 'io.portainer.kubernetes.application.kind'; + +export const KubernetesPortainerApplicationNameLabel = 'io.portainer.kubernetes.application.name'; + +export const KubernetesPortainerApplicationOwnerLabel = 'io.portainer.kubernetes.application.owner'; + +export const KubernetesPortainerApplicationNote = 'io.portainer.kubernetes.application.note'; diff --git a/app/kubernetes/models/application/models/index.js b/app/kubernetes/models/application/models/index.js new file mode 100644 index 0000000..20000bd --- /dev/null +++ b/app/kubernetes/models/application/models/index.js @@ -0,0 +1,48 @@ +export * from './constants'; + +export { Application as KubernetesApplication } from './Application'; +export { ConfigurationVolume as KubernetesApplicationConfigurationVolume } from './ConfigurationVolume'; +export { PersistedFolder as KubernetesApplicationPersistedFolder } from './PersistedFolder'; + +/** + * HelmApplication Model (Composite) + */ +export class HelmApplication { + constructor() { + this.Id = ''; + this.Name = ''; + this.KubernetesApplications = []; + this.ApplicationOwner = ''; + this.CreationDate = 0; + this.ApplicationType = 'Unknown'; + this.Status = ''; + this.StackName = '-'; + this.ResourcePool = '-'; + this.Image = '-'; + this.PublishedPorts = []; + } +} + +/** + * KubernetesApplicationPort Model + */ +const _KubernetesApplicationPort = Object.freeze({ + IngressRules: [], // KubernetesIngressRule[] + NodePort: 0, + TargetPort: 0, + Port: 0, + Protocol: '', +}); + +export class KubernetesApplicationPort { + constructor() { + Object.assign(this, JSON.parse(JSON.stringify(_KubernetesApplicationPort))); + } +} + +export const KubernetesDeploymentTypes = Object.freeze({ + GIT: 'git', + CONTENT: 'content', + APPLICATION_FORM: 'application form', + URL: 'url', +}); diff --git a/app/kubernetes/models/application/payloads.js b/app/kubernetes/models/application/payloads.js new file mode 100644 index 0000000..4671e9b --- /dev/null +++ b/app/kubernetes/models/application/payloads.js @@ -0,0 +1,142 @@ +/////////////////////////// VOLUME MOUNT /////////////////////////////// +/** + * KubernetesApplicationVolumeMount Model + */ +const _KubernetesApplicationVolumeMount = Object.freeze({ + name: '', + mountPath: '', + readOnly: false, +}); + +export class KubernetesApplicationVolumeMountPayload { + constructor(readOnly) { + Object.assign(this, JSON.parse(JSON.stringify(_KubernetesApplicationVolumeMount))); + if (readOnly) { + this.readOnly = true; + } else { + delete this.readOnly; + } + } +} + +///////////////////////////////// PVC ///////////////////////////////// +/** + * KubernetesApplicationVolumePersistentPayload Model + */ +const _KubernetesApplicationVolumePersistentPayload = Object.freeze({ + name: '', + persistentVolumeClaim: { + claimName: '', + }, +}); + +export class KubernetesApplicationVolumePersistentPayload { + constructor() { + Object.assign(this, JSON.parse(JSON.stringify(_KubernetesApplicationVolumePersistentPayload))); + } +} + +/////////////////////////////// CONFIG AS VOLUME //////////////////////// +/** + * KubernetesApplicationVolumeConfigMapPayload Model + */ +const _KubernetesApplicationVolumeConfigMapPayload = Object.freeze({ + name: '', + configMap: { + name: '', + items: [], // KubernetesApplicationVolumeEntryPayload + }, +}); + +export class KubernetesApplicationVolumeConfigMapPayload { + constructor() { + Object.assign(this, JSON.parse(JSON.stringify(_KubernetesApplicationVolumeConfigMapPayload))); + } +} + +//////////////////// SECRET AS VOLUME ///////////////////////////////////// +/** + * KubernetesApplicationVolumeSecretPayload Model + */ +const _KubernetesApplicationVolumeSecretPayload = Object.freeze({ + name: '', + secret: { + secretName: '', + items: [], // KubernetesApplicationVolumeEntryPayload + }, +}); + +export class KubernetesApplicationVolumeSecretPayload { + constructor() { + Object.assign(this, JSON.parse(JSON.stringify(_KubernetesApplicationVolumeSecretPayload))); + } +} + +/** + * KubernetesApplicationVolumeEntryPayload Model + */ +const _KubernetesApplicationVolumeEntryPayload = Object.freeze({ + key: '', + path: '', +}); + +export class KubernetesApplicationVolumeEntryPayload { + constructor() { + Object.assign(this, JSON.parse(JSON.stringify(_KubernetesApplicationVolumeEntryPayload))); + } +} + +//////////////////////////// ENV ENTRY ////////////////////////////// +/** + * KubernetesApplicationEnvPayload Model + */ +const _KubernetesApplicationEnvPayload = Object.freeze({ + name: '', + value: '', +}); + +export class KubernetesApplicationEnvPayload { + constructor() { + Object.assign(this, JSON.parse(JSON.stringify(_KubernetesApplicationEnvPayload))); + } +} + +///////////////////////// CONFIG AS ENV //////////////////////////////// +/** + * KubernetesApplicationEnvConfigMapPayload Model + */ +const _KubernetesApplicationEnvConfigMapPayload = Object.freeze({ + name: '', + valueFrom: { + configMapKeyRef: { + name: '', + key: '', + }, + }, +}); + +export class KubernetesApplicationEnvConfigMapPayload { + constructor() { + Object.assign(this, JSON.parse(JSON.stringify(_KubernetesApplicationEnvConfigMapPayload))); + } +} + +//////////////////////// SECRET AS ENV ////////////////////////////////// +/** + * KubernetesApplicationEnvSecretPayload Model + */ +const _KubernetesApplicationEnvSecretPayload = Object.freeze({ + name: '', + valueFrom: { + secretKeyRef: { + name: '', + key: '', + }, + }, +}); + +export class KubernetesApplicationEnvSecretPayload { + constructor() { + Object.assign(this, JSON.parse(JSON.stringify(_KubernetesApplicationEnvSecretPayload))); + } +} diff --git a/app/kubernetes/models/common/params.js b/app/kubernetes/models/common/params.js new file mode 100644 index 0000000..5c7de8d --- /dev/null +++ b/app/kubernetes/models/common/params.js @@ -0,0 +1,8 @@ +/** + * Generic params + */ +export function KubernetesCommonParams() { + return { + id: '', + }; +} diff --git a/app/kubernetes/models/common/payloads.js b/app/kubernetes/models/common/payloads.js new file mode 100644 index 0000000..28909d3 --- /dev/null +++ b/app/kubernetes/models/common/payloads.js @@ -0,0 +1,15 @@ +/** + * Generic metadata payload + */ +const _KubernetesCommonMetadataPayload = Object.freeze({ + uid: '', + name: '', + namespace: '', + labels: {}, + annotations: {}, +}); +export class KubernetesCommonMetadataPayload { + constructor() { + Object.assign(this, JSON.parse(JSON.stringify(_KubernetesCommonMetadataPayload))); + } +} diff --git a/app/kubernetes/models/config-map/models.js b/app/kubernetes/models/config-map/models.js new file mode 100644 index 0000000..bde4b85 --- /dev/null +++ b/app/kubernetes/models/config-map/models.js @@ -0,0 +1,31 @@ +export const KubernetesPortainerConfigMapNamespace = 'portainer'; +export const KubernetesPortainerConfigMapConfigName = 'portainer-config'; +export const KubernetesPortainerConfigMapAccessKey = 'NamespaceAccessPolicies'; + +export function KubernetesPortainerAccessConfigMap() { + return { + Id: 0, + Name: KubernetesPortainerConfigMapConfigName, + Namespace: KubernetesPortainerConfigMapNamespace, + Data: {}, + }; +} + +/** + * ConfigMap Model + */ +const _KubernetesConfigMap = Object.freeze({ + Id: 0, + Name: '', + Namespace: '', + Yaml: '', + ConfigurationOwner: '', + Data: [], + Labels: {}, +}); + +export class KubernetesConfigMap { + constructor() { + Object.assign(this, JSON.parse(JSON.stringify(_KubernetesConfigMap))); + } +} diff --git a/app/kubernetes/models/config-map/payloads.js b/app/kubernetes/models/config-map/payloads.js new file mode 100644 index 0000000..587daf8 --- /dev/null +++ b/app/kubernetes/models/config-map/payloads.js @@ -0,0 +1,29 @@ +import { KubernetesCommonMetadataPayload } from '@/kubernetes/models/common/payloads'; + +/** + * Payload for CREATE + */ +const _KubernetesConfigMapCreatePayload = Object.freeze({ + metadata: new KubernetesCommonMetadataPayload(), + data: {}, + binaryData: {}, +}); +export class KubernetesConfigMapCreatePayload { + constructor() { + Object.assign(this, JSON.parse(JSON.stringify(_KubernetesConfigMapCreatePayload))); + } +} + +/** + * Payload for UPDATE + */ +const _KubernetesConfigMapUpdatePayload = Object.freeze({ + metadata: new KubernetesCommonMetadataPayload(), + data: {}, + binaryData: {}, +}); +export class KubernetesConfigMapUpdatePayload { + constructor() { + Object.assign(this, JSON.parse(JSON.stringify(_KubernetesConfigMapUpdatePayload))); + } +} diff --git a/app/kubernetes/models/configuration/formvalues.js b/app/kubernetes/models/configuration/formvalues.js new file mode 100644 index 0000000..6679491 --- /dev/null +++ b/app/kubernetes/models/configuration/formvalues.js @@ -0,0 +1,37 @@ +import { KubernetesConfigurationKinds, KubernetesSecretTypeOptions } from './models'; + +/** + * KubernetesConfigurationFormValues Model + */ +const _KubernetesConfigurationFormValues = Object.freeze({ + Id: '', + ResourcePool: '', + Name: '', + ConfigurationOwner: '', + Kind: KubernetesConfigurationKinds.CONFIGMAP, + kind: 'ConfigMap', + Data: [], + DataYaml: '', + IsSimple: true, + ServiceAccountName: '', + Type: KubernetesSecretTypeOptions.OPAQUE.value, + Labels: {}, +}); + +export class KubernetesConfigurationFormValues { + constructor() { + Object.assign(this, _KubernetesConfigurationFormValues); + } +} + +const _KubernetesConfigurationFormValuesEntry = Object.freeze({ + Key: '', + Value: '', + IsBinary: false, +}); + +export class KubernetesConfigurationFormValuesEntry { + constructor() { + Object.assign(this, JSON.parse(JSON.stringify(_KubernetesConfigurationFormValuesEntry))); + } +} diff --git a/app/kubernetes/models/configuration/models.js b/app/kubernetes/models/configuration/models.js new file mode 100644 index 0000000..ec29ab6 --- /dev/null +++ b/app/kubernetes/models/configuration/models.js @@ -0,0 +1,41 @@ +export const KubernetesPortainerConfigurationOwnerLabel = 'io.portainer.kubernetes.configuration.owner'; +export const KubernetesPortainerConfigurationDataAnnotation = 'io.portainer.kubernetes.configuration.data'; + +/** + * Configuration Model (Composite) + */ +const _KubernetesConfiguration = Object.freeze({ + Id: 0, + Name: '', + Kind: '', + Namespace: '', + CreationDate: '', + ConfigurationOwner: '', + Used: false, + Applications: [], + Data: {}, + SecretType: '', +}); + +export class KubernetesConfiguration { + constructor() { + Object.assign(this, JSON.parse(JSON.stringify(_KubernetesConfiguration))); + } +} + +export const KubernetesConfigurationKinds = Object.freeze({ + CONFIGMAP: 1, + SECRET: 2, +}); + +export const KubernetesSecretTypeOptions = Object.freeze({ + OPAQUE: { name: 'Opaque', value: 'Opaque' }, + SERVICEACCOUNTTOKEN: { name: 'Service account token', value: 'kubernetes.io/service-account-token' }, + DOCKERCFG: { name: 'Dockercfg', value: 'kubernetes.io/dockercfg' }, + DOCKERCONFIGJSON: { name: 'Dockerconfigjson', value: 'kubernetes.io/dockerconfigjson' }, + BASICAUTH: { name: 'Basic auth', value: 'kubernetes.io/basic-auth' }, + SSHAUTH: { name: 'SSH auth', value: 'kubernetes.io/ssh-auth' }, + TLS: { name: 'TLS', value: 'kubernetes.io/tls' }, + BOOTSTRAPTOKEN: { name: 'Bootstrap token', value: 'bootstrap.kubernetes.io/token' }, + CUSTOM: { name: 'Custom', value: 'Custom' }, +}); diff --git a/app/kubernetes/models/daemon-set/models.js b/app/kubernetes/models/daemon-set/models.js new file mode 100644 index 0000000..4f2f82e --- /dev/null +++ b/app/kubernetes/models/daemon-set/models.js @@ -0,0 +1,25 @@ +/** + * KubernetesDaemonSet Model + */ +const _KubernetesDaemonSet = Object.freeze({ + Namespace: '', + Name: '', + StackName: '', + ImageModel: null, + Env: [], + CpuLimit: 0, + MemoryLimit: 0, + VoluemMounts: [], + Volumes: [], + Secret: undefined, + ApplicationName: '', + ApplicationOwner: '', + Note: '', + Affinity: undefined, // KubernetesPodAffinity +}); + +export class KubernetesDaemonSet { + constructor() { + Object.assign(this, JSON.parse(JSON.stringify(_KubernetesDaemonSet))); + } +} diff --git a/app/kubernetes/models/daemon-set/payloads.js b/app/kubernetes/models/daemon-set/payloads.js new file mode 100644 index 0000000..c6fb631 --- /dev/null +++ b/app/kubernetes/models/daemon-set/payloads.js @@ -0,0 +1,50 @@ +import { KubernetesCommonMetadataPayload } from '@/kubernetes/models/common/payloads'; + +/** + * KubernetesDaemonSetCreatePayload Model + */ +const _KubernetesDaemonSetCreatePayload = Object.freeze({ + metadata: new KubernetesCommonMetadataPayload(), + spec: { + replicas: 0, + selector: { + matchLabels: { + app: '', + }, + }, + updateStrategy: { + type: 'RollingUpdate', + rollingUpdate: { + maxUnavailable: 1, + }, + }, + template: { + metadata: { + labels: { + app: '', + }, + }, + spec: { + containers: [ + { + name: '', + image: '', + env: [], + resources: { + limits: {}, + requests: {}, + }, + volumeMounts: [], + }, + ], + volumes: [], + }, + }, + }, +}); + +export class KubernetesDaemonSetCreatePayload { + constructor() { + Object.assign(this, JSON.parse(JSON.stringify(_KubernetesDaemonSetCreatePayload))); + } +} diff --git a/app/kubernetes/models/deploy.js b/app/kubernetes/models/deploy.js new file mode 100644 index 0000000..d4158f6 --- /dev/null +++ b/app/kubernetes/models/deploy.js @@ -0,0 +1,22 @@ +export const KubernetesDeployManifestTypes = Object.freeze({ + KUBERNETES: 1, +}); + +export const KubernetesDeployBuildMethods = Object.freeze({ + GIT: 1, + WEB_EDITOR: 2, + CUSTOM_TEMPLATE: 3, + URL: 4, + HELM: 5, +}); + +export const KubernetesDeployRequestMethods = Object.freeze({ + REPOSITORY: 'repository', + STRING: 'string', + URL: 'url', +}); + +export const RepositoryMechanismTypes = Object.freeze({ + WEBHOOK: 'Webhook', + INTERVAL: 'Interval', +}); diff --git a/app/kubernetes/models/deployment/models.js b/app/kubernetes/models/deployment/models.js new file mode 100644 index 0000000..05a6963 --- /dev/null +++ b/app/kubernetes/models/deployment/models.js @@ -0,0 +1,26 @@ +/** + * KubernetesDeployment Model + */ +const _KubernetesDeployment = Object.freeze({ + Namespace: '', + Name: '', + StackName: '', + ReplicaCount: 0, + ImageModel: null, + Env: [], + CpuLimit: 0, + MemoryLimit: 0, + VolumeMounts: [], + Volumes: [], + Secret: undefined, + ApplicationName: '', + ApplicationOwner: '', + Note: '', + Affinity: undefined, // KubernetesPodAffinity +}); + +export class KubernetesDeployment { + constructor() { + Object.assign(this, JSON.parse(JSON.stringify(_KubernetesDeployment))); + } +} diff --git a/app/kubernetes/models/deployment/payloads.js b/app/kubernetes/models/deployment/payloads.js new file mode 100644 index 0000000..920176f --- /dev/null +++ b/app/kubernetes/models/deployment/payloads.js @@ -0,0 +1,52 @@ +import { KubernetesCommonMetadataPayload } from '@/kubernetes/models/common/payloads'; + +/** + * KubernetesDeploymentCreatePayload Model + */ +const _KubernetesDeploymentCreatePayload = Object.freeze({ + metadata: new KubernetesCommonMetadataPayload(), + spec: { + replicas: 0, + selector: { + matchLabels: { + app: '', + }, + }, + strategy: { + type: 'RollingUpdate', + rollingUpdate: { + maxSurge: 0, + maxUnavailable: '100%', + }, + }, + template: { + metadata: { + labels: { + app: '', + }, + }, + spec: { + affinity: {}, + containers: [ + { + name: '', + image: '', + env: [], + resources: { + limits: {}, + requests: {}, + }, + volumeMounts: [], + }, + ], + volumes: [], + }, + }, + }, +}); + +export class KubernetesDeploymentCreatePayload { + constructor() { + Object.assign(this, JSON.parse(JSON.stringify(_KubernetesDeploymentCreatePayload))); + } +} diff --git a/app/kubernetes/models/event/models.js b/app/kubernetes/models/event/models.js new file mode 100644 index 0000000..f79c8e4 --- /dev/null +++ b/app/kubernetes/models/event/models.js @@ -0,0 +1,16 @@ +/** + * KubernetesEvent Model + */ +const _KubernetesEvent = Object.freeze({ + Id: '', + Date: 0, + Type: '', + Message: '', + Involved: {}, +}); + +export class KubernetesEvent { + constructor() { + Object.assign(this, JSON.parse(JSON.stringify(_KubernetesEvent))); + } +} diff --git a/app/kubernetes/models/history/models.js b/app/kubernetes/models/history/models.js new file mode 100644 index 0000000..026aaff --- /dev/null +++ b/app/kubernetes/models/history/models.js @@ -0,0 +1,31 @@ +export const KubernetesSystem_DefaultDeploymentUniqueLabelKey = 'pod-template-hash'; +export const KubernetesSystem_RevisionAnnotation = 'deployment.kubernetes.io/revision'; +export const KubernetesSystem_RevisionHistoryAnnotation = 'deployment.kubernetes.io/revision-history'; +export const KubernetesSystem_DesiredReplicasAnnotation = 'deployment.kubernetes.io/desired-replicas'; +export const KubernetesSystem_MaxReplicasAnnotation = 'deployment.kubernetes.io/max-replicas'; + +// annotationsToSkip lists the annotations that should be preserved from the deployment and not +// copied from the replicaset when rolling a deployment back +// var annotationsToSkip = map[string]bool{ +// corev1.LastAppliedConfigAnnotation: true, +// deploymentutil.RevisionAnnotation: true, +// deploymentutil.RevisionHistoryAnnotation: true, +// deploymentutil.DesiredReplicasAnnotation: true, +// deploymentutil.MaxReplicasAnnotation: true, +// appsv1.DeprecatedRollbackTo: true, +// } + +// LastAppliedConfigAnnotation is the annotation used to store the previous +// configuration of a resource for use in a three way diff by UpdateApplyAnnotation. +const LastAppliedConfigAnnotation = 'kubectl.kubernetes.io/last-applied-configuration'; + +const DeprecatedRollbackTo = 'deprecated.deployment.rollback.to'; + +export const KubernetesSystem_AnnotationsToSkip = { + [LastAppliedConfigAnnotation]: true, + [KubernetesSystem_RevisionAnnotation]: true, + [KubernetesSystem_RevisionHistoryAnnotation]: true, + [KubernetesSystem_DesiredReplicasAnnotation]: true, + [KubernetesSystem_MaxReplicasAnnotation]: true, + [DeprecatedRollbackTo]: true, +}; diff --git a/app/kubernetes/models/namespace/models.js b/app/kubernetes/models/namespace/models.js new file mode 100644 index 0000000..7888cdc --- /dev/null +++ b/app/kubernetes/models/namespace/models.js @@ -0,0 +1,14 @@ +export class KubernetesNamespace { + constructor() { + this.Id = ''; + this.Name = ''; + this.CreationDate = ''; + this.Status = ''; + this.Yaml = ''; + this.ResourcePoolName = ''; + this.ResourcePoolOwner = ''; + this.IsSystem = false; + } +} + +export const KUBERNETES_DEFAULT_SYSTEM_NAMESPACES = ['kube-system', 'kube-public', 'kube-node-lease', 'portainer']; diff --git a/app/kubernetes/models/namespace/payloads.js b/app/kubernetes/models/namespace/payloads.js new file mode 100644 index 0000000..eb0d42f --- /dev/null +++ b/app/kubernetes/models/namespace/payloads.js @@ -0,0 +1,14 @@ +import { KubernetesCommonMetadataPayload } from '../common/payloads'; + +/** + * KubernetesNamespaceCreatePayload Model + */ +const _KubernetesNamespaceCreatePayload = Object.freeze({ + metadata: new KubernetesCommonMetadataPayload(), +}); + +export class KubernetesNamespaceCreatePayload { + constructor() { + Object.assign(this, JSON.parse(JSON.stringify(_KubernetesNamespaceCreatePayload))); + } +} diff --git a/app/kubernetes/models/nodes-limits/models.js b/app/kubernetes/models/nodes-limits/models.js new file mode 100644 index 0000000..b2f9425 --- /dev/null +++ b/app/kubernetes/models/nodes-limits/models.js @@ -0,0 +1,65 @@ +import _ from 'lodash-es'; + +/** + * NodesLimits Model + */ +export class KubernetesNodesLimits { + constructor(nodesLimits) { + this.MaxCPU = 0; + this.MaxMemory = 0; + this.nodesLimits = this.convertCPU(nodesLimits); + + this.calculateMaxCPUMemory(); + } + + convertCPU(nodesLimits) { + _.forEach(nodesLimits, (value) => { + if (value.CPU) { + value.CPU /= 1000.0; + } + }); + return nodesLimits; + } + + calculateMaxCPUMemory() { + const nodesLimitsArray = Object.values(this.nodesLimits); + this.MaxCPU = _.maxBy(nodesLimitsArray, 'CPU').CPU; + this.MaxMemory = _.maxBy(nodesLimitsArray, 'Memory').Memory; + } + + // check if there is enough cpu and memory to allocate containers in replica mode + overflowForReplica(cpu, memory, instances) { + _.forEach(this.nodesLimits, (value) => { + instances -= Math.min(Math.floor(value.CPU / cpu), Math.floor(value.Memory / memory)); + }); + + return instances > 0; + } + + // check if there is enough cpu and memory to allocate containers in global mode + overflowForGlobal(cpu, memory) { + let overflow = false; + + _.forEach(this.nodesLimits, (value) => { + if (cpu > value.CPU || memory > value.Memory) { + overflow = true; + } + }); + + return overflow; + } + + excludesPods(pods, cpuLimit, memoryLimit) { + const nodesLimits = this.nodesLimits; + + _.forEach(pods, (value) => { + const node = value.Node; + if (node && nodesLimits[node]) { + nodesLimits[node].CPU += cpuLimit; + nodesLimits[node].Memory += memoryLimit; + } + }); + + this.calculateMaxCPUMemory(); + } +} diff --git a/app/kubernetes/models/port/models.js b/app/kubernetes/models/port/models.js new file mode 100644 index 0000000..6b10040 --- /dev/null +++ b/app/kubernetes/models/port/models.js @@ -0,0 +1,34 @@ +/** + * PortMappingPort Model + */ +const _KubernetesPortMappingPort = Object.freeze({ + Port: 0, + TargetPort: 0, + Protocol: '', + IngressRules: [], // KubernetesIngressRule[] +}); + +export class KubernetesPortMappingPort { + constructor() { + Object.assign(this, JSON.parse(JSON.stringify(_KubernetesPortMappingPort))); + } +} + +/** + * PortMapping Model + */ +const _KubernetesPortMapping = Object.freeze({ + Expanded: false, + Highlighted: false, + ResourcePool: '', + ServiceType: '', + ApplicationOwner: '', + LoadBalancerIPAddress: '', + Ports: [], +}); + +export class KubernetesPortMapping { + constructor() { + Object.assign(this, JSON.parse(JSON.stringify(_KubernetesPortMapping))); + } +} diff --git a/app/kubernetes/models/resource-pool/formValues.js b/app/kubernetes/models/resource-pool/formValues.js new file mode 100644 index 0000000..9188658 --- /dev/null +++ b/app/kubernetes/models/resource-pool/formValues.js @@ -0,0 +1,57 @@ +import { KubernetesNginxRewriteTargetAnnotations, KubernetesNginxUseregexAnnotations, KubernetesTraefikRewriteTargetAnnotations } from '@/kubernetes/ingress/constants'; + +export function KubernetesResourcePoolFormValues(defaults) { + this.Name = ''; + this.MemoryLimit = defaults.MemoryLimit; + this.CpuLimit = defaults.CpuLimit; + this.HasQuota = false; + this.IngressClasses = []; // KubernetesResourcePoolIngressClassFormValue + this.Registries = []; // RegistryViewModel + this.EndpointId = 0; + this.IsSystem = false; +} + +/** + * @param {KubernetesIngressClass} ingressClass + */ +export function KubernetesResourcePoolIngressClassFormValue(ingressClass) { + return { + Namespace: undefined, // will be filled inside ResourcePoolService.create + IngressClass: ingressClass, + RewriteTarget: false, + Annotations: [], // KubernetesResourcePoolIngressClassAnnotationFormValue + Hosts: [], + Selected: false, + WasSelected: false, + AdvancedConfig: false, + Paths: [], // will be filled to save IngressClass.Paths inside ingressClassesToFormValues() on RP EDIT + }; +} + +export function KubernetesResourcePoolIngressClassAnnotationFormValue() { + return { + Key: '', + Value: '', + }; +} + +export function KubernetesResourcePoolIngressClassHostFormValue() { + return { + Host: '', + PreviousHost: '', + NeedsDeletion: false, + IsNew: true, + }; +} + +export function KubernetesResourcePoolNginxRewriteAnnotationFormValue() { + return KubernetesNginxRewriteTargetAnnotations; +} + +export function KubernetesResourcePoolNginxUseregexAnnotationFormValue() { + return KubernetesNginxUseregexAnnotations; +} + +export function KubernetesResourcePoolTraefikRewriteAnnotationFormValue() { + return KubernetesTraefikRewriteTargetAnnotations; +} diff --git a/app/kubernetes/models/resource-pool/models.js b/app/kubernetes/models/resource-pool/models.js new file mode 100644 index 0000000..b953cc5 --- /dev/null +++ b/app/kubernetes/models/resource-pool/models.js @@ -0,0 +1,17 @@ +export const KubernetesPortainerResourcePoolNameLabel = 'io.portainer.kubernetes.resourcepool.name'; + +export const KubernetesPortainerResourcePoolOwnerLabel = 'io.portainer.kubernetes.resourcepool.owner'; + +export const KubernetesPortainerNamespaceSystemLabel = 'io.portainer.kubernetes.namespace.system'; + +/** + * KubernetesResourcePool Model + */ +export function KubernetesResourcePool() { + return { + Namespace: {}, // KubernetesNamespace + Quota: undefined, // KubernetesResourceQuota, + Ingresses: [], // KubernetesIngress[] + Yaml: '', + }; +} diff --git a/app/kubernetes/models/resource-quota/models.js b/app/kubernetes/models/resource-quota/models.js new file mode 100644 index 0000000..12a273d --- /dev/null +++ b/app/kubernetes/models/resource-quota/models.js @@ -0,0 +1,27 @@ +import KubernetesResourceQuotaHelper from '@/kubernetes/helpers/resourceQuotaHelper'; + +export const KubernetesPortainerResourceQuotaPrefix = 'portainer-rq-'; +export const KubernetesPortainerResourceQuotaCPULimit = 'limits.cpu'; +export const KubernetesPortainerResourceQuotaMemoryLimit = 'limits.memory'; +export const KubernetesPortainerResourceQuotaCPURequest = 'requests.cpu'; +export const KubernetesPortainerResourceQuotaMemoryRequest = 'requests.memory'; + +export const KubernetesResourceQuotaDefaults = { + CpuLimit: 0, + MemoryLimit: 0, +}; + +export function KubernetesResourceQuota(namespace) { + return { + Id: '', + Namespace: namespace ? namespace : '', + Name: namespace ? KubernetesResourceQuotaHelper.generateResourceQuotaName(namespace) : '', + CpuLimit: KubernetesResourceQuotaDefaults.CpuLimit, + MemoryLimit: KubernetesResourceQuotaDefaults.MemoryLimit, + CpuLimitUsed: KubernetesResourceQuotaDefaults.CpuLimit, + MemoryLimitUsed: KubernetesResourceQuotaDefaults.MemoryLimit, + Yaml: '', + ResourcePoolName: '', + ResourcePoolOwner: '', + }; +} diff --git a/app/kubernetes/models/resource-quota/payloads.js b/app/kubernetes/models/resource-quota/payloads.js new file mode 100644 index 0000000..78f6fa8 --- /dev/null +++ b/app/kubernetes/models/resource-quota/payloads.js @@ -0,0 +1,21 @@ +import { KubernetesCommonMetadataPayload } from '@/kubernetes/models/common/payloads'; +import { + KubernetesPortainerResourceQuotaCPURequest, + KubernetesPortainerResourceQuotaMemoryRequest, + KubernetesPortainerResourceQuotaCPULimit, + KubernetesPortainerResourceQuotaMemoryLimit, +} from './models'; + +export function KubernetesResourceQuotaCreatePayload() { + return { + metadata: new KubernetesCommonMetadataPayload(), + spec: { + hard: { + [KubernetesPortainerResourceQuotaCPURequest]: 0, + [KubernetesPortainerResourceQuotaMemoryRequest]: 0, + [KubernetesPortainerResourceQuotaCPULimit]: 0, + [KubernetesPortainerResourceQuotaMemoryLimit]: 0, + }, + }, + }; +} diff --git a/app/kubernetes/models/resource-reservation/models.js b/app/kubernetes/models/resource-reservation/models.js new file mode 100644 index 0000000..ad13f6c --- /dev/null +++ b/app/kubernetes/models/resource-reservation/models.js @@ -0,0 +1,13 @@ +/** + * KubernetesResourceReservation Model + */ +const _KubernetesResourceReservation = Object.freeze({ + Memory: 0, + CPU: 0, +}); + +export class KubernetesResourceReservation { + constructor() { + Object.assign(this, JSON.parse(JSON.stringify(_KubernetesResourceReservation))); + } +} diff --git a/app/kubernetes/models/resource-types/models.js b/app/kubernetes/models/resource-types/models.js new file mode 100644 index 0000000..4e73964 --- /dev/null +++ b/app/kubernetes/models/resource-types/models.js @@ -0,0 +1,19 @@ +export const KubernetesResourceTypes = Object.freeze({ + NAMESPACE: 'Namespace', + RESOURCEQUOTA: 'ResourceQuota', + CONFIGMAP: 'ConfigMap', + SECRET: 'Secret', + DEPLOYMENT: 'Deployment', + STATEFULSET: 'StatefulSet', + DAEMONSET: 'Daemonset', + PERSISTENT_VOLUME_CLAIM: 'PersistentVolumeClaim', + SERVICE: 'Service', + INGRESS: 'Ingress', + HORIZONTAL_POD_AUTOSCALER: 'HorizontalPodAutoscaler', +}); + +export const KubernetesResourceActions = Object.freeze({ + CREATE: 'Create', + UPDATE: 'Update', + DELETE: 'Delete', +}); diff --git a/app/kubernetes/models/secret/models.js b/app/kubernetes/models/secret/models.js new file mode 100644 index 0000000..7cababc --- /dev/null +++ b/app/kubernetes/models/secret/models.js @@ -0,0 +1,22 @@ +/** + * KubernetesApplicationSecret Model + */ +const _KubernetesApplicationSecret = Object.freeze({ + Id: 0, + Name: '', + Namespace: '', + Type: '', + CreationDate: '', + ConfigurationOwner: '', + Yaml: '', + Data: [], + SecretType: '', + Annotations: [], + Labels: {}, +}); + +export class KubernetesApplicationSecret { + constructor() { + Object.assign(this, JSON.parse(JSON.stringify(_KubernetesApplicationSecret))); + } +} diff --git a/app/kubernetes/models/secret/payloads.js b/app/kubernetes/models/secret/payloads.js new file mode 100644 index 0000000..90bf59a --- /dev/null +++ b/app/kubernetes/models/secret/payloads.js @@ -0,0 +1,33 @@ +import { KubernetesCommonMetadataPayload } from '@/kubernetes/models/common/payloads'; + +/** + * KubernetesSecretCreatePayload Model + */ +const _KubernetesSecretCreatePayload = Object.freeze({ + metadata: new KubernetesCommonMetadataPayload(), + type: '', + data: {}, + stringData: {}, +}); + +export class KubernetesSecretCreatePayload { + constructor() { + Object.assign(this, JSON.parse(JSON.stringify(_KubernetesSecretCreatePayload))); + } +} + +/** + * KubernetesSecretUpdatePayload Model + */ +const _KubernetesSecretUpdatePayload = Object.freeze({ + metadata: new KubernetesCommonMetadataPayload(), + type: '', + data: {}, + stringData: {}, +}); + +export class KubernetesSecretUpdatePayload { + constructor() { + Object.assign(this, JSON.parse(JSON.stringify(_KubernetesSecretUpdatePayload))); + } +} diff --git a/app/kubernetes/models/service/models.js b/app/kubernetes/models/service/models.js new file mode 100644 index 0000000..06dfb56 --- /dev/null +++ b/app/kubernetes/models/service/models.js @@ -0,0 +1,73 @@ +export const KubernetesServiceHeadlessPrefix = 'headless-'; +export const KubernetesServiceHeadlessClusterIP = 'None'; +export const KubernetesServiceTypes = Object.freeze({ + LOAD_BALANCER: 'LoadBalancer', + NODE_PORT: 'NodePort', + CLUSTER_IP: 'ClusterIP', + INGRESS: 'Ingress', +}); + +/** + * KubernetesService Model + */ +const _KubernetesService = Object.freeze({ + Headless: false, + Namespace: '', + Name: '', + StackName: '', + Ports: [], + Type: '', + ClusterIP: '', + ApplicationName: '', + ApplicationOwner: '', + Note: '', + Ingress: false, + Selector: {}, + nodePortError: false, + servicePortError: false, +}); + +export class KubernetesService { + constructor() { + Object.assign(this, JSON.parse(JSON.stringify(_KubernetesService))); + } +} + +const _KubernetesIngressService = Object.freeze({ + Headless: false, + Namespace: '', + Name: '', + StackName: '', + Ports: [], + Type: '', + ClusterIP: '', + ApplicationName: '', + ApplicationOwner: '', + Note: '', + Ingress: true, + IngressRoute: [], +}); + +export class KubernetesIngressService { + constructor() { + Object.assign(this, JSON.parse(JSON.stringify(_KubernetesIngressService))); + } +} + +/** + * KubernetesServicePort Model + */ +const _KubernetesServicePort = Object.freeze({ + name: '', + port: 0, + targetPort: 0, + protocol: '', + nodePort: 0, + ingress: '', +}); + +export class KubernetesServicePort { + constructor() { + Object.assign(this, JSON.parse(JSON.stringify(_KubernetesServicePort))); + } +} diff --git a/app/kubernetes/models/service/payloads.js b/app/kubernetes/models/service/payloads.js new file mode 100644 index 0000000..ea72a6e --- /dev/null +++ b/app/kubernetes/models/service/payloads.js @@ -0,0 +1,22 @@ +import { KubernetesCommonMetadataPayload } from '@/kubernetes/models/common/payloads'; + +/** + * KubernetesServiceCreatePayload Model + */ +const _KubernetesServiceCreatePayload = Object.freeze({ + metadata: new KubernetesCommonMetadataPayload(), + spec: { + ports: [], + selector: { + app: '', + }, + type: '', + clusterIP: '', + }, +}); + +export class KubernetesServiceCreatePayload { + constructor() { + Object.assign(this, JSON.parse(JSON.stringify(_KubernetesServiceCreatePayload))); + } +} diff --git a/app/kubernetes/models/stack/models.js b/app/kubernetes/models/stack/models.js new file mode 100644 index 0000000..1c9c274 --- /dev/null +++ b/app/kubernetes/models/stack/models.js @@ -0,0 +1,14 @@ +/** + * Stack Model + */ +const _KubernetesStack = Object.freeze({ + Name: '', + ResourcePool: '', + Applications: [], +}); + +export class KubernetesStack { + constructor() { + Object.assign(this, JSON.parse(JSON.stringify(_KubernetesStack))); + } +} diff --git a/app/kubernetes/models/stateful-set/models.js b/app/kubernetes/models/stateful-set/models.js new file mode 100644 index 0000000..fa44579 --- /dev/null +++ b/app/kubernetes/models/stateful-set/models.js @@ -0,0 +1,28 @@ +/** + * KubernetesStatefulSet Model + */ +const _KubernetesStatefulSet = Object.freeze({ + Namespace: '', + Name: '', + StackName: '', + ReplicaCount: 0, + ImageModel: null, + Env: [], + CpuLimit: '', + MemoryLimit: '', + VolumeMounts: [], + Volumes: [], + Secret: undefined, + VolumeClaims: [], + ServiceName: '', + ApplicationName: '', + ApplicationOwner: '', + Note: '', + Affinity: undefined, // KubernetesPodAffinity +}); + +export class KubernetesStatefulSet { + constructor() { + Object.assign(this, JSON.parse(JSON.stringify(_KubernetesStatefulSet))); + } +} diff --git a/app/kubernetes/models/stateful-set/payloads.js b/app/kubernetes/models/stateful-set/payloads.js new file mode 100644 index 0000000..d2cde94 --- /dev/null +++ b/app/kubernetes/models/stateful-set/payloads.js @@ -0,0 +1,52 @@ +import { KubernetesCommonMetadataPayload } from '@/kubernetes/models/common/payloads'; + +/** + * KubernetesStatefulSetCreatePayload Model + */ +const _KubernetesStatefulSetCreatePayload = Object.freeze({ + metadata: new KubernetesCommonMetadataPayload(), + spec: { + replicas: 0, + serviceName: '', + selector: { + matchLabels: { + app: '', + }, + }, + volumeClaimTemplates: [], + updateStrategy: { + type: 'RollingUpdate', + rollingUpdate: { + partition: 0, + }, + }, + template: { + metadata: { + labels: { + app: '', + }, + }, + spec: { + containers: [ + { + name: '', + image: '', + env: [], + resources: { + limits: {}, + requests: {}, + }, + volumeMounts: [], + }, + ], + volumes: [], + }, + }, + }, +}); + +export class KubernetesStatefulSetCreatePayload { + constructor() { + Object.assign(this, JSON.parse(JSON.stringify(_KubernetesStatefulSetCreatePayload))); + } +} diff --git a/app/kubernetes/models/storage-class/StorageClass.ts b/app/kubernetes/models/storage-class/StorageClass.ts new file mode 100644 index 0000000..49d65bb --- /dev/null +++ b/app/kubernetes/models/storage-class/StorageClass.ts @@ -0,0 +1,9 @@ +export class StorageClass { + Name: string = ''; + + Provisioner: string = ''; + + ReclaimPolicy: string = ''; + + AllowVolumeExpansion: boolean = false; +} diff --git a/app/kubernetes/models/storage-class/models.js b/app/kubernetes/models/storage-class/models.js new file mode 100644 index 0000000..556f4c8 --- /dev/null +++ b/app/kubernetes/models/storage-class/models.js @@ -0,0 +1,21 @@ +export { StorageClass as KubernetesStorageClass } from './StorageClass'; + +/** + * KubernetesStorageClassAccessPolicies Model + */ +const _KubernetesStorageClassAccessPolicies = Object.freeze([ + { + Name: 'ReadWriteOnce', + Description: 'Allow read-write from a single pod only (RWO)', + selected: true, + }, + { + Name: 'ReadWriteMany', + Description: 'Allow read-write access from one or more pods concurrently (RWX)', + selected: false, + }, +]); + +export function KubernetesStorageClassAccessPolicies() { + return JSON.parse(JSON.stringify(_KubernetesStorageClassAccessPolicies)); +} diff --git a/app/kubernetes/models/storage-class/payload.js b/app/kubernetes/models/storage-class/payload.js new file mode 100644 index 0000000..c0eced1 --- /dev/null +++ b/app/kubernetes/models/storage-class/payload.js @@ -0,0 +1,16 @@ +import { KubernetesCommonMetadataPayload } from '@/kubernetes/models/common/payloads'; + +/** + * KubernetesStorageClassCreatePayload Model + */ +const _KubernetesStorageClassCreatePayload = Object.freeze({ + metadata: new KubernetesCommonMetadataPayload(), + provisioner: '', + allowVolumeExpansion: false, +}); + +export class KubernetesStorageClassCreatePayload { + constructor() { + Object.assign(this, JSON.parse(JSON.stringify(_KubernetesStorageClassCreatePayload))); + } +} diff --git a/app/kubernetes/models/volume/PersistentVolumeClaim.ts b/app/kubernetes/models/volume/PersistentVolumeClaim.ts new file mode 100644 index 0000000..5db4e0a --- /dev/null +++ b/app/kubernetes/models/volume/PersistentVolumeClaim.ts @@ -0,0 +1,33 @@ +import uuidv4 from 'uuid/v4'; + +import { StorageClass } from '../storage-class/StorageClass'; + +export class PersistentVolumeClaim { + Id: string = uuidv4(); + + Name: string = ''; + + PreviousName: string = ''; + + Namespace: string = ''; + + Storage: number = 0; + + storageClass?: StorageClass; // KubernetesStorageClass + + CreationDate: string = ''; + + ApplicationOwner: string = ''; + + AccessModes: Array = []; + + ApplicationName: string = ''; + + /** + * used for Application creation from `ApplicationFormValues` + * not used from API conversion + */ + MountPath: string = ''; + + Yaml: string = ''; +} diff --git a/app/kubernetes/models/volume/Volume.ts b/app/kubernetes/models/volume/Volume.ts new file mode 100644 index 0000000..ae5e534 --- /dev/null +++ b/app/kubernetes/models/volume/Volume.ts @@ -0,0 +1,14 @@ +import { KubernetesApplication } from '../application/models'; +import { KubernetesResourcePool } from '../resource-pool/models'; + +import { PersistentVolumeClaim } from './PersistentVolumeClaim'; + +type VolumeResourcePool = ReturnType; + +export class Volume { + ResourcePool?: VolumeResourcePool = {} as VolumeResourcePool; + + PersistentVolumeClaim: PersistentVolumeClaim = {} as PersistentVolumeClaim; + + Applications: KubernetesApplication[] = []; +} diff --git a/app/kubernetes/models/volume/models.js b/app/kubernetes/models/volume/models.js new file mode 100644 index 0000000..4228cd9 --- /dev/null +++ b/app/kubernetes/models/volume/models.js @@ -0,0 +1,2 @@ +export { Volume as KubernetesVolume } from './Volume'; +export { PersistentVolumeClaim as KubernetesPersistentVolumeClaim } from './PersistentVolumeClaim'; diff --git a/app/kubernetes/models/volume/payloads.js b/app/kubernetes/models/volume/payloads.js new file mode 100644 index 0000000..5c3a3ca --- /dev/null +++ b/app/kubernetes/models/volume/payloads.js @@ -0,0 +1,23 @@ +import { KubernetesCommonMetadataPayload } from '@/kubernetes/models/common/payloads'; + +/** + * KubernetesPersistentVolumClaimCreatePayload Model + */ +const _KubernetesPersistentVolumClaimCreatePayload = Object.freeze({ + metadata: new KubernetesCommonMetadataPayload(), + spec: { + accessModes: ['ReadWriteOnce'], + resources: { + requests: { + storage: '', + }, + }, + storageClassName: '', + }, +}); + +export class KubernetesPersistentVolumClaimCreatePayload { + constructor() { + Object.assign(this, JSON.parse(JSON.stringify(_KubernetesPersistentVolumClaimCreatePayload))); + } +} diff --git a/app/kubernetes/node/converter.js b/app/kubernetes/node/converter.js new file mode 100644 index 0000000..de4fb0d --- /dev/null +++ b/app/kubernetes/node/converter.js @@ -0,0 +1,172 @@ +import _ from 'lodash-es'; + +import * as JsonPatch from 'fast-json-patch'; +import { KubernetesNode, KubernetesNodeDetails, KubernetesNodeTaint, KubernetesNodeAvailabilities, KubernetesPortainerNodeDrainLabel } from '@/kubernetes/node/models'; +import { KubernetesNodeFormValues, KubernetesNodeTaintFormValues, KubernetesNodeLabelFormValues } from '@/kubernetes/node/formValues'; +import { KubernetesNodeCreatePayload, KubernetesNodeTaintPayload } from '@/kubernetes/node/payload'; +import { parseCPU } from '@/react/kubernetes/utils'; + +class KubernetesNodeConverter { + static apiToNode(data, res) { + if (!res) { + res = new KubernetesNode(); + } + res.Id = data.metadata.uid; + const hostName = _.find(data.status.addresses, { type: 'Hostname' }); + res.Name = data.metadata.name ? data.metadata.name : hostName.address; + res.Labels = data.metadata.labels; + // most kube clusters set control-plane label, older clusters set master, microk8s doesn't have either but instead sets microk8s-controlplane + let masters = ['node-role.kubernetes.io/control-plane', 'node-role.kubernetes.io/master', 'node.kubernetes.io/microk8s-controlplane']; + res.Role = _.some(masters, (master) => _.has(data.metadata.labels, master)) ? 'Master' : 'Worker'; + + const ready = _.find(data.status.conditions, { type: KubernetesNodeConditionTypes.READY }); + const memoryPressure = _.find(data.status.conditions, { type: KubernetesNodeConditionTypes.MEMORY_PRESSURE }); + const PIDPressure = _.find(data.status.conditions, { type: KubernetesNodeConditionTypes.PID_PRESSURE }); + const diskPressure = _.find(data.status.conditions, { type: KubernetesNodeConditionTypes.DISK_PRESSURE }); + const networkUnavailable = _.find(data.status.conditions, { type: KubernetesNodeConditionTypes.NETWORK_UNAVAILABLE }); + + res.Conditions = { + MemoryPressure: memoryPressure && memoryPressure.status === 'True', + PIDPressure: PIDPressure && PIDPressure.status === 'True', + DiskPressure: diskPressure && diskPressure.status === 'True', + NetworkUnavailable: networkUnavailable && networkUnavailable.status === 'True', + }; + + res.Availability = KubernetesNodeAvailabilities.ACTIVE; + if (data.spec.unschedulable === true) { + res.Availability = _.has(data.metadata.labels, KubernetesPortainerNodeDrainLabel) ? KubernetesNodeAvailabilities.DRAIN : KubernetesNodeAvailabilities.PAUSE; + } + + if (ready.status === 'False') { + res.Status = 'Unhealthy'; + } else if (ready.status === 'Unknown' || res.Conditions.MemoryPressure || res.Conditions.PIDPressure || res.Conditions.DiskPressure || res.Conditions.NetworkUnavailable) { + res.Status = 'Warning'; + } else { + res.Status = 'Ready'; + } + + res.CPU = parseCPU(data.status.allocatable.cpu); + res.Memory = data.status.allocatable.memory; + res.Version = data.status.nodeInfo.kubeletVersion; + const internalIP = _.find(data.status.addresses, { type: 'InternalIP' }); + res.IPAddress = internalIP ? internalIP.address : '-'; + res.Taints = _.map(data.spec.taints, (taint) => { + const res = new KubernetesNodeTaint(); + res.Key = taint.key; + res.Value = taint.value; + res.Effect = taint.effect; + return res; + }); + return res; + } + + static apiToNodeDetails(data, yaml) { + let res = new KubernetesNodeDetails(); + res = KubernetesNodeConverter.apiToNode(data, res); + res.CreationDate = data.metadata.creationTimestamp; + res.OS.Architecture = data.status.nodeInfo.architecture; + res.OS.Platform = data.status.nodeInfo.operatingSystem; + res.OS.Image = data.status.nodeInfo.osImage; + res.Yaml = yaml ? yaml.data : ''; + return res; + } + + static nodeToFormValues(node) { + const res = new KubernetesNodeFormValues(); + + res.Availability = node.Availability; + + res.Taints = _.map(node.Taints, (taint) => { + const res = new KubernetesNodeTaintFormValues(); + res.Key = taint.Key; + res.Value = taint.Value; + res.Effect = taint.Effect; + res.NeedsDeletion = false; + res.IsNew = false; + return res; + }); + + res.Labels = _.map(node.Labels, (value, key) => { + const res = new KubernetesNodeLabelFormValues(); + res.Key = key; + res.Value = value; + res.NeedsDeletion = false; + res.IsNew = false; + return res; + }); + + return res; + } + + static formValuesToNode(node, formValues) { + const res = angular.copy(node); + + res.Availability = formValues.Availability; + + const filteredTaints = _.filter(formValues.Taints, (taint) => !taint.NeedsDeletion); + res.Taints = _.map(filteredTaints, (item) => { + const taint = new KubernetesNodeTaint(); + taint.Key = item.Key; + taint.Value = item.Value; + taint.Effect = item.Effect; + return taint; + }); + + const filteredLabels = _.filter(formValues.Labels, (label) => !label.NeedsDeletion); + res.Labels = _.reduce( + filteredLabels, + (acc, item) => { + acc[item.Key] = item.Value ? item.Value : ''; + return acc; + }, + {} + ); + + return res; + } + + static createPayload(node) { + const payload = new KubernetesNodeCreatePayload(); + payload.metadata.name = node.Name; + + const taints = _.map(node.Taints, (taint) => { + const res = new KubernetesNodeTaintPayload(); + res.key = taint.Key; + res.value = taint.Value; + res.effect = taint.Effect; + return res; + }); + + payload.spec.taints = taints.length ? taints : undefined; + + payload.metadata.labels = node.Labels; + + if (node.Availability !== KubernetesNodeAvailabilities.ACTIVE) { + payload.spec.unschedulable = true; + if (node.Availability === KubernetesNodeAvailabilities.DRAIN) { + payload.metadata.labels[KubernetesPortainerNodeDrainLabel] = ''; + } else { + delete payload.metadata.labels[KubernetesPortainerNodeDrainLabel]; + } + } + + return payload; + } + + static patchPayload(oldNode, newNode) { + const oldPayload = KubernetesNodeConverter.createPayload(oldNode); + const newPayload = KubernetesNodeConverter.createPayload(newNode); + const payload = JsonPatch.compare(oldPayload, newPayload); + return payload; + } +} + +export const KubernetesNodeConditionTypes = Object.freeze({ + READY: 'Ready', + MEMORY_PRESSURE: 'MemoryPressure', + PID_PRESSURE: 'PIDPressure', + DISK_PRESSURE: 'DiskPressure', + NETWORK_UNAVAILABLE: 'NetworkUnavailable', +}); + +export default KubernetesNodeConverter; diff --git a/app/kubernetes/node/filters.js b/app/kubernetes/node/filters.js new file mode 100644 index 0000000..d848a95 --- /dev/null +++ b/app/kubernetes/node/filters.js @@ -0,0 +1,35 @@ +import _ from 'lodash-es'; + +angular + .module('portainer.kubernetes') + .filter('kubernetesNodeStatusColor', function () { + 'use strict'; + return function (text) { + var status = _.toLower(text); + switch (status) { + case 'ready': + return 'success'; + case 'warning': + return 'warning'; + default: + return 'danger'; + } + }; + }) + .filter('kubernetesNodeConditionsMessage', function () { + 'use strict'; + return function (conditions) { + if (conditions.MemoryPressure) { + return 'Node memory is running low'; + } + if (conditions.PIDPressure) { + return 'Too many processes running on the node'; + } + if (conditions.DiskPressure) { + return 'Node disk capacity is running low'; + } + if (conditions.NetworkUnavailable) { + return 'Incorrect node network configuration'; + } + }; + }); diff --git a/app/kubernetes/node/formValues.js b/app/kubernetes/node/formValues.js new file mode 100644 index 0000000..51e9cb4 --- /dev/null +++ b/app/kubernetes/node/formValues.js @@ -0,0 +1,41 @@ +const _KubernetesNodeFormValues = Object.freeze({ + Taints: [], + Labels: [], + Availability: '', +}); + +export class KubernetesNodeFormValues { + constructor() { + Object.assign(JSON.parse(JSON.stringify(_KubernetesNodeFormValues))); + } +} + +const _KubernetesNodeTaintFormValues = Object.freeze({ + Key: '', + Values: '', + Effect: '', + NeedsDeletion: false, + IsNew: false, + IsChanged: false, +}); + +export class KubernetesNodeTaintFormValues { + constructor() { + Object.assign(JSON.parse(JSON.stringify(_KubernetesNodeTaintFormValues))); + } +} + +const _KubernetesNodeLabelFormValues = Object.freeze({ + Key: '', + Values: '', + NeedsDeletion: false, + IsNew: false, + IsUsed: false, + IsChanged: false, +}); + +export class KubernetesNodeLabelFormValues { + constructor() { + Object.assign(JSON.parse(JSON.stringify(_KubernetesNodeLabelFormValues))); + } +} diff --git a/app/kubernetes/node/helper.js b/app/kubernetes/node/helper.js new file mode 100644 index 0000000..73ae999 --- /dev/null +++ b/app/kubernetes/node/helper.js @@ -0,0 +1,34 @@ +import _ from 'lodash-es'; + +export class KubernetesNodeHelper { + static isSystemLabel(label) { + return !label.IsNew && (_.startsWith(label.Key, 'beta.kubernetes.io') || _.startsWith(label.Key, 'kubernetes.io') || label.Key === 'node-role.kubernetes.io/master'); + } + + static reorderLabels(labels) { + return _.sortBy(labels, (label) => { + return !KubernetesNodeHelper.isSystemLabel(label); + }); + } + + static computeUsedLabels(applications, labels) { + const pods = _.flatten(_.map(applications, 'Pods')); + const nodeSelectors = _.uniq(_.flatten(_.map(pods, 'NodeSelector'))); + + return _.map(labels, (label) => { + label.IsUsed = _.find(nodeSelectors, (ns) => ns && ns[label.Key] === label.Value) ? true : false; + return label; + }); + } + + static generateNodeLabelsFromNodes(nodes) { + const pairs = _.flatMap(nodes, (node) => { + return _.map(_.toPairs(node.Labels), ([k, v]) => { + return { key: k, value: v }; + }); + }); + return _.map(_.groupBy(pairs, 'key'), (values, key) => { + return { Key: key, Values: _.uniq(_.map(values, 'value')) }; + }); + } +} diff --git a/app/kubernetes/node/models.js b/app/kubernetes/node/models.js new file mode 100644 index 0000000..86bf8da --- /dev/null +++ b/app/kubernetes/node/models.js @@ -0,0 +1,74 @@ +export const KubernetesPortainerNodeDrainLabel = 'io.portainer/node-status-drain'; + +/** + * KubernetesNode Model + */ +const _KubernetesNode = Object.freeze({ + Id: '', + Name: '', + Labels: {}, + Role: '', + Status: '', + CPU: 0, + Memory: '', + Version: '', + IPAddress: '', + Api: false, + Taints: [], + Port: 0, + Availability: '', +}); + +export class KubernetesNode { + constructor() { + Object.assign(this, JSON.parse(JSON.stringify(_KubernetesNode))); + } +} + +/** + * KubernetesNodeDetails Model + */ +const _KubernetesNodeDetails = Object.freeze({ + CreationDate: '', + OS: { + Architecture: '', + Platform: '', + Image: '', + }, + Conditions: [], + Yaml: '', +}); + +export class KubernetesNodeDetails { + constructor() { + Object.assign(this, JSON.parse(JSON.stringify(_KubernetesNode))); + Object.assign(this, JSON.parse(JSON.stringify(_KubernetesNodeDetails))); + } +} + +/** + * KubernetesNodeTaint Model + */ +const _KubernetesNodeTaint = Object.freeze({ + Key: '', + Value: '', + Effect: '', +}); + +export class KubernetesNodeTaint { + constructor() { + Object.assign(this, JSON.parse(JSON.stringify(_KubernetesNodeTaint))); + } +} + +export const KubernetesNodeAvailabilities = Object.freeze({ + ACTIVE: 'Active', + PAUSE: 'Pause', + DRAIN: 'Drain', +}); + +export const KubernetesNodeTaintEffects = Object.freeze({ + NOSCHEDULE: 'NoSchedule', + PREFERNOSCHEDULE: 'PreferNoSchedule', + NOEXECUTE: 'NoExecute', +}); diff --git a/app/kubernetes/node/payload.js b/app/kubernetes/node/payload.js new file mode 100644 index 0000000..fbd974f --- /dev/null +++ b/app/kubernetes/node/payload.js @@ -0,0 +1,31 @@ +/** + * KubernetesNode Create Payload Model + * Note: The current payload is here just to create patch payload. + */ +const _KubernetesNodeCreatePayload = Object.freeze({ + metadata: { + name: '', + labels: {}, + }, + spec: { + taints: undefined, + }, +}); + +export class KubernetesNodeCreatePayload { + constructor() { + Object.assign(this, JSON.parse(JSON.stringify(_KubernetesNodeCreatePayload))); + } +} + +const _KubernetesNodeTaintPayload = Object.freeze({ + key: '', + value: '', + effect: '', +}); + +export class KubernetesNodeTaintPayload { + constructor() { + Object.assign(this, JSON.parse(JSON.stringify(_KubernetesNodeTaintPayload))); + } +} diff --git a/app/kubernetes/node/rest.js b/app/kubernetes/node/rest.js new file mode 100644 index 0000000..7265156 --- /dev/null +++ b/app/kubernetes/node/rest.js @@ -0,0 +1,42 @@ +import { rawResponse } from '@/kubernetes/rest/response/transform'; + +angular.module('portainer.kubernetes').factory('KubernetesNodes', [ + '$resource', + 'API_ENDPOINT_ENDPOINTS', + 'EndpointProvider', + function KubernetesNodesFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { + 'use strict'; + return function () { + const url = API_ENDPOINT_ENDPOINTS + '/:endpointId/kubernetes/api/v1/nodes/:id/:action'; + return $resource( + url, + { + endpointId: EndpointProvider.endpointID, + }, + { + get: { + method: 'GET', + ignoreLoadingBar: true, + }, + getYaml: { + method: 'GET', + headers: { + Accept: 'application/yaml', + }, + transformResponse: rawResponse, + ignoreLoadingBar: true, + }, + create: { method: 'POST' }, + update: { method: 'PUT' }, + patch: { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json-patch+json', + }, + }, + delete: { method: 'DELETE' }, + } + ); + }; + }, +]); diff --git a/app/kubernetes/node/service.js b/app/kubernetes/node/service.js new file mode 100644 index 0000000..df9c4dc --- /dev/null +++ b/app/kubernetes/node/service.js @@ -0,0 +1,75 @@ +import angular from 'angular'; +import _ from 'lodash-es'; + +import PortainerError from '@/portainer/error'; +import KubernetesNodeConverter from '@/kubernetes/node/converter'; +import { KubernetesCommonParams } from '@/kubernetes/models/common/params'; + +class KubernetesNodeService { + /* @ngInject */ + constructor($async, KubernetesNodes) { + this.$async = $async; + this.KubernetesNodes = KubernetesNodes; + + this.getAsync = this.getAsync.bind(this); + this.getAllAsync = this.getAllAsync.bind(this); + this.patchAsync = this.patchAsync.bind(this); + } + + /** + * GET + */ + async getAsync(name) { + try { + const params = new KubernetesCommonParams(); + params.id = name; + const [details, yaml] = await Promise.all([this.KubernetesNodes().get(params).$promise, this.KubernetesNodes().getYaml(params).$promise]); + return KubernetesNodeConverter.apiToNodeDetails(details, yaml); + } catch (err) { + // changing the structure of error message to fix [object, Object] issue + const errData = err.data; + throw new PortainerError('Unable to retrieve node details', errData); + } + } + + async getAllAsync() { + try { + const data = await this.KubernetesNodes().get().$promise; + return _.map(data.items, (item) => KubernetesNodeConverter.apiToNode(item)); + } catch (err) { + throw { msg: 'Unable to retrieve nodes', err: err }; + } + } + + get(name) { + if (name) { + return this.$async(this.getAsync, name); + } + return this.$async(this.getAllAsync); + } + + /** + * PATCH + */ + + async patchAsync(node, nodeFormValues) { + try { + const params = new KubernetesCommonParams(); + params.id = node.Name; + const newNode = KubernetesNodeConverter.formValuesToNode(node, nodeFormValues); + const payload = KubernetesNodeConverter.patchPayload(node, newNode); + const data = await this.KubernetesNodes().patch(params, payload).$promise; + const patchedNode = KubernetesNodeConverter.apiToNodeDetails(data); + return patchedNode; + } catch (err) { + throw { msg: 'Unable to patch node', err: err }; + } + } + + patch(node, nodeFormValues) { + return this.$async(this.patchAsync, node, nodeFormValues); + } +} + +export default KubernetesNodeService; +angular.module('portainer.kubernetes').service('KubernetesNodeService', KubernetesNodeService); diff --git a/app/kubernetes/pod/converter.js b/app/kubernetes/pod/converter.js new file mode 100644 index 0000000..3b16f1d --- /dev/null +++ b/app/kubernetes/pod/converter.js @@ -0,0 +1,207 @@ +import * as JsonPatch from 'fast-json-patch'; +import _ from 'lodash-es'; + +import KubernetesCommonHelper from '@/kubernetes/helpers/commonHelper'; +import { + KubernetesPortainerApplicationStackNameLabel, + KubernetesPortainerApplicationNameLabel, + KubernetesPortainerApplicationOwnerLabel, + KubernetesPortainerApplicationNote, +} from '@/kubernetes/models/application/models'; + +import KubernetesResourceReservationHelper from '@/kubernetes/helpers/resourceReservationHelper'; +import { KubernetesPod, KubernetesPodToleration, KubernetesPodAffinity, KubernetesPodContainer, KubernetesPodContainerTypes, KubernetesPodEviction } from '@/kubernetes/pod/models'; +import KubernetesApplicationHelper from '@/kubernetes/helpers/application'; +import { createPayloadFactory } from './payloads/create'; + +function computeStatus(statuses) { + const containerStatuses = _.map(statuses, 'state'); + const running = _.filter(containerStatuses, (s) => s.running).length; + const waiting = _.filter(containerStatuses, (s) => s.waiting).length; + if (waiting) { + return 'Waiting'; + } else if (!running) { + return 'Terminated'; + } + return 'Running'; +} + +function computeContainerStatus(statuses, name) { + const status = _.find(statuses, { name: name }); + if (!status) { + return 'Terminated'; + } + const state = status.state; + if (state.waiting) { + return 'Waiting'; + } + if (!state.running) { + return 'Terminated'; + } + return 'Running'; +} + +function computeAffinity(affinity) { + const res = new KubernetesPodAffinity(); + if (affinity) { + res.nodeAffinity = affinity.nodeAffinity || {}; + } + return res; +} + +function computeTolerations(tolerations) { + return _.map(tolerations, (item) => { + const res = new KubernetesPodToleration(); + res.Key = item.key; + res.Operator = item.operator; + res.Value = item.value; + res.TolerationSeconds = item.tolerationSeconds; + res.Effect = item.effect; + return res; + }); +} + +function computeContainers(data) { + const containers = data.spec.containers; + const initContainers = data.spec.initContainers; + + return _.concat( + _.map(containers, (item) => { + const res = new KubernetesPodContainer(); + res.Type = KubernetesPodContainerTypes.APP; + res.PodName = data.metadata.name; + res.PodIP = data.status.podIP; + res.Name = item.name; + res.Image = item.image; + res.ImagePullPolicy = item.imagePullPolicy; + res.Node = data.spec.nodeName; + res.CreationDate = data.status.startTime; + res.Status = computeContainerStatus(data.status.containerStatuses, item.name); + res.Limits = item.resources.limits; + res.Requests = item.resources.requests; + res.VolumeMounts = item.volumeMounts; + res.Env = item.env; + return res; + }), + _.map(initContainers, (item) => { + const res = new KubernetesPodContainer(); + res.Type = KubernetesPodContainerTypes.INIT; + res.PodName = data.metadata.name; + res.Name = item.name; + res.Image = item.image; + res.Node = data.spec.nodeName; + res.CreationDate = data.status.startTime; + res.Status = computeContainerStatus(data.status.containerStatuses, item.name); + res.Limits = item.resources.limits; + res.Requests = item.resources.requests; + res.VolumeMounts = item.volumeMounts; + res.Env = item.env; + return res; + }) + ); +} + +export default class KubernetesPodConverter { + static applicationFormValuesToPod(formValues, volumeClaims) { + let serviceSelector = {}; + if (formValues.Services.length) { + serviceSelector = formValues.Services[0].Selector || { app: formValues.Name }; + } + + const res = new KubernetesPod(); + res.Namespace = formValues.ResourcePool.Namespace.Name; + res.Name = formValues.Name; + res.StackName = formValues.StackName ? formValues.StackName : formValues.Name; + res.ApplicationOwner = formValues.ApplicationOwner; + res.ApplicationName = formValues.Name; + res.ImageModel = formValues.ImageModel; + res.CpuLimit = formValues.CpuLimit; + res.MemoryLimit = KubernetesResourceReservationHelper.bytesValue(formValues.MemoryLimit); + res.Env = KubernetesApplicationHelper.generateEnvFromEnvVariables(formValues.EnvironmentVariables); + res.Containers = formValues.Containers; + res.ApplicationType = formValues.ApplicationType; + res.ServiceSelector = serviceSelector; + res.Labels = formValues.Labels; + KubernetesApplicationHelper.generateVolumesFromPersistentVolumClaims(res, volumeClaims); + KubernetesApplicationHelper.generateEnvOrVolumesFromConfigurations(res, formValues.ConfigMaps, formValues.Secrets); + KubernetesApplicationHelper.generateAffinityFromPlacements(res, formValues); + return res; + } + + static apiToModel(data) { + const res = new KubernetesPod(); + res.Id = data.metadata.uid; + res.Name = data.metadata.name; + res.Namespace = data.metadata.namespace; + res.Images = _.map(data.spec.containers, 'image'); + res.Status = computeStatus(data.status.containerStatuses); + res.Restarts = _.sumBy(data.status.containerStatuses, 'restartCount'); + res.Node = data.spec.nodeName; + res.CreationDate = data.status.startTime; + res.Containers = computeContainers(data); + res.Labels = data.metadata.labels; + res.Affinity = computeAffinity(data.spec.affinity); + res.NodeSelector = data.spec.nodeSelector; + res.Tolerations = computeTolerations(data.spec.tolerations); + res.Labels = data.metadata.labels; + return res; + } + + static evictionPayload(pod) { + const res = new KubernetesPodEviction(); + res.metadata.name = pod.Name; + res.metadata.namespace = pod.Namespace; + return res; + } + + static patchPayload(oldPod, newPod) { + const oldPayload = createPayload(oldPod); + const newPayload = createPayload(newPod); + const payload = JsonPatch.compare(oldPayload, newPayload); + return payload; + } +} + +function createPayload(pod) { + const payload = createPayloadFactory(); + payload.metadata.name = pod.Name; + payload.metadata.namespace = pod.Namespace; + // it's possible for pods not to have labels. Keep labels empty in the oldpayload if there aren't any, otherwise patch will fail + // TODO: when migrating to react, the oldValues should just be the fetched manifest directly from the kube api + if (Object.keys(pod.Labels || {}).length || Object.keys(pod.ServiceSelector || {}).length) { + payload.metadata.labels[KubernetesPortainerApplicationStackNameLabel] = pod.StackName; + payload.metadata.labels[KubernetesPortainerApplicationNameLabel] = pod.ApplicationName; + payload.metadata.labels[KubernetesPortainerApplicationOwnerLabel] = pod.ApplicationOwner; + payload.metadata.labels = { ...(pod.Labels || {}), ...(pod.ServiceSelector || {}), ...payload.metadata.labels }; + } else { + payload.metadata.labels = undefined; + } + if (pod.Note) { + payload.metadata.annotations[KubernetesPortainerApplicationNote] = pod.Note; + } else { + payload.metadata.annotations = undefined; + } + + payload.spec.replicas = pod.ReplicaCount; + payload.spec.selector.matchLabels.app = pod.Name; + payload.spec.template.metadata.labels.app = pod.Name; + payload.spec.template.metadata.labels[KubernetesPortainerApplicationNameLabel] = pod.ApplicationName; + payload.spec.template.spec.containers[0].name = pod.Name; + payload.spec.template.spec.containers[0].image = pod.Image; + payload.spec.template.spec.affinity = pod.Affinity; + KubernetesCommonHelper.assignOrDeleteIfEmpty(payload, 'spec.template.spec.containers[0].env', pod.Env); + KubernetesCommonHelper.assignOrDeleteIfEmpty(payload, 'spec.template.spec.containers[0].volumeMounts', pod.VolumeMounts); + KubernetesCommonHelper.assignOrDeleteIfEmpty(payload, 'spec.template.spec.volumes', pod.Volumes); + if (pod.MemoryLimit) { + payload.spec.template.spec.containers[0].resources.limits.memory = pod.MemoryLimit; + payload.spec.template.spec.containers[0].resources.requests.memory = pod.MemoryLimit; + } + if (pod.CpuLimit) { + payload.spec.template.spec.containers[0].resources.limits.cpu = pod.CpuLimit; + payload.spec.template.spec.containers[0].resources.requests.cpu = pod.CpuLimit; + } + if (!pod.CpuLimit && !pod.MemoryLimit) { + delete payload.spec.template.spec.containers[0].resources; + } + return payload; +} diff --git a/app/kubernetes/pod/models/affinities.ts b/app/kubernetes/pod/models/affinities.ts new file mode 100644 index 0000000..d2e2e60 --- /dev/null +++ b/app/kubernetes/pod/models/affinities.ts @@ -0,0 +1,22 @@ +export enum KubernetesPodNodeAffinityNodeSelectorRequirementOperators { + IN = 'In', + NOT_IN = 'NotIn', + EXISTS = 'Exists', + DOES_NOT_EXIST = 'DoesNotExist', + GREATER_THAN = 'Gt', + LOWER_THAN = 'Lt', +} + +/** + * KubernetesPodAffinity Model + */ +// this model will contain non transformed data (raw payload data) +// either during creation flow (model > api) +// than during reading flow (api > model) +export function KubernetesPodAffinity() { + return { + nodeAffinity: {}, // KubernetesPodNodeAffinityPayload + // podAffinity: {}, + // podAntiAffinity: {}, + }; +} diff --git a/app/kubernetes/pod/models/index.js b/app/kubernetes/pod/models/index.js new file mode 100644 index 0000000..d7a4950 --- /dev/null +++ b/app/kubernetes/pod/models/index.js @@ -0,0 +1,86 @@ +export * from './affinities'; + +/** + * KubernetesPod Model + */ +const _KubernetesPod = Object.freeze({ + Id: '', + Name: '', + Namespace: '', + Images: [], + Status: '', + Restarts: 0, + Node: '', + CreationDate: '', + Containers: [], + Labels: [], + Affinity: {}, // KubernetesPodAffinity + Tolerations: [], // KubernetesPodToleration[] + NodeSelector: undefined, +}); + +export class KubernetesPod { + constructor() { + Object.assign(this, JSON.parse(JSON.stringify(_KubernetesPod))); + } +} + +/** + * KubernetesPodToleration Model + */ +const _KubernetesPodToleration = Object.freeze({ + Key: '', + Operator: '', + Value: '', + TolerationSeconds: 0, + Effect: '', +}); + +export class KubernetesPodToleration { + constructor() { + Object.assign(this, JSON.parse(JSON.stringify(_KubernetesPodToleration))); + } +} + +const _KubernetesPodContainer = Object.freeze({ + Type: 0, + PodName: '', + Name: '', + Image: '', + ImagePullPolicy: '', + Node: '', + CreationDate: '', + Status: '', + Limits: {}, + Requests: {}, + VolumeMounts: {}, + ConfigurationVolumes: [], + PersistedFolders: [], + Env: [], +}); + +export class KubernetesPodContainer { + constructor() { + Object.assign(this, JSON.parse(JSON.stringify(_KubernetesPodContainer))); + } +} + +const _KubernetesPodEviction = Object.freeze({ + apiVersion: 'policy/v1beta1', + kind: 'Eviction', + metadata: { + name: '', + namespace: '', + }, +}); + +export class KubernetesPodEviction { + constructor() { + Object.assign(this, JSON.parse(JSON.stringify(_KubernetesPodEviction))); + } +} + +export const KubernetesPodContainerTypes = { + INIT: 1, + APP: 2, +}; diff --git a/app/kubernetes/pod/payloads/affinities.js b/app/kubernetes/pod/payloads/affinities.js new file mode 100644 index 0000000..5517fab --- /dev/null +++ b/app/kubernetes/pod/payloads/affinities.js @@ -0,0 +1,29 @@ +export function KubernetesPodNodeAffinityPayload() { + return { + requiredDuringSchedulingIgnoredDuringExecution: { + nodeSelectorTerms: [], // []KubernetesNodeSelectorTermPayload + }, + preferredDuringSchedulingIgnoredDuringExecution: [], // []KubernetesPreferredSchedulingTermPayload + }; +} + +export function KubernetesPreferredSchedulingTermPayload() { + return { + weight: 1, + preference: {}, // KubernetesNodeSelectorTermPayload + }; +} + +export function KubernetesNodeSelectorTermPayload() { + return { + matchExpressions: [], // []KubernetesNodeSelectorRequirementPayload + }; +} + +export function KubernetesNodeSelectorRequirementPayload() { + return { + key: '', // string + operator: '', // KubernetesPodNodeAffinityNodeSelectorRequirementOperators + values: [], // []string + }; +} diff --git a/app/kubernetes/pod/payloads/create.js b/app/kubernetes/pod/payloads/create.js new file mode 100644 index 0000000..3d16884 --- /dev/null +++ b/app/kubernetes/pod/payloads/create.js @@ -0,0 +1,45 @@ +import { KubernetesCommonMetadataPayload } from '@/kubernetes/models/common/payloads'; + +export function createPayloadFactory() { + return { + metadata: new KubernetesCommonMetadataPayload(), + spec: { + replicas: 0, + selector: { + matchLabels: { + app: '', + }, + }, + strategy: { + type: 'RollingUpdate', + rollingUpdate: { + maxSurge: 0, + maxUnavailable: '100%', + }, + }, + template: { + metadata: { + labels: { + app: '', + }, + }, + spec: { + affinity: {}, + containers: [ + { + name: '', + image: '', + env: [], + resources: { + limits: {}, + requests: {}, + }, + volumeMounts: [], + }, + ], + volumes: [], + }, + }, + }, + }; +} diff --git a/app/kubernetes/pod/service.js b/app/kubernetes/pod/service.js new file mode 100644 index 0000000..cb6c4b9 --- /dev/null +++ b/app/kubernetes/pod/service.js @@ -0,0 +1,122 @@ +import angular from 'angular'; +import PortainerError from '@/portainer/error'; + +import { KubernetesCommonParams } from '@/kubernetes/models/common/params'; +import KubernetesPodConverter from '@/kubernetes/pod/converter'; + +class KubernetesPodService { + /* @ngInject */ + constructor($async, KubernetesPods) { + this.$async = $async; + this.KubernetesPods = KubernetesPods; + + this.getAsync = this.getAsync.bind(this); + this.getAllAsync = this.getAllAsync.bind(this); + this.logsAsync = this.logsAsync.bind(this); + this.deleteAsync = this.deleteAsync.bind(this); + this.patchAsync = this.patchAsync.bind(this); + } + + async getAsync(namespace, name) { + try { + const params = new KubernetesCommonParams(); + params.id = name; + const [raw, yaml] = await Promise.all([this.KubernetesPods(namespace).get(params).$promise, this.KubernetesPods(namespace).getYaml(params).$promise]); + const res = { + Raw: raw, + Yaml: yaml.data, + }; + return res; + } catch (err) { + throw new PortainerError('Unable to retrieve pod', err); + } + } + + /** + * GET ALL + */ + async getAllAsync(namespace) { + try { + const data = await this.KubernetesPods(namespace).get().$promise; + return data.items; + } catch (err) { + throw new PortainerError('Unable to retrieve pods', err); + } + } + + get(namespace, name) { + if (name) { + return this.$async(this.getAsync, namespace, name); + } + return this.$async(this.getAllAsync, namespace); + } + + /** + * Logs + * + * @param {string} namespace + * @param {string} podName + * @param {string} containerName + */ + async logsAsync(namespace, podName, containerName) { + try { + const params = new KubernetesCommonParams(); + params.id = podName; + if (containerName) { + params.container = containerName; + } + const data = await this.KubernetesPods(namespace).logs(params).$promise; + return data.logs; + } catch (err) { + throw new PortainerError('Unable to retrieve pod logs', err); + } + } + + logs(namespace, podName, containerName) { + return this.$async(this.logsAsync, namespace, podName, containerName); + } + + /** + * PATCH + */ + async patchAsync(oldPod, newPod) { + try { + const params = new KubernetesCommonParams(); + params.id = newPod.Name; + const namespace = newPod.Namespace; + const payload = KubernetesPodConverter.patchPayload(oldPod, newPod); + if (!payload.length) { + return; + } + const data = await this.KubernetesPods(namespace).patch(params, payload).$promise; + return data; + } catch (err) { + throw new PortainerError('Unable to patch pod', err); + } + } + + patch(oldPod, newPod) { + return this.$async(this.patchAsync, oldPod, newPod); + } + + /** + * DELETE + */ + async deleteAsync(pod) { + try { + const params = new KubernetesCommonParams(); + params.id = pod.Name; + const namespace = pod.Namespace; + await this.KubernetesPods(namespace).delete(params).$promise; + } catch (err) { + throw new PortainerError('Unable to remove pod', err); + } + } + + delete(pod) { + return this.$async(this.deleteAsync, pod); + } +} + +export default KubernetesPodService; +angular.module('portainer.kubernetes').service('KubernetesPodService', KubernetesPodService); diff --git a/app/kubernetes/react/components/clusterManagement.ts b/app/kubernetes/react/components/clusterManagement.ts new file mode 100644 index 0000000..48c1084 --- /dev/null +++ b/app/kubernetes/react/components/clusterManagement.ts @@ -0,0 +1,22 @@ +import angular from 'angular'; + +import { r2a } from '@/react-tools/react2angular'; +import { withUIRouter } from '@/react-tools/withUIRouter'; +import { withCurrentUser } from '@/react-tools/withCurrentUser'; +import { NodeApplicationsDatatable } from '@/react/kubernetes/cluster/NodeView/NodeApplicationsDatatable/NodeApplicationsDatatable'; +import { ResourceEventsDatatable } from '@/react/kubernetes/components/EventsDatatable/ResourceEventsDatatable'; +import { withReactQuery } from '@/react-tools/withReactQuery'; + +export const clusterManagementModule = angular + .module('portainer.kubernetes.react.components.clusterManagement', []) + .component( + 'kubernetesNodeApplicationsDatatable', + r2a(withUIRouter(withCurrentUser(NodeApplicationsDatatable)), []) + ) + .component( + 'resourceEventsDatatable', + r2a( + withUIRouter(withReactQuery(withCurrentUser(ResourceEventsDatatable))), + ['resourceId', 'storageKey', 'namespace', 'noWidget', 'isLoading'] + ) + ).name; diff --git a/app/kubernetes/react/components/index.ts b/app/kubernetes/react/components/index.ts new file mode 100644 index 0000000..7137fcb --- /dev/null +++ b/app/kubernetes/react/components/index.ts @@ -0,0 +1,408 @@ +import angular from 'angular'; + +import { r2a } from '@/react-tools/react2angular'; +import { IngressClassDatatableAngular } from '@/react/kubernetes/cluster/ingressClass/IngressClassDatatable/IngressClassDatatableAngular'; +import { NamespacesSelector } from '@/react/kubernetes/cluster/RegistryAccessView/NamespacesSelector'; +import { NamespaceAccessUsersSelector } from '@/react/kubernetes/namespaces/AccessView/NamespaceAccessUsersSelector'; +import { KubeServicesForm } from '@/react/kubernetes/applications/CreateView/application-services/KubeServicesForm'; +import { kubeServicesValidation } from '@/react/kubernetes/applications/CreateView/application-services/kubeServicesValidation'; +import { withReactQuery } from '@/react-tools/withReactQuery'; +import { withUIRouter } from '@/react-tools/withUIRouter'; +import { + ApplicationSummaryWidget, + ApplicationDetailsWidget, + ApplicationEventsDatatable, +} from '@/react/kubernetes/applications/DetailsView'; +import { ApplicationContainersDatatable } from '@/react/kubernetes/applications/DetailsView/ApplicationContainersDatatable'; +import { + PlacementFormSection, + placementValidation, +} from '@/react/kubernetes/applications/components/PlacementFormSection'; +import { ApplicationSummarySection } from '@/react/kubernetes/applications/components/ApplicationSummarySection'; +import { withFormValidation } from '@/react-tools/withFormValidation'; +import { withCurrentUser } from '@/react-tools/withCurrentUser'; +import { YAMLInspector } from '@/react/kubernetes/components/YAMLInspector'; +import { NodesDatatable } from '@/react/kubernetes/cluster/HomeView/NodesDatatable'; +import { StackName } from '@/react/kubernetes/DeployView/StackName/StackName'; +import { StackNameLabelInsight } from '@/react/kubernetes/DeployView/StackName/StackNameLabelInsight'; +import { SecretsFormSection } from '@/react/kubernetes/applications/components/ConfigurationsFormSection/SecretsFormSection'; +import { configurationsValidationSchema } from '@/react/kubernetes/applications/components/ConfigurationsFormSection/configurationValidationSchema'; +import { ConfigMapsFormSection } from '@/react/kubernetes/applications/components/ConfigurationsFormSection/ConfigMapsFormSection'; +import { PersistedFoldersFormSection } from '@/react/kubernetes/applications/components/PersistedFoldersFormSection'; +import { DataAccessPolicyFormSection } from '@/react/kubernetes/applications/CreateView/DataAccessPolicyFormSection'; +import { persistedFoldersValidation } from '@/react/kubernetes/applications/components/PersistedFoldersFormSection/persistedFoldersValidation'; +import { + ResourceReservationFormSection, + resourceReservationValidation, +} from '@/react/kubernetes/applications/components/ResourceReservationFormSection'; +import { + ReplicationFormSection, + replicationValidation, +} from '@/react/kubernetes/applications/components/ReplicationFormSection'; +import { + AutoScalingFormSection, + autoScalingValidation, +} from '@/react/kubernetes/applications/components/AutoScalingFormSection'; +import { withControlledInput } from '@/react-tools/withControlledInput'; +import { + NamespaceSelector, + namespaceSelectorValidation, +} from '@/react/kubernetes/applications/components/NamespaceSelector'; +import { EditYamlFormSection } from '@/react/kubernetes/applications/components/EditYamlFormSection'; +import { + NameFormSection, + appNameValidation, +} from '@/react/kubernetes/applications/components/NameFormSection'; +import { deploymentTypeValidation } from '@/react/kubernetes/applications/components/AppDeploymentTypeFormSection/deploymentTypeValidation'; +import { AppDeploymentTypeFormSection } from '@/react/kubernetes/applications/components/AppDeploymentTypeFormSection/AppDeploymentTypeFormSection'; +import { EnvironmentVariablesFormSection } from '@/react/kubernetes/applications/components/EnvironmentVariablesFormSection/EnvironmentVariablesFormSection'; +import { kubeEnvVarValidationSchema } from '@/react/kubernetes/applications/components/EnvironmentVariablesFormSection/kubeEnvVarValidationSchema'; +import { IntegratedAppsDatatable } from '@/react/kubernetes/components/IntegratedAppsDatatable/IntegratedAppsDatatable'; +import { K8sRegistryAccessNotice } from '@/react/kubernetes/components/K8sRegistryAccessNotice'; +import { HelmTemplates } from '@/react/kubernetes/helm/HelmTemplates/HelmTemplates'; +import { SecretDetailsTable } from '@/react/kubernetes/configs/secrets/ItemView/SecretDetailsTable'; +import { KubernetesSummaryView } from '@/react/kubernetes/summary/KubernetesSummaryView'; +import { SecretItemTabsWidget } from '@/react/kubernetes/configs/secrets/ItemView/SecretItemTabsWidget'; + +import { namespacesModule } from './namespaces'; +import { clusterManagementModule } from './clusterManagement'; +import { registriesModule } from './registries'; + +export const ngModule = angular + .module('portainer.kubernetes.react.components', [ + namespacesModule, + clusterManagementModule, + registriesModule, + ]) + .component( + 'ingressClassDatatable', + r2a(IngressClassDatatableAngular, [ + 'onChangeControllers', + 'description', + 'ingressControllers', + 'initialIngressControllers', + 'allowNoneIngressClass', + 'isLoading', + 'view', + ]) + ) + .component( + 'namespacesSelector', + r2a(NamespacesSelector, [ + 'dataCy', + 'inputId', + 'name', + 'namespaces', + 'onChange', + 'placeholder', + 'value', + 'allowSelectAll', + ]) + ) + .component( + 'namespaceAccessUsersSelector', + r2a(NamespaceAccessUsersSelector, [ + 'inputId', + 'onChange', + 'options', + 'value', + 'dataCy', + 'placeholder', + 'name', + ]) + ) + .component( + 'kubeNodesDatatable', + r2a(withUIRouter(withReactQuery(withCurrentUser(NodesDatatable))), []) + ) + .component( + 'accessPolicyFormSection', + r2a(DataAccessPolicyFormSection, [ + 'value', + 'onChange', + 'isEdit', + 'persistedFoldersUseExistingVolumes', + ]) + ) + .component( + 'kubeYamlInspector', + r2a(withUIRouter(withReactQuery(withCurrentUser(YAMLInspector))), [ + 'identifier', + 'data', + 'hideMessage', + 'data-cy', + 'isLoading', + 'isError', + ]) + ) + .component( + 'kubeStackName', + r2a( + withControlledInput( + withUIRouter( + withReactQuery(withCurrentUser(withControlledInput(StackName))) + ), + { stackName: 'setStackName' } + ), + [ + 'setStackName', + 'stackName', + 'stacks', + 'inputClassName', + 'textTip', + 'error', + ] + ) + ) + .component( + 'stackNameLabelInsight', + r2a(withUIRouter(withCurrentUser(StackNameLabelInsight)), []) + ) + .component( + 'editYamlFormSection', + r2a(withUIRouter(withReactQuery(withCurrentUser(EditYamlFormSection))), [ + 'values', + 'onChange', + 'isComposeFormat', + ]) + ) + .component( + 'applicationSummaryWidget', + r2a( + withUIRouter(withReactQuery(withCurrentUser(ApplicationSummaryWidget))), + [] + ) + ) + .component( + 'applicationContainersDatatable', + r2a( + withUIRouter( + withReactQuery(withCurrentUser(ApplicationContainersDatatable)) + ), + [] + ) + ) + .component( + 'applicationDetailsWidget', + r2a( + withUIRouter(withReactQuery(withCurrentUser(ApplicationDetailsWidget))), + [] + ) + ) + .component( + 'applicationEventsDatatable', + r2a( + withUIRouter(withReactQuery(withCurrentUser(ApplicationEventsDatatable))), + [] + ) + ) + .component( + 'applicationSummarySection', + r2a( + withUIRouter(withReactQuery(withCurrentUser(ApplicationSummarySection))), + ['formValues', 'oldFormValues'] + ) + ) + .component( + 'kubernetesIntegratedApplicationsDatatable', + r2a(withUIRouter(withCurrentUser(IntegratedAppsDatatable)), [ + 'dataset', + 'isLoading', + 'onRefresh', + 'tableKey', + 'tableTitle', + 'dataCy', + ]) + ) + .component( + 'helmTemplatesView', + r2a(withUIRouter(withCurrentUser(HelmTemplates)), [ + 'onSelectHelmChart', + 'namespace', + 'name', + ]) + ) + .component( + 'secretDetailsTable', + r2a(withUIRouter(withCurrentUser(withReactQuery(SecretDetailsTable))), [ + 'name', + 'namespace', + 'secretTypeLabel', + 'isSystem', + 'registryId', + ]) + ) + .component( + 'secretItemTabsWidget', + r2a(withUIRouter(withReactQuery(withCurrentUser(SecretItemTabsWidget))), [ + 'name', + 'namespace', + 'secretTypeLabel', + 'isSystem', + 'registryId', + 'resourceId', + ]) + ) + .component( + 'kubernetesSummaryViewReact', + r2a(KubernetesSummaryView, ['actions', 'cpuLimit', 'memoryLimit']) + ) + .component( + 'k8sRegistryAccessNotice', + r2a( + withUIRouter(withReactQuery(withCurrentUser(K8sRegistryAccessNotice))), + ['namespace', 'manifestContent', 'environmentId'] + ) + ); + +export const componentsModule = ngModule.name; + +withFormValidation( + ngModule, + withUIRouter( + withCurrentUser( + withReactQuery( + withControlledInput(KubeServicesForm, { values: 'onChange' }) + ) + ) + ), + 'kubeServicesForm', + ['values', 'onChange', 'appName', 'selector', 'isEditMode', 'namespace'], + kubeServicesValidation +); + +withFormValidation( + ngModule, + withControlledInput( + withUIRouter(withCurrentUser(withReactQuery(ConfigMapsFormSection))), + { values: 'onChange' } + ), + 'configMapsFormSection', + ['values', 'onChange', 'namespace'], + configurationsValidationSchema +); + +withFormValidation( + ngModule, + withControlledInput( + withUIRouter(withCurrentUser(withReactQuery(SecretsFormSection))), + { values: 'onChange' } + ), + 'secretsFormSection', + ['values', 'onChange', 'namespace'], + configurationsValidationSchema +); + +withFormValidation( + ngModule, + withControlledInput( + withUIRouter(withCurrentUser(withReactQuery(PersistedFoldersFormSection))), + { values: 'onChange' } + ), + 'persistedFoldersFormSection', + [ + 'isEdit', + 'applicationValues', + 'isAddPersistentFolderButtonShown', + 'initialValues', + 'availableVolumes', + ], + persistedFoldersValidation +); + +withFormValidation( + ngModule, + withControlledInput( + withUIRouter( + withCurrentUser(withReactQuery(ResourceReservationFormSection)) + ), + { values: 'onChange' } + ), + 'resourceReservationFormSection', + [ + 'namespaceHasQuota', + 'resourceQuotaCapacityExceeded', + 'minMemoryLimit', + 'minCpuLimit', + 'maxMemoryLimit', + 'maxCpuLimit', + ], + resourceReservationValidation +); + +withFormValidation( + ngModule, + withControlledInput( + withUIRouter(withCurrentUser(withReactQuery(ReplicationFormSection))), + { values: 'onChange' } + ), + 'replicationFormSection', + [ + 'supportScalableReplicaDeployment', + 'cpuLimit', + 'memoryLimit', + 'resourceReservationsOverflow', + ], + replicationValidation +); + +withFormValidation( + ngModule, + withControlledInput( + withUIRouter(withCurrentUser(withReactQuery(AutoScalingFormSection))), + { values: 'onChange' } + ), + 'autoScalingFormSection', + ['isMetricsEnabled'], + autoScalingValidation +); + +withFormValidation( + ngModule, + withUIRouter(withCurrentUser(withReactQuery(PlacementFormSection))), + 'placementFormSection', + [], + placementValidation +); + +withFormValidation( + ngModule, + withControlledInput(withUIRouter(withCurrentUser(NamespaceSelector)), { + values: 'onChange', + }), + 'namespaceSelector', + ['isEdit'], + namespaceSelectorValidation, + true +); + +withFormValidation( + ngModule, + withUIRouter(withCurrentUser(withReactQuery(NameFormSection))), + 'nameFormSection', + ['isEdit'], + appNameValidation, + true +); + +withFormValidation( + ngModule, + AppDeploymentTypeFormSection, + 'appDeploymentTypeFormSection', + ['supportGlobalDeployment'], + deploymentTypeValidation, + true +); + +withFormValidation( + ngModule, + withControlledInput( + withUIRouter( + withCurrentUser(withReactQuery(EnvironmentVariablesFormSection)) + ), + { values: 'onChange' } + ), + 'environmentVariablesFormSection', + [], + kubeEnvVarValidationSchema +); diff --git a/app/kubernetes/react/components/namespaces.ts b/app/kubernetes/react/components/namespaces.ts new file mode 100644 index 0000000..c00b7b9 --- /dev/null +++ b/app/kubernetes/react/components/namespaces.ts @@ -0,0 +1,13 @@ +import angular from 'angular'; + +import { r2a } from '@/react-tools/react2angular'; +import { withUIRouter } from '@/react-tools/withUIRouter'; +import { withCurrentUser } from '@/react-tools/withCurrentUser'; +import { NamespacesDatatable } from '@/react/kubernetes/namespaces/ListView/NamespacesDatatable'; + +export const namespacesModule = angular + .module('portainer.kubernetes.react.components.namespaces', []) + .component( + 'kubernetesNamespacesDatatable', + r2a(withUIRouter(withCurrentUser(NamespacesDatatable)), []) + ).name; diff --git a/app/kubernetes/react/components/registries.tsx b/app/kubernetes/react/components/registries.tsx new file mode 100644 index 0000000..4b28686 --- /dev/null +++ b/app/kubernetes/react/components/registries.tsx @@ -0,0 +1,11 @@ +import angular from 'angular'; + +import { r2a } from '@/react-tools/react2angular'; +import { AccessTable } from '@/react/kubernetes/cluster/RegistryAccessView/AccessTable'; + +export const registriesModule = angular + .module('portainer.kubernetes.react.components.registries', []) + .component( + 'kubeRegistryAccessTable', + r2a(AccessTable, ['dataset', 'onRemove']) + ).name; diff --git a/app/kubernetes/react/index.ts b/app/kubernetes/react/index.ts new file mode 100644 index 0000000..3b1d288 --- /dev/null +++ b/app/kubernetes/react/index.ts @@ -0,0 +1,9 @@ +import angular from 'angular'; + +import { componentsModule } from './components'; +import { viewsModule } from './views'; + +export const reactModule = angular.module('portainer.kubernetes.react', [ + viewsModule, + componentsModule, +]).name; diff --git a/app/kubernetes/react/views/index.ts b/app/kubernetes/react/views/index.ts new file mode 100644 index 0000000..778d6f8 --- /dev/null +++ b/app/kubernetes/react/views/index.ts @@ -0,0 +1,146 @@ +import angular from 'angular'; + +import { r2a } from '@/react-tools/react2angular'; +import { withCurrentUser } from '@/react-tools/withCurrentUser'; +import { withReactQuery } from '@/react-tools/withReactQuery'; +import { withUIRouter } from '@/react-tools/withUIRouter'; +import { IngressesDatatableView } from '@/react/kubernetes/ingresses/IngressDatatable'; +import { CreateIngressView } from '@/react/kubernetes/ingresses/CreateIngressView'; +import { DashboardView } from '@/react/kubernetes/dashboard/DashboardView'; +import { ServicesView } from '@/react/kubernetes/services/ServicesView'; +import { ConsoleView } from '@/react/kubernetes/applications/ConsoleView'; +import { ConfigmapsAndSecretsView } from '@/react/kubernetes/configs/ListView/ConfigmapsAndSecretsView'; +import { CreateNamespaceView } from '@/react/kubernetes/namespaces/CreateView/CreateNamespaceView'; +import { ApplicationsView } from '@/react/kubernetes/applications/ListView/ApplicationsView'; +import { ApplicationDetailsView } from '@/react/kubernetes/applications/DetailsView/ApplicationDetailsView'; +import { ConfigureView } from '@/react/kubernetes/cluster/ConfigureView'; +import { NamespacesView } from '@/react/kubernetes/namespaces/ListView/NamespacesView'; +import { ServiceAccountsView } from '@/react/kubernetes/more-resources/ServiceAccountsView/ServiceAccountsView'; +import { ServiceAccountView } from '@/react/kubernetes/more-resources/ServiceAccountsView/ItemView/ServiceAccountView'; +import { ClusterRolesView } from '@/react/kubernetes/more-resources/ClusterRolesView'; +import { RolesView } from '@/react/kubernetes/more-resources/RolesView'; +import { VolumesView } from '@/react/kubernetes/volumes/ListView/VolumesView'; +import { NamespaceView } from '@/react/kubernetes/namespaces/ItemView/NamespaceView'; +import { AccessView } from '@/react/kubernetes/namespaces/AccessView/AccessView'; +import { JobsView } from '@/react/kubernetes/more-resources/JobsView/JobsView'; +import { ClusterView } from '@/react/kubernetes/cluster/ClusterView'; +import { HelmApplicationView } from '@/react/kubernetes/helm/HelmApplicationView'; +import { HelmInstallView } from '@/react/kubernetes/helm/install/HelmInstallView'; +import { NodeView } from '@/react/kubernetes/cluster/NodeView/NodeView'; +import { KubectlShellView } from '@/react/kubernetes/cluster/KubectlShell/KubectlShellView'; +import { ResourceDetailsYAMLView } from '@/react/kubernetes/more-resources/ResourceDetailsYAMLView'; + +export const viewsModule = angular + .module('portainer.kubernetes.react.views', []) + .component( + 'kubernetesCreateNamespaceView', + r2a(withUIRouter(withReactQuery(withCurrentUser(CreateNamespaceView))), []) + ) + .component( + 'namespaceView', + r2a(withUIRouter(withReactQuery(withCurrentUser(NamespaceView))), []) + ) + .component( + 'kubernetesNamespacesView', + r2a(withUIRouter(withReactQuery(withCurrentUser(NamespacesView))), []) + ) + .component( + 'kubernetesNamespaceAccessView', + r2a(withUIRouter(withReactQuery(withCurrentUser(AccessView))), []) + ) + .component( + 'kubernetesServicesView', + r2a(withUIRouter(withReactQuery(withCurrentUser(ServicesView))), []) + ) + .component( + 'kubernetesVolumesView', + r2a(withUIRouter(withReactQuery(withCurrentUser(VolumesView))), []) + ) + .component( + 'kubernetesIngressesView', + r2a( + withUIRouter(withReactQuery(withCurrentUser(IngressesDatatableView))), + [] + ) + ) + .component( + 'kubernetesIngressesCreateView', + r2a(withUIRouter(withReactQuery(withCurrentUser(CreateIngressView))), []) + ) + .component( + 'kubernetesConfigMapsAndSecretsView', + r2a( + withUIRouter(withReactQuery(withCurrentUser(ConfigmapsAndSecretsView))), + [] + ) + ) + .component( + 'kubernetesApplicationsView', + r2a(withUIRouter(withReactQuery(withCurrentUser(ApplicationsView))), []) + ) + .component( + 'applicationDetailsView', + r2a( + withUIRouter(withReactQuery(withCurrentUser(ApplicationDetailsView))), + [] + ) + ) + .component( + 'kubernetesHelmApplicationView', + r2a(withUIRouter(withReactQuery(withCurrentUser(HelmApplicationView))), []) + ) + .component( + 'helmInstallView', + r2a(withUIRouter(withReactQuery(withCurrentUser(HelmInstallView))), []) + ) + .component( + 'kubectlShellView', + r2a(withUIRouter(withReactQuery(withCurrentUser(KubectlShellView))), []) + ) + .component( + 'kubernetesClusterView', + r2a(withUIRouter(withReactQuery(withCurrentUser(ClusterView))), []) + ) + .component( + 'kubernetesNodeViewReact', + r2a(withUIRouter(withReactQuery(withCurrentUser(NodeView))), []) + ) + .component( + 'kubernetesConfigureView', + r2a(withUIRouter(withReactQuery(withCurrentUser(ConfigureView))), []) + ) + .component( + 'kubernetesDashboardView', + r2a(withUIRouter(withReactQuery(withCurrentUser(DashboardView))), []) + ) + .component( + 'kubernetesConsoleView', + r2a(withUIRouter(withReactQuery(withCurrentUser(ConsoleView))), []) + ) + .component( + 'jobsView', + r2a(withUIRouter(withReactQuery(withCurrentUser(JobsView))), []) + ) + .component( + 'serviceAccountsView', + r2a(withUIRouter(withReactQuery(withCurrentUser(ServiceAccountsView))), []) + ) + .component( + 'serviceAccountView', + r2a(withUIRouter(withReactQuery(withCurrentUser(ServiceAccountView))), []) + ) + .component( + 'clusterRolesView', + r2a(withUIRouter(withReactQuery(withCurrentUser(ClusterRolesView))), []) + ) + .component( + 'k8sRolesView', + r2a(withUIRouter(withReactQuery(withCurrentUser(RolesView))), []) + ) + .component( + 'kubernetesResourceDetailsYAMLView', + r2a( + withUIRouter(withReactQuery(withCurrentUser(ResourceDetailsYAMLView))), + [] + ) + ).name; diff --git a/app/kubernetes/registries/index.js b/app/kubernetes/registries/index.js new file mode 100644 index 0000000..5437382 --- /dev/null +++ b/app/kubernetes/registries/index.js @@ -0,0 +1,5 @@ +import angular from 'angular'; + +import { kubernetesRegistryAccessView } from './kube-registry-access-view'; + +export default angular.module('portainer.kubernetes.registries', []).component('kubernetesRegistryAccessView', kubernetesRegistryAccessView).name; diff --git a/app/kubernetes/registries/kube-registry-access-view/index.js b/app/kubernetes/registries/kube-registry-access-view/index.js new file mode 100644 index 0000000..bf51890 --- /dev/null +++ b/app/kubernetes/registries/kube-registry-access-view/index.js @@ -0,0 +1,9 @@ +import controller from './kube-registry-access-view.controller'; + +export const kubernetesRegistryAccessView = { + templateUrl: './kube-registry-access-view.html', + controller, + bindings: { + endpoint: '<', + }, +}; diff --git a/app/kubernetes/registries/kube-registry-access-view/kube-registry-access-view.controller.js b/app/kubernetes/registries/kube-registry-access-view/kube-registry-access-view.controller.js new file mode 100644 index 0000000..0c12f38 --- /dev/null +++ b/app/kubernetes/registries/kube-registry-access-view/kube-registry-access-view.controller.js @@ -0,0 +1,82 @@ +import KubernetesNamespaceHelper from '@/kubernetes/helpers/namespaceHelper'; + +export default class KubernetesRegistryAccessController { + /* @ngInject */ + constructor($async, $scope, $state, EndpointService, Notifications, RegistryService, KubernetesResourcePoolService) { + this.$async = $async; + this.$scope = $scope; + this.$state = $state; + this.Notifications = Notifications; + this.KubernetesResourcePoolService = KubernetesResourcePoolService; + this.RegistryService = RegistryService; + this.EndpointService = EndpointService; + + this.state = { + actionInProgress: false, + }; + + this.selectedResourcePools = []; + this.resourcePools = []; + this.savedResourcePools = []; + + this.handleRemove = this.handleRemove.bind(this); + this.onChangeResourcePools = this.onChangeResourcePools.bind(this); + } + + async submit() { + return this.updateNamespaces([...this.savedResourcePools.map(({ value }) => value), ...this.selectedResourcePools]); + } + + handleRemove(namespaces) { + const removeNamespaces = namespaces.map(({ value }) => value); + const nsToUpdate = this.savedResourcePools.map(({ value }) => value).filter((value) => !removeNamespaces.includes(value)); + + return this.updateNamespaces(nsToUpdate); + } + + updateNamespaces(namespaces) { + return this.$async(async () => { + try { + await this.EndpointService.updateRegistryAccess(this.endpoint.Id, this.registry.Id, { + namespaces, + }); + this.$state.reload(this.$state.current); + this.Notifications.success('Success', 'Registry access updated'); + } catch (err) { + this.Notifications.error('Failure', err, 'Failed saving registry access'); + } + }); + } + + onChangeResourcePools(resourcePools) { + return this.$scope.$evalAsync(() => { + this.selectedResourcePools = resourcePools; + }); + } + + $onInit() { + return this.$async(async () => { + try { + this.state = { + registryId: this.$state.params.id, + }; + this.registry = await this.RegistryService.registry(this.state.registryId, this.endpoint.Id); + if (this.registry.RegistryAccesses && this.registry.RegistryAccesses[this.endpoint.Id]) { + this.savedResourcePools = this.registry.RegistryAccesses[this.endpoint.Id].Namespaces.map((value) => ({ value })); + } + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to retrieve registry details'); + } + + try { + const resourcePools = await this.KubernetesResourcePoolService.get(); + + this.resourcePools = resourcePools + .filter((pool) => !KubernetesNamespaceHelper.isSystemNamespace(pool.Namespace.Name) && !this.savedResourcePools.find(({ value }) => value === pool.Namespace.Name)) + .map((pool) => ({ name: pool.Namespace.Name, id: pool.Namespace.Id })); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to retrieve namespaces'); + } + }); + } +} diff --git a/app/kubernetes/registries/kube-registry-access-view/kube-registry-access-view.html b/app/kubernetes/registries/kube-registry-access-view/kube-registry-access-view.html new file mode 100644 index 0000000..5c5e682 --- /dev/null +++ b/app/kubernetes/registries/kube-registry-access-view/kube-registry-access-view.html @@ -0,0 +1,53 @@ + + + + +
+
+ + + +
+
+ +
+ No namespaces available. + + +
+
+ + Adding this registry will expose the registry credentials to all users of this namespace. +
+
+ + +
+
+ +
+
+ +
+
+
+
+
+ + diff --git a/app/kubernetes/rest/configMap.js b/app/kubernetes/rest/configMap.js new file mode 100644 index 0000000..7946bb7 --- /dev/null +++ b/app/kubernetes/rest/configMap.js @@ -0,0 +1,37 @@ +import { rawResponse } from '@/kubernetes/rest/response/transform'; + +angular.module('portainer.kubernetes').factory('KubernetesConfigMaps', [ + '$resource', + 'API_ENDPOINT_ENDPOINTS', + 'EndpointProvider', + function KubernetesConfigMapsFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { + 'use strict'; + return function (namespace) { + const url = API_ENDPOINT_ENDPOINTS + '/:endpointId/kubernetes/api/v1' + (namespace ? '/namespaces/:namespace' : '') + '/configmaps/:id/:action'; + return $resource( + url, + { + endpointId: EndpointProvider.endpointID, + namespace: namespace, + }, + { + get: { + method: 'GET', + ignoreLoadingBar: true, + }, + getYaml: { + method: 'GET', + headers: { + Accept: 'application/yaml', + }, + transformResponse: rawResponse, + ignoreLoadingBar: true, + }, + create: { method: 'POST' }, + update: { method: 'PUT' }, + delete: { method: 'DELETE' }, + } + ); + }; + }, +]); diff --git a/app/kubernetes/rest/controllerRevision.js b/app/kubernetes/rest/controllerRevision.js new file mode 100644 index 0000000..d0c3388 --- /dev/null +++ b/app/kubernetes/rest/controllerRevision.js @@ -0,0 +1,43 @@ +import { rawResponse } from '@/kubernetes/rest/response/transform'; + +angular.module('portainer.kubernetes').factory('KubernetesControllerRevisions', [ + '$resource', + 'API_ENDPOINT_ENDPOINTS', + 'EndpointProvider', + function KubernetesControllerRevisionsFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { + 'use strict'; + return function (namespace) { + const url = API_ENDPOINT_ENDPOINTS + '/:endpointId/kubernetes/apis/apps/v1' + (namespace ? '/namespaces/:namespace' : '') + '/controllerrevisions/:id/:action'; + return $resource( + url, + { + endpointId: EndpointProvider.endpointID, + namespace: namespace, + }, + { + get: { + method: 'GET', + ignoreLoadingBar: true, + }, + getYaml: { + method: 'GET', + headers: { + Accept: 'application/yaml', + }, + transformResponse: rawResponse, + ignoreLoadingBar: true, + }, + create: { method: 'POST' }, + update: { method: 'PUT' }, + patch: { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json-patch+json', + }, + }, + delete: { method: 'DELETE' }, + } + ); + }; + }, +]); diff --git a/app/kubernetes/rest/daemonSet.js b/app/kubernetes/rest/daemonSet.js new file mode 100644 index 0000000..20cb5c4 --- /dev/null +++ b/app/kubernetes/rest/daemonSet.js @@ -0,0 +1,49 @@ +import { rawResponse } from '@/kubernetes/rest/response/transform'; + +angular.module('portainer.kubernetes').factory('KubernetesDaemonSets', [ + '$resource', + 'API_ENDPOINT_ENDPOINTS', + 'EndpointProvider', + function KubernetesDaemonSetsFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { + 'use strict'; + return function (namespace) { + const url = API_ENDPOINT_ENDPOINTS + '/:endpointId/kubernetes/apis/apps/v1' + (namespace ? '/namespaces/:namespace' : '') + '/daemonsets/:id/:action'; + return $resource( + url, + { + endpointId: EndpointProvider.endpointID, + namespace: namespace, + }, + { + get: { + method: 'GET', + ignoreLoadingBar: true, + }, + getYaml: { + method: 'GET', + headers: { + Accept: 'application/yaml', + }, + transformResponse: rawResponse, + ignoreLoadingBar: true, + }, + create: { method: 'POST' }, + update: { method: 'PUT' }, + patch: { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json-patch+json', + }, + }, + rollback: { + method: 'PATCH', + headers: { + 'Content-Type': 'application/strategic-merge-patch+json', + }, + }, + delete: { method: 'DELETE' }, + } + ); + }; + }, +]); diff --git a/app/kubernetes/rest/deployment.js b/app/kubernetes/rest/deployment.js new file mode 100644 index 0000000..e12a054 --- /dev/null +++ b/app/kubernetes/rest/deployment.js @@ -0,0 +1,49 @@ +import { rawResponse } from '@/kubernetes/rest/response/transform'; + +angular.module('portainer.kubernetes').factory('KubernetesDeployments', [ + '$resource', + 'API_ENDPOINT_ENDPOINTS', + 'EndpointProvider', + function KubernetesDeploymentsFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { + 'use strict'; + return function (namespace) { + const url = API_ENDPOINT_ENDPOINTS + '/:endpointId/kubernetes/apis/apps/v1' + (namespace ? '/namespaces/:namespace' : '') + '/deployments/:id/:action'; + return $resource( + url, + { + endpointId: EndpointProvider.endpointID, + namespace: namespace, + }, + { + get: { + method: 'GET', + ignoreLoadingBar: true, + }, + getYaml: { + method: 'GET', + headers: { + Accept: 'application/yaml', + }, + transformResponse: rawResponse, + ignoreLoadingBar: true, + }, + create: { method: 'POST' }, + update: { method: 'PUT' }, + patch: { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json-patch+json', + }, + }, + rollback: { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json-patch+json', + }, + }, + delete: { method: 'DELETE' }, + } + ); + }; + }, +]); diff --git a/app/kubernetes/rest/endpoint.js b/app/kubernetes/rest/endpoint.js new file mode 100644 index 0000000..71fde10 --- /dev/null +++ b/app/kubernetes/rest/endpoint.js @@ -0,0 +1,19 @@ +angular.module('portainer.kubernetes').factory('KubernetesEndpoints', function KubernetesEndpointsFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { + 'use strict'; + return function (namespace) { + const url = API_ENDPOINT_ENDPOINTS + '/:endpointId/kubernetes/api/v1' + (namespace ? '/namespaces/:namespace' : '') + '/endpoints/:id'; + return $resource( + url, + { + endpointId: EndpointProvider.endpointID, + namespace: namespace, + }, + { + get: { + method: 'GET', + ignoreLoadingBar: true, + }, + } + ); + }; +}); diff --git a/app/kubernetes/rest/event.js b/app/kubernetes/rest/event.js new file mode 100644 index 0000000..78354d3 --- /dev/null +++ b/app/kubernetes/rest/event.js @@ -0,0 +1,37 @@ +import { rawResponse } from '@/kubernetes/rest/response/transform'; + +angular.module('portainer.kubernetes').factory('KubernetesEvents', [ + '$resource', + 'API_ENDPOINT_ENDPOINTS', + 'EndpointProvider', + function KubernetesEventsFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { + 'use strict'; + return function (namespace) { + const url = API_ENDPOINT_ENDPOINTS + '/:endpointId/kubernetes/api/v1' + (namespace ? '/namespaces/:namespace' : '') + '/events/:id/:action'; + return $resource( + url, + { + endpointId: EndpointProvider.endpointID, + namespace: namespace, + }, + { + get: { + method: 'GET', + ignoreLoadingBar: true, + }, + getYaml: { + method: 'GET', + headers: { + Accept: 'application/yaml', + }, + transformResponse: rawResponse, + ignoreLoadingBar: true, + }, + create: { method: 'POST' }, + update: { method: 'PUT' }, + delete: { method: 'DELETE' }, + } + ); + }; + }, +]); diff --git a/app/kubernetes/rest/health.js b/app/kubernetes/rest/health.js new file mode 100644 index 0000000..bc41be4 --- /dev/null +++ b/app/kubernetes/rest/health.js @@ -0,0 +1,14 @@ +angular.module('portainer.kubernetes').factory('KubernetesHealth', [ + '$resource', + 'API_ENDPOINT_ENDPOINTS', + function KubernetesHealthFactory($resource, API_ENDPOINT_ENDPOINTS) { + 'use strict'; + return $resource( + API_ENDPOINT_ENDPOINTS + '/:id/kubernetes/healthz', + {}, + { + ping: { method: 'GET', params: { id: 'id' } }, + } + ); + }, +]); diff --git a/app/kubernetes/rest/namespace.js b/app/kubernetes/rest/namespace.js new file mode 100644 index 0000000..c594ef4 --- /dev/null +++ b/app/kubernetes/rest/namespace.js @@ -0,0 +1,49 @@ +import { rawResponse } from '@/kubernetes/rest/response/transform'; + +angular.module('portainer.kubernetes').factory('KubernetesNamespaces', [ + '$resource', + 'API_ENDPOINT_ENDPOINTS', + 'EndpointProvider', + function KubernetesNamespacesFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { + 'use strict'; + return function () { + const url = API_ENDPOINT_ENDPOINTS + '/:endpointId/kubernetes/api/v1/namespaces/:id/:action'; + return $resource( + url, + { + endpointId: EndpointProvider.endpointID, + }, + { + get: { + method: 'GET', + ignoreLoadingBar: true, + }, + getYaml: { + method: 'GET', + headers: { + Accept: 'application/yaml', + }, + transformResponse: rawResponse, + ignoreLoadingBar: true, + }, + getJSON: { + method: 'GET', + headers: { + Accept: 'application/json', + }, + transformResponse: rawResponse, + ignoreLoadingBar: true, + }, + create: { method: 'POST' }, + update: { method: 'PUT' }, + delete: { method: 'DELETE' }, + status: { + method: 'GET', + params: { action: 'status' }, + ignoreLoadingBar: true, + }, + } + ); + }; + }, +]); diff --git a/app/kubernetes/rest/nodesLimits.js b/app/kubernetes/rest/nodesLimits.js new file mode 100644 index 0000000..d8eb59d --- /dev/null +++ b/app/kubernetes/rest/nodesLimits.js @@ -0,0 +1,21 @@ +import angular from 'angular'; + +angular.module('portainer.kubernetes').factory('KubernetesNodesLimits', KubernetesNodesLimitsFactory); + +/* @ngInject */ +function KubernetesNodesLimitsFactory($resource, API_ENDPOINT_KUBERNETES, EndpointProvider) { + const url = API_ENDPOINT_KUBERNETES + '/:endpointId/nodes_limits'; + return $resource( + url, + { + endpointId: EndpointProvider.endpointID, + }, + { + get: { + method: 'GET', + ignoreLoadingBar: true, + transformResponse: (data) => ({ data: JSON.parse(data) }), + }, + } + ); +} diff --git a/app/kubernetes/rest/persistentVolumeClaim.js b/app/kubernetes/rest/persistentVolumeClaim.js new file mode 100644 index 0000000..329d73d --- /dev/null +++ b/app/kubernetes/rest/persistentVolumeClaim.js @@ -0,0 +1,43 @@ +import { rawResponse } from '@/kubernetes/rest/response/transform'; + +angular.module('portainer.kubernetes').factory('KubernetesPersistentVolumeClaims', [ + '$resource', + 'API_ENDPOINT_ENDPOINTS', + 'EndpointProvider', + function KubernetesPersistentVolumeClaimsFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { + 'use strict'; + return function (namespace) { + const url = API_ENDPOINT_ENDPOINTS + '/:endpointId/kubernetes/api/v1' + (namespace ? '/namespaces/:namespace' : '') + '/persistentvolumeclaims/:id/:action'; + return $resource( + url, + { + endpointId: EndpointProvider.endpointID, + namespace: namespace, + }, + { + get: { + method: 'GET', + ignoreLoadingBar: true, + }, + getYaml: { + method: 'GET', + headers: { + Accept: 'application/yaml', + }, + transformResponse: rawResponse, + ignoreLoadingBar: true, + }, + create: { method: 'POST' }, + update: { method: 'PUT' }, + patch: { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json-patch+json', + }, + }, + delete: { method: 'DELETE' }, + } + ); + }; + }, +]); diff --git a/app/kubernetes/rest/pod.js b/app/kubernetes/rest/pod.js new file mode 100644 index 0000000..46cfe95 --- /dev/null +++ b/app/kubernetes/rest/pod.js @@ -0,0 +1,57 @@ +import { rawResponse } from '@/kubernetes/rest/response/transform'; + +angular.module('portainer.kubernetes').factory('KubernetesPods', [ + '$resource', + 'API_ENDPOINT_ENDPOINTS', + 'EndpointProvider', + function KubernetesPodsFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { + 'use strict'; + return function (namespace) { + const url = API_ENDPOINT_ENDPOINTS + '/:endpointId/kubernetes/api/v1' + (namespace ? '/namespaces/:namespace' : '') + '/pods/:id/:action'; + return $resource( + url, + { + endpointId: EndpointProvider.endpointID, + namespace: namespace, + }, + { + get: { + method: 'GET', + ignoreLoadingBar: true, + }, + getYaml: { + method: 'GET', + headers: { + Accept: 'application/yaml', + }, + transformResponse: rawResponse, + ignoreLoadingBar: true, + }, + create: { method: 'POST' }, + update: { method: 'PUT' }, + delete: { method: 'DELETE' }, + patch: { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json-patch+json', + }, + }, + logs: { + method: 'GET', + params: { action: 'log' }, + transformResponse: logsHandler, + }, + evict: { method: 'POST' }, + } + ); + }; + }, +]); + +// The Docker API returns the logs as a single string. +// This handler wraps the data in a JSON object under the "logs" property. +function logsHandler(data) { + return { + logs: data, + }; +} diff --git a/app/kubernetes/rest/portainer-namespace.js b/app/kubernetes/rest/portainer-namespace.js new file mode 100644 index 0000000..ee54f3f --- /dev/null +++ b/app/kubernetes/rest/portainer-namespace.js @@ -0,0 +1,12 @@ +angular.module('portainer.kubernetes').factory('KubernetesPortainerNamespaces', KubernetesPortainerNamespacesFactory); + +function KubernetesPortainerNamespacesFactory($resource) { + const url = '/api/kubernetes/:endpointId/namespaces/:namespaceName/:action'; + return $resource( + url, + {}, + { + toggleSystem: { method: 'PUT', params: { action: 'system' } }, + } + ); +} diff --git a/app/kubernetes/rest/replicaSet.js b/app/kubernetes/rest/replicaSet.js new file mode 100644 index 0000000..bc42d00 --- /dev/null +++ b/app/kubernetes/rest/replicaSet.js @@ -0,0 +1,43 @@ +import { rawResponse } from '@/kubernetes/rest/response/transform'; + +angular.module('portainer.kubernetes').factory('KubernetesReplicaSets', [ + '$resource', + 'API_ENDPOINT_ENDPOINTS', + 'EndpointProvider', + function KubernetesReplicaSetsFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { + 'use strict'; + return function (namespace) { + const url = API_ENDPOINT_ENDPOINTS + '/:endpointId/kubernetes/apis/apps/v1' + (namespace ? '/namespaces/:namespace' : '') + '/replicasets/:id/:action'; + return $resource( + url, + { + endpointId: EndpointProvider.endpointID, + namespace: namespace, + }, + { + get: { + method: 'GET', + ignoreLoadingBar: true, + }, + getYaml: { + method: 'GET', + headers: { + Accept: 'application/yaml', + }, + transformResponse: rawResponse, + ignoreLoadingBar: true, + }, + create: { method: 'POST' }, + update: { method: 'PUT' }, + patch: { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json-patch+json', + }, + }, + delete: { method: 'DELETE' }, + } + ); + }; + }, +]); diff --git a/app/kubernetes/rest/resourceQuota.js b/app/kubernetes/rest/resourceQuota.js new file mode 100644 index 0000000..55d111c --- /dev/null +++ b/app/kubernetes/rest/resourceQuota.js @@ -0,0 +1,43 @@ +import { rawResponse } from '@/kubernetes/rest/response/transform'; + +angular.module('portainer.kubernetes').factory('KubernetesResourceQuotas', [ + '$resource', + 'API_ENDPOINT_ENDPOINTS', + 'EndpointProvider', + function KubernetesResourceQuotasFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { + 'use strict'; + return function (namespace) { + const url = API_ENDPOINT_ENDPOINTS + '/:endpointId/kubernetes/api/v1' + (namespace ? '/namespaces/:namespace' : '') + '/resourcequotas/:id/:action'; + return $resource( + url, + { + endpointId: EndpointProvider.endpointID, + namespace: namespace, + }, + { + get: { + method: 'GET', + ignoreLoadingBar: true, + }, + getYaml: { + method: 'GET', + headers: { + Accept: 'application/yaml', + }, + transformResponse: rawResponse, + ignoreLoadingBar: true, + }, + create: { method: 'POST' }, + update: { method: 'PUT' }, + patch: { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json-patch+json', + }, + }, + delete: { method: 'DELETE' }, + } + ); + }; + }, +]); diff --git a/app/kubernetes/rest/response/transform.js b/app/kubernetes/rest/response/transform.js new file mode 100644 index 0000000..d4a0db0 --- /dev/null +++ b/app/kubernetes/rest/response/transform.js @@ -0,0 +1,7 @@ +// Returns the raw response without JSON parsing +export function rawResponse(data) { + const response = { + data: data, + }; + return response; +} diff --git a/app/kubernetes/rest/secret.js b/app/kubernetes/rest/secret.js new file mode 100644 index 0000000..74931a4 --- /dev/null +++ b/app/kubernetes/rest/secret.js @@ -0,0 +1,37 @@ +import { rawResponse } from '@/kubernetes/rest/response/transform'; + +angular.module('portainer.kubernetes').factory('KubernetesSecrets', [ + '$resource', + 'API_ENDPOINT_ENDPOINTS', + 'EndpointProvider', + function KubernetesSecretsFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { + 'use strict'; + return function (namespace) { + const url = API_ENDPOINT_ENDPOINTS + '/:endpointId/kubernetes/api/v1' + (namespace ? '/namespaces/:namespace' : '') + '/secrets/:id/:action'; + return $resource( + url, + { + endpointId: EndpointProvider.endpointID, + namespace: namespace, + }, + { + get: { + method: 'GET', + ignoreLoadingBar: true, + }, + getYaml: { + method: 'GET', + headers: { + Accept: 'application/yaml', + }, + transformResponse: rawResponse, + ignoreLoadingBar: true, + }, + create: { method: 'POST' }, + update: { method: 'PUT' }, + delete: { method: 'DELETE' }, + } + ); + }; + }, +]); diff --git a/app/kubernetes/rest/service.js b/app/kubernetes/rest/service.js new file mode 100644 index 0000000..16acc97 --- /dev/null +++ b/app/kubernetes/rest/service.js @@ -0,0 +1,43 @@ +import { rawResponse } from '@/kubernetes/rest/response/transform'; + +angular.module('portainer.kubernetes').factory('KubernetesServices', [ + '$resource', + 'API_ENDPOINT_ENDPOINTS', + 'EndpointProvider', + function KubernetesServicesFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { + 'use strict'; + return function (namespace) { + const url = API_ENDPOINT_ENDPOINTS + '/:endpointId/kubernetes/api/v1' + (namespace ? '/namespaces/:namespace' : '') + '/services/:id/:action'; + return $resource( + url, + { + endpointId: EndpointProvider.endpointID, + namespace: namespace, + }, + { + get: { + method: 'GET', + ignoreLoadingBar: true, + }, + getYaml: { + method: 'GET', + headers: { + Accept: 'application/yaml', + }, + transformResponse: rawResponse, + ignoreLoadingBar: true, + }, + create: { method: 'POST' }, + update: { method: 'PUT' }, + patch: { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json-patch+json', + }, + }, + delete: { method: 'DELETE' }, + } + ); + }; + }, +]); diff --git a/app/kubernetes/rest/serviceAccount.js b/app/kubernetes/rest/serviceAccount.js new file mode 100644 index 0000000..e0b36f8 --- /dev/null +++ b/app/kubernetes/rest/serviceAccount.js @@ -0,0 +1,16 @@ +import axios, { parseAxiosError } from '@/react/portainer/services/axios/axios'; + +export async function getServiceAccounts(environmentId, namespaceId) { + try { + const { + data: { items }, + } = await axios.get(urlBuilder(environmentId, namespaceId)); + return items; + } catch (error) { + throw parseAxiosError(error); + } +} + +function urlBuilder(environmentId, namespaceId) { + return `endpoints/${environmentId}/kubernetes/api/v1/namespaces/${namespaceId}/serviceaccounts`; +} diff --git a/app/kubernetes/rest/statefulSet.js b/app/kubernetes/rest/statefulSet.js new file mode 100644 index 0000000..d4ef7ee --- /dev/null +++ b/app/kubernetes/rest/statefulSet.js @@ -0,0 +1,49 @@ +import { rawResponse } from '@/kubernetes/rest/response/transform'; + +angular.module('portainer.kubernetes').factory('KubernetesStatefulSets', [ + '$resource', + 'API_ENDPOINT_ENDPOINTS', + 'EndpointProvider', + function KubernetesStatefulSetsFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { + 'use strict'; + return function (namespace) { + const url = API_ENDPOINT_ENDPOINTS + '/:endpointId/kubernetes/apis/apps/v1' + (namespace ? '/namespaces/:namespace' : '') + '/statefulsets/:id/:action'; + return $resource( + url, + { + endpointId: EndpointProvider.endpointID, + namespace: namespace, + }, + { + get: { + method: 'GET', + ignoreLoadingBar: true, + }, + getYaml: { + method: 'GET', + headers: { + Accept: 'application/yaml', + }, + transformResponse: rawResponse, + ignoreLoadingBar: true, + }, + create: { method: 'POST' }, + update: { method: 'PUT' }, + patch: { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json-patch+json', + }, + }, + rollback: { + method: 'PATCH', + headers: { + 'Content-Type': 'application/strategic-merge-patch+json', + }, + }, + delete: { method: 'DELETE' }, + } + ); + }; + }, +]); diff --git a/app/kubernetes/rest/storage.js b/app/kubernetes/rest/storage.js new file mode 100644 index 0000000..496ff55 --- /dev/null +++ b/app/kubernetes/rest/storage.js @@ -0,0 +1,42 @@ +import { rawResponse } from '@/kubernetes/rest/response/transform'; + +angular.module('portainer.kubernetes').factory('KubernetesStorage', [ + '$resource', + 'API_ENDPOINT_ENDPOINTS', + 'EndpointProvider', + function KubernetesStorageFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { + 'use strict'; + return function () { + const url = API_ENDPOINT_ENDPOINTS + '/:endpointId/kubernetes/apis/storage.k8s.io/v1/storageclasses/:id/:action'; + return $resource( + url, + { + endpointId: EndpointProvider.endpointID, + }, + { + get: { + method: 'GET', + ignoreLoadingBar: true, + }, + getYaml: { + method: 'GET', + headers: { + Accept: 'application/yaml', + }, + transformResponse: rawResponse, + ignoreLoadingBar: true, + }, + create: { method: 'POST' }, + update: { method: 'PUT' }, + patch: { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json-patch+json', + }, + }, + delete: { method: 'DELETE' }, + } + ); + }; + }, +]); diff --git a/app/kubernetes/services/applicationService.js b/app/kubernetes/services/applicationService.js new file mode 100644 index 0000000..97db6da --- /dev/null +++ b/app/kubernetes/services/applicationService.js @@ -0,0 +1,475 @@ +import _ from 'lodash-es'; +import angular from 'angular'; +import PortainerError from '@/portainer/error'; + +import { KubernetesApplicationDeploymentTypes, KubernetesApplicationTypes } from '@/kubernetes/models/application/models/appConstants'; +import KubernetesApplicationHelper from '@/kubernetes/helpers/application'; +import KubernetesApplicationConverter from '@/kubernetes/converters/application'; +import { KubernetesStatefulSet } from '@/kubernetes/models/stateful-set/models'; +import KubernetesServiceHelper from '@/kubernetes/helpers/serviceHelper'; +import { KubernetesHorizontalPodAutoScalerHelper } from '@/kubernetes/horizontal-pod-auto-scaler/helper'; +import { KubernetesHorizontalPodAutoScalerConverter } from '@/kubernetes/horizontal-pod-auto-scaler/converter'; +import KubernetesPodConverter from '@/kubernetes/pod/converter'; +import { notifyError } from '@/portainer/services/notifications'; +import { KubernetesIngressConverter } from '@/kubernetes/ingress/converter'; +import { generateNewIngressesFromFormPaths } from '@/react/kubernetes/applications/CreateView/application-services/utils'; +import { KubernetesPod } from '../pod/models'; + +class KubernetesApplicationService { + /* #region CONSTRUCTOR */ + /* @ngInject */ + constructor( + $async, + Authentication, + KubernetesDeploymentService, + KubernetesDaemonSetService, + KubernetesStatefulSetService, + KubernetesServiceService, + KubernetesSecretService, + KubernetesPersistentVolumeClaimService, + KubernetesNamespaceService, + KubernetesPodService, + KubernetesHorizontalPodAutoScalerService, + KubernetesIngressService + ) { + this.$async = $async; + this.Authentication = Authentication; + this.KubernetesDeploymentService = KubernetesDeploymentService; + this.KubernetesDaemonSetService = KubernetesDaemonSetService; + this.KubernetesStatefulSetService = KubernetesStatefulSetService; + this.KubernetesServiceService = KubernetesServiceService; + this.KubernetesSecretService = KubernetesSecretService; + this.KubernetesPersistentVolumeClaimService = KubernetesPersistentVolumeClaimService; + this.KubernetesNamespaceService = KubernetesNamespaceService; + this.KubernetesPodService = KubernetesPodService; + this.KubernetesHorizontalPodAutoScalerService = KubernetesHorizontalPodAutoScalerService; + this.KubernetesIngressService = KubernetesIngressService; + + this.getAsync = this.getAsync.bind(this); + this.getAllAsync = this.getAllAsync.bind(this); + this.createAsync = this.createAsync.bind(this); + this.patchAsync = this.patchAsync.bind(this); + this.patchPartialAsync = this.patchPartialAsync.bind(this); + this.deleteAsync = this.deleteAsync.bind(this); + } + /* #endregion */ + + /* #region UTILS */ + _getApplicationApiService(app) { + let apiService; + if (app.ApplicationType === KubernetesApplicationTypes.Deployment) { + apiService = this.KubernetesDeploymentService; + } else if (app.ApplicationType === KubernetesApplicationTypes.DaemonSet) { + apiService = this.KubernetesDaemonSetService; + } else if (app.ApplicationType === KubernetesApplicationTypes.StatefulSet) { + apiService = this.KubernetesStatefulSetService; + } else if (app instanceof KubernetesPod || KubernetesApplicationTypes.Pod) { + apiService = this.KubernetesPodService; + } else { + throw new PortainerError('Unable to determine which association to use to retrieve API Service'); + } + return apiService; + } + + _generateIngressPatchPromises(oldIngresses, newIngresses) { + return _.map(newIngresses, (newIng) => { + const oldIng = _.find(oldIngresses, { Name: newIng.Name }); + return this.KubernetesIngressService.patch(oldIng, newIng); + }); + } + /* #endregion */ + + /* #region GET */ + async getAsync(namespace, name) { + const [deployment, daemonSet, statefulSet, pod, pods, autoScalers, ingresses] = await Promise.allSettled([ + this.KubernetesDeploymentService.get(namespace, name), + this.KubernetesDaemonSetService.get(namespace, name), + this.KubernetesStatefulSetService.get(namespace, name), + this.KubernetesPodService.get(namespace, name), + this.KubernetesPodService.get(namespace), + this.KubernetesHorizontalPodAutoScalerService.get(namespace), + this.KubernetesIngressService.get(namespace), + ]); + + let rootItem; + let converterFunc; + if (deployment.status === 'fulfilled') { + rootItem = deployment; + converterFunc = KubernetesApplicationConverter.apiDeploymentToApplication; + } else if (daemonSet.status === 'fulfilled') { + rootItem = daemonSet; + converterFunc = KubernetesApplicationConverter.apiDaemonSetToApplication; + } else if (statefulSet.status === 'fulfilled') { + rootItem = statefulSet; + converterFunc = KubernetesApplicationConverter.apiStatefulSetToapplication; + } else if (pod.status === 'fulfilled') { + rootItem = pod; + converterFunc = KubernetesApplicationConverter.apiPodToApplication; + } else { + throw new PortainerError('Unable to determine which association to use to convert application'); + } + + const services = await this.KubernetesServiceService.get(namespace); + const boundService = KubernetesServiceHelper.findApplicationBoundService(services, rootItem.value.Raw); + const service = boundService ? await this.KubernetesServiceService.get(namespace, boundService.metadata.name) : {}; + const boundServices = KubernetesServiceHelper.findApplicationBoundServices(services, rootItem.value.Raw); + + const application = converterFunc(rootItem.value.Raw, pods.value, service.Raw, ingresses.value); + application.Yaml = rootItem.value.Yaml; + application.Raw = rootItem.value.Raw; + application.Pods = _.map(application.Pods, (item) => KubernetesPodConverter.apiToModel(item)); + application.Containers = KubernetesApplicationHelper.associateContainersAndApplication(application); + application.Services = boundServices; + + const boundScaler = KubernetesHorizontalPodAutoScalerHelper.findApplicationBoundScaler(autoScalers.value, application); + const scaler = boundScaler ? await this.KubernetesHorizontalPodAutoScalerService.get(namespace, boundScaler.Name) : undefined; + application.AutoScaler = scaler; + + if (service.Yaml) { + application.Yaml += '---\n' + service.Yaml; + } + if (scaler && scaler.Yaml) { + application.Yaml += '---\n' + scaler.Yaml; + } + // TODO: refactor @LP + // append ingress yaml ? + return application; + } + + async getAllAsync(namespace) { + const namespaces = namespace ? [namespace] : _.map(await this.KubernetesNamespaceService.get(), 'Name'); + + const convertToApplication = (item, converterFunc, services, pods, ingresses) => { + const service = KubernetesServiceHelper.findApplicationBoundService(services, item); + const servicesFound = KubernetesServiceHelper.findApplicationBoundServices(services, item); + const application = converterFunc(item, pods, service, ingresses); + application.Containers = KubernetesApplicationHelper.associateContainersAndApplication(application); + application.Services = servicesFound; + return application; + }; + + const res = await Promise.all( + _.map(namespaces, async (ns) => { + const [deployments, daemonSets, statefulSets, services, pods, ingresses, autoScalers] = await Promise.all([ + this.KubernetesDeploymentService.get(ns), + this.KubernetesDaemonSetService.get(ns), + this.KubernetesStatefulSetService.get(ns), + this.KubernetesServiceService.get(ns), + this.KubernetesPodService.get(ns), + this.KubernetesIngressService.get(ns), + this.KubernetesHorizontalPodAutoScalerService.get(ns), + ]); + + const deploymentApplications = _.map(deployments, (item) => + convertToApplication(item, KubernetesApplicationConverter.apiDeploymentToApplication, services, pods, ingresses) + ); + const daemonSetApplications = _.map(daemonSets, (item) => convertToApplication(item, KubernetesApplicationConverter.apiDaemonSetToApplication, services, pods, ingresses)); + const statefulSetApplications = _.map(statefulSets, (item) => + convertToApplication(item, KubernetesApplicationConverter.apiStatefulSetToapplication, services, pods, ingresses) + ); + + const boundPods = _.concat(_.flatMap(deploymentApplications, 'Pods'), _.flatMap(daemonSetApplications, 'Pods'), _.flatMap(statefulSetApplications, 'Pods')); + const unboundPods = _.without(pods, ...boundPods); + const nakedPodsApplications = _.map(unboundPods, (item) => convertToApplication(item, KubernetesApplicationConverter.apiPodToApplication, services, pods, ingresses)); + + const applications = _.concat(deploymentApplications, daemonSetApplications, statefulSetApplications, nakedPodsApplications); + _.forEach(applications, (app) => { + app.Pods = _.map(app.Pods, (item) => KubernetesPodConverter.apiToModel(item)); + }); + await Promise.all( + _.forEach(applications, async (application) => { + const boundScaler = KubernetesHorizontalPodAutoScalerHelper.findApplicationBoundScaler(autoScalers, application); + const scaler = boundScaler ? await this.KubernetesHorizontalPodAutoScalerService.get(ns, boundScaler.Name) : undefined; + application.AutoScaler = scaler; + }) + ); + return applications; + }) + ); + return _.flatten(res); + } + + get(namespace, name) { + if (name) { + return this.$async(this.getAsync, namespace, name); + } + return this.$async(this.getAllAsync, namespace); + } + /* #endregion */ + + /* #region CREATE */ + // TODO: review + // resource creation flow + // should we keep formValues > Resource_1 || Resource_2 + // or should we switch to formValues > Composite > Resource_1 || Resource_2 + /** + * NOTE: Keep this method flow in sync with `getCreatedApplicationResources` method in the `applicationService` file + * To synchronise with kubernetes resource creation summary output, any new resources created in this method should + * also be displayed in the summary output (getCreatedApplicationResources) + */ + async createAsync(formValues, hideStacks) { + // formValues -> Application + let [app, headlessService, services, , claims] = KubernetesApplicationConverter.applicationFormValuesToApplication(formValues); + + if (hideStacks) { + app.StackName = ''; + } + + if (services) { + services.forEach(async (service) => { + try { + await this.KubernetesServiceService.create(service); + } catch (error) { + notifyError('Unable to create service', error); + } + }); + + try { + //Generate all ingresses from current form by passing services object + const newServicePorts = formValues.Services.flatMap((service) => service.Ports); + const newIngresses = generateNewIngressesFromFormPaths(formValues.OriginalIngresses, newServicePorts); + if (newIngresses) { + //Update original ingress with current ingress + await Promise.all(this._generateIngressPatchPromises(formValues.OriginalIngresses, newIngresses)); + } + } catch (error) { + notifyError('Unable to update service', error); + } + } + + const apiService = this._getApplicationApiService(app); + + if (app instanceof KubernetesStatefulSet) { + app.VolumeClaims = claims; + try { + headlessService = await this.KubernetesServiceService.create(headlessService); + } catch (error) { + notifyError('Unable to create service', error); + } + app.ServiceName = headlessService.metadata.name; + } else { + const claimPromises = _.map(claims, (item) => { + if (!item.PreviousName && !item.Id) { + return this.KubernetesPersistentVolumeClaimService.create(item); + } + }); + await Promise.all(_.without(claimPromises, undefined)); + } + + if (formValues.AutoScaler.isUsed && formValues.DeploymentType !== KubernetesApplicationDeploymentTypes.Global) { + const kind = app.ApplicationType; + const autoScaler = KubernetesHorizontalPodAutoScalerConverter.applicationFormValuesToModel(formValues, kind); + await this.KubernetesHorizontalPodAutoScalerService.create(autoScaler); + } + + await apiService.create(app); + } + + create(formValues, _, hideStacks) { + return this.$async(this.createAsync, formValues, hideStacks); + } + /* #endregion */ + + /* #region PATCH */ + // this function accepts KubernetesApplicationFormValues as parameters + /** + * NOTE: Keep this method flow in sync with `getUpdatedApplicationResources` method in the `applicationService` file + * To synchronise with kubernetes resource creation, update and delete summary output, any new resources created + * in this method should also be displayed in the summary output (getUpdatedApplicationResources) + */ + async patchAsync(oldFormValues, newFormValues, originalServicePorts) { + const [oldApp, oldHeadlessService, oldServices, , oldClaims] = KubernetesApplicationConverter.applicationFormValuesToApplication(oldFormValues); + const [newApp, newHeadlessService, newServices, , newClaims] = KubernetesApplicationConverter.applicationFormValuesToApplication(newFormValues); + const oldApiService = this._getApplicationApiService(oldApp); + const newApiService = this._getApplicationApiService(newApp); + + if (oldApiService !== newApiService) { + // delete services first + if (oldServices) { + await this.KubernetesServiceService.delete(oldServices); + } + + // delete the app + await this.delete(oldApp); + + // sleep for 5 seconds to allow the app/services to be deleted + await new Promise((r) => setTimeout(r, 5000)); + + // create the app + return await this.create(newFormValues); + } + + if (newApp instanceof KubernetesStatefulSet) { + try { + await this.KubernetesServiceService.patch(oldHeadlessService, newHeadlessService); + } catch (error) { + notifyError('Unable to update service', error); + } + } else { + const claimPromises = _.map(newClaims, (newClaim) => { + if (!newClaim.PreviousName && !newClaim.Id) { + return this.KubernetesPersistentVolumeClaimService.create(newClaim); + } else if (!newClaim.Id) { + const oldClaim = _.find(oldClaims, { Name: newClaim.PreviousName }); + return this.KubernetesPersistentVolumeClaimService.patch(oldClaim, newClaim); + } + }); + await Promise.all(claimPromises); + } + + await newApiService.patch(oldApp, newApp); + + // Create services + if (oldServices.length === 0 && newServices.length !== 0) { + newServices.forEach(async (service) => { + try { + await this.KubernetesServiceService.create(service); + } catch (error) { + notifyError('Unable to create service', error); + } + }); + } + + // Delete services ( only called when all services been deleted ) + if (oldServices.length !== 0 && newServices.length === 0) { + await this.KubernetesServiceService.deleteAll(oldServices); + } + + // Patch services ( Action including: Delete, Update, Create ) + if (oldServices.length !== 0 && newServices.length !== 0) { + oldServices.forEach(async (oldService) => { + const newServiceMatched = _.find(newServices, { Name: oldService.Name }); + if (!newServiceMatched) { + await this.KubernetesServiceService.deleteSingle(oldService); + } + }); + + newServices.forEach(async (newService) => { + const oldServiceMatched = _.find(oldServices, { Name: newService.Name }); + if (oldServiceMatched) { + try { + await this.KubernetesServiceService.patch(oldServiceMatched, newService); + } catch (error) { + notifyError('Unable to update service', error); + } + } else { + try { + await this.KubernetesServiceService.create(newService); + } catch (error) { + notifyError('Unable to create service', error); + } + } + }); + } + + // Update ingresses + if (newServices) { + try { + //Generate all ingresses from current form by passing services object + const newServicePorts = newFormValues.Services.flatMap((service) => service.Ports); + const newIngresses = generateNewIngressesFromFormPaths(newFormValues.OriginalIngresses, newServicePorts, originalServicePorts); + if (newIngresses) { + //Update original ingress with current ingress + await Promise.all(this._generateIngressPatchPromises(newFormValues.OriginalIngresses, newIngresses)); + } + } catch (error) { + notifyError('Unable to update service', error); + } + } + + const newKind = newApp.ApplicationType; + const newAutoScaler = KubernetesHorizontalPodAutoScalerConverter.applicationFormValuesToModel(newFormValues, newKind); + if (!oldFormValues.AutoScaler.isUsed) { + if (newFormValues.AutoScaler.isUsed) { + await this.KubernetesHorizontalPodAutoScalerService.create(newAutoScaler); + } + } else { + const oldKind = oldApp.ApplicationType; + const oldAutoScaler = KubernetesHorizontalPodAutoScalerConverter.applicationFormValuesToModel(oldFormValues, oldKind); + if (newFormValues.AutoScaler.isUsed) { + await this.KubernetesHorizontalPodAutoScalerService.patch(oldAutoScaler, newAutoScaler); + } else { + await this.KubernetesHorizontalPodAutoScalerService.delete(oldAutoScaler); + } + } + } + + // this function accepts KubernetesApplication as parameters + async patchPartialAsync(oldApp, newApp) { + const oldAppPayload = { + Name: oldApp.Name, + Namespace: oldApp.ResourcePool, + StackName: oldApp.StackName, + Note: oldApp.Note, + }; + const newAppPayload = { + Name: newApp.Name, + Namespace: newApp.ResourcePool, + StackName: newApp.StackName, + Note: newApp.Note, + }; + const apiService = this._getApplicationApiService(oldApp); + await apiService.patch(oldAppPayload, newAppPayload); + } + + // accept either formValues or applications as parameters depending on partial value + // true = KubernetesApplication + // false = KubernetesApplicationFormValues + // + // e.g. signatures are + // + // patch(oldValues: KubernetesApplication, newValues: KubernetesApplication, partial: (undefined | false)): Promise + // patch(oldValues: KubernetesApplicationFormValues, newValues: KubernetesApplicationFormValues, partial: true): Promise + patch(oldValues, newValues, partial = false, originalServicePorts) { + if (partial) { + return this.$async(this.patchPartialAsync, oldValues, newValues); + } + return this.$async(this.patchAsync, oldValues, newValues, originalServicePorts); + } + /* #endregion */ + + /* #region DELETE */ + async deleteAsync(application) { + const payload = { + Namespace: application.ResourcePool || application.Namespace, + Name: application.Name, + }; + const servicePayload = angular.copy(payload); + servicePayload.Name = application.Name; + + const apiService = this._getApplicationApiService(application); + await apiService.delete(payload); + + if (apiService === this.KubernetesStatefulSetService) { + const headlessServicePayload = angular.copy(payload); + headlessServicePayload.Name = application instanceof KubernetesStatefulSet ? application.ServiceName : application.HeadlessServiceName; + } + + if (application.ServiceType) { + // delete headless service && non-headless service + await this.KubernetesServiceService.delete(application.Services); + const appHasIngressPath = application.PublishedPorts && application.PublishedPorts.flatMap((pp) => pp.IngressRules).length >= 1; + if (appHasIngressPath) { + const originalIngresses = await this.KubernetesIngressService.get(payload.Namespace); + const formValues = { + OriginalIngresses: originalIngresses, + PublishedPorts: KubernetesApplicationHelper.generatePublishedPortsFormValuesFromPublishedPorts(application.ServiceType, application.PublishedPorts), + }; + const ingresses = KubernetesIngressConverter.applicationFormValuesToDeleteIngresses(formValues, application); + + await Promise.all(this._generateIngressPatchPromises(formValues.OriginalIngresses, ingresses)); + } + } + if (!_.isEmpty(application.AutoScaler)) { + await this.KubernetesHorizontalPodAutoScalerService.delete(application.AutoScaler); + } + } + + delete(application) { + return this.$async(this.deleteAsync, application); + } + /* #endregion */ +} + +export default KubernetesApplicationService; +angular.module('portainer.kubernetes').service('KubernetesApplicationService', KubernetesApplicationService); diff --git a/app/kubernetes/services/configMapService.js b/app/kubernetes/services/configMapService.js new file mode 100644 index 0000000..f7bfb8f --- /dev/null +++ b/app/kubernetes/services/configMapService.js @@ -0,0 +1,161 @@ +import angular from 'angular'; +import _ from 'lodash-es'; +import PortainerError from '@/portainer/error'; +import KubernetesConfigMapConverter from '@/kubernetes/converters/configMap'; +import { KubernetesCommonParams } from '@/kubernetes/models/common/params'; + +class KubernetesConfigMapService { + /* @ngInject */ + constructor($async, KubernetesConfigMaps) { + this.$async = $async; + this.KubernetesConfigMaps = KubernetesConfigMaps; + + 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); + } + + createAccess(config) { + return this.$async(async () => { + try { + const payload = KubernetesConfigMapConverter.createAccessPayload(config); + const params = {}; + const namespace = payload.metadata.namespace; + const data = await this.KubernetesConfigMaps(namespace).create(params, payload).$promise; + return KubernetesConfigMapConverter.apiToPortainerAccessConfigMap(data); + } catch (err) { + throw new PortainerError('Unable to create Portainer accesses', err); + } + }); + } + + updateAccess(config) { + return this.$async(async () => { + try { + if (!config.Id) { + return await this.createAccess(config); + } + const payload = KubernetesConfigMapConverter.updateAccessPayload(config); + const params = new KubernetesCommonParams(); + params.id = payload.metadata.name; + const namespace = payload.metadata.namespace; + const data = await this.KubernetesConfigMaps(namespace).update(params, payload).$promise; + return KubernetesConfigMapConverter.apiToPortainerAccessConfigMap(data); + } catch (err) { + throw new PortainerError('Unable to update Portainer accesses', err); + } + }); + } + + /** + * GET + */ + async getAsync(namespace, name) { + try { + const params = new KubernetesCommonParams(); + params.id = name; + const [rawPromise, yamlPromise] = await Promise.allSettled([ + this.KubernetesConfigMaps(namespace).get(params).$promise, + this.KubernetesConfigMaps(namespace).getYaml(params).$promise, + ]); + + if (_.get(rawPromise, 'reason.status') === 404 && _.get(yamlPromise, 'reason.status') === 404) { + return KubernetesConfigMapConverter.defaultConfigMap(namespace, name); + } + + // Saving binary data to 'data' field in configMap Object is not allowed by kubernetes and getYaml() may get + // an error. We should keep binary data to 'binaryData' field instead of 'data'. Before that, we + // use response from get() and ignore 500 error as a workaround. + if (rawPromise.value) { + return KubernetesConfigMapConverter.apiToConfigMap(rawPromise.value, yamlPromise.value); + } + + throw new PortainerError('Unable to retrieve config map ', name); + } catch (err) { + if (err.status === 404) { + return KubernetesConfigMapConverter.defaultConfigMap(namespace, name); + } + throw new PortainerError('Unable to retrieve config map', err); + } + } + + async getAllAsync(namespace) { + try { + const data = await this.KubernetesConfigMaps(namespace).get().$promise; + return _.map(data.items, (item) => KubernetesConfigMapConverter.apiToConfigMap(item)); + } catch (err) { + throw new PortainerError('Unable to retrieve config maps', err); + } + } + + get(namespace, name) { + if (name) { + return this.$async(this.getAsync, namespace, name); + } + return this.$async(this.getAllAsync, namespace); + } + + /** + * CREATE + */ + async createAsync(config) { + try { + const payload = KubernetesConfigMapConverter.createPayload(config); + const params = {}; + const namespace = payload.metadata.namespace; + const data = await this.KubernetesConfigMaps(namespace).create(params, payload).$promise; + return KubernetesConfigMapConverter.apiToConfigMap(data); + } catch (err) { + throw new PortainerError('Unable to create config map', err); + } + } + + create(config) { + return this.$async(this.createAsync, config); + } + + /** + * UPDATE + */ + async updateAsync(config) { + try { + if (!config.Id) { + return await this.create(config); + } + const payload = KubernetesConfigMapConverter.updatePayload(config); + const params = new KubernetesCommonParams(); + params.id = payload.metadata.name; + const namespace = payload.metadata.namespace; + const data = await this.KubernetesConfigMaps(namespace).update(params, payload).$promise; + return KubernetesConfigMapConverter.apiToConfigMap(data); + } catch (err) { + throw new PortainerError('Unable to update config map', err); + } + } + update(config) { + return this.$async(this.updateAsync, config); + } + + /** + * DELETE + */ + async deleteAsync(config) { + try { + const params = new KubernetesCommonParams(); + params.id = config.Name; + const namespace = config.Namespace; + await this.KubernetesConfigMaps(namespace).delete(params).$promise; + } catch (err) { + throw new PortainerError('Unable to delete config map', err); + } + } + + delete(config) { + return this.$async(this.deleteAsync, config); + } +} + +export default KubernetesConfigMapService; +angular.module('portainer.kubernetes').service('KubernetesConfigMapService', KubernetesConfigMapService); diff --git a/app/kubernetes/services/configurationService.js b/app/kubernetes/services/configurationService.js new file mode 100644 index 0000000..d35910f --- /dev/null +++ b/app/kubernetes/services/configurationService.js @@ -0,0 +1,116 @@ +import angular from 'angular'; +import _ from 'lodash-es'; +import KubernetesConfigurationConverter from '@/kubernetes/converters/configuration'; +import KubernetesConfigMapConverter from '@/kubernetes/converters/configMap'; +import KubernetesSecretConverter from '@/kubernetes/converters/secret'; +import { KubernetesConfigurationKinds } from '@/kubernetes/models/configuration/models'; +import KubernetesCommonHelper from '@/kubernetes/helpers/commonHelper'; + +class KubernetesConfigurationService { + /* @ngInject */ + constructor($async, Authentication, KubernetesNamespaceService, KubernetesConfigMapService, KubernetesSecretService) { + this.$async = $async; + this.Authentication = Authentication; + this.KubernetesNamespaceService = KubernetesNamespaceService; + this.KubernetesConfigMapService = KubernetesConfigMapService; + this.KubernetesSecretService = KubernetesSecretService; + + 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(namespace, name) { + const [configMap, secret] = await Promise.allSettled([this.KubernetesConfigMapService.get(namespace, name), this.KubernetesSecretService.get(namespace, name)]); + let configuration; + if (secret.status === 'fulfilled') { + configuration = KubernetesConfigurationConverter.secretToConfiguration(secret.value); + return configuration; + } + configuration = KubernetesConfigurationConverter.configMapToConfiguration(configMap.value); + return configuration; + } + + async getAllAsync(namespace) { + const namespaces = namespace ? [namespace] : _.map(await this.KubernetesNamespaceService.get(), 'Name'); + const res = await Promise.all( + _.map(namespaces, async (ns) => { + const [configMaps, secrets] = await Promise.all([this.KubernetesConfigMapService.get(ns), this.KubernetesSecretService.get(ns)]); + const secretsConfigurations = _.map(secrets, (secret) => KubernetesConfigurationConverter.secretToConfiguration(secret)); + const configMapsConfigurations = _.map(configMaps, (configMap) => KubernetesConfigurationConverter.configMapToConfiguration(configMap)); + return _.concat(configMapsConfigurations, secretsConfigurations); + }) + ); + return _.flatten(res); + } + + get(namespace, name) { + if (name) { + return this.$async(this.getAsync, namespace, name); + } + return this.$async(this.getAllAsync, namespace); + } + + /** + * CREATE + */ + async createAsync(formValues) { + formValues.ConfigurationOwner = KubernetesCommonHelper.ownerToLabel(formValues.ConfigurationOwner); + + if (formValues.Kind === KubernetesConfigurationKinds.CONFIGMAP) { + const configMap = KubernetesConfigMapConverter.configurationFormValuesToConfigMap(formValues); + await this.KubernetesConfigMapService.create(configMap); + } else { + const secret = KubernetesSecretConverter.configurationFormValuesToSecret(formValues); + await this.KubernetesSecretService.create(secret); + } + } + + create(formValues) { + return this.$async(this.createAsync, formValues); + } + + /** + * UPDATE + */ + async updateAsync(formValues, configuration) { + if (formValues.Kind === KubernetesConfigurationKinds.CONFIGMAP) { + const configMap = KubernetesConfigMapConverter.configurationFormValuesToConfigMap(formValues); + configMap.ConfigurationOwner = configuration.ConfigurationOwner; + configMap.Labels = configuration.Labels; + await this.KubernetesConfigMapService.update(configMap); + } else { + const secret = KubernetesSecretConverter.configurationFormValuesToSecret(formValues); + secret.ConfigurationOwner = configuration.ConfigurationOwner; + secret.Labels = configuration.Labels; + await this.KubernetesSecretService.update(secret); + } + } + + update(formValues, configuration) { + return this.$async(this.updateAsync, formValues, configuration); + } + + /** + * DELETE + */ + async deleteAsync(config) { + if (config.Kind == KubernetesConfigurationKinds.CONFIGMAP) { + await this.KubernetesConfigMapService.delete(config); + } else { + await this.KubernetesSecretService.delete(config); + } + } + + delete(config) { + return this.$async(this.deleteAsync, config); + } +} + +export default KubernetesConfigurationService; +angular.module('portainer.kubernetes').service('KubernetesConfigurationService', KubernetesConfigurationService); diff --git a/app/kubernetes/services/controllerRevisionService.js b/app/kubernetes/services/controllerRevisionService.js new file mode 100644 index 0000000..eda1b37 --- /dev/null +++ b/app/kubernetes/services/controllerRevisionService.js @@ -0,0 +1,31 @@ +import angular from 'angular'; +import PortainerError from '@/portainer/error'; + +class KubernetesControllerRevisionService { + /* @ngInject */ + constructor($async, KubernetesControllerRevisions) { + this.$async = $async; + this.KubernetesControllerRevisions = KubernetesControllerRevisions; + + this.getAllAsync = this.getAllAsync.bind(this); + } + + /** + * GET + */ + async getAllAsync(namespace) { + try { + const data = await this.KubernetesControllerRevisions(namespace).get().$promise; + return data.items; + } catch (err) { + throw new PortainerError('Unable to retrieve ControllerRevisions', err); + } + } + + get(namespace) { + return this.$async(this.getAllAsync, namespace); + } +} + +export default KubernetesControllerRevisionService; +angular.module('portainer.kubernetes').service('KubernetesControllerRevisionService', KubernetesControllerRevisionService); diff --git a/app/kubernetes/services/daemonSetService.js b/app/kubernetes/services/daemonSetService.js new file mode 100644 index 0000000..5532729 --- /dev/null +++ b/app/kubernetes/services/daemonSetService.js @@ -0,0 +1,115 @@ +import angular from 'angular'; +import PortainerError from '@/portainer/error'; +import { KubernetesCommonParams } from '@/kubernetes/models/common/params'; +import KubernetesDaemonSetConverter from '@/kubernetes/converters/daemonSet'; + +class KubernetesDaemonSetService { + /* @ngInject */ + constructor($async, KubernetesDaemonSets) { + this.$async = $async; + this.KubernetesDaemonSets = KubernetesDaemonSets; + + this.getAsync = this.getAsync.bind(this); + this.getAllAsync = this.getAllAsync.bind(this); + this.createAsync = this.createAsync.bind(this); + this.patchAsync = this.patchAsync.bind(this); + this.deleteAsync = this.deleteAsync.bind(this); + } + + /** + * GET + */ + async getAsync(namespace, name) { + try { + const params = new KubernetesCommonParams(); + params.id = name; + const [raw, yaml] = await Promise.all([this.KubernetesDaemonSets(namespace).get(params).$promise, this.KubernetesDaemonSets(namespace).getYaml(params).$promise]); + const res = { + Raw: raw, + Yaml: yaml.data, + }; + return res; + } catch (err) { + throw new PortainerError('Unable to retrieve DaemonSet', err); + } + } + + async getAllAsync(namespace) { + try { + const data = await this.KubernetesDaemonSets(namespace).get().$promise; + return data.items; + } catch (err) { + throw new PortainerError('Unable to retrieve DaemonSets', err); + } + } + + get(namespace, name) { + if (name) { + return this.$async(this.getAsync, namespace, name); + } + return this.$async(this.getAllAsync, namespace); + } + + /** + * CREATE + */ + async createAsync(daemonSet) { + try { + const params = {}; + const payload = KubernetesDaemonSetConverter.createPayload(daemonSet); + const namespace = payload.metadata.namespace; + const data = await this.KubernetesDaemonSets(namespace).create(params, payload).$promise; + return data; + } catch (err) { + throw new PortainerError('Unable to create daemonset', err); + } + } + + create(daemonSet) { + return this.$async(this.createAsync, daemonSet); + } + + /** + * PATCH + */ + async patchAsync(oldDaemonSet, newDaemonSet) { + try { + const params = new KubernetesCommonParams(); + params.id = newDaemonSet.Name; + const namespace = newDaemonSet.Namespace; + const payload = KubernetesDaemonSetConverter.patchPayload(oldDaemonSet, newDaemonSet); + if (!payload.length) { + return; + } + const data = await this.KubernetesDaemonSets(namespace).patch(params, payload).$promise; + return data; + } catch (err) { + throw new PortainerError('Unable to patch daemonSet', err); + } + } + + patch(oldDaemonSet, newDaemonSet) { + return this.$async(this.patchAsync, oldDaemonSet, newDaemonSet); + } + + /** + * DELETE + */ + async deleteAsync(daemonSet) { + try { + const params = new KubernetesCommonParams(); + params.id = daemonSet.Name; + const namespace = daemonSet.Namespace; + await this.KubernetesDaemonSets(namespace).delete(params).$promise; + } catch (err) { + throw new PortainerError('Unable to remove daemonset', err); + } + } + + delete(daemonSet) { + return this.$async(this.deleteAsync, daemonSet); + } +} + +export default KubernetesDaemonSetService; +angular.module('portainer.kubernetes').service('KubernetesDaemonSetService', KubernetesDaemonSetService); diff --git a/app/kubernetes/services/deploymentService.js b/app/kubernetes/services/deploymentService.js new file mode 100644 index 0000000..ee0ea68 --- /dev/null +++ b/app/kubernetes/services/deploymentService.js @@ -0,0 +1,115 @@ +import angular from 'angular'; +import PortainerError from '@/portainer/error'; +import { KubernetesCommonParams } from '@/kubernetes/models/common/params'; +import KubernetesDeploymentConverter from '@/kubernetes/converters/deployment'; + +class KubernetesDeploymentService { + /* @ngInject */ + constructor($async, KubernetesDeployments) { + this.$async = $async; + this.KubernetesDeployments = KubernetesDeployments; + + this.getAsync = this.getAsync.bind(this); + this.getAllAsync = this.getAllAsync.bind(this); + this.createAsync = this.createAsync.bind(this); + this.patchAsync = this.patchAsync.bind(this); + this.deleteAsync = this.deleteAsync.bind(this); + } + + /** + * GET + */ + async getAsync(namespace, name) { + try { + const params = new KubernetesCommonParams(); + params.id = name; + const [raw, yaml] = await Promise.all([this.KubernetesDeployments(namespace).get(params).$promise, this.KubernetesDeployments(namespace).getYaml(params).$promise]); + const res = { + Raw: raw, + Yaml: yaml.data, + }; + return res; + } catch (err) { + throw new PortainerError('Unable to retrieve Deployment', err); + } + } + + async getAllAsync(namespace) { + try { + const data = await this.KubernetesDeployments(namespace).get().$promise; + return data.items; + } catch (err) { + throw new PortainerError('Unable to retrieve Deployments', err); + } + } + + get(namespace, name) { + if (name) { + return this.$async(this.getAsync, namespace, name); + } + return this.$async(this.getAllAsync, namespace); + } + + /** + * CREATE + */ + async createAsync(deployment) { + try { + const params = {}; + const payload = KubernetesDeploymentConverter.createPayload(deployment); + const namespace = payload.metadata.namespace; + const data = await this.KubernetesDeployments(namespace).create(params, payload).$promise; + return data; + } catch (err) { + throw new PortainerError('Unable to create deployment', err); + } + } + + create(deployment) { + return this.$async(this.createAsync, deployment); + } + + /** + * PATCH + */ + async patchAsync(oldDeployment, newDeployment) { + try { + const params = new KubernetesCommonParams(); + params.id = newDeployment.Name; + const namespace = newDeployment.Namespace; + const payload = KubernetesDeploymentConverter.patchPayload(oldDeployment, newDeployment); + if (!payload.length) { + return; + } + const data = await this.KubernetesDeployments(namespace).patch(params, payload).$promise; + return data; + } catch (err) { + throw new PortainerError('Unable to patch deployment', err); + } + } + + patch(oldDeployment, newDeployment) { + return this.$async(this.patchAsync, oldDeployment, newDeployment); + } + + /** + * DELETE + */ + async deleteAsync(deployment) { + try { + const params = new KubernetesCommonParams(); + params.id = deployment.Name; + const namespace = deployment.Namespace; + await this.KubernetesDeployments(namespace).delete(params).$promise; + } catch (err) { + throw new PortainerError('Unable to remove deployment', err); + } + } + + delete(deployment) { + return this.$async(this.deleteAsync, deployment); + } +} + +export default KubernetesDeploymentService; +angular.module('portainer.kubernetes').service('KubernetesDeploymentService', KubernetesDeploymentService); diff --git a/app/kubernetes/services/eventService.js b/app/kubernetes/services/eventService.js new file mode 100644 index 0000000..ac84c18 --- /dev/null +++ b/app/kubernetes/services/eventService.js @@ -0,0 +1,34 @@ +import _ from 'lodash-es'; +import angular from 'angular'; +import PortainerError from '@/portainer/error'; +import KubernetesEventConverter from '@/kubernetes/converters/event'; + +class KubernetesEventService { + /* @ngInject */ + constructor($async, KubernetesEvents) { + this.$async = $async; + this.KubernetesEvents = KubernetesEvents; + + this.getAllAsync = this.getAllAsync.bind(this); + } + + /** + * GET + */ + async getAllAsync(namespace) { + try { + const data = await this.KubernetesEvents(namespace).get().$promise; + const res = _.map(data.items, (item) => KubernetesEventConverter.apiToEvent(item)); + return res; + } catch (err) { + throw new PortainerError('Unable to retrieve events', err); + } + } + + get(namespace) { + return this.$async(this.getAllAsync, namespace); + } +} + +export default KubernetesEventService; +angular.module('portainer.kubernetes').service('KubernetesEventService', KubernetesEventService); diff --git a/app/kubernetes/services/healthService.js b/app/kubernetes/services/healthService.js new file mode 100644 index 0000000..deaea4c --- /dev/null +++ b/app/kubernetes/services/healthService.js @@ -0,0 +1,30 @@ +import angular from 'angular'; +import PortainerError from '@/portainer/error'; + +class KubernetesHealthService { + /* @ngInject */ + constructor($async, KubernetesHealth) { + this.$async = $async; + this.KubernetesHealth = KubernetesHealth; + + this.pingAsync = this.pingAsync.bind(this); + } + + /** + * PING + */ + async pingAsync(endpointID) { + try { + return await this.KubernetesHealth.ping({ id: endpointID }).$promise; + } catch (err) { + throw new PortainerError('Unable to retrieve environment health', err); + } + } + + ping(endpointID) { + return this.$async(this.pingAsync, endpointID); + } +} + +export default KubernetesHealthService; +angular.module('portainer.kubernetes').service('KubernetesHealthService', KubernetesHealthService); diff --git a/app/kubernetes/services/namespaceService.js b/app/kubernetes/services/namespaceService.js new file mode 100644 index 0000000..284b9ff --- /dev/null +++ b/app/kubernetes/services/namespaceService.js @@ -0,0 +1,129 @@ +import angular from 'angular'; +import PortainerError from '@/portainer/error'; +import { KubernetesCommonParams } from '@/kubernetes/models/common/params'; +import KubernetesNamespaceConverter from '@/kubernetes/converters/namespace'; +import KubernetesNamespaceHelper from '@/kubernetes/helpers/namespaceHelper'; +import { updateNamespaces } from '@/kubernetes/store/namespace'; + +class KubernetesNamespaceService { + /* @ngInject */ + constructor($async, KubernetesNamespaces, Authentication, LocalStorage, $state) { + this.$async = $async; + this.$state = $state; + this.KubernetesNamespaces = KubernetesNamespaces; + this.LocalStorage = LocalStorage; + this.Authentication = Authentication; + + this.getAsync = this.getAsync.bind(this); + this.getAllAsync = this.getAllAsync.bind(this); + this.createAsync = this.createAsync.bind(this); + this.deleteAsync = this.deleteAsync.bind(this); + this.getJSONAsync = this.getJSONAsync.bind(this); + this.updateFinalizeAsync = this.updateFinalizeAsync.bind(this); + } + + /** + * GET + */ + async getAsync(name) { + try { + const params = new KubernetesCommonParams(); + params.id = name; + await this.KubernetesNamespaces().status(params).$promise; + const [raw, yaml] = await Promise.all([this.KubernetesNamespaces().get(params).$promise, this.KubernetesNamespaces().getYaml(params).$promise]); + const ns = KubernetesNamespaceConverter.apiToNamespace(raw, yaml); + updateNamespaces([ns]); + return ns; + } catch (err) { + throw new PortainerError('Unable to retrieve namespace', err); + } + } + + /** + * GET namesspace in JSON format + */ + async getJSONAsync(name) { + try { + const params = new KubernetesCommonParams(); + params.id = name; + await this.KubernetesNamespaces().status(params).$promise; + return await this.KubernetesNamespaces().getJSON(params).$promise; + } catch (err) { + throw new PortainerError('Unable to retrieve namespace', err); + } + } + + /** + * Update finalize + */ + async updateFinalizeAsync(namespace) { + try { + return await this.KubernetesNamespaces().update({ id: namespace.metadata.name, action: 'finalize' }, namespace).$promise; + } catch (err) { + throw new PortainerError('Unable to update namespace', err); + } + } + + async getAllAsync() { + try { + // get the list of all namespaces (RBAC allows users to see the list of namespaces) + const data = await this.KubernetesNamespaces().get().$promise; + // get the list of all namespaces with isAccessAllowed flags + const hasK8sAccessSystemNamespaces = this.Authentication.hasAuthorizations(['K8sAccessSystemNamespaces']); + const namespaces = data.items.filter((item) => !KubernetesNamespaceHelper.isSystemNamespace(item.metadata.name) || hasK8sAccessSystemNamespaces); + // parse the namespaces + const visibleNamespaces = namespaces.map((item) => KubernetesNamespaceConverter.apiToNamespace(item)); + updateNamespaces(visibleNamespaces); + return visibleNamespaces; + } catch (err) { + throw new PortainerError('Unable to retrieve namespaces', err); + } + } + + async get(name) { + if (name) { + return this.$async(this.getAsync, name); + } + const allowedNamespaces = await this.getAllAsync(); + updateNamespaces(allowedNamespaces); + return allowedNamespaces; + } + + /** + * CREATE + */ + async createAsync(namespace) { + try { + const payload = KubernetesNamespaceConverter.createPayload(namespace); + const params = {}; + const data = await this.KubernetesNamespaces().create(params, payload).$promise; + return data; + } catch (err) { + throw new PortainerError('Unable to create namespace', err); + } + } + + create(namespace) { + return this.$async(this.createAsync, namespace); + } + + /** + * DELETE + */ + async deleteAsync(namespace) { + try { + const params = new KubernetesCommonParams(); + params.id = namespace.Name; + await this.KubernetesNamespaces().delete(params).$promise; + } catch (err) { + throw new PortainerError('Unable to delete namespace', err); + } + } + + delete(namespace) { + return this.$async(this.deleteAsync, namespace); + } +} + +export default KubernetesNamespaceService; +angular.module('portainer.kubernetes').service('KubernetesNamespaceService', KubernetesNamespaceService); diff --git a/app/kubernetes/services/nodesLimitsService.js b/app/kubernetes/services/nodesLimitsService.js new file mode 100644 index 0000000..110bb2c --- /dev/null +++ b/app/kubernetes/services/nodesLimitsService.js @@ -0,0 +1,25 @@ +import angular from 'angular'; +import PortainerError from '@/portainer/error'; +import { KubernetesNodesLimits } from '@/kubernetes/models/nodes-limits/models'; + +class KubernetesNodesLimitsService { + /* @ngInject */ + constructor(KubernetesNodesLimits) { + this.KubernetesNodesLimits = KubernetesNodesLimits; + } + + /** + * GET + */ + async get() { + try { + const nodesLimits = await this.KubernetesNodesLimits.get().$promise; + return new KubernetesNodesLimits(nodesLimits.data); + } catch (err) { + throw new PortainerError('Unable to retrieve nodes limits', err); + } + } +} + +export default KubernetesNodesLimitsService; +angular.module('portainer.kubernetes').service('KubernetesNodesLimitsService', KubernetesNodesLimitsService); diff --git a/app/kubernetes/services/persistentVolumeClaimService.js b/app/kubernetes/services/persistentVolumeClaimService.js new file mode 100644 index 0000000..8dcf6fb --- /dev/null +++ b/app/kubernetes/services/persistentVolumeClaimService.js @@ -0,0 +1,114 @@ +import angular from 'angular'; +import _ from 'lodash-es'; +import PortainerError from '@/portainer/error'; +import KubernetesPersistentVolumeClaimConverter from '@/kubernetes/converters/persistentVolumeClaim'; +import { KubernetesCommonParams } from '@/kubernetes/models/common/params'; + +class KubernetesPersistentVolumeClaimService { + /* @ngInject */ + constructor($async, KubernetesPersistentVolumeClaims) { + this.$async = $async; + this.KubernetesPersistentVolumeClaims = KubernetesPersistentVolumeClaims; + + this.getAsync = this.getAsync.bind(this); + this.getAllAsync = this.getAllAsync.bind(this); + this.createAsync = this.createAsync.bind(this); + this.patchAsync = this.patchAsync.bind(this); + this.deleteAsync = this.deleteAsync.bind(this); + } + + async getAsync(namespace, storageClasses, name) { + try { + const params = new KubernetesCommonParams(); + params.id = name; + const [raw, yaml] = await Promise.all([ + this.KubernetesPersistentVolumeClaims(namespace).get(params).$promise, + this.KubernetesPersistentVolumeClaims(namespace).getYaml(params).$promise, + ]); + + return KubernetesPersistentVolumeClaimConverter.apiToPersistentVolumeClaim(raw, storageClasses, yaml); + } catch (err) { + throw new PortainerError('Unable to retrieve persistent volume claim', err); + } + } + + async getAllAsync(namespace, storageClasses) { + try { + const data = await this.KubernetesPersistentVolumeClaims(namespace).get().$promise; + + return _.map(data.items, (item) => KubernetesPersistentVolumeClaimConverter.apiToPersistentVolumeClaim(item, storageClasses)); + } catch (err) { + throw new PortainerError('Unable to retrieve persistent volume claims', err); + } + } + + get(namespace, storageClasses, name) { + if (name) { + return this.$async(this.getAsync, namespace, storageClasses, name); + } + return this.$async(this.getAllAsync, namespace, storageClasses); + } + + /** + * CREATE + */ + async createAsync(claim) { + try { + const params = {}; + const payload = KubernetesPersistentVolumeClaimConverter.createPayload(claim); + const namespace = payload.metadata.namespace; + const data = await this.KubernetesPersistentVolumeClaims(namespace).create(params, payload).$promise; + return data; + } catch (err) { + throw new PortainerError('Unable to create persistent volume claim', err); + } + } + + create(claim) { + return this.$async(this.createAsync, claim); + } + + /** + * PATCH + */ + async patchAsync(oldPVC, newPVC) { + try { + const params = new KubernetesCommonParams(); + params.id = newPVC.Name; + const namespace = newPVC.Namespace; + const payload = KubernetesPersistentVolumeClaimConverter.patchPayload(oldPVC, newPVC); + if (!payload.length) { + return; + } + const data = await this.KubernetesPersistentVolumeClaims(namespace).patch(params, payload).$promise; + return data; + } catch (err) { + throw new PortainerError('Unable to patch persistent volume claim', err); + } + } + + patch(oldPVC, newPVC) { + return this.$async(this.patchAsync, oldPVC, newPVC); + } + + /** + * DELETE + */ + async deleteAsync(pvc) { + try { + const params = new KubernetesCommonParams(); + params.id = pvc.Name; + const namespace = pvc.Namespace; + await this.KubernetesPersistentVolumeClaims(namespace).delete(params).$promise; + } catch (err) { + throw new PortainerError('Unable to delete persistent volume claim', err); + } + } + + delete(pvc) { + return this.$async(this.deleteAsync, pvc); + } +} + +export default KubernetesPersistentVolumeClaimService; +angular.module('portainer.kubernetes').service('KubernetesPersistentVolumeClaimService', KubernetesPersistentVolumeClaimService); diff --git a/app/kubernetes/services/resourcePoolService.js b/app/kubernetes/services/resourcePoolService.js new file mode 100644 index 0000000..909bed7 --- /dev/null +++ b/app/kubernetes/services/resourcePoolService.js @@ -0,0 +1,143 @@ +import _ from 'lodash-es'; + +import angular from 'angular'; +import KubernetesResourcePoolConverter from '@/kubernetes/converters/resourcePool'; +import KubernetesResourceQuotaHelper from '@/kubernetes/helpers/resourceQuotaHelper'; +import { getNamespaces } from '@/react/kubernetes/namespaces/queries/useNamespacesQuery'; + +/* @ngInject */ +export function KubernetesResourcePoolService( + $async, + EndpointService, + KubernetesNamespaceService, + KubernetesResourceQuotaService, + KubernetesIngressService, + KubernetesPortainerNamespaces, + EndpointProvider +) { + return { + get, + create, + patch, + delete: _delete, + toggleSystem, + }; + + // getting quota isn't a costly operation for one namespace, so we can get it by default + async function getOne(name, { getQuota = true }) { + const namespace = await KubernetesNamespaceService.get(name); + const pool = KubernetesResourcePoolConverter.apiToResourcePool(namespace); + if (getQuota) { + const [quotaAttempt] = await Promise.allSettled([KubernetesResourceQuotaService.get(name, KubernetesResourceQuotaHelper.generateResourceQuotaName(name))]); + if (quotaAttempt.status === 'fulfilled') { + pool.Quota = quotaAttempt.value; + pool.Yaml += '---\n' + quotaAttempt.value.Yaml; + } + } + return pool; + } + + // getting the quota for all namespaces is costly by default, so disable getting it by default + async function getAll({ getQuota = false }) { + const namespaces = await getNamespaces(EndpointProvider.endpointID()); + // there is a lot of downstream logic using the angular namespace type with a '.Status' field (not '.Status.phase'), so format the status here to match this logic + const namespacesFormattedStatus = namespaces.map((namespace) => ({ + ...namespace, + Status: namespace.Status.phase, + })); + const pools = await Promise.all( + _.map(namespacesFormattedStatus, async (namespace) => { + const name = namespace.Name; + const pool = KubernetesResourcePoolConverter.apiToResourcePool(namespace); + if (getQuota) { + const [quotaAttempt] = await Promise.allSettled([KubernetesResourceQuotaService.get(name, KubernetesResourceQuotaHelper.generateResourceQuotaName(name))]); + if (quotaAttempt.status === 'fulfilled') { + pool.Quota = quotaAttempt.value; + pool.Yaml += '---\n' + quotaAttempt.value.Yaml; + } + } + return pool; + }) + ); + return pools; + } + + function get(name, options = {}) { + if (name) { + return $async(getOne, name, options); + } + return $async(getAll, options); + } + + function create(formValues) { + return $async(async () => { + const [namespace, quota, ingresses, registries] = KubernetesResourcePoolConverter.formValuesToResourcePool(formValues); + await KubernetesNamespaceService.create(namespace); + + if (quota) { + await KubernetesResourceQuotaService.create(quota); + } + const ingressPromises = _.map(ingresses, (i) => KubernetesIngressService.create(i)); + await Promise.all(ingressPromises); + + const endpointId = formValues.EndpointId; + const registriesPromises = _.map(registries, (r) => EndpointService.updateRegistryAccess(endpointId, r.Id, r.RegistryAccesses[endpointId])); + await Promise.all(registriesPromises); + }); + } + + function patch(oldFormValues, newFormValues) { + return $async(async () => { + const [, oldQuota, oldIngresses, oldRegistries] = KubernetesResourcePoolConverter.formValuesToResourcePool(oldFormValues); + const [, newQuota, newIngresses, newRegistries] = KubernetesResourcePoolConverter.formValuesToResourcePool(newFormValues); + + if (oldQuota && newQuota) { + await KubernetesResourceQuotaService.patch(oldQuota, newQuota); + } else if (!oldQuota && newQuota) { + await KubernetesResourceQuotaService.create(newQuota); + } else if (oldQuota && !newQuota) { + await KubernetesResourceQuotaService.delete(oldQuota); + } + + const create = _.filter(newIngresses, (ing) => !_.find(oldIngresses, { Name: ing.Name })); + const del = _.filter(oldIngresses, (ing) => !_.find(newIngresses, { Name: ing.Name })); + const patch = _.without(newIngresses, ...create); + + const createPromises = _.map(create, (i) => KubernetesIngressService.create(i)); + const delPromises = _.map(del, (i) => KubernetesIngressService.delete(i.Namespace, i.Name)); + const patchPromises = _.map(patch, (ing) => { + const old = _.find(oldIngresses, { Name: ing.Name }); + ing.Paths = angular.copy(old.Paths); + ing.PreviousHost = old.Host; + return KubernetesIngressService.patch(old, ing); + }); + + const promises = _.flatten([createPromises, delPromises, patchPromises]); + await Promise.all(promises); + + const endpointId = newFormValues.EndpointId; + const keptRegistries = _.intersectionBy(oldRegistries, newRegistries, 'Id'); + const removedRegistries = _.without(oldRegistries, ...keptRegistries); + + const newRegistriesPromises = _.map(newRegistries, (r) => EndpointService.updateRegistryAccess(endpointId, r.Id, r.RegistryAccesses[endpointId])); + const removedRegistriesPromises = _.map(removedRegistries, (r) => { + _.pull(r.RegistryAccesses[endpointId].Namespaces, newFormValues.Name); + return EndpointService.updateRegistryAccess(endpointId, r.Id, r.RegistryAccesses[endpointId]); + }); + + await Promise.all(_.concat(newRegistriesPromises, removedRegistriesPromises)); + }); + } + + function _delete(pool) { + return $async(async () => { + await KubernetesNamespaceService.delete(pool.Namespace); + }); + } + + function toggleSystem(endpointId, namespaceName, system) { + return KubernetesPortainerNamespaces.toggleSystem({ namespaceName, endpointId }, { system }).$promise; + } +} + +angular.module('portainer.kubernetes').service('KubernetesResourcePoolService', KubernetesResourcePoolService); diff --git a/app/kubernetes/services/resourceQuotaService.js b/app/kubernetes/services/resourceQuotaService.js new file mode 100644 index 0000000..019c29d --- /dev/null +++ b/app/kubernetes/services/resourceQuotaService.js @@ -0,0 +1,89 @@ +import _ from 'lodash-es'; + +import angular from 'angular'; +import PortainerError from '@/portainer/error'; +import { KubernetesCommonParams } from '@/kubernetes/models/common/params'; +import KubernetesResourceQuotaConverter from '@/kubernetes/converters/resourceQuota'; + +/* @ngInject */ +export function KubernetesResourceQuotaService($async, KubernetesResourceQuotas) { + return { + get, + create, + patch, + delete: _delete, + }; + + async function getOne(namespace, name) { + try { + const params = new KubernetesCommonParams(); + params.id = name; + const [raw, yaml] = await Promise.all([KubernetesResourceQuotas(namespace).get(params).$promise, KubernetesResourceQuotas(namespace).getYaml(params).$promise]); + return KubernetesResourceQuotaConverter.apiToResourceQuota(raw, yaml); + } catch (err) { + throw new PortainerError('Unable to retrieve resource quota', err); + } + } + + async function getAll(namespace) { + try { + const data = await KubernetesResourceQuotas(namespace).get().$promise; + return _.map(data.items, (item) => KubernetesResourceQuotaConverter.apiToResourceQuota(item)); + } catch (err) { + throw new PortainerError('Unable to retrieve resource quotas', err); + } + } + + function get(namespace, name) { + if (name) { + return $async(getOne, namespace, name); + } + return $async(getAll, namespace); + } + + function create(quota) { + return $async(async () => { + try { + const payload = KubernetesResourceQuotaConverter.createPayload(quota); + const namespace = payload.metadata.namespace; + const params = {}; + const data = await KubernetesResourceQuotas(namespace).create(params, payload).$promise; + return KubernetesResourceQuotaConverter.apiToResourceQuota(data); + } catch (err) { + throw new PortainerError('Unable to create quota', err); + } + }); + } + + function patch(oldQuota, newQuota) { + return $async(async () => { + try { + const params = new KubernetesCommonParams(); + params.id = newQuota.Name; + const namespace = newQuota.Namespace; + const payload = KubernetesResourceQuotaConverter.patchPayload(oldQuota, newQuota); + if (!payload.length) { + return; + } + const data = await KubernetesResourceQuotas(namespace).patch(params, payload).$promise; + return data; + } catch (err) { + throw new PortainerError('Unable to update resource quota', err); + } + }); + } + + function _delete(quota) { + return $async(async () => { + try { + const params = new KubernetesCommonParams(); + params.id = quota.Name; + await KubernetesResourceQuotas(quota.Namespace).delete(params).$promise; + } catch (err) { + throw new PortainerError('Unable to delete quota', err); + } + }); + } +} + +angular.module('portainer.kubernetes').service('KubernetesResourceQuotaService', KubernetesResourceQuotaService); diff --git a/app/kubernetes/services/secretService.js b/app/kubernetes/services/secretService.js new file mode 100644 index 0000000..7283142 --- /dev/null +++ b/app/kubernetes/services/secretService.js @@ -0,0 +1,110 @@ +import angular from 'angular'; +import _ from 'lodash-es'; +import PortainerError from '@/portainer/error'; +import KubernetesSecretConverter from '@/kubernetes/converters/secret'; +import { KubernetesCommonParams } from '@/kubernetes/models/common/params'; + +class KubernetesSecretService { + /* @ngInject */ + constructor($async, KubernetesSecrets) { + this.$async = $async; + this.KubernetesSecrets = KubernetesSecrets; + + 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(namespace, name) { + try { + const params = new KubernetesCommonParams(); + params.id = name; + const [raw, yaml] = await Promise.all([this.KubernetesSecrets(namespace).get(params).$promise, this.KubernetesSecrets(namespace).getYaml(params).$promise]); + const secret = KubernetesSecretConverter.apiToSecret(raw, yaml); + return secret; + } catch (err) { + throw new PortainerError('Unable to retrieve secret', err); + } + } + + async getAllAsync(namespace) { + try { + const data = await this.KubernetesSecrets(namespace).get().$promise; + return _.map(data.items, (item) => KubernetesSecretConverter.apiToSecret(item)); + } catch (err) { + throw new PortainerError('Unable to retrieve secrets', err); + } + } + + get(namespace, name) { + if (name) { + return this.$async(this.getAsync, namespace, name); + } + return this.$async(this.getAllAsync, namespace); + } + + /** + * CREATE + */ + async createAsync(secret) { + try { + const payload = KubernetesSecretConverter.createPayload(secret); + const namespace = payload.metadata.namespace; + const params = {}; + const data = await this.KubernetesSecrets(namespace).create(params, payload).$promise; + return data; + } catch (err) { + throw new PortainerError('Unable to create secret', err); + } + } + + create(secret) { + return this.$async(this.createAsync, secret); + } + + /** + * UPDATE + */ + async updateAsync(secret) { + try { + const payload = KubernetesSecretConverter.updatePayload(secret); + const params = new KubernetesCommonParams(); + params.id = payload.metadata.name; + const namespace = payload.metadata.namespace; + const data = await this.KubernetesSecrets(namespace).update(params, payload).$promise; + return KubernetesSecretConverter.apiToSecret(data); + } catch (err) { + throw new PortainerError('Unable to update secret', err); + } + } + + update(secret) { + return this.$async(this.updateAsync, secret); + } + + /** + * DELETE + */ + async deleteAsync(secret) { + try { + const params = new KubernetesCommonParams(); + params.id = secret.Name; + const namespace = secret.Namespace; + await this.KubernetesSecrets(namespace).delete(params).$promise; + } catch (err) { + throw new PortainerError('Unable to delete secret', err); + } + } + + delete(secret) { + return this.$async(this.deleteAsync, secret); + } +} + +export default KubernetesSecretService; +angular.module('portainer.kubernetes').service('KubernetesSecretService', KubernetesSecretService); diff --git a/app/kubernetes/services/serviceService.js b/app/kubernetes/services/serviceService.js new file mode 100644 index 0000000..297380d --- /dev/null +++ b/app/kubernetes/services/serviceService.js @@ -0,0 +1,154 @@ +import angular from 'angular'; +import PortainerError from '@/portainer/error'; +import { KubernetesCommonParams } from '@/kubernetes/models/common/params'; +import KubernetesServiceConverter from '@/kubernetes/converters/service'; + +class KubernetesServiceService { + /* @ngInject */ + constructor($async, KubernetesServices) { + this.$async = $async; + this.KubernetesServices = KubernetesServices; + + this.getAsync = this.getAsync.bind(this); + this.getAllAsync = this.getAllAsync.bind(this); + this.createAsync = this.createAsync.bind(this); + this.patchAsync = this.patchAsync.bind(this); + this.deleteAsync = this.deleteAsync.bind(this); + this.deleteSingleAsync = this.deleteSingleAsync.bind(this); + this.deleteAllAsync = this.deleteAllAsync.bind(this); + } + + /** + * GET + */ + async getAsync(namespace, name) { + try { + const params = new KubernetesCommonParams(); + params.id = name; + const [raw, yaml] = await Promise.all([this.KubernetesServices(namespace).get(params).$promise, this.KubernetesServices(namespace).getYaml(params).$promise]); + const res = { + Raw: raw, + Yaml: yaml.data, + }; + return res; + } catch (err) { + throw new PortainerError('Unable to retrieve service', err); + } + } + + async getAllAsync(namespace) { + try { + const data = await this.KubernetesServices(namespace).get().$promise; + return data.items; + } catch (err) { + throw new PortainerError('Unable to retrieve services', err); + } + } + + get(namespace, name) { + if (name) { + return this.$async(this.getAsync, namespace, name); + } + return this.$async(this.getAllAsync, namespace); + } + + /** + * CREATE + */ + async createAsync(service) { + try { + const params = {}; + const payload = KubernetesServiceConverter.createPayload(service); + const namespace = payload.metadata.namespace; + const data = await this.KubernetesServices(namespace).create(params, payload).$promise; + return data; + } catch (err) { + throw new PortainerError('Unable to create service', err); + } + } + + create(service) { + return this.$async(this.createAsync, service); + } + + /** + * PATCH + */ + async patchAsync(oldService, newService) { + try { + const params = new KubernetesCommonParams(); + params.id = newService.Name; + const namespace = newService.Namespace; + const payload = KubernetesServiceConverter.patchPayload(oldService, newService); + if (!payload.length) { + return; + } + const data = await this.KubernetesServices(namespace).patch(params, payload).$promise; + return data; + } catch (err) { + throw new PortainerError('Unable to patch service', err); + } + } + + patch(oldService, newService) { + return this.$async(this.patchAsync, oldService, newService); + } + + /** + * DELETE + */ + async deleteAsync(services) { + services.forEach(async (service) => { + try { + const params = new KubernetesCommonParams(); + params.id = (service.metadata && service.metadata.name) || service.Name; + const namespace = (service.metadata && service.metadata.namespace) || service.Namespace; + await this.KubernetesServices(namespace).delete(params).$promise; + } catch (err) { + // eslint-disable-next-line no-console + console.error('unable to remove service', err); + } + }); + } + + delete(services) { + return this.$async(this.deleteAsync, services); + } + + async deleteAllAsync(formValuesServices) { + formValuesServices.forEach(async (service) => { + try { + const params = new KubernetesCommonParams(); + params.id = service.Name; + const namespace = service.Namespace; + await this.KubernetesServices(namespace).delete(params).$promise; + } catch (err) { + // eslint-disable-next-line no-console + console.error('unable to remove service', err); + } + }); + } + + deleteAll(formValuesServices) { + return this.$async(this.deleteAllAsync, formValuesServices); + } + + async deleteSingleAsync(service) { + try { + const params = new KubernetesCommonParams(); + params.id = service.Name; + const namespace = service.Namespace; + await this.KubernetesServices(namespace).delete(params).$promise; + } catch (err) { + // eslint-disable-next-line no-console + console.error('unable to remove service', err); + } + } + + deleteSingle(service) { + return this.$async(this.deleteSingleAsync, service); + } +} + +export default KubernetesServiceService; +angular.module('portainer.kubernetes').service('KubernetesServiceService', KubernetesServiceService); diff --git a/app/kubernetes/services/stackService.js b/app/kubernetes/services/stackService.js new file mode 100644 index 0000000..de0e6a6 --- /dev/null +++ b/app/kubernetes/services/stackService.js @@ -0,0 +1,28 @@ +import _ from 'lodash-es'; +import angular from 'angular'; + +class KubernetesStackService { + /* @ngInject */ + constructor($async, KubernetesApplicationService) { + this.$async = $async; + this.KubernetesApplicationService = KubernetesApplicationService; + + this.getAllAsync = this.getAllAsync.bind(this); + } + + /** + * GET + */ + async getAllAsync(namespace) { + const applications = await this.KubernetesApplicationService.get(namespace); + const stacks = _.map(applications, (item) => item.StackName); + return _.uniq(_.without(stacks, '-', '')); + } + + get(namespace) { + return this.$async(this.getAllAsync, namespace); + } +} + +export default KubernetesStackService; +angular.module('portainer.kubernetes').service('KubernetesStackService', KubernetesStackService); diff --git a/app/kubernetes/services/statefulSetService.js b/app/kubernetes/services/statefulSetService.js new file mode 100644 index 0000000..197929d --- /dev/null +++ b/app/kubernetes/services/statefulSetService.js @@ -0,0 +1,127 @@ +import angular from 'angular'; +import PortainerError from '@/portainer/error'; +import { KubernetesCommonParams } from '@/kubernetes/models/common/params'; +import KubernetesStatefulSetConverter from '@/kubernetes/converters/statefulSet'; + +class KubernetesStatefulSetService { + /* @ngInject */ + constructor($async, KubernetesStatefulSets, KubernetesServiceService) { + this.$async = $async; + this.KubernetesStatefulSets = KubernetesStatefulSets; + this.KubernetesServiceService = KubernetesServiceService; + + this.getAsync = this.getAsync.bind(this); + this.getAllAsync = this.getAllAsync.bind(this); + this.createAsync = this.createAsync.bind(this); + this.patchAsync = this.patchAsync.bind(this); + this.deleteAsync = this.deleteAsync.bind(this); + } + + /** + * GET + */ + async getAsync(namespace, name) { + try { + const params = new KubernetesCommonParams(); + params.id = name; + const [raw, yaml] = await Promise.all([this.KubernetesStatefulSets(namespace).get(params).$promise, this.KubernetesStatefulSets(namespace).getYaml(params).$promise]); + const res = { + Raw: raw, + Yaml: yaml.data, + }; + const headlessServiceName = raw.spec.serviceName; + if (headlessServiceName) { + try { + const headlessService = await this.KubernetesServiceService.get(namespace, headlessServiceName); + res.Yaml += '---\n' + headlessService.Yaml; + } catch (error) { + // if has error means headless service does not exist + // skip error as we don't care in this case + } + } + return res; + } catch (err) { + throw new PortainerError('Unable to retrieve StatefulSet', err); + } + } + + async getAllAsync(namespace) { + try { + const data = await this.KubernetesStatefulSets(namespace).get().$promise; + return data.items; + } catch (err) { + throw new PortainerError('Unable to retrieve StatefulSets', err); + } + } + + get(namespace, name) { + if (name) { + return this.$async(this.getAsync, namespace, name); + } + return this.$async(this.getAllAsync, namespace); + } + + /** + * CREATE + */ + async createAsync(statefulSet) { + try { + const params = {}; + const payload = KubernetesStatefulSetConverter.createPayload(statefulSet); + const namespace = payload.metadata.namespace; + const data = await this.KubernetesStatefulSets(namespace).create(params, payload).$promise; + return data; + } catch (err) { + throw new PortainerError('Unable to create statefulSet', err); + } + } + + create(statefulSet) { + return this.$async(this.createAsync, statefulSet); + } + + /** + * PATCH + */ + async patchAsync(oldStatefulSet, newStatefulSet) { + try { + const params = new KubernetesCommonParams(); + params.id = newStatefulSet.Name; + const namespace = newStatefulSet.Namespace; + const payload = KubernetesStatefulSetConverter.patchPayload(oldStatefulSet, newStatefulSet); + if (!payload.length) { + return; + } + + const data = await this.KubernetesStatefulSets(namespace).patch(params, payload).$promise; + return data; + } catch (err) { + throw new PortainerError('Unable to patch statefulSet', err); + } + } + + patch(oldStatefulSet, newStatefulSet) { + return this.$async(this.patchAsync, oldStatefulSet, newStatefulSet); + } + + /** + * DELETE + */ + async deleteAsync(statefulSet) { + try { + const params = new KubernetesCommonParams(); + params.id = statefulSet.Name; + const namespace = statefulSet.Namespace; + await this.KubernetesStatefulSets(namespace).delete(params).$promise; + } catch (err) { + throw new PortainerError('Unable to remove statefulSet', err); + } + } + + delete(statefulSet) { + return this.$async(this.deleteAsync, statefulSet); + } +} + +export default KubernetesStatefulSetService; +angular.module('portainer.kubernetes').service('KubernetesStatefulSetService', KubernetesStatefulSetService); diff --git a/app/kubernetes/services/storageService.js b/app/kubernetes/services/storageService.js new file mode 100644 index 0000000..312f397 --- /dev/null +++ b/app/kubernetes/services/storageService.js @@ -0,0 +1,58 @@ +import angular from 'angular'; +import _ from 'lodash-es'; +import PortainerError from '@/portainer/error'; +import KubernetesStorageClassConverter from '@/kubernetes/converters/storageClass'; +import { KubernetesCommonParams } from '@/kubernetes/models/common/params'; + +class KubernetesStorageService { + /* @ngInject */ + constructor($async, KubernetesStorage) { + this.$async = $async; + this.KubernetesStorage = KubernetesStorage; + + this.getAsync = this.getAsync.bind(this); + this.patchAsync = this.patchAsync.bind(this); + } + + /** + * GET + */ + async getAsync(endpointId) { + try { + const params = { + endpointId: endpointId, + }; + const classes = await this.KubernetesStorage().get(params).$promise; + const res = _.map(classes.items, (item) => KubernetesStorageClassConverter.apiToStorageClass(item)); + return res; + } catch (err) { + throw new PortainerError('Unable to retrieve storage classes', err); + } + } + + get(endpointId) { + return this.$async(this.getAsync, endpointId); + } + + /** + * PATCH + */ + async patchAsync(endpointId, oldStorageClass, newStorageClass) { + try { + const params = new KubernetesCommonParams(); + params.id = newStorageClass.Name; + params.endpointId = endpointId; + const payload = KubernetesStorageClassConverter.patchPayload(oldStorageClass, newStorageClass); + await this.KubernetesStorage().patch(params, payload).$promise; + } catch (err) { + throw new PortainerError('Unable to patch storage class', err); + } + } + + patch(endpointId, oldStorageClass, newStorageClass) { + return this.$async(this.patchAsync, endpointId, oldStorageClass, newStorageClass); + } +} + +export default KubernetesStorageService; +angular.module('portainer.kubernetes').service('KubernetesStorageService', KubernetesStorageService); diff --git a/app/kubernetes/services/volumeService.js b/app/kubernetes/services/volumeService.js new file mode 100644 index 0000000..7b9bf0d --- /dev/null +++ b/app/kubernetes/services/volumeService.js @@ -0,0 +1,59 @@ +import angular from 'angular'; +import _ from 'lodash-es'; + +import KubernetesVolumeConverter from '@/kubernetes/converters/volume'; + +class KubernetesVolumeService { + /* @ngInject */ + constructor($async, KubernetesResourcePoolService, KubernetesApplicationService, KubernetesPersistentVolumeClaimService) { + this.$async = $async; + this.KubernetesResourcePoolService = KubernetesResourcePoolService; + this.KubernetesApplicationService = KubernetesApplicationService; + this.KubernetesPersistentVolumeClaimService = KubernetesPersistentVolumeClaimService; + + this.getAsync = this.getAsync.bind(this); + this.getAllAsync = this.getAllAsync.bind(this); + this.deleteAsync = this.deleteAsync.bind(this); + } + + /** + * GET + */ + async getAsync(namespace, storageClasses, name) { + const [pvc, pool] = await Promise.all([this.KubernetesPersistentVolumeClaimService.get(namespace, storageClasses, name), this.KubernetesResourcePoolService.get(namespace)]); + return KubernetesVolumeConverter.pvcToVolume(pvc, pool); + } + + async getAllAsync(namespace, storageClasses) { + const data = await this.KubernetesResourcePoolService.get(namespace); + const pools = data instanceof Array ? data : [data]; + const res = await Promise.all( + _.map(pools, async (pool) => { + const pvcs = await this.KubernetesPersistentVolumeClaimService.get(pool.Namespace.Name, storageClasses); + return _.map(pvcs, (pvc) => KubernetesVolumeConverter.pvcToVolume(pvc, pool)); + }) + ); + return _.flatten(res); + } + + get(namespace, storageClasses, name) { + if (name) { + return this.$async(this.getAsync, namespace, storageClasses, name); + } + return this.$async(this.getAllAsync, namespace, storageClasses); + } + + /** + * DELETE + */ + async deleteAsync(volume) { + await this.KubernetesPersistentVolumeClaimService.delete(volume.PersistentVolumeClaim); + } + + delete(volume) { + return this.$async(this.deleteAsync, volume); + } +} + +export default KubernetesVolumeService; +angular.module('portainer.kubernetes').service('KubernetesVolumeService', KubernetesVolumeService); diff --git a/app/kubernetes/store/namespace.js b/app/kubernetes/store/namespace.js new file mode 100644 index 0000000..456b427 --- /dev/null +++ b/app/kubernetes/store/namespace.js @@ -0,0 +1,21 @@ +// singleton pattern as: +// * we don't want to use AngularJS DI to fetch the single instance +// * we need to use the Store in static functions / non-instanciated classes +const storeNamespaces = {}; + +/** + * Check if a namespace of the store is system or not + * @param {String} name Namespace name + * @returns Boolean + */ +export function isSystem(name) { + return storeNamespaces[name] && storeNamespaces[name].IsSystem; +} + +/** + * Called from KubernetesNamespaceService.get() + * @param {KubernetesNamespace[]} namespaces list of namespaces to update in Store + */ +export function updateNamespaces(namespaces) { + namespaces.forEach((ns) => (storeNamespaces[ns.Name] = ns)); +} diff --git a/app/kubernetes/views/applications/create/createApplication.html b/app/kubernetes/views/applications/create/createApplication.html new file mode 100644 index 0000000..ba32499 --- /dev/null +++ b/app/kubernetes/views/applications/create/createApplication.html @@ -0,0 +1,409 @@ + + + + + + + + + + +
+
+
+ + +
+
+
Namespace
+ + + +
+ +
+ + + + +
+ +
+
+ +
+ +
Actions
+ +
+
+ + + + +
+
+ +
+
+ + + + + + + + + + + +
+ + + + + +
+
+ +
+
+ + +
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + +
+ +
+ +
+ +
+ + + + +
+ + + + +
+ + +
+
+ + +
+ +
+ +
Actions
+
+
+ + + + +
+
+
+
+
+
+
+
+
diff --git a/app/kubernetes/views/applications/create/createApplication.js b/app/kubernetes/views/applications/create/createApplication.js new file mode 100644 index 0000000..c5c8df1 --- /dev/null +++ b/app/kubernetes/views/applications/create/createApplication.js @@ -0,0 +1,8 @@ +angular.module('portainer.kubernetes').component('kubernetesCreateApplicationView', { + templateUrl: './createApplication.html', + controller: 'KubernetesCreateApplicationController', + controllerAs: 'ctrl', + bindings: { + endpoint: '<', + }, +}); diff --git a/app/kubernetes/views/applications/create/createApplicationController.js b/app/kubernetes/views/applications/create/createApplicationController.js new file mode 100644 index 0000000..d943860 --- /dev/null +++ b/app/kubernetes/views/applications/create/createApplicationController.js @@ -0,0 +1,1184 @@ +import angular from 'angular'; +import _ from 'lodash-es'; +import filesizeParser from 'filesize-parser'; +import * as JsonPatch from 'fast-json-patch'; +import { RegistryTypes } from '@/portainer/models/registryTypes'; +import { getServices } from '@/react/kubernetes/services/useNamespaceServices'; +import { KubernetesConfigurationKinds } from '@/kubernetes/models/configuration/models'; +import { getGlobalDeploymentOptions } from '@/react/portainer/settings/settings.service'; + +import { + KubernetesApplicationDataAccessPolicies, + KubernetesApplicationDeploymentTypes, + KubernetesApplicationServiceTypes, + KubernetesApplicationTypes, +} from '@/kubernetes/models/application/models/appConstants'; +import { KubernetesApplicationQuotaDefaults, KubernetesDeploymentTypes } from '@/kubernetes/models/application/models'; +import { KubernetesApplicationEnvironmentVariableFormValue, KubernetesApplicationFormValues, KubernetesFormValidationReferences } from '@/kubernetes/models/application/formValues'; +import KubernetesFormValidationHelper from '@/kubernetes/helpers/formValidationHelper'; +import KubernetesApplicationConverter from '@/kubernetes/converters/application'; +import KubernetesResourceReservationHelper from '@/kubernetes/helpers/resourceReservationHelper'; +import { KubernetesServiceTypes } from '@/kubernetes/models/service/models'; +import KubernetesApplicationHelper from '@/kubernetes/helpers/application/index'; +import KubernetesVolumeHelper from '@/kubernetes/helpers/volumeHelper'; +import KubernetesNamespaceHelper from '@/kubernetes/helpers/namespaceHelper'; +import { KubernetesNodeHelper } from '@/kubernetes/node/helper'; +import { updateIngress, getIngresses } from '@/react/kubernetes/ingresses/service'; +import { confirmUpdateAppIngress } from '@/react/kubernetes/applications/CreateView/UpdateIngressPrompt'; +import { KUBE_STACK_NAME_VALIDATION_REGEX } from '@/react/kubernetes/DeployView/StackName/constants'; +import { isVolumeUsed } from '@/react/kubernetes/volumes/utils'; +import { confirm, confirmUpdate, confirmWebEditorDiscard } from '@@/modals/confirm'; +import { buildConfirmButton } from '@@/modals/utils'; +import { ModalType } from '@@/modals'; + +class KubernetesCreateApplicationController { + /* #region CONSTRUCTOR */ + + /* @ngInject */ + constructor( + $scope, + $async, + $state, + $timeout, + $window, + Notifications, + Authentication, + KubernetesResourcePoolService, + KubernetesApplicationService, + KubernetesStackService, + KubernetesConfigurationService, + KubernetesNodeService, + KubernetesIngressService, + KubernetesPersistentVolumeClaimService, + KubernetesVolumeService, + RegistryService, + StackService, + KubernetesNodesLimitsService, + EndpointService, + StateManager + ) { + this.$scope = $scope; + this.$async = $async; + this.$state = $state; + this.$timeout = $timeout; + this.$window = $window; + this.Notifications = Notifications; + this.Authentication = Authentication; + this.KubernetesResourcePoolService = KubernetesResourcePoolService; + this.KubernetesApplicationService = KubernetesApplicationService; + this.KubernetesStackService = KubernetesStackService; + this.KubernetesConfigurationService = KubernetesConfigurationService; + this.KubernetesNodeService = KubernetesNodeService; + this.KubernetesVolumeService = KubernetesVolumeService; + this.KubernetesIngressService = KubernetesIngressService; + this.KubernetesPersistentVolumeClaimService = KubernetesPersistentVolumeClaimService; + this.RegistryService = RegistryService; + this.StackService = StackService; + this.KubernetesNodesLimitsService = KubernetesNodesLimitsService; + this.EndpointService = EndpointService; + this.StateManager = StateManager; + + this.ApplicationDeploymentTypes = KubernetesApplicationDeploymentTypes; + this.ApplicationDataAccessPolicies = KubernetesApplicationDataAccessPolicies; + this.KubernetesApplicationServiceTypes = KubernetesApplicationServiceTypes; + this.ApplicationTypes = KubernetesApplicationTypes; + this.ServiceTypes = KubernetesServiceTypes; + this.KubernetesDeploymentTypes = KubernetesDeploymentTypes; + + this.state = { + appType: this.KubernetesDeploymentTypes.APPLICATION_FORM, + updateWebEditorInProgress: false, + actionInProgress: false, + useLoadBalancer: false, + useServerMetrics: false, + sliders: { + cpu: { + min: 0, + max: 0, + }, + memory: { + min: 0, + max: 0, + }, + }, + nodes: { + memory: 0, + cpu: 0, + }, + namespaceLimits: { + memory: 0, + cpu: 0, + }, + resourcePoolHasQuota: false, + viewReady: false, + availableSizeUnits: ['MB', 'GB', 'TB'], + alreadyExists: false, + duplicates: { + environmentVariables: new KubernetesFormValidationReferences(), + persistedFolders: new KubernetesFormValidationReferences(), + configMapPaths: new KubernetesFormValidationReferences(), + secretPaths: new KubernetesFormValidationReferences(), + existingVolumes: new KubernetesFormValidationReferences(), + placements: new KubernetesFormValidationReferences(), + }, + isEdit: this.$state.params.namespace && this.$state.params.name, + persistedFoldersUseExistingVolumes: false, + pullImageValidity: false, + nodePortServices: [], + // when the namespace available resources changes, and the existing app not has a resource limit that exceeds whats available, + // a validation message will be shown. isExistingCPUReservationUnchanged and isExistingMemoryReservationUnchanged (with available resources being exceeded) is used to decide whether to show the message or not. + isExistingCPUReservationUnchanged: false, + isExistingMemoryReservationUnchanged: false, + stackNameError: '', + }; + + this.isAdmin = this.Authentication.isAdmin(); + + this.editChanges = []; + + this.storageClasses = []; + this.state.useLoadBalancer = false; + this.state.useServerMetrics = false; + + this.formValues = new KubernetesApplicationFormValues(); + + this.updateApplicationAsync = this.updateApplicationAsync.bind(this); + this.deployApplicationAsync = this.deployApplicationAsync.bind(this); + this.setPullImageValidity = this.setPullImageValidity.bind(this); + this.onChangeFileContent = this.onChangeFileContent.bind(this); + this.checkIngressesToUpdate = this.checkIngressesToUpdate.bind(this); + this.confirmUpdateApplicationAsync = this.confirmUpdateApplicationAsync.bind(this); + this.onDataAccessPolicyChange = this.onDataAccessPolicyChange.bind(this); + this.onChangeDeploymentType = this.onChangeDeploymentType.bind(this); + this.supportGlobalDeployment = this.supportGlobalDeployment.bind(this); + this.onServicesChange = this.onServicesChange.bind(this); + this.onEnvironmentVariableChange = this.onEnvironmentVariableChange.bind(this); + this.onConfigMapsChange = this.onConfigMapsChange.bind(this); + this.onSecretsChange = this.onSecretsChange.bind(this); + this.onChangePersistedFolder = this.onChangePersistedFolder.bind(this); + this.onChangeResourceReservation = this.onChangeResourceReservation.bind(this); + this.onChangeReplicaCount = this.onChangeReplicaCount.bind(this); + this.onAutoScaleChange = this.onAutoScaleChange.bind(this); + this.onChangePlacements = this.onChangePlacements.bind(this); + this.updateApplicationType = this.updateApplicationType.bind(this); + this.getAppType = this.getAppType.bind(this); + this.showDataAccessPolicySection = this.showDataAccessPolicySection.bind(this); + this.refreshReactComponent = this.refreshReactComponent.bind(this); + this.onChangeNamespaceName = this.onChangeNamespaceName.bind(this); + this.canSupportSharedAccess = this.canSupportSharedAccess.bind(this); + this.isUpdateApplicationViaWebEditorButtonDisabled = this.isUpdateApplicationViaWebEditorButtonDisabled.bind(this); + + this.$scope.$watch( + () => this.formValues, + () => { + this.refreshReactComponent(); + }, + _.isEqual + ); + } + /* #endregion */ + + refreshReactComponent() { + this.isTemporaryRefresh = true; + + this.$timeout(() => { + this.isTemporaryRefresh = false; + }, 10); + this.onChangeStackName = this.onChangeStackName.bind(this); + this.onChangeAppName = this.onChangeAppName.bind(this); + } + /* #endregion */ + + onChangeStackName(name) { + return this.$async(async () => { + if (KUBE_STACK_NAME_VALIDATION_REGEX.test(name) || name === '') { + this.state.stackNameError = ''; + } else { + this.state.stackNameError = + "Stack must consist of alphanumeric characters, '-', '_' or '.', must start and end with an alphanumeric character and must be 63 characters or less (e.g. 'my-name', or 'abc-123')."; + } + + this.formValues.StackName = name; + }); + } + + onChangePlacements(values) { + return this.$async(async () => { + this.formValues.Placements = values.placements; + this.formValues.PlacementType = values.placementType; + }); + } + + onChangeDeploymentType(value) { + this.$scope.$evalAsync(() => { + this.formValues.DeploymentType = value; + }); + this.updateApplicationType(); + } + + getAppType() { + if (this.formValues.DeploymentType === this.ApplicationDeploymentTypes.Global) { + return this.ApplicationTypes.DaemonSet; + } + const persistedFolders = this.formValues.PersistedFolders && this.formValues.PersistedFolders.filter((pf) => !pf.NeedsDeletion); + if (persistedFolders && persistedFolders.length && this.formValues.DataAccessPolicy === this.ApplicationDataAccessPolicies.Isolated) { + return this.ApplicationTypes.StatefulSet; + } + return this.ApplicationTypes.Deployment; + } + + // keep the application type up to date + updateApplicationType() { + return this.$scope.$evalAsync(() => { + this.formValues.ApplicationType = this.getAppType(); + this.validatePersistedFolders(); + }); + } + + onChangeFileContent(value) { + this.$scope.$evalAsync(() => { + if (this.oldStackFileContent.replace(/(\r\n|\n|\r)/gm, '') !== value.replace(/(\r\n|\n|\r)/gm, '')) { + this.state.isEditorDirty = true; + } else { + this.state.isEditorDirty = false; + } + this.stackFileContent = value; + }); + } + + onDataAccessPolicyChange(value) { + this.$scope.$evalAsync(() => { + this.formValues.DataAccessPolicy = value; + this.resetDeploymentType(); + this.updateApplicationType(); + }); + } + + async updateApplicationViaWebEditor() { + return this.$async(async () => { + try { + const confirmed = await confirm({ + title: 'Are you sure?', + message: 'Any changes to this application will be overridden and may cause a service interruption. Do you wish to continue?', + confirmButton: buildConfirmButton('Update', 'warning'), + modalType: ModalType.Warn, + }); + if (!confirmed) { + return; + } + + this.state.updateWebEditorInProgress = true; + await this.StackService.updateKubeStack( + { EndpointId: this.endpoint.Id, Id: this.application.StackId }, + { stackFile: this.stackFileContent, stackName: this.formValues.StackName } + ); + this.state.isEditorDirty = false; + this.Notifications.success('Success', 'Request to update application successfully submitted'); + this.$state.go( + 'kubernetes.applications.application', + { name: this.application.Name, namespace: this.application.ResourcePool, endpointId: this.endpoint.Id }, + { inherit: false } + ); + } catch (err) { + this.Notifications.error('Failure', err, 'Failed redeploying application'); + } finally { + this.state.updateWebEditorInProgress = false; + } + }); + } + + async uiCanExit() { + if (this.stackFileContent && this.state.isEditorDirty) { + return confirmWebEditorDiscard(); + } + } + + setPullImageValidity(validity) { + this.state.pullImageValidity = validity; + } + + imageValidityIsValid() { + return ( + this.isExternalApplication() || this.state.pullImageValidity || (this.formValues.registryDetails && this.formValues.registryDetails.Registry.Type !== RegistryTypes.DOCKERHUB) + ); + } + + onChangeAppName(appName) { + return this.$async(async () => { + this.formValues.Name = appName; + }); + } + + /* #region AUTO SCALER UI MANAGEMENT */ + onAutoScaleChange(values) { + return this.$async(async () => { + this.formValues.AutoScaler = values; + + // reset it to previous form values if the user disables the auto scaler + if (!this.oldFormValues.AutoScaler.isUsed && !values.isUsed) { + this.formValues.AutoScaler = this.oldFormValues.AutoScaler; + } + }); + } + /* #endregion */ + + /* #region CONFIGMAP UI MANAGEMENT */ + onConfigMapsChange(configMaps) { + return this.$async(async () => { + this.formValues.ConfigMaps = configMaps; + }); + } + + clearConfigMaps() { + this.formValues.ConfigMaps = []; + } + /* #endregion */ + + /* #region SECRET UI MANAGEMENT */ + onSecretsChange(secrets) { + return this.$async(async () => { + this.formValues.Secrets = secrets; + }); + } + + clearSecrets() { + this.formValues.Secrets = []; + } + /* #endregion */ + + /* #region ENVIRONMENT UI MANAGEMENT */ + onEnvironmentVariableChange(enviromnentVariables) { + return this.$async(async () => { + const newEnvVars = enviromnentVariables.map((envVar) => { + const newEnvVar = new KubernetesApplicationEnvironmentVariableFormValue(); + return { newEnvVar, ...envVar }; + }); + this.formValues.EnvironmentVariables = newEnvVars; + }); + } + /* #endregion */ + + /* #region PERSISTENT FOLDERS UI MANAGEMENT */ + resetPersistedFolders() { + this.formValues.PersistedFolders = _.forEach(this.formValues.PersistedFolders, (persistedFolder) => { + persistedFolder.existingVolume = null; + persistedFolder.useNewVolume = true; + }); + this.validatePersistedFolders(); + this.updateApplicationType(); + } + /* #endregion */ + + /* #region PERSISTENT FOLDERS ON CHANGE VALIDATION */ + validatePersistedFolders() { + this.onChangePersistedFolderPath(); + this.onChangeExistingVolumeSelection(); + } + + onChangePersistedFolderPath() { + this.state.duplicates.persistedFolders.refs = KubernetesFormValidationHelper.getDuplicates( + _.map(this.formValues.PersistedFolders, (persistedFolder) => { + if (persistedFolder.needsDeletion) { + return undefined; + } + return persistedFolder.containerPath; + }) + ); + this.state.duplicates.persistedFolders.hasRefs = Object.keys(this.state.duplicates.persistedFolders.refs).length > 0; + } + + onChangePersistedFolder(values) { + this.$scope.$evalAsync(() => { + this.state.persistedFoldersUseExistingVolumes = values.some((pf) => pf.existingVolume); + if (!this.state.isEdit && this.state.persistedFoldersUseExistingVolumes) { + this.formValues.DataAccessPolicy = this.ApplicationDataAccessPolicies.Shared; + } + this.formValues.PersistedFolders = values; + if (values && values.length && !this.supportGlobalDeployment()) { + this.onChangeDeploymentType(this.ApplicationDeploymentTypes.Replicated); + } + this.updateApplicationType(); + }); + } + + onChangeExistingVolumeSelection() { + this.state.duplicates.existingVolumes.refs = KubernetesFormValidationHelper.getDuplicates( + _.map(this.formValues.PersistedFolders, (persistedFolder) => { + if (persistedFolder.needsDeletion) { + return undefined; + } + return persistedFolder.existingVolume ? persistedFolder.existingVolume.PersistentVolumeClaim.Name : ''; + }) + ); + this.state.duplicates.existingVolumes.hasRefs = Object.keys(this.state.duplicates.existingVolumes.refs).length > 0; + } + /* #endregion */ + + /* #region SERVICES UI MANAGEMENT */ + onServicesChange(services) { + return this.$async(async () => { + // if the ingress isn't found in the currently loaded ingresses, then refresh the ingresses + const ingressNamesUsed = services.flatMap((s) => s.Ports.flatMap((p) => (p.ingressPaths ? p.ingressPaths.flatMap((ip) => ip.IngressName || []) : []))); + if (ingressNamesUsed.length) { + const uniqueIngressNamesUsed = Array.from(new Set(ingressNamesUsed)); // get the unique ingress names used + const ingressNamesLoaded = this.ingresses.map((i) => i.Name); + const areAllIngressesLoaded = uniqueIngressNamesUsed.every((ingressNameUsed) => ingressNamesLoaded.includes(ingressNameUsed)); + if (!areAllIngressesLoaded) { + this.refreshIngresses(this.application.ResourcePool); + } + } + // update the services + this.formValues.Services = services; + }); + } + /* #endregion */ + + /* #region STATE VALIDATION FUNCTIONS */ + isValid() { + return ( + !this.state.duplicates.environmentVariables.hasRefs && + !this.state.duplicates.persistedFolders.hasRefs && + !this.state.duplicates.configMapPaths.hasRefs && + !this.state.duplicates.secretPaths.hasRefs && + !this.state.duplicates.existingVolumes.hasRefs + ); + } + + storageClassAvailable() { + return this.storageClasses && this.storageClasses.length > 0; + } + + resetDeploymentType() { + this.formValues.DeploymentType = this.ApplicationDeploymentTypes.Replicated; + } + + // // The data access policy panel is shown when a persisted folder is specified + showDataAccessPolicySection() { + return this.formValues.PersistedFolders.length > 0; + } + + // A global deployment is not available when either: + // * For each persisted folder specified, if one of the storage object only supports the RWO access mode + // * The data access policy is set to ISOLATED + supportGlobalDeployment() { + const hasFolders = this.formValues.PersistedFolders.length !== 0; + const hasRWOOnly = KubernetesApplicationHelper.hasRWOOnly(this.formValues); + const isIsolated = this.formValues.DataAccessPolicy === this.ApplicationDataAccessPolicies.Isolated; + + if (hasFolders && (hasRWOOnly || isIsolated)) { + return false; + } + + return true; + } + + // from the pvcs in the form values, get all selected storage classes and find if they are all support RWX + canSupportSharedAccess() { + const formStorageClasses = this.formValues.PersistedFolders.map((pf) => pf.storageClass); + const isRWXSupported = formStorageClasses.every((sc) => sc.AccessModes.includes('RWX')); + return isRWXSupported; + } + + // A StatefulSet is defined by DataAccessPolicy === 'Isolated' + isEditAndStatefulSet() { + return this.state.isEdit && this.formValues.DataAccessPolicy === this.ApplicationDataAccessPolicies.Isolated; + } + + // A scalable deployment is available when either: + // * No persisted folders are specified + // * The access policy is set to shared and for each persisted folders specified, all the associated + // storage objects support at least RWX access mode (no RWO only) + // * The access policy is set to isolated + supportScalableReplicaDeployment() { + const hasFolders = this.formValues.PersistedFolders.length !== 0; + const hasRWOOnly = KubernetesApplicationHelper.hasRWOOnly(this.formValues); + const isIsolated = this.formValues.DataAccessPolicy === this.ApplicationDataAccessPolicies.Isolated; + + if (!hasFolders || isIsolated || (hasFolders && !hasRWOOnly)) { + return true; + } + return false; + } + + onChangeReplicaCount(values) { + return this.$async(async () => { + this.formValues.ReplicaCount = values.replicaCount; + }); + } + + // For each persisted folders, returns the non scalable deployments options (storage class that only supports RWO) + getNonScalableStorage() { + let storageOptions = []; + + for (let i = 0; i < this.formValues.PersistedFolders.length; i++) { + const folder = this.formValues.PersistedFolders[i]; + + if (folder.storageClass && _.isEqual(folder.storageClass.AccessModes, ['RWO'])) { + storageOptions.push(folder.storageClass.Name); + } else { + storageOptions.push(''); + } + } + + return _.uniq(storageOptions).join(', '); + } + + enforceReplicaCountMinimum() { + if (this.formValues.ReplicaCount === null) { + this.formValues.ReplicaCount = 1; + } + } + + onChangeResourceReservation(values) { + return this.$async(async () => { + this.formValues.MemoryLimit = values.memoryLimit; + this.formValues.CpuLimit = values.cpuLimit; + + if (this.oldFormValues.CpuLimit !== this.formValues.CpuLimit && this.state.isExistingCPUReservationUnchanged) { + this.state.isExistingCPUReservationUnchanged = false; + } + if (this.oldFormValues.MemoryLimit !== this.formValues.MemoryLimit && this.state.isExistingMemoryReservationUnchanged) { + this.state.isExistingMemoryReservationUnchanged = false; + } + }); + } + + resourceQuotaCapacityExceeded() { + return !this.state.sliders.memory.max || !this.state.sliders.cpu.max; + } + + nodeLimitsOverflow() { + const cpu = this.formValues.CpuLimit; + const memory = KubernetesResourceReservationHelper.bytesValue(this.formValues.MemoryLimit); + + const overflow = this.nodesLimits.overflowForReplica(cpu, memory, 1); + + return overflow; + } + + effectiveInstances() { + return this.formValues.DeploymentType === this.ApplicationDeploymentTypes.Global ? this.nodeNumber : this.formValues.ReplicaCount; + } + + hasPortErrors() { + const portError = this.formValues.Services.some((service) => service.nodePortError || service.servicePortError); + return portError; + } + + resourceReservationsOverflow() { + const instances = this.effectiveInstances(); + const cpu = this.formValues.CpuLimit; + const maxCpu = this.state.namespaceLimits.cpu; + const memory = KubernetesResourceReservationHelper.bytesValue(this.formValues.MemoryLimit); + const maxMemory = this.state.namespaceLimits.memory; + + // multiply 1000 can avoid 0.1 * 3 > 0.3 + if (cpu * 1000 * instances > maxCpu * 1000) { + return true; + } + + if (memory * instances > maxMemory) { + return true; + } + + if (this.formValues.DeploymentType === this.ApplicationDeploymentTypes.Replicated) { + return this.nodesLimits.overflowForReplica(cpu, memory, instances); + } + + // DeploymentType == 'Global' + return this.nodesLimits.overflowForGlobal(cpu, memory); + } + + autoScalerOverflow() { + const instances = this.formValues.AutoScaler.MaxReplicas; + const cpu = this.formValues.CpuLimit; + const maxCpu = this.state.namespaceLimits.cpu; + const memory = KubernetesResourceReservationHelper.bytesValue(this.formValues.MemoryLimit); + const maxMemory = this.state.namespaceLimits.memory; + + // multiply 1000 can avoid 0.1 * 3 > 0.3 + if (cpu * 1000 * instances > maxCpu * 1000) { + return true; + } + + if (memory * instances > maxMemory) { + return true; + } + + return this.nodesLimits.overflowForReplica(cpu, memory, instances); + } + + publishViaIngressEnabled() { + return this.ingresses.length; + } + + isEditAndNoChangesMade() { + if (!this.state.isEdit) return false; + const changes = JsonPatch.compare(this.savedFormValues, this.formValues); + this.editChanges = _.filter(changes, (change) => !_.includes(change.path, '$$hashKey') && change.path !== '/ApplicationType'); + return !this.editChanges.length; + } + + /* #region PERSISTED FOLDERS */ + /* #region BUTTONS STATES */ + isAddPersistentFolderButtonShown() { + return !this.isEditAndStatefulSet() && this.formValues.Containers.length <= 1; + } + + isNewVolumeButtonDisabled(index) { + return this.isEditAndExistingPersistedFolder(index); + } + + isExistingVolumeButtonDisabled() { + return !this.hasAvailableVolumes() || (this.isEdit && this.application.ApplicationType === this.ApplicationTypes.StatefulSet); + } + /* #endregion */ + + hasAvailableVolumes() { + return this.availableVolumes.length > 0; + } + + isEditAndExistingPersistedFolder(index) { + return this.state.isEdit && this.formValues.PersistedFolders[index].persistentVolumeClaimName; + } + /* #endregion */ + + isNonScalable() { + const scalable = this.supportScalableReplicaDeployment(); + const global = this.supportGlobalDeployment(); + const replica = this.formValues.ReplicaCount > 1; + const replicated = this.formValues.DeploymentType === this.ApplicationDeploymentTypes.Replicated; + const res = (replicated && !scalable && replica) || (!replicated && !global); + return res; + } + + isDeployUpdateButtonDisabled() { + const overflow = this.resourceReservationsOverflow(); + const autoScalerOverflow = this.autoScalerOverflow(); + const inProgress = this.state.actionInProgress; + const invalid = !this.isValid(); + const hasNoChanges = this.isEditAndNoChangesMade(); + const nonScalable = this.isNonScalable(); + const stackNameInvalid = this.state.stackNameError !== ''; + return overflow || autoScalerOverflow || inProgress || invalid || hasNoChanges || nonScalable || stackNameInvalid; + } + + isUpdateApplicationViaWebEditorButtonDisabled() { + return (this.savedFormValues.StackName === this.formValues.StackName && !this.state.isEditorDirty) || this.state.updateWebEditorInProgress; + } + + isExternalApplication() { + if (this.application) { + return KubernetesApplicationHelper.isExternalApplication(this.application); + } else { + return false; + } + } + /* #endregion */ + + /* #region DATA AUTO REFRESH */ + updateSliders(namespaceWithQuota) { + const quota = namespaceWithQuota.Quota; + let minCpu = 0, + minMemory = 0, + maxCpu = this.state.namespaceLimits.cpu, + maxMemory = this.state.namespaceLimits.memory; + + if (quota) { + if (quota.CpuLimit) { + minCpu = KubernetesApplicationQuotaDefaults.CpuLimit; + } + if (quota.MemoryLimit) { + minMemory = KubernetesResourceReservationHelper.bytesValue(KubernetesApplicationQuotaDefaults.MemoryLimit); + } + } + + maxCpu = Math.min(maxCpu, this.nodesLimits.MaxCPU); + maxMemory = Math.min(maxMemory, this.nodesLimits.MaxMemory); + + if (maxMemory < minMemory) { + minMemory = 0; + maxMemory = 0; + } + + this.state.sliders.memory.min = KubernetesResourceReservationHelper.megaBytesValue(minMemory); + this.state.sliders.memory.max = KubernetesResourceReservationHelper.megaBytesValue(maxMemory); + this.state.sliders.cpu.min = minCpu; + this.state.sliders.cpu.max = _.floor(maxCpu, 2); + if (!this.state.isEdit) { + this.formValues.CpuLimit = minCpu; + this.formValues.MemoryLimit = KubernetesResourceReservationHelper.megaBytesValue(minMemory); + } + } + + updateNamespaceLimits(namespaceWithQuota) { + return this.$async(async () => { + let maxCpu = this.state.nodes.cpu; + let maxMemory = this.state.nodes.memory; + + const quota = namespaceWithQuota.Quota; + + this.state.resourcePoolHasQuota = false; + + if (quota) { + if (quota.CpuLimit) { + this.state.resourcePoolHasQuota = true; + maxCpu = quota.CpuLimit - quota.CpuLimitUsed; + if (this.state.isEdit && this.savedFormValues.CpuLimit) { + maxCpu += this.savedFormValues.CpuLimit * this.effectiveInstances(); + } + } + + if (quota.MemoryLimit) { + this.state.resourcePoolHasQuota = true; + maxMemory = quota.MemoryLimit - quota.MemoryLimitUsed; + if (this.state.isEdit && this.savedFormValues.MemoryLimit) { + maxMemory += KubernetesResourceReservationHelper.bytesValue(this.savedFormValues.MemoryLimit) * this.effectiveInstances(); + } + } + } + + this.state.namespaceLimits.cpu = maxCpu; + this.state.namespaceLimits.memory = maxMemory; + }); + } + + refreshStacks(namespace) { + return this.$async(async () => { + try { + this.stacks = await this.KubernetesStackService.get(namespace); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to retrieve stacks'); + } + }); + } + + refreshConfigurations(namespace) { + return this.$async(async () => { + try { + this.configurations = await this.KubernetesConfigurationService.get(namespace); + this.configMaps = this.configurations.filter((configuration) => configuration.Kind === KubernetesConfigurationKinds.CONFIGMAP); + this.secrets = this.configurations.filter((configuration) => configuration.Kind === KubernetesConfigurationKinds.SECRET); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to retrieve configurations'); + } + }); + } + + refreshApplications(namespace) { + return this.$async(async () => { + try { + this.applications = await this.KubernetesApplicationService.get(namespace); + this.applicationNames = _.map(this.applications, 'Name'); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to retrieve applications'); + } + }); + } + + refreshVolumes(namespace) { + return this.$async(async () => { + try { + const storageClasses = this.endpoint.Kubernetes.Configuration.StorageClasses; + const volumes = await this.KubernetesVolumeService.get(namespace, storageClasses); + _.forEach(volumes, (volume) => { + volume.Applications = KubernetesVolumeHelper.getUsingApplications(volume, this.applications); + }); + this.volumes = volumes; + const filteredVolumes = _.filter(this.volumes, (volume) => { + const isUnused = !isVolumeUsed(volume); + const isRWX = volume.PersistentVolumeClaim.storageClass && _.includes(volume.PersistentVolumeClaim.storageClass.AccessModes, 'RWX'); + return isUnused || isRWX; + }); + this.availableVolumes = filteredVolumes; + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to retrieve volumes'); + } + }); + } + + refreshIngresses(namespace) { + return this.$async(async () => { + try { + this.ingresses = await this.KubernetesIngressService.get(namespace); + this.ingressPaths = this.ingresses.flatMap((ingress) => ingress.Paths); + this.ingressHostnames = this.ingresses.length ? this.ingresses[0].Hosts : []; + if (!this.publishViaIngressEnabled()) { + if (this.savedFormValues) { + this.formValues.PublishingType = this.savedFormValues.PublishingType; + } else { + this.formValues.PublishingType = this.KubernetesApplicationServiceTypes.ClusterIP; + } + } + this.formValues.OriginalIngresses = this.ingresses; + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to retrieve ingresses'); + } + }); + } + + refreshNamespaceData(namespace) { + return this.$async(async () => { + await Promise.all([ + this.refreshStacks(namespace), + this.refreshConfigurations(namespace), + this.refreshApplications(namespace), + this.refreshIngresses(namespace), + this.refreshVolumes(namespace), + ]); + }); + } + + resetFormValues() { + this.clearConfigMaps(); + this.clearSecrets(); + this.resetPersistedFolders(); + } + + onChangeNamespaceName(namespaceName) { + return this.$async(async () => { + this.formValues.ResourcePool.Namespace.Name = namespaceName; + const namespaceWithQuota = await this.KubernetesResourcePoolService.get(namespaceName); + this.updateNamespaceLimits(namespaceWithQuota); + this.updateSliders(namespaceWithQuota); + await this.refreshNamespaceData(namespaceName); + this.resetFormValues(); + }); + } + /* #endregion */ + + /* #region ACTIONS */ + async deployApplicationAsync() { + this.state.actionInProgress = true; + try { + this.formValues.ApplicationOwner = this.Authentication.getUserDetails().username; + // combine the secrets and configmap form values when submitting the form + _.remove(this.formValues.Configurations, (item) => item.selectedConfiguration === undefined); + await this.KubernetesApplicationService.create(this.formValues, this.originalServicePorts, this.deploymentOptions.hideStacksFunctionality); + this.Notifications.success('Request to deploy application successfully submitted', this.formValues.Name); + this.$state.go('kubernetes.applications'); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to create application'); + } finally { + this.state.actionInProgress = false; + } + } + + async updateApplicationAsync(ingressesToUpdate) { + if (ingressesToUpdate.length) { + try { + await Promise.all(ingressesToUpdate.map((ing) => updateIngress(this.endpoint.Id, ing))); + this.Notifications.success('Success', `Ingress ${ingressesToUpdate.length > 1 ? 'rules' : 'rule'} successfully updated`); + } catch (error) { + this.Notifications.error('Failure', error, 'Unable to update ingress'); + } + } + + try { + this.state.actionInProgress = true; + await this.KubernetesApplicationService.patch(this.savedFormValues, this.formValues, false, this.originalServicePorts); + this.Notifications.success('Success', 'Request to update application successfully submitted'); + this.$state.go( + 'kubernetes.applications.application', + { name: this.application.Name, namespace: this.application.ResourcePool, endpointId: this.endpoint.Id }, + { inherit: false } + ); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to update application'); + } finally { + this.state.actionInProgress = false; + } + } + + async confirmUpdateApplicationAsync() { + const [ingressesToUpdate, servicePortsToUpdate] = await this.checkIngressesToUpdate(); + // if there is an ingressesToUpdate, then show a warning modal with asking if they want to update the ingresses + if (ingressesToUpdate.length) { + const result = await confirmUpdateAppIngress(ingressesToUpdate, servicePortsToUpdate); + if (!result) { + return; + } + + const { noMatch } = result; + if (!noMatch) { + return this.$async(this.updateApplicationAsync, []); + } + if (noMatch) { + return this.$async(this.updateApplicationAsync, ingressesToUpdate); + } + } else { + confirmUpdate('Updating the application may cause a service interruption. Do you wish to continue?', (confirmed) => { + if (confirmed) { + return this.$async(this.updateApplicationAsync, []); + } + }); + } + } + + // check if service ports with ingresses have changed and allow the user to update the ingress to the new port values with a modal + async checkIngressesToUpdate() { + let ingressesToUpdate = []; + let servicePortsToUpdate = []; + const fullIngresses = await getIngresses(this.endpoint.Id); + this.formValues.Services.forEach((updatedService) => { + const oldServiceIndex = this.oldFormValues.Services.findIndex((oldService) => oldService.Name === updatedService.Name); + const numberOfPortsInOldService = this.oldFormValues.Services[oldServiceIndex] && this.oldFormValues.Services[oldServiceIndex].Ports.length; + // if the service has an ingress and there is the same number of ports or more in the updated service + if (updatedService.Ingress && numberOfPortsInOldService && numberOfPortsInOldService <= updatedService.Ports.length) { + const updatedOldPorts = updatedService.Ports.slice(0, numberOfPortsInOldService); + const ingressesForService = fullIngresses.filter((ing) => { + if (ing.Paths) { + const ingServiceNames = ing.Paths.map((path) => path.ServiceName); + if (ingServiceNames.includes(updatedService.Name)) { + return true; + } + } + }); + ingressesForService.forEach((ingressForService) => { + updatedOldPorts.forEach((servicePort, pIndex) => { + if (servicePort.ingressPaths) { + // if there isn't a ingress path that has a matching service name and port + const doesIngressPathMatchServicePort = ingressForService.Paths.find((ingPath) => ingPath.ServiceName === updatedService.Name && ingPath.Port === servicePort.port); + if (!doesIngressPathMatchServicePort) { + // then find the ingress path index to update by looking for the matching port in the old form values + const oldServicePort = this.oldFormValues.Services[oldServiceIndex].Ports[pIndex].port; + const newServicePort = servicePort.port; + + const ingressPathIndex = ingressForService.Paths.findIndex((ingPath) => { + return ingPath.ServiceName === updatedService.Name && ingPath.Port === oldServicePort; + }); + if (ingressPathIndex !== -1) { + // if the ingress to update isn't in the ingressesToUpdate list + const ingressUpdateIndex = ingressesToUpdate.findIndex((ing) => ing.Name === ingressForService.Name); + if (ingressUpdateIndex === -1) { + // then add it to the list with the new port + const ingressToUpdate = angular.copy(ingressForService); + ingressToUpdate.Paths[ingressPathIndex].Port = newServicePort; + ingressesToUpdate.push(ingressToUpdate); + } else { + // if the ingress is already in the list, then update the path with the new port + ingressesToUpdate[ingressUpdateIndex].Paths[ingressPathIndex].Port = newServicePort; + } + if (!servicePortsToUpdate.includes(newServicePort)) { + servicePortsToUpdate.push(newServicePort); + } + } + } + } + }); + }); + } + }); + return [ingressesToUpdate, servicePortsToUpdate]; + } + + deployApplication() { + if (this.state.isEdit) { + return this.$async(this.confirmUpdateApplicationAsync); + } else { + return this.$async(this.deployApplicationAsync); + } + } + /* #endregion */ + + /* #region APPLICATION - used on edit context only */ + getApplication() { + return this.$async(async () => { + try { + const namespace = this.$state.params.namespace; + const storageClasses = this.endpoint.Kubernetes.Configuration.StorageClasses; + + [this.application, this.persistentVolumeClaims] = await Promise.all([ + this.KubernetesApplicationService.get(namespace, this.$state.params.name), + this.KubernetesPersistentVolumeClaimService.get(namespace, storageClasses), + ]); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to retrieve application details'); + } + }); + } + + async parseImageConfiguration(imageModel) { + return this.$async(async () => { + try { + return await this.RegistryService.retrievePorRegistryModelFromRepository(imageModel.Image, this.endpoint.Id, imageModel.Registry.Id, this.$state.params.namespace); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to retrieve registry'); + return imageModel; + } + }); + } + /* #endregion */ + + /* #region ON INIT */ + $onInit() { + return this.$async(async () => { + try { + this.endpoint = await this.EndpointService.endpoint(this.endpoint.Id); + await this.StateManager.updateEndpointState(this.endpoint); + + this.storageClasses = this.endpoint.Kubernetes.Configuration.StorageClasses; + this.state.useLoadBalancer = this.endpoint.Kubernetes.Configuration.UseLoadBalancer; + this.state.useServerMetrics = this.endpoint.Kubernetes.Configuration.UseServerMetrics; + + this.deploymentOptions = await getGlobalDeploymentOptions(); + + const [resourcePools, nodes, nodesLimits] = await Promise.all([ + this.KubernetesResourcePoolService.get(), + this.KubernetesNodeService.get(), + this.KubernetesNodesLimitsService.get(), + ]); + this.nodesLimits = nodesLimits; + + const nonSystemNamespaces = _.filter( + resourcePools, + (resourcePool) => !KubernetesNamespaceHelper.isSystemNamespace(resourcePool.Namespace.Name) && resourcePool.Namespace.Status === 'Active' + ); + + this.allNamespaces = resourcePools.map(({ Namespace }) => Namespace.Name); + this.resourcePools = _.sortBy(nonSystemNamespaces, ({ Namespace }) => (Namespace.Name === 'default' ? 0 : 1)); + + // this.state.nodes.memory and this.state.nodes.cpu are used to calculate the slider limits, so set them before calling updateSliders() + _.forEach(nodes, (item) => { + this.state.nodes.memory += filesizeParser(item.Memory); + this.state.nodes.cpu += item.CPU; + }); + + var namespace = ''; + this.formValues.ResourcePool = this.resourcePools[0]; + + if (this.resourcePools.length) { + if (this.state.isEdit) { + namespace = this.$state.params.namespace; + this.formValues.ResourcePool = _.find(this.resourcePools, ['Namespace.Name', namespace]); + } + + namespace = this.formValues.ResourcePool.Namespace.Name; + this.namespaceWithQuota = await this.KubernetesResourcePoolService.get(namespace); + this.formValues.ResourcePool.Quota = this.namespaceWithQuota.Quota; + + // this.savedFormValues is being used in updateNamespaceLimits behind a check to see isEdit + if (this.state.isEdit) { + this.savedFormValues = angular.copy(this.formValues); + } + + this.updateNamespaceLimits(this.namespaceWithQuota); + this.updateSliders(this.namespaceWithQuota); + } + + if (!this.formValues.ResourcePool) { + return; + } + + this.nodesLabels = KubernetesNodeHelper.generateNodeLabelsFromNodes(nodes); + this.nodeNumber = nodes.length; + + await this.refreshNamespaceData(namespace); + + if (this.state.isEdit) { + await this.getApplication(); + this.formValues = KubernetesApplicationConverter.applicationToFormValues( + this.application, + this.resourcePools, + this.configurations, + this.persistentVolumeClaims, + this.nodesLabels, + this.ingresses + ); + + this.formValues.Services = this.formValues.Services || []; + this.originalServicePorts = structuredClone(this.formValues.Services.flatMap((service) => service.Ports)); + this.originalIngressPaths = structuredClone(this.originalServicePorts.flatMap((port) => port.ingressPaths).filter((ingressPath) => ingressPath.Host)); + + if (this.formValues.CpuLimit) { + this.state.isExistingCPUReservationUnchanged = true; + } + if (this.formValues.MemoryLimit) { + this.state.isExistingMemoryReservationUnchanged = true; + } + + if (this.application.ApplicationKind) { + this.state.appType = KubernetesDeploymentTypes[this.application.ApplicationKind.toUpperCase()]; + if (this.application.ApplicationKind === KubernetesDeploymentTypes.URL) { + this.state.appType = KubernetesDeploymentTypes.CONTENT; + } + + if (this.application.StackId) { + this.stack = await this.StackService.stack(this.application.StackId); + if (this.state.appType === KubernetesDeploymentTypes.CONTENT) { + this.stackFileContent = await this.StackService.getStackFile(this.application.StackId); + this.oldStackFileContent = this.stackFileContent; + } + } + + if (this.application.ApplicationKind === KubernetesDeploymentTypes.GIT) { + this.$state.go( + 'kubernetes.applications.application', + { + name: this.application.Name, + namespace: this.application.ResourcePool, + endpointId: this.endpoint.Id, + openGitSettings: true, + }, + { inherit: false } + ); + return; + } + } + + this.formValues.OriginalIngresses = this.ingresses; + this.formValues.ImageModel = await this.parseImageConfiguration(this.formValues.ImageModel); + + if (this.application.ApplicationType !== KubernetesApplicationTypes.StatefulSet) { + _.forEach(this.formValues.PersistedFolders, (persistedFolder) => { + const volume = _.find(this.availableVolumes, ['PersistentVolumeClaim.Name', persistedFolder.persistentVolumeClaimName]); + if (volume) { + persistedFolder.useNewVolume = false; + persistedFolder.existingVolume = volume; + } + }); + } + this.formValues.OriginalPersistedFolders = this.formValues.PersistedFolders; + await this.refreshNamespaceData(namespace); + + this.savedFormValues = angular.copy(this.formValues); + this.updateNamespaceLimits(this.namespaceWithQuota); + this.updateSliders(this.namespaceWithQuota); + } else { + this.formValues.AutoScaler = KubernetesApplicationHelper.generateAutoScalerFormValueFromHorizontalPodAutoScaler(null, this.formValues.ReplicaCount); + } + + if (this.state.isEdit) { + this.nodesLimits.excludesPods(this.application.Pods, this.formValues.CpuLimit, KubernetesResourceReservationHelper.bytesValue(this.formValues.MemoryLimit)); + + // Workaround for EE-6118 + if (this.stack && !this.stack.EndpointId) { + this.stack.EndpointId = this.endpoint.Id; + } + } + + this.oldFormValues = angular.copy(this.formValues); + this.savedFormValues = angular.copy(this.formValues); + this.updateNamespaceLimits(this.namespaceWithQuota); + this.updateSliders(this.namespaceWithQuota); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to load view data'); + } finally { + this.state.viewReady = true; + } + // get all nodeport services in the cluster, to validate unique nodeports in the form + // this is below the try catch, to not block the page rendering + const allSettledServices = await Promise.allSettled(this.resourcePools.map((namespace) => getServices(this.endpoint.Id, namespace.Namespace.Name))); + const allServices = allSettledServices + .filter((settledService) => settledService.status === 'fulfilled' && settledService.value) + .map((fulfilledService) => fulfilledService.value) + .flat(); + this.state.nodePortServices = allServices.filter((service) => service.Type === 'NodePort'); + }); + } + + /* #endregion */ +} + +export default KubernetesCreateApplicationController; +angular.module('portainer.kubernetes').controller('KubernetesCreateApplicationController', KubernetesCreateApplicationController); diff --git a/app/kubernetes/views/applications/logs/logs.html b/app/kubernetes/views/applications/logs/logs.html new file mode 100644 index 0000000..06d45ec --- /dev/null +++ b/app/kubernetes/views/applications/logs/logs.html @@ -0,0 +1,84 @@ + + + + + +
+
+
+ + +
+
Actions
+ +
+ +
+ +
+
+ + +
+ +
+ +
+
+ + +
+
+ +
+
+ +
+
+
+
+
+ +
+
+
+        

{{ span.text }}

+

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

+

No logs available

+
+
+
diff --git a/app/kubernetes/views/applications/logs/logs.js b/app/kubernetes/views/applications/logs/logs.js new file mode 100644 index 0000000..513bf92 --- /dev/null +++ b/app/kubernetes/views/applications/logs/logs.js @@ -0,0 +1,8 @@ +angular.module('portainer.kubernetes').component('kubernetesApplicationLogsView', { + templateUrl: './logs.html', + controller: 'KubernetesApplicationLogsController', + controllerAs: 'ctrl', + bindings: { + $transition$: '<', + }, +}); diff --git a/app/kubernetes/views/applications/logs/logsController.js b/app/kubernetes/views/applications/logs/logsController.js new file mode 100644 index 0000000..37cae6c --- /dev/null +++ b/app/kubernetes/views/applications/logs/logsController.js @@ -0,0 +1,96 @@ +import angular from 'angular'; + +import { concatLogsToString, formatLogs } from '@/docker/helpers/logHelper'; + +class KubernetesApplicationLogsController { + /* @ngInject */ + constructor($async, $state, $interval, Notifications, KubernetesApplicationService, KubernetesPodService, Blob, FileSaver) { + this.$async = $async; + this.$state = $state; + this.$interval = $interval; + this.Notifications = Notifications; + this.KubernetesApplicationService = KubernetesApplicationService; + this.KubernetesPodService = KubernetesPodService; + this.Blob = Blob; + this.FileSaver = FileSaver; + + this.onInit = this.onInit.bind(this); + this.stopRepeater = this.stopRepeater.bind(this); + this.getApplicationLogsAsync = this.getApplicationLogsAsync.bind(this); + } + + updateAutoRefresh() { + if (this.state.autoRefresh) { + this.setUpdateRepeater(); + return; + } + + this.stopRepeater(); + } + + stopRepeater() { + if (angular.isDefined(this.repeater)) { + this.$interval.cancel(this.repeater); + this.repeater = null; + } + } + + setUpdateRepeater() { + this.repeater = this.$interval(this.getApplicationLogsAsync, this.state.refreshRate); + } + + downloadLogs() { + const logsAsString = concatLogsToString(this.applicationLogs); + const data = new this.Blob([logsAsString]); + this.FileSaver.saveAs(data, this.podName + '_logs.txt'); + } + + async getApplicationLogsAsync() { + try { + const rawLogs = await this.KubernetesPodService.logs(this.application.ResourcePool, this.podName, this.containerName); + this.applicationLogs = formatLogs(rawLogs); + } catch (err) { + this.stopRepeater(); + this.Notifications.error('Failure', err, 'Unable to retrieve application logs'); + } + } + + async onInit() { + this.state = { + autoRefresh: false, + refreshRate: 5000, // 5 seconds + search: '', + viewReady: false, + }; + + const podName = this.$transition$.params().pod; + const applicationName = this.$transition$.params().name; + const namespace = this.$transition$.params().namespace; + const containerName = this.$transition$.params().container; + + this.applicationLogs = []; + this.podName = podName; + this.containerName = containerName; + + try { + this.application = await this.KubernetesApplicationService.get(namespace, applicationName); + await this.getApplicationLogsAsync(); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to retrieve application logs'); + this.stopRepeater(); + } finally { + this.state.viewReady = true; + } + } + + $onInit() { + return this.$async(this.onInit); + } + + $onDestroy() { + this.stopRepeater(); + } +} + +export default KubernetesApplicationLogsController; +angular.module('portainer.kubernetes').controller('KubernetesApplicationLogsController', KubernetesApplicationLogsController); diff --git a/app/kubernetes/views/applications/stats/stats.html b/app/kubernetes/views/applications/stats/stats.html new file mode 100644 index 0000000..1d39851 --- /dev/null +++ b/app/kubernetes/views/applications/stats/stats.html @@ -0,0 +1,118 @@ + + + + + + + + + Portainer was unable to retrieve any metrics associated to that container. Please contact your administrator to ensure that the Kubernetes metrics feature is properly + configured. + + +
+
+ +
+
+
+ +
+ About statistics +
+
+ +
+
+
+ + This view displays real-time statistics about the container {{ ctrl.state.transition.containerName | trimcontainername }}. + +
+
+
+ +
+ +
+ + + +
+
+
+ + + Network stats are unavailable for this container. + +
+
+
+
+
+
+
+ +
+
+ +
+
+
+ +
+ Memory usage +
+
+ +
+ +
+
+
+
+
+ +
+
+
+ +
+ CPU usage +
+
+ +
+ +
+
+
+
+
diff --git a/app/kubernetes/views/applications/stats/stats.js b/app/kubernetes/views/applications/stats/stats.js new file mode 100644 index 0000000..01496a9 --- /dev/null +++ b/app/kubernetes/views/applications/stats/stats.js @@ -0,0 +1,8 @@ +angular.module('portainer.kubernetes').component('kubernetesApplicationStatsView', { + templateUrl: './stats.html', + controller: 'KubernetesApplicationStatsController', + controllerAs: 'ctrl', + bindings: { + $transition$: '<', + }, +}); diff --git a/app/kubernetes/views/applications/stats/statsController.js b/app/kubernetes/views/applications/stats/statsController.js new file mode 100644 index 0000000..6c70520 --- /dev/null +++ b/app/kubernetes/views/applications/stats/statsController.js @@ -0,0 +1,173 @@ +import angular from 'angular'; +import moment from 'moment'; +import _ from 'lodash-es'; +import filesizeParser from 'filesize-parser'; +import KubernetesPodConverter from '@/kubernetes/pod/converter'; +import { getMetricsForPod } from '@/react/kubernetes/metrics/metrics.ts'; +import { parseCPU } from '@/react/kubernetes/utils'; + +class KubernetesApplicationStatsController { + /* @ngInject */ + constructor($async, $state, $interval, $document, Notifications, KubernetesPodService, KubernetesNodeService, ChartService) { + this.$async = $async; + this.$state = $state; + this.$interval = $interval; + this.$document = $document; + this.Notifications = Notifications; + this.KubernetesPodService = KubernetesPodService; + this.KubernetesNodeService = KubernetesNodeService; + this.ChartService = ChartService; + + this.onInit = this.onInit.bind(this); + this.initCharts = this.initCharts.bind(this); + } + + changeUpdateRepeater() { + var cpuChart = this.cpuChart; + var memoryChart = this.memoryChart; + + this.stopRepeater(); + this.setUpdateRepeater(cpuChart, memoryChart); + $('#refreshRateChange').show(); + $('#refreshRateChange').fadeOut(1500); + } + + updateCPUChart() { + const label = moment(this.stats.read).format('HH:mm:ss'); + + this.ChartService.UpdateCPUChart(label, this.stats.CPUUsage, this.cpuChart); + } + + updateMemoryChart() { + const label = moment(this.stats.read).format('HH:mm:ss'); + + this.ChartService.UpdateMemoryChart(label, this.stats.MemoryUsage, this.stats.MemoryCache, this.memoryChart); + } + + stopRepeater() { + var repeater = this.repeater; + if (angular.isDefined(repeater)) { + this.$interval.cancel(repeater); + } + } + + setUpdateRepeater() { + const refreshRate = this.state.refreshRate; + + this.repeater = this.$interval(async () => { + try { + await this.getStats(); + + this.updateCPUChart(); + this.updateMemoryChart(); + } catch (error) { + this.stopRepeater(); + this.Notifications.error('Failure', error); + } + }, refreshRate * 1000); + } + + initCharts() { + let i = 0; + const findCharts = setInterval(() => { + let cpuChartCtx = $('#cpuChart'); + let memoryChartCtx = $('#memoryChart'); + if (cpuChartCtx.length !== 0 && memoryChartCtx.length !== 0) { + const cpuChart = this.ChartService.CreateCPUChart(cpuChartCtx); + this.cpuChart = cpuChart; + const memoryChart = this.ChartService.CreateMemoryChart(memoryChartCtx); + this.memoryChart = memoryChart; + this.updateCPUChart(); + this.updateMemoryChart(); + this.setUpdateRepeater(); + clearInterval(findCharts); + return; + } + i++; + if (i >= 10) { + clearInterval(findCharts); + } + }, 200); + } + + getStats() { + return this.$async(async () => { + try { + const stats = await getMetricsForPod(this.$state.params.endpointId, this.state.transition.namespace, this.state.transition.podName); + const container = _.find(stats.containers, { name: this.state.transition.containerName }); + if (container) { + const memory = filesizeParser(container.usage.memory); + const cpu = parseCPU(container.usage.cpu); + this.stats = { + read: stats.timestamp, + preread: '', + MemoryCache: 0, + MemoryUsage: memory, + NumProcs: '', + isWindows: false, + PreviousCPUTotalUsage: 0, + CPUUsage: (cpu / this.nodeCPU) * 100, + CPUCores: 0, + }; + } + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to retrieve application stats'); + } + }); + } + + $onDestroy() { + this.stopRepeater(); + } + + async onInit() { + this.state = { + autoRefresh: false, + refreshRate: '30', + viewReady: false, + transition: { + podName: this.$transition$.params().pod, + containerName: this.$transition$.params().container, + namespace: this.$transition$.params().namespace, + applicationName: this.$transition$.params().name, + }, + getMetrics: false, + }; + + try { + await getMetricsForPod(this.$state.params.endpointId, this.state.transition.namespace, this.state.transition.podName); + } catch (error) { + this.state.getMetrics = false; + this.state.viewReady = true; + return; + } + + try { + const podRaw = await this.KubernetesPodService.get(this.state.transition.namespace, this.state.transition.podName); + const pod = KubernetesPodConverter.apiToModel(podRaw.Raw); + if (pod) { + const node = await this.KubernetesNodeService.get(pod.Node); + this.nodeCPU = node.CPU; + } else { + throw new Error('Unable to find pod'); + } + await this.getStats(); + this.state.getMetrics = true; + + this.$document.ready(() => { + this.initCharts(); + }); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to retrieve application stats'); + } finally { + this.state.viewReady = true; + } + } + + $onInit() { + return this.$async(this.onInit); + } +} + +export default KubernetesApplicationStatsController; +angular.module('portainer.kubernetes').controller('KubernetesApplicationStatsController', KubernetesApplicationStatsController); diff --git a/app/kubernetes/views/cluster/node/stats/stats.html b/app/kubernetes/views/cluster/node/stats/stats.html new file mode 100644 index 0000000..7cd0412 --- /dev/null +++ b/app/kubernetes/views/cluster/node/stats/stats.html @@ -0,0 +1,98 @@ + + + + + + + + Portainer was unable to retrieve any metrics associated to that node. Please contact your administrator to ensure that the Kubernetes metrics feature is properly configured. + + +
+
+ +
+
+
+ +
+ About statistics +
+
+ +
+
+
+ + This view displays real-time statistics about the node {{ ctrl.state.transition.nodeName }}. + +
+
+
+ +
+ +
+ + + +
+
+
+
+
+
+ +
+
+ +
+
+
+ +
+ Memory usage +
+
+ +
+ +
+
+
+
+
+ +
+
+
+ +
+ CPU usage +
+
+ +
+ +
+
+
+
+
diff --git a/app/kubernetes/views/cluster/node/stats/stats.js b/app/kubernetes/views/cluster/node/stats/stats.js new file mode 100644 index 0000000..98e3627 --- /dev/null +++ b/app/kubernetes/views/cluster/node/stats/stats.js @@ -0,0 +1,8 @@ +angular.module('portainer.kubernetes').component('kubernetesNodeStatsView', { + templateUrl: './stats.html', + controller: 'KubernetesNodeStatsController', + controllerAs: 'ctrl', + bindings: { + $transition$: '<', + }, +}); diff --git a/app/kubernetes/views/cluster/node/stats/statsController.js b/app/kubernetes/views/cluster/node/stats/statsController.js new file mode 100644 index 0000000..7142951 --- /dev/null +++ b/app/kubernetes/views/cluster/node/stats/statsController.js @@ -0,0 +1,147 @@ +import angular from 'angular'; +import moment from 'moment'; +import filesizeParser from 'filesize-parser'; +import { PORTAINER_FADEOUT } from '@/constants'; +import { getMetricsForNode } from '@/react/kubernetes/metrics/queries/useNodeMetricsQuery'; +import { parseCPU } from '@/react/kubernetes/utils'; + +class KubernetesNodeStatsController { + /* @ngInject */ + constructor($async, $state, $interval, $document, Notifications, KubernetesNodeService, ChartService) { + this.$async = $async; + this.$state = $state; + this.$interval = $interval; + this.$document = $document; + this.Notifications = Notifications; + this.KubernetesNodeService = KubernetesNodeService; + this.ChartService = ChartService; + + this.onInit = this.onInit.bind(this); + this.initCharts = this.initCharts.bind(this); + } + + changeUpdateRepeater() { + var cpuChart = this.cpuChart; + var memoryChart = this.memoryChart; + + this.stopRepeater(); + this.setUpdateRepeater(cpuChart, memoryChart); + $('#refreshRateChange').show(); + $('#refreshRateChange').fadeOut(PORTAINER_FADEOUT); + } + + updateCPUChart() { + const label = moment(this.stats.read).format('HH:mm:ss'); + this.ChartService.UpdateCPUChart(label, this.stats.CPUUsage, this.cpuChart); + } + + updateMemoryChart() { + const label = moment(this.stats.read).format('HH:mm:ss'); + this.ChartService.UpdateMemoryChart(label, this.stats.MemoryUsage, 0, this.memoryChart); + } + + stopRepeater() { + var repeater = this.repeater; + if (angular.isDefined(repeater)) { + this.$interval.cancel(repeater); + this.repeater = undefined; + } + } + + setUpdateRepeater() { + const refreshRate = this.state.refreshRate; + + this.repeater = this.$interval(async () => { + try { + await this.getStats(); + this.updateCPUChart(); + this.updateMemoryChart(); + } catch (error) { + this.stopRepeater(); + this.Notifications.error('Failure', error); + } + }, refreshRate * 1000); + } + + initCharts() { + const findCharts = setInterval(() => { + let cpuChartCtx = $('#cpuChart'); + let memoryChartCtx = $('#memoryChart'); + if (cpuChartCtx.length !== 0 && memoryChartCtx.length !== 0) { + const cpuChart = this.ChartService.CreateCPUChart(cpuChartCtx); + this.cpuChart = cpuChart; + const memoryChart = this.ChartService.CreateMemoryChart(memoryChartCtx); + this.memoryChart = memoryChart; + this.updateCPUChart(); + this.updateMemoryChart(); + this.setUpdateRepeater(); + clearInterval(findCharts); + } + }, 200); + } + + getStats() { + return this.$async(async () => { + try { + const stats = await getMetricsForNode(this.$state.params.endpointId, this.state.transition.nodeName); + if (stats) { + const memory = filesizeParser(stats.usage.memory); + const cpu = parseCPU(stats.usage.cpu); + this.stats = { + read: stats.metadata.creationTimestamp, + MemoryUsage: memory, + CPUUsage: (cpu / this.nodeCPU) * 100, + }; + } + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to retrieve node stats'); + } + }); + } + + $onDestroy() { + this.stopRepeater(); + } + + async onInit() { + this.state = { + autoRefresh: false, + refreshRate: '30', + viewReady: false, + transition: { + nodeName: this.$transition$.params().nodeName, + }, + getMetrics: true, + }; + + try { + const nodeMetrics = await getMetricsForNode(this.$state.params.endpointId, this.state.transition.nodeName); + + if (nodeMetrics) { + const node = await this.KubernetesNodeService.get(this.state.transition.nodeName); + this.nodeCPU = node.CPU || 1; + + await this.getStats(); + } else { + this.state.getMetrics = false; + } + } catch (err) { + this.state.getMetrics = false; + this.Notifications.error('Failure', err, 'Unable to retrieve node stats'); + } finally { + this.state.viewReady = true; + if (this.state.getMetrics) { + this.$document.ready(() => { + this.initCharts(); + }); + } + } + } + + $onInit() { + return this.$async(this.onInit); + } +} + +export default KubernetesNodeStatsController; +angular.module('portainer.kubernetes').controller('KubernetesNodeStatsController', KubernetesNodeStatsController); diff --git a/app/kubernetes/views/configurations/configmap/create/createConfigMap.html b/app/kubernetes/views/configurations/configmap/create/createConfigMap.html new file mode 100644 index 0000000..1dc37cf --- /dev/null +++ b/app/kubernetes/views/configurations/configmap/create/createConfigMap.html @@ -0,0 +1,125 @@ + + + + +
+
+
+ + +
+ +
+ +
+ +
+
+
+
+ + This namespace has exhausted its resource capacity and you will not be able to deploy the configuration. Contact your administrator to expand the capacity of the + namespace. +
+
+
+
+ + You do not have access to any namespace. Contact your administrator to get access to a namespace. +
+
+ + + +
+ +
+ +
+
+
+

This field is required.

+

This field must consist of lower case alphanumeric characters, '-' or + '.', and contain at most 63 characters, and must start and end with an alphanumeric character.

+
+

A configuration with the same name already exists inside the selected namespace.

+
+
+
+
+ + +
+ +
+ +
+ +
+ + + + + +
Actions
+
+
+ +
+
+ +
+
+
+
+
+
diff --git a/app/kubernetes/views/configurations/configmap/create/createConfigMap.js b/app/kubernetes/views/configurations/configmap/create/createConfigMap.js new file mode 100644 index 0000000..a3f14f4 --- /dev/null +++ b/app/kubernetes/views/configurations/configmap/create/createConfigMap.js @@ -0,0 +1,5 @@ +angular.module('portainer.kubernetes').component('kubernetesCreateConfigMapView', { + templateUrl: './createConfigMap.html', + controller: 'KubernetesCreateConfigMapController', + controllerAs: 'ctrl', +}); diff --git a/app/kubernetes/views/configurations/configmap/create/createConfigMapController.js b/app/kubernetes/views/configurations/configmap/create/createConfigMapController.js new file mode 100644 index 0000000..91198d1 --- /dev/null +++ b/app/kubernetes/views/configurations/configmap/create/createConfigMapController.js @@ -0,0 +1,165 @@ +import angular from 'angular'; +import _ from 'lodash-es'; +import { KubernetesConfigurationFormValues, KubernetesConfigurationFormValuesEntry } from '@/kubernetes/models/configuration/formvalues'; +import { KubernetesConfigurationKinds } from '@/kubernetes/models/configuration/models'; +import KubernetesConfigurationHelper from '@/kubernetes/helpers/configurationHelper'; +import KubernetesNamespaceHelper from '@/kubernetes/helpers/namespaceHelper'; +import { getServiceAccounts } from '@/kubernetes/rest/serviceAccount'; + +import { confirmWebEditorDiscard } from '@@/modals/confirm'; +import { isConfigurationFormValid } from '../../validation'; + +class KubernetesCreateConfigMapController { + /* @ngInject */ + constructor($async, $state, $scope, $window, Notifications, Authentication, KubernetesConfigurationService, KubernetesResourcePoolService, EndpointProvider) { + this.$async = $async; + this.$state = $state; + this.$scope = $scope; + this.$window = $window; + this.EndpointProvider = EndpointProvider; + this.Notifications = Notifications; + this.Authentication = Authentication; + this.KubernetesConfigurationService = KubernetesConfigurationService; + this.KubernetesResourcePoolService = KubernetesResourcePoolService; + this.KubernetesConfigurationKinds = KubernetesConfigurationKinds; + + this.onInit = this.onInit.bind(this); + this.createConfigurationAsync = this.createConfigurationAsync.bind(this); + this.getConfigurationsAsync = this.getConfigurationsAsync.bind(this); + this.onResourcePoolSelectionChangeAsync = this.onResourcePoolSelectionChangeAsync.bind(this); + } + + onChangeName() { + const filteredConfigurations = _.filter( + this.configurations, + (config) => config.Namespace === this.formValues.ResourcePool.Namespace.Name && config.Kind === this.formValues.Kind + ); + this.state.alreadyExist = _.find(filteredConfigurations, (config) => config.Name === this.formValues.Name) !== undefined; + } + + async onResourcePoolSelectionChangeAsync() { + try { + this.onChangeName(); + this.availableServiceAccounts = await getServiceAccounts(this.environmentId, this.formValues.ResourcePool.Namespace.Name); + this.formValues.ServiceAccountName = this.availableServiceAccounts.length > 0 ? this.availableServiceAccounts[0].metadata.name : ''; + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to load service accounts'); + } + } + onResourcePoolSelectionChange() { + this.$async(this.onResourcePoolSelectionChangeAsync); + } + + addRequiredKeysToForm(keys) { + // remove data entries that have an empty value + this.formValues.Data = this.formValues.Data.filter((entry) => entry.Value); + + keys.forEach((key) => { + // if the key doesn't exist on the form, add a new formValues.Data entry + if (!this.formValues.Data.some((data) => data.Key === key)) { + this.formValues.Data.push(new KubernetesConfigurationFormValuesEntry()); + const index = this.formValues.Data.length - 1; + this.formValues.Data[index].Key = key; + } + }); + } + + isFormValid() { + const [isValid] = isConfigurationFormValid(this.state.alreadyExist, this.state.isDataValid, this.formValues); + return isValid; + } + + async createConfigurationAsync() { + try { + this.state.actionInProgress = true; + this.formValues.ConfigurationOwner = this.Authentication.getUserDetails().username; + if (!this.formValues.IsSimple) { + this.formValues.Data = KubernetesConfigurationHelper.parseYaml(this.formValues); + } + + await this.KubernetesConfigurationService.create(this.formValues); + this.Notifications.success('Success', `ConfigMap successfully created`); + this.state.isEditorDirty = false; + this.$state.go('kubernetes.configurations', { tab: 'configmaps' }); + } catch (err) { + this.Notifications.error('Failure', err, `Unable to create ConfigMap`); + } finally { + this.state.actionInProgress = false; + } + } + + createConfiguration() { + return this.$async(this.createConfigurationAsync); + } + + async getConfigurationsAsync() { + try { + this.configurations = await this.KubernetesConfigurationService.get(); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to retrieve ConfigMaps'); + } + } + + getConfigurations() { + return this.$async(this.getConfigurationsAsync); + } + + async uiCanExit() { + if (!this.formValues.IsSimple && this.formValues.DataYaml && this.state.isEditorDirty) { + return confirmWebEditorDiscard(); + } + } + + async onInit() { + this.state = { + actionInProgress: false, + viewReady: false, + alreadyExist: false, + isDataValid: true, + isEditorDirty: false, + isDockerConfig: false, + }; + + this.formValues = new KubernetesConfigurationFormValues(); + this.formValues.Kind = KubernetesConfigurationKinds.CONFIGMAP; + this.formValues.Data = [new KubernetesConfigurationFormValuesEntry()]; + + try { + const resourcePools = await this.KubernetesResourcePoolService.get(); + this.resourcePools = _.filter( + resourcePools, + (resourcePool) => !KubernetesNamespaceHelper.isSystemNamespace(resourcePool.Namespace.Name) && resourcePool.Namespace.Status === 'Active' + ); + + this.formValues.ResourcePool = this.resourcePools[0]; + if (!this.formValues.ResourcePool) { + return; + } + + this.environmentId = this.EndpointProvider.endpointID(); + this.availableServiceAccounts = await getServiceAccounts(this.environmentId, this.resourcePools[0].Namespace.Name); + this.formValues.ServiceAccountName = this.availableServiceAccounts.length > 0 ? this.availableServiceAccounts[0].metadata.name : ''; + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to load view data'); + } finally { + this.state.viewReady = true; + } + + this.$window.onbeforeunload = () => { + if (!this.formValues.IsSimple && this.formValues.DataYaml && this.state.isEditorDirty) { + return ''; + } + }; + } + + $onDestroy() { + this.state.isEditorDirty = false; + } + + $onInit() { + return this.$async(this.onInit); + } +} + +export default KubernetesCreateConfigMapController; +angular.module('portainer.kubernetes').controller('KubernetesCreateConfigMapController', KubernetesCreateConfigMapController); diff --git a/app/kubernetes/views/configurations/configmap/edit/configMap.html b/app/kubernetes/views/configurations/configmap/edit/configMap.html new file mode 100644 index 0000000..70a2d4f --- /dev/null +++ b/app/kubernetes/views/configurations/configmap/edit/configMap.html @@ -0,0 +1,162 @@ + + + + + +
+
+
+ + + + + + + ConfigMap + +
+ + + + + + + + + + + +
Name {{ ctrl.configuration.Name }}
Namespace + {{ ctrl.configuration.Namespace }} + system +
+
+
+ + + + Events +
+ + {{ ctrl.state.eventWarningCount }} warning(s) +
+
+ +
+ + + + YAML + +
+ +
+
+
+
+
+
+
+ +
+
+ + +
+
+ +
+ + + + + + + +
Actions
+
+
+ +
+
+ +
+
+
Data
+ + + + + + + + + + + + +
KeyValue
{{ item.Key }} +
{{ item.Value }}
+
+ Copy + +
+
+
+
+
+
+
+ + + +
diff --git a/app/kubernetes/views/configurations/configmap/edit/configMap.js b/app/kubernetes/views/configurations/configmap/edit/configMap.js new file mode 100644 index 0000000..b96433b --- /dev/null +++ b/app/kubernetes/views/configurations/configmap/edit/configMap.js @@ -0,0 +1,8 @@ +angular.module('portainer.kubernetes').component('kubernetesConfigMapView', { + templateUrl: './configMap.html', + controller: 'KubernetesConfigMapController', + controllerAs: 'ctrl', + bindings: { + $transition$: '<', + }, +}); diff --git a/app/kubernetes/views/configurations/configmap/edit/configMapController.js b/app/kubernetes/views/configurations/configmap/edit/configMapController.js new file mode 100644 index 0000000..7ee1e4d --- /dev/null +++ b/app/kubernetes/views/configurations/configmap/edit/configMapController.js @@ -0,0 +1,298 @@ +import angular from 'angular'; +import _ from 'lodash-es'; + +import { KubernetesConfigurationFormValues } from '@/kubernetes/models/configuration/formvalues'; +import { KubernetesConfigurationKinds } from '@/kubernetes/models/configuration/models'; +import KubernetesConfigurationHelper from '@/kubernetes/helpers/configurationHelper'; +import KubernetesConfigurationConverter from '@/kubernetes/converters/configuration'; +import KubernetesEventHelper from '@/kubernetes/helpers/eventHelper'; +import KubernetesNamespaceHelper from '@/kubernetes/helpers/namespaceHelper'; +import { pluralize } from '@/portainer/helpers/strings'; + +import { confirmUpdate, confirmWebEditorDiscard } from '@@/modals/confirm'; +import { isConfigurationFormValid } from '../../validation'; + +class KubernetesConfigMapController { + /* @ngInject */ + constructor( + $async, + $state, + $window, + clipboard, + EndpointProvider, + Notifications, + LocalStorage, + Authentication, + KubernetesConfigurationService, + KubernetesConfigMapService, + KubernetesResourcePoolService, + KubernetesApplicationService, + KubernetesEventService + ) { + this.$async = $async; + this.$state = $state; + this.$window = $window; + this.clipboard = clipboard; + this.EndpointProvider = EndpointProvider; + this.Notifications = Notifications; + this.LocalStorage = LocalStorage; + this.Authentication = Authentication; + this.KubernetesConfigurationService = KubernetesConfigurationService; + this.KubernetesConfigMapService = KubernetesConfigMapService; + this.KubernetesResourcePoolService = KubernetesResourcePoolService; + this.KubernetesApplicationService = KubernetesApplicationService; + this.KubernetesEventService = KubernetesEventService; + this.KubernetesConfigurationKinds = KubernetesConfigurationKinds; + + this.onInit = this.onInit.bind(this); + this.getConfigurationAsync = this.getConfigurationAsync.bind(this); + this.getEvents = this.getEvents.bind(this); + this.getEventsAsync = this.getEventsAsync.bind(this); + this.getApplications = this.getApplications.bind(this); + this.getApplicationsAsync = this.getApplicationsAsync.bind(this); + this.getConfigurationsAsync = this.getConfigurationsAsync.bind(this); + this.updateConfiguration = this.updateConfiguration.bind(this); + this.updateConfigurationAsync = this.updateConfigurationAsync.bind(this); + } + + isSystemNamespace() { + return KubernetesNamespaceHelper.isSystemNamespace(this.configuration.Namespace); + } + + isSystemConfig() { + return this.isSystemNamespace(); + } + + selectTab(index) { + this.LocalStorage.storeActiveTab('configuration', index); + } + + showEditor() { + this.state.showEditorTab = true; + this.selectTab(2); + } + + copyConfigurationValue(idx) { + this.clipboard.copyText(this.formValues.Data[idx].Value); + $('#copyValueNotification_' + idx) + .show() + .fadeOut(2500); + } + + isFormValid() { + const [isValid] = isConfigurationFormValid(this.state.alreadyExist, this.state.isDataValid, this.formValues); + return isValid; + } + + // TODO: refactor + // It looks like we're still doing a create/delete process but we've decided to get rid of this + // approach. + async updateConfigurationAsync() { + try { + this.state.actionInProgress = true; + if (this.formValues.Kind !== this.configuration.Kind || this.formValues.ResourcePool !== this.configuration.Namespace || this.formValues.Name !== this.configuration.Name) { + await this.KubernetesConfigurationService.create(this.formValues); + await this.KubernetesConfigurationService.delete(this.configuration); + this.Notifications.success('Success', `ConfigMap successfully updated`); + this.$state.go( + 'kubernetes.configurations.configmap', + { + namespace: this.formValues.ResourcePool, + name: this.formValues.Name, + }, + { reload: true } + ); + } else { + await this.KubernetesConfigurationService.update(this.formValues, this.configuration); + this.Notifications.success('Success', `ConfigMap successfully updated`); + this.$state.reload(this.$state.current); + } + } catch (err) { + this.Notifications.error('Failure', err, `Unable to update ConfigMap`); + } finally { + this.state.actionInProgress = false; + } + } + + updateConfiguration() { + if (this.configuration.Used) { + confirmUpdate( + `The changes will be propagated to ${this.configuration.Applications.length} running ${pluralize( + this.configuration.Applications.length, + 'application' + )}. Are you sure you want to update this ConfigMap?`, + (confirmed) => { + if (confirmed) { + return this.$async(this.updateConfigurationAsync); + } + } + ); + } else { + return this.$async(this.updateConfigurationAsync); + } + } + + async getConfigurationAsync() { + try { + this.state.configurationLoading = true; + const name = this.$transition$.params().name; + const namespace = this.$transition$.params().namespace; + + try { + const configMap = await this.KubernetesConfigMapService.get(namespace, name); + this.configuration = KubernetesConfigurationConverter.configMapToConfiguration(configMap); + this.formValues.Data = configMap.Data; + } catch (err) { + if (err.status === 403) { + this.$state.go('kubernetes.configurations', { tab: 'configmaps' }); + throw new Error('Not authorized to edit ConfigMap', { cause: err }); + } + } + + this.formValues.ResourcePool = this.configuration.Namespace; + this.formValues.Id = this.configuration.Id; + this.formValues.Name = this.configuration.Name; + this.formValues.Type = this.configuration.Type; + this.formValues.Kind = this.configuration.Kind; + this.oldDataYaml = this.formValues.DataYaml; + this.formValues.Labels = this.configuration.Labels; + + return this.configuration; + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to retrieve ConfigMap'); + } finally { + this.state.configurationLoading = false; + } + } + + getConfiguration() { + return this.$async(this.getConfigurationAsync); + } + + async getApplicationsAsync(namespace) { + try { + this.state.applicationsLoading = true; + const applications = await this.KubernetesApplicationService.get(namespace); + this.configuration.Applications = KubernetesConfigurationHelper.getUsingApplications(this.configuration, applications); + KubernetesConfigurationHelper.setConfigurationUsed(this.configuration); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to retrieve applications'); + } finally { + this.state.applicationsLoading = false; + } + } + + getApplications(namespace) { + return this.$async(this.getApplicationsAsync, namespace); + } + + hasEventWarnings() { + return this.state.eventWarningCount; + } + + async getEventsAsync(namespace) { + try { + this.state.eventsLoading = true; + this.events = await this.KubernetesEventService.get(namespace); + this.events = _.filter(this.events, (event) => event.Involved.uid === this.configuration.Id); + this.state.eventWarningCount = KubernetesEventHelper.warningCount(this.events); + } catch (err) { + this.Notifications('Failure', err, 'Unable to retrieve events'); + } finally { + this.state.eventsLoading = false; + } + } + + getEvents(namespace) { + return this.$async(this.getEventsAsync, namespace); + } + + async getConfigurationsAsync() { + try { + this.configurations = await this.KubernetesConfigurationService.get(); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to retrieve configurations'); + } + } + + getConfigurations() { + return this.$async(this.getConfigurationsAsync); + } + + tagUsedDataKeys() { + const configName = this.$transition$.params().name; + const usedDataKeys = _.uniq( + this.configuration.Applications.flatMap((app) => + app.Env.filter((e) => e.valueFrom && e.valueFrom.configMapKeyRef && e.valueFrom.configMapKeyRef.name === configName).map((e) => e.name) + ) + ); + + this.formValues.Data = this.formValues.Data.map((variable) => { + if (!usedDataKeys.includes(variable.Key)) { + return variable; + } + + return { ...variable, Used: true }; + }); + } + + async uiCanExit() { + if (!this.formValues.IsSimple && this.formValues.DataYaml !== this.oldDataYaml && this.state.isEditorDirty) { + return confirmWebEditorDiscard(); + } + } + + async onInit() { + try { + this.state = { + actionInProgress: false, + configurationLoading: true, + applicationsLoading: true, + eventsLoading: true, + showEditorTab: false, + viewReady: false, + eventWarningCount: 0, + activeTab: 0, + currentName: this.$state.$current.name, + isDataValid: true, + isEditorDirty: false, + }; + + this.state.activeTab = this.LocalStorage.getActiveTab('configuration'); + + this.formValues = new KubernetesConfigurationFormValues(); + + const configuration = await this.getConfiguration(); + if (configuration) { + await this.getApplications(this.configuration.Namespace); + await this.getEvents(this.configuration.Namespace); + } + + this.tagUsedDataKeys(); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to load view data'); + } finally { + this.state.viewReady = true; + } + + this.$window.onbeforeunload = () => { + if (!this.formValues.IsSimple && this.formValues.DataYaml !== this.oldDataYaml && this.state.isEditorDirty) { + return ''; + } + }; + } + + $onInit() { + return this.$async(this.onInit); + } + + $onDestroy() { + if (this.state.currentName !== this.$state.$current.name) { + this.LocalStorage.storeActiveTab('configuration', 0); + } + this.state.isEditorDirty = false; + } +} + +export default KubernetesConfigMapController; +angular.module('portainer.kubernetes').controller('KubernetesConfigMapController', KubernetesConfigMapController); diff --git a/app/kubernetes/views/configurations/configurationsController.js b/app/kubernetes/views/configurations/configurationsController.js new file mode 100644 index 0000000..bcfd5bf --- /dev/null +++ b/app/kubernetes/views/configurations/configurationsController.js @@ -0,0 +1,112 @@ +import angular from 'angular'; +import { confirmDelete } from '@@/modals/confirm'; +import KubernetesConfigurationHelper from '@/kubernetes/helpers/configurationHelper'; + +class KubernetesConfigurationsController { + /* @ngInject */ + constructor($async, $state, Notifications, Authentication, KubernetesConfigurationService, KubernetesApplicationService) { + this.$async = $async; + this.$state = $state; + this.Notifications = Notifications; + this.Authentication = Authentication; + this.KubernetesConfigurationService = KubernetesConfigurationService; + this.KubernetesApplicationService = KubernetesApplicationService; + + this.onInit = this.onInit.bind(this); + this.getConfigurations = this.getConfigurations.bind(this); + this.getConfigurationsAsync = this.getConfigurationsAsync.bind(this); + this.getApplicationsAsync = this.getApplicationsAsync.bind(this); + this.removeAction = this.removeAction.bind(this); + this.removeActionAsync = this.removeActionAsync.bind(this); + this.refreshCallback = this.refreshCallback.bind(this); + this.refreshCallbackAsync = this.refreshCallbackAsync.bind(this); + } + + async getConfigurationsAsync() { + try { + this.state.configurationsLoading = true; + this.configurations = await this.KubernetesConfigurationService.get(); + KubernetesConfigurationHelper.setConfigurationsUsed(this.configurations, this.applications); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to retrieve ConfigMaps & Secrets'); + } finally { + this.state.configurationsLoading = false; + } + } + + getConfigurations() { + return this.$async(this.getConfigurationsAsync); + } + + async removeActionAsync(selectedItems) { + let actionCount = selectedItems.length; + for (const configuration of selectedItems) { + try { + await this.KubernetesConfigurationService.delete(configuration); + this.Notifications.success('ConfigMaps/Secrets successfully removed', configuration.Name); + const index = this.configurations.indexOf(configuration); + this.configurations.splice(index, 1); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to remove ConfigMaps/Secrets'); + } finally { + --actionCount; + if (actionCount === 0) { + this.$state.reload(this.$state.current); + } + } + } + } + + removeAction(selectedItems) { + confirmDelete('Do you want to remove the selected ConfigMap(s) & Secret(s)?').then((confirmed) => { + if (confirmed) { + return this.$async(this.removeActionAsync, selectedItems); + } + }); + } + + async getApplicationsAsync() { + try { + this.state.applicationsLoading = true; + this.applications = await this.KubernetesApplicationService.get(); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to retrieve applications'); + } finally { + this.state.applicationsLoading = false; + } + } + + getApplications() { + return this.$async(this.getApplicationsAsync); + } + + async refreshCallbackAsync() { + await this.getConfigurations(); + await this.getApplications(); + } + + refreshCallback() { + return this.$async(this.refreshCallbackAsync); + } + + async onInit() { + this.state = { + configurationsLoading: true, + applicationsLoading: true, + viewReady: false, + isAdmin: this.Authentication.isAdmin(), + }; + + await this.getApplications(); + await this.getConfigurations(); + + this.state.viewReady = true; + } + + $onInit() { + return this.$async(this.onInit); + } +} + +export default KubernetesConfigurationsController; +angular.module('portainer.kubernetes').controller('KubernetesConfigurationsController', KubernetesConfigurationsController); diff --git a/app/kubernetes/views/configurations/secret/create/createSecret.html b/app/kubernetes/views/configurations/secret/create/createSecret.html new file mode 100644 index 0000000..f34bc5d --- /dev/null +++ b/app/kubernetes/views/configurations/secret/create/createSecret.html @@ -0,0 +1,233 @@ + + + + +
+
+
+ + +
+ +
+ +
+ +
+
+
+
+ + This namespace has exhausted its resource capacity and you will not be able to deploy the configuration. Contact your administrator to expand the capacity of the + namespace. +
+
+
+
+ + You do not have access to any namespace. Contact your administrator to get access to a namespace. +
+
+ + + +
+ +
+ +
+
+
+

This field is required.

+

This field must consist of lower case alphanumeric characters, '-' or + '.', and contain at most 63 characters, and must start and end with an alphanumeric character.

+
+

A configuration with the same name already exists inside the selected namespace.

+
+
+
+
+ + +
+ +
+ +
+
Information
+
+
+ + + More information about types of secret can be found in the official + Kubernetes documentation. + +
+
+ +
+ +
+ + +
+
+
+ + You should only create a service account token Secret object if you can't use the TokenRequest API to obtain a token, and the security exposure of persisting a + non-expiring token credential in a readable API object is acceptable to you.
See + service account token secrets in the + kubernetes documentation.
+
+
+ + Ensure the Secret data field contains a .dockercfg key whose value is content of a legacy ~/.dockercfg file. +
+
+ + Ensure the Secret data field contains a .dockerconfigjson key whose value is content of a ~/.docker/config.json file. +
+
+ + Ensure the Secret data field contains a tls.key key and a tls.crt key. +
+
+ + Ensure the Secret data field contains a token-id key and a token-secret key. See + bootstrap token secrets in the kubernetes + documentation for optional keys. +
+
+
+ +
+ +
+
+
+

This field is required.

+

This field must consist of lower case alphanumeric characters, '-' + or '.', and contain at most 63 characters, and must start and end with an alphanumeric character.

+
+
+
+
+
+
+ +
+ +
+

This field is required.

+
+
+
+ + + +
+
+ + {{ ctrl.state.secretWarningMessage }} +
+
+
+ + + + + +
Actions
+
+
+ +
+
+ +
+
+
+
+
+
diff --git a/app/kubernetes/views/configurations/secret/create/createSecret.js b/app/kubernetes/views/configurations/secret/create/createSecret.js new file mode 100644 index 0000000..54e9e05 --- /dev/null +++ b/app/kubernetes/views/configurations/secret/create/createSecret.js @@ -0,0 +1,5 @@ +angular.module('portainer.kubernetes').component('kubernetesCreateSecretView', { + templateUrl: './createSecret.html', + controller: 'KubernetesCreateSecretController', + controllerAs: 'ctrl', +}); diff --git a/app/kubernetes/views/configurations/secret/create/createSecretController.js b/app/kubernetes/views/configurations/secret/create/createSecretController.js new file mode 100644 index 0000000..4dbfe4f --- /dev/null +++ b/app/kubernetes/views/configurations/secret/create/createSecretController.js @@ -0,0 +1,212 @@ +import angular from 'angular'; +import _ from 'lodash-es'; +import { KubernetesConfigurationFormValues, KubernetesConfigurationFormValuesEntry } from '@/kubernetes/models/configuration/formvalues'; +import { KubernetesConfigurationKinds, KubernetesSecretTypeOptions } from '@/kubernetes/models/configuration/models'; +import KubernetesConfigurationHelper from '@/kubernetes/helpers/configurationHelper'; +import KubernetesNamespaceHelper from '@/kubernetes/helpers/namespaceHelper'; +import { getServiceAccounts } from '@/kubernetes/rest/serviceAccount'; + +import { confirmWebEditorDiscard } from '@@/modals/confirm'; +import { isConfigurationFormValid } from '../../validation'; + +class KubernetesCreateSecretController { + /* @ngInject */ + constructor($async, $state, $scope, $window, Notifications, Authentication, KubernetesConfigurationService, KubernetesResourcePoolService, EndpointProvider) { + this.$async = $async; + this.$state = $state; + this.$scope = $scope; + this.$window = $window; + this.EndpointProvider = EndpointProvider; + this.Notifications = Notifications; + this.Authentication = Authentication; + this.KubernetesConfigurationService = KubernetesConfigurationService; + this.KubernetesResourcePoolService = KubernetesResourcePoolService; + this.KubernetesConfigurationKinds = KubernetesConfigurationKinds; + this.KubernetesSecretTypeOptions = KubernetesSecretTypeOptions; + + this.onInit = this.onInit.bind(this); + this.createConfigurationAsync = this.createConfigurationAsync.bind(this); + this.getConfigurationsAsync = this.getConfigurationsAsync.bind(this); + this.onResourcePoolSelectionChangeAsync = this.onResourcePoolSelectionChangeAsync.bind(this); + this.onSecretTypeChange = this.onSecretTypeChange.bind(this); + } + + onChangeName() { + const filteredConfigurations = _.filter( + this.configurations, + (config) => config.Namespace === this.formValues.ResourcePool.Namespace.Name && config.Kind === this.formValues.Kind + ); + this.state.alreadyExist = _.find(filteredConfigurations, (config) => config.Name === this.formValues.Name) !== undefined; + } + + async onResourcePoolSelectionChangeAsync() { + try { + this.onChangeName(); + this.availableServiceAccounts = await getServiceAccounts(this.environmentId, this.formValues.ResourcePool.Name); + this.formValues.ServiceAccountName = this.availableServiceAccounts.length > 0 ? this.availableServiceAccounts[0].metadata.name : ''; + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to load service accounts'); + } + } + onResourcePoolSelectionChange() { + this.$async(this.onResourcePoolSelectionChangeAsync); + } + + onSecretTypeChange() { + switch (this.formValues.Type) { + case KubernetesSecretTypeOptions.OPAQUE.value: + case KubernetesSecretTypeOptions.CUSTOM.value: + this.formValues.Data = this.formValues.Data.filter((entry) => entry.Value !== ''); + if (this.formValues.Data.length === 0) { + this.addRequiredKeysToForm(['']); + } + this.state.isDockerConfig = false; + break; + case KubernetesSecretTypeOptions.SERVICEACCOUNTTOKEN.value: + // data isn't required for service account tokens, so remove the data fields if they are empty + this.addRequiredKeysToForm([]); + this.state.isDockerConfig = false; + break; + case KubernetesSecretTypeOptions.DOCKERCONFIGJSON.value: + this.addRequiredKeysToForm(['.dockerconfigjson']); + this.state.isDockerConfig = true; + break; + case KubernetesSecretTypeOptions.DOCKERCFG.value: + this.addRequiredKeysToForm(['.dockercfg']); + this.state.isDockerConfig = true; + break; + case KubernetesSecretTypeOptions.BASICAUTH.value: + this.addRequiredKeysToForm(['username', 'password']); + this.state.isDockerConfig = false; + break; + case KubernetesSecretTypeOptions.SSHAUTH.value: + this.addRequiredKeysToForm(['ssh-privatekey']); + this.state.isDockerConfig = false; + break; + case KubernetesSecretTypeOptions.TLS.value: + this.addRequiredKeysToForm(['tls.crt', 'tls.key']); + this.state.isDockerConfig = false; + break; + case KubernetesSecretTypeOptions.BOOTSTRAPTOKEN.value: + this.addRequiredKeysToForm(['token-id', 'token-secret']); + this.state.isDockerConfig = false; + break; + default: + this.state.isDockerConfig = false; + break; + } + this.isFormValid(); + } + + addRequiredKeysToForm(keys) { + // remove data entries that have an empty value + this.formValues.Data = this.formValues.Data.filter((entry) => entry.Value); + + keys.forEach((key) => { + // if the key doesn't exist on the form, add a new formValues.Data entry + if (!this.formValues.Data.some((data) => data.Key === key)) { + this.formValues.Data.push(new KubernetesConfigurationFormValuesEntry()); + const index = this.formValues.Data.length - 1; + this.formValues.Data[index].Key = key; + } + }); + } + + isFormValid() { + const [isValid, warningMessage] = isConfigurationFormValid(this.state.alreadyExist, this.state.isDataValid, this.formValues); + this.state.secretWarningMessage = warningMessage; + return isValid; + } + + async createConfigurationAsync() { + try { + this.state.actionInProgress = true; + this.formValues.ConfigurationOwner = this.Authentication.getUserDetails().username; + if (!this.formValues.IsSimple) { + this.formValues.Data = KubernetesConfigurationHelper.parseYaml(this.formValues); + } + + await this.KubernetesConfigurationService.create(this.formValues); + + this.Notifications.success('Success', `Secret successfully created`); + this.state.isEditorDirty = false; + this.$state.go('kubernetes.configurations', { tab: 'secrets' }); + } catch (err) { + this.Notifications.error('Failure', err, `Unable to create secret`); + } finally { + this.state.actionInProgress = false; + } + } + + createConfiguration() { + return this.$async(this.createConfigurationAsync); + } + + async getConfigurationsAsync() { + try { + this.configurations = await this.KubernetesConfigurationService.get(); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to retrieve Secrets'); + } + } + + getConfigurations() { + return this.$async(this.getConfigurationsAsync); + } + + async uiCanExit() { + if (!this.formValues.IsSimple && this.formValues.DataYaml && this.state.isEditorDirty) { + return confirmWebEditorDiscard(); + } + } + + async onInit() { + this.state = { + actionInProgress: false, + viewReady: false, + alreadyExist: false, + isDataValid: true, + isEditorDirty: false, + isDockerConfig: false, + secretWarningMessage: '', + }; + + this.formValues = new KubernetesConfigurationFormValues(); + this.formValues.Kind = KubernetesConfigurationKinds.SECRET; + this.formValues.Data = [new KubernetesConfigurationFormValuesEntry()]; + + try { + const resourcePools = await this.KubernetesResourcePoolService.get(); + this.resourcePools = _.filter( + resourcePools, + (resourcePool) => !KubernetesNamespaceHelper.isSystemNamespace(resourcePool.Namespace.Name) && resourcePool.Namespace.Status === 'Active' + ); + + this.formValues.ResourcePool = this.resourcePools[0]; + this.environmentId = this.EndpointProvider.endpointID(); + this.availableServiceAccounts = await getServiceAccounts(this.environmentId, this.resourcePools[0].Namespace.Name); + this.formValues.ServiceAccountName = this.availableServiceAccounts.length > 0 ? this.availableServiceAccounts[0].metadata.name : ''; + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to load view data'); + } finally { + this.state.viewReady = true; + } + + this.$window.onbeforeunload = () => { + if (!this.formValues.IsSimple && this.formValues.DataYaml && this.state.isEditorDirty) { + return ''; + } + }; + } + + $onInit() { + return this.$async(this.onInit); + } + + $onDestroy() { + this.state.isEditorDirty = false; + } +} + +export default KubernetesCreateSecretController; +angular.module('portainer.kubernetes').controller('KubernetesCreateSecretController', KubernetesCreateSecretController); diff --git a/app/kubernetes/views/configurations/secret/edit/secret.html b/app/kubernetes/views/configurations/secret/edit/secret.html new file mode 100644 index 0000000..6b0ec77 --- /dev/null +++ b/app/kubernetes/views/configurations/secret/edit/secret.html @@ -0,0 +1,120 @@ + + + + + +
+ + +
+
+ + +
+
+ +
+ + + +
+
+ + {{ ctrl.state.secretWarningMessage }} +
+
+ + + + + +
Actions
+
+
+ +
+
+ +
+
+
Data
+ + + + + + + + + + + + +
KeyValue
{{ item.Key }} +
{{ item.Value }}
+
+ Copy + +
+
+
+
+
+
+
+ + + +
diff --git a/app/kubernetes/views/configurations/secret/edit/secret.js b/app/kubernetes/views/configurations/secret/edit/secret.js new file mode 100644 index 0000000..f59157d --- /dev/null +++ b/app/kubernetes/views/configurations/secret/edit/secret.js @@ -0,0 +1,8 @@ +angular.module('portainer.kubernetes').component('kubernetesSecretView', { + templateUrl: './secret.html', + controller: 'KubernetesSecretController', + controllerAs: 'ctrl', + bindings: { + $transition$: '<', + }, +}); diff --git a/app/kubernetes/views/configurations/secret/edit/secretController.js b/app/kubernetes/views/configurations/secret/edit/secretController.js new file mode 100644 index 0000000..6e02e0b --- /dev/null +++ b/app/kubernetes/views/configurations/secret/edit/secretController.js @@ -0,0 +1,326 @@ +import angular from 'angular'; +import _ from 'lodash-es'; + +import { KubernetesConfigurationFormValues } from '@/kubernetes/models/configuration/formvalues'; +import { KubernetesConfigurationKinds, KubernetesSecretTypeOptions } from '@/kubernetes/models/configuration/models'; +import KubernetesConfigurationHelper from '@/kubernetes/helpers/configurationHelper'; +import KubernetesConfigurationConverter from '@/kubernetes/converters/configuration'; +import KubernetesEventHelper from '@/kubernetes/helpers/eventHelper'; +import KubernetesNamespaceHelper from '@/kubernetes/helpers/namespaceHelper'; + +import { pluralize } from '@/portainer/helpers/strings'; + +import { confirmUpdate, confirmWebEditorDiscard } from '@@/modals/confirm'; +import { isConfigurationFormValid } from '../../validation'; +import { getConfigurationActions } from '../../../../../react/kubernetes/configs/secrets/summary-utils'; + +class KubernetesSecretController { + /* @ngInject */ + constructor( + $async, + $scope, + $state, + $window, + clipboard, + Notifications, + LocalStorage, + KubernetesConfigurationService, + KubernetesSecretService, + KubernetesResourcePoolService, + KubernetesApplicationService, + KubernetesEventService + ) { + this.$async = $async; + this.$scope = $scope; + this.$state = $state; + this.$window = $window; + this.clipboard = clipboard; + this.Notifications = Notifications; + this.LocalStorage = LocalStorage; + this.KubernetesConfigurationService = KubernetesConfigurationService; + this.KubernetesResourcePoolService = KubernetesResourcePoolService; + this.KubernetesApplicationService = KubernetesApplicationService; + this.KubernetesEventService = KubernetesEventService; + this.KubernetesConfigurationKinds = KubernetesConfigurationKinds; + this.KubernetesSecretTypeOptions = KubernetesSecretTypeOptions; + this.KubernetesSecretService = KubernetesSecretService; + + this.onInit = this.onInit.bind(this); + this.getConfigurationAsync = this.getConfigurationAsync.bind(this); + this.getEvents = this.getEvents.bind(this); + this.getEventsAsync = this.getEventsAsync.bind(this); + this.getApplications = this.getApplications.bind(this); + this.getApplicationsAsync = this.getApplicationsAsync.bind(this); + this.updateConfiguration = this.updateConfiguration.bind(this); + this.updateConfigurationAsync = this.updateConfigurationAsync.bind(this); + } + + isSystemNamespace() { + return KubernetesNamespaceHelper.isSystemNamespace(this.configuration.Namespace); + } + + getRegistryId() { + const id = this.configuration?.Annotations?.['portainer.io/registry.id']; + return id ? parseInt(id, 10) || undefined : undefined; + } + + isSystemConfig() { + return this.isSystemNamespace() || this.configuration.IsRegistrySecret; + } + + selectTab(index) { + this.LocalStorage.storeActiveTab('configuration', index); + } + + showEditor() { + this.state.showEditorTab = true; + this.selectTab(2); + } + + copyConfigurationValue(idx) { + this.clipboard.copyText(this.formValues.Data[idx].Value); + $('#copyValueNotification_' + idx) + .show() + .fadeOut(2500); + } + + isFormValid() { + const [isValid, warningMessage] = isConfigurationFormValid(this.state.alreadyExist, this.state.isDataValid, this.formValues); + this.state.secretWarningMessage = warningMessage; + return isValid; + } + + // TODO: refactor + // It looks like we're still doing a create/delete process but we've decided to get rid of this + // approach. + async updateConfigurationAsync() { + try { + this.state.actionInProgress = true; + if (this.formValues.Kind !== this.configuration.Kind || this.formValues.ResourcePool !== this.configuration.Namespace || this.formValues.Name !== this.configuration.Name) { + await this.KubernetesConfigurationService.create(this.formValues); + await this.KubernetesConfigurationService.delete(this.configuration); + this.Notifications.success('Success', `Secret successfully updated`); + this.$state.go( + 'kubernetes.secrets.secret', + { + namespace: this.formValues.ResourcePool, + name: this.formValues.Name, + }, + { reload: true } + ); + } else { + await this.KubernetesConfigurationService.update(this.formValues, this.configuration); + this.Notifications.success('Success', `Secret successfully updated`); + this.$state.reload(this.$state.current); + } + } catch (err) { + this.Notifications.error('Failure', err, `Unable to update Secret`); + } finally { + this.state.actionInProgress = false; + } + } + + updateConfiguration() { + if (this.configuration.Used) { + confirmUpdate( + `The changes will be propagated to ${this.configuration.Applications.length} running ${pluralize( + this.configuration.Applications.length, + 'application' + )}. Are you sure you want to update this Secret?`, + (confirmed) => { + if (confirmed) { + return this.$async(this.updateConfigurationAsync); + } + } + ); + } else { + return this.$async(this.updateConfigurationAsync); + } + } + + async getConfigurationAsync() { + try { + this.state.configurationLoading = true; + const name = this.$transition$.params().name; + const namespace = this.$transition$.params().namespace; + try { + const secret = await this.KubernetesSecretService.get(namespace, name); + this.configuration = KubernetesConfigurationConverter.secretToConfiguration(secret); + this.formValues.Data = secret.Data; + } catch (err) { + if (err.status === 403) { + this.$state.go('kubernetes.configurations', { tab: 'secrets' }); + throw new Error('Not authorized to edit secret', { cause: err }); + } + } + this.formValues.ResourcePool = this.configuration.Namespace; + this.formValues.Id = this.configuration.Id; + this.formValues.Name = this.configuration.Name; + this.formValues.Type = this.configuration.Type; + this.formValues.Kind = this.configuration.Kind; + this.oldDataYaml = this.formValues.DataYaml; + this.formValues.Labels = this.configuration.Labels; + + return this.configuration; + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to retrieve secret'); + } finally { + this.state.configurationLoading = false; + } + } + + getConfiguration() { + return this.$async(this.getConfigurationAsync); + } + + async getApplicationsAsync(namespace) { + try { + this.state.applicationsLoading = true; + const applications = await this.KubernetesApplicationService.get(namespace); + this.configuration.Applications = KubernetesConfigurationHelper.getUsingApplications(this.configuration, applications); + KubernetesConfigurationHelper.setConfigurationUsed(this.configuration); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to retrieve applications'); + } finally { + this.state.applicationsLoading = false; + } + } + + getApplications(namespace) { + return this.$async(this.getApplicationsAsync, namespace); + } + + hasEventWarnings() { + return this.state.eventWarningCount; + } + + async getEventsAsync(namespace) { + try { + this.state.eventsLoading = true; + this.events = await this.KubernetesEventService.get(namespace); + this.events = _.filter(this.events, (event) => event.Involved.uid === this.configuration.Id); + this.state.eventWarningCount = KubernetesEventHelper.warningCount(this.events); + } catch (err) { + this.Notifications('Failure', err, 'Unable to retrieve events'); + } finally { + this.state.eventsLoading = false; + } + } + + getEvents(namespace) { + return this.$async(this.getEventsAsync, namespace); + } + + tagUsedDataKeys() { + const configName = this.$transition$.params().name; + const usedDataKeys = _.uniq( + this.configuration.Applications.flatMap((app) => + app.Env.filter((e) => e.valueFrom && e.valueFrom.configMapKeyRef && e.valueFrom.configMapKeyRef.name === configName).map((e) => e.name) + ) + ); + + this.formValues.Data = this.formValues.Data.map((variable) => { + if (!usedDataKeys.includes(variable.Key)) { + return variable; + } + + return { ...variable, Used: true }; + }); + } + + async uiCanExit() { + if (!this.formValues.IsSimple && this.formValues.DataYaml !== this.oldDataYaml && this.state.isEditorDirty) { + return confirmWebEditorDiscard(); + } + } + + async onInit() { + try { + this.state = { + actionInProgress: false, + configurationLoading: true, + applicationsLoading: true, + eventsLoading: true, + showEditorTab: false, + viewReady: false, + eventWarningCount: 0, + activeTab: 0, + currentName: this.$state.$current.name, + isDataValid: true, + isEditorDirty: false, + isDockerConfig: false, + secretWarningMessage: '', + summaryActions: [], + }; + + this.state.activeTab = this.LocalStorage.getActiveTab('configuration'); + + this.formValues = new KubernetesConfigurationFormValues(); + + const configuration = await this.getConfiguration(); + if (configuration) { + await this.getApplications(this.configuration.Namespace); + await this.getEvents(this.configuration.Namespace); + } + + // after loading the configuration, check if it is a docker config secret type + if ( + this.formValues.Kind === this.KubernetesConfigurationKinds.SECRET && + (this.formValues.Type === this.KubernetesSecretTypeOptions.DOCKERCONFIGJSON.value || this.formValues.Type === this.KubernetesSecretTypeOptions.DOCKERCFG.value) + ) { + this.state.isDockerConfig = true; + } + // convert the secret type to a human readable value + if (this.formValues.Type) { + const secretTypeValues = Object.values(this.KubernetesSecretTypeOptions); + const secretType = secretTypeValues.find((secretType) => secretType.value === this.formValues.Type); + this.secretTypeName = secretType ? secretType.name : this.formValues.Type; + } else { + this.secretTypeName = ''; + } + + if (this.formValues.Type === this.KubernetesSecretTypeOptions.SERVICEACCOUNTTOKEN.value) { + this.formValues.ServiceAccountName = configuration.ServiceAccountName; + } + + this.tagUsedDataKeys(); + + // Watch formValues for changes and cache the computed summary actions + // Use $watchCollection to avoid infinite digest loops + this.$scope.$watchCollection( + () => this.formValues?.Data, + () => { + this.state.summaryActions = getConfigurationActions({ + isCreate: !this.configuration.Id, + secretName: this.formValues.Name, + secretType: this.formValues.Type, + }); + } + ); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to load view data'); + } finally { + this.state.viewReady = true; + } + + this.$window.onbeforeunload = () => { + if (!this.formValues.IsSimple && this.formValues.DataYaml !== this.oldDataYaml && this.state.isEditorDirty) { + return ''; + } + }; + } + + $onInit() { + return this.$async(this.onInit); + } + + $onDestroy() { + if (this.state.currentName !== this.$state.$current.name) { + this.LocalStorage.storeActiveTab('configuration', 0); + } + this.state.isEditorDirty = false; + } +} + +export default KubernetesSecretController; +angular.module('portainer.kubernetes').controller('KubernetesSecretController', KubernetesSecretController); diff --git a/app/kubernetes/views/configurations/validation.js b/app/kubernetes/views/configurations/validation.js new file mode 100644 index 0000000..4702aa1 --- /dev/null +++ b/app/kubernetes/views/configurations/validation.js @@ -0,0 +1,58 @@ +import { KubernetesSecretTypeOptions } from '@/kubernetes/models/configuration/models'; +import { KubernetesConfigurationKinds } from '@/kubernetes/models/configuration/models'; + +export function isConfigurationFormValid(alreadyExist, isDataValid, formValues) { + const uniqueCheck = !alreadyExist && isDataValid; + let secretWarningMessage = ''; + let isFormValid = false; + + if (formValues.IsSimple) { + if (formValues.Kind === KubernetesConfigurationKinds.SECRET) { + let isSecretDataValid = true; + + switch (formValues.Type) { + case KubernetesSecretTypeOptions.SERVICEACCOUNTTOKEN.value: + // data isn't required for service account tokens + isFormValid = uniqueCheck && formValues.ResourcePool; + return [isFormValid, '']; + case KubernetesSecretTypeOptions.DOCKERCFG.value: + // needs to contain a .dockercfg key + isSecretDataValid = formValues.Data.some((entry) => entry.Key === '.dockercfg'); + secretWarningMessage = isSecretDataValid ? '' : 'A data entry with a .dockercfg key is required.'; + break; + case KubernetesSecretTypeOptions.DOCKERCONFIGJSON.value: + // needs to contain a .dockerconfigjson key + isSecretDataValid = formValues.Data.some((entry) => entry.Key === '.dockerconfigjson'); + secretWarningMessage = isSecretDataValid ? '' : 'A data entry with a .dockerconfigjson key. is required.'; + break; + case KubernetesSecretTypeOptions.BASICAUTH.value: + isSecretDataValid = formValues.Data.some((entry) => entry.Key === 'username' || entry.Key === 'password'); + secretWarningMessage = isSecretDataValid ? '' : 'A data entry with a username or password key is required.'; + break; + case KubernetesSecretTypeOptions.SSHAUTH.value: + isSecretDataValid = formValues.Data.some((entry) => entry.Key === 'ssh-privatekey'); + secretWarningMessage = isSecretDataValid ? '' : `A data entry with a 'ssh-privatekey' key is required.`; + break; + case KubernetesSecretTypeOptions.TLS.value: + isSecretDataValid = formValues.Data.some((entry) => entry.Key === 'tls.crt') && formValues.Data.some((entry) => entry.Key === 'tls.key'); + secretWarningMessage = isSecretDataValid ? '' : `Data entries containing a 'tls.crt' key and a 'tls.key' key are required.`; + break; + case KubernetesSecretTypeOptions.BOOTSTRAPTOKEN.value: + isSecretDataValid = formValues.Data.some((entry) => entry.Key === 'token-id') && formValues.Data.some((entry) => entry.Key === 'token-secret'); + secretWarningMessage = isSecretDataValid ? '' : `Data entries containing a 'token-id' key and a 'token-secret' key are required.`; + break; + default: + break; + } + + isFormValid = uniqueCheck && formValues.ResourcePool && formValues.Data.length >= 1 && isSecretDataValid; + return [isFormValid, secretWarningMessage]; + } + + isFormValid = formValues.Data.length > 0 && uniqueCheck && formValues.ResourcePool; + return [isFormValid, secretWarningMessage]; + } + + isFormValid = uniqueCheck && formValues.ResourcePool; + return [isFormValid, secretWarningMessage]; +} diff --git a/app/kubernetes/views/deploy/deploy.html b/app/kubernetes/views/deploy/deploy.html new file mode 100644 index 0000000..b5b057c --- /dev/null +++ b/app/kubernetes/views/deploy/deploy.html @@ -0,0 +1,216 @@ + + + + +
+ + +
+
+ + + + + Deploy +
+
Deploy from
+ + + +
Deploy to
+
+ +
+ +
+
+ +
+
+
+ +
+
+ +
+
+ +
+ Namespaces specified in the manifest will be used +
+
+ +
+ +
Resource names specified in the manifest will be used
+
+ +
+
+ +
+ +
+ + + + + + +
+
Custom template
+ + + + +
+ + +
+
+ +
+ + +

+ + This feature allows you to deploy any kind of Kubernetes resource in this environment (Deployment, Secret, ConfigMap...). +

+

+ You can get more information about Kubernetes file format in the + official documentation. +

+
+
+
+ + + +
+
URL
+
+
+ + Specify the URL to the + Kubernetes manifest. +
+
+
+ +
+ +
+
+
+ + + +
Actions
+
+
+ +
+
+ +
+
+ + + Logs +
+
+
+ +
+
+
+
+
+
+
+
+
+
diff --git a/app/kubernetes/views/deploy/deploy.js b/app/kubernetes/views/deploy/deploy.js new file mode 100644 index 0000000..d7e45f4 --- /dev/null +++ b/app/kubernetes/views/deploy/deploy.js @@ -0,0 +1,8 @@ +angular.module('portainer.kubernetes').component('kubernetesDeployView', { + templateUrl: './deploy.html', + controller: 'KubernetesDeployController', + controllerAs: 'ctrl', + bindings: { + endpoint: '<', + }, +}); diff --git a/app/kubernetes/views/deploy/deployController.js b/app/kubernetes/views/deploy/deployController.js new file mode 100644 index 0000000..96d9899 --- /dev/null +++ b/app/kubernetes/views/deploy/deployController.js @@ -0,0 +1,430 @@ +import angular from 'angular'; +import _ from 'lodash-es'; +import stripAnsi from 'strip-ansi'; + +import PortainerError from '@/portainer/error'; +import { KubernetesDeployManifestTypes, KubernetesDeployBuildMethods, KubernetesDeployRequestMethods, RepositoryMechanismTypes } from '@/kubernetes/models/deploy'; +import { isTemplateVariablesEnabled, renderTemplate } from '@/react/portainer/custom-templates/components/utils'; +import { getDeploymentOptions } from '@/react/portainer/environments/environment.service'; +import { parseAutoUpdateResponse, transformAutoUpdateViewModel } from '@/react/portainer/gitops/AutoUpdateFieldset/utils'; +import { baseStackWebhookUrl, createWebhookId } from '@/portainer/helpers/webhookHelper'; +import { getVariablesFieldDefaultValues } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField'; +import { KUBE_STACK_NAME_VALIDATION_REGEX } from '@/react/kubernetes/DeployView/StackName/constants'; +import { confirmWebEditorDiscard } from '@@/modals/confirm'; +import { editor, git, customTemplate, url } from '@@/BoxSelector/common-options/build-methods'; +import { kubernetes } from '@@/BoxSelector/common-options/deployment-methods'; + +class KubernetesDeployController { + /* @ngInject */ + constructor($scope, $async, $state, $window, Authentication, Notifications, KubernetesResourcePoolService, StackService, CustomTemplateService, KubernetesApplicationService) { + this.$scope = $scope; + this.$async = $async; + this.$state = $state; + this.$window = $window; + this.Authentication = Authentication; + this.Notifications = Notifications; + this.KubernetesResourcePoolService = KubernetesResourcePoolService; + this.StackService = StackService; + this.CustomTemplateService = CustomTemplateService; + this.KubernetesApplicationService = KubernetesApplicationService; + + this.isTemplateVariablesEnabled = isTemplateVariablesEnabled; + + this.deployOptions = [{ ...kubernetes, value: KubernetesDeployManifestTypes.KUBERNETES }]; + + this.methodOptions = [ + { ...git, value: KubernetesDeployBuildMethods.GIT }, + { ...editor, value: KubernetesDeployBuildMethods.WEB_EDITOR }, + { ...url, value: KubernetesDeployBuildMethods.URL }, + { ...customTemplate, value: KubernetesDeployBuildMethods.CUSTOM_TEMPLATE }, + ]; + + let buildMethod = Number(this.$state.params.buildMethod) || KubernetesDeployBuildMethods.GIT; + if (buildMethod > Object.keys(KubernetesDeployBuildMethods).length) { + buildMethod = KubernetesDeployBuildMethods.GIT; + } + + this.state = { + DeployType: buildMethod, + BuildMethod: KubernetesDeployBuildMethods.GIT, + tabLogsDisabled: true, + activeTab: 0, + viewReady: false, + isEditorDirty: false, + templateId: null, + template: null, + baseWebhookUrl: baseStackWebhookUrl(), + webhookId: createWebhookId(), + templateLoadFailed: false, + isEditorReadOnly: false, + selectedHelmChart: '', + stackNameError: '', + }; + + this.currentUser = { + isAdmin: false, + id: null, + }; + + this.formValues = { + StackName: '', + SourceId: undefined, + RepositoryURL: '', + RepositoryReferenceName: '', + RepositoryAuthentication: false, + RepositoryUsername: '', + RepositoryPassword: '', + AdditionalFiles: [], + ComposeFilePathInRepository: '', + Variables: [], + AutoUpdate: parseAutoUpdateResponse(), + TLSSkipVerify: false, + Name: '', + }; + + this.stacks = []; + + this.ManifestDeployTypes = KubernetesDeployManifestTypes; + this.BuildMethods = KubernetesDeployBuildMethods; + + this.onSelectHelmChart = this.onSelectHelmChart.bind(this); + this.onChangeTemplateId = this.onChangeTemplateId.bind(this); + this.deployAsync = this.deployAsync.bind(this); + this.onChangeFileContent = this.onChangeFileContent.bind(this); + this.getNamespacesAsync = this.getNamespacesAsync.bind(this); + this.onChangeFormValues = this.onChangeFormValues.bind(this); + this.buildAnalyticsProperties = this.buildAnalyticsProperties.bind(this); + this.onChangeMethod = this.onChangeMethod.bind(this); + this.onChangeDeployType = this.onChangeDeployType.bind(this); + this.onChangeTemplateVariables = this.onChangeTemplateVariables.bind(this); + this.setStackName = this.setStackName.bind(this); + this.onChangeNamespace = this.onChangeNamespace.bind(this); + } + + onChangeNamespace(namespaceName) { + return this.$async(async () => { + this.formValues.Namespace = namespaceName; + const applications = await this.KubernetesApplicationService.get(namespaceName); + const stacks = _.map(applications, (item) => item.StackName).filter((item) => item !== ''); + this.stacks = _.uniq(stacks); + }); + } + + onSelectHelmChart(chart) { + this.state.selectedHelmChart = chart; + + // Force a digest cycle to ensure the change is reflected in the UI + this.$scope.$apply(); + } + + onChangeTemplateVariables(value) { + this.onChangeFormValues({ Variables: value }); + + this.renderTemplate(); + } + + setStackName(name) { + return this.$async(async () => { + if (KUBE_STACK_NAME_VALIDATION_REGEX.test(name) || name === '') { + this.state.stackNameError = ''; + } else { + this.state.stackNameError = + "Stack must consist of alphanumeric characters, '-', '_' or '.', must start and end with an alphanumeric character and must be 63 characters or less (e.g. 'my-name', or 'abc-123')."; + } + + this.formValues.StackName = name; + }); + } + + renderTemplate() { + if (!this.isTemplateVariablesEnabled) { + return; + } + + const rendered = renderTemplate(this.state.templateContent, this.formValues.Variables, this.state.template.Variables); + this.onChangeFormValues({ EditorContent: rendered }); + } + + buildAnalyticsProperties() { + const metadata = { + type: buildLabel(this.state.BuildMethod), + format: formatLabel(this.state.DeployType), + role: roleLabel(this.currentUser.isAdmin), + 'automatic-updates': automaticUpdatesLabel(this.formValues.RepositoryAutomaticUpdates, this.formValues.RepositoryMechanism), + }; + + if (this.state.BuildMethod === KubernetesDeployBuildMethods.GIT) { + metadata.auth = this.formValues.RepositoryAuthentication; + } + + return { metadata }; + + function automaticUpdatesLabel(repositoryAutomaticUpdates, repositoryMechanism) { + switch (repositoryAutomaticUpdates && repositoryMechanism) { + case RepositoryMechanismTypes.INTERVAL: + return 'polling'; + case RepositoryMechanismTypes.WEBHOOK: + return 'webhook'; + default: + return 'off'; + } + } + + function roleLabel(isAdmin) { + if (isAdmin) { + return 'admin'; + } + + return 'standard'; + } + + function buildLabel(buildMethod) { + switch (buildMethod) { + case KubernetesDeployBuildMethods.GIT: + return 'git'; + case KubernetesDeployBuildMethods.WEB_EDITOR: + return 'web-editor'; + } + } + + function formatLabel(format) { + switch (format) { + case KubernetesDeployManifestTypes.COMPOSE: + return 'compose'; + case KubernetesDeployManifestTypes.KUBERNETES: + return 'manifest'; + } + } + } + + onChangeMethod(method) { + return this.$async(async () => { + this.state.BuildMethod = method; + }); + } + + onChangeDeployType(type) { + return this.$async(async () => { + this.state.DeployType = type; + }); + } + + disableDeploy() { + const isWebEditorInvalid = this.state.BuildMethod === KubernetesDeployBuildMethods.WEB_EDITOR && _.isEmpty(this.formValues.EditorContent); + const isURLFormInvalid = this.state.BuildMethod === KubernetesDeployBuildMethods.URL && _.isEmpty(this.formValues.ManifestURL); + const isCustomTemplateInvalid = this.state.BuildMethod === KubernetesDeployBuildMethods.CUSTOM_TEMPLATE && _.isEmpty(this.formValues.EditorContent); + const isNamespaceInvalid = _.isEmpty(this.formValues.Namespace); + const isStackNameInvalid = this.state.stackNameError !== ''; + return isWebEditorInvalid || isURLFormInvalid || isCustomTemplateInvalid || this.state.actionInProgress || isNamespaceInvalid || isStackNameInvalid; + } + + onChangeFormValues(newValues) { + return this.$async(async () => { + this.formValues = { + ...this.formValues, + ...newValues, + }; + }); + } + + onChangeTemplateId(templateId, template) { + return this.$async(async () => { + if (!template || (this.state.templateId === templateId && this.state.template === template)) { + return; + } + + this.state.templateId = templateId; + this.state.template = template; + + try { + try { + this.state.templateContent = await this.CustomTemplateService.customTemplateFile(templateId, template.GitConfig !== null); + this.onChangeFileContent(this.state.templateContent); + + this.state.isEditorReadOnly = false; + } catch (err) { + this.state.templateLoadFailed = true; + throw err; + } + + if (template.Variables && template.Variables.length > 0) { + const variables = getVariablesFieldDefaultValues(template.Variables); + this.onChangeTemplateVariables(variables); + } + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to load template file'); + } + }); + } + + onChangeFileContent(value) { + this.formValues.EditorContent = value; + this.state.isEditorDirty = true; + } + + displayErrorLog(log) { + this.errorLog = stripAnsi(log); + this.state.tabLogsDisabled = false; + this.state.activeTab = 1; + } + + async deployAsync() { + this.errorLog = ''; + this.state.actionInProgress = true; + + try { + let method; + let composeFormat = this.state.DeployType === this.ManifestDeployTypes.COMPOSE; + + switch (this.state.BuildMethod) { + case this.BuildMethods.GIT: + method = KubernetesDeployRequestMethods.REPOSITORY; + break; + case this.BuildMethods.WEB_EDITOR: + method = KubernetesDeployRequestMethods.STRING; + break; + case KubernetesDeployBuildMethods.CUSTOM_TEMPLATE: + method = KubernetesDeployRequestMethods.STRING; + composeFormat = false; + break; + case this.BuildMethods.URL: + method = KubernetesDeployRequestMethods.URL; + break; + default: + throw new PortainerError('Unable to determine build method'); + } + + let deployNamespace = ''; + + if (this.formValues.namespace_toggle) { + deployNamespace = ''; + } else { + deployNamespace = this.formValues.Namespace; + } + + const payload = { + ComposeFormat: composeFormat, + Namespace: deployNamespace, + StackName: this.formValues.StackName, + }; + + if (method === KubernetesDeployRequestMethods.REPOSITORY) { + payload.RepositoryReferenceName = this.formValues.RepositoryReferenceName; + if (this.formValues.SourceId) { + payload.SourceId = this.formValues.SourceId; + } else { + payload.TLSSkipVerify = this.formValues.TLSSkipVerify; + payload.RepositoryURL = this.formValues.RepositoryURL; + payload.RepositoryAuthentication = this.formValues.RepositoryAuthentication ? true : false; + if (payload.RepositoryAuthentication) { + payload.RepositoryUsername = this.formValues.RepositoryUsername; + payload.RepositoryPassword = this.formValues.RepositoryPassword; + } + } + payload.ManifestFile = this.formValues.ComposeFilePathInRepository; + payload.AdditionalFiles = this.formValues.AdditionalFiles; + payload.AutoUpdate = transformAutoUpdateViewModel(this.formValues.AutoUpdate, this.state.webhookId); + } else if (method === KubernetesDeployRequestMethods.STRING) { + payload.StackFileContent = this.formValues.EditorContent; + } else { + payload.ManifestURL = this.formValues.ManifestURL; + } + + await this.StackService.kubernetesDeploy(this.endpoint.Id, method, payload); + + this.Notifications.success('Success', 'Request to deploy manifest successfully submitted'); + this.state.isEditorDirty = false; + + if (this.$state.params.referrer && this.$state.params.tab) { + this.$state.go(this.$state.params.referrer, { tab: this.$state.params.tab }); + return; + } + + if (this.$state.params.referrer) { + this.$state.go(this.$state.params.referrer); + return; + } + + this.$state.go('kubernetes.applications'); + } catch (err) { + this.Notifications.error('Unable to deploy manifest', err, 'Unable to deploy resources'); + this.displayErrorLog(err.err.data.details); + } finally { + this.state.actionInProgress = false; + } + } + + deploy() { + return this.$async(this.deployAsync); + } + + async getNamespacesAsync() { + try { + const pools = await this.KubernetesResourcePoolService.get(); + let namespaces = pools.filter((pool) => pool.Namespace.Status === 'Active'); + namespaces = _.map(namespaces, 'Namespace').sort((a, b) => { + if (a.Name === 'default') { + return -1; + } + if (b.Name === 'default') { + return 1; + } + return 0; + }); + + this.namespaces = namespaces; + if (this.namespaces.length > 0) { + this.formValues.Namespace = this.namespaces[0].Name; + } + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to load namespaces data'); + } + } + + getNamespaces() { + return this.$async(this.getNamespacesAsync); + } + + async uiCanExit() { + if (this.formValues.EditorContent && this.state.isEditorDirty) { + return confirmWebEditorDiscard(); + } + } + + $onInit() { + return this.$async(async () => { + this.currentUser.isAdmin = this.Authentication.isAdmin(); + this.currentUser.id = this.Authentication.getUserDetails().ID; + + this.formValues.namespace_toggle = false; + await this.getNamespaces(); + + this.deploymentOptions = await getDeploymentOptions(this.endpoint.Id); + + if (this.$state.params.templateId) { + const templateId = parseInt(this.$state.params.templateId, 10); + if (templateId && !Number.isNaN(templateId)) { + this.state.BuildMethod = KubernetesDeployBuildMethods.CUSTOM_TEMPLATE; + this.state.templateId = templateId; + } + } + + this.onChangeNamespace(this.formValues.Namespace); + + this.state.viewReady = true; + + this.$window.onbeforeunload = () => { + if (this.formValues.EditorContent && this.state.isEditorDirty) { + return ''; + } + }; + }); + } + + $onDestroy() { + this.state.isEditorDirty = false; + } +} + +export default KubernetesDeployController; +angular.module('portainer.kubernetes').controller('KubernetesDeployController', KubernetesDeployController); diff --git a/app/kubernetes/views/kubernetes.css b/app/kubernetes/views/kubernetes.css new file mode 100644 index 0000000..de5aefd --- /dev/null +++ b/app/kubernetes/views/kubernetes.css @@ -0,0 +1,23 @@ +.service-form .form-group { + vertical-align: top; +} + +.service-form .form-group .input-group { + width: 100%; +} + +.service-form .clear-both { + clear: both; +} + +.service-form .form-group { + vertical-align: top; +} + +.service-form .form-group .input-group { + width: 100%; +} + +.service-form .clear-both { + clear: both; +} diff --git a/app/kubernetes/views/security-constraint/constraint.html b/app/kubernetes/views/security-constraint/constraint.html new file mode 100644 index 0000000..42834ac --- /dev/null +++ b/app/kubernetes/views/security-constraint/constraint.html @@ -0,0 +1,40 @@ + + + + +
+
+
+ + + +
+ +
+
+ + +
+
+
+
+
+
+
+
diff --git a/app/kubernetes/views/security-constraint/constraintController.js b/app/kubernetes/views/security-constraint/constraintController.js new file mode 100644 index 0000000..d8a8b94 --- /dev/null +++ b/app/kubernetes/views/security-constraint/constraintController.js @@ -0,0 +1,25 @@ +import angular from 'angular'; +import { FeatureId } from '@/react/portainer/feature-flags/enums'; + +angular.module('portainer.kubernetes').controller('KubernetesSecurityConstraintController', [ + '$scope', + 'EndpointProvider', + 'EndpointService', + function ($scope, EndpointProvider, EndpointService) { + $scope.limitedFeaturePodSecurityPolicy = FeatureId.POD_SECURITY_POLICY_CONSTRAINT; + $scope.state = { + viewReady: false, + actionInProgress: false, + }; + + async function initView() { + const endpointID = EndpointProvider.endpointID(); + EndpointService.endpoint(endpointID).then((endpoint) => { + $scope.endpoint = endpoint; + $scope.state.viewReady = true; + }); + } + + initView(); + }, +]); diff --git a/app/kubernetes/views/stacks/logs/logs.html b/app/kubernetes/views/stacks/logs/logs.html new file mode 100644 index 0000000..9d744d1 --- /dev/null +++ b/app/kubernetes/views/stacks/logs/logs.html @@ -0,0 +1,79 @@ + + + + +
+
+
+ + +
+
Actions
+ +
+ +
+ +
+
+ + +
+ +
+ +
+
+ + +
+
+ +
+
+ +
+
+
+
+
+ +
+
+
+        

{{ log.appName }} {{ span.text }}

+

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

+

No logs available

+
+
+
+
diff --git a/app/kubernetes/views/stacks/logs/logs.js b/app/kubernetes/views/stacks/logs/logs.js new file mode 100644 index 0000000..bd8a1dc --- /dev/null +++ b/app/kubernetes/views/stacks/logs/logs.js @@ -0,0 +1,8 @@ +angular.module('portainer.kubernetes').component('kubernetesStackLogsView', { + templateUrl: './logs.html', + controller: 'KubernetesStackLogsController', + controllerAs: 'ctrl', + bindings: { + $transition$: '<', + }, +}); diff --git a/app/kubernetes/views/stacks/logs/logsController.js b/app/kubernetes/views/stacks/logs/logsController.js new file mode 100644 index 0000000..e176e99 --- /dev/null +++ b/app/kubernetes/views/stacks/logs/logsController.js @@ -0,0 +1,123 @@ +import { filter, flatMap, map } from 'lodash'; +import angular from 'angular'; +import $allSettled from '@/portainer/services/allSettled'; +import { concatLogsToString, formatLogs } from '@/docker/helpers/logHelper'; + +const colors = ['red', 'orange', 'lime', 'green', 'darkgreen', 'cyan', 'turquoise', 'teal', 'deepskyblue', 'blue', 'darkblue', 'slateblue', 'magenta', 'darkviolet']; + +class KubernetesStackLogsController { + /* @ngInject */ + constructor($async, $state, $interval, Notifications, KubernetesApplicationService, KubernetesPodService, FileSaver, Blob) { + this.$async = $async; + this.$state = $state; + this.$interval = $interval; + this.Notifications = Notifications; + this.KubernetesApplicationService = KubernetesApplicationService; + this.KubernetesPodService = KubernetesPodService; + this.Blob = Blob; + this.FileSaver = FileSaver; + + this.onInit = this.onInit.bind(this); + this.stopRepeater = this.stopRepeater.bind(this); + this.generateLogsPromise = this.generateLogsPromise.bind(this); + this.generateAppPromise = this.generateAppPromise.bind(this); + this.getStackLogsAsync = this.getStackLogsAsync.bind(this); + } + + updateAutoRefresh() { + if (this.state.autoRefresh) { + this.setUpdateRepeater(); + return; + } + + this.stopRepeater(); + } + + stopRepeater() { + if (angular.isDefined(this.repeater)) { + this.$interval.cancel(this.repeater); + this.repeater = null; + } + } + + setUpdateRepeater() { + this.repeater = this.$interval(this.getStackLogsAsync, this.state.refreshRate); + } + + async generateLogsPromise(pod, container) { + const res = { + Pod: pod, + Logs: [], + }; + res.Logs = await this.KubernetesPodService.logs(pod.Namespace, pod.Name, container.Name); + return res; + } + + async generateAppPromise(app) { + const res = { + Application: app, + Pods: [], + }; + + const promises = flatMap(map(app.Pods, (pod) => map(pod.Containers, (container) => this.generateLogsPromise(pod, container)))); + const result = await $allSettled(promises); + res.Pods = result.fulfilled; + return res; + } + + async getStackLogsAsync() { + try { + const applications = await this.KubernetesApplicationService.get(this.state.transition.namespace); + const filteredApplications = filter(applications, (app) => app.StackName === this.state.transition.name); + const logsPromises = map(filteredApplications, this.generateAppPromise); + const data = await Promise.all(logsPromises); + const logs = flatMap(data, (app, index) => + flatMap(app.Pods, (pod) => formatLogs(pod.Logs).map((line) => ({ ...line, appColor: colors[index % colors.length], appName: pod.Pod.Name }))) + ); + this.stackLogs = logs; + } catch (err) { + this.stopRepeater(); + this.Notifications.error('Failure', err, 'Unable to retrieve application logs'); + } + } + + downloadLogs() { + const logsAsString = concatLogsToString(this.state.filteredLogs, (line) => `${line.appName} ${line.line}`); + const data = new this.Blob([logsAsString]); + this.FileSaver.saveAs(data, this.state.transition.name + '_logs.txt'); + } + + async onInit() { + this.state = { + autoRefresh: false, + refreshRate: 30000, // 30 seconds + search: '', + viewReady: false, + transition: { + namespace: this.$transition$.params().namespace, + name: this.$transition$.params().name, + }, + }; + + this.stackLogs = []; + try { + await this.getStackLogsAsync(); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to retrieve stack logs'); + this.stopRepeater(); + } finally { + this.state.viewReady = true; + } + } + + $onInit() { + return this.$async(this.onInit); + } + + $onDestroy() { + this.stopRepeater(); + } +} + +export default KubernetesStackLogsController; +angular.module('portainer.kubernetes').controller('KubernetesStackLogsController', KubernetesStackLogsController); diff --git a/app/kubernetes/views/summary/resources/applicationResources.js b/app/kubernetes/views/summary/resources/applicationResources.js new file mode 100644 index 0000000..325deb0 --- /dev/null +++ b/app/kubernetes/views/summary/resources/applicationResources.js @@ -0,0 +1,253 @@ +import _ from 'lodash-es'; +import { KubernetesResourceTypes, KubernetesResourceActions } from '@/kubernetes/models/resource-types/models'; +import { KubernetesApplicationFormValues } from '@/kubernetes/models/application/formValues'; +import { KubernetesStatefulSet } from '@/kubernetes/models/stateful-set/models'; +import { KubernetesService, KubernetesServiceTypes } from '@/kubernetes/models/service/models'; +import { KubernetesApplicationDeploymentTypes } from '@/kubernetes/models/application/models/appConstants'; +import { KubernetesHorizontalPodAutoScalerConverter } from '@/kubernetes/horizontal-pod-auto-scaler/converter'; +import KubernetesApplicationConverter from '@/kubernetes/converters/application'; +import KubernetesServiceConverter from '@/kubernetes/converters/service'; +import { KubernetesIngressConverter } from '@/kubernetes/ingress/converter'; +import KubernetesPersistentVolumeClaimConverter from '@/kubernetes/converters/persistentVolumeClaim'; +import { generateNewIngressesFromFormPaths } from '@/react/kubernetes/applications/CreateView/application-services/utils'; + +const { CREATE, UPDATE, DELETE } = KubernetesResourceActions; + +/** + * Get summary of Kubernetes resources to be created, updated or deleted + * @param {KubernetesApplicationFormValues} formValues + */ +export function getApplicationResources(formValues, oldFormValues = {}) { + if (oldFormValues instanceof KubernetesApplicationFormValues) { + const resourceSummary = getUpdatedApplicationResources(oldFormValues, formValues); + return resourceSummary; + } + const resourceSummary = getCreatedApplicationResources(formValues); + return resourceSummary; +} + +/** + * Get summary of Kubernetes resources to be created + * @param {KubernetesApplicationFormValues} formValues + */ +export function getCreatedApplicationResources(formValues) { + const resources = []; + + let [app, headlessService, services, service, claims] = KubernetesApplicationConverter.applicationFormValuesToApplication(formValues); + + if (services) { + services.forEach((service) => { + resources.push({ action: CREATE, kind: KubernetesResourceTypes.SERVICE, name: service.Name, type: service.Type || KubernetesServiceTypes.CLUSTER_IP }); + // Ingress + const newServicePorts = formValues.Services.flatMap((service) => service.Ports); + const newIngresses = generateNewIngressesFromFormPaths(formValues.OriginalIngresses, newServicePorts); + resources.push(...getIngressUpdateSummary(formValues.OriginalIngresses, newIngresses)); + }); + } + + if (service) { + // Service + resources.push({ action: CREATE, kind: KubernetesResourceTypes.SERVICE, name: service.Name, type: service.Type || KubernetesServiceTypes.CLUSTER_IP }); + } + + if (app instanceof KubernetesStatefulSet) { + // Service + resources.push({ action: CREATE, kind: KubernetesResourceTypes.SERVICE, name: headlessService.Name, type: headlessService.Type || KubernetesServiceTypes.CLUSTER_IP }); + } else { + // Persistent volume claims + const persistentVolumeClaimResources = claims + .filter((pvc) => !pvc.PreviousName && !pvc.Id) + .map((pvc) => ({ action: CREATE, kind: KubernetesResourceTypes.PERSISTENT_VOLUME_CLAIM, name: pvc.Name })); + resources.push(...persistentVolumeClaimResources); + } + + // Horizontal pod autoscalers + if (formValues.AutoScaler.IsUsed && formValues.DeploymentType !== KubernetesApplicationDeploymentTypes.Global) { + const kind = app.ApplicationType; + const autoScaler = KubernetesHorizontalPodAutoScalerConverter.applicationFormValuesToModel(formValues, kind); + resources.push({ action: CREATE, kind: KubernetesResourceTypes.HORIZONTAL_POD_AUTOSCALER, name: autoScaler.Name }); + } + + // Deployment + const appResourceType = app.ApplicationType; + if (appResourceType !== null) { + resources.push({ action: CREATE, kind: appResourceType, name: app.Name }); + } + + return resources; +} + +/** + * Get summary of Kubernetes resources to be created, updated and/or deleted + * @param {KubernetesApplicationFormValues} oldFormValues + * @param {KubernetesApplicationFormValues} newFormValues + */ +export function getUpdatedApplicationResources(oldFormValues, newFormValues) { + const resources = []; + + const [oldApp, oldHeadlessService, oldServices, oldService, oldClaims] = KubernetesApplicationConverter.applicationFormValuesToApplication(oldFormValues); + const [newApp, newHeadlessService, newServices, newService, newClaims] = KubernetesApplicationConverter.applicationFormValuesToApplication(newFormValues); + const oldAppResourceType = oldApp.ApplicationType; + const newAppResourceType = newApp.ApplicationType; + + if (oldAppResourceType !== newAppResourceType) { + // Deployment + resources.push({ action: DELETE, kind: oldAppResourceType, name: oldApp.Name }); + if (oldService && oldServices) { + // Service + resources.push({ action: DELETE, kind: KubernetesResourceTypes.SERVICE, name: oldService.Name, type: oldService.Type || KubernetesServiceTypes.CLUSTER_IP }); + } + // re-creation of resources + const createdApplicationResourceSummary = getCreatedApplicationResources(newFormValues); + resources.push(...createdApplicationResourceSummary); + return resources; + } + + if (newApp instanceof KubernetesStatefulSet) { + const headlessServiceUpdateResourceSummary = getServiceUpdateResourceSummary(oldHeadlessService, newHeadlessService); + if (headlessServiceUpdateResourceSummary) { + resources.push(headlessServiceUpdateResourceSummary); + } + } else { + // Persistent volume claims + const claimSummaries = newClaims + .map((pvc) => { + if (!pvc.PreviousName && !pvc.Id) { + return { action: CREATE, kind: KubernetesResourceTypes.PERSISTENT_VOLUME_CLAIM, name: pvc.Name }; + } else if (!pvc.Id) { + const oldClaim = _.find(oldClaims, { Name: pvc.PreviousName }); + return getVolumeClaimUpdateResourceSummary(oldClaim, pvc); + } + }) + .filter((pvc) => pvc); // remove nulls + resources.push(...claimSummaries); + } + + // Deployment + resources.push({ action: UPDATE, kind: oldAppResourceType, name: oldApp.Name }); + + if (oldServices && newServices) { + // Service + const serviceUpdateResourceSummary = getServiceUpdateResourceSummary(oldServices, newServices); + if (serviceUpdateResourceSummary !== null) { + serviceUpdateResourceSummary.forEach((updateSummary) => { + resources.push(updateSummary); + }); + } + + // Ingress + const oldServicePorts = oldFormValues.Services.flatMap((service) => service.Ports); + const oldIngresses = generateNewIngressesFromFormPaths(oldFormValues.OriginalIngresses, oldServicePorts, oldServicePorts); + const newServicePorts = newFormValues.Services.flatMap((service) => service.Ports); + const newIngresses = generateNewIngressesFromFormPaths(newFormValues.OriginalIngresses, newServicePorts, oldServicePorts); + resources.push(...getIngressUpdateSummary(oldIngresses, newIngresses)); + } else if (!oldService && newService) { + // Service + resources.push({ action: CREATE, kind: KubernetesResourceTypes.SERVICE, name: newService.Name, type: newService.Type || KubernetesServiceTypes.CLUSTER_IP }); + } else if (oldService && !newService) { + // Service + resources.push({ action: DELETE, kind: KubernetesResourceTypes.SERVICE, name: oldService.Name, type: oldService.Type || KubernetesServiceTypes.CLUSTER_IP }); + } + + const newKind = newApp.ApplicationType; + const newAutoScaler = KubernetesHorizontalPodAutoScalerConverter.applicationFormValuesToModel(newFormValues, newKind); + if (!oldFormValues.AutoScaler.IsUsed) { + if (newFormValues.AutoScaler.IsUsed) { + // Horizontal pod autoscalers + resources.push({ action: CREATE, kind: KubernetesResourceTypes.HORIZONTAL_POD_AUTOSCALER, name: newAutoScaler.Name }); + } + } else { + // Horizontal pod autoscalers + const oldKind = oldApp.ApplicationType; + const oldAutoScaler = KubernetesHorizontalPodAutoScalerConverter.applicationFormValuesToModel(oldFormValues, oldKind); + if (newFormValues.AutoScaler.IsUsed) { + const hpaUpdateSummary = getHorizontalPodAutoScalerUpdateResourceSummary(oldAutoScaler, newAutoScaler); + if (hpaUpdateSummary) { + resources.push(hpaUpdateSummary); + } + } else { + resources.push({ action: DELETE, kind: KubernetesResourceTypes.HORIZONTAL_POD_AUTOSCALER, name: oldAutoScaler.Name }); + } + } + + return resources; +} + +function getIngressUpdateSummary(oldIngresses, newIngresses) { + const ingressesSummaries = newIngresses + .map((newIng) => { + const oldIng = oldIngresses.find((oldIng) => oldIng.Name === newIng.Name); + return getIngressUpdateResourceSummary(oldIng, newIng); + }) + .filter((s) => s); // remove nulls + return ingressesSummaries; +} + +// getIngressUpdateResourceSummary replicates KubernetesIngressService.patch +function getIngressUpdateResourceSummary(oldIngress, newIngress) { + const payload = KubernetesIngressConverter.patchPayload(oldIngress, newIngress); + if (payload.length) { + return { action: UPDATE, kind: KubernetesResourceTypes.INGRESS, name: oldIngress.Name }; + } + return null; +} + +// getVolumeClaimUpdateResourceSummary replicates KubernetesPersistentVolumeClaimService.patch +function getVolumeClaimUpdateResourceSummary(oldPVC, newPVC) { + const payload = KubernetesPersistentVolumeClaimConverter.patchPayload(oldPVC, newPVC); + if (payload.length) { + return { action: UPDATE, kind: KubernetesResourceTypes.PERSISTENT_VOLUME_CLAIM, name: oldPVC.Name }; + } + return null; +} + +// getServiceUpdateResourceSummary replicates KubernetesServiceService.patch +function getServiceUpdateResourceSummary(oldServices, newServices) { + let summary = []; + // skip update summary when service is headless service + if (!oldServices.Headless) { + newServices.forEach((newService) => { + const oldServiceMatched = _.find(oldServices, { Name: newService.Name }); + if (oldServiceMatched) { + const payload = KubernetesServiceConverter.patchPayload(oldServiceMatched, newService); + if (payload.length) { + const serviceUpdate = { + action: UPDATE, + kind: KubernetesResourceTypes.SERVICE, + name: oldServiceMatched.Name, + type: oldServiceMatched.Type || KubernetesServiceTypes.CLUSTER_IP, + }; + summary.push(serviceUpdate); + } + } else { + const emptyService = new KubernetesService(); + const payload = KubernetesServiceConverter.patchPayload(emptyService, newService); + if (payload.length) { + const serviceCreate = { action: CREATE, kind: KubernetesResourceTypes.SERVICE, name: newService.Name, type: newService.Type || KubernetesServiceTypes.CLUSTER_IP }; + summary.push(serviceCreate); + } + } + }); + + oldServices.forEach((oldService) => { + const newServiceMatched = _.find(newServices, { Name: oldService.Name }); + if (!newServiceMatched) { + const serviceDelete = { action: DELETE, kind: KubernetesResourceTypes.SERVICE, name: oldService.Name, type: oldService.Type || KubernetesServiceTypes.CLUSTER_IP }; + summary.push(serviceDelete); + } + }); + } + if (summary.length !== 0) { + return summary; + } + return null; +} + +// getHorizontalPodAutoScalerUpdateResourceSummary replicates KubernetesHorizontalPodAutoScalerService.patch +function getHorizontalPodAutoScalerUpdateResourceSummary(oldHorizontalPodAutoScaler, newHorizontalPodAutoScaler) { + const payload = KubernetesHorizontalPodAutoScalerConverter.patchPayload(oldHorizontalPodAutoScaler, newHorizontalPodAutoScaler); + if (payload.length) { + return { action: UPDATE, kind: KubernetesResourceTypes.HORIZONTAL_POD_AUTOSCALER, name: oldHorizontalPodAutoScaler.Name }; + } + return null; +} diff --git a/app/kubernetes/views/summary/resources/configurationResources.js b/app/kubernetes/views/summary/resources/configurationResources.js new file mode 100644 index 0000000..3dabcbc --- /dev/null +++ b/app/kubernetes/views/summary/resources/configurationResources.js @@ -0,0 +1,17 @@ +import { KubernetesResourceTypes, KubernetesResourceActions } from '@/kubernetes/models/resource-types/models'; +import { KubernetesConfigurationKinds } from '@/kubernetes/models/configuration/models'; + +const { CREATE, UPDATE } = KubernetesResourceActions; + +export default function (formValues) { + const action = formValues.Id ? UPDATE : CREATE; + if (formValues.Kind === KubernetesConfigurationKinds.CONFIGMAP) { + return [{ action, kind: KubernetesResourceTypes.CONFIGMAP, name: formValues.Name }]; + } else if (formValues.Kind === KubernetesConfigurationKinds.SECRET) { + let type = formValues.Type; + if (formValues.customType) { + type = formValues.customType; + } + return [{ action, kind: KubernetesResourceTypes.SECRET, name: formValues.Name, type }]; + } +} diff --git a/app/kubernetes/views/summary/resources/helpers.js b/app/kubernetes/views/summary/resources/helpers.js new file mode 100644 index 0000000..15b2fc6 --- /dev/null +++ b/app/kubernetes/views/summary/resources/helpers.js @@ -0,0 +1,54 @@ +import _ from 'lodash-es'; +import * as JsonPatch from 'fast-json-patch'; +import { KubernetesResourceActions } from '@/kubernetes/models/resource-types/models'; + +function findCreateResources(newResources, oldResources) { + return _.differenceBy(newResources, oldResources, 'Name'); +} + +function findDeleteResources(newResources, oldResources) { + return _.differenceBy(oldResources, newResources, 'Name'); +} + +function findUpdateResources(newResources, oldResources) { + const updateResources = _.intersectionWith(newResources, oldResources, (newResource, oldResource) => { + // find out resources with same name but content changed + if (newResource.Name != oldResource.Name) { + return false; + } + return !isEqual(newResource, oldResource); + }); + + return updateResources; +} + +function isEqual(newResource, oldResource) { + let patches = JsonPatch.compare(newResource, oldResource); + patches = _.filter(patches, (change) => { + return !_.includes(change.path, '$$hashKey') && !_.includes(change.path, 'Duplicate'); + }); + + return !patches.length; +} + +function doGetResourcesSummary(newResources, oldResources, kind, action, actionFilter) { + const filteredResources = actionFilter(newResources, oldResources); + const summary = filteredResources.map((resource) => ({ name: resource.Name, action, kind })); + + return summary; +} + +export function getResourcesSummary(newResources, oldResources, kind) { + if (!Array.isArray(newResources)) { + newResources = newResources ? [newResources] : []; + oldResources = oldResources ? [oldResources] : []; + } + + const summary = [ + ...doGetResourcesSummary(newResources, oldResources, kind, KubernetesResourceActions.CREATE, findCreateResources), + ...doGetResourcesSummary(newResources, oldResources, kind, KubernetesResourceActions.UPDATE, findUpdateResources), + ...doGetResourcesSummary(newResources, oldResources, kind, KubernetesResourceActions.DELETE, findDeleteResources), + ]; + + return summary; +} diff --git a/app/kubernetes/views/summary/resources/namespaceResources.js b/app/kubernetes/views/summary/resources/namespaceResources.js new file mode 100644 index 0000000..01a6c8d --- /dev/null +++ b/app/kubernetes/views/summary/resources/namespaceResources.js @@ -0,0 +1,23 @@ +import KubernetesResourcePoolConverter from '@/kubernetes/converters/resourcePool'; +import { KubernetesResourcePoolFormValues } from '@/kubernetes/models/resource-pool/formValues'; +import { KubernetesResourceQuotaDefaults } from '@/kubernetes/models/resource-quota/models'; +import { KubernetesResourceTypes } from '@/kubernetes/models/resource-types/models'; +import { getResourcesSummary } from '@/kubernetes/views/summary/resources/helpers'; + +export default function (newFormValues, oldFormValues) { + const [newNamespace, newQuota, newIngresses] = KubernetesResourcePoolConverter.formValuesToResourcePool(newFormValues); + + if (!(oldFormValues instanceof KubernetesResourcePoolFormValues)) { + oldFormValues = new KubernetesResourcePoolFormValues(KubernetesResourceQuotaDefaults); + } + + const [oldNamespace, oldQuota, oldIngresses] = KubernetesResourcePoolConverter.formValuesToResourcePool(oldFormValues); + + const resources = [ + ...getResourcesSummary(newNamespace, oldNamespace, KubernetesResourceTypes.NAMESPACE), + ...getResourcesSummary(newQuota, oldQuota, KubernetesResourceTypes.RESOURCEQUOTA), + ...getResourcesSummary(newIngresses, oldIngresses, KubernetesResourceTypes.INGRESS), + ]; + + return resources; +} diff --git a/app/kubernetes/views/summary/summary.html b/app/kubernetes/views/summary/summary.html new file mode 100644 index 0000000..685310e --- /dev/null +++ b/app/kubernetes/views/summary/summary.html @@ -0,0 +1 @@ + diff --git a/app/kubernetes/views/summary/summary.js b/app/kubernetes/views/summary/summary.js new file mode 100644 index 0000000..841a9f4 --- /dev/null +++ b/app/kubernetes/views/summary/summary.js @@ -0,0 +1,9 @@ +angular.module('portainer.kubernetes').component('kubernetesSummaryView', { + templateUrl: './summary.html', + controller: 'KubernetesSummaryController', + controllerAs: '$ctrl', + bindings: { + formValues: '<', + oldFormValues: '<', + }, +}); diff --git a/app/kubernetes/views/summary/summaryController.js b/app/kubernetes/views/summary/summaryController.js new file mode 100644 index 0000000..ceb9d5b --- /dev/null +++ b/app/kubernetes/views/summary/summaryController.js @@ -0,0 +1,89 @@ +import angular from 'angular'; +import { KubernetesConfigurationFormValues } from '@/kubernetes/models/configuration/formvalues'; +import { KubernetesResourcePoolFormValues } from '@/kubernetes/models/resource-pool/formValues'; +import { KubernetesApplicationFormValues } from '@/kubernetes/models/application/formValues'; +import { KubernetesResourceActions, KubernetesResourceTypes } from '@/kubernetes/models/resource-types/models'; +import { getApplicationResources } from './resources/applicationResources'; +import getNamespaceResources from './resources/namespaceResources'; +import getConfigurationResources from './resources/configurationResources'; + +class KubernetesSummaryController { + /* @ngInject */ + constructor($scope, LocalStorage, KubernetesResourcePoolService) { + this.LocalStorage = LocalStorage; + this.KubernetesResourcePoolService = KubernetesResourcePoolService; + + this.toggleSummary = this.toggleSummary.bind(this); + this.generateResourceSummaryList = this.generateResourceSummaryList.bind(this); + + // Deep-watch changes on formValues property + $scope.$watch( + '$ctrl.formValues', + (formValues) => { + this.state.resources = this.generateResourceSummaryList(angular.copy(formValues)); + }, + true + ); + } + + getArticle(resourceType, resourceAction) { + let article = 'a'; + if (resourceAction === KubernetesResourceActions.CREATE) { + if (resourceType === KubernetesResourceTypes.INGRESS) { + article = 'an'; + } + } else { + article = 'the'; + } + + return article; + } + + /** + * toggleSummary toggles the summary panel state and persists it to browser localstorage + */ + toggleSummary() { + this.state.expandedTemplate = !this.state.expandedTemplate; + this.LocalStorage.storeKubernetesSummaryToggle(this.state.expandedTemplate); + } + + /** + * generateResourceSummaryList maps formValues to custom object + * @param {object} formValues + * @returns {object} => { action: "string", kind: "string", name: "string" } + */ + generateResourceSummaryList(formValues) { + const oldFormValues = this.oldFormValues; + + if (formValues instanceof KubernetesConfigurationFormValues) { + // Configuration + return getConfigurationResources(formValues); + } else if (formValues instanceof KubernetesResourcePoolFormValues) { + // Namespaces + return getNamespaceResources(formValues, oldFormValues); + } else if (formValues instanceof KubernetesApplicationFormValues) { + // Applications + + // extract cpu and memory requests & limits for pod + this.state.limits = { cpu: formValues.CpuLimit, memory: formValues.MemoryLimit }; + + return getApplicationResources(formValues, oldFormValues); + } + + return []; + } + + $onInit() { + const toggleValue = this.LocalStorage.getKubernetesSummaryToggle(); + const expanded = typeof toggleValue === 'boolean' ? toggleValue : true; + + this.state = { + expandedTemplate: expanded, + resources: [], + limits: { cpu: null, memory: null }, + }; + } +} + +export default KubernetesSummaryController; +angular.module('portainer.kubernetes').controller('KubernetesSummaryController', KubernetesSummaryController); diff --git a/app/kubernetes/views/volumes/edit/volume.html b/app/kubernetes/views/volumes/edit/volume.html new file mode 100644 index 0000000..5e83682 --- /dev/null +++ b/app/kubernetes/views/volumes/edit/volume.html @@ -0,0 +1,188 @@ + + + + + +
+
+
+ + + + + + + Volume + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Name + {{ ctrl.volume.PersistentVolumeClaim.Name }} + external + unused +
Namespace + {{ + ctrl.volume.ResourcePool.Namespace.Name + }} + system +
Storage Class{{ ctrl.volume.PersistentVolumeClaim.storageClass.Name }}
Access Modes +
+
+ {{ accessPolicy }} + +
+
+
Provisioner{{ + ctrl.volume.PersistentVolumeClaim.storageClass.Provisioner ? ctrl.volume.PersistentVolumeClaim.storageClass.Provisioner : '-' + }}
Creation date{{ ctrl.volume.PersistentVolumeClaim.CreationDate | getisodate }}
size + {{ ctrl.volume.PersistentVolumeClaim.Storage }} + + +
+
+
+ + + + +
+ + +
+ +
+
+
+

This field is required.

+
+

The new size must be greater than the actual size.

+
+
+
+
+
+
+ + + + Events +
+ + {{ ctrl.state.eventWarningCount }} warning(s) +
+
+ + +
+ + + YAML +
+ +
+
+
+
+
+
+
+ + + +
diff --git a/app/kubernetes/views/volumes/edit/volume.js b/app/kubernetes/views/volumes/edit/volume.js new file mode 100644 index 0000000..d46ee39 --- /dev/null +++ b/app/kubernetes/views/volumes/edit/volume.js @@ -0,0 +1,9 @@ +angular.module('portainer.kubernetes').component('kubernetesVolumeView', { + templateUrl: './volume.html', + controller: 'KubernetesVolumeController', + controllerAs: 'ctrl', + bindings: { + $transition$: '<', + endpoint: '<', + }, +}); diff --git a/app/kubernetes/views/volumes/edit/volumeController.js b/app/kubernetes/views/volumes/edit/volumeController.js new file mode 100644 index 0000000..1fa26eb --- /dev/null +++ b/app/kubernetes/views/volumes/edit/volumeController.js @@ -0,0 +1,217 @@ +import angular from 'angular'; +import _ from 'lodash-es'; +import filesizeParser from 'filesize-parser'; +import KubernetesVolumeHelper from '@/kubernetes/helpers/volumeHelper'; +import KubernetesEventHelper from '@/kubernetes/helpers/eventHelper'; +import { KubernetesStorageClassAccessPolicies } from '@/kubernetes/models/storage-class/models'; +import KubernetesNamespaceHelper from '@/kubernetes/helpers/namespaceHelper'; +import { confirmRedeploy } from '@/react/kubernetes/volumes/ItemView/ConfirmRedeployModal'; +import { isVolumeUsed } from '@/react/kubernetes/volumes/utils'; + +class KubernetesVolumeController { + /* @ngInject */ + constructor( + $async, + $state, + Notifications, + LocalStorage, + KubernetesVolumeService, + KubernetesEventService, + KubernetesApplicationService, + KubernetesPersistentVolumeClaimService, + KubernetesPodService + ) { + this.$async = $async; + this.$state = $state; + this.Notifications = Notifications; + this.LocalStorage = LocalStorage; + + this.KubernetesVolumeService = KubernetesVolumeService; + this.KubernetesEventService = KubernetesEventService; + this.KubernetesApplicationService = KubernetesApplicationService; + this.KubernetesPersistentVolumeClaimService = KubernetesPersistentVolumeClaimService; + this.KubernetesPodService = KubernetesPodService; + + this.onInit = this.onInit.bind(this); + this.getVolume = this.getVolume.bind(this); + this.getVolumeAsync = this.getVolumeAsync.bind(this); + this.updateVolumeAsync = this.updateVolumeAsync.bind(this); + this.getEvents = this.getEvents.bind(this); + this.getEventsAsync = this.getEventsAsync.bind(this); + } + + selectTab(index) { + this.LocalStorage.storeActiveTab('volume', index); + } + + showEditor() { + this.state.showEditorTab = true; + this.selectTab(2); + } + + isExternalVolume() { + return !this.volume.PersistentVolumeClaim.ApplicationOwner; + } + + isSystemNamespace() { + return KubernetesNamespaceHelper.isSystemNamespace(this.volume.ResourcePool.Namespace.Name); + } + + isUsed() { + return isVolumeUsed(this.volume); + } + + onChangeSize() { + if (this.state.volumeSize) { + const size = filesizeParser(this.state.volumeSize + this.state.volumeSizeUnit, { base: 10 }); + if (this.state.oldVolumeSize > size) { + this.state.errors.volumeSize = true; + } else { + this.state.errors.volumeSize = false; + } + } + } + + sizeIsValid() { + return !this.state.errors.volumeSize && this.state.volumeSize && this.state.oldVolumeSize !== filesizeParser(this.state.volumeSize + this.state.volumeSizeUnit, { base: 10 }); + } + + /** + * VOLUME + */ + + async updateVolumeAsync(redeploy) { + try { + this.volume.PersistentVolumeClaim.Storage = this.state.volumeSize + this.state.volumeSizeUnit.charAt(0); + await this.KubernetesPersistentVolumeClaimService.patch(this.oldVolume.PersistentVolumeClaim, this.volume.PersistentVolumeClaim); + this.Notifications.success('Success', 'Volume successfully updated'); + + if (redeploy) { + const promises = _.flatten( + _.map(this.volume.Applications, (app) => { + return _.map(app.Pods, (item) => this.KubernetesPodService.delete(item)); + }) + ); + await Promise.all(promises); + this.Notifications.success('Success', 'Applications successfully redeployed'); + } + + this.$state.reload(this.$state.current); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to update volume.'); + } + } + + updateVolume() { + if (isVolumeUsed(this.volume)) { + confirmRedeploy().then((redeploy) => { + return this.$async(this.updateVolumeAsync, redeploy); + }); + } else { + return this.$async(this.updateVolumeAsync, false); + } + } + + async getVolumeAsync() { + const storageClasses = this.endpoint.Kubernetes.Configuration.StorageClasses; + try { + const [volume, applications] = await Promise.all([ + this.KubernetesVolumeService.get(this.state.namespace, storageClasses, this.state.name), + this.KubernetesApplicationService.get(this.state.namespace), + ]); + volume.Applications = KubernetesVolumeHelper.getUsingApplications(volume, applications); + this.volume = volume; + this.oldVolume = angular.copy(volume); + this.state.volumeSize = parseInt(volume.PersistentVolumeClaim.Storage.slice(0, -2), 10); + this.state.volumeSizeUnit = volume.PersistentVolumeClaim.Storage.slice(-2); + this.state.oldVolumeSize = filesizeParser(volume.PersistentVolumeClaim.Storage, { base: 10 }); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to retrieve volume'); + } + } + + getVolume() { + return this.$async(this.getVolumeAsync); + } + + /** + * EVENTS + */ + hasEventWarnings() { + return this.state.eventWarningCount; + } + + async getEventsAsync() { + try { + this.state.eventsLoading = true; + const events = await this.KubernetesEventService.get(this.state.namespace); + this.events = _.filter(events, (event) => event.Involved.uid === this.volume.PersistentVolumeClaim.Id); + this.state.eventWarningCount = KubernetesEventHelper.warningCount(this.events); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to retrieve application related events'); + } finally { + this.state.eventsLoading = false; + } + } + + getEvents() { + return this.$async(this.getEventsAsync); + } + + /** + * ON INIT + */ + async onInit() { + this.state = { + activeTab: 0, + currentName: this.$state.$current.name, + showEditorTab: false, + eventsLoading: true, + viewReady: false, + namespace: this.$transition$.params().namespace, + name: this.$transition$.params().name, + eventWarningCount: 0, + availableSizeUnits: ['MB', 'GB', 'TB'], + increaseSize: false, + volumeSize: 0, + volumeSizeUnit: 'GB', + volumeSharedAccessPolicies: [], + volumeSharedAccessPolicyTooltips: '', + errors: { + volumeSize: false, + }, + }; + + this.state.activeTab = this.LocalStorage.getActiveTab('volume'); + + try { + await this.getVolume(); + await this.getEvents(); + if (this.volume.PersistentVolumeClaim.storageClass !== undefined) { + this.state.volumeSharedAccessPolicies = this.volume.PersistentVolumeClaim.AccessModes; + let policies = KubernetesStorageClassAccessPolicies(); + this.state.volumeSharedAccessPolicyTooltips = this.state.volumeSharedAccessPolicies.map((policy) => { + const matchingPolicy = policies.find((p) => p.Name === policy); + return matchingPolicy ? matchingPolicy.Description : undefined; + }); + } + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to load view data'); + } finally { + this.state.viewReady = true; + } + } + + $onInit() { + return this.$async(this.onInit); + } + + $onDestroy() { + if (this.state.currentName !== this.$state.$current.name) { + this.LocalStorage.storeActiveTab('volume', 0); + } + } +} + +export default KubernetesVolumeController; +angular.module('portainer.kubernetes').controller('KubernetesVolumeController', KubernetesVolumeController); diff --git a/app/ng-constants.ts b/app/ng-constants.ts new file mode 100644 index 0000000..87fa45b --- /dev/null +++ b/app/ng-constants.ts @@ -0,0 +1,63 @@ +import angular from 'angular'; + +import { + API_ENDPOINT_AUTH, + API_ENDPOINT_BACKUP, + API_ENDPOINT_CUSTOM_TEMPLATES, + API_ENDPOINT_EDGE_GROUPS, + API_ENDPOINT_EDGE_JOBS, + API_ENDPOINT_EDGE_STACKS, + API_ENDPOINT_ENDPOINTS, + API_ENDPOINT_ENDPOINT_GROUPS, + API_ENDPOINT_KUBERNETES, + API_ENDPOINT_MOTD, + API_ENDPOINT_REGISTRIES, + API_ENDPOINT_RESOURCE_CONTROLS, + API_ENDPOINT_SETTINGS, + API_ENDPOINT_STACKS, + API_ENDPOINT_SUPPORT, + API_ENDPOINT_USERS, + API_ENDPOINT_TAGS, + API_ENDPOINT_TEAMS, + API_ENDPOINT_TEAM_MEMBERSHIPS, + API_ENDPOINT_TEMPLATES, + API_ENDPOINT_WEBHOOKS, + PAGINATION_MAX_ITEMS, + APPLICATION_CACHE_VALIDITY, + CONSOLE_COMMANDS_LABEL_PREFIX, + PREDEFINED_NETWORKS, +} from './constants'; +import { BROWSER_OS_PLATFORM } from './react/constants'; + +// don't declare new constants, either: +// - if only used in one file or module, declare in that file or module (as a regular js constant) +// - if needed across modules, declare like in `./constants` and use es6 import for that + +export const constantsModule = angular + .module('portainer.app.constants', []) + .constant('API_ENDPOINT_AUTH', API_ENDPOINT_AUTH) + .constant('API_ENDPOINT_BACKUP', API_ENDPOINT_BACKUP) + .constant('API_ENDPOINT_CUSTOM_TEMPLATES', API_ENDPOINT_CUSTOM_TEMPLATES) + .constant('API_ENDPOINT_EDGE_GROUPS', API_ENDPOINT_EDGE_GROUPS) + .constant('API_ENDPOINT_EDGE_JOBS', API_ENDPOINT_EDGE_JOBS) + .constant('API_ENDPOINT_EDGE_STACKS', API_ENDPOINT_EDGE_STACKS) + .constant('API_ENDPOINT_ENDPOINTS', API_ENDPOINT_ENDPOINTS) + .constant('API_ENDPOINT_ENDPOINT_GROUPS', API_ENDPOINT_ENDPOINT_GROUPS) + .constant('API_ENDPOINT_KUBERNETES', API_ENDPOINT_KUBERNETES) + .constant('API_ENDPOINT_MOTD', API_ENDPOINT_MOTD) + .constant('API_ENDPOINT_REGISTRIES', API_ENDPOINT_REGISTRIES) + .constant('API_ENDPOINT_RESOURCE_CONTROLS', API_ENDPOINT_RESOURCE_CONTROLS) + .constant('API_ENDPOINT_SETTINGS', API_ENDPOINT_SETTINGS) + .constant('API_ENDPOINT_STACKS', API_ENDPOINT_STACKS) + .constant('API_ENDPOINT_SUPPORT', API_ENDPOINT_SUPPORT) + .constant('API_ENDPOINT_USERS', API_ENDPOINT_USERS) + .constant('API_ENDPOINT_TAGS', API_ENDPOINT_TAGS) + .constant('API_ENDPOINT_TEAMS', API_ENDPOINT_TEAMS) + .constant('API_ENDPOINT_TEAM_MEMBERSHIPS', API_ENDPOINT_TEAM_MEMBERSHIPS) + .constant('API_ENDPOINT_TEMPLATES', API_ENDPOINT_TEMPLATES) + .constant('API_ENDPOINT_WEBHOOKS', API_ENDPOINT_WEBHOOKS) + .constant('PAGINATION_MAX_ITEMS', PAGINATION_MAX_ITEMS) + .constant('APPLICATION_CACHE_VALIDITY', APPLICATION_CACHE_VALIDITY) + .constant('CONSOLE_COMMANDS_LABEL_PREFIX', CONSOLE_COMMANDS_LABEL_PREFIX) + .constant('PREDEFINED_NETWORKS', PREDEFINED_NETWORKS) + .constant('BROWSER_OS_PLATFORM', BROWSER_OS_PLATFORM).name; diff --git a/app/portainer/__module.js b/app/portainer/__module.js new file mode 100644 index 0000000..2ac17d1 --- /dev/null +++ b/app/portainer/__module.js @@ -0,0 +1,523 @@ +import featureFlagModule from '@/react/portainer/feature-flags'; + +import './rbac'; + +import componentsModule from './components'; +import settingsModule from './settings'; +import userActivityModule from './user-activity'; +import servicesModule from './services'; +import { reactModule } from './react'; +import { sidebarModule } from './react/views/sidebar'; +import environmentsModule from './environments'; +import { helpersModule } from './helpers'; +import { AccessHeaders, requiresAuthHook } from './authorization-guard'; +import { filterParam, paginationParams } from './helpers/stateParamHelper'; + +async function initAuthentication(Authentication) { + return await Authentication.init(); +} + +angular + .module('portainer.app', [ + 'portainer.oauth', + 'portainer.rbac', + 'portainer.registrymanagement', + componentsModule, + settingsModule, + featureFlagModule, + userActivityModule, + servicesModule, + reactModule, + sidebarModule, + environmentsModule, + helpersModule, + ]) + .config([ + '$stateRegistryProvider', + function ($stateRegistryProvider) { + var root = { + name: 'root', + abstract: true, + onEnter: /* @ngInject */ function onEnter($async, StateManager, Authentication, Notifications, $state) { + return $async(async () => { + const appState = StateManager.getState(); + if (!appState.loading) { + return; + } + try { + const loggedIn = await initAuthentication(Authentication); + await StateManager.initialize(); + if (!loggedIn && isTransitionRequiresAuthentication($state.transition)) { + $state.go('portainer.logout'); + return Promise.reject('Unauthenticated'); + } + } catch (err) { + Notifications.error('Failure', err, 'Unable to retrieve application settings'); + throw err; + } + }); + }, + views: { + 'sidebar@': { + component: 'sidebar', + }, + }, + data: { + access: AccessHeaders.Restricted, + }, + }; + + var endpointRoot = { + name: 'endpoint', + url: '/:endpointId', + parent: 'root', + abstract: true, + resolve: { + endpoint: /* @ngInject */ function endpoint($async, $state, $transition$, EndpointProvider, EndpointService, Notifications) { + return $async(async () => { + try { + const endpointId = +$transition$.params().endpointId; + + const endpoint = await EndpointService.endpoint(endpointId); + if ((endpoint.Type === 4 || endpoint.Type === 7) && !endpoint.EdgeID) { + $state.go('portainer.endpoints.endpoint', { id: endpoint.Id }); + return; + } + + EndpointProvider.setCurrentEndpoint(endpoint); + + return endpoint; + } catch (e) { + Notifications.error('Failed loading environment', e); + $state.go('portainer.home', {}, { reload: true }); + return; + } + }); + }, + }, + }; + + var portainer = { + name: 'portainer', + parent: 'root', + abstract: true, + }; + + var account = { + name: 'portainer.account', + url: '/account', + views: { + 'content@': { + templateUrl: './views/account/account.html', + controller: 'AccountController', + }, + }, + data: { + docs: '/user/account-settings', + }, + }; + + const tokenCreation = { + name: 'portainer.account.new-access-token', + url: '/tokens/new', + views: { + 'content@': { + component: 'createUserAccessToken', + }, + }, + }; + + const createHelmRepository = { + name: 'portainer.account.createHelmRepository', + url: '/helm-repository/new', + views: { + 'content@': { + component: 'createHelmRepositoryView', + }, + }, + }; + + var authentication = { + name: 'portainer.auth', + url: '/auth', + params: { + reload: false, + }, + views: { + 'content@': { + templateUrl: './views/auth/auth.html', + controller: 'AuthenticationController', + controllerAs: 'ctrl', + }, + 'sidebar@': {}, + }, + data: { + access: undefined, + }, + }; + + const logout = { + name: 'portainer.logout', + url: '/logout', + params: { + error: '', + }, + views: { + 'content@': { + templateUrl: './views/logout/logout.html', + controller: 'LogoutController', + controllerAs: 'ctrl', + }, + 'sidebar@': {}, + }, + data: { + access: undefined, + }, + }; + + var endpoints = { + name: 'portainer.endpoints', + url: '/endpoints', + views: { + 'content@': { + component: 'environmentsListView', + }, + }, + data: { + docs: '/admin/environments/environments', + }, + }; + + var endpoint = { + name: 'portainer.endpoints.endpoint', + url: '/:id?redirectTo', + params: { + redirectTo: '', + }, + views: { + 'content@': { + component: 'environmentsItemView', + }, + }, + }; + + const edgeAutoCreateScript = { + name: 'portainer.endpoints.edgeAutoCreateScript', + url: '/aeec', + views: { + 'content@': { + component: 'edgeAutoCreateScriptView', + }, + }, + data: { + docs: '/admin/environments/aeec', + }, + }; + + var endpointAccess = { + name: 'portainer.endpoints.endpoint.access', + url: '/access', + views: { + 'content@': { + templateUrl: './views/endpoints/access/endpointAccess.html', + controller: 'EndpointAccessController', + controllerAs: 'ctrl', + }, + }, + }; + + var groups = { + name: 'portainer.groups', + url: '/groups', + views: { + 'content@': { + component: 'environmentGroupsListView', + }, + }, + data: { + docs: '/admin/environments/groups', + access: AccessHeaders.Admin, + }, + }; + + var group = { + name: 'portainer.groups.group', + url: '/:id', + views: { + 'content@': { + component: 'environmentGroupEditView', + }, + }, + params: { + id: { + type: 'int', + }, + tab: { + dynamic: true, + }, + }, + }; + + var groupCreation = { + name: 'portainer.groups.new', + url: '/new', + views: { + 'content@': { + component: 'environmentGroupCreateView', + }, + }, + }; + + var home = { + name: 'portainer.home', + url: '/home?redirect&environmentId&environmentName&route&groupBy&groupFilter&search&order', + params: { + ...paginationParams(), + sort: filterParam(), + order: filterParam(), + groupBy: filterParam(), + groupFilter: filterParam(), + }, + views: { + 'content@': { + component: 'homeView', + }, + }, + data: { + docs: '/user/home', + }, + }; + + var gitopsBase = { + name: 'portainer.gitops', + url: '/gitops', + abstract: true, + }; + + var workflows = { + name: 'portainer.gitops.workflows', + url: '/workflows?search&sort&order&page&pageSize&status&type&platform&groupBy&groupFilter', + data: { docs: '/user/app-delivery/workflows' }, + params: { + ...paginationParams(), + sort: filterParam(), + order: filterParam(), + status: filterParam(), + type: filterParam(), + platform: filterParam(), + groupBy: filterParam(), + groupFilter: filterParam(), + }, + views: { + 'content@': { + component: 'workflowsView', + }, + }, + }; + + var gitopsWorkflowDetail = { + name: 'portainer.gitops.workflows.item', + url: '/:workflowId?tab', + params: { + tab: filterParam('overview'), + }, + views: { + 'content@': { + component: 'workflowItemView', + }, + }, + }; + + var gitopsSources = { + name: 'portainer.gitops.sources', + url: '/sources?search&sort&order&page&pageSize&status&type', + data: { docs: '/user/app-delivery/sources' }, + params: { + ...paginationParams(), + sort: filterParam(), + order: filterParam(), + status: filterParam(), + type: filterParam(), + }, + views: { + 'content@': { + component: 'sourcesListView', + }, + }, + }; + + var gitopsSourceDetail = { + name: 'portainer.gitops.sources.item', + url: '/:sourceId?tab', + params: { + tab: filterParam('settings'), + }, + views: { + 'content@': { + component: 'sourceItemView', + }, + }, + }; + + const gitopsSourceCreate = { + name: 'portainer.gitops.sources.new', + url: '/new', + views: { + 'content@': { + component: 'sourceCreateView', + }, + }, + }; + + var init = { + name: 'portainer.init', + abstract: true, + url: '/init', + views: { + 'sidebar@': {}, + }, + data: { + access: undefined, + }, + }; + + var initAdmin = { + name: 'portainer.init.admin', + url: '/admin', + views: { + 'content@': { + templateUrl: './views/init/admin/initAdmin.html', + controller: 'InitAdminController', + }, + }, + }; + + var settings = { + name: 'portainer.settings', + url: '/settings', + views: { + 'content@': { + component: 'settingsView', + }, + }, + data: { + docs: '/admin/settings', + access: AccessHeaders.Admin, + }, + }; + + var settingsAuthentication = { + name: 'portainer.settings.authentication', + url: '/auth', + views: { + 'content@': { + templateUrl: './views/settings/authentication/settingsAuthentication.html', + controller: 'SettingsAuthenticationController', + }, + }, + data: { + docs: '/admin/settings/authentication', + }, + }; + + var settingsEdgeCompute = { + name: 'portainer.settings.edgeCompute', + url: '/edge', + views: { + 'content@': { + component: 'settingsEdgeComputeView', + }, + }, + data: { + docs: '/admin/settings/edge', + }, + }; + + var tags = { + name: 'portainer.tags', + url: '/tags', + views: { + 'content@': { + templateUrl: './views/tags/tags.html', + controller: 'TagsController', + }, + }, + data: { + docs: '/admin/environments/tags', + access: AccessHeaders.Admin, + }, + }; + + var users = { + name: 'portainer.users', + url: '/users', + views: { + 'content@': { + component: 'usersListView', + }, + }, + data: { + docs: '/admin/user/users', + access: AccessHeaders.Restricted, // allow for team leaders + }, + }; + + var user = { + name: 'portainer.users.user', + url: '/:id', + views: { + 'content@': { + templateUrl: './views/users/edit/user.html', + controller: 'UserController', + }, + }, + }; + + $stateRegistryProvider.register(root); + $stateRegistryProvider.register(endpointRoot); + $stateRegistryProvider.register(portainer); + $stateRegistryProvider.register(account); + $stateRegistryProvider.register(tokenCreation); + $stateRegistryProvider.register(authentication); + $stateRegistryProvider.register(logout); + $stateRegistryProvider.register(endpoints); + $stateRegistryProvider.register(endpoint); + $stateRegistryProvider.register(endpointAccess); + $stateRegistryProvider.register(edgeAutoCreateScript); + $stateRegistryProvider.register(groups); + $stateRegistryProvider.register(group); + $stateRegistryProvider.register(groupCreation); + $stateRegistryProvider.register(home); + $stateRegistryProvider.register(gitopsBase); + $stateRegistryProvider.register(workflows); + $stateRegistryProvider.register(gitopsWorkflowDetail); + $stateRegistryProvider.register(gitopsSources); + $stateRegistryProvider.register(gitopsSourceDetail); + $stateRegistryProvider.register(gitopsSourceCreate); + $stateRegistryProvider.register(init); + $stateRegistryProvider.register(initAdmin); + $stateRegistryProvider.register(settings); + $stateRegistryProvider.register(settingsAuthentication); + $stateRegistryProvider.register(settingsEdgeCompute); + $stateRegistryProvider.register(tags); + $stateRegistryProvider.register(users); + $stateRegistryProvider.register(user); + $stateRegistryProvider.register(createHelmRepository); + }, + ]) + .run(run); + +function isTransitionRequiresAuthentication(transition) { + const UNAUTHENTICATED_ROUTES = ['portainer.logout', 'portainer.auth']; + if (!transition) { + return true; + } + const nextTransition = transition && transition.to(); + const nextTransitionName = nextTransition ? nextTransition.name : ''; + return !UNAUTHENTICATED_ROUTES.some((route) => nextTransitionName.startsWith(route)); +} + +/* @ngInject */ +function run($transitions) { + requiresAuthHook($transitions); +} diff --git a/app/portainer/authorization-guard.test.ts b/app/portainer/authorization-guard.test.ts new file mode 100644 index 0000000..a89199c --- /dev/null +++ b/app/portainer/authorization-guard.test.ts @@ -0,0 +1,139 @@ +import { + StateDeclaration, + StateService, + Transition, +} from '@uirouter/angularjs'; + +import { get, keyBuilder } from '@/react/hooks/useLocalStorage'; + +import { checkAuthorizations } from './authorization-guard'; +import { IAuthenticationService } from './services/types'; + +describe('checkAuthorizations', () => { + let authService = { + init: vi.fn(), + isPureAdmin: vi.fn(), + isAdmin: vi.fn(), + hasAuthorizations: vi.fn(), + getUserDetails: vi.fn(), + isAuthenticated: vi.fn(), + } satisfies IAuthenticationService; + let transition: Transition; + const stateTo: StateDeclaration = { + data: { + access: 'restricted', + }, + }; + const $state = { + target: vi.fn((t) => t), + } as unknown as StateService; + + beforeEach(() => { + authService = { + init: vi.fn(), + isPureAdmin: vi.fn(), + isAdmin: vi.fn(), + hasAuthorizations: vi.fn(), + getUserDetails: vi.fn(), + isAuthenticated: vi.fn(), + }; + + transition = { + injector: vi.fn().mockReturnValue({ + get: vi.fn().mockReturnValue(authService), + }), + to: vi.fn().mockReturnValue(stateTo), + router: { + stateService: $state, + } as Transition['router'], + } as unknown as Transition; + + stateTo.data.access = 'restricted'; + localStorage.removeItem(keyBuilder('RETURN_URL')); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should return undefined if access is not defined', async () => { + stateTo.data.access = undefined; + const result = await checkAuthorizations(transition); + + expect(result).toBeUndefined(); + }); + + it('should return undefined if user is not authenticated and route access is defined', async () => { + stateTo.data.access = 'something'; + authService.init.mockResolvedValue(false); + + const result = await checkAuthorizations(transition); + expect(result).toBeUndefined(); + }); + + it('should return logout if access is "restricted"', async () => { + const result = await checkAuthorizations(transition); + + expect(result).toBeDefined(); + expect($state.target).toHaveBeenCalledWith('portainer.logout'); + }); + + it('should store the current URL in localStorage when the user is not authenticated', async () => { + authService.init.mockResolvedValue(false); + + await checkAuthorizations(transition); + + expect(get('RETURN_URL', null)).toBe( + window.location.pathname + window.location.search + window.location.hash + ); + }); + + it('should not store a returnUrl when the user is authenticated', async () => { + authService.init.mockResolvedValue(true); + + await checkAuthorizations(transition); + + expect(get('RETURN_URL', null)).toBeNull(); + }); + + it('should return undefined if user is an admin and access is "admin"', async () => { + authService.init.mockResolvedValue(true); + authService.isPureAdmin.mockReturnValue(true); + stateTo.data.access = 'admin'; + + const result = await checkAuthorizations(transition); + + expect(result).toBeUndefined(); + }); + + it('should return undefined if user is an admin and access is "edge-admin"', async () => { + authService.init.mockResolvedValue(true); + authService.isAdmin.mockReturnValue(true); + stateTo.data.access = 'edge-admin'; + + const result = await checkAuthorizations(transition); + + expect(result).toBeUndefined(); + }); + + it('should return undefined if user has the required authorizations', async () => { + authService.init.mockResolvedValue(true); + authService.hasAuthorizations.mockReturnValue(true); + stateTo.data.access = ['permission1', 'permission2']; + + const result = await checkAuthorizations(transition); + + expect(result).toBeUndefined(); + }); + + it('should redirect to home if user does not have the required authorizations', async () => { + authService.init.mockResolvedValue(true); + authService.hasAuthorizations.mockReturnValue(false); + stateTo.data.access = ['permission1', 'permission2']; + + const result = await checkAuthorizations(transition); + + expect(result).toBeDefined(); + expect($state.target).toHaveBeenCalledWith('portainer.home'); + }); +}); diff --git a/app/portainer/authorization-guard.ts b/app/portainer/authorization-guard.ts new file mode 100644 index 0000000..c942195 --- /dev/null +++ b/app/portainer/authorization-guard.ts @@ -0,0 +1,104 @@ +import { Transition, TransitionService } from '@uirouter/angularjs'; + +import { storeReturnUrl } from '@/react/portainer/helpers/returnUrl'; + +import { IAuthenticationService } from './services/types'; + +export enum AccessHeaders { + Restricted = 'restricted', + Admin = 'admin', + EdgeAdmin = 'edge-admin', +} + +type Authorizations = string[]; +type Access = + | AccessHeaders.Restricted + | AccessHeaders.Admin + | AccessHeaders.EdgeAdmin + | Authorizations; + +export function requiresAuthHook(transitionService: TransitionService) { + transitionService.onBefore({}, checkAuthorizations); +} + +// exported for tests +export async function checkAuthorizations(transition: Transition) { + const authService: IAuthenticationService = transition + .injector() + .get('Authentication'); + const stateTo = transition.to(); + const $state = transition.router.stateService; + + const { access } = stateTo.data || {}; + if (!isAccess(access)) { + return undefined; + } + + const isLoggedIn = await authService.init(); + + if (!isLoggedIn) { + // eslint-disable-next-line no-console + console.info( + 'User is not authenticated, redirecting to login, access:', + access + ); + const currentUrl = + window.location.pathname + window.location.search + window.location.hash; + storeReturnUrl(currentUrl); + return $state.target('portainer.logout'); + } + + if (typeof access === 'string') { + if (access === 'restricted') { + return undefined; + } + + if (access === 'admin') { + if (authService.isPureAdmin()) { + return undefined; + } + + // eslint-disable-next-line no-console + console.info( + 'User is not an admin, redirecting to home, access:', + access + ); + return $state.target('portainer.home'); + } + + if (access === 'edge-admin') { + if (authService.isAdmin(true)) { + return undefined; + } + + // eslint-disable-next-line no-console + console.info( + 'User is not an edge admin, redirecting to home, access:', + access + ); + return $state.target('portainer.home'); + } + } + + if (access.length > 0 && !authService.hasAuthorizations(access)) { + // eslint-disable-next-line no-console + console.info( + 'User does not have the required authorizations, redirecting to home' + ); + return $state.target('portainer.home'); + } + + return undefined; +} + +function isAccess(access: unknown): access is Access { + if (!access || (typeof access !== 'string' && !Array.isArray(access))) { + return false; + } + + if (Array.isArray(access)) { + return access.every((a) => typeof a === 'string'); + } + + return ['restricted', 'admin', 'edge-admin'].includes(access); +} diff --git a/app/portainer/components/BEFeatureIndicator/BEFeatureIndicator.controller.ts b/app/portainer/components/BEFeatureIndicator/BEFeatureIndicator.controller.ts new file mode 100644 index 0000000..79f9b50 --- /dev/null +++ b/app/portainer/components/BEFeatureIndicator/BEFeatureIndicator.controller.ts @@ -0,0 +1,24 @@ +import { FeatureId } from '@/react/portainer/feature-flags/enums'; + +import { getFeatureDetails } from '@@/BEFeatureIndicator/utils'; + +export default class BeIndicatorController { + limitedToBE?: boolean; + + url?: string; + + feature?: FeatureId; + + /* @ngInject */ + constructor() { + this.limitedToBE = false; + this.url = ''; + } + + $onInit() { + const { url, limitedToBE } = getFeatureDetails(this.feature); + + this.limitedToBE = limitedToBE; + this.url = url; + } +} diff --git a/app/portainer/components/BEFeatureIndicator/BEFeatureIndicator.html b/app/portainer/components/BEFeatureIndicator/BEFeatureIndicator.html new file mode 100644 index 0000000..64a23c1 --- /dev/null +++ b/app/portainer/components/BEFeatureIndicator/BEFeatureIndicator.html @@ -0,0 +1,5 @@ + + + + Business Feature + diff --git a/app/portainer/components/BEFeatureIndicator/index.ts b/app/portainer/components/BEFeatureIndicator/index.ts new file mode 100644 index 0000000..4d253c1 --- /dev/null +++ b/app/portainer/components/BEFeatureIndicator/index.ts @@ -0,0 +1,10 @@ +import controller from './BEFeatureIndicator.controller'; + +export const beFeatureIndicator = { + templateUrl: './BEFeatureIndicator.html', + controller, + bindings: { + feature: '<', + }, + transclude: true, +}; diff --git a/app/portainer/components/BoxSelector/BoxSelectorAngular.ts b/app/portainer/components/BoxSelector/BoxSelectorAngular.ts new file mode 100644 index 0000000..71279d6 --- /dev/null +++ b/app/portainer/components/BoxSelector/BoxSelectorAngular.ts @@ -0,0 +1,53 @@ +import { + IComponentOptions, + IComponentController, + IFormController, + IScope, +} from 'angular'; + +class BoxSelectorController implements IComponentController { + formCtrl!: IFormController; + + onChange!: (value: string | number) => void; + + radioName!: string; + + $scope: IScope; + + /* @ngInject */ + constructor($scope: IScope) { + this.handleChange = this.handleChange.bind(this); + + this.$scope = $scope; + } + + handleChange(value: string | number, limitedToBE: boolean) { + this.$scope.$evalAsync(() => { + this.formCtrl.$setValidity(this.radioName, !limitedToBE, this.formCtrl); + this.onChange(value); + }); + } +} + +export const BoxSelectorAngular: IComponentOptions = { + template: ``, + bindings: { + value: '<', + onChange: '<', + options: '<', + radioName: '<', + slim: '<', + label: '<', + }, + require: { + formCtrl: '^form', + }, + controller: BoxSelectorController, +}; diff --git a/app/portainer/components/BoxSelector/index.ts b/app/portainer/components/BoxSelector/index.ts new file mode 100644 index 0000000..764f177 --- /dev/null +++ b/app/portainer/components/BoxSelector/index.ts @@ -0,0 +1,27 @@ +import angular from 'angular'; + +import { react2angular } from '@/react-tools/react2angular'; + +import { BoxSelector } from '@@/BoxSelector'; + +import { BoxSelectorAngular } from './BoxSelectorAngular'; + +export { buildOption } from './utils'; +const BoxSelectorReact = react2angular(BoxSelector, [ + 'isMulti', + 'value', + 'onChange', + 'options', + 'radioName', + 'slim', + 'hiddenSpacingCount', + 'error', + 'useGridLayout', + 'className', + 'label', +]); + +export const boxSelectorModule = angular + .module('app.portainer.component.box-selector', []) + .component('boxSelectorReact', BoxSelectorReact) + .component('boxSelector', BoxSelectorAngular).name; diff --git a/app/portainer/components/BoxSelector/utils.ts b/app/portainer/components/BoxSelector/utils.ts new file mode 100644 index 0000000..8a6afc4 --- /dev/null +++ b/app/portainer/components/BoxSelector/utils.ts @@ -0,0 +1,15 @@ +import { FeatureId } from '@/react/portainer/feature-flags/enums'; + +import { BoxSelectorOption } from '@@/BoxSelector/types'; +import { IconProps } from '@@/Icon'; + +export function buildOption( + id: BoxSelectorOption['id'], + icon: IconProps['icon'], + label: BoxSelectorOption['label'], + description: BoxSelectorOption['description'], + value: BoxSelectorOption['value'], + feature?: FeatureId +): BoxSelectorOption { + return { id, icon, label, description, value, feature }; +} diff --git a/app/portainer/components/InformationPanel/InformationPanelAngular.html b/app/portainer/components/InformationPanel/InformationPanelAngular.html new file mode 100644 index 0000000..ad7e53d --- /dev/null +++ b/app/portainer/components/InformationPanel/InformationPanelAngular.html @@ -0,0 +1,22 @@ +
+
+ + +
+ + {{ $ctrl.titleText }} + + + + + dismiss + +
+
+ +
+
+
+
+
diff --git a/app/portainer/components/InformationPanel/InformationPanelAngular.js b/app/portainer/components/InformationPanel/InformationPanelAngular.js new file mode 100644 index 0000000..acbf9e7 --- /dev/null +++ b/app/portainer/components/InformationPanel/InformationPanelAngular.js @@ -0,0 +1,8 @@ +export const InformationPanelAngular = { + templateUrl: './InformationPanelAngular.html', + bindings: { + titleText: '@', + dismissAction: '&?', + }, + transclude: true, +}; diff --git a/app/portainer/components/InformationPanel/index.ts b/app/portainer/components/InformationPanel/index.ts new file mode 100644 index 0000000..68b1a80 --- /dev/null +++ b/app/portainer/components/InformationPanel/index.ts @@ -0,0 +1 @@ +export { InformationPanelAngular } from './InformationPanelAngular'; diff --git a/app/portainer/components/accessControlForm/por-access-control-form.js b/app/portainer/components/accessControlForm/por-access-control-form.js new file mode 100644 index 0000000..d757220 --- /dev/null +++ b/app/portainer/components/accessControlForm/por-access-control-form.js @@ -0,0 +1,13 @@ +angular.module('portainer.app').component('porAccessControlForm', { + templateUrl: './porAccessControlForm.html', + controller: 'porAccessControlFormController', + bindings: { + // This object will be populated with the form data. + // Model reference in porAccessControlFromModel.js + formData: '=', + // Optional. An existing resource control object that will be used to set + // the default values of the component. + resourceControl: '<', + hideTitle: '<', + }, +}); diff --git a/app/portainer/components/accessControlForm/porAccessControlForm.html b/app/portainer/components/accessControlForm/porAccessControlForm.html new file mode 100644 index 0000000..d20223d --- /dev/null +++ b/app/portainer/components/accessControlForm/porAccessControlForm.html @@ -0,0 +1,89 @@ +
+
Access control
+ +
+
+ +
+
+ + + + + + + +
+
+ +
+ + You have not yet created any teams. Head over to the Teams view to manage teams. + + + +
+
+
+ + + +
+
+ +
+ + You have not yet created any users. Head over to the Users view to manage users. + + + +
+
+
+ +
diff --git a/app/portainer/components/accessControlForm/porAccessControlFormController.js b/app/portainer/components/accessControlForm/porAccessControlFormController.js new file mode 100644 index 0000000..d8d247e --- /dev/null +++ b/app/portainer/components/accessControlForm/porAccessControlFormController.js @@ -0,0 +1,105 @@ +import _ from 'lodash-es'; +import { ResourceControlOwnership as RCO } from '@/react/portainer/access-control/types'; + +angular.module('portainer.app').controller('porAccessControlFormController', [ + '$q', + '$scope', + '$state', + 'UserService', + 'TeamService', + 'Notifications', + 'Authentication', + 'ResourceControlService', + function ($q, $scope, $state, UserService, TeamService, Notifications, Authentication, ResourceControlService) { + var ctrl = this; + + ctrl.RCO = RCO; + + this.onAuthorizedTeamsChange = onAuthorizedTeamsChange.bind(this); + this.onAuthorizedUsersChange = onAuthorizedUsersChange.bind(this); + + ctrl.availableTeams = []; + ctrl.availableUsers = []; + + ctrl.onChangeEnablement = onChangeEnablement; + ctrl.onChangeOwnership = onChangeOwnership; + + function onChangeOwnership(ownership) { + onChange({ Ownership: ownership }); + } + + function setOwnership(resourceControl, isAdmin) { + if (isAdmin && resourceControl.Ownership === RCO.PRIVATE) { + ctrl.formData.Ownership = RCO.RESTRICTED; + } else { + ctrl.formData.Ownership = resourceControl.Ownership; + } + + if (ctrl.formData.Ownership === RCO.PUBLIC) { + ctrl.formData.AccessControlEnabled = false; + } + } + + function setAuthorizedUsersAndTeams(authorizedUsers, authorizedTeams) { + ctrl.formData.AuthorizedTeams = authorizedTeams; + ctrl.formData.AuthorizedUsers = authorizedUsers; + } + + function onAuthorizedTeamsChange(AuthorizedTeams) { + onChange({ AuthorizedTeams }); + } + + function onAuthorizedUsersChange(AuthorizedUsers) { + onChange({ AuthorizedUsers }); + } + + function onChange(formData) { + $scope.$evalAsync(() => { + ctrl.formData = { + ...ctrl.formData, + ...formData, + }; + }); + } + + this.$onInit = $onInit; + function $onInit() { + var isAdmin = Authentication.isPureAdmin(); + ctrl.isAdmin = isAdmin; + + if (isAdmin) { + ctrl.formData.Ownership = ctrl.RCO.ADMINISTRATORS; + } + + const environmentId = $state.params.endpointId; + $q.all({ + availableTeams: TeamService.teams(environmentId), + availableUsers: isAdmin ? UserService.users(false, environmentId) : [], + }) + .then(function success(data) { + ctrl.availableUsers = _.orderBy(data.availableUsers, 'Username', 'asc'); + ctrl.availableTeams = _.orderBy(data.availableTeams, 'Name', 'asc'); + if (!isAdmin && ctrl.availableTeams.length === 1) { + ctrl.formData.AuthorizedTeams = ctrl.availableTeams; + } + return $q.when(ctrl.resourceControl && ResourceControlService.retrieveOwnershipDetails(ctrl.resourceControl)); + }) + .then(function success(data) { + if (data) { + const authorizedTeams = !isAdmin && ctrl.availableTeams.length === 1 ? ctrl.availableTeams : data.authorizedTeams; + const authorizedUsers = !isAdmin && authorizedTeams.length === 1 ? [] : data.authorizedUsers; + setOwnership(ctrl.resourceControl, isAdmin); + setAuthorizedUsersAndTeams(authorizedUsers, authorizedTeams); + } + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve access control information'); + }); + } + + function onChangeEnablement(enable) { + const isAdmin = Authentication.isAdmin(); + onChange({ AccessControlEnabled: enable, Ownership: isAdmin ? RCO.ADMINISTRATORS : RCO.PRIVATE }); + } + }, +]); diff --git a/app/portainer/components/accessControlForm/porAccessControlFormModel.js b/app/portainer/components/accessControlForm/porAccessControlFormModel.js new file mode 100644 index 0000000..e4c77d9 --- /dev/null +++ b/app/portainer/components/accessControlForm/porAccessControlFormModel.js @@ -0,0 +1,11 @@ +import { ResourceControlOwnership as RCO } from '@/react/portainer/access-control/types'; + +/** + * @deprecated use only for angularjs components. For react components use ./model.ts + */ +export function AccessControlFormData() { + this.AccessControlEnabled = true; + this.Ownership = RCO.PRIVATE; + this.AuthorizedUsers = []; + this.AuthorizedTeams = []; +} diff --git a/app/portainer/components/accessManagement/index.js b/app/portainer/components/accessManagement/index.js new file mode 100644 index 0000000..35afc02 --- /dev/null +++ b/app/portainer/components/accessManagement/index.js @@ -0,0 +1,5 @@ +import angular from 'angular'; + +import { porAccessManagement } from './por-access-management'; + +export default angular.module('portainer.app.component.access-management', []).component('porAccessManagement', porAccessManagement).name; diff --git a/app/portainer/components/accessManagement/por-access-management.js b/app/portainer/components/accessManagement/por-access-management.js new file mode 100644 index 0000000..0ab8670 --- /dev/null +++ b/app/portainer/components/accessManagement/por-access-management.js @@ -0,0 +1,14 @@ +export const porAccessManagement = { + templateUrl: './porAccessManagement.html', + controller: 'porAccessManagementController', + controllerAs: 'ctrl', + bindings: { + accessControlledEntity: '<', + inheritFrom: '<', + entityType: '@', + updateAccess: '<', + actionInProgress: '<', + filterUsers: '<', + limitedFeature: '<', + }, +}; diff --git a/app/portainer/components/accessManagement/porAccessManagement.html b/app/portainer/components/accessManagement/porAccessManagement.html new file mode 100644 index 0000000..9a93a82 --- /dev/null +++ b/app/portainer/components/accessManagement/porAccessManagement.html @@ -0,0 +1,78 @@ +
+
+ + + +
+
+ +

+ + Adding user access will require the affected user(s) to logout and login for the changes to be taken into account. +

+
+
+ + + +
+ +
+
+
+ +
+ +
+
+
+ + +
+
+ +
+
+ +
+
+
+
+
+ + + diff --git a/app/portainer/components/accessManagement/porAccessManagementController.js b/app/portainer/components/accessManagement/porAccessManagementController.js new file mode 100644 index 0000000..096d86c --- /dev/null +++ b/app/portainer/components/accessManagement/porAccessManagementController.js @@ -0,0 +1,117 @@ +import _ from 'lodash-es'; +import angular from 'angular'; + +import { RoleTypes } from '@/portainer/rbac/models/role'; +import { isLimitedToBE } from '@/react/portainer/feature-flags/feature-flags.service'; + +class PorAccessManagementController { + /* @ngInject */ + constructor($scope, $state, Notifications, AccessService, RoleService) { + Object.assign(this, { $scope, $state, Notifications, AccessService, RoleService }); + + this.limitedToBE = false; + this.$state = $state; + + this.unauthorizeAccess = this.unauthorizeAccess.bind(this); + this.updateAction = this.updateAction.bind(this); + this.onChangeUsersAndTeams = this.onChangeUsersAndTeams.bind(this); + } + + onChangeUsersAndTeams(value) { + this.$scope.$evalAsync(() => { + this.formValues.multiselectOutput = value; + }); + } + + updateAction(updatedUserAccesses, updatedTeamAccesses) { + const entity = this.accessControlledEntity; + const oldUserAccessPolicies = entity.UserAccessPolicies; + const oldTeamAccessPolicies = entity.TeamAccessPolicies; + + const accessPolicies = this.AccessService.generateAccessPolicies(oldUserAccessPolicies, oldTeamAccessPolicies, updatedUserAccesses, updatedTeamAccesses); + this.accessControlledEntity.UserAccessPolicies = accessPolicies.userAccessPolicies; + this.accessControlledEntity.TeamAccessPolicies = accessPolicies.teamAccessPolicies; + this.updateAccess(); + } + + authorizeAccess() { + const entity = this.accessControlledEntity; + const oldUserAccessPolicies = entity.UserAccessPolicies; + const oldTeamAccessPolicies = entity.TeamAccessPolicies; + const selectedRoleId = this.formValues.selectedRole.Id; + const selectedUserAccesses = _.filter(this.formValues.multiselectOutput, (access) => access.Type === 'user'); + const selectedTeamAccesses = _.filter(this.formValues.multiselectOutput, (access) => access.Type === 'team'); + + const accessPolicies = this.AccessService.generateAccessPolicies(oldUserAccessPolicies, oldTeamAccessPolicies, selectedUserAccesses, selectedTeamAccesses, selectedRoleId); + this.accessControlledEntity.UserAccessPolicies = accessPolicies.userAccessPolicies; + this.accessControlledEntity.TeamAccessPolicies = accessPolicies.teamAccessPolicies; + this.updateAccess(); + } + + unauthorizeAccess(selectedAccesses) { + const entity = this.accessControlledEntity; + const userAccessPolicies = entity.UserAccessPolicies; + const teamAccessPolicies = entity.TeamAccessPolicies; + const selectedUserAccesses = _.filter(selectedAccesses, (access) => access.Type === 'user'); + const selectedTeamAccesses = _.filter(selectedAccesses, (access) => access.Type === 'team'); + + _.forEach(selectedUserAccesses, (access) => delete userAccessPolicies[access.Id]); + _.forEach(selectedTeamAccesses, (access) => delete teamAccessPolicies[access.Id]); + this.updateAccess(); + } + + isRoleLimitedToBE(role) { + if (!this.limitedToBE) { + return false; + } + + return role.ID !== RoleTypes.STANDARD; + } + + roleLabel(role) { + if (!this.limitedToBE) { + return role.Name; + } + + if (this.isRoleLimitedToBE(role)) { + return `${role.Name} (Business Feature)`; + } + + return `${role.Name} (Default)`; + } + + async $onInit() { + try { + if (this.limitedFeature) { + this.limitedToBE = isLimitedToBE(this.limitedFeature); + } + + const entity = this.accessControlledEntity; + const parent = this.inheritFrom; + + const roles = await this.RoleService.roles(); + this.roles = _.orderBy(roles, 'Priority', 'asc'); + this.formValues = { + multiselectOutput: [], + selectedRole: this.roles.find((role) => !this.isRoleLimitedToBE(role)), + }; + + const data = await this.AccessService.accesses(entity, parent, this.roles); + + if (this.filterUsers) { + data.availableUsersAndTeams = this.filterUsers(data.availableUsersAndTeams); + } + + this.availableUsersAndTeams = _.orderBy(data.availableUsersAndTeams, 'Name', 'asc'); + this.authorizedUsersAndTeams = data.authorizedUsersAndTeams; + } catch (err) { + this.$state.go('portainer.home'); + this.availableUsersAndTeams = []; + this.authorizedUsersAndTeams = []; + this.Notifications.error('Failure', err, 'Unable to retrieve accesses'); + } + } +} + +export default PorAccessManagementController; +angular.module('portainer.app').controller('porAccessManagementController', PorAccessManagementController); diff --git a/app/portainer/components/autofocus.js b/app/portainer/components/autofocus.js new file mode 100644 index 0000000..5a468a6 --- /dev/null +++ b/app/portainer/components/autofocus.js @@ -0,0 +1,15 @@ +angular.module('portainer.app').directive('autoFocus', [ + '$timeout', + function porAutoFocus($timeout) { + var directive = { + restrict: 'A', + link: function (scope, element) { + $timeout(function () { + element[0].focus(); + }); + }, + }; + + return directive; + }, +]); diff --git a/app/portainer/components/buttonSpinner.js b/app/portainer/components/buttonSpinner.js new file mode 100644 index 0000000..b1a43fb --- /dev/null +++ b/app/portainer/components/buttonSpinner.js @@ -0,0 +1,12 @@ +angular.module('portainer.app').directive('buttonSpinner', function buttonSpinner() { + var directive = { + restrict: 'A', + scope: { + spinning: '=buttonSpinner', + }, + transclude: true, + template: ' ', + }; + + return directive; +}); diff --git a/app/portainer/components/code-editor/code-editor.controller.js b/app/portainer/components/code-editor/code-editor.controller.js new file mode 100644 index 0000000..55a4457 --- /dev/null +++ b/app/portainer/components/code-editor/code-editor.controller.js @@ -0,0 +1,27 @@ +/* @ngInject */ +export default function CodeEditorController($scope) { + this.type = ''; + + this.handleChange = (value) => { + $scope.$evalAsync(() => { + this.onChange(value); + }); + }; + + this.$onInit = () => { + this.type = getType({ yaml: this.yml, dockerFile: this.dockerFile, shell: this.shell }); + }; +} + +function getType({ yaml, dockerFile, shell }) { + if (yaml) { + return 'yaml'; + } + if (dockerFile) { + return 'dockerfile'; + } + if (shell) { + return 'shell'; + } + return undefined; +} diff --git a/app/portainer/components/code-editor/code-editor.html b/app/portainer/components/code-editor/code-editor.html new file mode 100644 index 0000000..446da5d --- /dev/null +++ b/app/portainer/components/code-editor/code-editor.html @@ -0,0 +1,10 @@ + diff --git a/app/portainer/components/code-editor/code-editor.js b/app/portainer/components/code-editor/code-editor.js new file mode 100644 index 0000000..25a77c3 --- /dev/null +++ b/app/portainer/components/code-editor/code-editor.js @@ -0,0 +1,18 @@ +import controller from './code-editor.controller'; + +angular.module('portainer.app').component('codeEditor', { + templateUrl: './code-editor.html', + controller, + bindings: { + identifier: '@', + textTip: '@', + yml: '<', + dockerFile: '<', + shell: '<', + readOnly: '<', + onChange: '<', + value: '<', + height: '@', + schema: '<', + }, +}); diff --git a/app/portainer/components/code-editor/codeEditorController.js b/app/portainer/components/code-editor/codeEditorController.js new file mode 100644 index 0000000..73e7734 --- /dev/null +++ b/app/portainer/components/code-editor/codeEditorController.js @@ -0,0 +1,33 @@ +angular.module('portainer.app').controller('CodeEditorController', function CodeEditorController($document, CodeMirrorService, $scope) { + var ctrl = this; + + this.$onChanges = function $onChanges({ value, readOnly }) { + if (!ctrl.editor) { + return; + } + + if (readOnly && typeof readOnly.currentValue === 'boolean' && ctrl.editor.getValue('readOnly') !== ctrl.readOnly) { + ctrl.editor.setOption('readOnly', ctrl.readOnly); + } + if (value && value.currentValue && ctrl.editor.getValue() !== value.currentValue) { + ctrl.editor.setValue(value.currentValue); + } + + if (ctrl.editor) { + ctrl.editor.setOption('readOnly', ctrl.readOnly); + } + }; + + this.$onInit = function () { + $document.ready(function () { + var editorElement = $document[0].getElementById(ctrl.identifier); + ctrl.editor = CodeMirrorService.applyCodeMirrorOnElement(editorElement, ctrl.yml, ctrl.readOnly); + if (ctrl.onChange) { + ctrl.editor.on('change', (...args) => $scope.$evalAsync(() => ctrl.onChange(...args))); + } + if (ctrl.value) { + ctrl.editor.setValue(ctrl.value); + } + }); + }; +}); diff --git a/app/portainer/components/custom-template-selector/custom-template-selector.controller.js b/app/portainer/components/custom-template-selector/custom-template-selector.controller.js new file mode 100644 index 0000000..c7b82b5 --- /dev/null +++ b/app/portainer/components/custom-template-selector/custom-template-selector.controller.js @@ -0,0 +1,36 @@ +class CustomTemplateSelectorController { + /* @ngInject */ + constructor($async, CustomTemplateService, Notifications) { + Object.assign(this, { $async, CustomTemplateService, Notifications }); + + this.selectedTemplate = null; + this.templates = null; + } + + async handleChangeTemplate(templateId) { + this.selectedTemplate = this.templates.find((t) => t.Id === templateId); + this.onChange(templateId, this.selectedTemplate); + } + + $onChanges({ value }) { + if (value && value.currentValue && this.templates) { + this.handleChangeTemplate(value.currentValue); + } + } + + $onInit() { + return this.$async(async () => { + try { + const templates = await this.CustomTemplateService.customTemplates(this.stackType); + this.templates = templates.map((template) => ({ ...template, label: `${template.Title} - ${template.Description}` })); + if (this.value) { + this.handleChangeTemplate(this.value); + } + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to retrieve Custom Templates'); + } + }); + } +} + +export default CustomTemplateSelectorController; diff --git a/app/portainer/components/custom-template-selector/custom-template-selector.html b/app/portainer/components/custom-template-selector/custom-template-selector.html new file mode 100644 index 0000000..cde665d --- /dev/null +++ b/app/portainer/components/custom-template-selector/custom-template-selector.html @@ -0,0 +1,40 @@ +
+
+ +
+ + +

+ Custom template could not be loaded, please + click here for configuration.

+

+ Custom template could not be loaded, please contact your administrator.

+
+ + No custom templates are available. Head over to the custom template view to create one. + +
+
+ + +
+
Information
+
+
+
+
+
+
+ +
diff --git a/app/portainer/components/custom-template-selector/index.js b/app/portainer/components/custom-template-selector/index.js new file mode 100644 index 0000000..9b5e5c0 --- /dev/null +++ b/app/portainer/components/custom-template-selector/index.js @@ -0,0 +1,17 @@ +import angular from 'angular'; +import controller from './custom-template-selector.controller.js'; + +export const customTemplateSelector = { + templateUrl: './custom-template-selector.html', + controller, + bindings: { + newTemplatePath: '@', + stackType: '<', + isLoadFailed: '<', + + value: '<', + onChange: '<', + }, +}; + +angular.module('portainer.app').component('customTemplateSelector', customTemplateSelector); diff --git a/app/portainer/components/endpointSecurity/por-endpoint-security.js b/app/portainer/components/endpointSecurity/por-endpoint-security.js new file mode 100644 index 0000000..7b9c54f --- /dev/null +++ b/app/portainer/components/endpointSecurity/por-endpoint-security.js @@ -0,0 +1,12 @@ +angular.module('portainer.app').component('porEndpointSecurity', { + templateUrl: './porEndpointSecurity.html', + controller: 'porEndpointSecurityController', + bindings: { + // This object will be populated with the form data. + // Model reference in endpointSecurityModel.js + formData: '=', + // The component will use this object to initialize the default values + // if present. + endpoint: '<', + }, +}); diff --git a/app/portainer/components/endpointSecurity/porEndpointSecurity.html b/app/portainer/components/endpointSecurity/porEndpointSecurity.html new file mode 100644 index 0000000..b76955b --- /dev/null +++ b/app/portainer/components/endpointSecurity/porEndpointSecurity.html @@ -0,0 +1,83 @@ +
+ +
+
+ +
+
+ +
TLS mode
+ +
+
+ + You can find out more information about how to protect a Docker environment with TLS in the + Docker documentation. + +
+
+ + + +
Required TLS files
+ +
+ +
+ +
+ + + {{ $ctrl.formData.TLSCACert.name }} + + + +
+
+ + +
+ +
+ +
+ + + {{ $ctrl.formData.TLSCert.name }} + + + +
+
+ + +
+ +
+ + + {{ $ctrl.formData.TLSKey.name }} + + + +
+
+ +
+ +
+ +
diff --git a/app/portainer/components/endpointSecurity/porEndpointSecurityController.js b/app/portainer/components/endpointSecurity/porEndpointSecurityController.js new file mode 100644 index 0000000..56d11a5 --- /dev/null +++ b/app/portainer/components/endpointSecurity/porEndpointSecurityController.js @@ -0,0 +1,56 @@ +import { tlsOptions } from '@/react/portainer/environments/ItemView/tls-options'; + +angular.module('portainer.app').controller('porEndpointSecurityController', [ + '$scope', + function ($scope) { + var ctrl = this; + + this.tlsOptions = tlsOptions; + + function onChange(values) { + $scope.$evalAsync(() => { + ctrl.formData = { + ...ctrl.formData, + ...values, + }; + }); + } + + ctrl.onChangeTLSMode = onChangeTLSMode; + function onChangeTLSMode(mode) { + onChange({ TLSMode: mode }); + } + + ctrl.onToggleTLS = onToggleTLS; + function onToggleTLS(newValue) { + onChange({ TLS: newValue }); + } + + this.$onInit = $onInit; + function $onInit() { + if (ctrl.endpoint) { + var endpoint = ctrl.endpoint; + var TLS = endpoint.TLSConfig.TLS; + ctrl.formData.TLS = TLS; + var CACert = endpoint.TLSConfig.TLSCACert; + ctrl.formData.TLSCACert = CACert; + var cert = endpoint.TLSConfig.TLSCert; + ctrl.formData.TLSCert = cert; + var key = endpoint.TLSConfig.TLSKey; + ctrl.formData.TLSKey = key; + + if (TLS) { + if (CACert && cert && key) { + ctrl.formData.TLSMode = 'tls_client_ca'; + } else if (cert && key) { + ctrl.formData.TLSMode = 'tls_client_noca'; + } else if (CACert) { + ctrl.formData.TLSMode = 'tls_ca'; + } else { + ctrl.formData.TLSMode = 'tls_only'; + } + } + } + } + }, +]); diff --git a/app/portainer/components/endpointSecurity/porEndpointSecurityModel.js b/app/portainer/components/endpointSecurity/porEndpointSecurityModel.js new file mode 100644 index 0000000..795bdb7 --- /dev/null +++ b/app/portainer/components/endpointSecurity/porEndpointSecurityModel.js @@ -0,0 +1,7 @@ +export function EndpointSecurityFormData() { + this.TLS = false; + this.TLSMode = 'tls_client_ca'; + this.TLSCACert = null; + this.TLSCert = null; + this.TLSKey = null; +} diff --git a/app/portainer/components/focusIf.js b/app/portainer/components/focusIf.js new file mode 100644 index 0000000..6a8650e --- /dev/null +++ b/app/portainer/components/focusIf.js @@ -0,0 +1,25 @@ +import angular from 'angular'; +// ng-focus-if pkg from: https://github.com/hiebj/ng-focus-if +angular.module('portainer.app').directive('focusIf', function ($timeout) { + return { + restrict: 'A', + link: function ($scope, $element, $attrs) { + var dom = $element[0]; + if ($attrs.focusIf) { + $scope.$watch($attrs.focusIf, focus); + } else { + focus(true); + } + function focus(condition) { + if (condition) { + $timeout( + function () { + dom.focus(); + }, + $scope.$eval($attrs.focusDelay) || 0 + ); + } + } + }, + }; +}); diff --git a/app/portainer/components/form-components/file-upload-form/file-upload-form.html b/app/portainer/components/form-components/file-upload-form/file-upload-form.html new file mode 100644 index 0000000..d6dddc7 --- /dev/null +++ b/app/portainer/components/form-components/file-upload-form/file-upload-form.html @@ -0,0 +1,11 @@ + +
Upload
+
+ +
+
+
+ +
+
+
diff --git a/app/portainer/components/form-components/file-upload-form/index.js b/app/portainer/components/form-components/file-upload-form/index.js new file mode 100644 index 0000000..8a3afeb --- /dev/null +++ b/app/portainer/components/form-components/file-upload-form/index.js @@ -0,0 +1,13 @@ +export const fileUploadForm = { + templateUrl: './file-upload-form.html', + + bindings: { + file: '<', + ngRequired: '<', + onChange: '<', + }, + + transclude: { + description: '?fileUploadDescription', + }, +}; diff --git a/app/portainer/components/form-components/index.js b/app/portainer/components/form-components/index.js new file mode 100644 index 0000000..a60a755 --- /dev/null +++ b/app/portainer/components/form-components/index.js @@ -0,0 +1,6 @@ +import angular from 'angular'; + +import { webEditorForm } from './web-editor-form'; +import { fileUploadForm } from './file-upload-form'; + +export default angular.module('portainer.app.components.form', []).component('webEditorForm', webEditorForm).component('fileUploadForm', fileUploadForm).name; diff --git a/app/portainer/components/form-components/web-editor-form/index.js b/app/portainer/components/form-components/web-editor-form/index.js new file mode 100644 index 0000000..804966f --- /dev/null +++ b/app/portainer/components/form-components/web-editor-form/index.js @@ -0,0 +1,22 @@ +import controller from './web-editor-form.controller.js'; + +export const webEditorForm = { + templateUrl: './web-editor-form.html', + controller, + + bindings: { + identifier: '@', + textTip: '@', + yml: '<', + value: '<', + readOnly: '<', + onChange: '<', + hideTitle: '<', + height: '@', + schema: '<', + }, + + transclude: { + description: '?editorDescription', + }, +}; diff --git a/app/portainer/components/form-components/web-editor-form/web-editor-form.controller.js b/app/portainer/components/form-components/web-editor-form/web-editor-form.controller.js new file mode 100644 index 0000000..09c2e4c --- /dev/null +++ b/app/portainer/components/form-components/web-editor-form/web-editor-form.controller.js @@ -0,0 +1,8 @@ +class WebEditorFormController { + /* @ngInject */ + constructor(BROWSER_OS_PLATFORM) { + this.BROWSER_OS_PLATFORM = BROWSER_OS_PLATFORM; + } +} + +export default WebEditorFormController; diff --git a/app/portainer/components/form-components/web-editor-form/web-editor-form.html b/app/portainer/components/form-components/web-editor-form/web-editor-form.html new file mode 100644 index 0000000..336da3b --- /dev/null +++ b/app/portainer/components/form-components/web-editor-form/web-editor-form.html @@ -0,0 +1,56 @@ + +
+
Web editor +
+ Ctrl+F for search + Cmd+F for search + + + + +
+
+
+
+
+ +
+
+
+
diff --git a/app/portainer/components/forms/git-form/git-form.controller.ts b/app/portainer/components/forms/git-form/git-form.controller.ts new file mode 100644 index 0000000..43a5295 --- /dev/null +++ b/app/portainer/components/forms/git-form/git-form.controller.ts @@ -0,0 +1,65 @@ +import { IFormController } from 'angular'; +import { FormikErrors } from 'formik'; + +import { DeployMethod, GitFormModel } from '@/react/portainer/gitops/types'; +import { validateGitForm } from '@/react/portainer/gitops/GitForm'; + +export default class GitFormController { + errors?: FormikErrors; + + $async: (fn: () => Promise) => Promise; + + gitForm?: IFormController; + + value?: GitFormModel; + + onChange?: (value: GitFormModel) => void; + + createdFromCustomTemplateId?: number; + + deployMethod?: DeployMethod; + + /* @ngInject */ + constructor($async: (fn: () => Promise) => Promise) { + this.$async = $async; + + this.handleChange = this.handleChange.bind(this); + this.runGitFormValidation = this.runGitFormValidation.bind(this); + } + + async handleChange(newValues: Partial) { + // this should never happen, but just in case + if (!this.value) { + throw new Error('GitFormController: value is required'); + } + + const value = { + ...this.value, + ...newValues, + }; + this.onChange?.(value); + + await this.runGitFormValidation(value); + } + + async runGitFormValidation(value: GitFormModel) { + return this.$async(async () => { + this.errors = {}; + this.gitForm?.$setValidity('gitForm', true, this.gitForm); + + this.errors = await validateGitForm(value, this.deployMethod); + if (this.errors && Object.keys(this.errors).length > 0) { + this.gitForm?.$setValidity('gitForm', false, this.gitForm); + } + }); + } + + async $onInit() { + // this should never happen, but just in case + if (!this.value) { + throw new Error('GitFormController: value is required'); + } + + await this.runGitFormValidation(this.value); + } +} diff --git a/app/portainer/components/forms/git-form/git-form.ts b/app/portainer/components/forms/git-form/git-form.ts new file mode 100644 index 0000000..20eca21 --- /dev/null +++ b/app/portainer/components/forms/git-form/git-form.ts @@ -0,0 +1,39 @@ +import { IComponentOptions } from 'angular'; + +import controller from './git-form.controller'; + +export const gitForm: IComponentOptions = { + template: ` + + + +`, + bindings: { + value: '<', + onChange: '<', + environmentType: '@', + isDockerStandalone: '<', + deployMethod: '@', + baseWebhookUrl: '@', + isAdditionalFilesFieldVisible: '<', + isForcePullVisible: '<', + isAuthExplanationVisible: '<', + webhookId: '@', + webhooksDocs: '@', + createdFromCustomTemplateId: '<', + }, + controller, +}; diff --git a/app/portainer/components/forms/git-form/index.ts b/app/portainer/components/forms/git-form/index.ts new file mode 100644 index 0000000..b6b4cc4 --- /dev/null +++ b/app/portainer/components/forms/git-form/index.ts @@ -0,0 +1,7 @@ +import angular from 'angular'; + +import { gitForm } from './git-form'; + +export const gitFormModule = angular + .module('portainer.app.components.git-form', []) + .component('gitForm', gitForm).name; diff --git a/app/portainer/components/forms/group-form/groupForm.html b/app/portainer/components/forms/group-form/groupForm.html new file mode 100644 index 0000000..cee7bde --- /dev/null +++ b/app/portainer/components/forms/group-form/groupForm.html @@ -0,0 +1,69 @@ +
+ +
+ +
+ +
+
+
+

+ + This field is required. +

+
+
+
+
+
+ + +
+ +
+ +
+
+ +
Metadata
+ + + +
+ +
+ +
+ +
+ + +
Actions
+
+
+ +
+
+ +
diff --git a/app/portainer/components/forms/group-form/groupFormController.js b/app/portainer/components/forms/group-form/groupFormController.js new file mode 100644 index 0000000..b7675ac --- /dev/null +++ b/app/portainer/components/forms/group-form/groupFormController.js @@ -0,0 +1,31 @@ +import angular from 'angular'; + +class GroupFormController { + /* @ngInject */ + constructor($async, $scope, GroupService, Notifications, Authentication) { + this.$async = $async; + this.$scope = $scope; + this.GroupService = GroupService; + this.Notifications = Notifications; + this.Authentication = Authentication; + + this.state = { + allowCreateTag: this.Authentication.isAdmin(), + }; + + this.unassociatedQuery = { + groupIds: [1], + }; + + this.onChangeTags = this.onChangeTags.bind(this); + } + + onChangeTags(value) { + return this.$scope.$evalAsync(() => { + this.model.TagIds = value; + }); + } +} + +angular.module('portainer.app').controller('GroupFormController', GroupFormController); +export default GroupFormController; diff --git a/app/portainer/components/forms/registry-form-aws-ecr/registry-form-ecr.html b/app/portainer/components/forms/registry-form-aws-ecr/registry-form-ecr.html new file mode 100644 index 0000000..6f930db --- /dev/null +++ b/app/portainer/components/forms/registry-form-aws-ecr/registry-form-ecr.html @@ -0,0 +1,184 @@ +
+
Important notice
+
+ + For information on how to generate an Access Key, follow the + AWS guide. + +
+ +
ECR connection details
+ + +
+ +
+ +
+
+
+

+ + This field is required. +

+

+ + A registry with the same name already exists. +

+
+
+
+
+
+ + + +
+ +
+ +
+
+
+

+ + This field is required. +

+
+
+
+
+
+ + + +
+
+ +
+
+ + +
+ +
+ +
+ +
+
+
+

+ + This field is required. +

+
+
+
+
+
+ + + +
+ +
+ +
+
+
+

+ + This field is required. +

+
+
+
+
+
+ + + +
+ +
+ +
+
+
+

+ + This field is required. +

+

+ + A registry with the same name already exists. +

+
+
+
+
+
+ +
+ + +
Actions
+
+
+ +
+
+ +
diff --git a/app/portainer/components/forms/registry-form-aws-ecr/registry-form-ecr.js b/app/portainer/components/forms/registry-form-aws-ecr/registry-form-ecr.js new file mode 100644 index 0000000..2eba888 --- /dev/null +++ b/app/portainer/components/forms/registry-form-aws-ecr/registry-form-ecr.js @@ -0,0 +1,28 @@ +class controller { + constructor($scope) { + this.$scope = $scope; + this.toggleAuthentication = this.toggleAuthentication.bind(this); + } + + $postLink() { + this.registryFormEcr.registry_name.$validators.used = (modelValue) => !this.nameIsUsed(modelValue); + } + + toggleAuthentication(newValue) { + this.$scope.$evalAsync(() => { + this.model.Authentication = newValue; + }); + } +} + +angular.module('portainer.app').component('registryFormEcr', { + templateUrl: './registry-form-ecr.html', + bindings: { + model: '=', + formAction: '<', + formActionLabel: '@', + actionInProgress: '<', + nameIsUsed: '<', + }, + controller, +}); diff --git a/app/portainer/components/forms/registry-form-azure/registry-form-azure.html b/app/portainer/components/forms/registry-form-azure/registry-form-azure.html new file mode 100644 index 0000000..478c831 --- /dev/null +++ b/app/portainer/components/forms/registry-form-azure/registry-form-azure.html @@ -0,0 +1,121 @@ +
+
Azure registry details
+ +
+ +
+ +
+
+
+

+ + This field is required. +

+

+ + A registry with the same name already exists. +

+
+
+
+
+
+ + +
+ +
+ +
+
+
+

+ + This field is required. +

+
+
+
+
+
+ + +
+ +
+ +
+
+
+

+ + This field is required. +

+
+
+
+
+
+ + +
+ +
+ +
+
+
+

+ + This field is required. +

+
+
+
+
+
+ + +
Actions
+
+
+ +
+
+ +
diff --git a/app/portainer/components/forms/registry-form-azure/registry-form-azure.js b/app/portainer/components/forms/registry-form-azure/registry-form-azure.js new file mode 100644 index 0000000..c8305da --- /dev/null +++ b/app/portainer/components/forms/registry-form-azure/registry-form-azure.js @@ -0,0 +1,17 @@ +class controller { + $postLink() { + this.registryFormAzure.registry_name.$validators.used = (modelValue) => !this.nameIsUsed(modelValue); + } +} + +angular.module('portainer.app').component('registryFormAzure', { + templateUrl: './registry-form-azure.html', + bindings: { + model: '=', + formAction: '<', + formActionLabel: '@', + actionInProgress: '<', + nameIsUsed: '<', + }, + controller, +}); diff --git a/app/portainer/components/forms/registry-form-custom/registry-form-custom.html b/app/portainer/components/forms/registry-form-custom/registry-form-custom.html new file mode 100644 index 0000000..0095450 --- /dev/null +++ b/app/portainer/components/forms/registry-form-custom/registry-form-custom.html @@ -0,0 +1,153 @@ +
+
Important notice
+
+ + Docker requires you to connect to a secure registry. You can find more + information about how to connect to an insecure registry in the Docker documentation. + +
+
Custom registry details
+ +
+ +
+ +
+
+
+

+ + This field is required. +

+

+ + A registry with the same name already exists. +

+
+
+
+
+
+ + +
+ +
+ +
+
+
+

+ + This field is required. +

+
+
+
+
+
+ + +
+
+ +
+
+ +
+ +
+ +
+ +
+
+
+

+ + This field is required. +

+
+
+
+
+
+ + +
+ +
+ +
+
+
+

+ + This field is required. +

+
+
+
+
+
+ +
+ +
Actions
+
+
+ +
+
+ +
diff --git a/app/portainer/components/forms/registry-form-custom/registry-form-custom.js b/app/portainer/components/forms/registry-form-custom/registry-form-custom.js new file mode 100644 index 0000000..c0e56e7 --- /dev/null +++ b/app/portainer/components/forms/registry-form-custom/registry-form-custom.js @@ -0,0 +1,28 @@ +class controller { + constructor($scope) { + this.$scope = $scope; + this.toggleAuthentication = this.toggleAuthentication.bind(this); + } + + $postLink() { + this.registryFormCustom.registry_name.$validators.used = (modelValue) => !this.nameIsUsed(modelValue); + } + + toggleAuthentication(newValue) { + this.$scope.$evalAsync(() => { + this.model.Authentication = newValue; + }); + } +} + +angular.module('portainer.app').component('registryFormCustom', { + templateUrl: './registry-form-custom.html', + bindings: { + model: '=', + formAction: '<', + formActionLabel: '@', + actionInProgress: '<', + nameIsUsed: '<', + }, + controller, +}); diff --git a/app/portainer/components/forms/registry-form-gitlab/registry-form-gitlab.controller.js b/app/portainer/components/forms/registry-form-gitlab/registry-form-gitlab.controller.js new file mode 100644 index 0000000..1b83bbd --- /dev/null +++ b/app/portainer/components/forms/registry-form-gitlab/registry-form-gitlab.controller.js @@ -0,0 +1,12 @@ +/* @ngInject */ +export default function RegistryFormGitlab($scope) { + this.selectedRegistries = []; + + this.onChangeRegistries = onChangeRegistries.bind(this); + + function onChangeRegistries(value) { + $scope.$evalAsync(() => { + this.selectedRegistries = value; + }); + } +} diff --git a/app/portainer/components/forms/registry-form-gitlab/registry-form-gitlab.html b/app/portainer/components/forms/registry-form-gitlab/registry-form-gitlab.html new file mode 100644 index 0000000..370498c --- /dev/null +++ b/app/portainer/components/forms/registry-form-gitlab/registry-form-gitlab.html @@ -0,0 +1,172 @@ +
+
Important notice
+
+ +

+ For information on how to generate a GitLab Personal Access Token, follow the + GitLab guide. +

+
+
+
GitLab registry connection details
+ +
+ +
+ +
+
+
+

+ + This field is required. +

+
+
+
+
+
+ + +
+ +
+ +
+
+
+

+ + This field is required. +

+
+
+
+
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+
+
+

+ + This field is required. +

+
+
+
+
+
+ + +
+ +
+ +
+
+
+

+ + This field is required. +

+
+
+
+
+
+ + +
+
+ +
+
+
+
+
GitLab projects
+
+ Select the project's registries you want to manage. Portainer will create one registry for each selected project. + + + If you can't select a project, make sure that registry feature is activated on it. + +
+ + + +
+
+ +
+
+ +
diff --git a/app/portainer/components/forms/registry-form-gitlab/registry-form-gitlab.js b/app/portainer/components/forms/registry-form-gitlab/registry-form-gitlab.js new file mode 100644 index 0000000..13a78fb --- /dev/null +++ b/app/portainer/components/forms/registry-form-gitlab/registry-form-gitlab.js @@ -0,0 +1,15 @@ +import controller from './registry-form-gitlab.controller'; + +angular.module('portainer.app').component('registryFormGitlab', { + templateUrl: './registry-form-gitlab.html', + controller, + bindings: { + model: '=', + retrieveRegistries: '<', + createRegistries: '<', + actionInProgress: '<', + projects: '=', + state: '=', + resetDefaults: '<', + }, +}); diff --git a/app/portainer/components/forms/registry-form-proget/registry-form-proget.html b/app/portainer/components/forms/registry-form-proget/registry-form-proget.html new file mode 100644 index 0000000..3da38e1 --- /dev/null +++ b/app/portainer/components/forms/registry-form-proget/registry-form-proget.html @@ -0,0 +1,160 @@ +
+
Important notice
+
+
+ Any Portainer user that has access to this registry will be able to use the Registry Manager features against the content of any Feed in the ProGet registry that the ProGet + user has access to. +
+
+
ProGet registry details
+ +
+ +
+ +
+
+
+

+ + This field is required. +

+

+ + A registry with the same name already exists. +

+
+
+
+
+
+ + +
+ +
+ +
+
+
+

+ + This field is required. +

+
+
+
+
+
+ + +
+ +
+ +
+
+
+

+ + This field is required. +

+
+
+
+
+
+ +
+ +
+ +
+ +
+
+
+

+ + This field is required. +

+
+
+
+
+
+ + +
+ +
+ +
+
+
+

+ + This field is required. +

+
+
+
+
+
+ +
+ +
Actions
+
+
+ +
+
+ +
diff --git a/app/portainer/components/forms/registry-form-proget/registry-form-proget.js b/app/portainer/components/forms/registry-form-proget/registry-form-proget.js new file mode 100644 index 0000000..46df5ac --- /dev/null +++ b/app/portainer/components/forms/registry-form-proget/registry-form-proget.js @@ -0,0 +1,17 @@ +class controller { + $postLink() { + this.registryFormProGet.registry_name.$validators.used = (modelValue) => !this.nameIsUsed(modelValue); + } +} + +angular.module('portainer.app').component('registryFormProget', { + templateUrl: './registry-form-proget.html', + bindings: { + model: '=', + formAction: '<', + formActionLabel: '@', + actionInProgress: '<', + nameIsUsed: '<', + }, + controller, +}); diff --git a/app/portainer/components/forms/registry-form-quay/registry-form-quay.html b/app/portainer/components/forms/registry-form-quay/registry-form-quay.html new file mode 100644 index 0000000..6253eee --- /dev/null +++ b/app/portainer/components/forms/registry-form-quay/registry-form-quay.html @@ -0,0 +1,132 @@ +
+
Quay account details
+ +
+ +
+ +
+
+
+

+ + This field is required. +

+

+ + A registry with the same name already exists. +

+
+
+
+
+
+ + +
+ +
+ +
+
+
+

+ + This field is required. +

+
+
+
+
+
+ + +
+ +
+ +
+
+
+

+ + This field is required. +

+
+
+
+
+
+ + + +
+
+ +
+
+ +
+ +
+ +
+ +
+
+
+

+ + This field is required. +

+
+
+
+
+
+ +
+ + +
Actions
+
+
+ +
+
+ +
diff --git a/app/portainer/components/forms/registry-form-quay/registry-form-quay.js b/app/portainer/components/forms/registry-form-quay/registry-form-quay.js new file mode 100644 index 0000000..24869cf --- /dev/null +++ b/app/portainer/components/forms/registry-form-quay/registry-form-quay.js @@ -0,0 +1,28 @@ +class controller { + constructor($scope) { + this.$scope = $scope; + this.toggleOrganisation = this.toggleOrganisation.bind(this); + } + + $postLink() { + this.registryFormQuay.registry_name.$validators.used = (modelValue) => !this.nameIsUsed(modelValue); + } + + toggleOrganisation(newValue) { + this.$scope.$evalAsync(() => { + this.model.Quay.useOrganisation = newValue; + }); + } +} + +angular.module('portainer.app').component('registryFormQuay', { + templateUrl: './registry-form-quay.html', + bindings: { + model: '=', + formAction: '<', + formActionLabel: '@', + actionInProgress: '<', + nameIsUsed: '<', + }, + controller, +}); diff --git a/app/portainer/components/index.js b/app/portainer/components/index.js new file mode 100644 index 0000000..033af9f --- /dev/null +++ b/app/portainer/components/index.js @@ -0,0 +1,16 @@ +import angular from 'angular'; + +import formComponentsModule from './form-components'; +import porAccessManagementModule from './accessManagement'; +import widgetModule from './widget'; +import { boxSelectorModule } from './BoxSelector'; + +import { beFeatureIndicator } from './BEFeatureIndicator'; +import { InformationPanelAngular } from './InformationPanel'; +import { gitFormModule } from './forms/git-form'; +import { tlsFieldsetModule } from './tls-fieldset'; + +export default angular + .module('portainer.app.components', [boxSelectorModule, widgetModule, gitFormModule, porAccessManagementModule, formComponentsModule, tlsFieldsetModule]) + .component('informationPanel', InformationPanelAngular) + .component('beFeatureIndicator', beFeatureIndicator).name; diff --git a/app/portainer/components/onEnterKey.js b/app/portainer/components/onEnterKey.js new file mode 100644 index 0000000..d02e6d7 --- /dev/null +++ b/app/portainer/components/onEnterKey.js @@ -0,0 +1,19 @@ +angular.module('portainer.app').directive('onEnterKey', [ + function porOnEnterKey() { + var directive = { + restrict: 'A', + link: function (scope, element, attrs) { + element.bind('keydown keypress', function (e) { + if (e.which === 13) { + e.preventDefault(); + scope.$apply(function () { + scope.$eval(attrs.onEnterKey); + }); + } + }); + }, + }; + + return directive; + }, +]); diff --git a/app/portainer/components/option-panel/index.js b/app/portainer/components/option-panel/index.js new file mode 100644 index 0000000..f06f27a --- /dev/null +++ b/app/portainer/components/option-panel/index.js @@ -0,0 +1,13 @@ +import angular from 'angular'; + +import controller from './option-panel.controller.js'; + +angular.module('portainer.app').component('optionPanel', { + templateUrl: './option-panel.html', + controller, + bindings: { + ngModel: '<', + explanation: '@', + onChange: '<', + }, +}); diff --git a/app/portainer/components/option-panel/option-panel.controller.js b/app/portainer/components/option-panel/option-panel.controller.js new file mode 100644 index 0000000..f1b8fed --- /dev/null +++ b/app/portainer/components/option-panel/option-panel.controller.js @@ -0,0 +1,10 @@ +export default class OptionPanelController { + /* @ngInject */ + constructor() { + this.switchPruneService = this.switchPruneService.bind(this); + } + + switchPruneService() { + this.onChange(this.ngModel); + } +} diff --git a/app/portainer/components/option-panel/option-panel.html b/app/portainer/components/option-panel/option-panel.html new file mode 100644 index 0000000..d52cea9 --- /dev/null +++ b/app/portainer/components/option-panel/option-panel.html @@ -0,0 +1,15 @@ + +
+
Options
+ + +
+ + +
+ +
+
diff --git a/app/portainer/components/product-list/product-item/product-item.js b/app/portainer/components/product-list/product-item/product-item.js new file mode 100644 index 0000000..fd0fe06 --- /dev/null +++ b/app/portainer/components/product-list/product-item/product-item.js @@ -0,0 +1,7 @@ +angular.module('portainer.app').component('productItem', { + templateUrl: './productItem.html', + bindings: { + model: '<', + goTo: '<', + }, +}); diff --git a/app/portainer/components/product-list/product-item/productItem.html b/app/portainer/components/product-list/product-item/productItem.html new file mode 100644 index 0000000..9e48829 --- /dev/null +++ b/app/portainer/components/product-list/product-item/productItem.html @@ -0,0 +1,34 @@ + +
+
+ + + + + + +
+ + + {{ $ctrl.model.Name }} + + +
+ + +
+ + + {{ $ctrl.model.ShortDescription }} + + +
+ +
+ +
+ +
diff --git a/app/portainer/components/product-list/product-list.js b/app/portainer/components/product-list/product-list.js new file mode 100644 index 0000000..ac3a6b5 --- /dev/null +++ b/app/portainer/components/product-list/product-list.js @@ -0,0 +1,8 @@ +angular.module('portainer.app').component('productList', { + templateUrl: './productList.html', + bindings: { + titleText: '@', + products: '<', + goTo: '<', + }, +}); diff --git a/app/portainer/components/product-list/productList.html b/app/portainer/components/product-list/productList.html new file mode 100644 index 0000000..5bdb44e --- /dev/null +++ b/app/portainer/components/product-list/productList.html @@ -0,0 +1,16 @@ +
+ + +
+
+ + {{ $ctrl.titleText }} +
+
+ +
+ +
+
+
+
diff --git a/app/portainer/components/registry-details/index.js b/app/portainer/components/registry-details/index.js new file mode 100644 index 0000000..418c487 --- /dev/null +++ b/app/portainer/components/registry-details/index.js @@ -0,0 +1,10 @@ +import angular from 'angular'; + +export const registryDetails = { + templateUrl: './registry-details.html', + bindings: { + registry: '<', + }, +}; + +angular.module('portainer.app').component('registryDetails', registryDetails); diff --git a/app/portainer/components/registry-details/registry-details.html b/app/portainer/components/registry-details/registry-details.html new file mode 100644 index 0000000..3c53bca --- /dev/null +++ b/app/portainer/components/registry-details/registry-details.html @@ -0,0 +1,25 @@ +
+
+ + + + + + + + + + + + + + +
Name + {{ $ctrl.registry.Name }} +
URL + {{ $ctrl.registry.URL }} +
+
+
+
+
diff --git a/app/portainer/components/slider/slider.html b/app/portainer/components/slider/slider.html new file mode 100644 index 0000000..94cb04a --- /dev/null +++ b/app/portainer/components/slider/slider.html @@ -0,0 +1,3 @@ +
+ +
diff --git a/app/portainer/components/slider/slider.js b/app/portainer/components/slider/slider.js new file mode 100644 index 0000000..c57f040 --- /dev/null +++ b/app/portainer/components/slider/slider.js @@ -0,0 +1,12 @@ +angular.module('portainer.app').component('slider', { + templateUrl: './slider.html', + controller: 'SliderController', + bindings: { + model: '=', + onChange: '<', + floor: '<', + ceil: '<', + step: '<', + precision: '<', + }, +}); diff --git a/app/portainer/components/slider/sliderController.js b/app/portainer/components/slider/sliderController.js new file mode 100644 index 0000000..4ac47d9 --- /dev/null +++ b/app/portainer/components/slider/sliderController.js @@ -0,0 +1,45 @@ +// TODO: k8s merge - TEST WITH EXISTING SLIDERS ! +// Not sure if this is not breaking existing sliders on docker views +// Or sliders with onChange call (docker service update view) +import angular from 'angular'; + +class SliderController { + /* @ngInject */ + constructor($scope) { + this.$scope = $scope; + + this.buildOptions = this.buildOptions.bind(this); + this.translate = this.translate.bind(this); + } + + $onChanges() { + this.buildOptions(); + } + + translate(value, sliderId, label) { + if ((label === 'floor' && this.floor === 0) || value === 0) { + return 'unlimited'; + } + return value; + } + + buildOptions() { + this.options = { + floor: this.floor, + ceil: this.ceil, + step: this.step, + precision: this.precision, + showSelectionBar: true, + enforceStep: false, + translate: this.translate, + onChange: () => this.onChange(), + }; + } + + $onInit() { + this.buildOptions(); + } +} + +export default SliderController; +angular.module('portainer.app').controller('SliderController', SliderController); diff --git a/app/portainer/components/template-widget.js b/app/portainer/components/template-widget.js new file mode 100644 index 0000000..fb989fd --- /dev/null +++ b/app/portainer/components/template-widget.js @@ -0,0 +1,11 @@ +angular.module('portainer.app').directive('rdTemplateWidget', function rdWidget() { + var directive = { + scope: { + ngModel: '=', + }, + transclude: true, + template: '
', + restrict: 'EA', + }; + return directive; +}); diff --git a/app/portainer/components/theme/theme-settings.controller.js b/app/portainer/components/theme/theme-settings.controller.js new file mode 100644 index 0000000..bb614d7 --- /dev/null +++ b/app/portainer/components/theme/theme-settings.controller.js @@ -0,0 +1,61 @@ +import { notifyError, notifySuccess } from '@/portainer/services/notifications'; +import { userQueryKeys } from '@/portainer/users/queries/queryKeys'; +import { queryClient } from '@/react-tools/react-query'; +import { options } from '@/react/portainer/account/AccountView/theme-options'; + +export default class ThemeSettingsController { + /* @ngInject */ + constructor($async, Authentication, ThemeManager, StateManager, UserService) { + this.$async = $async; + this.Authentication = Authentication; + this.ThemeManager = ThemeManager; + this.StateManager = StateManager; + this.UserService = UserService; + + this.setThemeColor = this.setThemeColor.bind(this); + } + + async setThemeColor(color) { + return this.$async(async () => { + if (color === 'auto' || !color) { + this.ThemeManager.autoTheme(); + } else { + this.ThemeManager.setTheme(color); + } + + this.state.themeColor = color; + this.updateThemeSettings({ color }); + }); + } + + async updateThemeSettings(theme) { + try { + await this.UserService.updateUserTheme(this.state.userId, theme); + await queryClient.invalidateQueries(userQueryKeys.user(this.state.userId)); + + notifySuccess('Success', 'User theme settings successfully updated'); + } catch (err) { + notifyError('Failure', err, 'Unable to update user theme settings'); + } + } + + $onInit() { + return this.$async(async () => { + this.state = { + userId: null, + themeColor: 'auto', + }; + + this.state.availableThemes = options; + + try { + this.state.userId = await this.Authentication.getUserDetails().ID; + const user = await this.UserService.user(this.state.userId); + + this.state.themeColor = user.ThemeSettings.color || this.state.themeColor; + } catch (err) { + notifyError('Failure', err, 'Unable to get user details'); + } + }); + } +} diff --git a/app/portainer/components/theme/theme-settings.html b/app/portainer/components/theme/theme-settings.html new file mode 100644 index 0000000..6c3065d --- /dev/null +++ b/app/portainer/components/theme/theme-settings.html @@ -0,0 +1,10 @@ +
+ + + +
+ +
+
+
+
diff --git a/app/portainer/components/theme/theme-settings.js b/app/portainer/components/theme/theme-settings.js new file mode 100644 index 0000000..d8d57be --- /dev/null +++ b/app/portainer/components/theme/theme-settings.js @@ -0,0 +1,7 @@ +import angular from 'angular'; +import controller from './theme-settings.controller'; + +angular.module('portainer.app').component('themeSettings', { + templateUrl: './theme-settings.html', + controller, +}); diff --git a/app/portainer/components/tls-fieldset/index.ts b/app/portainer/components/tls-fieldset/index.ts new file mode 100644 index 0000000..32ab1f8 --- /dev/null +++ b/app/portainer/components/tls-fieldset/index.ts @@ -0,0 +1,22 @@ +import angular from 'angular'; + +import { + TLSFieldset, + tlsConfigValidation, +} from '@/react/components/TLSFieldset'; +import { withFormValidation } from '@/react-tools/withFormValidation'; + +export const ngModule = angular.module( + 'portainer.app.components.tls-fieldset', + [] +); + +export const tlsFieldsetModule = ngModule.name; + +withFormValidation( + ngModule, + TLSFieldset, + 'tlsFieldset', + [], + tlsConfigValidation +); diff --git a/app/portainer/components/widget/index.ts b/app/portainer/components/widget/index.ts new file mode 100644 index 0000000..eed2348 --- /dev/null +++ b/app/portainer/components/widget/index.ts @@ -0,0 +1,17 @@ +import angular from 'angular'; + +import { rdWidgetBody } from './rd-widget-body'; +import { rdWidget } from './rd-widget'; +import { rdWidgetCustomHeader } from './rd-widget-custom-header'; +import { rdWidgetFooter } from './rd-widget-footer'; +import { rdWidgetTaskbar } from './rd-widget-taskbar'; +import { rdWidgetTitle } from './rd-widget-title'; + +export default angular + .module('portainer.app.components.widget', []) + .component('rdWidget', rdWidget) + .component('rdWidgetBody', rdWidgetBody) + .component('rdWidgetCustomHeader', rdWidgetCustomHeader) + .component('rdWidgetFooter', rdWidgetFooter) + .component('rdWidgetHeader', rdWidgetTitle) + .component('rdWidgetTaskbar', rdWidgetTaskbar).name; diff --git a/app/portainer/components/widget/rd-widget-body.ts b/app/portainer/components/widget/rd-widget-body.ts new file mode 100644 index 0000000..7b7370f --- /dev/null +++ b/app/portainer/components/widget/rd-widget-body.ts @@ -0,0 +1,14 @@ +export const rdWidgetBody = { + requires: '^rdWidget', + bindings: { + loading: '@?', + classes: '@?', + }, + transclude: true, + template: ` +
+ +
+
+ `, +}; diff --git a/app/portainer/components/widget/rd-widget-custom-header.ts b/app/portainer/components/widget/rd-widget-custom-header.ts new file mode 100644 index 0000000..c15b2f4 --- /dev/null +++ b/app/portainer/components/widget/rd-widget-custom-header.ts @@ -0,0 +1,22 @@ +export const rdWidgetCustomHeader = { + requires: '^rdWidget', + bindings: { + titleText: '=', + icon: '=', + }, + transclude: true, + template: ` +
+
+ + header-icon + + {{$ctrl.titleText}} + + +
+
+ `, +}; + +// a react component wasn't created because WidgetTitle were adjusted to support a custom image diff --git a/app/portainer/components/widget/rd-widget-footer.ts b/app/portainer/components/widget/rd-widget-footer.ts new file mode 100644 index 0000000..153d843 --- /dev/null +++ b/app/portainer/components/widget/rd-widget-footer.ts @@ -0,0 +1,7 @@ +export const rdWidgetFooter = { + requires: '^rdWidget', + transclude: true, + template: ` + + `, +}; diff --git a/app/portainer/components/widget/rd-widget-taskbar.ts b/app/portainer/components/widget/rd-widget-taskbar.ts new file mode 100644 index 0000000..ac0e1d1 --- /dev/null +++ b/app/portainer/components/widget/rd-widget-taskbar.ts @@ -0,0 +1,14 @@ +export const rdWidgetTaskbar = { + requires: '^rdWidget', + bindings: { + classes: '@?', + }, + transclude: true, + template: ` +
+
+
+
+
+ `, +}; diff --git a/app/portainer/components/widget/rd-widget-title.ts b/app/portainer/components/widget/rd-widget-title.ts new file mode 100644 index 0000000..5f0bb19 --- /dev/null +++ b/app/portainer/components/widget/rd-widget-title.ts @@ -0,0 +1,25 @@ +export const rdWidgetTitle = { + requires: '^rdWidget', + bindings: { + titleText: '@', + icon: '@', + classes: '@?', + parentClasses: '@?', + }, + transclude: { + title: '?headerTitle', + }, + template: ` +
+
+ +
+ +
+ {{ $ctrl.titleText }} +
+ +
+
+`, +}; diff --git a/app/portainer/components/widget/rd-widget.ts b/app/portainer/components/widget/rd-widget.ts new file mode 100644 index 0000000..d4cbffc --- /dev/null +++ b/app/portainer/components/widget/rd-widget.ts @@ -0,0 +1,4 @@ +export const rdWidget = { + transclude: true, + template: `
`, +}; diff --git a/app/portainer/environments/index.ts b/app/portainer/environments/index.ts new file mode 100644 index 0000000..d454599 --- /dev/null +++ b/app/portainer/environments/index.ts @@ -0,0 +1,3 @@ +import angular from 'angular'; + +export default angular.module('portainer.environments', []).name; diff --git a/app/portainer/error.ts b/app/portainer/error.ts new file mode 100644 index 0000000..806eac0 --- /dev/null +++ b/app/portainer/error.ts @@ -0,0 +1,19 @@ +export default class PortainerError extends Error { + err?: unknown; + + isPortainerError = true; + + constructor(msg: string, err?: unknown) { + super(msg); + this.err = err; + } +} + +export function isPortainerError(error: unknown): error is PortainerError { + return ( + !!error && + typeof error === 'object' && + 'isPortainerError' in error && + (error as PortainerError).isPortainerError + ); +} diff --git a/app/portainer/filters/filters.js b/app/portainer/filters/filters.js new file mode 100644 index 0000000..2fc52c9 --- /dev/null +++ b/app/portainer/filters/filters.js @@ -0,0 +1,112 @@ +import moment from 'moment'; +import _ from 'lodash-es'; +import { filesize } from 'filesize'; + +export function truncateLeftRight(text, max, left, right) { + max = isNaN(max) ? 50 : max; + left = isNaN(left) ? 25 : left; + right = isNaN(right) ? 25 : right; + + if (text.length <= max) { + return text; + } else { + return text.substring(0, left) + '[...]' + text.substring(text.length - right, text.length); + } +} + +export function humanize(bytes, round, base) { + if (!round) { + round = 1; + } + if (!base) { + base = 10; + } + if (bytes || bytes === 0) { + return filesize(bytes, { base: base, round: round }); + } +} +export const TIME_FORMAT = 'YYYY-MM-DD HH:mm:ss'; + +export function isoDateFromTimestamp(timestamp) { + return moment.unix(timestamp).format(TIME_FORMAT); +} + +export function isoDate(date, format = TIME_FORMAT) { + return moment(date).format(format); +} + +export function parseIsoDate(date, format = TIME_FORMAT) { + return moment(date, format).toDate(); +} + +export function formatDate(date, strFormat = 'YYYY-MM-DD HH:mm:ss Z', outFormat = TIME_FORMAT) { + return moment(date, strFormat).format(outFormat); +} + +export function getPairKey(pair, separator) { + if (!pair.includes(separator)) { + return pair; + } + + return pair.slice(0, pair.indexOf(separator)); +} + +export function getPairValue(pair, separator) { + if (!pair.includes(separator)) { + return ''; + } + + return pair.slice(pair.indexOf(separator) + 1); +} + +export function ipAddress(ip) { + return ip.slice(0, ip.indexOf('/')); +} + +export function arrayToStr(arr, separator) { + if (arr) { + return _.join(arr, separator); + } + return ''; +} + +export function labelsToStr(arr, separator) { + if (arr) { + return _.join( + arr.map((item) => item.key + ':' + item.value), + separator + ); + } + return ''; +} + +export function endpointTypeName(type) { + if (type === 1) { + return 'Docker'; + } else if (type === 2 || type === 6) { + return 'Agent'; + } else if (type === 3) { + return 'Azure ACI'; + } else if (type === 5) { + return 'Kubernetes'; + } else if (type === 4 || type === 7) { + return 'Edge Agent'; + } + return ''; +} + +export function truncate(text, length, end) { + if (isNaN(length)) { + length = 10; + } + + if (end === undefined) { + end = '...'; + } + + if (text.length <= length || text.length - end.length <= length) { + return text; + } else { + return String(text).substring(0, length - end.length) + end; + } +} diff --git a/app/portainer/filters/index.js b/app/portainer/filters/index.js new file mode 100644 index 0000000..c8cec82 --- /dev/null +++ b/app/portainer/filters/index.js @@ -0,0 +1,24 @@ +import angular from 'angular'; +import _ from 'lodash-es'; + +import { ownershipIcon } from '@/react/docker/components/datatable/createOwnershipColumn'; +import { stripProtocol } from '@/react/common/string-utils'; +import { arrayToStr, endpointTypeName, getPairKey, getPairValue, humanize, ipAddress, isoDate, isoDateFromTimestamp, labelsToStr, truncate, truncateLeftRight } from './filters'; + +angular + .module('portainer.app') + .filter('truncate', () => truncate) + .filter('truncatelr', () => truncateLeftRight) + .filter('capitalize', () => _.capitalize) + .filter('stripprotocol', () => stripProtocol) + .filter('humansize', () => humanize) + .filter('getisodatefromtimestamp', () => isoDateFromTimestamp) + .filter('getisodate', () => isoDate) + .filter('key', () => getPairKey) + .filter('value', () => getPairValue) + .filter('emptyobject', () => _.isEmpty) + .filter('ipaddress', () => ipAddress) + .filter('arraytostr', () => arrayToStr) + .filter('labelsToStr', () => labelsToStr) + .filter('endpointtypename', () => endpointTypeName) + .filter('ownershipicon', () => ownershipIcon); diff --git a/app/portainer/helpers/array.ts b/app/portainer/helpers/array.ts new file mode 100644 index 0000000..f3eec36 --- /dev/null +++ b/app/portainer/helpers/array.ts @@ -0,0 +1,15 @@ +export function getValueAsArrayOfStrings(value: unknown): string[] { + if (Array.isArray(value)) { + return value; + } + + if (!value || (typeof value !== 'string' && typeof value !== 'number')) { + return []; + } + + if (typeof value === 'number') { + return [value.toString()]; + } + + return [value]; +} diff --git a/app/portainer/helpers/endpointHelper.js b/app/portainer/helpers/endpointHelper.js new file mode 100644 index 0000000..9e43fbb --- /dev/null +++ b/app/portainer/helpers/endpointHelper.js @@ -0,0 +1,37 @@ +import _ from 'lodash-es'; +import { PortainerEndpointTypes } from '@/portainer/models/endpoint/models'; + +function findAssociatedGroup(endpoint, groups) { + return _.find(groups, function (group) { + return group.Id === endpoint.GroupId; + }); +} + +export default class EndpointHelper { + static isDockerEndpoint(endpoint) { + return [PortainerEndpointTypes.DockerEnvironment, PortainerEndpointTypes.AgentOnDockerEnvironment, PortainerEndpointTypes.EdgeAgentOnDockerEnvironment].includes(endpoint.Type); + } + + static isAgentEndpoint(endpoint) { + return [ + PortainerEndpointTypes.AgentOnDockerEnvironment, + PortainerEndpointTypes.EdgeAgentOnDockerEnvironment, + PortainerEndpointTypes.AgentOnKubernetesEnvironment, + PortainerEndpointTypes.EdgeAgentOnKubernetesEnvironment, + ].includes(endpoint.Type); + } + + static isEdgeEndpoint(endpoint) { + return [PortainerEndpointTypes.EdgeAgentOnDockerEnvironment, PortainerEndpointTypes.EdgeAgentOnKubernetesEnvironment].includes(endpoint.Type); + } + + static mapGroupNameToEndpoint(endpoints, groups) { + for (var i = 0; i < endpoints.length; i++) { + var endpoint = endpoints[i]; + var group = findAssociatedGroup(endpoint, groups); + if (group) { + endpoint.GroupName = group.Name; + } + } + } +} diff --git a/app/portainer/helpers/formHelper.js b/app/portainer/helpers/formHelper.js new file mode 100644 index 0000000..dc7d195 --- /dev/null +++ b/app/portainer/helpers/formHelper.js @@ -0,0 +1,19 @@ +angular.module('portainer.app').factory('FormHelper', [ + function FormHelperFactory() { + 'use strict'; + var helper = {}; + + helper.removeInvalidEnvVars = function (env) { + for (var i = env.length - 1; i >= 0; i--) { + var envvar = env[i]; + if (!envvar.name) { + env.splice(i, 1); + } + } + + return env; + }; + + return helper; + }, +]); diff --git a/app/portainer/helpers/index.ts b/app/portainer/helpers/index.ts new file mode 100644 index 0000000..ac6cf0a --- /dev/null +++ b/app/portainer/helpers/index.ts @@ -0,0 +1,7 @@ +import angular from 'angular'; + +import { WebhookHelperFactory } from './webhookHelper'; + +export const helpersModule = angular + .module('portainer.app.helpers', []) + .factory('WebhookHelper', WebhookHelperFactory).name; diff --git a/app/portainer/helpers/json.test.ts b/app/portainer/helpers/json.test.ts new file mode 100644 index 0000000..6ecf295 --- /dev/null +++ b/app/portainer/helpers/json.test.ts @@ -0,0 +1,46 @@ +import { json2formData } from './json'; + +describe('json2formData', () => { + it('should handle undefined and null values', () => { + const json = { key1: undefined, key2: null }; + const formData = json2formData(json); + expect(formData.has('key1')).toBe(false); + expect(formData.has('key2')).toBe(false); + }); + + it('should handle File instances', () => { + const file = new File([''], 'filename'); + const json = { key: file }; + const formData = json2formData(json); + expect(formData.get('key')).toBe(file); + }); + + it('should handle arrays', () => { + const json = { key: [1, 2, 3] }; + const formData = json2formData(json); + expect(formData.get('key')).toBe('[1,2,3]'); + }); + + it('should handle objects', () => { + const json = { key: { subkey: 'value' } }; + const formData = json2formData(json); + expect(formData.get('key')).toBe('{"subkey":"value"}'); + }); + + it('should handle other types of values', () => { + const json = { key1: 'value', key2: 123, key3: true }; + const formData = json2formData(json); + expect(formData.get('key1')).toBe('value'); + expect(formData.get('key2')).toBe('123'); + expect(formData.get('key3')).toBe('true'); + }); + + it('should fail when handling circular references', () => { + const circularReference = { self: undefined }; + + // @ts-expect-error test + circularReference.self = circularReference; + const json = { key: circularReference }; + expect(() => json2formData(json)).toThrow(); + }); +}); diff --git a/app/portainer/helpers/json.ts b/app/portainer/helpers/json.ts new file mode 100644 index 0000000..d85ee0d --- /dev/null +++ b/app/portainer/helpers/json.ts @@ -0,0 +1,51 @@ +export function arrayToJson(arr?: Array) { + if (!arr) { + return ''; + } + + return JSON.stringify(arr); +} + +export function json2formData(json: Record) { + const formData = new FormData(); + + Object.entries(json).forEach(([key, value]) => { + if (typeof value === 'undefined' || value === null) { + return; + } + + if (value instanceof File) { + formData.append(key, value); + return; + } + + if (Array.isArray(value)) { + formData.append(key, arrayToJson(value)); + return; + } + + if (typeof value === 'object') { + formData.append(key, JSON.stringify(value)); + return; + } + + formData.append(key, value.toString()); + }); + + return formData; +} + +/** + * The Docker API often returns a list of JSON object. + * This handler wrap the JSON objects in an array. + * @param data Raw docker API response (stream of objects in a single string) + * @returns An array of parsed objects + */ +export function jsonObjectsToArrayHandler(data: string): unknown[] { + // catching empty data helps the function not to fail and prevents unwanted error message to user. + if (!data) { + return []; + } + const str = `[${data.replace(/\n/g, ' ').replace(/\}\s*\{/g, '}, {')}]`; + return JSON.parse(str); +} diff --git a/app/portainer/helpers/networkHelper.js b/app/portainer/helpers/networkHelper.js new file mode 100644 index 0000000..e3a15f6 --- /dev/null +++ b/app/portainer/helpers/networkHelper.js @@ -0,0 +1,16 @@ +import _ from 'lodash-es'; +import angular from 'angular'; + +class NetworkHelper { + /* @ngInject */ + constructor(PREDEFINED_NETWORKS) { + this.PREDEFINED_NETWORKS = PREDEFINED_NETWORKS; + } + + isSystemNetwork(item) { + return _.includes(this.PREDEFINED_NETWORKS, item.Name); + } +} + +export default NetworkHelper; +angular.module('portainer.app').service('NetworkHelper', NetworkHelper); diff --git a/app/portainer/helpers/pathHelper.js b/app/portainer/helpers/pathHelper.js new file mode 100644 index 0000000..9dfea3f --- /dev/null +++ b/app/portainer/helpers/pathHelper.js @@ -0,0 +1,10 @@ +/** + * calculates baseHref + * + * return [string] + * + */ +export function baseHref() { + const base = document.getElementById('base'); + return base ? base.getAttribute('href') || '/' : '/'; +} diff --git a/app/portainer/helpers/promise-utils.test.ts b/app/portainer/helpers/promise-utils.test.ts new file mode 100644 index 0000000..ccaeb21 --- /dev/null +++ b/app/portainer/helpers/promise-utils.test.ts @@ -0,0 +1,24 @@ +import { promiseSequence } from './promise-utils'; + +describe('promiseSequence', () => { + it('should run successfully for an empty list', async () => { + await expect(promiseSequence([])).resolves.toBeUndefined(); + }); + + it('provided two promise functions, the second should run after the first', async () => { + const callback = vi.fn(); + + function first() { + return Promise.resolve(callback(1)); + } + + function second() { + return Promise.resolve(callback(2)); + } + + await promiseSequence([first, second]); + expect(callback).toHaveBeenCalledTimes(2); + expect(callback).toHaveBeenNthCalledWith(1, 1); + expect(callback).toHaveBeenNthCalledWith(2, 2); + }); +}); diff --git a/app/portainer/helpers/promise-utils.ts b/app/portainer/helpers/promise-utils.ts new file mode 100644 index 0000000..98c7731 --- /dev/null +++ b/app/portainer/helpers/promise-utils.ts @@ -0,0 +1,69 @@ +/** + * runs the provided promises in a sequence, and returns a promise that resolves when all promises have resolved + * + * @param promises a list of functions that return promises + */ +export function promiseSequence(promises: (() => Promise)[]) { + return promises.reduce( + (promise, nextPromise) => promise.then(nextPromise), + Promise.resolve(undefined as unknown) + ); +} + +type AllSettledItems = { + fulfilledItems: T[]; + rejectedItems: { item: T; reason?: string }[]; +}; + +/** + * Separates a list of items into successful and rejected items based on the results of asynchronous operations. + * This function is useful for deleting in parallel, or other requests where the response doesn't have much information. + * + * @template T - The type of the items in the list. + * @param {T[]} items - The list of items to process. + * @param {(item: T) => Promise} asyncFn - An asynchronous function that takes the item and performs an operation on it. + * @returns {Promise>} - A promise that resolves to an object containing arrays of successful and rejected items. + */ +export async function getAllSettledItems( + items: T[], + asyncFn: (item: T) => Promise +): Promise> { + const results = await Promise.allSettled(items.map((item) => asyncFn(item))); + + // make an array of successful items and an array of rejected items + const separatedItems = results.reduce>( + (acc, result, index) => { + if (result.status === 'fulfilled') { + acc.fulfilledItems.push(items[index]); + } else { + const reason = + result.reason instanceof Error + ? result.reason.message + : String(result.reason); + acc.rejectedItems.push({ item: items[index], reason }); + } + return acc; + }, + { fulfilledItems: [], rejectedItems: [] } + ); + + return separatedItems; +} + +export function isFulfilled( + result: PromiseSettledResult +): result is PromiseFulfilledResult { + return result.status === 'fulfilled'; +} + +export function isRejected( + result: PromiseSettledResult +): result is PromiseRejectedResult { + return result.status === 'rejected'; +} + +export function getFulfilledResults( + results: Array> +) { + return results.filter(isFulfilled).map((result) => result.value); +} diff --git a/app/portainer/helpers/resourceControlHelper.js b/app/portainer/helpers/resourceControlHelper.js new file mode 100644 index 0000000..08382db --- /dev/null +++ b/app/portainer/helpers/resourceControlHelper.js @@ -0,0 +1,95 @@ +import _ from 'lodash-es'; +import angular from 'angular'; +import { ResourceControlOwnership as RCO } from '@/react/portainer/access-control/types'; +import { ResourceControlOwnershipParameters } from '../models/resourceControl/resourceControlOwnershipParameters'; + +class ResourceControlHelper { + /** + * Transform ResourceControlViewModel to ResourceControlOwnershipParameters + * @param {int} userId ID of User performing the action + * @param {ResourceControlViewModel} resourceControl ResourceControl view model + */ + RCViewModelToOwnershipParameters(userId, resourceControl) { + if (!resourceControl) { + return new ResourceControlOwnershipParameters(true); + } + let adminOnly = false; + let publicOnly = false; + let users = []; + let teams = []; + switch (resourceControl.Ownership) { + case RCO.PUBLIC: + publicOnly = true; + break; + case RCO.PRIVATE: + users = [userId]; + break; + case RCO.RESTRICTED: + users = _.map(resourceControl.UserAccesses, (user) => user.UserId); + teams = _.map(resourceControl.TeamAccesses, (team) => team.TeamId); + break; + default: + adminOnly = true; + break; + } + return new ResourceControlOwnershipParameters(adminOnly, publicOnly, users, teams); + } + + /** + * Transform AccessControlFormData to ResourceControlOwnershipParameters + * @param {int} userId ID of user performing the operation + * @param {AccessControlFormData} formValues Form data (generated by AccessControlForm) + * @param {int[]} subResources Sub Resources restricted by the ResourceControl + */ + RCFormDataToOwnershipParameters(userId, formValues, subResources = []) { + if (!formValues.AccessControlEnabled) { + formValues.Ownership = RCO.PUBLIC; + } + + let adminOnly = false; + let publicOnly = false; + let users = []; + let teams = []; + switch (formValues.Ownership) { + case RCO.PUBLIC: + publicOnly = true; + break; + case RCO.PRIVATE: + users.push(userId); + break; + case RCO.RESTRICTED: + users = _.map(formValues.AuthorizedUsers, (user) => user.Id); + teams = _.map(formValues.AuthorizedTeams, (team) => team.Id); + break; + default: + adminOnly = true; + break; + } + return new ResourceControlOwnershipParameters(adminOnly, publicOnly, users, teams, subResources); + } + + retrieveAuthorizedUsers(resourceControl, users) { + const authorizedUsers = []; + _.forEach(resourceControl.UserAccesses, (access) => { + const user = _.find(users, { Id: access.UserId }); + if (user) { + authorizedUsers.push(user); + } + }); + return authorizedUsers; + } + + retrieveAuthorizedTeams(resourceControl, teams) { + const authorizedTeams = []; + _.forEach(resourceControl.TeamAccesses, (access) => { + const team = _.find(teams, { Id: access.TeamId }); + if (team) { + authorizedTeams.push(team); + } + }); + return authorizedTeams; + } +} + +export default ResourceControlHelper; +angular.module('portainer.app').service('ResourceControlHelper', ResourceControlHelper); diff --git a/app/portainer/helpers/stackHelper.js b/app/portainer/helpers/stackHelper.js new file mode 100644 index 0000000..cbace44 --- /dev/null +++ b/app/portainer/helpers/stackHelper.js @@ -0,0 +1,29 @@ +import _ from 'lodash-es'; +import { ExternalStackViewModel } from '@/react/docker/stacks/view-models/external-stack'; +import { validateYAML } from '@/react/docker/stacks/common/stackYamlValidation'; + +angular.module('portainer.app').factory('StackHelper', [ + function StackHelperFactory() { + 'use strict'; + var helper = {}; + + helper.getExternalStacksFromContainers = function (containers) { + return getExternalStacksFromLabel(containers, 'com.docker.compose.project', 2); + }; + + helper.getExternalStacksFromServices = function (services) { + return getExternalStacksFromLabel(services, 'com.docker.stack.namespace', 1); + }; + + function getExternalStacksFromLabel(items, label, type) { + return _.uniqBy( + items.filter((item) => item.Labels && item.Labels[label]).map((item) => new ExternalStackViewModel(item.Labels[label], type, item.Created)), + 'Name' + ); + } + + helper.validateYAML = validateYAML; + + return helper; + }, +]); diff --git a/app/portainer/helpers/stateParamHelper.js b/app/portainer/helpers/stateParamHelper.js new file mode 100644 index 0000000..2588f45 --- /dev/null +++ b/app/portainer/helpers/stateParamHelper.js @@ -0,0 +1,13 @@ +export function filterParam(defaultValue = null) { + return { value: defaultValue, squash: true, dynamic: true }; +} + +export function paginationParams(defaultSort = 'name') { + return { + search: filterParam(), + sort: filterParam(defaultSort), + order: filterParam('asc'), + page: filterParam('0'), + pageSize: filterParam(), + }; +} diff --git a/app/portainer/helpers/strings.ts b/app/portainer/helpers/strings.ts new file mode 100644 index 0000000..fb8d69b --- /dev/null +++ b/app/portainer/helpers/strings.ts @@ -0,0 +1,7 @@ +// Re-exporting so we don't have to update one meeeeellion files that are already importing these +// functions from here. +export { + pluralize, + addPlural, + grammaticallyJoin, +} from '@/react/common/string-utils'; diff --git a/app/portainer/helpers/tagHelper.js b/app/portainer/helpers/tagHelper.js new file mode 100644 index 0000000..555a3ff --- /dev/null +++ b/app/portainer/helpers/tagHelper.js @@ -0,0 +1,7 @@ +import _ from 'lodash'; + +export function idsToTagNames(tags, ids) { + const filteredTags = _.filter(tags, (tag) => _.includes(ids, tag.ID)); + const tagNames = _.map(filteredTags, 'Name'); + return tagNames; +} diff --git a/app/portainer/helpers/url-utils.test.ts b/app/portainer/helpers/url-utils.test.ts new file mode 100644 index 0000000..5d6143e --- /dev/null +++ b/app/portainer/helpers/url-utils.test.ts @@ -0,0 +1,51 @@ +import { isValidReturnUrl } from './url-utils'; + +describe('isValidReturnUrl', () => { + const origin = 'http://localhost:9000'; + + it('returns false for empty string', () => { + expect(isValidReturnUrl('', origin)).toBe(false); + }); + + it('returns false for cross-origin URLs', () => { + expect(isValidReturnUrl('https://evil.com', origin)).toBe(false); + expect(isValidReturnUrl('http://evil.com/path', origin)).toBe(false); + expect(isValidReturnUrl('//evil.com', origin)).toBe(false); + expect(isValidReturnUrl('javascript:alert(1)', origin)).toBe(false); + }); + + it('rejects backslash / mixed slash-backslash authority tricks', () => { + // Browsers (WHATWG URL) treat backslashes as forward slashes in the + // authority, so these all resolve to evil.com and must be rejected. + expect(isValidReturnUrl('/\\evil.com', origin)).toBe(false); + expect(isValidReturnUrl('\\/evil.com', origin)).toBe(false); + expect(isValidReturnUrl('\\\\evil.com', origin)).toBe(false); + expect(isValidReturnUrl('/\\/evil.com', origin)).toBe(false); + expect(isValidReturnUrl('https:\\\\evil.com', origin)).toBe(false); + }); + + it('treats percent-encoded slashes and single backslash as same-origin paths (not open redirects)', () => { + // %2f stays encoded, so the origin remains the app origin — navigating to + // http://localhost:9000/%2f%2fevil.com, never to evil.com. + expect(isValidReturnUrl('%2f%2fevil.com', origin)).toBe(true); + expect(isValidReturnUrl('/%2f%2fevil.com', origin)).toBe(true); + // A single leading backslash resolves to /evil.com on the app origin. + expect(isValidReturnUrl('\\evil.com', origin)).toBe(true); + }); + + it('returns true for same-origin paths', () => { + expect(isValidReturnUrl('/home', origin)).toBe(true); + expect(isValidReturnUrl('/run/page?foo=bar', origin)).toBe(true); + expect(isValidReturnUrl('/run/page?foo=bar#section', origin)).toBe(true); + expect(isValidReturnUrl('/home?redirect=https://example.com', origin)).toBe( + true + ); + expect(isValidReturnUrl('http://localhost:9000/run/page', origin)).toBe( + true + ); + expect(isValidReturnUrl('#!/home', origin)).toBe(true); + expect(isValidReturnUrl('#!/environments/1/docker/dashboard', origin)).toBe( + true + ); + }); +}); diff --git a/app/portainer/helpers/url-utils.ts b/app/portainer/helpers/url-utils.ts new file mode 100644 index 0000000..1e38132 --- /dev/null +++ b/app/portainer/helpers/url-utils.ts @@ -0,0 +1,12 @@ +export function isValidReturnUrl( + url: string, + origin = window.location.origin +): boolean { + if (!url) return false; + try { + const parsed = new URL(url, origin); + return parsed.origin === origin; + } catch { + return false; + } +} diff --git a/app/portainer/helpers/urlHelper.js b/app/portainer/helpers/urlHelper.js new file mode 100644 index 0000000..1f2902d --- /dev/null +++ b/app/portainer/helpers/urlHelper.js @@ -0,0 +1,32 @@ +angular.module('portainer.app').factory('URLHelper', [ + '$window', + function URLHelperFactory($window) { + 'use strict'; + var helper = {}; + + helper.getParameter = getParameter; + helper.cleanParameters = cleanParameters; + + function getParameter(param) { + var parameters = extractParameters(); + return parameters[param]; + } + + function extractParameters() { + var queryString = $window.location.search.replace(/.*?\?/, '').split('&'); + return queryString.reduce(function (acc, keyValStr) { + var keyVal = keyValStr.split('='); + var key = keyVal[0]; + var val = keyVal[1]; + acc[key] = val; + return acc; + }, {}); + } + + function cleanParameters() { + $window.location.replace($window.location.origin + $window.location.pathname + $window.location.hash); + } + + return helper; + }, +]); diff --git a/app/portainer/helpers/webhookHelper.ts b/app/portainer/helpers/webhookHelper.ts new file mode 100644 index 0000000..b0ff5f6 --- /dev/null +++ b/app/portainer/helpers/webhookHelper.ts @@ -0,0 +1,58 @@ +import uuid from 'uuid'; + +import { + API_ENDPOINT_EDGE_STACKS, + API_ENDPOINT_STACKS, + API_ENDPOINT_WEBHOOKS, +} from '@/constants'; + +import { baseHref } from './pathHelper'; + +const baseUrl = getBaseUrl(); + +export function dockerWebhookUrl(token: string) { + return `${baseUrl}${API_ENDPOINT_WEBHOOKS}/${token}`; +} + +export function baseStackWebhookUrl() { + return `${baseUrl}${API_ENDPOINT_STACKS}/webhooks`; +} + +export function stackWebhookUrl(token: string) { + return `${baseStackWebhookUrl()}/${token}`; +} + +export function createWebhookId() { + return uuid(); +} + +export function baseEdgeStackWebhookUrl() { + return `${baseUrl}${API_ENDPOINT_EDGE_STACKS}/webhooks`; +} + +/* @ngInject */ +export function WebhookHelperFactory() { + return { + returnWebhookUrl: dockerWebhookUrl, + getBaseStackWebhookUrl: baseStackWebhookUrl, + returnStackWebhookUrl: stackWebhookUrl, + }; +} + +function getBaseUrl() { + const protocol = window.location.protocol.toLowerCase().replace(':', ''); + + if (protocol === 'file') { + return baseHref(); + } + + const { hostname } = window.location; + const port = parseInt(window.location.port, 10); + const displayPort = + (protocol === 'http' && port === 80) || + (protocol === 'https' && port === 443) || + Number.isNaN(port) + ? '' + : `:${port}`; + return `${protocol}://${hostname}${displayPort}${baseHref()}`; +} diff --git a/app/portainer/license-management/license.service.ts b/app/portainer/license-management/license.service.ts new file mode 100644 index 0000000..3385062 --- /dev/null +++ b/app/portainer/license-management/license.service.ts @@ -0,0 +1,20 @@ +import { + getLicenses, + attachLicense, + removeLicense, + getLicenseInfo, + unsubscribe, + subscribe, +} from '@/react/portainer/licenses/license.service'; + +/* @ngInject */ +export function LicenseService() { + return { + licenses: getLicenses, + attach: attachLicense, + remove: removeLicense, + info: getLicenseInfo, + subscribe, + unsubscribe, + }; +} diff --git a/app/portainer/models/access.js b/app/portainer/models/access.js new file mode 100644 index 0000000..02cd31f --- /dev/null +++ b/app/portainer/models/access.js @@ -0,0 +1,19 @@ +// create UserAccessViewModel from UserViewModel +export function UserAccessViewModel(data) { + this.Id = data.Id; + this.Name = data.Username; + this.Type = 'user'; + this.Inherited = false; + this.Override = false; + this.Role = { Id: 0, Name: '-' }; +} + +// create TeamAccessViewModel from TeamViewModel +export function TeamAccessViewModel(data) { + this.Id = data.Id; + this.Name = data.Name; + this.Type = 'team'; + this.Inherited = false; + this.Override = false; + this.Role = { Id: 0, Name: '-' }; +} diff --git a/app/portainer/models/dockerhub.js b/app/portainer/models/dockerhub.js new file mode 100644 index 0000000..cc8aeed --- /dev/null +++ b/app/portainer/models/dockerhub.js @@ -0,0 +1,8 @@ +import { RegistryTypes } from './registryTypes'; + +export function DockerHubViewModel() { + this.Id = 0; + this.Type = RegistryTypes.ANONYMOUS; + this.Name = 'Docker Hub (anonymous)'; + this.URL = 'docker.io'; +} diff --git a/app/portainer/models/endpoint/formValues.js b/app/portainer/models/endpoint/formValues.js new file mode 100644 index 0000000..3c4a284 --- /dev/null +++ b/app/portainer/models/endpoint/formValues.js @@ -0,0 +1,18 @@ +import { PortainerEndpointConnectionTypes } from '@/portainer/models/endpoint/models'; + +export class PortainerEndpointInitFormValues { + constructor() { + this.ConnectionType = PortainerEndpointConnectionTypes.KUBERNETES_LOCAL; + this.Name = ''; + this.URL = ''; + this.TLS = false; + this.TLSSkipVerify = false; + this.TLSSKipClientVerify = false; + this.TLSCACert = null; + this.TLSCert = null; + this.TLSKey = null; + this.AzureApplicationId = ''; + this.AzureTenantId = ''; + this.AzureAuthenticationKey = ''; + } +} diff --git a/app/portainer/models/endpoint/models.js b/app/portainer/models/endpoint/models.js new file mode 100644 index 0000000..cdeb9a7 --- /dev/null +++ b/app/portainer/models/endpoint/models.js @@ -0,0 +1,39 @@ +/** + * JS reference of portainer.go#EndpointType iota + */ +export const PortainerEndpointTypes = Object.freeze({ + // DockerEnvironment represents an endpoint connected to a Docker environment + DockerEnvironment: 1, + // AgentOnDockerEnvironment represents an endpoint connected to a Portainer agent deployed on a Docker environment + AgentOnDockerEnvironment: 2, + // AzureEnvironment represents an endpoint connected to an Azure environment + AzureEnvironment: 3, + // EdgeAgentOnDockerEnvironment represents an endpoint connected to an Edge agent deployed on a Docker environment + EdgeAgentOnDockerEnvironment: 4, + // KubernetesLocalEnvironment represents an endpoint connected to a local Kubernetes environment + KubernetesLocalEnvironment: 5, + // AgentOnKubernetesEnvironment represents an endpoint connected to a Portainer agent deployed on a Kubernetes environment + AgentOnKubernetesEnvironment: 6, + // EdgeAgentOnKubernetesEnvironment represents an endpoint connected to an Edge agent deployed on a Kubernetes environment + EdgeAgentOnKubernetesEnvironment: 7, +}); + +/** + * JS reference of endpoint_create.go#EndpointCreationType iota + */ +export const PortainerEndpointCreationTypes = Object.freeze({ + LocalDockerEnvironment: 1, + AgentEnvironment: 2, + AzureEnvironment: 3, + EdgeAgentEnvironment: 4, + LocalKubernetesEnvironment: 5, +}); + +export const PortainerEndpointConnectionTypes = Object.freeze({ + DOCKER_LOCAL: 1, + KUBERNETES_LOCAL: 2, + REMOTE: 3, + AZURE: 4, + AGENT: 5, + EDGE: 6, +}); diff --git a/app/portainer/models/group.js b/app/portainer/models/group.js new file mode 100644 index 0000000..3cf1dc1 --- /dev/null +++ b/app/portainer/models/group.js @@ -0,0 +1,34 @@ +export function EndpointGroupDefaultModel() { + this.Name = ''; + this.Description = ''; + this.TagIds = []; + this.AssociatedEndpoints = []; +} + +export function EndpointGroupModel(data) { + this.Id = data.Id; + this.Name = data.Name; + this.Description = data.Description; + this.TagIds = data.TagIds; + this.AuthorizedUsers = data.AuthorizedUsers; + this.AuthorizedTeams = data.AuthorizedTeams; + this.UserAccessPolicies = data.UserAccessPolicies || {}; + this.TeamAccessPolicies = data.TeamAccessPolicies || {}; +} + +export function EndpointGroupCreateRequest(model, endpoints) { + this.Name = model.Name; + this.Description = model.Description; + this.TagIds = model.TagIds; + this.AssociatedEndpoints = endpoints; +} + +export function EndpointGroupUpdateRequest(model, endpoints) { + this.id = model.Id; + this.Name = model.Name; + this.Description = model.Description; + this.TagIds = model.TagIds; + this.AssociatedEndpoints = endpoints; + this.UserAccessPolicies = model.UserAccessPolicies; + this.TeamAccessPolicies = model.TeamAccessPolicies; +} diff --git a/app/portainer/models/registry.js b/app/portainer/models/registry.js new file mode 100644 index 0000000..509521b --- /dev/null +++ b/app/portainer/models/registry.js @@ -0,0 +1,91 @@ +import _ from 'lodash-es'; +import { RegistryTypes } from './registryTypes'; + +export function RegistryViewModel(data) { + this.Id = data.Id; + this.Type = data.Type; + this.Name = data.Name; + this.URL = data.URL; + this.BaseURL = data.BaseURL; + this.Authentication = data.Authentication; + this.Username = data.Username; + this.Password = data.Password; + this.RegistryAccesses = data.RegistryAccesses; // map[EndpointID]{UserAccessPolicies, TeamAccessPolicies, NamespaceAccessPolicies} + this.Checked = false; + this.Gitlab = data.Gitlab; + this.Quay = data.Quay; + this.Ecr = data.Ecr; + this.ManagementConfiguration = data.ManagementConfiguration; +} + +export function RegistryManagementConfigurationDefaultModel(registry) { + this.Authentication = registry.Authentication; + this.Username = registry.Username; + this.Password = ''; + this.TLS = (registry.ManagementConfiguration && registry.ManagementConfiguration.TLSConfig && registry.ManagementConfiguration.TLSConfig.TLS) || false; + this.TLSSkipVerify = (registry.ManagementConfiguration && registry.ManagementConfiguration.TLSConfig && registry.ManagementConfiguration.TLSConfig.TLSSkipVerify) || false; + this.TLSCACertFile = null; + this.TLSCertFile = null; + this.TLSKeyFile = null; + + if (registry.Type === RegistryTypes.ECR) { + this.Region = registry.Ecr.Region; + } + + if (registry.Type === RegistryTypes.QUAY || registry.Type === RegistryTypes.ECR) { + this.Authentication = true; + this.Username = registry.Username; + this.TLS = true; + } + + if ((registry.Type === RegistryTypes.CUSTOM || registry.Type === RegistryTypes.PROGET || registry.Type === RegistryTypes.AZURE) && registry.Authentication) { + this.Authentication = true; + this.Username = registry.Username; + } +} + +export function RegistryCreateFormValues() { + this.Type = RegistryTypes.CUSTOM; + this.URL = ''; + this.Name = ''; + this.Authentication = false; + this.Username = ''; + this.Password = ''; +} + +export function RegistryCreateRequest(model) { + this.Name = model.Name; + this.Type = model.Type; + this.URL = _.replace(model.URL, /^https?\:\/\//i, ''); + this.URL = _.replace(this.URL, /\/$/, ''); + this.Authentication = model.Authentication; + // default TLS based on URL scheme: enable TLS unless explicitly http:// + const isHttp = /^http:\/\//i.test(model.URL); + if (!isHttp) { + this.TLS = true; + } + if (model.Authentication) { + this.Username = model.Username; + this.Password = model.Password; + } + if (model.Type === RegistryTypes.GITLAB) { + this.Gitlab = { + ProjectId: model.Gitlab.ProjectId, + InstanceURL: model.Gitlab.InstanceURL, + ProjectPath: model.Gitlab.ProjectPath, + }; + } + if (model.Type === RegistryTypes.ECR) { + this.Ecr = model.Ecr; + } + if (model.Type === RegistryTypes.QUAY) { + this.Quay = { + useOrganisation: model.Quay.useOrganisation, + organisationName: model.Quay.organisationName, + }; + } + if (model.Type === RegistryTypes.PROGET) { + this.BaseURL = _.replace(model.BaseURL, /^https?\:\/\//i, ''); + this.BaseURL = _.replace(this.BaseURL, /\/$/, ''); + } +} diff --git a/app/portainer/models/registryRepository.js b/app/portainer/models/registryRepository.js new file mode 100644 index 0000000..eb723bd --- /dev/null +++ b/app/portainer/models/registryRepository.js @@ -0,0 +1,16 @@ +import _ from 'lodash-es'; + +export function RegistryRepositoryViewModel(item) { + if (item.name && item.tags) { + this.Name = item.name; + this.TagsCount = _.without(item.tags, null).length; + } else { + this.Name = item; + this.TagsCount = 0; + } +} + +export function RegistryRepositoryGitlabViewModel(data) { + this.Name = data.path; + this.TagsCount = data.tags.length; +} diff --git a/app/portainer/models/registryTypes.js b/app/portainer/models/registryTypes.js new file mode 100644 index 0000000..1d4057a --- /dev/null +++ b/app/portainer/models/registryTypes.js @@ -0,0 +1,10 @@ +export const RegistryTypes = Object.freeze({ + ANONYMOUS: 0, // not backend replicated, only for frontend + QUAY: 1, + AZURE: 2, + CUSTOM: 3, + GITLAB: 4, + PROGET: 5, + DOCKERHUB: 6, + ECR: 7, +}); diff --git a/app/portainer/models/resourceControl/resourceControlCreatePayload.js b/app/portainer/models/resourceControl/resourceControlCreatePayload.js new file mode 100644 index 0000000..4e4825c --- /dev/null +++ b/app/portainer/models/resourceControl/resourceControlCreatePayload.js @@ -0,0 +1,17 @@ +/** + * Payload for resourceControleCreate operation + * @param {string} resourceId ID of involved Resource + * @param {ResourceControlResourceType} resourceType Type of involved Resource + * @param {ResourceControlOwnershipParameters} data Transcient type from view data to payload + */ +export function ResourceControlCreatePayload(resourceId, resourceType, data) { + void data; + + this.ResourceID = resourceId; + this.Type = resourceType; + this.Public = data.Public; + this.AdministratorsOnly = data.AdministratorsOnly; + this.Users = data.Users; + this.Teams = data.Teams; + this.SubResourceIDs = data.SubResourceIDs; +} diff --git a/app/portainer/models/resourceControl/resourceControlOwnershipParameters.js b/app/portainer/models/resourceControl/resourceControlOwnershipParameters.js new file mode 100644 index 0000000..e150425 --- /dev/null +++ b/app/portainer/models/resourceControl/resourceControlOwnershipParameters.js @@ -0,0 +1,15 @@ +/** + * Transcient type from view data to payload + * @param {bool} adminOnly is ResourceControl restricted to admin only + * @param {bool} publicOnly is ResourceControl exposed to public + * @param {[]int} users Authorized UserIDs array + * @param {[]int} teams Authorized TeamIDs array + * @param {[]int} subResources subResourceIDs array + */ +export function ResourceControlOwnershipParameters(adminOnly = false, publicOnly = false, users = [], teams = [], subResources = []) { + this.AdministratorsOnly = adminOnly; + this.Public = publicOnly; + this.Users = users; + this.Teams = teams; + this.SubResourceIDs = subResources; +} diff --git a/app/portainer/models/schedule.js b/app/portainer/models/schedule.js new file mode 100644 index 0000000..572f4be --- /dev/null +++ b/app/portainer/models/schedule.js @@ -0,0 +1,19 @@ +export function ScheduleCreateRequest(model) { + this.Name = model.Name; + this.Recurring = model.Recurring; + this.CronExpression = model.CronExpression; + this.Endpoints = model.Endpoints; + this.FileContent = model.FileContent; + this.File = model.File; + this.EdgeGroups = model.EdgeGroups; +} + +export function ScheduleUpdateRequest(model) { + this.id = model.Id; + this.Name = model.Name; + this.Recurring = model.Recurring; + this.CronExpression = model.CronExpression; + this.Endpoints = model.Endpoints; + this.FileContent = model.FileContent; + this.EdgeGroups = model.EdgeGroups; +} diff --git a/app/portainer/models/settings.js b/app/portainer/models/settings.js new file mode 100644 index 0000000..6795e14 --- /dev/null +++ b/app/portainer/models/settings.js @@ -0,0 +1,84 @@ +export function SettingsViewModel(data) { + this.LogoURL = data.LogoURL; + this.BlackListedLabels = data.BlackListedLabels; + this.AuthenticationMethod = data.AuthenticationMethod; + this.InternalAuthSettings = data.InternalAuthSettings; + this.LDAPSettings = data.LDAPSettings; + this.OAuthSettings = new OAuthSettingsViewModel(data.OAuthSettings); + this.SnapshotInterval = data.SnapshotInterval; + this.TemplatesURL = data.TemplatesURL; + this.EdgeAgentCheckinInterval = data.EdgeAgentCheckinInterval; + this.EnableEdgeComputeFeatures = data.EnableEdgeComputeFeatures; + this.FeatureFlagSettings = data.FeatureFlagSettings; + this.UserSessionTimeout = data.UserSessionTimeout; + this.KubeconfigExpiry = data.KubeconfigExpiry; + this.HelmRepositoryURL = data.HelmRepositoryURL; + this.TrustOnFirstConnect = data.TrustOnFirstConnect; + this.EnforceEdgeID = data.EnforceEdgeID; + this.AgentSecret = data.AgentSecret; + this.EdgePortainerUrl = data.EdgePortainerUrl; +} + +export function PublicSettingsViewModel(settings) { + this.AuthenticationMethod = settings.AuthenticationMethod; + this.TeamSync = settings.TeamSync; + this.RequiredPasswordLength = settings.RequiredPasswordLength; + this.EnableEdgeComputeFeatures = settings.EnableEdgeComputeFeatures; + this.EnforceEdgeID = settings.EnforceEdgeID; + this.LogoURL = settings.LogoURL; + this.OAuthLoginURI = settings.OAuthLoginURI; + this.OAuthLogoutURI = settings.OAuthLogoutURI; + this.KubeconfigExpiry = settings.KubeconfigExpiry; + this.Features = settings.Features; + this.Edge = new EdgeSettingsViewModel(settings.Edge); + this.DefaultRegistry = settings.DefaultRegistry; + this.RequiresSetupToken = settings.RequiresSetupToken; +} + +export function InternalAuthSettingsViewModel(data) { + this.RequiredPasswordLength = data.RequiredPasswordLength; +} + +export function LDAPSettingsViewModel(data) { + this.ReaderDN = data.ReaderDN; + this.Password = data.Password; + this.URL = data.URL; + this.SearchSettings = data.SearchSettings; + this.GroupSearchSettings = data.GroupSearchSettings; + this.AutoCreateUsers = data.AutoCreateUsers; +} + +export function LDAPSearchSettings(BaseDN, UsernameAttribute, Filter) { + this.BaseDN = BaseDN; + this.UsernameAttribute = UsernameAttribute; + this.Filter = Filter; +} + +export function LDAPGroupSearchSettings(GroupBaseDN, GroupAttribute, GroupFilter) { + this.GroupBaseDN = GroupBaseDN; + this.GroupAttribute = GroupAttribute; + this.GroupFilter = GroupFilter; +} + +export function OAuthSettingsViewModel(data) { + this.ClientID = data.ClientID; + this.ClientSecret = data.ClientSecret; + this.AccessTokenURI = data.AccessTokenURI; + this.AuthorizationURI = data.AuthorizationURI; + this.ResourceURI = data.ResourceURI; + this.RedirectURI = data.RedirectURI; + this.UserIdentifier = data.UserIdentifier; + this.Scopes = data.Scopes; + this.OAuthAutoCreateUsers = data.OAuthAutoCreateUsers; + this.DefaultTeamID = data.DefaultTeamID; + this.SSO = data.SSO; + this.LogoutURI = data.LogoutURI; + this.AuthStyle = data.AuthStyle; +} + +export function EdgeSettingsViewModel(data = {}) { + this.CheckinInterval = data.CheckinInterval; + this.PingInterval = data.PingInterval; + this.SnapshotInterval = data.SnapshotInterval; + this.CommandInterval = data.CommandInterval; +} diff --git a/app/portainer/models/status.js b/app/portainer/models/status.js new file mode 100644 index 0000000..7046092 --- /dev/null +++ b/app/portainer/models/status.js @@ -0,0 +1,12 @@ +export function StatusViewModel(data) { + this.Authentication = data.Authentication; + this.Snapshot = data.Snapshot; + this.Version = data.Version; + this.Edition = data.Edition; + this.InstanceID = data.InstanceID; +} + +export function StatusVersionViewModel(data) { + this.UpdateAvailable = data.UpdateAvailable; + this.LatestVersion = data.LatestVersion; +} diff --git a/app/portainer/models/tag.js b/app/portainer/models/tag.js new file mode 100644 index 0000000..ee3660c --- /dev/null +++ b/app/portainer/models/tag.js @@ -0,0 +1,6 @@ +import _ from 'lodash-es'; + +export function TagViewModel(data) { + this.Id = data.ID; + this.Name = _.escape(data.Name); +} diff --git a/app/portainer/models/team.js b/app/portainer/models/team.js new file mode 100644 index 0000000..617b36c --- /dev/null +++ b/app/portainer/models/team.js @@ -0,0 +1,7 @@ +import _ from 'lodash-es'; + +export function TeamViewModel(data) { + this.Id = data.Id; + this.Name = _.escape(data.Name); + this.Checked = false; +} diff --git a/app/portainer/models/teamMembership.js b/app/portainer/models/teamMembership.js new file mode 100644 index 0000000..43aad61 --- /dev/null +++ b/app/portainer/models/teamMembership.js @@ -0,0 +1,6 @@ +export function TeamMembershipModel(data) { + this.Id = data.Id; + this.UserId = data.UserID; + this.TeamId = data.TeamID; + this.Role = data.Role; +} diff --git a/app/portainer/models/user.js b/app/portainer/models/user.js new file mode 100644 index 0000000..a8979a0 --- /dev/null +++ b/app/portainer/models/user.js @@ -0,0 +1,25 @@ +export function UserViewModel(data) { + this.Id = data.Id; + this.Username = data.Username; + this.Role = data.Role; + this.ThemeSettings = data.ThemeSettings; + this.EndpointAuthorizations = data.EndpointAuthorizations; + this.PortainerAuthorizations = data.PortainerAuthorizations; + if (data.Role === 1) { + this.RoleName = 'administrator'; + } else { + this.RoleName = 'user'; + } + this.AuthenticationMethod = data.AuthenticationMethod; + this.Checked = false; + this.UseCache = data.UseCache; +} + +export function UserTokenModel(data) { + this.id = data.id; + this.userId = data.userId; + this.description = data.description; + this.prefix = data.prefix; + this.dateCreated = data.dateCreated; + this.lastUsed = data.lastUsed; +} diff --git a/app/portainer/models/webhook.js b/app/portainer/models/webhook.js new file mode 100644 index 0000000..8f63ee3 --- /dev/null +++ b/app/portainer/models/webhook.js @@ -0,0 +1,7 @@ +export function WebhookViewModel(data) { + this.Id = data.Id; + this.Token = data.Token; + this.ResourceId = data.ResourceID; + this.EndpointId = data.EndpointID; + this.WebhookType = data.WebhookType; +} diff --git a/app/portainer/oauth/__module.js b/app/portainer/oauth/__module.js new file mode 100644 index 0000000..f6b1192 --- /dev/null +++ b/app/portainer/oauth/__module.js @@ -0,0 +1 @@ +angular.module('portainer.oauth', ['ngResource']).constant('API_ENDPOINT_OAUTH', 'api/auth/oauth'); diff --git a/app/portainer/oauth/components/oauth-providers-selector/index.js b/app/portainer/oauth/components/oauth-providers-selector/index.js new file mode 100644 index 0000000..000f869 --- /dev/null +++ b/app/portainer/oauth/components/oauth-providers-selector/index.js @@ -0,0 +1,11 @@ +import angular from 'angular'; +import controller from './oauth-provider-selector.controller'; + +angular.module('portainer.oauth').component('oauthProvidersSelector', { + templateUrl: './oauth-providers-selector.html', + bindings: { + onChange: '<', + value: '<', + }, + controller, +}); diff --git a/app/portainer/oauth/components/oauth-providers-selector/oauth-provider-selector.controller.js b/app/portainer/oauth/components/oauth-providers-selector/oauth-provider-selector.controller.js new file mode 100644 index 0000000..f7dceab --- /dev/null +++ b/app/portainer/oauth/components/oauth-providers-selector/oauth-provider-selector.controller.js @@ -0,0 +1,7 @@ +import { options } from '@/react/portainer/settings/AuthenticationView/oauth-options'; + +export default class OAuthProviderSelectorController { + constructor() { + this.options = options; + } +} diff --git a/app/portainer/oauth/components/oauth-providers-selector/oauth-providers-selector.html b/app/portainer/oauth/components/oauth-providers-selector/oauth-providers-selector.html new file mode 100644 index 0000000..af7c823 --- /dev/null +++ b/app/portainer/oauth/components/oauth-providers-selector/oauth-providers-selector.html @@ -0,0 +1 @@ + diff --git a/app/portainer/oauth/components/oauth-settings/index.js b/app/portainer/oauth/components/oauth-settings/index.js new file mode 100644 index 0000000..fb452d8 --- /dev/null +++ b/app/portainer/oauth/components/oauth-settings/index.js @@ -0,0 +1,13 @@ +import angular from 'angular'; +import controller from './oauth-settings.controller'; + +angular.module('portainer.oauth').component('oauthSettings', { + templateUrl: './oauth-settings.html', + bindings: { + settings: '=', + teams: '<', + onSaveSettings: '<', + saveButtonState: '<', + }, + controller, +}); diff --git a/app/portainer/oauth/components/oauth-settings/oauth-settings.controller.js b/app/portainer/oauth/components/oauth-settings/oauth-settings.controller.js new file mode 100644 index 0000000..87ab600 --- /dev/null +++ b/app/portainer/oauth/components/oauth-settings/oauth-settings.controller.js @@ -0,0 +1,181 @@ +import { baseHref } from '@/portainer/helpers/pathHelper'; +import { FeatureId } from '@/react/portainer/feature-flags/enums'; +import { isLimitedToBE } from '@/react/portainer/feature-flags/feature-flags.service'; +import { ModalType } from '@@/modals'; +import { confirm } from '@@/modals/confirm'; +import { buildConfirmButton } from '@@/modals/utils'; +import providers, { getProviderByUrl } from './providers'; + +const MS_TENANT_ID_PLACEHOLDER = 'TENANT_ID'; + +export default class OAuthSettingsController { + /* @ngInject */ + constructor($scope, $async) { + Object.assign(this, { $scope, $async }); + + this.limitedFeature = FeatureId.HIDE_INTERNAL_AUTH; + this.limitedFeatureClass = 'limited-be'; + + this.state = { + provider: 'custom', + overrideConfiguration: false, + microsoftTenantID: '', + }; + + this.$onInit = this.$onInit.bind(this); + this.onSelectProvider = this.onSelectProvider.bind(this); + this.onMicrosoftTenantIDChange = this.onMicrosoftTenantIDChange.bind(this); + this.useDefaultProviderConfiguration = this.useDefaultProviderConfiguration.bind(this); + this.updateSSO = this.updateSSO.bind(this); + this.addTeamMembershipMapping = this.addTeamMembershipMapping.bind(this); + this.removeTeamMembership = this.removeTeamMembership.bind(this); + this.onToggleAutoTeamMembership = this.onToggleAutoTeamMembership.bind(this); + this.onChangeAuthStyle = this.onChangeAuthStyle.bind(this); + this.onAutoUserProvisionChange = this.onAutoUserProvisionChange.bind(this); + } + + onAutoUserProvisionChange(value) { + this.$scope.$evalAsync(() => { + this.settings.OAuthAutoCreateUsers = value; + }); + } + + onMicrosoftTenantIDChange() { + const tenantID = this.state.microsoftTenantID || MS_TENANT_ID_PLACEHOLDER; + + this.settings.AuthorizationURI = `https://login.microsoftonline.com/${tenantID}/oauth2/v2.0/authorize`; + this.settings.AccessTokenURI = `https://login.microsoftonline.com/${tenantID}/oauth2/v2.0/token`; + this.settings.ResourceURI = `https://graph.microsoft.com/v1.0/me`; + this.settings.LogoutURI = `https://login.microsoftonline.com/${tenantID}/oauth2/v2.0/logout`; + } + + useDefaultProviderConfiguration(providerId) { + const provider = providers[providerId]; + + this.state.overrideConfiguration = false; + + if (!this.isLimitedToBE || providerId === 'custom') { + this.settings.AuthorizationURI = provider.authUrl; + this.settings.AccessTokenURI = provider.accessTokenUrl; + this.settings.ResourceURI = provider.resourceUrl; + this.settings.LogoutURI = provider.logoutUrl; + this.settings.UserIdentifier = provider.userIdentifier; + this.settings.Scopes = provider.scopes; + this.settings.AuthStyle = provider.authStyle; + + if (providerId === 'microsoft' && this.state.microsoftTenantID !== '') { + this.onMicrosoftTenantIDChange(); + } + } else { + this.settings.ClientID = ''; + this.settings.ClientSecret = ''; + } + } + + onSelectProvider(provider) { + this.state.provider = provider; + + this.useDefaultProviderConfiguration(provider); + } + + updateSSO(checked) { + this.$scope.$evalAsync(() => { + this.settings.SSO = checked; + this.settings.HideInternalAuth = false; + }); + } + + onChangeAuthStyle(val) { + this.$scope.$evalAsync(() => { + this.settings.AuthStyle = val; + }); + } + + async onChangeHideInternalAuth(checked) { + this.$async(async () => { + if (this.isLimitedToBE) { + return; + } + + if (checked) { + const confirmed = await confirm({ + title: 'Hide internal authentication prompt', + message: 'By hiding internal authentication prompt, you will only be able to login via SSO. Are you sure?', + confirmButton: buildConfirmButton('Confirm', 'danger'), + modalType: ModalType.Warn, + }); + + if (!confirmed) { + return; + } + } + + this.settings.HideInternalAuth = checked; + }); + } + + onToggleAutoTeamMembership(checked) { + this.$scope.$evalAsync(() => { + this.settings.OAuthAutoMapTeamMemberships = checked; + }); + } + + addTeamMembershipMapping() { + this.settings.TeamMemberships.OAuthClaimMappings.push({ ClaimValRegex: '', Team: this.settings.DefaultTeamID }); + } + + removeTeamMembership(index) { + this.settings.TeamMemberships.OAuthClaimMappings.splice(index, 1); + } + + isOAuthTeamMembershipFormValid() { + if (this.settings.OAuthAutoMapTeamMemberships && this.settings.TeamMemberships) { + if (!this.settings.TeamMemberships.OAuthClaimName) { + return false; + } + + const hasInvalidMapping = this.settings.TeamMemberships.OAuthClaimMappings.some((m) => !(m.ClaimValRegex && m.Team)); + if (hasInvalidMapping) { + return false; + } + } + return true; + } + + $onInit() { + this.isLimitedToBE = isLimitedToBE(this.limitedFeature); + + if (this.isLimitedToBE) { + return; + } + + if (this.settings.RedirectURI === '') { + this.settings.RedirectURI = window.location.origin + baseHref(); + } + + if (this.settings.AuthorizationURI) { + const authUrl = this.settings.AuthorizationURI; + + this.state.provider = getProviderByUrl(authUrl); + if (this.state.provider === 'microsoft') { + const tenantID = authUrl.match(/login.microsoftonline.com\/(.*?)\//)[1]; + if (tenantID !== MS_TENANT_ID_PLACEHOLDER) { + this.state.microsoftTenantID = tenantID; + this.onMicrosoftTenantIDChange(); + } + } + } + + if (this.settings.DefaultTeamID === 0) { + this.settings.DefaultTeamID = null; + } + + if (this.settings.TeamMemberships == null) { + this.settings.TeamMemberships = {}; + } + + if (this.settings.TeamMemberships.OAuthClaimMappings === null) { + this.settings.TeamMemberships.OAuthClaimMappings = []; + } + } +} diff --git a/app/portainer/oauth/components/oauth-settings/oauth-settings.html b/app/portainer/oauth/components/oauth-settings/oauth-settings.html new file mode 100644 index 0000000..4f445e9 --- /dev/null +++ b/app/portainer/oauth/components/oauth-settings/oauth-settings.html @@ -0,0 +1,455 @@ + +
Single Sign-On
+ + +
+
+ +
+
+ + + +
+
+ +
+
+ + + + +
+
+ +

The users created by the automatic provisioning feature can be added to a default team on creation.

+

+ By assigning newly created users to a team, they will be able to access the environments associated to that team. This setting is optional and if not set, newly created + users won't be able to access any environments. +

+
+
+
+ + + You have not yet created any teams. Head over to the Teams view to manage teams. + + +
+
+

+ + The default team option will be disabled when automatic team membership is enabled +

+
+
+ + +
+
+
+
+ +
Team membership
+
+
Automatic team membership synchronizes the team membership based on a custom claim in the token from the OAuth provider.
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ +
+ +
+ + + add team mapping + + +
+
+ claim value regex + +
+ maps to +
+ team + +
+ + +
+
+ + Claim value regex is required. +
+
+
+
+
+ +
+
The default team will be assigned when the user does not belong to any other team
+ + + You have not yet created any teams. Head over to the Teams view to manage teams. + + +
+
+ +
+
+
+
+ + + +
+
OAuth Configuration
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ + + +
+ +
+ +
+
OAuth Configuration
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ + + +
+
+
diff --git a/app/portainer/oauth/components/oauth-settings/providers.js b/app/portainer/oauth/components/oauth-settings/providers.js new file mode 100644 index 0000000..124bf35 --- /dev/null +++ b/app/portainer/oauth/components/oauth-settings/providers.js @@ -0,0 +1,49 @@ +import { baseHref } from '@/portainer/helpers/pathHelper'; +import { OAuthStyle } from '@/react/portainer/settings/types'; + +export default { + microsoft: { + authUrl: 'https://login.microsoftonline.com/TENANT_ID/oauth2/v2.0/authorize', + accessTokenUrl: 'https://login.microsoftonline.com/TENANT_ID/oauth2/v2.0/token', + resourceUrl: 'https://graph.microsoft.com/v1.0/me', + logoutUrl: `https://login.microsoftonline.com/TENANT_ID/oauth2/v2.0/logout`, + userIdentifier: 'userPrincipalName', + scopes: 'profile openid', + authStyle: OAuthStyle.InParams, + }, + google: { + authUrl: 'https://accounts.google.com/o/oauth2/auth', + accessTokenUrl: 'https://accounts.google.com/o/oauth2/token', + resourceUrl: 'https://www.googleapis.com/oauth2/v1/userinfo?alt=json', + logoutUrl: `https://www.google.com/accounts/Logout?continue=https://appengine.google.com/_ah/logout?continue=${window.location.origin}${baseHref()}#!/auth`, + userIdentifier: 'email', + scopes: 'profile email', + authStyle: OAuthStyle.InParams, + }, + github: { + authUrl: 'https://github.com/login/oauth/authorize', + accessTokenUrl: 'https://github.com/login/oauth/access_token', + resourceUrl: 'https://api.github.com/user', + logoutUrl: `https://github.com/logout`, + userIdentifier: 'login', + scopes: 'id email name', + authStyle: OAuthStyle.AutoDetect, + }, + custom: { authUrl: '', accessTokenUrl: '', resourceUrl: '', logoutUrl: '', userIdentifier: '', scopes: '', authStyle: OAuthStyle.AutoDetect }, +}; + +export function getProviderByUrl(providerAuthURL = '') { + if (providerAuthURL.includes('login.microsoftonline.com')) { + return 'microsoft'; + } + + if (providerAuthURL.includes('accounts.google.com')) { + return 'google'; + } + + if (providerAuthURL.includes('github.com')) { + return 'github'; + } + + return 'custom'; +} diff --git a/app/portainer/oauth/services/rest/oauth.js b/app/portainer/oauth/services/rest/oauth.js new file mode 100644 index 0000000..b221f7d --- /dev/null +++ b/app/portainer/oauth/services/rest/oauth.js @@ -0,0 +1,20 @@ +angular.module('portainer.oauth').factory('OAuth', [ + '$resource', + 'API_ENDPOINT_OAUTH', + function OAuthFactory($resource, API_ENDPOINT_OAUTH) { + 'use strict'; + return $resource( + API_ENDPOINT_OAUTH + '/:action', + {}, + { + validate: { + method: 'POST', + ignoreLoadingBar: true, + params: { + action: 'validate', + }, + }, + } + ); + }, +]); diff --git a/app/portainer/rbac/components/access-viewer/access-viewer.controller.js b/app/portainer/rbac/components/access-viewer/access-viewer.controller.js new file mode 100644 index 0000000..b42bd37 --- /dev/null +++ b/app/portainer/rbac/components/access-viewer/access-viewer.controller.js @@ -0,0 +1,69 @@ +import _ from 'lodash-es'; + +import { isLimitedToBE } from '@/react/portainer/feature-flags/feature-flags.service'; + +export default class AccessViewerController { + /* @ngInject */ + constructor($scope, Notifications, UserService, TeamMembershipService, Authentication) { + this.$scope = $scope; + this.Notifications = Notifications; + this.UserService = UserService; + this.TeamMembershipService = TeamMembershipService; + this.Authentication = Authentication; + + this.limitedFeature = 'rbac-roles'; + this.users = []; + this.selectedUserId = null; + + this.onUserSelect = this.onUserSelect.bind(this); + } + + onUserSelect(selectedUserId) { + this.$scope.$evalAsync(() => { + this.selectedUserId = selectedUserId; + }); + } + + // for admin, returns all users + // for team leader, only return all his/her team member users + async teamMemberUsers(users, teamMemberships) { + if (this.isAdmin) { + return users; + } + + const filteredUsers = []; + const userId = this.Authentication.getUserDetails().ID; + const leadingTeams = await this.UserService.userLeadingTeams(userId); + + const isMember = (userId, teamId) => { + return !!_.find(teamMemberships, { UserId: userId, TeamId: teamId }); + }; + + for (const user of users) { + for (const leadingTeam of leadingTeams) { + if (isMember(user.Id, leadingTeam.Id)) { + filteredUsers.push(user); + break; + } + } + } + + return filteredUsers; + } + + async $onInit() { + try { + if (isLimitedToBE(this.limitedFeature)) { + return; + } + + this.isAdmin = this.Authentication.isAdmin(); + const allUsers = await this.UserService.users(); + const teamMemberships = await this.TeamMembershipService.memberships(); + const teamUsers = await this.teamMemberUsers(allUsers, teamMemberships); + this.users = teamUsers.map((user) => ({ label: user.Username, value: user.Id })); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to retrieve users'); + } + } +} diff --git a/app/portainer/rbac/components/access-viewer/access-viewer.html b/app/portainer/rbac/components/access-viewer/access-viewer.html new file mode 100644 index 0000000..3d5ac0c --- /dev/null +++ b/app/portainer/rbac/components/access-viewer/access-viewer.html @@ -0,0 +1,26 @@ +
+
+ +
+ + + Effective access viewer + + +
+
User
+
+
+ No user available + + + +
+
+ +
+
+
+
+
+
diff --git a/app/portainer/rbac/components/access-viewer/index.js b/app/portainer/rbac/components/access-viewer/index.js new file mode 100644 index 0000000..334f0f0 --- /dev/null +++ b/app/portainer/rbac/components/access-viewer/index.js @@ -0,0 +1,6 @@ +import controller from './access-viewer.controller'; + +export const accessViewer = { + templateUrl: './access-viewer.html', + controller, +}; diff --git a/app/portainer/rbac/index.js b/app/portainer/rbac/index.js new file mode 100644 index 0000000..7638c33 --- /dev/null +++ b/app/portainer/rbac/index.js @@ -0,0 +1,34 @@ +import { AccessHeaders } from '../authorization-guard'; +import { rolesView } from './views/roles'; +import { accessViewer } from './components/access-viewer'; + +import { RoleService } from './services/role.service'; +import { RolesFactory } from './services/role.rest'; + +angular + .module('portainer.rbac', ['ngResource']) + .constant('API_ENDPOINT_ROLES', 'api/roles') + .component('accessViewer', accessViewer) + .component('rolesView', rolesView) + .factory('RoleService', RoleService) + .factory('Roles', RolesFactory) + .config(config); + +/* @ngInject */ +function config($stateRegistryProvider) { + const roles = { + name: 'portainer.roles', + url: '/roles', + views: { + 'content@': { + component: 'rolesView', + }, + }, + data: { + docs: '/admin/user/roles', + access: AccessHeaders.Admin, + }, + }; + + $stateRegistryProvider.register(roles); +} diff --git a/app/portainer/rbac/models/role.js b/app/portainer/rbac/models/role.js new file mode 100644 index 0000000..44cb4a3 --- /dev/null +++ b/app/portainer/rbac/models/role.js @@ -0,0 +1,14 @@ +export function RoleViewModel(id, name, description, authorizations) { + this.ID = id; + this.Name = name; + this.Description = description; + this.Authorizations = authorizations; +} + +export const RoleTypes = Object.freeze({ + ENDPOINT_ADMIN: 1, + HELPDESK: 2, + STANDARD: 3, + READ_ONLY: 4, + OPERATOR: 5, +}); diff --git a/app/portainer/rbac/services/role.rest.js b/app/portainer/rbac/services/role.rest.js new file mode 100644 index 0000000..1213609 --- /dev/null +++ b/app/portainer/rbac/services/role.rest.js @@ -0,0 +1,14 @@ +/* @ngInject */ +export function RolesFactory($resource, API_ENDPOINT_ROLES) { + return $resource( + API_ENDPOINT_ROLES + '/:id', + {}, + { + create: { method: 'POST', ignoreLoadingBar: true }, + query: { method: 'GET', isArray: true }, + get: { method: 'GET', params: { id: '@id' } }, + update: { method: 'PUT', params: { id: '@id' } }, + remove: { method: 'DELETE', params: { id: '@id' } }, + } + ); +} diff --git a/app/portainer/rbac/services/role.service.js b/app/portainer/rbac/services/role.service.js new file mode 100644 index 0000000..14b763b --- /dev/null +++ b/app/portainer/rbac/services/role.service.js @@ -0,0 +1,19 @@ +import { RoleViewModel, RoleTypes } from '../models/role'; + +export function RoleService() { + const rolesData = [ + new RoleViewModel(RoleTypes.ENDPOINT_ADMIN, 'Environment administrator', 'Full control of all resources in an environment', []), + new RoleViewModel(RoleTypes.OPERATOR, 'Operator', 'Operational Control of all existing resources in an environment', []), + new RoleViewModel(RoleTypes.HELPDESK, 'Helpdesk', 'Read-only access of all resources in an environment', []), + new RoleViewModel(RoleTypes.READ_ONLY, 'Read-only user', 'Read-only access of assigned resources in an environment', []), + new RoleViewModel(RoleTypes.STANDARD, 'Standard user', 'Full control of assigned resources in an environment', []), + ]; + + return { + roles, + }; + + function roles() { + return rolesData; + } +} diff --git a/app/portainer/rbac/views/roles/index.js b/app/portainer/rbac/views/roles/index.js new file mode 100644 index 0000000..c1e91cf --- /dev/null +++ b/app/portainer/rbac/views/roles/index.js @@ -0,0 +1,6 @@ +import controller from './roles.controller'; + +export const rolesView = { + templateUrl: './roles.html', + controller, +}; diff --git a/app/portainer/rbac/views/roles/roles.controller.js b/app/portainer/rbac/views/roles/roles.controller.js new file mode 100644 index 0000000..742c243 --- /dev/null +++ b/app/portainer/rbac/views/roles/roles.controller.js @@ -0,0 +1,20 @@ +import _ from 'lodash-es'; + +export default class RolesController { + /* @ngInject */ + constructor(Notifications, RoleService) { + this.Notifications = Notifications; + this.RoleService = RoleService; + } + + async $onInit() { + this.roles = []; + + try { + this.roles = await this.RoleService.roles(); + this.roles = _.orderBy(this.roles, 'Priority', 'asc'); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to retrieve roles'); + } + } +} diff --git a/app/portainer/rbac/views/roles/roles.html b/app/portainer/rbac/views/roles/roles.html new file mode 100644 index 0000000..ac32d08 --- /dev/null +++ b/app/portainer/rbac/views/roles/roles.html @@ -0,0 +1,7 @@ + + + + +
+ +
diff --git a/app/portainer/react/components/access-control.ts b/app/portainer/react/components/access-control.ts new file mode 100644 index 0000000..ce36739 --- /dev/null +++ b/app/portainer/react/components/access-control.ts @@ -0,0 +1,65 @@ +import angular from 'angular'; + +import { r2a } from '@/react-tools/react2angular'; +import { withCurrentUser } from '@/react-tools/withCurrentUser'; +import { withReactQuery } from '@/react-tools/withReactQuery'; +import { withUIRouter } from '@/react-tools/withUIRouter'; +import { PorAccessControlFormTeamSelector } from '@/react/portainer/access-control/PorAccessControlForm/TeamsSelector'; +import { PorAccessControlFormUserSelector } from '@/react/portainer/access-control/PorAccessControlForm/UsersSelector'; +import { PorAccessManagementUsersSelector } from '@/react/portainer/access-control/AccessManagement/PorAccessManagementUsersSelector'; +import { AccessTypeSelector } from '@/react/portainer/access-control/EditDetails/AccessTypeSelector'; +import { AccessControlPanel } from '@/react/portainer/access-control'; + +export const accessControlModule = angular + .module('portainer.app.react.components.access-control', []) + .component( + 'accessControlPanel', + r2a(withUIRouter(withReactQuery(withCurrentUser(AccessControlPanel))), [ + 'disableOwnershipChange', + 'onUpdateSuccess', + 'resourceControl', + 'resourceId', + 'resourceType', + 'environmentId', + 'resourceName', + ]) + ) + .component( + 'accessTypeSelector', + r2a(AccessTypeSelector, [ + 'isAdmin', + 'isPublicVisible', + 'name', + 'onChange', + 'value', + 'teams', + 'resourceName', + ]) + ) + .component( + 'porAccessControlFormTeamSelector', + r2a(PorAccessControlFormTeamSelector, [ + 'inputId', + 'onChange', + 'options', + 'value', + ]) + ) + .component( + 'porAccessControlFormUserSelector', + r2a(PorAccessControlFormUserSelector, [ + 'inputId', + 'onChange', + 'options', + 'value', + ]) + ) + .component( + 'porAccessManagementUsersSelector', + r2a(PorAccessManagementUsersSelector, [ + 'onChange', + 'options', + 'value', + 'isLoading', + ]) + ).name; diff --git a/app/portainer/react/components/account.ts b/app/portainer/react/components/account.ts new file mode 100644 index 0000000..ad96c99 --- /dev/null +++ b/app/portainer/react/components/account.ts @@ -0,0 +1,33 @@ +import angular from 'angular'; + +import { r2a } from '@/react-tools/react2angular'; +import { withCurrentUser } from '@/react-tools/withCurrentUser'; +import { withUIRouter } from '@/react-tools/withUIRouter'; +import { withReactQuery } from '@/react-tools/withReactQuery'; +import { HelmRepositoryDatatable } from '@/react/portainer/account/AccountView/HelmRepositoryDatatable'; +import { AccessTokensDatatable } from '@/react/portainer/account/AccountView/AccessTokensDatatable'; +import { ApplicationSettingsWidget } from '@/react/portainer/account/AccountView/ApplicationSettings'; + +export const accountModule = angular + .module('portainer.app.react.components.account', []) + .component( + 'applicationSettingsWidget', + r2a( + withUIRouter(withReactQuery(withCurrentUser(ApplicationSettingsWidget))), + [] + ) + ) + .component( + 'helmRepositoryDatatable', + r2a( + withUIRouter(withReactQuery(withCurrentUser(HelmRepositoryDatatable))), + [] + ) + ) + .component( + 'accessTokensDatatable', + r2a( + withUIRouter(withReactQuery(withCurrentUser(AccessTokensDatatable))), + [] + ) + ).name; diff --git a/app/portainer/react/components/activity-logs.ts b/app/portainer/react/components/activity-logs.ts new file mode 100644 index 0000000..4c83725 --- /dev/null +++ b/app/portainer/react/components/activity-logs.ts @@ -0,0 +1,24 @@ +import angular from 'angular'; + +import { r2a } from '@/react-tools/react2angular'; +import { withUIRouter } from '@/react-tools/withUIRouter'; +import { withReactQuery } from '@/react-tools/withReactQuery'; +import { AuthenticationLogsTable } from '@/react/portainer/logs/AuthenticationLogsView/AuthenticationLogsTable'; + +export const activityLogsModule = angular + .module('portainer.app.react.components.activity-logs', []) + .component( + 'authenticationLogsTable', + r2a(withUIRouter(withReactQuery(AuthenticationLogsTable)), [ + 'currentPage', + 'dataset', + 'keyword', + 'limit', + 'totalItems', + 'sort', + 'onChangeSort', + 'onChangePage', + 'onChangeLimit', + 'onChangeKeyword', + ]) + ).name; diff --git a/app/portainer/react/components/auth.ts b/app/portainer/react/components/auth.ts new file mode 100644 index 0000000..fede9ba --- /dev/null +++ b/app/portainer/react/components/auth.ts @@ -0,0 +1,8 @@ +import angular from 'angular'; + +import { r2a } from '@/react-tools/react2angular'; +import { SetupTokenTextTip } from '@/react/portainer/init/InitAdminView/SetupTokenTextTip'; + +export const authModule = angular + .module('portainer.app.react.components.auth', []) + .component('setupTokenTextTip', r2a(SetupTokenTextTip, [])).name; diff --git a/app/portainer/react/components/custom-templates/index.ts b/app/portainer/react/components/custom-templates/index.ts new file mode 100644 index 0000000..46f8172 --- /dev/null +++ b/app/portainer/react/components/custom-templates/index.ts @@ -0,0 +1,32 @@ +import angular from 'angular'; + +import { r2a } from '@/react-tools/react2angular'; +import { CustomTemplatesVariablesDefinitionField } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesDefinitionField'; +import { CustomTemplatesVariablesField } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField'; +import { withControlledInput } from '@/react-tools/withControlledInput'; + +import { VariablesFieldAngular } from './variables-field'; + +export const ngModule = angular + .module('portainer.app.react.components.custom-templates', []) + .component( + 'customTemplatesVariablesFieldReact', + r2a(withControlledInput(CustomTemplatesVariablesField), [ + 'value', + 'onChange', + 'definitions', + 'errors', + ]) + ) + .component('customTemplatesVariablesField', VariablesFieldAngular) + .component( + 'customTemplatesVariablesDefinitionField', + r2a(withControlledInput(CustomTemplatesVariablesDefinitionField), [ + 'onChange', + 'value', + 'errors', + 'isVariablesNamesFromParent', + ]) + ); + +export const customTemplatesModule = ngModule.name; diff --git a/app/portainer/react/components/custom-templates/variables-field.ts b/app/portainer/react/components/custom-templates/variables-field.ts new file mode 100644 index 0000000..3058041 --- /dev/null +++ b/app/portainer/react/components/custom-templates/variables-field.ts @@ -0,0 +1,71 @@ +import { + IComponentOptions, + IComponentController, + IFormController, + IScope, + IOnChangesObject, +} from 'angular'; + +import { VariableDefinition } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesDefinitionField/CustomTemplatesVariablesDefinitionField'; + +class VariablesFieldController implements IComponentController { + formCtrl!: IFormController; + + value!: Record; + + definitions!: VariableDefinition[]; + + onChange!: (value: Record) => void; + + $scope: IScope; + + /* @ngInject */ + constructor($scope: IScope) { + this.handleChange = this.handleChange.bind(this); + + this.$scope = $scope; + } + + handleChange(value: Record) { + this.$scope.$evalAsync(() => { + this.onChange(value); + }); + } + + $onChanges({ value }: IOnChangesObject) { + if (value?.currentValue) { + this.checkValidity(value.currentValue); + } + } + + checkValidity(value: Record) { + this.formCtrl.$setValidity( + 'templateVariables', + Object.entries(value).every( + ([name, value]) => + !!value || + this.definitions.some( + (def) => def.name === name && !!def.defaultValue + ) + ), + this.formCtrl + ); + } +} + +export const VariablesFieldAngular: IComponentOptions = { + template: ``, + bindings: { + value: '<', + definitions: '<', + onChange: '<', + }, + require: { + formCtrl: '^form', + }, + controller: VariablesFieldController, +}; diff --git a/app/portainer/react/components/environments.ts b/app/portainer/react/components/environments.ts new file mode 100644 index 0000000..f6b831d --- /dev/null +++ b/app/portainer/react/components/environments.ts @@ -0,0 +1,8 @@ +import angular from 'angular'; + +import { r2a } from '@/react-tools/react2angular'; +import { TagsDatatable } from '@/react/portainer/environments/TagsView/TagsDatatable'; + +export const environmentsModule = angular + .module('portainer.app.react.components.environments', []) + .component('tagsDatatable', r2a(TagsDatatable, ['dataset', 'onRemove'])).name; diff --git a/app/portainer/react/components/file-upload-field.ts b/app/portainer/react/components/file-upload-field.ts new file mode 100644 index 0000000..7c12a42 --- /dev/null +++ b/app/portainer/react/components/file-upload-field.ts @@ -0,0 +1,20 @@ +import { r2a } from '@/react-tools/react2angular'; + +import { FileUploadField } from '@@/form-components/FileUpload'; + +export const fileUploadField = r2a(FileUploadField, [ + 'onChange', + 'value', + 'title', + 'required', + 'accept', + 'inputId', + 'data-cy', + 'className', + 'color', + 'name', + 'hideFilename', + 'tooltip', + 'disabled', + 'state', +]); diff --git a/app/portainer/react/components/git-form.ts b/app/portainer/react/components/git-form.ts new file mode 100644 index 0000000..74dbf14 --- /dev/null +++ b/app/portainer/react/components/git-form.ts @@ -0,0 +1,27 @@ +import angular from 'angular'; + +import { r2a } from '@/react-tools/react2angular'; +import { withCurrentUser } from '@/react-tools/withCurrentUser'; +import { withReactQuery } from '@/react-tools/withReactQuery'; +import { withUIRouter } from '@/react-tools/withUIRouter'; +import { GitForm } from '@/react/portainer/gitops/GitForm'; + +export const gitFormModule = angular + .module('portainer.app.components.forms.git', []) + .component( + 'reactGitForm', + r2a(withUIRouter(withReactQuery(withCurrentUser(GitForm))), [ + 'value', + 'onChange', + 'environmentType', + 'isDockerStandalone', + 'deployMethod', + 'isAdditionalFilesFieldVisible', + 'isForcePullVisible', + 'errors', + 'baseWebhookUrl', + 'webhookId', + 'webhooksDocs', + 'isAutoUpdateVisible', + ]) + ).name; diff --git a/app/portainer/react/components/index.ts b/app/portainer/react/components/index.ts new file mode 100644 index 0000000..f0b3017 --- /dev/null +++ b/app/portainer/react/components/index.ts @@ -0,0 +1,306 @@ +import angular from 'angular'; + +import { r2a } from '@/react-tools/react2angular'; +import { withCurrentUser } from '@/react-tools/withCurrentUser'; +import { withReactQuery } from '@/react-tools/withReactQuery'; +import { withUIRouter } from '@/react-tools/withUIRouter'; +import { AnnotationsBeTeaser } from '@/react/kubernetes/annotations/AnnotationsBeTeaser'; +import { withFormValidation } from '@/react-tools/withFormValidation'; +import { withControlledInput } from '@/react-tools/withControlledInput'; + +import { + EnvironmentVariablesFieldset, + EnvironmentVariablesPanel, + StackEnvironmentVariablesPanel, + envVarValidation, +} from '@@/form-components/EnvironmentVariablesFieldset'; +import { Icon } from '@@/Icon'; +import { ReactQueryDevtoolsWrapper } from '@@/ReactQueryDevtoolsWrapper'; +import { PageHeader } from '@@/PageHeader'; +import { TagSelector } from '@@/TagSelector'; +import { Loading } from '@@/Widget/Loading'; +import { PasswordCheckHint } from '@@/PasswordCheckHint'; +import { Tooltip } from '@@/Tip/Tooltip'; +import { TableColumnHeaderAngular } from '@@/datatables/TableHeaderCell'; +import { DashboardItem } from '@@/DashboardItem'; +import { SearchBar } from '@@/datatables/SearchBar'; +import { FallbackImage } from '@@/FallbackImage'; +import { BadgeIcon } from '@@/BadgeIcon'; +import { TeamsSelector } from '@@/TeamsSelector'; +import { TerminalTooltip } from '@@/TerminalTooltip'; +import { Terminal } from '@@/Terminal/Terminal'; +import { PortainerSelect } from '@@/form-components/PortainerSelect'; +import { Slider } from '@@/form-components/Slider'; +import { TagButton } from '@@/TagButton'; +import { BETeaserButton } from '@@/BETeaserButton'; +import { CodeEditor } from '@@/CodeEditor'; +import { HelpLink } from '@@/HelpLink'; +import { TextTip } from '@@/Tip/TextTip'; +import { InlineLoader } from '@@/InlineLoader/InlineLoader'; + +import { fileUploadField } from './file-upload-field'; +import { switchField } from './switch-field'; +import { customTemplatesModule } from './custom-templates'; +import { gitFormModule } from './git-form'; +import { settingsModule } from './settings'; +import { accessControlModule } from './access-control'; +import { environmentsModule } from './environments'; +import { registriesModule } from './registries'; +import { accountModule } from './account'; +import { usersModule } from './users'; +import { activityLogsModule } from './activity-logs'; +import { rbacModule } from './rbac'; +import { stacksModule } from './stacks'; +import { authModule } from './auth'; + +export const ngModule = angular + .module('portainer.app.react.components', [ + authModule, + accessControlModule, + customTemplatesModule, + environmentsModule, + gitFormModule, + registriesModule, + settingsModule, + accountModule, + usersModule, + activityLogsModule, + rbacModule, + stacksModule, + ]) + .component( + 'tagSelector', + r2a(withUIRouter(withReactQuery(TagSelector)), [ + 'allowCreate', + 'onChange', + 'value', + 'errors', + ]) + ) + .component( + 'beTeaserButton', + r2a(BETeaserButton, [ + 'featureId', + 'heading', + 'message', + 'buttonText', + 'className', + 'buttonClassName', + 'data-cy', + ]) + ) + .component( + 'tagButton', + r2a(TagButton, ['value', 'label', 'title', 'onRemove']) + ) + + .component( + 'portainerTooltip', + r2a(Tooltip, ['message', 'position', 'className', 'setHtmlMessage', 'size']) + ) + .component('terminalTooltip', r2a(TerminalTooltip, [])) + .component('fileUploadField', fileUploadField) + .component('porSwitchField', switchField) + .component( + 'passwordCheckHint', + r2a(withReactQuery(PasswordCheckHint), [ + 'forceChangePassword', + 'passwordValid', + ]) + ) + .component('rdLoading', r2a(Loading, [])) + .component( + 'tableColumnHeader', + r2a(TableColumnHeaderAngular, [ + 'colTitle', + 'canSort', + 'isSorted', + 'isSortedDesc', + ]) + ) + .component( + 'pageHeader', + r2a(withUIRouter(withReactQuery(withCurrentUser(PageHeader))), [ + 'title', + 'breadcrumbs', + 'loading', + 'onReload', + 'reload', + 'id', + 'showTitle', + ]) + ) + .component( + 'fallbackImage', + r2a(FallbackImage, ['src', 'fallbackIcon', 'alt', 'className']) + ) + .component('prIcon', r2a(Icon, ['className', 'icon', 'mode', 'size', 'spin'])) + .component( + 'reactQueryDevTools', + r2a(withReactQuery(ReactQueryDevtoolsWrapper), []) + ) + .component( + 'helpLink', + r2a(withUIRouter(withReactQuery(HelpLink)), [ + 'docLink', + 'target', + 'children', + ]) + ) + .component( + 'dashboardItem', + r2a(DashboardItem, [ + 'icon', + 'type', + 'value', + 'to', + 'params', + 'children', + 'pluralType', + 'isLoading', + 'isRefetching', + 'data-cy', + 'iconClass', + ]) + ) + .component( + 'datatableSearchbar', + r2a(SearchBar, [ + 'data-cy', + 'onChange', + 'value', + 'placeholder', + 'children', + 'className', + ]) + ) + .component('badgeIcon', r2a(BadgeIcon, ['icon', 'size', 'iconClass'])) + .component( + 'teamsSelector', + r2a(TeamsSelector, [ + 'onChange', + 'value', + 'dataCy', + 'inputId', + 'name', + 'placeholder', + 'teams', + 'disabled', + ]) + ) + .component( + 'porSelect', + r2a(PortainerSelect, [ + 'name', + 'inputId', + 'placeholder', + 'disabled', + 'data-cy', + 'bindToBody', + 'value', + 'onChange', + 'options', + 'isMulti', + 'filterOption', + 'isClearable', + 'components', + 'isLoading', + 'noOptionsMessage', + 'aria-label', + 'size', + 'loadingMessage', + 'getOptionValue', + 'onBlur', + ]) + ) + .component( + 'porSlider', + r2a(Slider, [ + 'min', + 'max', + 'step', + 'value', + 'onChange', + 'visibleTooltip', + 'dataCy', + 'disabled', + ]) + ) + .component( + 'reactCodeEditor', + r2a(CodeEditor, [ + 'id', + 'textTip', + 'type', + 'readonly', + 'onChange', + 'value', + 'height', + 'data-cy', + 'versions', + 'onVersionChange', + 'schema', + 'fileName', + 'placeholder', + 'showToolbar', + 'aria-label', + ]) + ) + .component( + 'textTip', + r2a(TextTip, [ + 'className', + 'color', + 'icon', + 'inline', + 'children', + 'childrenWrapperClassName', + ]) + ) + .component( + 'inlineLoader', + r2a(InlineLoader, ['children', 'className', 'size']) + ) + .component('annotationsBeTeaser', r2a(AnnotationsBeTeaser, [])) + .component( + 'shellTerminal', + r2a(Terminal, [ + 'url', + 'connect', + 'onStateChange', + 'onResize', + 'initialCommands', + ]) + ); + +export const componentsModule = ngModule.name; + +withFormValidation( + ngModule, + withControlledInput(EnvironmentVariablesFieldset, { values: 'onChange' }), + 'environmentVariablesFieldset', + ['canUndoDelete'], + envVarValidation +); + +withFormValidation( + ngModule, + withControlledInput(EnvironmentVariablesPanel, { values: 'onChange' }), + 'environmentVariablesPanel', + ['explanation', 'showHelpMessage', 'isFoldable'], + envVarValidation +); + +withFormValidation( + ngModule, + withUIRouter( + withReactQuery( + withControlledInput(StackEnvironmentVariablesPanel, { + values: 'onChange', + }) + ) + ), + 'stackEnvironmentVariablesPanel', + ['showHelpMessage', 'isFoldable'], + envVarValidation +); diff --git a/app/portainer/react/components/rbac.ts b/app/portainer/react/components/rbac.ts new file mode 100644 index 0000000..24e80c5 --- /dev/null +++ b/app/portainer/react/components/rbac.ts @@ -0,0 +1,24 @@ +import angular from 'angular'; + +import { r2a } from '@/react-tools/react2angular'; +import { withReactQuery } from '@/react-tools/withReactQuery'; +import { withUIRouter } from '@/react-tools/withUIRouter'; +import { AccessDatatable } from '@/react/portainer/access-control/AccessManagement/AccessDatatable/AccessDatatable'; + +export const rbacModule = angular + .module('portainer.app.react.components.rbac', []) + .component( + 'accessDatatable', + r2a(withUIRouter(withReactQuery(AccessDatatable)), [ + 'dataset', + 'inheritFrom', + 'isUpdateEnabled', + 'onRemove', + 'onUpdate', + 'showRoles', + 'showWarning', + 'tableKey', + 'isUpdatingAccess', + 'isLoading', + ]) + ).name; diff --git a/app/portainer/react/components/registries.ts b/app/portainer/react/components/registries.ts new file mode 100644 index 0000000..2ac733c --- /dev/null +++ b/app/portainer/react/components/registries.ts @@ -0,0 +1,39 @@ +import angular from 'angular'; + +import { r2a } from '@/react-tools/react2angular'; +import { withReactQuery } from '@/react-tools/withReactQuery'; +import { withUIRouter } from '@/react-tools/withUIRouter'; +import { RepositoriesDatatable } from '@/react/portainer/registries/repositories/ListView/RepositoriesDatatable'; +import { TagsDatatable } from '@/react/portainer/registries/repositories/ItemView/TagsDatatable/TagsDatatable'; +import { GitlabProjectTable } from '@/react/portainer/registries/CreateView/GitlabProjectsTable/GitlabProjectsTable'; +import { RegistryFormDockerhub } from '@/react/portainer/registries/CreateView/RegistryFormDockerhub/RegistryFormDockerhub'; + +export const registriesModule = angular + .module('portainer.app.react.components.registries', []) + .component( + 'registryRepositoriesDatatable', + r2a(withUIRouter(withReactQuery(RepositoriesDatatable)), ['dataset']) + ) + .component( + 'registriesRepositoryTagsDatatable', + r2a(withUIRouter(withReactQuery(TagsDatatable)), [ + 'dataset', + 'advancedFeaturesAvailable', + 'onRemove', + 'onRetag', + ]) + ) + .component( + 'gitlabProjectSelector', + r2a(GitlabProjectTable, ['dataset', 'onChange', 'value']) + ) + .component( + 'registryFormDockerhub', + r2a(withReactQuery(RegistryFormDockerhub), [ + 'initialValues', + 'onSubmit', + 'submitLabel', + 'isLoading', + 'nameIsUsed', + ]) + ).name; diff --git a/app/portainer/react/components/settings.ts b/app/portainer/react/components/settings.ts new file mode 100644 index 0000000..1745592 --- /dev/null +++ b/app/portainer/react/components/settings.ts @@ -0,0 +1,134 @@ +import angular from 'angular'; + +import { InternalAuth } from '@/react/portainer/settings/AuthenticationView/InternalAuth'; +import { AuthenticationMethodSelector } from '@/react/portainer/settings/AuthenticationView/AuthenticationMethodSelector'; +import { r2a } from '@/react-tools/react2angular'; +import { withReactQuery } from '@/react-tools/withReactQuery'; +import { withUIRouter } from '@/react-tools/withUIRouter'; +import { LDAPUsersTable } from '@/react/portainer/settings/AuthenticationView/LDAPAuth/LDAPUsersTable'; +import { LDAPGroupsTable } from '@/react/portainer/settings/AuthenticationView/LDAPAuth/LDAPGroupsTable'; +import { ApplicationSettingsPanel } from '@/react/portainer/settings/SettingsView/ApplicationSettingsPanel'; +import { KubeSettingsPanel } from '@/react/portainer/settings/SettingsView/KubeSettingsPanel'; +import { HelmCertPanel } from '@/react/portainer/settings/SettingsView/HelmCertPanel'; +import { HiddenContainersPanel } from '@/react/portainer/settings/SettingsView/HiddenContainersPanel/HiddenContainersPanel'; +import { SSLSettingsPanelWrapper } from '@/react/portainer/settings/SettingsView/SSLSettingsPanel/SSLSettingsPanel'; +import { AuthStyleField } from '@/react/portainer/settings/AuthenticationView/OAuth'; +import { AutoUserProvisionToggle } from '@/react/portainer/settings/AuthenticationView/AutoUserProvisionToggle/AutoUserProvisionToggle'; +import { LdapSettingsTestLogin } from '@/react/portainer/settings/AuthenticationView/LDAPAuth/LdapSettingsTestLogin/LdapSettingsTestLogin'; +import { SessionLifetimeSelect } from '@/react/portainer/settings/AuthenticationView/SessionLifetimeSelect'; +import { LdapSecurityFieldset } from '@/react/portainer/settings/AuthenticationView/LDAPAuth/LdapSecurityFieldset/LdapSecurityFieldset'; +import { DnBuilder } from '@/react/portainer/settings/AuthenticationView/LDAPAuth/DnEntriesField/DnBuilder'; +import { GroupDnBuilder } from '@/react/portainer/settings/AuthenticationView/LDAPAuth/DnEntriesField/GroupDnBuilder'; +import { withControlledInput } from '@/react-tools/withControlledInput'; +import { AdminGroupsSectionCE } from '@/react/portainer/settings/AuthenticationView/LDAPAuth/AdminGroupsSection'; + +export const settingsModule = angular + .module('portainer.app.react.components.settings', []) + .component( + 'sessionLifetimeSelect', + r2a(SessionLifetimeSelect, ['value', 'onChange']) + ) + .component( + 'internalAuth', + r2a(InternalAuth, ['onSaveSettings', 'isLoading', 'value', 'onChange']) + ) + .component( + 'authenticationMethodSelector', + r2a(AuthenticationMethodSelector, ['value', 'onChange']) + ) + .component('ldapUsersDatatable', r2a(LDAPUsersTable, ['dataset'])) + .component('ldapGroupsDatatable', r2a(LDAPGroupsTable, ['dataset'])) + .component( + 'ldapSettingsDnBuilder', + r2a(DnBuilder, ['value', 'onChange', 'suffix', 'label', 'limitedFeatureId']) + ) + .component( + 'ldapSettingsGroupDnBuilder', + r2a(GroupDnBuilder, [ + 'value', + 'onChange', + 'suffix', + 'index', + 'onRemoveClick', + 'limitedFeatureId', + ]) + ) + .component( + 'applicationSettingsPanel', + r2a(withReactQuery(ApplicationSettingsPanel), ['onSuccess', 'settings']) + ) + .component( + 'sslSettingsPanel', + r2a(withReactQuery(SSLSettingsPanelWrapper), []) + ) + .component('helmCertPanel', r2a(withReactQuery(HelmCertPanel), [])) + .component( + 'hiddenContainersPanel', + r2a(withUIRouter(withReactQuery(HiddenContainersPanel)), []) + ) + .component( + 'kubeSettingsPanel', + r2a(withUIRouter(withReactQuery(KubeSettingsPanel)), ['settings']) + ) + .component( + 'oauthAuthStyle', + r2a(AuthStyleField, [ + 'value', + 'onChange', + 'label', + 'tooltip', + 'readonly', + 'size', + ]) + ) + .component( + 'autoUserProvisionToggle', + r2a(AutoUserProvisionToggle, [ + 'value', + 'onChange', + 'description', + 'data-cy', + ]) + ) + .component( + 'ldapSettingsTestLogin', + r2a(withReactQuery(LdapSettingsTestLogin), [ + 'settings', + 'limitedFeatureId', + 'showBeIndicatorIfNeeded', + 'isLimitedFeatureSelfContained', + ]) + ) + .component( + 'ldapSecurityFieldset', + r2a(LdapSecurityFieldset, [ + 'values', + 'onChange', + 'errors', + 'uploadState', + 'limitedFeatureId', + 'title', + ]) + ) + .component( + 'ldapCustomAdminGroup', + r2a( + withReactQuery( + withControlledInput(AdminGroupsSectionCE, { + searchSettings: 'onSearchSettingsChange', + autoPopulate: 'onAutoPopulateChange', + selectedAdminGroups: 'onSelectedAdminGroupsChange', + }) + ), + [ + 'searchSettings', + 'onSearchSettingsChange', + 'autoPopulate', + 'onAutoPopulateChange', + 'selectedAdminGroups', + 'onSelectedAdminGroupsChange', + 'limitedFeatureId', + 'isLimitedFeatureSelfContained', + ] + ) + ).name; diff --git a/app/portainer/react/components/stacks.ts b/app/portainer/react/components/stacks.ts new file mode 100644 index 0000000..9b1099f --- /dev/null +++ b/app/portainer/react/components/stacks.ts @@ -0,0 +1,6 @@ +import angular from 'angular'; + +export const stacksModule = angular.module( + 'portainer.app.react.components.stacks', + [] +).name; diff --git a/app/portainer/react/components/switch-field.ts b/app/portainer/react/components/switch-field.ts new file mode 100644 index 0000000..4f8b721 --- /dev/null +++ b/app/portainer/react/components/switch-field.ts @@ -0,0 +1,20 @@ +import { r2a } from '@/react-tools/react2angular'; + +import { SwitchField } from '@@/form-components/SwitchField'; + +export const switchField = r2a(SwitchField, [ + 'tooltip', + 'checked', + 'index', + 'label', + 'name', + 'labelClass', + 'fieldClass', + 'data-cy', + 'disabled', + 'onChange', + 'featureId', + 'switchClass', + 'setTooltipHtmlMessage', + 'valueExplanation', +]); diff --git a/app/portainer/react/components/users.ts b/app/portainer/react/components/users.ts new file mode 100644 index 0000000..018ed8e --- /dev/null +++ b/app/portainer/react/components/users.ts @@ -0,0 +1,25 @@ +import angular from 'angular'; + +import { r2a } from '@/react-tools/react2angular'; +import { withUIRouter } from '@/react-tools/withUIRouter'; +import { withReactQuery } from '@/react-tools/withReactQuery'; +import { withCurrentUser } from '@/react-tools/withCurrentUser'; +import { EffectiveAccessViewerDatatable } from '@/react/portainer/users/RolesView/AccessViewer/EffectiveAccessViewerDatatable'; +import { EffectiveAccessViewer } from '@/react/portainer/users/RolesView/AccessViewer/EffectiveAccessViewer'; +import { RbacRolesDatatable } from '@/react/portainer/users/RolesView/RbacRolesDatatable'; + +export const usersModule = angular + .module('portainer.app.react.components.users', []) + .component( + 'effectiveAccessViewerDatatable', + r2a(withUIRouter(withCurrentUser(EffectiveAccessViewerDatatable)), [ + 'dataset', + ]) + ) + .component( + 'effectiveAccessViewer', + r2a(withUIRouter(withReactQuery(withCurrentUser(EffectiveAccessViewer))), [ + 'userId', + ]) + ) + .component('rbacRolesDatatable', r2a(RbacRolesDatatable, ['dataset'])).name; diff --git a/app/portainer/react/index.ts b/app/portainer/react/index.ts new file mode 100644 index 0000000..ddbcc47 --- /dev/null +++ b/app/portainer/react/index.ts @@ -0,0 +1,9 @@ +import angular from 'angular'; + +import { componentsModule } from './components'; +import { viewsModule } from './views'; + +export const reactModule = angular.module('portainer.app.react', [ + viewsModule, + componentsModule, +]).name; diff --git a/app/portainer/react/views/activity-logs.ts b/app/portainer/react/views/activity-logs.ts new file mode 100644 index 0000000..e742767 --- /dev/null +++ b/app/portainer/react/views/activity-logs.ts @@ -0,0 +1,13 @@ +import angular from 'angular'; + +import { r2a } from '@/react-tools/react2angular'; +import { withUIRouter } from '@/react-tools/withUIRouter'; +import { ActivityLogsView } from '@/react/portainer/logs/ActivityLogsView/ActivityLogsView'; +import { withCurrentUser } from '@/react-tools/withCurrentUser'; + +export const activityLogsModule = angular + .module('portainer.app.react.views.activity-logs', []) + .component( + 'activityLogsView', + r2a(withUIRouter(withCurrentUser(ActivityLogsView)), []) + ).name; diff --git a/app/portainer/react/views/env-groups.ts b/app/portainer/react/views/env-groups.ts new file mode 100644 index 0000000..7237789 --- /dev/null +++ b/app/portainer/react/views/env-groups.ts @@ -0,0 +1,24 @@ +import angular from 'angular'; + +import { r2a } from '@/react-tools/react2angular'; +import { withCurrentUser } from '@/react-tools/withCurrentUser'; +import { withReactQuery } from '@/react-tools/withReactQuery'; +import { withUIRouter } from '@/react-tools/withUIRouter'; +import { ListView } from '@/react/portainer/environments/environment-groups/ListView'; +import { EditGroupView } from '@/react/portainer/environments/environment-groups/ItemView/EditGroupView'; +import { CreateGroupView } from '@/react/portainer/environments/environment-groups/CreateView/CreateGroupView'; + +export const environmentGroupModule = angular + .module('portainer.app.react.views.environment-groups', []) + .component( + 'environmentGroupsListView', + r2a(withUIRouter(withReactQuery(withCurrentUser(ListView))), []) + ) + .component( + 'environmentGroupEditView', + r2a(withUIRouter(withReactQuery(withCurrentUser(EditGroupView))), []) + ) + .component( + 'environmentGroupCreateView', + r2a(withUIRouter(withReactQuery(withCurrentUser(CreateGroupView))), []) + ).name; diff --git a/app/portainer/react/views/environments.ts b/app/portainer/react/views/environments.ts new file mode 100644 index 0000000..d06b242 --- /dev/null +++ b/app/portainer/react/views/environments.ts @@ -0,0 +1,23 @@ +import angular from 'angular'; + +import { r2a } from '@/react-tools/react2angular'; +import { withCurrentUser } from '@/react-tools/withCurrentUser'; +import { withUIRouter } from '@/react-tools/withUIRouter'; +import { ListView } from '@/react/portainer/environments/ListView'; +import { EdgeAutoCreateScriptViewWrapper } from '@/react/portainer/environments/EdgeAutoCreateScriptView/EdgeAutoCreateScriptView'; +import { ItemView } from '@/react/portainer/environments/ItemView/ItemView'; + +export const environmentsModule = angular + .module('portainer.app.react.views.environments', []) + .component( + 'environmentsListView', + r2a(withUIRouter(withCurrentUser(ListView)), []) + ) + .component( + 'environmentsItemView', + r2a(withUIRouter(withCurrentUser(ItemView)), []) + ) + .component( + 'edgeAutoCreateScriptView', + r2a(withUIRouter(withCurrentUser(EdgeAutoCreateScriptViewWrapper)), []) + ).name; diff --git a/app/portainer/react/views/gitops.ts b/app/portainer/react/views/gitops.ts new file mode 100644 index 0000000..f95194f --- /dev/null +++ b/app/portainer/react/views/gitops.ts @@ -0,0 +1,33 @@ +import angular from 'angular'; + +import { ListView as WorkflowsListView } from '@/react/portainer/gitops/workflows/ListView/ListView'; +import { ItemView as WorkflowItemView } from '@/react/portainer/gitops/workflows/ItemView/ItemView'; +import { ListView as SourcesListView } from '@/react/portainer/gitops/sources/ListView/ListView'; +import { ItemView as SourceItemView } from '@/react/portainer/gitops/sources/ItemView/ItemView'; +import { CreateView as SourceCreateView } from '@/react/portainer/gitops/sources/CreateView/CreateView'; +import { r2a } from '@/react-tools/react2angular'; +import { withCurrentUser } from '@/react-tools/withCurrentUser'; +import { withUIRouter } from '@/react-tools/withUIRouter'; + +export const gitopsViewsModule = angular + .module('portainer.app.react.views.gitops', []) + .component( + 'workflowsView', + r2a(withUIRouter(withCurrentUser(WorkflowsListView)), []) + ) + .component( + 'workflowItemView', + r2a(withUIRouter(withCurrentUser(WorkflowItemView)), []) + ) + .component( + 'sourcesListView', + r2a(withUIRouter(withCurrentUser(SourcesListView)), []) + ) + .component( + 'sourceItemView', + r2a(withUIRouter(withCurrentUser(SourceItemView)), []) + ) + .component( + 'sourceCreateView', + r2a(withUIRouter(withCurrentUser(SourceCreateView)), []) + ).name; diff --git a/app/portainer/react/views/index.ts b/app/portainer/react/views/index.ts new file mode 100644 index 0000000..566e510 --- /dev/null +++ b/app/portainer/react/views/index.ts @@ -0,0 +1,71 @@ +import angular from 'angular'; + +import { HomeView } from '@/react/portainer/HomeView'; +import { withCurrentUser } from '@/react-tools/withCurrentUser'; +import { r2a } from '@/react-tools/react2angular'; +import { withReactQuery } from '@/react-tools/withReactQuery'; +import { withUIRouter } from '@/react-tools/withUIRouter'; +import { CreateUserAccessToken } from '@/react/portainer/account/CreateAccessTokenView'; +import { EdgeComputeSettingsView } from '@/react/portainer/settings/EdgeComputeView/EdgeComputeSettingsView'; +import { BackupSettingsPanel } from '@/react/portainer/settings/SettingsView/BackupSettingsView/BackupSettingsPanel'; +import { SettingsView } from '@/react/portainer/settings/SettingsView/SettingsView'; +import { CreateHelmRepositoriesView } from '@/react/portainer/account/helm-repositories/CreateHelmRepositoryView'; + +import { wizardModule } from './wizard'; +import { teamsModule } from './teams'; +import { updateSchedulesModule } from './update-schedules'; +import { environmentGroupModule } from './env-groups'; +import { registriesModule } from './registries'; +import { activityLogsModule } from './activity-logs'; +import { templatesModule } from './templates'; +import { usersModule } from './users'; +import { environmentsModule } from './environments'; +import { gitopsViewsModule } from './gitops'; + +export const viewsModule = angular + .module('portainer.app.react.views', [ + wizardModule, + teamsModule, + updateSchedulesModule, + environmentGroupModule, + registriesModule, + activityLogsModule, + templatesModule, + usersModule, + environmentsModule, + gitopsViewsModule, + ]) + .component( + 'homeView', + r2a(withUIRouter(withReactQuery(withCurrentUser(HomeView))), []) + ) + .component( + 'createUserAccessToken', + r2a( + withReactQuery(withCurrentUser(withUIRouter(CreateUserAccessToken))), + [] + ) + ) + .component( + 'settingsEdgeCompute', + r2a( + withUIRouter(withReactQuery(withCurrentUser(EdgeComputeSettingsView))), + ['onSubmit', 'settings'] + ) + ) + + .component( + 'backupSettingsPanel', + r2a(withUIRouter(withReactQuery(withCurrentUser(BackupSettingsPanel))), []) + ) + .component( + 'settingsView', + r2a(withUIRouter(withReactQuery(withCurrentUser(SettingsView))), []) + ) + .component( + 'createHelmRepositoryView', + r2a( + withUIRouter(withReactQuery(withCurrentUser(CreateHelmRepositoriesView))), + [] + ) + ).name; diff --git a/app/portainer/react/views/registries.ts b/app/portainer/react/views/registries.ts new file mode 100644 index 0000000..7b4cfdd --- /dev/null +++ b/app/portainer/react/views/registries.ts @@ -0,0 +1,19 @@ +import angular from 'angular'; + +import { r2a } from '@/react-tools/react2angular'; +import { withCurrentUser } from '@/react-tools/withCurrentUser'; +import { withReactQuery } from '@/react-tools/withReactQuery'; +import { withUIRouter } from '@/react-tools/withUIRouter'; +import { ListView } from '@/react/portainer/registries/ListView'; +import { ListView as EnvironmentListView } from '@/react/portainer/registries/environments/ListView'; + +export const registriesModule = angular + .module('portainer.app.react.views.registries', []) + .component( + 'registriesView', + r2a(withUIRouter(withReactQuery(withCurrentUser(ListView))), []) + ) + .component( + 'environmentRegistriesView', + r2a(withUIRouter(withReactQuery(withCurrentUser(EnvironmentListView))), []) + ).name; diff --git a/app/portainer/react/views/sidebar.ts b/app/portainer/react/views/sidebar.ts new file mode 100644 index 0000000..623e244 --- /dev/null +++ b/app/portainer/react/views/sidebar.ts @@ -0,0 +1,16 @@ +import angular from 'angular'; + +import { AngularSidebarService } from '@/react/sidebar/useSidebarState'; +import { Sidebar } from '@/react/sidebar/Sidebar'; +import { r2a } from '@/react-tools/react2angular'; +import { withCurrentUser } from '@/react-tools/withCurrentUser'; +import { withReactQuery } from '@/react-tools/withReactQuery'; +import { withUIRouter } from '@/react-tools/withUIRouter'; + +export const sidebarModule = angular + .module('portainer.app.sidebar', []) + .component( + 'sidebar', + r2a(withUIRouter(withReactQuery(withCurrentUser(Sidebar))), []) + ) + .factory('SidebarService', AngularSidebarService).name; diff --git a/app/portainer/react/views/teams.ts b/app/portainer/react/views/teams.ts new file mode 100644 index 0000000..9d8c34c --- /dev/null +++ b/app/portainer/react/views/teams.ts @@ -0,0 +1,48 @@ +import angular from 'angular'; +import { StateRegistry } from '@uirouter/angularjs'; + +import { ItemView, ListView } from '@/react/portainer/users/teams'; +import { r2a } from '@/react-tools/react2angular'; +import { withCurrentUser } from '@/react-tools/withCurrentUser'; +import { withReactQuery } from '@/react-tools/withReactQuery'; +import { withUIRouter } from '@/react-tools/withUIRouter'; +import { AccessHeaders } from '@/portainer/authorization-guard'; + +export const teamsModule = angular + .module('portainer.app.teams', []) + .config(config) + .component( + 'teamView', + r2a(withUIRouter(withReactQuery(withCurrentUser(ItemView))), []) + ) + .component( + 'teamsView', + r2a(withUIRouter(withReactQuery(withCurrentUser(ListView))), []) + ).name; + +/* @ngInject */ +function config($stateRegistryProvider: StateRegistry) { + $stateRegistryProvider.register({ + name: 'portainer.teams', + url: '/teams', + views: { + 'content@': { + component: 'teamsView', + }, + }, + data: { + docs: '/admin/user/teams', + access: AccessHeaders.Restricted, // allow for team leaders + }, + }); + + $stateRegistryProvider.register({ + name: 'portainer.teams.team', + url: '/:id', + views: { + 'content@': { + component: 'teamView', + }, + }, + }); +} diff --git a/app/portainer/react/views/templates.ts b/app/portainer/react/views/templates.ts new file mode 100644 index 0000000..5b79de9 --- /dev/null +++ b/app/portainer/react/views/templates.ts @@ -0,0 +1,28 @@ +import angular from 'angular'; + +import { r2a } from '@/react-tools/react2angular'; +import { withCurrentUser } from '@/react-tools/withCurrentUser'; +import { withUIRouter } from '@/react-tools/withUIRouter'; +import { CreateView } from '@/react/portainer/templates/custom-templates/CreateView'; +import { EditView } from '@/react/portainer/templates/custom-templates/EditView'; +import { AppTemplatesView } from '@/react/portainer/templates/app-templates/AppTemplatesView'; +import { ListView } from '@/react/portainer/templates/custom-templates/ListView/ListView'; + +export const templatesModule = angular + .module('portainer.app.react.views.templates', []) + .component( + 'appTemplatesView', + r2a(withCurrentUser(withUIRouter(AppTemplatesView)), []) + ) + .component( + 'customTemplatesView', + r2a(withCurrentUser(withUIRouter(ListView)), []) + ) + .component( + 'createCustomTemplatesView', + r2a(withCurrentUser(withUIRouter(CreateView)), []) + ) + .component( + 'editCustomTemplatesView', + r2a(withCurrentUser(withUIRouter(EditView)), []) + ).name; diff --git a/app/portainer/react/views/update-schedules.ts b/app/portainer/react/views/update-schedules.ts new file mode 100644 index 0000000..73c684d --- /dev/null +++ b/app/portainer/react/views/update-schedules.ts @@ -0,0 +1,63 @@ +import angular from 'angular'; +import { StateRegistry } from '@uirouter/angularjs'; + +import { r2a } from '@/react-tools/react2angular'; +import { + ListView, + CreateView, + ItemView, +} from '@/react/portainer/environments/update-schedules'; +import { withUIRouter } from '@/react-tools/withUIRouter'; +import { withReactQuery } from '@/react-tools/withReactQuery'; +import { withCurrentUser } from '@/react-tools/withCurrentUser'; + +export const updateSchedulesModule = angular + .module('portainer.edge.updateSchedules', []) + .component( + 'updateSchedulesListView', + r2a(withUIRouter(withReactQuery(withCurrentUser(ListView))), []) + ) + .component( + 'updateSchedulesCreateView', + r2a(withUIRouter(withReactQuery(withCurrentUser(CreateView))), []) + ) + .component( + 'updateSchedulesItemView', + r2a(withUIRouter(withReactQuery(withCurrentUser(ItemView))), []) + ) + .config(config).name; + +function config($stateRegistryProvider: StateRegistry) { + $stateRegistryProvider.register({ + name: 'portainer.endpoints.updateSchedules', + url: '/update-schedules', + views: { + 'content@': { + component: 'updateSchedulesListView', + }, + }, + data: { + docs: '/admin/environments/update', + }, + }); + + $stateRegistryProvider.register({ + name: 'portainer.endpoints.updateSchedules.create', + url: '/update-schedules/new', + views: { + 'content@': { + component: 'updateSchedulesCreateView', + }, + }, + }); + + $stateRegistryProvider.register({ + name: 'portainer.endpoints.updateSchedules.item', + url: '/update-schedules/:id', + views: { + 'content@': { + component: 'updateSchedulesItemView', + }, + }, + }); +} diff --git a/app/portainer/react/views/users.ts b/app/portainer/react/views/users.ts new file mode 100644 index 0000000..22991e9 --- /dev/null +++ b/app/portainer/react/views/users.ts @@ -0,0 +1,15 @@ +import angular from 'angular'; + +import { ListView } from '@/react/portainer/users/ListView/ListView'; +import { r2a } from '@/react-tools/react2angular'; +import { withCurrentUser } from '@/react-tools/withCurrentUser'; +import { withReactQuery } from '@/react-tools/withReactQuery'; +import { withUIRouter } from '@/react-tools/withUIRouter'; + +export const usersModule = angular + .module('portainer.app.react.views.users', []) + + .component( + 'usersListView', + r2a(withUIRouter(withReactQuery(withCurrentUser(ListView))), []) + ).name; diff --git a/app/portainer/react/views/wizard.ts b/app/portainer/react/views/wizard.ts new file mode 100644 index 0000000..6387583 --- /dev/null +++ b/app/portainer/react/views/wizard.ts @@ -0,0 +1,80 @@ +import angular from 'angular'; +import { StateRegistry } from '@uirouter/angularjs'; + +import { r2a } from '@/react-tools/react2angular'; +import { + EnvironmentCreationView, + EnvironmentTypeSelectView, + HomeView, +} from '@/react/portainer/environments/wizard'; +import { withCurrentUser } from '@/react-tools/withCurrentUser'; +import { withReactQuery } from '@/react-tools/withReactQuery'; +import { withUIRouter } from '@/react-tools/withUIRouter'; +import { AccessHeaders } from '@/portainer/authorization-guard'; + +export const wizardModule = angular + .module('portainer.app.react.views.wizard', []) + .component( + 'wizardEnvironmentCreationView', + r2a( + withUIRouter(withReactQuery(withCurrentUser(EnvironmentCreationView))), + [] + ) + ) + .component( + 'wizardEnvironmentTypeSelectView', + r2a( + withUIRouter(withReactQuery(withCurrentUser(EnvironmentTypeSelectView))), + [] + ) + ) + .component( + 'wizardMainView', + r2a(withUIRouter(withReactQuery(withCurrentUser(HomeView))), []) + ) + .config(config).name; + +function config($stateRegistryProvider: StateRegistry) { + $stateRegistryProvider.register({ + name: 'portainer.wizard', + url: '/wizard', + views: { + 'content@': { + component: 'wizardMainView', + }, + }, + data: { + access: AccessHeaders.Admin, + }, + }); + + $stateRegistryProvider.register({ + name: 'portainer.wizard.endpoints', + url: '/endpoints?referrer', + views: { + 'content@': { + component: 'wizardEnvironmentTypeSelectView', + }, + }, + params: { + localEndpointId: 0, + }, + data: { + docs: '/admin/environments/add', + }, + }); + + $stateRegistryProvider.register({ + name: 'portainer.wizard.endpoints.create', + url: '/create?envType&step', + views: { + 'content@': { + component: 'wizardEnvironmentCreationView', + }, + }, + params: { + envType: '', + step: { value: null, squash: true }, + }, + }); +} diff --git a/app/portainer/registry-management/index.js b/app/portainer/registry-management/index.js new file mode 100644 index 0000000..9afb351 --- /dev/null +++ b/app/portainer/registry-management/index.js @@ -0,0 +1,50 @@ +import { AccessHeaders } from '../authorization-guard'; + +angular.module('portainer.registrymanagement', []).config(config); + +/* @ngInject */ +function config($stateRegistryProvider) { + const registries = { + name: 'portainer.registries', + url: '/registries', + views: { + 'content@': { + component: 'registriesView', + }, + }, + data: { + docs: '/admin/registries', + access: AccessHeaders.Admin, + }, + }; + + const registryCreation = { + name: 'portainer.registries.new', + url: '/new', + views: { + 'content@': { + component: 'createRegistry', + }, + }, + data: { + docs: '/admin/registries/add', + }, + }; + + const registry = { + name: 'portainer.registries.registry', + url: '/:id', + views: { + 'content@': { + component: 'editRegistry', + }, + }, + data: { + docs: '/admin/registries/edit', + }, + }; + + $stateRegistryProvider.register(registries); + $stateRegistryProvider.register(registry); + $stateRegistryProvider.register(registryCreation); +} diff --git a/app/portainer/registry-management/views/create/createRegistry.html b/app/portainer/registry-management/views/create/createRegistry.html new file mode 100644 index 0000000..7d24562 --- /dev/null +++ b/app/portainer/registry-management/views/create/createRegistry.html @@ -0,0 +1,80 @@ + + +
+
+ + +
+
Registry provider
+ + + + + + + + + + + + + + + + +
+
+
+
+
diff --git a/app/portainer/registry-management/views/create/createRegistry.js b/app/portainer/registry-management/views/create/createRegistry.js new file mode 100644 index 0000000..e935fee --- /dev/null +++ b/app/portainer/registry-management/views/create/createRegistry.js @@ -0,0 +1,10 @@ +import angular from 'angular'; +import CreateRegistryController from './createRegistryController'; + +angular.module('portainer.app').component('createRegistry', { + templateUrl: './createRegistry.html', + controller: CreateRegistryController, + bindings: { + $transition$: '<', + }, +}); diff --git a/app/portainer/registry-management/views/create/createRegistryController.js b/app/portainer/registry-management/views/create/createRegistryController.js new file mode 100644 index 0000000..3f26221 --- /dev/null +++ b/app/portainer/registry-management/views/create/createRegistryController.js @@ -0,0 +1,220 @@ +import _ from 'lodash'; +import { RegistryTypes } from '@/portainer/models/registryTypes'; +import { RegistryCreateFormValues } from '@/portainer/models/registry'; +import { options } from '@/react/portainer/registries/CreateView/options'; + +class CreateRegistryController { + /* @ngInject */ + constructor($async, $state, Notifications, RegistryService, RegistryGitlabService) { + Object.assign(this, { $async, $state, Notifications, RegistryService, RegistryGitlabService }); + + this.RegistryTypes = RegistryTypes; + this.state = { + actionInProgress: false, + overrideConfiguration: false, + originViewReference: 'portainer.registries', + originalEndpointId: null, + }; + + this.createRegistry = this.createRegistry.bind(this); + this.onSubmitDocker = this.onSubmitDocker.bind(this); + this.getRegistries = this.getRegistries.bind(this); + this.nameIsUsed = this.nameIsUsed.bind(this); + this.retrieveGitlabRegistries = this.retrieveGitlabRegistries.bind(this); + this.createGitlabRegistries = this.createGitlabRegistries.bind(this); + + this.selectDockerHub = this.selectDockerHub.bind(this); + this.selectEcr = this.selectEcr.bind(this); + this.selectQuayRegistry = this.selectQuayRegistry.bind(this); + this.selectProGetRegistry = this.selectProGetRegistry.bind(this); + this.selectAzureRegistry = this.selectAzureRegistry.bind(this); + this.selectGitlabRegistry = this.selectGitlabRegistry.bind(this); + this.selectCustomRegistry = this.selectCustomRegistry.bind(this); + + this.setRegistry = this.setRegistry.bind(this); + } + + useDefaultQuayConfiguration() { + this.model.Quay.useOrganisation = false; + this.model.Quay.organisationName = ''; + } + + selectQuayRegistry() { + this.model.Name = 'Quay'; + this.model.URL = 'quay.io'; + this.model.Authentication = true; + this.model.Quay = {}; + this.useDefaultQuayConfiguration(); + this.model.Type = RegistryTypes.QUAY; + } + + useDefaultGitlabConfiguration() { + this.model.URL = 'https://registry.gitlab.com'; + this.model.Gitlab.InstanceURL = 'https://gitlab.com'; + } + + selectGitlabRegistry() { + this.model.Name = ''; + this.model.Authentication = true; + this.model.Gitlab = {}; + this.useDefaultGitlabConfiguration(); + this.model.Type = RegistryTypes.GITLAB; + } + + selectAzureRegistry() { + this.model.Name = ''; + this.model.URL = ''; + this.model.Authentication = true; + this.model.Type = RegistryTypes.AZURE; + } + + selectProGetRegistry() { + this.model.Name = ''; + this.model.URL = ''; + this.model.BaseURL = ''; + this.model.Authentication = true; + this.model.Type = RegistryTypes.PROGET; + } + + selectCustomRegistry() { + this.model.Name = ''; + this.model.URL = ''; + this.model.Authentication = false; + this.model.Type = RegistryTypes.CUSTOM; + } + + selectDockerHub() { + this.model.Name = ''; + this.model.URL = 'docker.io'; + this.model.Authentication = true; + this.model.Type = RegistryTypes.DOCKERHUB; + } + + useDefaultEcrConfiguration() { + this.model.Ecr.Region = ''; + } + + selectEcr() { + this.model.Name = ''; + this.model.URL = ''; + this.model.Authentication = false; + this.model.Ecr = {}; + this.useDefaultEcrConfiguration(); + this.model.Type = RegistryTypes.ECR; + } + + retrieveGitlabRegistries() { + return this.$async(async () => { + this.state.actionInProgress = true; + try { + this.gitlabProjects = await this.RegistryGitlabService.projects(this.model.Gitlab.InstanceURL, this.model.Token); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to retrieve projects'); + } finally { + this.state.actionInProgress = false; + } + }); + } + + createGitlabRegistries(registries) { + return this.$async(async () => { + try { + this.state.actionInProgress = true; + await this.RegistryService.createGitlabRegistries(this.model, registries); + this.Notifications.success('Success', 'Registries successfully created'); + this.$state.go(this.state.originViewReference, { endpointId: this.state.originalEndpointId }); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to create registries'); + this.state.actionInProgress = false; + } + }); + } + + createRegistry() { + return this.$async(async () => { + try { + this.state.actionInProgress = true; + await this.RegistryService.createRegistry(this.model); + this.Notifications.success('Success', 'Registry successfully created'); + this.$state.go(this.state.originViewReference, { endpointId: this.state.originalEndpointId }); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to create registry'); + this.state.actionInProgress = false; + } + }); + } + + /** + * + * @param {import('@/react/portainer/registries/CreateView/RegistryFormDockerhub/RegistryFormDockerhub').RegistryFormDockerhubValues} model + */ + onSubmitDocker(model) { + this.model = model; + this.createRegistry(); + } + + nameIsUsed(name) { + return _.includes(this.registriesNames, name); + } + + getRegistries() { + return this.$async(async () => { + try { + const registries = await this.RegistryService.registries(); + this.registriesNames = _.map(registries, 'Name'); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to fetch existing registries'); + } + }); + } + + setRegistry(registry) { + this.state.registryValue = registry; + + switch (registry) { + case '6': + this.selectDockerHub(); + break; + case '7': + this.selectEcr(); + break; + case '1': + this.selectQuayRegistry(); + break; + case '5': + this.selectProGetRegistry(); + break; + case '2': + this.selectAzureRegistry(); + break; + case '4': + this.selectGitlabRegistry(); + break; + case '3': + this.selectCustomRegistry(); + break; + } + } + + $onInit() { + return this.$async(async () => { + this.model = new RegistryCreateFormValues(); + this.model.Type = RegistryTypes.DOCKERHUB; + this.selectDockerHub(); + this.state.availableRegistry = options; + // Default registryValue is DockerHub, which is 6 + this.state.registryValue = '6'; + + const from = this.$transition$.from(); + const params = this.$transition$.params('from'); + + if (from.name && /^[a-z]+\.registries$/.test(from.name)) { + this.state.originViewReference = from; + this.state.originalEndpointId = params.endpointId || null; + } + await this.getRegistries(); + }); + } +} + +export default CreateRegistryController; diff --git a/app/portainer/registry-management/views/edit/registry.html b/app/portainer/registry-management/views/edit/registry.html new file mode 100644 index 0000000..864c890 --- /dev/null +++ b/app/portainer/registry-management/views/edit/registry.html @@ -0,0 +1,289 @@ + + +
+
+ + +
+ +
+ +
+ +
+
+ + +
+ +
+ +
+
+
+
+
+

+ + A registry with the same name already exists. +

+
+
+
+
+
+
+

+ + This field is required. +

+
+
+
+ + +
+ +
+ +
+
+
+
+
+

+ + This field is required. +

+
+
+
+ + +
+
+ +
+ +
+
+
+
+
+

+ + This field is required. +

+
+
+
+
+ + + +
+ +
+ +
+
+ + + +
+ +
+ +
+ +
+
+
+
+
+

+ + This field is required. +

+
+
+
+ + +
+ +
+ +
+
+
+
+
+

+ + This field is required. +

+
+
+
+ +
+
+ +
+ +
+
+
+
+
+

+ + This field is required. +

+
+
+
+
+ + +
+ + +
+ +
+ +
+ +
+
+ +
+ +
+ +
+ +
+
+
+
+
+

+ + This field is required. +

+
+
+
+ +
+
+ +
+
+ + Cancel +
+
+
+
+
+
+
diff --git a/app/portainer/registry-management/views/edit/registry.js b/app/portainer/registry-management/views/edit/registry.js new file mode 100644 index 0000000..c09ea41 --- /dev/null +++ b/app/portainer/registry-management/views/edit/registry.js @@ -0,0 +1,10 @@ +import angular from 'angular'; +import controller from './registryController'; + +angular.module('portainer.app').component('editRegistry', { + templateUrl: './registry.html', + controller, + bindings: { + $transition$: '<', + }, +}); diff --git a/app/portainer/registry-management/views/edit/registryController.js b/app/portainer/registry-management/views/edit/registryController.js new file mode 100644 index 0000000..97e3129 --- /dev/null +++ b/app/portainer/registry-management/views/edit/registryController.js @@ -0,0 +1,119 @@ +import _ from 'lodash'; +import { RegistryTypes } from '@/portainer/models/registryTypes'; + +export default class RegistryController { + /* @ngInject */ + constructor($scope, $async, $state, RegistryService, Notifications) { + this.$scope = $scope; + Object.assign(this, { $async, $state, RegistryService, Notifications }); + + this.RegistryTypes = RegistryTypes; + + this.state = { + actionInProgress: false, + loading: false, + }; + + this.Password = ''; + + this.toggleAuthentication = this.toggleAuthentication.bind(this); + this.toggleQuayUseOrganisation = this.toggleQuayUseOrganisation.bind(this); + } + + toggleAuthentication(newValue) { + this.$scope.$evalAsync(() => { + this.registry.Authentication = newValue; + }); + } + + toggleQuayUseOrganisation(newValue) { + this.$scope.$evalAsync(() => { + this.registry.Quay.UseOrganisation = newValue; + }); + } + + passwordLabel() { + const type = this.registry.Type; + switch (type) { + case RegistryTypes.ECR: + return 'AWS Secret Access Key'; + case RegistryTypes.DOCKERHUB: + return 'Access token'; + case RegistryTypes.GITLAB: + return 'Personal Access Token'; + default: + return 'Password'; + } + } + + updateRegistry() { + return this.$async(async () => { + try { + this.state.actionInProgress = true; + const registry = this.registry; + registry.Password = this.Password; + + await this.RegistryService.updateRegistry(registry); + this.Notifications.success('Success', 'Registry successfully updated'); + this.$state.go('portainer.registries'); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to update registry'); + } finally { + this.state.actionInProgress = false; + } + }); + } + + onChangeName() { + this.state.nameAlreadyExists = _.includes(this.registriesNames, this.registry.Name); + } + + isUpdateButtonDisabled() { + return ( + this.state.actionInProgress || + this.state.nameAlreadyExists || + !this.registry.Name || + !this.registry.URL || + (this.registry.Type == this.RegistryTypes.QUAY && this.registry.Quay.UseOrganisation && !this.registry.Quay.OrganisationName) + ); + } + + getRegistryProvider(registryType) { + switch (registryType) { + case RegistryTypes.QUAY: + return 'Quay.io'; + case RegistryTypes.AZURE: + return 'Azure'; + case RegistryTypes.CUSTOM: + return 'Custom'; + case RegistryTypes.GITLAB: + return 'Gitlab'; + case RegistryTypes.PROGET: + return 'ProGet'; + case RegistryTypes.DOCKERHUB: + return 'Docker Hub'; + case RegistryTypes.ECR: + return 'AWS ECR'; + default: + return ''; + } + } + async $onInit() { + try { + this.state.loading = true; + + const registryId = this.$state.params.id; + const registry = await this.RegistryService.registry(registryId); + this.registry = registry; + this.provider = this.getRegistryProvider(registry.Type); + + const registries = await this.RegistryService.registries(); + _.pullAllBy(registries, [registry], 'Id'); + this.registriesNames = _.map(registries, 'Name'); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to retrieve registry details'); + } finally { + this.state.loading = false; + } + } +} diff --git a/app/portainer/rest/auth.js b/app/portainer/rest/auth.js new file mode 100644 index 0000000..1723ccd --- /dev/null +++ b/app/portainer/rest/auth.js @@ -0,0 +1,15 @@ +angular.module('portainer.app').factory('Auth', [ + '$resource', + 'API_ENDPOINT_AUTH', + function AuthFactory($resource, API_ENDPOINT_AUTH) { + 'use strict'; + return $resource( + API_ENDPOINT_AUTH + '/:action', + {}, + { + login: { method: 'POST', ignoreLoadingBar: true }, + logout: { method: 'POST', params: { action: 'logout' }, ignoreLoadingBar: true }, + } + ); + }, +]); diff --git a/app/portainer/rest/backup.js b/app/portainer/rest/backup.js new file mode 100644 index 0000000..6cc8539 --- /dev/null +++ b/app/portainer/rest/backup.js @@ -0,0 +1,34 @@ +angular.module('portainer.app').factory('Backup', [ + '$resource', + 'API_ENDPOINT_BACKUP', + function BackupFactory($resource, API_ENDPOINT_BACKUP) { + 'use strict'; + return $resource( + API_ENDPOINT_BACKUP + '/:subResource/:action', + {}, + { + download: { + method: 'POST', + responseType: 'arraybuffer', + ignoreLoadingBar: true, + transformResponse: (data, headersGetter, status) => { + if (status !== 200) { + const decoder = new TextDecoder('utf-8'); + const str = decoder.decode(data); + return JSON.parse(str); + } + + return { + file: data, + name: headersGetter('Content-Disposition').replace('attachment; filename=', ''), + }; + }, + }, + getS3Settings: { method: 'GET', params: { subResource: 's3', action: 'settings' } }, + saveS3Settings: { method: 'POST', params: { subResource: 's3', action: 'settings' } }, + exportS3Backup: { method: 'POST', params: { subResource: 's3', action: 'execute' } }, + restoreS3Backup: { method: 'POST', params: { subResource: 's3', action: 'restore' } }, + } + ); + }, +]); diff --git a/app/portainer/rest/customTemplate.js b/app/portainer/rest/customTemplate.js new file mode 100644 index 0000000..f712355 --- /dev/null +++ b/app/portainer/rest/customTemplate.js @@ -0,0 +1,19 @@ +import angular from 'angular'; + +angular.module('portainer.app').factory('CustomTemplates', CustomTemplatesFactory); + +function CustomTemplatesFactory($resource, API_ENDPOINT_CUSTOM_TEMPLATES) { + return $resource( + API_ENDPOINT_CUSTOM_TEMPLATES + '/:id/:action', + {}, + { + create: { method: 'POST', ignoreLoadingBar: true, params: { id: 'create', action: '@method' } }, + query: { method: 'GET', isArray: true }, + get: { method: 'GET', params: { id: '@id' } }, + update: { method: 'PUT', params: { id: '@id' } }, + remove: { method: 'DELETE', params: { id: '@id' } }, + file: { method: 'GET', params: { id: '@id', action: 'file' } }, + gitFetch: { method: 'PUT', params: { id: '@id', action: 'git_fetch' } }, + } + ); +} diff --git a/app/portainer/rest/endpoint.js b/app/portainer/rest/endpoint.js new file mode 100644 index 0000000..4f0d4c7 --- /dev/null +++ b/app/portainer/rest/endpoint.js @@ -0,0 +1,44 @@ +import getEndpointsTotalCount from './transform/getEndpointsTotalCount'; + +angular.module('portainer.app').factory('Endpoints', [ + '$resource', + 'API_ENDPOINT_ENDPOINTS', + function EndpointsFactory($resource, API_ENDPOINT_ENDPOINTS) { + 'use strict'; + return $resource( + API_ENDPOINT_ENDPOINTS + '/:id/:action', + {}, + { + query: { + method: 'GET', + params: { start: '@start', limit: '@limit', search: '@search', groupId: '@groupId' }, + transformResponse: getEndpointsTotalCount, + }, + get: { method: 'GET', params: { id: '@id' } }, + update: { method: 'PUT', params: { id: '@id' } }, + disassociate: { method: 'DELETE', params: { id: '@id', action: 'association' } }, + updateAccess: { method: 'PUT', params: { id: '@id', action: 'access' } }, + remove: { method: 'DELETE', params: { id: '@id' } }, + snapshots: { method: 'POST', params: { action: 'snapshot' } }, + snapshot: { method: 'POST', params: { id: '@id', action: 'snapshot' } }, + status: { method: 'GET', params: { id: '@id', action: 'status' } }, + updateSecuritySettings: { method: 'PUT', params: { id: '@id', action: 'settings' } }, + dockerhubLimits: { + method: 'GET', + url: `${API_ENDPOINT_ENDPOINTS}/:id/dockerhub/:registryId`, + }, + registries: { + method: 'GET', + url: `${API_ENDPOINT_ENDPOINTS}/:id/registries`, + params: { id: '@id', namespace: '@namespace' }, + isArray: true, + }, + updateRegistryAccess: { + method: 'PUT', + url: `${API_ENDPOINT_ENDPOINTS}/:id/registries/:registryId`, + params: { id: '@id', registryId: '@registryId' }, + }, + } + ); + }, +]); diff --git a/app/portainer/rest/gitlab.js b/app/portainer/rest/gitlab.js new file mode 100644 index 0000000..1418a4c --- /dev/null +++ b/app/portainer/rest/gitlab.js @@ -0,0 +1,38 @@ +import gitlabResponseGetLink from './transform/gitlabResponseGetLink'; + +angular.module('portainer.app').factory('Gitlab', [ + '$resource', + 'API_ENDPOINT_REGISTRIES', + function GitlabFactory($resource, API_ENDPOINT_REGISTRIES) { + 'use strict'; + return function (env) { + const headers = {}; + if (env) { + headers['Private-Token'] = env.token; + headers['X-Gitlab-Domain'] = env.url; + } + + const baseUrl = API_ENDPOINT_REGISTRIES + '/:id/proxies/gitlab/api/v4/projects'; + + return $resource( + baseUrl, + { id: '@id' }, + { + projects: { + method: 'GET', + params: { membership: 'true' }, + transformResponse: gitlabResponseGetLink, + headers: headers, + }, + repositories: { + method: 'GET', + url: baseUrl + '/:projectId/registry/repositories', + params: { tags: true }, + headers: headers, + transformResponse: gitlabResponseGetLink, + }, + } + ); + }; + }, +]); diff --git a/app/portainer/rest/group.js b/app/portainer/rest/group.js new file mode 100644 index 0000000..f652656 --- /dev/null +++ b/app/portainer/rest/group.js @@ -0,0 +1,21 @@ +angular.module('portainer.app').factory('EndpointGroups', [ + '$resource', + 'API_ENDPOINT_ENDPOINT_GROUPS', + function EndpointGroupsFactory($resource, API_ENDPOINT_ENDPOINT_GROUPS) { + 'use strict'; + return $resource( + API_ENDPOINT_ENDPOINT_GROUPS + '/:id/:action', + {}, + { + create: { method: 'POST', ignoreLoadingBar: true }, + query: { method: 'GET', isArray: true }, + get: { method: 'GET', params: { id: '@id' } }, + update: { method: 'PUT', params: { id: '@id' } }, + updateAccess: { method: 'PUT', params: { id: '@id', action: 'access' } }, + addEndpoint: { method: 'PUT', params: { id: '@id', action: '@action' } }, + removeEndpoint: { method: 'DELETE', params: { id: '@id', action: '@action' } }, + remove: { method: 'DELETE', params: { id: '@id' } }, + } + ); + }, +]); diff --git a/app/portainer/rest/registry.js b/app/portainer/rest/registry.js new file mode 100644 index 0000000..706c56a --- /dev/null +++ b/app/portainer/rest/registry.js @@ -0,0 +1,19 @@ +angular.module('portainer.app').factory('Registries', [ + '$resource', + 'API_ENDPOINT_REGISTRIES', + function RegistriesFactory($resource, API_ENDPOINT_REGISTRIES) { + 'use strict'; + return $resource( + API_ENDPOINT_REGISTRIES + '/:id/:action', + {}, + { + create: { method: 'POST', ignoreLoadingBar: true }, + query: { method: 'GET', isArray: true }, + get: { method: 'GET', params: { id: '@id', action: '', endpointId: '@endpointId' } }, + update: { method: 'PUT', params: { id: '@id' } }, + remove: { method: 'DELETE', params: { id: '@id' } }, + configure: { method: 'POST', params: { id: '@id', action: 'configure' } }, + } + ); + }, +]); diff --git a/app/portainer/rest/resourceControl.js b/app/portainer/rest/resourceControl.js new file mode 100644 index 0000000..24ba913 --- /dev/null +++ b/app/portainer/rest/resourceControl.js @@ -0,0 +1,14 @@ +angular.module('portainer.app').factory('ResourceControl', [ + '$resource', + 'API_ENDPOINT_RESOURCE_CONTROLS', + function ResourceControlFactory($resource, API_ENDPOINT_RESOURCE_CONTROLS) { + 'use strict'; + return $resource( + API_ENDPOINT_RESOURCE_CONTROLS + '/:id', + {}, + { + update: { method: 'PUT', params: { id: '@id' } }, + } + ); + }, +]); diff --git a/app/portainer/rest/settings.js b/app/portainer/rest/settings.js new file mode 100644 index 0000000..c25a799 --- /dev/null +++ b/app/portainer/rest/settings.js @@ -0,0 +1,17 @@ +angular.module('portainer.app').factory('Settings', [ + '$resource', + 'API_ENDPOINT_SETTINGS', + function SettingsFactory($resource, API_ENDPOINT_SETTINGS) { + 'use strict'; + return $resource( + API_ENDPOINT_SETTINGS + '/:subResource/:action', + {}, + { + get: { method: 'GET' }, + update: { method: 'PUT', ignoreLoadingBar: true }, + publicSettings: { method: 'GET', params: { subResource: 'public' }, ignoreLoadingBar: true }, + checkLDAPConnectivity: { method: 'PUT', params: { subResource: 'authentication', action: 'checkLDAP' } }, + } + ); + }, +]); diff --git a/app/portainer/rest/ssl.js b/app/portainer/rest/ssl.js new file mode 100644 index 0000000..d70be50 --- /dev/null +++ b/app/portainer/rest/ssl.js @@ -0,0 +1,17 @@ +import angular from 'angular'; + +const API_ENDPOINT_SSL = 'api/ssl'; + +angular.module('portainer.app').factory('SSL', SSLFactory); + +/* @ngInject */ +function SSLFactory($resource) { + return $resource( + API_ENDPOINT_SSL, + {}, + { + get: { method: 'GET' }, + upload: { method: 'PUT' }, + } + ); +} diff --git a/app/portainer/rest/stack.js b/app/portainer/rest/stack.js new file mode 100644 index 0000000..85803ca --- /dev/null +++ b/app/portainer/rest/stack.js @@ -0,0 +1,35 @@ +import angular from 'angular'; + +angular.module('portainer.app').factory('Stack', StackFactory); +angular.module('portainer.app').factory('StackByName', StackByNameFactory); + +/* @ngInject */ +function StackFactory($resource, API_ENDPOINT_STACKS) { + return $resource( + API_ENDPOINT_STACKS + '/:id/:action/:subaction', + {}, + { + get: { method: 'GET', params: { id: '@id' } }, + query: { method: 'GET', isArray: true }, + create: { method: 'POST', ignoreLoadingBar: true, params: { id: 'create', subaction: '@method', action: '@type' } }, + update: { method: 'PUT', params: { id: '@id' }, ignoreLoadingBar: true }, + associate: { method: 'PUT', params: { id: '@id', swarmId: '@swarmId', endpointId: '@endpointId', orphanedRunning: '@orphanedRunning', action: 'associate' } }, + remove: { method: 'DELETE', params: { id: '@id', external: '@external', endpointId: '@endpointId' } }, + getStackFile: { method: 'GET', params: { id: '@id', action: 'file' } }, + start: { method: 'POST', params: { id: '@id', action: 'start', endpointId: '@endpointId' } }, + stop: { method: 'POST', params: { id: '@id', action: 'stop', endpointId: '@endpointId' } }, + updateGit: { method: 'PUT', params: { id: '@id', action: 'git', subaction: 'redeploy' } }, + updateGitStackSettings: { method: 'POST', params: { id: '@id', action: 'git' }, ignoreLoadingBar: true }, + } + ); +} + +function StackByNameFactory($resource, API_ENDPOINT_STACKS) { + return $resource( + API_ENDPOINT_STACKS + '/name/:name', + {}, + { + remove: { method: 'DELETE', params: { name: '@name', external: '@external', endpointId: '@endpointId', namespace: '@namespace' } }, + } + ); +} diff --git a/app/portainer/rest/support.js b/app/portainer/rest/support.js new file mode 100644 index 0000000..386810b --- /dev/null +++ b/app/portainer/rest/support.js @@ -0,0 +1,14 @@ +angular.module('portainer.app').factory('Support', [ + '$resource', + 'API_ENDPOINT_SUPPORT', + function SupportFactory($resource, API_ENDPOINT_SUPPORT) { + 'use strict'; + return $resource( + API_ENDPOINT_SUPPORT, + {}, + { + get: { method: 'GET', isArray: true }, + } + ); + }, +]); diff --git a/app/portainer/rest/tag.js b/app/portainer/rest/tag.js new file mode 100644 index 0000000..b741614 --- /dev/null +++ b/app/portainer/rest/tag.js @@ -0,0 +1,16 @@ +angular.module('portainer.app').factory('Tags', [ + '$resource', + 'API_ENDPOINT_TAGS', + function TagsFactory($resource, API_ENDPOINT_TAGS) { + 'use strict'; + return $resource( + API_ENDPOINT_TAGS + '/:id', + {}, + { + create: { method: 'POST' }, + query: { method: 'GET', isArray: true }, + remove: { method: 'DELETE', params: { id: '@id' } }, + } + ); + }, +]); diff --git a/app/portainer/rest/team.js b/app/portainer/rest/team.js new file mode 100644 index 0000000..b17a9ec --- /dev/null +++ b/app/portainer/rest/team.js @@ -0,0 +1,18 @@ +angular.module('portainer.app').factory('Teams', [ + '$resource', + 'API_ENDPOINT_TEAMS', + function TeamsFactory($resource, API_ENDPOINT_TEAMS) { + 'use strict'; + return $resource( + API_ENDPOINT_TEAMS + '/:id/:entity/:entityId', + {}, + { + create: { method: 'POST', ignoreLoadingBar: true }, + query: { method: 'GET', isArray: true, params: { environmentId: '@environmentId' } }, + get: { method: 'GET', params: { id: '@id' } }, + remove: { method: 'DELETE', params: { id: '@id' } }, + queryMemberships: { method: 'GET', isArray: true, params: { id: '@id', entity: 'memberships' } }, + } + ); + }, +]); diff --git a/app/portainer/rest/teamMembership.js b/app/portainer/rest/teamMembership.js new file mode 100644 index 0000000..7d9ee4f --- /dev/null +++ b/app/portainer/rest/teamMembership.js @@ -0,0 +1,17 @@ +angular.module('portainer.app').factory('TeamMemberships', [ + '$resource', + 'API_ENDPOINT_TEAM_MEMBERSHIPS', + function TeamMembershipsFactory($resource, API_ENDPOINT_TEAM_MEMBERSHIPS) { + 'use strict'; + return $resource( + API_ENDPOINT_TEAM_MEMBERSHIPS + '/:id/:action', + {}, + { + create: { method: 'POST', ignoreLoadingBar: true }, + query: { method: 'GET', isArray: true }, + update: { method: 'PUT', params: { id: '@id' } }, + remove: { method: 'DELETE', params: { id: '@id' } }, + } + ); + }, +]); diff --git a/app/portainer/rest/template.js b/app/portainer/rest/template.js new file mode 100644 index 0000000..f628f7f --- /dev/null +++ b/app/portainer/rest/template.js @@ -0,0 +1,14 @@ +angular.module('portainer.app').factory('Templates', [ + '$resource', + 'API_ENDPOINT_TEMPLATES', + function TemplatesFactory($resource, API_ENDPOINT_TEMPLATES) { + return $resource( + API_ENDPOINT_TEMPLATES + '/:action', + {}, + { + query: { method: 'GET' }, + file: { method: 'POST', params: { action: 'file' } }, + } + ); + }, +]); diff --git a/app/portainer/rest/transform/getEndpointsTotalCount.js b/app/portainer/rest/transform/getEndpointsTotalCount.js new file mode 100644 index 0000000..a689ee0 --- /dev/null +++ b/app/portainer/rest/transform/getEndpointsTotalCount.js @@ -0,0 +1,6 @@ +export default function getEndpointsTotalCount(data, headers) { + const response = {}; + response.value = angular.fromJson(data); + response.totalCount = headers('X-Total-Count'); + return response; +} diff --git a/app/portainer/rest/transform/gitlabResponseGetLink.js b/app/portainer/rest/transform/gitlabResponseGetLink.js new file mode 100644 index 0000000..7513d7a --- /dev/null +++ b/app/portainer/rest/transform/gitlabResponseGetLink.js @@ -0,0 +1,10 @@ +export default function gitlabResponseGetLink(data, headers) { + let response = {}; + try { + response.data = angular.fromJson(data); + response.next = headers('X-Next-Page'); + } catch (error) { + response = data; + } + return response; +} diff --git a/app/portainer/rest/user.js b/app/portainer/rest/user.js new file mode 100644 index 0000000..9b749c3 --- /dev/null +++ b/app/portainer/rest/user.js @@ -0,0 +1,25 @@ +angular.module('portainer.app').factory('Users', [ + '$resource', + 'API_ENDPOINT_USERS', + function UsersFactory($resource, API_ENDPOINT_USERS) { + 'use strict'; + return $resource( + API_ENDPOINT_USERS + '/:id/:entity/:entityId', + {}, + { + create: { method: 'POST', ignoreLoadingBar: true }, + query: { method: 'GET', isArray: true }, + get: { method: 'GET', params: { id: '@id' } }, + update: { method: 'PUT', params: { id: '@id' }, ignoreLoadingBar: true }, + updatePassword: { method: 'PUT', params: { id: '@id', entity: 'passwd' } }, + updateTheme: { method: 'PUT', params: { id: '@id' } }, + remove: { method: 'DELETE', params: { id: '@id' } }, + queryMemberships: { method: 'GET', isArray: true, params: { id: '@id', entity: 'memberships' } }, + checkAdminUser: { method: 'GET', params: { id: 'admin', entity: 'check' }, isArray: true, ignoreLoadingBar: true }, + initAdminUser: { method: 'POST', params: { id: 'admin', entity: 'init' }, ignoreLoadingBar: true }, + getAccessTokens: { method: 'GET', params: { id: '@id', entity: 'tokens' }, isArray: true }, + deleteAccessToken: { url: `${API_ENDPOINT_USERS}/:id/tokens/:tokenId`, method: 'DELETE', params: { id: '@id', entityId: '@tokenId' } }, + } + ); + }, +]); diff --git a/app/portainer/rest/webhooks.js b/app/portainer/rest/webhooks.js new file mode 100644 index 0000000..8e32ff1 --- /dev/null +++ b/app/portainer/rest/webhooks.js @@ -0,0 +1,17 @@ +angular.module('portainer.app').factory('Webhooks', [ + '$resource', + 'API_ENDPOINT_WEBHOOKS', + function WebhooksFactory($resource, API_ENDPOINT_WEBHOOKS) { + 'use strict'; + return $resource( + API_ENDPOINT_WEBHOOKS + '/:id', + {}, + { + query: { method: 'GET', isArray: true }, + create: { method: 'POST' }, + update: { method: 'PUT', params: { id: '@id' } }, + remove: { method: 'DELETE', params: { id: '@id' } }, + } + ); + }, +]); diff --git a/app/portainer/services/allSettled.js b/app/portainer/services/allSettled.js new file mode 100644 index 0000000..020869d --- /dev/null +++ b/app/portainer/services/allSettled.js @@ -0,0 +1,34 @@ +import _ from 'lodash-es'; + +/** + * + * @param {any[]} promises + */ +export default async function $allSettled(promises) { + const res = { + fulfilled: [], + rejected: [], + }; + const data = await Promise.allSettled(promises); + res.fulfilled = _.reduce( + data, + (acc, item) => { + if (item.status === 'fulfilled') { + acc.push(item.value); + } + return acc; + }, + [] + ); + res.rejected = _.reduce( + data, + (acc, item) => { + if (item.status === 'rejected') { + acc.push(item.reason); + } + return acc; + }, + [] + ); + return res; +} diff --git a/app/portainer/services/angularToReact.ts b/app/portainer/services/angularToReact.ts new file mode 100644 index 0000000..b6b0809 --- /dev/null +++ b/app/portainer/services/angularToReact.ts @@ -0,0 +1,116 @@ +import { EnvironmentId } from '@/react/portainer/environments/types'; + +import { EndpointProviderInterface } from './endpointProvider'; + +// see async.js +type AsyncInterface = ( + asyncFunc: AsyncFunction, + ...args: unknown[] +) => Promise; + +type AsyncFunction = (...params: unknown[]) => Promise; +type AxiosFunction = ( + environmentId: EnvironmentId | undefined, + ...params: unknown[] +) => Promise; + +/* @ngInject */ +export function AngularToReact( + EndpointProvider: EndpointProviderInterface, + $async: AsyncInterface +) { + return { useAxios, injectEnvironmentId }; + + /** + * Wraps the async axios function with `$async` to ensures the request runs inside the AngularJS digest cycle + * + * See `$async` (async.js) implementation and notes + * + * See `AngularToReact.injectEnvironmentId` to solve `environmentId` injection for services functions relying + * on `EndpointProvider.endpointID()` in their `$resource()` definition + * + * @example + * **Old AngularJS service** + * ``` + * // file:: AngularJS service.js + * + * // ngInject + * function ServiceServiceFactory($q, Service) { + * return { getService }; + * + * // the original signature doesn't have environmentId passed to it + * // it relies on EndpointProvider in $resource() definition + * // we will inject it on refactor + * // the function uses $q, which internally triggers a redraw of the UI when it resolves/rejects + * function getService(serviceId) { + * var deferred = $q.defer(); + * [...] + * return deferred.promise; + * }; + * + * // the original signature has environmentId passed to it + * // it doesn't rely on EndpointProvider in $resource() definition + * // we won't inject environmentId on refactor + * // the function uses $q, which internally triggers a redraw of the UI when it resolves/rejects + * function listServices(environmentId) { + * var deferred = $q.defer(); + * [...] + * return deferred.promise; + * }; + * } + * ``` + * + * **New format** + * ``` + * // file:: '@/react/.../useService.ts' + * // this function has `environmentId` as first parameter, which doesn't match the old AngularJS service signature + * export async function getService(environmentId: EnvironmentId, serviceId: ServiceId) { + * // axios.get() + * } + * // file:: '@/react/.../useServices.ts' + * // this function has `environmentId` as first parameter, which matches the old AngularJS service signature + * export async function listServices(environmentId: EnvironmentId, serviceId: ServiceId) { + * // axios.get() + * } + * // file:: AngularJS service.js + * import { getService } from '@/react/.../useService.ts'; + * import { listServices } from '@/react/.../useServices.ts'; + * + * // ngInject + * function ServiceServiceFactory(AngularToReact) { + * const { useAxios, injectEnvironmentId } = AngularToReact; + * return { + * // ask to inject environmentId to maintain the old signature + * getService: useAxios(injectEnvironmentId(getService)), + * // do not ask to inject environmentId as it was already in the old signature + * // and is already passed by the caller + * listServices: useAxios(listServices), + * }; + * } + * ``` + */ + function useAxios(axiosFunc: AxiosFunction) { + return (...params: unknown[]) => + $async(axiosFunc as AsyncFunction, ...params); + } + + /** + * Wraps the Axios function taking `endpointId` as first param to expose the old service format. + * + * Leverage injected `EndpointProvider` that was used in the rest file - `$resource()` definition. + * + * The axios function params **MUST** match the old AngularJS-service ones to use this helper without changing the service calls + * + * Should be used in conjunction with `AngularToReact.useAxios` + * + * @example + * See `AngularToReact.useAxios` + * + * @param {(environmentId: EnvironmentId, ...params: unknown[]) => Promise} axiosFunc Axios function taking `environmentId` as first param + * @returns a function with the old AngularJS signature + */ + function injectEnvironmentId(axiosFunc: AxiosFunction) { + return async (...params: unknown[]) => + axiosFunc(EndpointProvider.endpointID(), ...params); + } +} diff --git a/app/portainer/services/api/accessService.js b/app/portainer/services/api/accessService.js new file mode 100644 index 0000000..711f052 --- /dev/null +++ b/app/portainer/services/api/accessService.js @@ -0,0 +1,117 @@ +import _ from 'lodash-es'; +import { UserAccessViewModel } from '../../models/access'; +import { TeamAccessViewModel } from '../../models/access'; + +angular.module('portainer.app').factory('AccessService', [ + '$q', + '$async', + 'UserService', + 'TeamService', + function AccessServiceFactory($q, $async, UserService, TeamService) { + 'use strict'; + return { + accesses, + generateAccessPolicies, + }; + + function _mapAccessData(accesses, authorizedPolicies, inheritedPolicies) { + var availableAccesses = []; + var authorizedAccesses = []; + + for (var i = 0; i < accesses.length; i++) { + const access = accesses[i]; + + const authorized = authorizedPolicies && authorizedPolicies[access.Id]; + const inherited = inheritedPolicies && inheritedPolicies[access.Id]; + + if (authorized && inherited) { + access.Override = true; + authorizedAccesses.push(access); + } else if (authorized && !inherited) { + authorizedAccesses.push(access); + } else if (!authorized && inherited) { + access.Inherited = true; + authorizedAccesses.push(access); + availableAccesses.push(access); + } else { + availableAccesses.push(access); + } + } + + return { + available: availableAccesses, + authorized: authorizedAccesses, + }; + } + + function getAccesses(authorizedUserPolicies, authorizedTeamPolicies, inheritedUserPolicies, inheritedTeamPolicies) { + var deferred = $q.defer(); + + $q.all({ + users: UserService.users(false), + teams: TeamService.teams(), + }) + .then(function success(data) { + var userAccesses = data.users.map(function (user) { + return new UserAccessViewModel(user); + }); + var teamAccesses = data.teams.map(function (team) { + return new TeamAccessViewModel(team); + }); + + var userAccessData = _mapAccessData(userAccesses, authorizedUserPolicies, inheritedUserPolicies); + var teamAccessData = _mapAccessData(teamAccesses, authorizedTeamPolicies, inheritedTeamPolicies); + + var accessData = { + availableUsersAndTeams: userAccessData.available.concat(teamAccessData.available), + authorizedUsersAndTeams: userAccessData.authorized.concat(teamAccessData.authorized), + }; + + deferred.resolve(accessData); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve users and teams', err: err }); + }); + + return deferred.promise; + } + + async function accessesAsync(entity, parent) { + if (!entity) { + throw new Error('Unable to retrieve accesses'); + } + if (!entity.UserAccessPolicies) { + entity.UserAccessPolicies = {}; + } + if (!entity.TeamAccessPolicies) { + entity.TeamAccessPolicies = {}; + } + if (parent && !parent.UserAccessPolicies) { + parent.UserAccessPolicies = {}; + } + if (parent && !parent.TeamAccessPolicies) { + parent.TeamAccessPolicies = {}; + } + return await getAccesses(entity.UserAccessPolicies, entity.TeamAccessPolicies, parent ? parent.UserAccessPolicies : {}, parent ? parent.TeamAccessPolicies : {}); + } + + function accesses(entity, parent) { + return $async(accessesAsync, entity, parent); + } + + function generateAccessPolicies(userAccessPolicies, teamAccessPolicies, selectedUserAccesses, selectedTeamAccesses, selectedRoleId) { + const newUserPolicies = _.clone(userAccessPolicies); + const newTeamPolicies = _.clone(teamAccessPolicies); + + _.forEach(selectedUserAccesses, (access) => (newUserPolicies[access.Id] = { RoleId: selectedRoleId ? selectedRoleId : access.Role.Id })); + _.forEach(selectedTeamAccesses, (access) => (newTeamPolicies[access.Id] = { RoleId: selectedRoleId ? selectedRoleId : access.Role.Id })); + + const accessPolicies = { + userAccessPolicies: newUserPolicies, + teamAccessPolicies: newTeamPolicies, + }; + + return accessPolicies; + } + }, +]); diff --git a/app/portainer/services/api/backup.service.ts b/app/portainer/services/api/backup.service.ts new file mode 100644 index 0000000..d45c9ff --- /dev/null +++ b/app/portainer/services/api/backup.service.ts @@ -0,0 +1,29 @@ +import axios, { parseAxiosError } from '../axios/axios'; + +interface StatusResponse { + Failed: boolean; + TimestampUTC: string; +} + +export async function getBackupStatus() { + try { + const { data } = await axios.get(buildUrl('s3', 'status')); + return data; + } catch (error) { + throw parseAxiosError(error as Error, 'Unable to retrieve backup status'); + } +} + +function buildUrl(resource?: string, action?: string) { + let url = '/backup'; + + if (resource) { + url += `/${resource}`; + } + + if (action) { + url += `/${action}`; + } + + return url; +} diff --git a/app/portainer/services/api/backupService.js b/app/portainer/services/api/backupService.js new file mode 100644 index 0000000..026328a --- /dev/null +++ b/app/portainer/services/api/backupService.js @@ -0,0 +1,76 @@ +angular.module('portainer.app').factory('BackupService', [ + '$q', + '$async', + 'Backup', + 'FileUploadService', + function BackupServiceFactory($q, $async, Backup, FileUploadService) { + 'use strict'; + const service = {}; + + service.downloadBackup = function (payload) { + return Backup.download({}, payload).$promise; + }; + + service.uploadBackup = function (file, password, setupToken) { + return FileUploadService.uploadBackup(file, password, setupToken); + }; + + service.getS3Settings = function () { + var deferred = $q.defer(); + + Backup.getS3Settings() + .$promise.then(function success(data) { + deferred.resolve(data); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve backup S3 settings', err: err }); + }); + + return deferred.promise; + }; + + service.saveS3Settings = function (payload) { + var deferred = $q.defer(); + + Backup.saveS3Settings({}, payload) + .$promise.then(function success(data) { + deferred.resolve(data); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to save backup S3 settings', err: err }); + }); + + return deferred.promise; + }; + + service.exportBackup = function (payload) { + var deferred = $q.defer(); + + Backup.exportS3Backup({}, payload) + .$promise.then(function success(data) { + deferred.resolve(data); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to export backup', err: err }); + }); + + return deferred.promise; + }; + + service.restoreFromS3 = function (payload) { + var deferred = $q.defer(); + + Backup.restoreS3Backup({}, payload) + .$promise.then(function success(data) { + deferred.resolve(data); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to restore backup from S3', err: err }); + }); + + return deferred.promise; + }; + + return service; + }, +]); diff --git a/app/portainer/services/api/customTemplate.js b/app/portainer/services/api/customTemplate.js new file mode 100644 index 0000000..3bfe3e6 --- /dev/null +++ b/app/portainer/services/api/customTemplate.js @@ -0,0 +1,67 @@ +import angular from 'angular'; +import PortainerError from '@/portainer/error'; + +angular.module('portainer.app').factory('CustomTemplateService', CustomTemplateServiceFactory); + +/* @ngInject */ +function CustomTemplateServiceFactory($sanitize, CustomTemplates, FileUploadService) { + var service = {}; + + service.customTemplate = function customTemplate(id) { + return CustomTemplates.get({ id }).$promise; + }; + + service.customTemplates = async function customTemplates(type, edge = false) { + const templates = await CustomTemplates.query({ type, edge }).$promise; + templates.forEach((template) => { + if (template.Note) { + template.Note = $('

').html($sanitize(template.Note)).find('img').remove().end().html(); + } + }); + return templates; + }; + + service.remove = function remove(id) { + return CustomTemplates.remove({ id }).$promise; + }; + + service.customTemplateFile = async function customTemplateFile(id, remote = false) { + try { + const { FileContent } = remote ? await CustomTemplates.gitFetch({ id }).$promise : await CustomTemplates.file({ id }).$promise; + return FileContent; + } catch (err) { + throw new PortainerError('Unable to retrieve custom template content', err); + } + }; + + service.updateCustomTemplate = async function updateCustomTemplate(id, customTemplate) { + return CustomTemplates.update({ id }, customTemplate).$promise; + }; + + service.createCustomTemplateFromFileContent = async function createCustomTemplateFromFileContent(payload) { + try { + return await CustomTemplates.create({}, { method: 'string', ...payload }).$promise; + } catch (err) { + throw { msg: 'Unable to create the customTemplate', err }; + } + }; + + service.createCustomTemplateFromFileUpload = async function createCustomTemplateFromFileUpload(payload) { + try { + const { data } = await FileUploadService.createCustomTemplate(payload); + return data; + } catch (err) { + throw { msg: 'Unable to create the customTemplate', err }; + } + }; + + service.createCustomTemplateFromGitRepository = async function createCustomTemplateFromGitRepository(payload) { + try { + return await CustomTemplates.create({}, { method: 'repository', ...payload }).$promise; + } catch (err) { + throw { msg: 'Unable to create the customTemplate', err }; + } + }; + + return service; +} diff --git a/app/portainer/services/api/dockerhubService.js b/app/portainer/services/api/dockerhubService.js new file mode 100644 index 0000000..b49c7eb --- /dev/null +++ b/app/portainer/services/api/dockerhubService.js @@ -0,0 +1,27 @@ +import { PortainerEndpointTypes } from '@/portainer/models/endpoint/models'; +import { isLocalEnvironment } from '@/react/portainer/environments/utils'; + +angular.module('portainer.app').factory('DockerHubService', DockerHubService); + +/* @ngInject */ +function DockerHubService(Endpoints, AgentDockerhub) { + return { + checkRateLimits, + }; + + function checkRateLimits(endpoint, registryId) { + if (isLocalEnvironment(endpoint)) { + return Endpoints.dockerhubLimits({ id: endpoint.Id, registryId }).$promise; + } + + switch (endpoint.Type) { + case PortainerEndpointTypes.AgentOnDockerEnvironment: + case PortainerEndpointTypes.EdgeAgentOnDockerEnvironment: + return AgentDockerhub.limits({ endpointId: endpoint.Id, endpointType: 'docker', registryId }).$promise; + + case PortainerEndpointTypes.AgentOnKubernetesEnvironment: + case PortainerEndpointTypes.EdgeAgentOnKubernetesEnvironment: + return AgentDockerhub.limits({ endpointId: endpoint.Id, endpointType: 'kubernetes', registryId }).$promise; + } + } +} diff --git a/app/portainer/services/api/endpointService.js b/app/portainer/services/api/endpointService.js new file mode 100644 index 0000000..b4ef12d --- /dev/null +++ b/app/portainer/services/api/endpointService.js @@ -0,0 +1,173 @@ +import { PortainerEndpointCreationTypes } from '@/portainer/models/endpoint/models'; + +angular.module('portainer.app').factory('EndpointService', [ + '$q', + 'Endpoints', + 'FileUploadService', + function EndpointServiceFactory($q, Endpoints, FileUploadService) { + 'use strict'; + var service = { + updateSecuritySettings, + registries, + updateRegistryAccess, + }; + + service.endpoint = function (endpointID) { + return Endpoints.get({ id: endpointID }).$promise; + }; + + service.snapshotEndpoints = function () { + return Endpoints.snapshots({}, {}).$promise; + }; + + service.snapshotEndpoint = function (endpointID) { + return Endpoints.snapshot({ id: endpointID }, {}).$promise; + }; + + service.updateAccess = function (id, userAccessPolicies, teamAccessPolicies) { + return Endpoints.updateAccess({ id: id }, { UserAccessPolicies: userAccessPolicies, TeamAccessPolicies: teamAccessPolicies }).$promise; + }; + + service.disassociateEndpoint = function (endpointID) { + return Endpoints.disassociate({ id: endpointID }).$promise; + }; + + service.updateEndpoint = function (id, payload) { + var deferred = $q.defer(); + FileUploadService.uploadTLSFilesForEndpoint(id, payload.TLSCACert, payload.TLSCert, payload.TLSKey) + .then(function success() { + deferred.notify({ upload: false }); + return Endpoints.update({ id: id }, payload).$promise; + }) + .then(function success(data) { + deferred.resolve(data); + }) + .catch(function error(err) { + deferred.notify({ upload: false }); + deferred.reject({ msg: 'Unable to update environment', err: err }); + }); + return deferred.promise; + }; + + service.deleteEndpoint = function (endpointID) { + return Endpoints.remove({ id: endpointID }).$promise; + }; + + service.createLocalEndpoint = function (name = 'local', URL = '', PublicURL = '', groupID = 1, tagIds = []) { + var deferred = $q.defer(); + + var endpointURL = URL; + if (endpointURL !== '') { + if (endpointURL.indexOf('//./pipe/') == 0) { + // Windows named pipe + endpointURL = 'npipe://' + URL; + } else { + endpointURL = 'unix://' + URL; + } + } + + FileUploadService.createEndpoint(name, PortainerEndpointCreationTypes.LocalDockerEnvironment, endpointURL, PublicURL, groupID, tagIds, false) + .then(function success(response) { + deferred.resolve(response.data); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to create environment', err: err }); + }); + + return deferred.promise; + }; + + service.createRemoteEndpoint = function ( + name, + creationType, + URL, + PublicURL, + groupID, + tagIds, + TLS, + TLSSkipVerify, + TLSSkipClientVerify, + TLSCAFile, + TLSCertFile, + TLSKeyFile, + checkinInterval + ) { + var deferred = $q.defer(); + + var endpointURL = URL; + if (creationType !== PortainerEndpointCreationTypes.EdgeAgentEnvironment) { + endpointURL = 'tcp://' + URL; + } + + FileUploadService.createEndpoint( + name, + creationType, + endpointURL, + PublicURL, + groupID, + tagIds, + TLS, + TLSSkipVerify, + TLSSkipClientVerify, + TLSCAFile, + TLSCertFile, + TLSKeyFile, + checkinInterval + ) + .then(function success(response) { + deferred.resolve(response.data); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to create environment', err: err }); + }); + + return deferred.promise; + }; + + service.createLocalKubernetesEndpoint = function (name = 'local', tagIds = []) { + var deferred = $q.defer(); + + FileUploadService.createEndpoint(name, PortainerEndpointCreationTypes.LocalKubernetesEnvironment, '', '', 1, tagIds, true, true, true) + .then(function success(response) { + deferred.resolve(response.data); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to create environment', err: err }); + }); + + return deferred.promise; + }; + + service.createAzureEndpoint = function (name, applicationId, tenantId, authenticationKey, groupId, tagIds) { + var deferred = $q.defer(); + + FileUploadService.createAzureEndpoint(name, applicationId, tenantId, authenticationKey, groupId, tagIds) + .then(function success(response) { + deferred.resolve(response.data); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to connect to Azure', err: err }); + }); + + return deferred.promise; + }; + + service.trust = function (id) { + Endpoints.updateEndpoint({ id }, { UserTrusted: true }).$promise; + }; + + function updateRegistryAccess(id, registryId, endpointAccesses) { + return Endpoints.updateRegistryAccess({ registryId, id }, endpointAccesses).$promise; + } + + function registries(id, namespace) { + return Endpoints.registries({ namespace, id }).$promise; + } + + return service; + + function updateSecuritySettings(id, securitySettings) { + return Endpoints.updateSecuritySettings({ id }, securitySettings).$promise; + } + }, +]); diff --git a/app/portainer/services/api/groupService.js b/app/portainer/services/api/groupService.js new file mode 100644 index 0000000..acc823f --- /dev/null +++ b/app/portainer/services/api/groupService.js @@ -0,0 +1,57 @@ +import { EndpointGroupCreateRequest, EndpointGroupModel, EndpointGroupUpdateRequest } from '../../models/group'; + +angular.module('portainer.app').factory('GroupService', [ + '$q', + 'EndpointGroups', + function GroupService($q, EndpointGroups) { + 'use strict'; + var service = {}; + + service.group = function (groupId) { + var deferred = $q.defer(); + + EndpointGroups.get({ id: groupId }) + .$promise.then(function success(data) { + var group = new EndpointGroupModel(data); + deferred.resolve(group); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve group', err: err }); + }); + + return deferred.promise; + }; + + service.groups = function () { + return EndpointGroups.query({}).$promise; + }; + + service.createGroup = function (model, endpoints) { + var payload = new EndpointGroupCreateRequest(model, endpoints); + return EndpointGroups.create(payload).$promise; + }; + + service.updateGroup = function (model, endpoints) { + var payload = new EndpointGroupUpdateRequest(model, endpoints); + return EndpointGroups.update(payload).$promise; + }; + + service.updateAccess = function (groupId, userAccessPolicies, teamAccessPolicies) { + return EndpointGroups.updateAccess({ id: groupId }, { UserAccessPolicies: userAccessPolicies, TeamAccessPolicies: teamAccessPolicies }).$promise; + }; + + service.addEndpoint = function (groupId, endpointId) { + return EndpointGroups.addEndpoint({ id: groupId, action: 'endpoints/' + endpointId }).$promise; + }; + + service.removeEndpoint = function (groupId, endpointId) { + return EndpointGroups.removeEndpoint({ id: groupId, action: 'endpoints/' + endpointId }).$promise; + }; + + service.deleteGroup = function (groupId) { + return EndpointGroups.remove({ id: groupId }).$promise; + }; + + return service; + }, +]); diff --git a/app/portainer/services/api/index.ts b/app/portainer/services/api/index.ts new file mode 100644 index 0000000..7caf483 --- /dev/null +++ b/app/portainer/services/api/index.ts @@ -0,0 +1,7 @@ +import angular from 'angular'; + +import { UserService } from './userService'; + +export const apiServicesModule = angular + .module('portainer.app.services.api', []) + .factory('UserService', UserService).name; diff --git a/app/portainer/services/api/registryService.js b/app/portainer/services/api/registryService.js new file mode 100644 index 0000000..a05401e --- /dev/null +++ b/app/portainer/services/api/registryService.js @@ -0,0 +1,203 @@ +import _ from 'lodash-es'; +import { PorImageRegistryModel } from '@/docker/models/porImageRegistry'; +import { RegistryTypes } from '@/portainer/models/registryTypes'; +import { RegistryCreateRequest, RegistryViewModel } from '@/portainer/models/registry'; +import { DockerHubViewModel } from '@/portainer/models/dockerhub'; + +angular.module('portainer.app').factory('RegistryService', [ + '$q', + '$async', + 'EndpointService', + 'Registries', + 'ImageHelper', + 'FileUploadService', + function RegistryServiceFactory($q, $async, EndpointService, Registries, ImageHelper, FileUploadService) { + return { + registries, + registry, + encodedCredentials, + deleteRegistry, + updateRegistry, + configureRegistry, + createRegistry, + createGitlabRegistries, + retrievePorRegistryModelFromRepository, + retrievePorRegistryModelFromRepositoryWithRegistries, + loadRegistriesForDropdown, + }; + + function registries() { + var deferred = $q.defer(); + + Registries.query() + .$promise.then(function success(data) { + var registries = data.map(function (item) { + return new RegistryViewModel(item); + }); + deferred.resolve(registries); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve registries', err: err }); + }); + + return deferred.promise; + } + + function registry(id, endpointId) { + var deferred = $q.defer(); + + Registries.get({ id, endpointId }) + .$promise.then(function success(data) { + var registry = new RegistryViewModel(data); + deferred.resolve(registry); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve registry details', err: err }); + }); + + return deferred.promise; + } + + function encodedCredentials(registry) { + var credentials = { + registryId: registry.Id, + }; + return btoa(JSON.stringify(credentials)); + } + + function deleteRegistry(id) { + return Registries.remove({ id: id }).$promise; + } + + function updateRegistry(registry) { + registry.URL = _.replace(registry.URL, /^https?\:\/\//i, ''); + registry.URL = _.replace(registry.URL, /\/$/, ''); + return Registries.update({ id: registry.Id }, registry).$promise; + } + + function configureRegistry(id, registryManagementConfigurationModel) { + return FileUploadService.configureRegistry(id, registryManagementConfigurationModel); + } + + function createRegistry(model) { + var payload = new RegistryCreateRequest(model); + return Registries.create(payload).$promise; + } + + /** + * @param {import('@/portainer/models/registry').RegistryCreateFormValues} model + * @param {Array} projects + */ + function createGitlabRegistries(model, projects) { + const promises = []; + _.forEach(projects, (p) => { + const m = model; + m.Name = p.PathWithNamespace; + m.Gitlab.ProjectPath = _.toLower(p.PathWithNamespace); + m.Gitlab.ProjectId = p.Id; + m.Password = m.Token; + const payload = new RegistryCreateRequest(m); + promises.push(Registries.create(payload).$promise); + }); + return $q.all(promises); + } + + function getURL(reg) { + let url = reg.URL; + if (reg.Type === RegistryTypes.GITLAB) { + url = reg.URL + '/' + reg.Gitlab.ProjectPath; + } else if (reg.Type === RegistryTypes.QUAY) { + const name = reg.Quay.UseOrganisation ? reg.Quay.OrganisationName : reg.Username; + url = reg.URL + '/' + name; + } + return url; + } + + // findBestMatchRegistry finds out the best match registry for repository + // matching precedence: + // 1. registryId matched + // 2. both domain name and username matched (for dockerhub only) + // 3. only URL matched + // 4. pick up the first dockerhub registry + function findBestMatchRegistry(repository, registries, registryId) { + let match2, match3, match4; + + for (const registry of registries) { + if (registry.Id == registryId) { + return registry; + } + + if (registry.Type === RegistryTypes.DOCKERHUB) { + // try to match repository examples: + // /nginx:latest + // docker.io//nginx:latest + if (repository.startsWith(registry.Username + '/') || repository.startsWith(getURL(registry) + '/' + registry.Username + '/')) { + match2 = registry; + } + + // try to match repository examples: + // portainer/portainer-ee:latest + // /portainer-ee:latest + match4 = match4 || registry; + } + + if (repository.startsWith(getURL(registry))) { + match3 = registry; + } + } + + return match2 || match3 || match4; + } + + function retrievePorRegistryModelFromRepositoryWithRegistries(repository, registries, registryId) { + const model = new PorImageRegistryModel(); + const registry = findBestMatchRegistry(repository, registries, registryId); + if (registry) { + const url = getURL(registry); + let lastIndex = repository.lastIndexOf(url); + lastIndex = lastIndex === -1 ? 0 : lastIndex + url.length; + let image = repository.substring(lastIndex); + if (_.startsWith(image, '/')) { + image = image.substring(1); + } + model.Registry = registry; + model.Image = image; + } else { + if (ImageHelper.imageContainsURL(repository)) { + model.UseRegistry = false; + } + model.Registry = new DockerHubViewModel(); + model.Image = repository; + } + return model; + } + + function retrievePorRegistryModelFromRepository(repository, endpointId, registryId, namespace) { + return $async(async () => { + try { + const regs = await EndpointService.registries(endpointId, namespace); + return retrievePorRegistryModelFromRepositoryWithRegistries(repository, regs, registryId); + } catch (err) { + throw { msg: 'Unable to retrieve the registry associated to the repository', err: err }; + } + }); + } + + function loadRegistriesForDropdown(endpointId, namespace) { + return $async(async () => { + try { + const registries = await EndpointService.registries(endpointId, namespace); + + // hide default(anonymous) dockerhub registry if user has an authenticated one + if (!registries.some((registry) => registry.Type === RegistryTypes.DOCKERHUB)) { + registries.push(new DockerHubViewModel()); + } + + return registries; + } catch (err) { + throw { msg: 'Unable to retrieve the registries', err: err }; + } + }); + } + }, +]); diff --git a/app/portainer/services/api/resourceControlService.js b/app/portainer/services/api/resourceControlService.js new file mode 100644 index 0000000..50408d4 --- /dev/null +++ b/app/portainer/services/api/resourceControlService.js @@ -0,0 +1,100 @@ +angular.module('portainer.app').factory('ResourceControlService', [ + '$q', + 'ResourceControl', + 'UserService', + 'TeamService', + 'ResourceControlHelper', + function ResourceControlServiceFactory($q, ResourceControl, UserService, TeamService, ResourceControlHelper) { + 'use strict'; + const service = {}; + + service.duplicateResourceControl = duplicateResourceControl; + service.applyResourceControl = applyResourceControl; + service.retrieveOwnershipDetails = retrieveOwnershipDetails; + + /** + * PRIVATE SECTION + */ + + /** + * Update a ResourceControl + * @param {String} rcID ID of involved resource + * @param {ResourceControlOwnershipParameters} ownershipParameters Transcient type from view data to payload + */ + function updateResourceControl(rcID, ownershipParameters) { + const payload = { + AdministratorsOnly: ownershipParameters.AdministratorsOnly, + Public: ownershipParameters.Public, + Users: ownershipParameters.Users, + Teams: ownershipParameters.Teams, + }; + + return ResourceControl.update({ id: rcID }, payload).$promise; + } + + /** + * END PRIVATE SECTION + */ + + /** + * PUBLIC SECTION + */ + + /** + * Apply a ResourceControl after Resource creation + * @param {int} userId ID of User performing the action + * @param {AccessControlFormData} accessControlData ResourceControl to apply + * @param {ResourceControlViewModel} resourceControl ResourceControl to update + * @param {[]int} subResources SubResources managed by the ResourceControl + */ + function applyResourceControl(userId, accessControlData, resourceControl, subResources = []) { + const ownershipParameters = ResourceControlHelper.RCFormDataToOwnershipParameters(userId, accessControlData, subResources); + return updateResourceControl(resourceControl.Id, ownershipParameters); + } + + /** + * Duplicate an existing ResourceControl (default to AdministratorsOnly if undefined) + * @param {int} userId ID of User performing the action + * @param {ResourceControlViewModel} oldResourceControl ResourceControl to duplicate + * @param {ResourceControlViewModel} newResourceControl ResourceControl to apply duplication to + */ + function duplicateResourceControl(userId, oldResourceControl, newResourceControl) { + const ownershipParameters = ResourceControlHelper.RCViewModelToOwnershipParameters(userId, oldResourceControl); + return updateResourceControl(newResourceControl.Id, ownershipParameters); + } + + /** + * Retrieve users and team details for ResourceControlViewModel + * @param {ResourceControlViewModel} resourceControl ResourceControl view model + */ + function retrieveOwnershipDetails(resourceControl) { + var deferred = $q.defer(); + + if (!resourceControl) { + deferred.resolve({ authorizedUsers: [], authorizedTeams: [] }); + return deferred.promise; + } + + $q.all({ + users: resourceControl.UserAccesses.length > 0 ? UserService.users(false) : [], + teams: resourceControl.TeamAccesses.length > 0 ? TeamService.teams() : [], + }) + .then(function success(data) { + var authorizedUsers = ResourceControlHelper.retrieveAuthorizedUsers(resourceControl, data.users); + var authorizedTeams = ResourceControlHelper.retrieveAuthorizedTeams(resourceControl, data.teams); + deferred.resolve({ authorizedUsers: authorizedUsers, authorizedTeams: authorizedTeams }); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve user and team information', err: err }); + }); + + return deferred.promise; + } + + /** + * END PUBLIC SECTION + */ + + return service; + }, +]); diff --git a/app/portainer/services/api/settingsService.js b/app/portainer/services/api/settingsService.js new file mode 100644 index 0000000..1933c0b --- /dev/null +++ b/app/portainer/services/api/settingsService.js @@ -0,0 +1,50 @@ +import { SettingsViewModel, PublicSettingsViewModel } from '../../models/settings'; + +angular.module('portainer.app').factory('SettingsService', [ + '$q', + 'Settings', + function SettingsServiceFactory($q, Settings) { + 'use strict'; + var service = {}; + + service.settings = function () { + var deferred = $q.defer(); + + Settings.get() + .$promise.then(function success(data) { + var settings = new SettingsViewModel(data); + deferred.resolve(settings); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve application settings', err: err }); + }); + + return deferred.promise; + }; + + service.update = function (settings) { + return Settings.update({}, settings).$promise; + }; + + service.publicSettings = function () { + var deferred = $q.defer(); + + Settings.publicSettings() + .$promise.then(function success(data) { + var settings = new PublicSettingsViewModel(data); + deferred.resolve(settings); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve application settings', err: err }); + }); + + return deferred.promise; + }; + + service.checkLDAPConnectivity = function (settings) { + return Settings.checkLDAPConnectivity({}, settings).$promise; + }; + + return service; + }, +]); diff --git a/app/portainer/services/api/sslService.js b/app/portainer/services/api/sslService.js new file mode 100644 index 0000000..387b289 --- /dev/null +++ b/app/portainer/services/api/sslService.js @@ -0,0 +1,19 @@ +import angular from 'angular'; + +angular.module('portainer.app').service('SSLService', SSLServiceFactory); + +/* @ngInject */ +function SSLServiceFactory(SSL) { + return { + upload, + get, + }; + + function get() { + return SSL.get().$promise; + } + + function upload(SSLPayload) { + return SSL.upload(SSLPayload).$promise; + } +} diff --git a/app/portainer/services/api/stackService.js b/app/portainer/services/api/stackService.js new file mode 100644 index 0000000..22a228c --- /dev/null +++ b/app/portainer/services/api/stackService.js @@ -0,0 +1,446 @@ +import _ from 'lodash-es'; +import { transformAutoUpdateViewModel } from '@/react/portainer/gitops/AutoUpdateFieldset/utils'; +import { StackViewModel } from '@/react/docker/stacks/view-models/stack'; + +angular.module('portainer.app').factory('StackService', [ + '$q', + '$async', + 'Stack', + 'StackByName', + 'FileUploadService', + 'StackHelper', + 'ServiceService', + 'ContainerService', + 'SwarmService', + function StackServiceFactory($q, $async, Stack, StackByName, FileUploadService, StackHelper, ServiceService, ContainerService, SwarmService) { + 'use strict'; + var service = { + updateGit, + updateKubeGit, + }; + + service.stack = function (id) { + var deferred = $q.defer(); + + Stack.get({ id: id }) + .$promise.then(function success(data) { + var stack = new StackViewModel(data); + deferred.resolve(stack); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve stack details', err: err }); + }); + + return deferred.promise; + }; + + service.getStackFile = function (id) { + var deferred = $q.defer(); + + Stack.getStackFile({ id: id }) + .$promise.then(function success(data) { + deferred.resolve(data.StackFileContent); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve stack content', err: err }); + }); + + return deferred.promise; + }; + + service.stacks = function (compose, swarm, endpointId, includeOrphanedStacks = false) { + var deferred = $q.defer(); + + var queries = []; + if (compose) { + queries.push(service.composeStacks(endpointId, true, { EndpointID: endpointId, IncludeOrphanedStacks: includeOrphanedStacks })); + } + if (swarm) { + queries.push(service.swarmStacks(endpointId, true, { IncludeOrphanedStacks: includeOrphanedStacks })); + } + + $q.all(queries) + .then(function success(data) { + var stacks = []; + if (data[0]) { + stacks = stacks.concat(data[0]); + } + if (data[1]) { + stacks = stacks.concat(data[1]); + } + deferred.resolve(stacks); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve stacks', err: err }); + }); + + return deferred.promise; + }; + + service.externalSwarmStacks = function () { + var deferred = $q.defer(); + + ServiceService.services() + .then(function success(services) { + deferred.resolve(StackHelper.getExternalStacksFromServices(services)); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve external stacks', err: err }); + }); + + return deferred.promise; + }; + + service.externalComposeStacks = function (environmentId) { + var deferred = $q.defer(); + + ContainerService.containers(environmentId, 1) + .then(function success(containers) { + deferred.resolve(StackHelper.getExternalStacksFromContainers(containers)); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve external stacks', err: err }); + }); + + return deferred.promise; + }; + + service.unionStacks = function (stacks, externalStacks) { + stacks.forEach((stack) => { + externalStacks.forEach((externalStack) => { + if (stack.Orphaned && stack.Name == externalStack.Name) { + stack.OrphanedRunning = true; + } + }); + }); + const result = _.unionWith(stacks, externalStacks, function (a, b) { + return a.Name === b.Name; + }); + + return result; + }; + + service.composeStacks = function (endpointId, includeExternalStacks, filters) { + var deferred = $q.defer(); + + $q.all({ + stacks: Stack.query({ filters: filters }).$promise, + externalStacks: includeExternalStacks ? service.externalComposeStacks(endpointId) : [], + }) + .then(function success(data) { + var stacks = data.stacks.map(function (item) { + return new StackViewModel(item, item.EndpointId != endpointId); + }); + + var externalStacks = data.externalStacks; + const result = service.unionStacks(stacks, externalStacks); + deferred.resolve(result); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve stacks', err: err }); + }); + + return deferred.promise; + }; + + service.swarmStacks = function (endpointId, includeExternalStacks, filters = {}) { + var deferred = $q.defer(); + + SwarmService.swarm(endpointId) + .then(function success(data) { + var swarm = data; + filters = { SwarmID: swarm.ID, ...filters }; + + return $q.all({ + stacks: Stack.query({ filters: filters }).$promise, + externalStacks: includeExternalStacks ? service.externalSwarmStacks() : [], + }); + }) + .then(function success(data) { + var stacks = data.stacks.map(function (item) { + return new StackViewModel(item, item.EndpointId != endpointId); + }); + + var externalStacks = data.externalStacks; + const result = service.unionStacks(stacks, externalStacks); + deferred.resolve(result); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve stacks', err: err }); + }); + + return deferred.promise; + }; + + service.remove = function (stack, external, endpointId) { + var deferred = $q.defer(); + + Stack.remove({ id: stack.Id ? stack.Id : stack.Name, external: external, endpointId: endpointId }) + .$promise.then(function success() { + deferred.resolve(); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to remove the stack', err: err }); + }); + + return deferred.promise; + }; + + service.removeKubernetesStacksByName = function (name, namespace, external, endpointId) { + var deferred = $q.defer(); + StackByName.remove({ name: name, external: external, endpointId: endpointId, namespace: namespace }) + .$promise.then(function success() { + deferred.resolve(); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to remove the stack', err: err }); + }); + + return deferred.promise; + }; + + service.associate = function (stack, endpointId, orphanedRunning) { + var deferred = $q.defer(); + + if (stack.Type == 1) { + SwarmService.swarm(endpointId) + .then(function success(data) { + const swarm = data; + return Stack.associate({ id: stack.Id, endpointId: endpointId, swarmId: swarm.ID, orphanedRunning }).$promise; + }) + .then(function success(data) { + deferred.resolve(data); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to associate the stack', err: err }); + }); + } else { + Stack.associate({ id: stack.Id, endpointId: endpointId, orphanedRunning }) + .$promise.then(function success(data) { + deferred.resolve(data); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to associate the stack', err: err }); + }); + } + + return deferred.promise; + }; + + service.updateStack = function (stack, stackFile, env, prune, repullImageAndRedeploy) { + return Stack.update( + { endpointId: stack.EndpointId }, + { + id: stack.Id, + StackFileContent: stackFile, + Env: env, + Prune: prune, + RepullImageAndRedeploy: repullImageAndRedeploy, + } + ).$promise; + }; + + service.updateKubeStack = function (stack, { stackFile, gitConfig, webhookId, stackName }) { + let payload = {}; + + if (stackFile) { + payload = { + StackFileContent: stackFile, + StackName: stackName, + }; + } else { + payload = { + AutoUpdate: transformAutoUpdateViewModel(gitConfig.AutoUpdate, webhookId), + RepositoryReferenceName: gitConfig.RefName, + RepositoryAuthentication: gitConfig.RepositoryAuthentication, + RepositoryUsername: gitConfig.RepositoryUsername, + RepositoryPassword: gitConfig.RepositoryPassword, + TLSSkipVerify: gitConfig.TLSSkipVerify, + }; + } + + return Stack.update({ id: stack.Id, endpointId: stack.EndpointId }, payload).$promise; + }; + + service.createComposeStackFromFileUpload = function (name, stackFile, env, endpointId) { + return FileUploadService.createComposeStack(name, stackFile, env, endpointId); + }; + + service.createSwarmStackFromFileUpload = function (name, stackFile, env, endpointId) { + var deferred = $q.defer(); + + SwarmService.swarm(endpointId) + .then(function success(data) { + var swarm = data; + return FileUploadService.createSwarmStack(name, swarm.ID, stackFile, env, endpointId); + }) + .then(function success(data) { + deferred.resolve(data.data); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to create the stack', err: err }); + }); + + return deferred.promise; + }; + service.createComposeStackFromFileContent = function (name, stackFileContent, env, endpointId) { + var payload = { + Name: name, + StackFileContent: stackFileContent, + Env: env, + }; + return Stack.create({ endpointId: endpointId }, { method: 'string', type: 'standalone', ...payload }).$promise; + }; + + service.createSwarmStackFromFileContent = function (name, stackFileContent, env, endpointId) { + var deferred = $q.defer(); + + SwarmService.swarm(endpointId) + .then(function success(swarm) { + var payload = { + Name: name, + SwarmID: swarm.ID, + StackFileContent: stackFileContent, + Env: env, + }; + return Stack.create({ endpointId: endpointId }, { method: 'string', type: 'swarm', ...payload }).$promise; + }) + .then(function success(data) { + deferred.resolve(data); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to create the stack', err: err }); + }); + + return deferred.promise; + }; + + service.createComposeStackFromGitRepository = function (name, repositoryOptions, env, endpointId) { + var payload = { + Name: name, + RepositoryURL: repositoryOptions.RepositoryURL, + RepositoryReferenceName: repositoryOptions.RepositoryReferenceName, + ComposeFile: repositoryOptions.ComposeFilePathInRepository, + AdditionalFiles: repositoryOptions.AdditionalFiles, + RepositoryAuthentication: repositoryOptions.RepositoryAuthentication, + RepositoryUsername: repositoryOptions.RepositoryUsername, + RepositoryPassword: repositoryOptions.RepositoryPassword, + Env: env, + FromAppTemplate: repositoryOptions.FromAppTemplate, + TLSSkipVerify: repositoryOptions.TLSSkipVerify, + }; + + if (repositoryOptions.AutoUpdate) { + payload.AutoUpdate = repositoryOptions.AutoUpdate; + } + + return Stack.create({ endpointId: endpointId }, { method: 'repository', type: 'standalone', ...payload }).$promise; + }; + + service.createSwarmStackFromGitRepository = function (name, repositoryOptions, env, endpointId) { + var deferred = $q.defer(); + + SwarmService.swarm(endpointId) + .then(function success(data) { + var swarm = data; + var payload = { + Name: name, + SwarmID: swarm.ID, + RepositoryURL: repositoryOptions.RepositoryURL, + RepositoryReferenceName: repositoryOptions.RepositoryReferenceName, + ComposeFile: repositoryOptions.ComposeFilePathInRepository, + AdditionalFiles: repositoryOptions.AdditionalFiles, + RepositoryAuthentication: repositoryOptions.RepositoryAuthentication, + RepositoryUsername: repositoryOptions.RepositoryUsername, + RepositoryPassword: repositoryOptions.RepositoryPassword, + Env: env, + FromAppTemplate: repositoryOptions.FromAppTemplate, + TLSSkipVerify: repositoryOptions.TLSSkipVerify, + }; + + if (repositoryOptions.AutoUpdate) { + payload.AutoUpdate = repositoryOptions.AutoUpdate; + } + + return Stack.create({ endpointId: endpointId }, { method: 'repository', type: 'swarm', ...payload }).$promise; + }) + .then(function success(data) { + deferred.resolve(data); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to create the stack', err: err }); + }); + + return deferred.promise; + }; + + async function kubernetesDeployAsync(endpointId, method, payload) { + try { + await Stack.create({ endpointId: endpointId }, { method, type: 'kubernetes', ...payload }).$promise; + } catch (err) { + throw { err: err }; + } + } + + service.kubernetesDeploy = function (endpointId, method, payload) { + return $async(kubernetesDeployAsync, endpointId, method, payload); + }; + + service.start = start; + function start(endpointId, id) { + return Stack.start({ id, endpointId }).$promise; + } + + service.stop = stop; + function stop(endpointId, id) { + return Stack.stop({ endpointId, id }).$promise; + } + + function updateGit(id, endpointId, env, prune, gitConfig, pullImage) { + return Stack.updateGit( + { endpointId, id }, + { + env, + prune, + RepositoryReferenceName: gitConfig.RefName, + RepositoryAuthentication: gitConfig.RepositoryAuthentication, + RepositoryUsername: gitConfig.RepositoryUsername, + RepositoryPassword: gitConfig.RepositoryPassword, + PullImage: pullImage, + } + ).$promise; + } + + function updateKubeGit(id, endpointId, namespace, gitConfig) { + return Stack.updateGit( + { endpointId, id }, + { + Namespace: namespace, + RepositoryReferenceName: gitConfig.RefName, + RepositoryAuthentication: gitConfig.RepositoryAuthentication, + RepositoryUsername: gitConfig.RepositoryUsername, + RepositoryPassword: gitConfig.RepositoryPassword, + StackName: gitConfig.StackName, + } + ).$promise; + } + + service.updateGitStackSettings = function (id, endpointId, env, gitConfig, webhookId) { + return Stack.updateGitStackSettings( + { endpointId, id }, + { + AutoUpdate: transformAutoUpdateViewModel(gitConfig.AutoUpdate, webhookId), + Env: env, + RepositoryReferenceName: gitConfig.RefName, + RepositoryAuthentication: gitConfig.RepositoryAuthentication, + RepositoryUsername: gitConfig.RepositoryUsername, + RepositoryPassword: gitConfig.RepositoryPassword, + Prune: gitConfig.Option.Prune, + TLSSkipVerify: gitConfig.TLSSkipVerify, + } + ).$promise; + }; + + return service; + }, +]); diff --git a/app/portainer/services/api/statusService.js b/app/portainer/services/api/statusService.js new file mode 100644 index 0000000..a91d71f --- /dev/null +++ b/app/portainer/services/api/statusService.js @@ -0,0 +1,27 @@ +import { getSystemStatus } from '@/react/portainer/system/useSystemStatus'; +import { StatusViewModel } from '../../models/status'; + +angular.module('portainer.app').factory('StatusService', StatusServiceFactory); + +/* @ngInject */ +function StatusServiceFactory($q) { + 'use strict'; + var service = {}; + + service.status = function () { + var deferred = $q.defer(); + + getSystemStatus() + .then(function success(data) { + var status = new StatusViewModel(data); + deferred.resolve(status); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve application status', err: err }); + }); + + return deferred.promise; + }; + + return service; +} diff --git a/app/portainer/services/api/supportService.js b/app/portainer/services/api/supportService.js new file mode 100644 index 0000000..00a3c1d --- /dev/null +++ b/app/portainer/services/api/supportService.js @@ -0,0 +1,24 @@ +angular.module('portainer.app').factory('SupportService', [ + '$q', + 'Support', + function SupportServiceFactory($q, Support) { + 'use strict'; + var service = {}; + + service.supportProducts = function () { + var deferred = $q.defer(); + + Support.get() + .$promise.then(function success(data) { + deferred.resolve(data); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve support options', err: err }); + }); + + return deferred.promise; + }; + + return service; + }, +]); diff --git a/app/portainer/services/api/tagService.js b/app/portainer/services/api/tagService.js new file mode 100644 index 0000000..ccc4551 --- /dev/null +++ b/app/portainer/services/api/tagService.js @@ -0,0 +1,64 @@ +import { TagViewModel } from '../../models/tag'; + +angular.module('portainer.app').factory('TagService', [ + '$q', + '$async', + 'Tags', + function TagServiceFactory($q, $async, Tags) { + 'use strict'; + var service = {}; + + service.tags = function () { + var deferred = $q.defer(); + Tags.query() + .$promise.then(function success(data) { + var tags = data.map(function (item) { + return new TagViewModel(item); + }); + deferred.resolve(tags); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve tags', err: err }); + }); + return deferred.promise; + }; + + service.tagNames = function () { + var deferred = $q.defer(); + Tags.query() + .$promise.then(function success(data) { + var tags = data.map(function (item) { + return item.Name; + }); + deferred.resolve(tags); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve tags', err: err }); + }); + return deferred.promise; + }; + + async function createTagAsync(name) { + var payload = { + Name: name, + }; + try { + const tag = await Tags.create({}, payload).$promise; + return new TagViewModel(tag); + } catch (err) { + throw { msg: 'Unable to create tag', err }; + } + } + + function createTag(name) { + return $async(createTagAsync, name); + } + service.createTag = createTag; + + service.deleteTag = function (id) { + return Tags.remove({ id: id }).$promise; + }; + + return service; + }, +]); diff --git a/app/portainer/services/api/teamMembershipService.js b/app/portainer/services/api/teamMembershipService.js new file mode 100644 index 0000000..b2d74d5 --- /dev/null +++ b/app/portainer/services/api/teamMembershipService.js @@ -0,0 +1,49 @@ +import { TeamMembershipModel } from '../../models/teamMembership'; + +angular.module('portainer.app').factory('TeamMembershipService', [ + '$q', + 'TeamMemberships', + function TeamMembershipFactory($q, TeamMemberships) { + 'use strict'; + var service = {}; + + service.memberships = function () { + var deferred = $q.defer(); + TeamMemberships.query() + .$promise.then(function success(data) { + var memberships = data.map(function (item) { + return new TeamMembershipModel(item); + }); + deferred.resolve(memberships); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve team memberships', err: err }); + }); + return deferred.promise; + }; + + service.createMembership = function (userId, teamId, role) { + var payload = { + UserID: userId, + TeamID: teamId, + Role: role, + }; + return TeamMemberships.create({}, payload).$promise; + }; + + service.deleteMembership = function (id) { + return TeamMemberships.remove({ id: id }).$promise; + }; + + service.updateMembership = function (id, userId, teamId, role) { + var payload = { + UserID: userId, + TeamID: teamId, + Role: role, + }; + return TeamMemberships.update({ id: id }, payload).$promise; + }; + + return service; + }, +]); diff --git a/app/portainer/services/api/teamService.js b/app/portainer/services/api/teamService.js new file mode 100644 index 0000000..b42ffca --- /dev/null +++ b/app/portainer/services/api/teamService.js @@ -0,0 +1,76 @@ +import { TeamViewModel } from '../../models/team'; +import { TeamMembershipModel } from '../../models/teamMembership'; + +angular.module('portainer.app').factory('TeamService', [ + '$q', + 'Teams', + function TeamServiceFactory($q, Teams) { + 'use strict'; + var service = {}; + + service.teams = function (environmentId) { + var deferred = $q.defer(); + Teams.query({ environmentId: environmentId }) + .$promise.then(function success(data) { + var teams = data.map(function (item) { + return new TeamViewModel(item); + }); + deferred.resolve(teams); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve teams', err: err }); + }); + return deferred.promise; + }; + + service.team = function (id) { + var deferred = $q.defer(); + Teams.get({ id: id }) + .$promise.then(function success(data) { + var team = new TeamViewModel(data); + deferred.resolve(team); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve team details', err: err }); + }); + return deferred.promise; + }; + + service.createTeam = function (name, leaderIds) { + var deferred = $q.defer(); + var payload = { + Name: name, + TeamLeaders: leaderIds, + }; + Teams.create({}, payload) + .$promise.then(function success() { + deferred.resolve(); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to create team', err: err }); + }); + return deferred.promise; + }; + + service.deleteTeam = function (id) { + return Teams.remove({ id: id }).$promise; + }; + + service.userMemberships = function (id) { + var deferred = $q.defer(); + Teams.queryMemberships({ id: id }) + .$promise.then(function success(data) { + var memberships = data.map(function (item) { + return new TeamMembershipModel(item); + }); + deferred.resolve(memberships); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve user memberships for the team', err: err }); + }); + return deferred.promise; + }; + + return service; + }, +]); diff --git a/app/portainer/services/api/templateService.js b/app/portainer/services/api/templateService.js new file mode 100644 index 0000000..2a40752 --- /dev/null +++ b/app/portainer/services/api/templateService.js @@ -0,0 +1,48 @@ +import { TemplateViewModel } from '@/react/portainer/templates/app-templates/view-model'; +import { DockerHubViewModel } from '@/portainer/models/dockerhub'; + +angular.module('portainer.app').factory('TemplateService', TemplateServiceFactory); + +/* @ngInject */ +function TemplateServiceFactory($q, Templates, EndpointService) { + var service = { + templates, + }; + + function templates(endpointId) { + const deferred = $q.defer(); + + $q.all({ + templates: Templates.query().$promise, + registries: EndpointService.registries(endpointId), + }) + .then(function success({ templates, registries }) { + const version = templates.version; + deferred.resolve( + templates.templates.map((item) => { + try { + const template = new TemplateViewModel(item, version); + const registryURL = template.RegistryModel.Registry.URL; + const registry = registryURL ? registries.find((reg) => reg.URL === registryURL) : new DockerHubViewModel(); + template.RegistryModel.Registry = registry; + return template; + } catch (err) { + deferred.reject({ msg: 'Unable to retrieve templates', err: err }); + } + }) + ); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve templates', err: err }); + }); + + return deferred.promise; + } + + service.templateFile = templateFile; + function templateFile(repositoryUrl, composeFilePathInRepository) { + return Templates.file({ repositoryUrl, composeFilePathInRepository }).$promise; + } + + return service; +} diff --git a/app/portainer/services/api/userService.js b/app/portainer/services/api/userService.js new file mode 100644 index 0000000..2d1102f --- /dev/null +++ b/app/portainer/services/api/userService.js @@ -0,0 +1,113 @@ +import _ from 'lodash-es'; +import { UserViewModel } from '@/portainer/models/user'; +import { getUsers } from '@/portainer/users/user.service'; +import { getUser } from '@/portainer/users/queries/useUser'; +import { userAdminInit } from '@api/sdk.gen'; + +import { TeamMembershipModel } from '../../models/teamMembership'; + +/* @ngInject */ +export function UserService($q, Users, TeamService) { + 'use strict'; + var service = {}; + + service.users = async function (includeAdministrators, environmentId) { + const users = await getUsers(includeAdministrators, environmentId); + + return users.map((u) => new UserViewModel(u)); + }; + + service.user = async function (userId) { + const user = await getUser(userId); + + return new UserViewModel(user); + }; + + service.deleteUser = function (id) { + return Users.remove({ id: id }).$promise; + }; + + service.updateUser = function (id, { newPassword, role, username }) { + return Users.update({ id }, { newPassword, role, username }).$promise; + }; + + service.updateUserPassword = function (id, currentPassword, newPassword) { + var payload = { + Password: currentPassword, + NewPassword: newPassword, + }; + + return Users.updatePassword({ id: id }, payload).$promise; + }; + + service.updateUserTheme = function (id, theme) { + return Users.updateTheme({ id }, { theme }).$promise; + }; + + service.userMemberships = function (id) { + var deferred = $q.defer(); + + Users.queryMemberships({ id: id }) + .$promise.then(function success(data) { + var memberships = data.map(function (item) { + return new TeamMembershipModel(item); + }); + deferred.resolve(memberships); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve user memberships', err: err }); + }); + + return deferred.promise; + }; + + service.userLeadingTeams = function (id) { + var deferred = $q.defer(); + + $q.all({ + teams: TeamService.teams(), + memberships: service.userMemberships(id), + }) + .then(function success(data) { + var memberships = data.memberships; + var teams = data.teams.filter(function (team) { + var membership = _.find(memberships, { TeamId: team.Id }); + if (membership && membership.Role === 1) { + return team; + } + }); + deferred.resolve(teams); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve user teams', err: err }); + }); + + return deferred.promise; + }; + + service.initAdministrator = function (username, password, setupToken) { + return userAdminInit({ + body: { Username: username, Password: password }, + ...(setupToken && { headers: { 'X-Setup-Token': setupToken } }), + }); + }; + + service.administratorExists = function () { + var deferred = $q.defer(); + + Users.checkAdminUser({}) + .$promise.then(function success() { + deferred.resolve(true); + }) + .catch(function error(err) { + if (err.status === 404) { + deferred.resolve(false); + } + deferred.reject({ msg: 'Unable to verify administrator account existence', err: err }); + }); + + return deferred.promise; + }; + + return service; +} diff --git a/app/portainer/services/api/webhookService.js b/app/portainer/services/api/webhookService.js new file mode 100644 index 0000000..b615be7 --- /dev/null +++ b/app/portainer/services/api/webhookService.js @@ -0,0 +1,42 @@ +import { WebhookViewModel } from '../../models/webhook'; + +angular.module('portainer.app').factory('WebhookService', [ + '$q', + 'Webhooks', + function WebhookServiceFactory($q, Webhooks) { + 'use strict'; + var service = {}; + + service.webhooks = function (serviceID, endpointID) { + var deferred = $q.defer(); + var filters = { ResourceID: serviceID, EndpointID: endpointID }; + + Webhooks.query({ filters: filters }) + .$promise.then(function success(data) { + var webhooks = data.map(function (item) { + return new WebhookViewModel(item); + }); + deferred.resolve(webhooks); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve webhooks', err: err }); + }); + + return deferred.promise; + }; + + service.createServiceWebhook = function (serviceID, endpointID, registryID) { + return Webhooks.create({ ResourceID: serviceID, EndpointID: endpointID, WebhookType: 1, registryID }).$promise; + }; + + service.updateServiceWebhook = function (id, registryID) { + return Webhooks.update({ id, registryID }).$promise; + }; + + service.deleteWebhook = function (id) { + return Webhooks.remove({ id: id }).$promise; + }; + + return service; + }, +]); diff --git a/app/portainer/services/async.js b/app/portainer/services/async.js new file mode 100644 index 0000000..b88e619 --- /dev/null +++ b/app/portainer/services/async.js @@ -0,0 +1,24 @@ +/** + * Look a the following PR for how to use the wrapper + * and documentation about it + * https://github.com/portainer/portainer/pull/2945 + */ + +angular.module('portainer').factory('$async', [ + '$q', + function ($q) { + return function (asyncFunc, ...args) { + const def = $q.defer(); + const wrapper = function (params) { + const deferred = $q.defer(); + asyncFunc(...params) + .then(deferred.resolve) + .catch(deferred.reject); + return deferred.promise; + }; + + wrapper(args).then(def.resolve).catch(def.reject); + return def.promise; + }; + }, +]); diff --git a/app/portainer/services/authentication.js b/app/portainer/services/authentication.js new file mode 100644 index 0000000..3825aff --- /dev/null +++ b/app/portainer/services/authentication.js @@ -0,0 +1,177 @@ +import { hasAuthorizations as useUserHasAuthorization } from '@/react/hooks/useUser'; +import { getCurrentUser } from '../users/queries/useLoadCurrentUser'; +import * as userHelpers from '../users/user.helpers'; +import { clear as clearSessionStorage } from './session-storage'; +const DEFAULT_USER = 'admin'; +const DEFAULT_PASSWORD = 'K7yJPP5qNK4hf1QsRnfV'; + +angular.module('portainer.app').factory('Authentication', [ + '$async', + '$state', + 'Auth', + 'OAuth', + 'LocalStorage', + 'StateManager', + 'EndpointProvider', + 'ThemeManager', + function AuthenticationFactory($async, $state, Auth, OAuth, LocalStorage, StateManager, EndpointProvider, ThemeManager) { + 'use strict'; + + var user = {}; + if (process.env.NODE_ENV === 'development') { + window.login = loginAsync; + } + + return { + init, + OAuthLogin, + login, + logout, + isAuthenticated, + getUserDetails, + isAdmin, + isEdgeAdmin, + isPureAdmin, + hasAuthorizations, + redirectIfUnauthorized, + }; + + async function initAsync() { + try { + const userId = LocalStorage.getUserId(); + if (userId && user.ID === userId) { + return true; + } + await tryAutoLoginExtension(); + await loadUserData(); + return true; + } catch (error) { + return tryAutoLoginExtension(); + } + } + + async function logoutAsync() { + if (isAuthenticated()) { + await Auth.logout().$promise; + } + + clearSessionStorage(); + StateManager.clean(); + EndpointProvider.clean(); + LocalStorage.cleanAuthData(); + LocalStorage.storeLoginStateUUID(''); + tryAutoLoginExtension(); + cleanUserData(); + } + + function logout() { + return $async(logoutAsync); + } + + function init() { + return $async(initAsync); + } + + async function OAuthLoginAsync(code) { + await OAuth.validate({ code: code }).$promise; + await loadUserData(); + } + + function OAuthLogin(code) { + return $async(OAuthLoginAsync, code); + } + + async function loginAsync(username, password) { + await Auth.login({ username: username, password: password }).$promise; + await loadUserData(); + } + + function login(username, password) { + return $async(loginAsync, username, password); + } + + function isAuthenticated() { + return !!user.ID; + } + + function getUserDetails() { + return user; + } + + function cleanUserData() { + user = {}; + } + + async function loadUserData() { + const userData = await getCurrentUser(); + user.username = userData.Username; + user.ID = userData.Id; + user.role = userData.Role; + user.forceChangePassword = userData.forceChangePassword; + user.endpointAuthorizations = userData.EndpointAuthorizations; + user.portainerAuthorizations = userData.PortainerAuthorizations; + + // Initialize user theme base on UserTheme from database + const userTheme = userData.ThemeSettings ? userData.ThemeSettings.color : 'auto'; + if (userTheme === 'auto' || !userTheme) { + ThemeManager.autoTheme(); + } else { + ThemeManager.setTheme(userTheme); + } + + LocalStorage.storeUserId(userData.Id); + } + + function tryAutoLoginExtension() { + if (!window.ddExtension) { + return false; + } + + return login(DEFAULT_USER, DEFAULT_PASSWORD); + } + + // To avoid creating divergence between CE and EE + // isAdmin checks if the user is a portainer admin or edge admin + + function isEdgeAdmin(noEnvScope = false) { + const environment = EndpointProvider.currentEndpoint(); + return userHelpers.isEdgeAdmin({ Role: user.role }, noEnvScope ? undefined : environment); + } + + /** + * @deprecated use Authentication.isAdmin instead + */ + function isAdmin(noEnvScope = false) { + return isEdgeAdmin(noEnvScope); + } + + // To avoid creating divergence between CE and EE + // isPureAdmin checks if the user is portainer admin only + function isPureAdmin() { + return userHelpers.isPureAdmin({ Role: user.role }); + } + + function hasAuthorizations(authorizations) { + const endpointId = EndpointProvider.endpointID(); + + if (isEdgeAdmin()) { + return true; + } + + return useUserHasAuthorization( + { + EndpointAuthorizations: user.endpointAuthorizations, + }, + authorizations, + endpointId + ); + } + + function redirectIfUnauthorized(authorizations) { + const authorized = hasAuthorizations(authorizations); + if (!authorized) { + $state.go('portainer.home'); + } + } + }, +]); diff --git a/app/portainer/services/axios/axios.ts b/app/portainer/services/axios/axios.ts new file mode 100644 index 0000000..80cf741 --- /dev/null +++ b/app/portainer/services/axios/axios.ts @@ -0,0 +1,3 @@ +export * from '@/react/portainer/services/axios/axios'; +// eslint-disable-next-line no-restricted-exports +export { default } from '@/react/portainer/services/axios/axios'; diff --git a/app/portainer/services/axios/index.ts b/app/portainer/services/axios/index.ts new file mode 100644 index 0000000..8c902d8 --- /dev/null +++ b/app/portainer/services/axios/index.ts @@ -0,0 +1,3 @@ +export * from './axios'; +// eslint-disable-next-line no-restricted-exports +export { default } from './axios'; diff --git a/app/portainer/services/axios/utils/isAxiosError.ts b/app/portainer/services/axios/utils/isAxiosError.ts new file mode 100644 index 0000000..b6bfa47 --- /dev/null +++ b/app/portainer/services/axios/utils/isAxiosError.ts @@ -0,0 +1 @@ +export * from '@/react/portainer/services/axios/utils/isAxiosError'; diff --git a/app/portainer/services/chartService.js b/app/portainer/services/chartService.js new file mode 100644 index 0000000..3cb3f1d --- /dev/null +++ b/app/portainer/services/chartService.js @@ -0,0 +1,277 @@ +import Chart from 'chart.js'; +import { filesize } from 'filesize'; + +angular.module('portainer.app').factory('ChartService', [ + function ChartService() { + 'use strict'; + + // Max. number of items to display on a chart + var CHART_LIMIT = 600; + + var service = {}; + + function defaultChartOptions(pos, tooltipCallback, scalesCallback, isStacked) { + return { + animation: { duration: 0 }, + responsiveAnimationDuration: 0, + responsive: true, + tooltips: { + mode: 'index', + intersect: false, + position: pos, + callbacks: { + label: function (tooltipItem, data) { + var datasetLabel = data.datasets[tooltipItem.datasetIndex].label; + return tooltipCallback(datasetLabel, tooltipItem.yLabel); + }, + }, + }, + layout: { + padding: { + left: 15, + }, + }, + hover: { animationDuration: 0 }, + scales: { + yAxes: [ + { + stacked: isStacked, + ticks: { + beginAtZero: true, + callback: scalesCallback, + precision: 0, + }, + }, + ], + }, + }; + } + + function CreateChart(context, label, tooltipCallback, scalesCallback) { + return new Chart(context, { + type: 'line', + data: { + labels: [], + datasets: [ + { + label: label, + data: [], + fill: true, + backgroundColor: 'rgba(151,187,205,0.4)', + borderColor: 'rgba(151,187,205,0.6)', + pointBackgroundColor: 'rgba(151,187,205,1)', + pointBorderColor: 'rgba(151,187,205,1)', + pointRadius: 2, + borderWidth: 2, + }, + ], + }, + options: defaultChartOptions('nearest', tooltipCallback, scalesCallback), + }); + } + + function CreateMemoryChart(context, tooltipCallback, scalesCallback) { + return new Chart(context, { + type: 'line', + data: { + labels: [], + datasets: [ + { + label: 'Memory', + data: [], + fill: true, + backgroundColor: 'rgba(151,187,205,0.4)', + borderColor: 'rgba(151,187,205,0.6)', + pointBackgroundColor: 'rgba(151,187,205,1)', + pointBorderColor: 'rgba(151,187,205,1)', + pointRadius: 2, + borderWidth: 2, + }, + { + label: 'Cache', + data: [], + fill: true, + backgroundColor: 'rgba(255,180,174,0.4)', + borderColor: 'rgba(255,180,174,0.6)', + pointBackgroundColor: 'rgba(255,180,174,1)', + pointBorderColor: 'rgba(255,180,174,1)', + pointRadius: 2, + borderWidth: 2, + }, + ], + }, + options: defaultChartOptions('nearest', tooltipCallback, scalesCallback, true), + }); + } + + function CreateIOChart(context, tooltipCallback, scalesCallback) { + return new Chart(context, { + type: 'line', + data: { + labels: [], + datasets: [ + { + label: 'Read (Aggregate)', + data: [], + fill: true, + backgroundColor: 'rgba(151,187,205,0.4)', + borderColor: 'rgba(151,187,205,0.6)', + pointBackgroundColor: 'rgba(151,187,205,1)', + pointBorderColor: 'rgba(151,187,205,1)', + pointRadius: 2, + borderWidth: 2, + }, + { + label: 'Write (Aggregate)', + data: [], + fill: true, + backgroundColor: 'rgba(255,180,174,0.4)', + borderColor: 'rgba(255,180,174,0.6)', + pointBackgroundColor: 'rgba(255,180,174,1)', + pointBorderColor: 'rgba(255,180,174,1)', + pointRadius: 2, + borderWidth: 2, + }, + ], + }, + options: defaultChartOptions('nearest', tooltipCallback, scalesCallback, true), + }); + } + + service.CreateCPUChart = function (context) { + return CreateChart(context, 'CPU', percentageBasedTooltipLabel, percentageBasedAxisLabel); + }; + + service.CreateIOChart = function (context) { + return CreateIOChart(context, byteBasedTooltipLabel, byteBasedAxisLabel); + }; + + service.CreateMemoryChart = function (context) { + return CreateMemoryChart(context, byteBasedTooltipLabel, byteBasedAxisLabel); + }; + + service.CreateNetworkChart = function (context) { + return new Chart(context, { + type: 'line', + data: { + labels: [], + datasets: [ + { + label: 'RX on eth0', + data: [], + fill: false, + backgroundColor: 'rgba(151,187,205,0.4)', + borderColor: 'rgba(151,187,205,0.6)', + pointBackgroundColor: 'rgba(151,187,205,1)', + pointBorderColor: 'rgba(151,187,205,1)', + pointRadius: 2, + borderWidth: 2, + }, + { + label: 'TX on eth0', + data: [], + fill: false, + backgroundColor: 'rgba(255,180,174,0.4)', + borderColor: 'rgba(255,180,174,0.6)', + pointBackgroundColor: 'rgba(255,180,174,1)', + pointBorderColor: 'rgba(255,180,174,1)', + pointRadius: 2, + borderWidth: 2, + }, + ], + }, + options: defaultChartOptions('average', byteBasedTooltipLabel, byteBasedAxisLabel), + }); + }; + + function LimitChartItems(chart, CHART_LIMIT) { + if (chart.data.datasets[0].data.length > CHART_LIMIT) { + chart.data.labels.pop(); + chart.data.datasets[0].data.pop(); + chart.data.datasets[1].data.pop(); + } + } + + function UpdateChart(label, value, chart) { + chart.data.labels.push(label); + chart.data.datasets[0].data.push(value); + + if (chart.data.datasets[0].data.length > CHART_LIMIT) { + chart.data.labels.pop(); + chart.data.datasets[0].data.pop(); + } + + chart.update(0); + } + + service.UpdateMemoryChart = function UpdateChart(label, memoryValue, cacheValue, chart) { + chart.data.labels.push(label); + chart.data.datasets[0].data.push(memoryValue); + + if (cacheValue) { + chart.data.datasets[1].data.push(cacheValue); + } else { + // cache values are not available for Windows + chart.data.datasets.splice(1, 1); + } + + LimitChartItems(chart); + + chart.update(0); + }; + service.UpdateCPUChart = UpdateChart; + service.UpdateIOChart = function (label, read, write, chart) { + chart.data.labels.push(label); + chart.data.datasets[0].data.push(read); + chart.data.datasets[1].data.push(write); + LimitChartItems(chart); + chart.update(0); + }; + + service.UpdateNetworkChart = function (label, rx, tx, chart) { + chart.data.labels.push(label); + chart.data.datasets[0].data.push(rx); + chart.data.datasets[1].data.push(tx); + + LimitChartItems(chart); + + chart.update(0); + }; + + function byteBasedTooltipLabel(label, value) { + var processedValue = 0; + if (value > 5) { + processedValue = filesize(value, { base: 10, round: 1 }); + } else { + processedValue = value.toFixed(1) + 'B'; + } + return label + ': ' + processedValue; + } + + function byteBasedAxisLabel(value) { + if (value > 5) { + return filesize(value, { base: 10, round: 1 }); + } + return value.toFixed(1) + 'B'; + } + + function percentageBasedAxisLabel(value) { + if (value > 1) { + return Math.round(value) + '%'; + } + return value.toFixed(1) + '%'; + } + + function percentageBasedTooltipLabel(label, value) { + var processedValue = 0; + if (value > 1) { + processedValue = Math.round(value); + } else { + processedValue = value.toFixed(1); + } + return label + ': ' + processedValue + '%'; + } + + return service; + }, +]); diff --git a/app/portainer/services/dockerMaxApiVersion.ts b/app/portainer/services/dockerMaxApiVersion.ts new file mode 100644 index 0000000..80ff7ec --- /dev/null +++ b/app/portainer/services/dockerMaxApiVersion.ts @@ -0,0 +1,2 @@ +// this is the version we are using with the generated API types +export const MAX_DOCKER_API_VERSION = 1.47; diff --git a/app/portainer/services/dockerMaxApiVersionInterceptor.ts b/app/portainer/services/dockerMaxApiVersionInterceptor.ts new file mode 100644 index 0000000..ba3da40 --- /dev/null +++ b/app/portainer/services/dockerMaxApiVersionInterceptor.ts @@ -0,0 +1,58 @@ +import { SystemVersion } from 'docker-types'; +import Axios, { InternalAxiosRequestConfig } from 'axios'; +import { setupCache, buildMemoryStorage } from 'axios-cache-interceptor'; + +import { buildDockerProxyUrl } from '@/react/docker/proxy/queries/buildDockerProxyUrl'; + +import { MAX_DOCKER_API_VERSION } from './dockerMaxApiVersion'; + +const envVersionAxios = Axios.create({ + baseURL: 'api', +}); + +// setup a cache for the intermediary request sent by the interceptor +const envVersionCache = buildMemoryStorage(); +setupCache(envVersionAxios, { + storage: envVersionCache, + ttl: 5 * 60 * 1000, + methods: ['get'], +}); + +export async function dockerMaxAPIVersionInterceptor( + rawConfig: InternalAxiosRequestConfig +) { + try { + const config = rawConfig; + const found = config.url?.match( + /endpoints\/(?\d+)\/docker\// + ); + + if (found && found.groups) { + const { environmentId } = found.groups; + const envId = parseInt(environmentId, 10); + + // if we cannot parse the env ID, don't send a request that will fail, + // exit the interceptor and let the original request config pass through + if (Number.isNaN(envId)) { + return config; + } + + const { data } = await envVersionAxios.get( + buildDockerProxyUrl(envId, 'version') + ); + + const apiVersion = parseFloat(data.ApiVersion ?? '0'); + + if (apiVersion > MAX_DOCKER_API_VERSION) { + config.url = config.url?.replace( + /docker/, + `docker/v${MAX_DOCKER_API_VERSION}` + ); + } + } + return config; + } catch { + // if the interceptor errors, return the original config + return rawConfig; + } +} diff --git a/app/portainer/services/endpointProvider.ts b/app/portainer/services/endpointProvider.ts new file mode 100644 index 0000000..3c562e2 --- /dev/null +++ b/app/portainer/services/endpointProvider.ts @@ -0,0 +1,69 @@ +import { ping } from '@/react/docker/proxy/queries/usePing'; +import { environmentStore } from '@/react/hooks/current-environment-store'; +import { + Environment, + EnvironmentType, +} from '@/react/portainer/environments/types'; + +interface State { + currentEndpoint: Environment | null; + pingInterval: NodeJS.Timeout | null; +} + +const DEFAULT_TITLE = 'Portainer'; + +/* @ngInject */ +export function EndpointProvider() { + const state: State = { + currentEndpoint: null, + pingInterval: null, + }; + + environmentStore.subscribe( + (state) => state.environmentId, + (environmentId) => { + if (!environmentId) { + setCurrentEndpoint(null); + } + } + ); + + return { endpointID, setCurrentEndpoint, currentEndpoint, clean }; + + function endpointID() { + return state.currentEndpoint?.Id; + } + + function setCurrentEndpoint(endpoint: Environment | null) { + state.currentEndpoint = endpoint; + + if (state.pingInterval) { + clearInterval(state.pingInterval); + state.pingInterval = null; + } + + if (endpoint && endpoint.Type === EnvironmentType.EdgeAgentOnDocker) { + state.pingInterval = setInterval(() => ping(endpoint.Id), 60 * 1000); + } + + if (endpoint === null) { + sessionStorage.setItem( + 'portainer.environmentId', + JSON.stringify(undefined) + ); + } + + document.title = endpoint ? `${endpoint.Name}` : `${DEFAULT_TITLE}`; + } + + function currentEndpoint() { + return state.currentEndpoint; + } + + function clean() { + setCurrentEndpoint(null); + environmentStore.getState().clear(); + } +} + +export type EndpointProviderInterface = ReturnType; diff --git a/app/portainer/services/fileUpload.js b/app/portainer/services/fileUpload.js new file mode 100644 index 0000000..c0f9e82 --- /dev/null +++ b/app/portainer/services/fileUpload.js @@ -0,0 +1,197 @@ +import { PortainerEndpointCreationTypes } from '@/portainer/models/endpoint/models'; + +angular.module('portainer.app').factory('FileUploadService', FileUploadFactory); + +/* @ngInject */ +function FileUploadFactory($q, Upload) { + var service = { + // createSchedule, // edge jobs service + // uploadBackup, // backup service + // createSwarmStack, // stack service + // createComposeStack, // stack service + // createEdgeStack, // edge stack service + // createCustomTemplate, // custom template service + // configureRegistry, // registry service + // createEndpoint, // endpoint service + // createAzureEndpoint, // endpoint service + // uploadLDAPTLSFiles, // auth settings controller + // uploadTLSFilesForEndpoint, // endpoint service + // uploadOwnershipVoucher, // import device controller + }; + + function uploadFile(url, file) { + return Upload.upload({ url: url, data: { file: file } }); + } + + service.createSchedule = function (payload) { + return Upload.upload({ + url: 'api/edge_jobs/create/file', + data: { + file: payload.File, + Name: payload.Name, + CronExpression: payload.CronExpression, + Image: payload.Image, + Endpoints: Upload.json(payload.Endpoints), + RetryCount: payload.RetryCount, + RetryInterval: payload.RetryInterval, + }, + }); + }; + + service.uploadBackup = function (file, password, setupToken) { + return Upload.upload({ + url: 'api/restore', + data: { file, password }, + headers: setupToken ? { 'X-Setup-Token': setupToken } : {}, + }); + }; + + service.createSwarmStack = function (stackName, swarmId, file, env, endpointId, webhook) { + return Upload.upload({ + url: `api/stacks/create/swarm/file?endpointId=${endpointId}`, + data: { + file: file, + Name: stackName, + SwarmID: swarmId, + Env: Upload.json(env), + Webhook: webhook, + }, + ignoreLoadingBar: true, + }); + }; + + service.createComposeStack = function (stackName, file, env, endpointId, webhook) { + return Upload.upload({ + url: `api/stacks/create/standalone/file?endpointId=${endpointId}`, + data: { + file: file, + Name: stackName, + Env: Upload.json(env), + Webhook: webhook, + }, + ignoreLoadingBar: true, + }); + }; + + service.createEdgeStack = function createEdgeStack({ EdgeGroups, Registries, envVars, staggerConfig, ...payload }, file, dryrun) { + return Upload.upload({ + url: `api/edge_stacks/create/file?dryrun=${dryrun}`, + data: { + file, + EdgeGroups: Upload.json(EdgeGroups), + Registries: Upload.json(Registries), + EnvVars: Upload.json(envVars), + StaggerConfig: Upload.json(staggerConfig), + ...payload, + }, + ignoreLoadingBar: true, + }); + }; + + service.createCustomTemplate = function createCustomTemplate(data) { + return Upload.upload({ + url: 'api/custom_templates/create/file', + data, + ignoreLoadingBar: true, + }); + }; + + service.configureRegistry = function (registryId, registryManagementConfigurationModel) { + return Upload.upload({ + url: 'api/registries/' + registryId + '/configure', + data: registryManagementConfigurationModel, + }); + }; + + service.createEndpoint = function ( + name, + creationType, + URL, + PublicURL, + groupID, + tagIds, + TLS, + TLSSkipVerify, + TLSSkipClientVerify, + TLSCAFile, + TLSCertFile, + TLSKeyFile, + checkinInterval, + EdgePingInterval, + EdgeSnapshotInterval, + EdgeCommandInterval + ) { + return Upload.upload({ + url: 'api/endpoints', + data: { + Name: name, + EndpointCreationType: creationType, + URL: URL, + PublicURL: PublicURL, + GroupID: groupID, + TagIds: Upload.json(tagIds), + TLS: TLS, + TLSSkipVerify: TLSSkipVerify, + TLSSkipClientVerify: TLSSkipClientVerify, + TLSCACertFile: TLSCAFile, + TLSCertFile: TLSCertFile, + TLSKeyFile: TLSKeyFile, + CheckinInterval: checkinInterval, + EdgePingInterval: EdgePingInterval, + EdgeSnapshotInterval: EdgeSnapshotInterval, + EdgeCommandInterval: EdgeCommandInterval, + }, + ignoreLoadingBar: true, + }); + }; + + service.createAzureEndpoint = function (name, applicationId, tenantId, authenticationKey, groupId, tagIds) { + return Upload.upload({ + url: 'api/endpoints', + data: { + Name: name, + EndpointCreationType: PortainerEndpointCreationTypes.AzureEnvironment, + GroupID: groupId, + TagIds: Upload.json(tagIds), + AzureApplicationID: applicationId, + AzureTenantID: tenantId, + AzureAuthenticationKey: authenticationKey, + }, + ignoreLoadingBar: true, + }); + }; + + service.uploadLDAPTLSFiles = function (TLSCAFile, TLSCertFile, TLSKeyFile) { + var queue = []; + + if (TLSCAFile) { + queue.push(uploadFile('api/upload/tls/ca?folder=ldap', TLSCAFile)); + } + if (TLSCertFile) { + queue.push(uploadFile('api/upload/tls/cert?folder=ldap', TLSCertFile)); + } + if (TLSKeyFile) { + queue.push(uploadFile('api/upload/tls/key?folder=ldap', TLSKeyFile)); + } + + return $q.all(queue); + }; + + service.uploadTLSFilesForEndpoint = function (endpointID, TLSCAFile, TLSCertFile, TLSKeyFile) { + var queue = []; + + if (TLSCAFile) { + queue.push(uploadFile('api/upload/tls/ca?folder=' + endpointID, TLSCAFile)); + } + if (TLSCertFile) { + queue.push(uploadFile('api/upload/tls/cert?folder=' + endpointID, TLSCertFile)); + } + if (TLSKeyFile) { + queue.push(uploadFile('api/upload/tls/key?folder=' + endpointID, TLSKeyFile)); + } + + return $q.all(queue); + }; + + return service; +} diff --git a/app/portainer/services/fileUploadReact.ts b/app/portainer/services/fileUploadReact.ts new file mode 100644 index 0000000..f8b50a3 --- /dev/null +++ b/app/portainer/services/fileUploadReact.ts @@ -0,0 +1,25 @@ +export function readFileAsArrayBuffer(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.readAsArrayBuffer(file); + reader.onload = () => { + if (reader.result instanceof ArrayBuffer) { + resolve(reader.result); + } + }; + reader.onerror = (error) => reject(error); + }); +} + +export function readFileAsText(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.readAsText(file); + reader.onload = () => { + if (typeof reader.result === 'string') { + resolve(reader.result); + } + }; + reader.onerror = (error) => reject(error); + }); +} diff --git a/app/portainer/services/formValidator.js b/app/portainer/services/formValidator.js new file mode 100644 index 0000000..7cac122 --- /dev/null +++ b/app/portainer/services/formValidator.js @@ -0,0 +1,24 @@ +import { ResourceControlOwnership as RCO } from '@/react/portainer/access-control/types'; + +angular.module('portainer.app').factory('FormValidator', [ + function FormValidatorFactory() { + 'use strict'; + + var validator = {}; + + validator.validateAccessControl = function (accessControlData, isAdmin) { + if (!accessControlData.AccessControlEnabled) { + return ''; + } + + if (isAdmin && accessControlData.Ownership === RCO.RESTRICTED && accessControlData.AuthorizedUsers.length === 0 && accessControlData.AuthorizedTeams.length === 0) { + return 'You must specify at least one team or user.'; + } else if (!isAdmin && accessControlData.Ownership === RCO.RESTRICTED && accessControlData.AuthorizedTeams.length === 0) { + return 'You must specify at least a team.'; + } + return ''; + }; + + return validator; + }, +]); diff --git a/app/portainer/services/http-request.helper.test.ts b/app/portainer/services/http-request.helper.test.ts new file mode 100644 index 0000000..703b7bf --- /dev/null +++ b/app/portainer/services/http-request.helper.test.ts @@ -0,0 +1,69 @@ +import { + registryAuthenticationHeader, + setRegistryAuthenticationHeader, + portainerAgentTargetHeader, + setPortainerAgentTargetHeader, + setPortainerAgentManagerOperation, + portainerAgentManagerOperation, + resetAgentHeaders, +} from './http-request.helper'; + +afterEach(() => { + resetAgentHeaders(); +}); + +test('registryAuthenticationHeader', () => { + const header = 'header'; + + expect(registryAuthenticationHeader()).toBeUndefined(); + + setRegistryAuthenticationHeader(header); + + expect(registryAuthenticationHeader()).toBe(header); + + resetAgentHeaders(); + + expect(registryAuthenticationHeader()).toBeUndefined(); +}); + +test('portainerAgentTargetHeader', () => { + const header = 'header'; + + expect(portainerAgentTargetHeader()).toBe(''); + + setPortainerAgentTargetHeader(header); + + expect(portainerAgentTargetHeader()).toBe(header); + + resetAgentHeaders(); + + expect(portainerAgentTargetHeader()).toBe(''); +}); + +test('when setting portainerAgentTargetHeader more than once, should return headers in fifo, until the last one then it should be the last one', () => { + const headers = Array.from({ length: 5 }).map((_, i) => `header${i}`); + + expect(portainerAgentTargetHeader()).toBe(''); + + headers.forEach((header) => setPortainerAgentTargetHeader(header)); + + headers.forEach((_, i) => + expect(portainerAgentTargetHeader()).toBe(`header${i}`) + ); + + expect(portainerAgentTargetHeader()).toBe('header4'); + expect(portainerAgentTargetHeader()).toBe('header4'); + expect(portainerAgentTargetHeader()).toBe('header4'); +}); + +test('portainerAgentManagerOperation', () => { + expect(portainerAgentManagerOperation()).toBe(false); + + setPortainerAgentManagerOperation(true); + + expect(portainerAgentManagerOperation()).toBe(true); + + resetAgentHeaders(); + + expect(portainerAgentManagerOperation()).toBe(false); +}); diff --git a/app/portainer/services/http-request.helper.ts b/app/portainer/services/http-request.helper.ts new file mode 100644 index 0000000..19e244c --- /dev/null +++ b/app/portainer/services/http-request.helper.ts @@ -0,0 +1,102 @@ +import { AxiosRequestConfig } from 'axios'; + +export const CACHE_DURATION = 5 * 60 * 1000; // 5m in ms +// event emitted when cache need to be refreshed +// used to sync $http + axios cache clear +export const CACHE_REFRESH_EVENT = '__cache__refresh__event__'; + +// utility function to dispatch catch refresh event +export function dispatchCacheRefreshEvent() { + dispatchEvent(new CustomEvent(CACHE_REFRESH_EVENT, {})); +} + +// perform checks on config.method and config.url +// to dispatch event in only specific scenarios +export function dispatchCacheRefreshEventIfNeeded(req: AxiosRequestConfig) { + if ( + req.method && + ['post', 'patch', 'put', 'delete'].includes(req.method.toLowerCase()) && + // don't clear cache when we try to check for namespaces accesses + // otherwise we will clear it on every page + req.url && + !req.url.includes('selfsubjectaccessreviews') && + req.url.includes('kubernetes') + ) { + dispatchCacheRefreshEvent(); + } +} + +interface Headers { + agentTargetQueue: string[]; + agentManagerOperation: boolean; + registryAuthentication?: string; + agentTargetLastValue: string; +} + +const headers: Headers = { + agentTargetQueue: [], + agentManagerOperation: false, + agentTargetLastValue: '', +}; + +export function registryAuthenticationHeader() { + return headers.registryAuthentication; +} + +export function setRegistryAuthenticationHeader(headerValue: string) { + headers.registryAuthentication = headerValue; +} + +// Due to the fact that async HTTP requests are decorated using an interceptor +// we need to store and retrieve the headers using a first-in-first-out (FIFO) data structure. +// Otherwise, sequential HTTP requests might end up using the same header value (incorrect in the case +// of starting multiple containers on different nodes for example). +// To prevent having to use the HttpRequestHelper.setPortainerAgentTargetHeader before EACH request, +// we re-use the latest available header in the data structure (handy in thee case of multiple requests affecting +// the same node in the same view). +export function portainerAgentTargetHeader() { + if (headers.agentTargetQueue.length === 0) { + return headers.agentTargetLastValue; + } + + if (headers.agentTargetQueue.length === 1) { + const [lastValue] = headers.agentTargetQueue; + headers.agentTargetLastValue = lastValue; + } + + return headers.agentTargetQueue.shift() || ''; +} + +export function setPortainerAgentTargetHeader(headerValue: string) { + if (headerValue) { + headers.agentTargetQueue.push(headerValue); + } +} + +export function setPortainerAgentManagerOperation(set: boolean) { + headers.agentManagerOperation = set; +} + +export function portainerAgentManagerOperation() { + return headers.agentManagerOperation; +} + +export function resetAgentHeaders() { + headers.agentTargetQueue = []; + headers.agentTargetLastValue = ''; + headers.agentManagerOperation = false; + delete headers.registryAuthentication; +} + +/* @ngInject */ +export function HttpRequestHelperAngular() { + return { + registryAuthenticationHeader, + setRegistryAuthenticationHeader, + portainerAgentTargetHeader, + setPortainerAgentTargetHeader, + setPortainerAgentManagerOperation, + portainerAgentManagerOperation, + resetAgentHeaders, + }; +} diff --git a/app/portainer/services/index.ts b/app/portainer/services/index.ts new file mode 100644 index 0000000..1d05092 --- /dev/null +++ b/app/portainer/services/index.ts @@ -0,0 +1,14 @@ +import angular from 'angular'; + +import { apiServicesModule } from './api'; +import { Notifications } from './notifications'; +import { HttpRequestHelperAngular } from './http-request.helper'; +import { EndpointProvider } from './endpointProvider'; +import { AngularToReact } from './angularToReact'; + +export default angular + .module('portainer.app.services', [apiServicesModule]) + .factory('Notifications', Notifications) + .factory('EndpointProvider', EndpointProvider) + .factory('HttpRequestHelper', HttpRequestHelperAngular) + .factory('AngularToReact', AngularToReact).name; diff --git a/app/portainer/services/localStorage.js b/app/portainer/services/localStorage.js new file mode 100644 index 0000000..038c752 --- /dev/null +++ b/app/portainer/services/localStorage.js @@ -0,0 +1,132 @@ +angular.module('portainer.app').factory('LocalStorage', [ + 'localStorageService', + function LocalStorageFactory(localStorageService) { + return { + storeLoginStateUUID: function (uuid) { + localStorageService.set('LOGIN_STATE_UUID', uuid); + }, + getLoginStateUUID: function () { + return localStorageService.get('LOGIN_STATE_UUID'); + }, + storeEndpointState: function (state) { + localStorageService.set('ENDPOINT_STATE', state); + }, + getEndpointState: function () { + return localStorageService.get('ENDPOINT_STATE'); + }, + cleanEndpointState() { + localStorageService.remove('ENDPOINT_STATE'); + }, + storeApplicationState: function (state) { + localStorageService.set('APPLICATION_STATE', state); + }, + getApplicationState: function () { + return localStorageService.get('APPLICATION_STATE'); + }, + storeUIState: function (state) { + localStorageService.set('UI_STATE', state); + }, + getUIState: function () { + return localStorageService.get('UI_STATE'); + }, + getUserId() { + return localStorageService.get('USER_ID'); + }, + storeUserId: function (userId) { + localStorageService.set('USER_ID', userId); + }, + deleteUserId: function () { + localStorageService.remove('USER_ID'); + }, + storePaginationLimit: function (key, count) { + localStorageService.set('datatable_pagination_' + key, count); + }, + getPaginationLimit: function (key) { + return localStorageService.get('datatable_pagination_' + key); + }, + getDataTableOrder: function (key) { + return localStorageService.get('datatable_order_' + key); + }, + storeDataTableOrder: function (key, data) { + localStorageService.set('datatable_order_' + key, data); + }, + getDataTableTextFilters: function (key) { + return localStorageService.get('datatable_text_filter_' + key); + }, + storeDataTableTextFilters: function (key, data) { + localStorageService.set('datatable_text_filter_' + key, data); + }, + getDataTableFilters: function (key) { + return localStorageService.get('datatable_filters_' + key); + }, + storeDataTableFilters: function (key, data) { + localStorageService.set('datatable_filters_' + key, data); + }, + getDataTableSettings: function (key) { + return localStorageService.get('datatable_settings_' + key); + }, + storeDataTableSettings: function (key, data) { + localStorageService.set('datatable_settings_' + key, data); + }, + getDataTableExpandedItems: function (key) { + return localStorageService.get('datatable_expandeditems_' + key); + }, + storeDataTableExpandedItems: function (key, data) { + localStorageService.set('datatable_expandeditems_' + key, data); + }, + getDataTableSelectedItems: function (key) { + return localStorageService.get('datatable_selecteditems_' + key); + }, + storeDataTableSelectedItems: function (key, data) { + localStorageService.set('datatable_selecteditems_' + key, data); + }, + storeSwarmVisualizerSettings: function (key, data) { + localStorageService.set('swarmvisualizer_' + key, data); + }, + getSwarmVisualizerSettings: function (key) { + return localStorageService.get('swarmvisualizer_' + key); + }, + storeColumnVisibilitySettings: function (key, data) { + localStorageService.set('col_visibility_' + key, data); + }, + getColumnVisibilitySettings: function (key) { + return localStorageService.get('col_visibility_' + key); + }, + storeJobImage: function (data) { + localStorageService.set('job_image', data); + }, + getJobImage: function () { + return localStorageService.get('job_image'); + }, + storeActiveTab: function (key, index) { + return localStorageService.set('active_tab_' + key, index); + }, + getActiveTab: function (key) { + const activeTab = localStorageService.get('active_tab_' + key); + return activeTab === null ? 0 : activeTab; + }, + storeNamespaceFilter: function (environmentId, userID, data) { + // store one filter per environment + localStorageService.set(`kubernetes_namespace_filter_${environmentId}_${userID}`, data); + }, + getNamespaceFilter: function (environmentId, userID) { + return localStorageService.get(`kubernetes_namespace_filter_${environmentId}_${userID}`); + }, + storeLogoutReason: (reason) => localStorageService.set('logout_reason', reason), + getLogoutReason: () => localStorageService.get('logout_reason'), + cleanLogoutReason: () => localStorageService.remove('logout_reason'), + clean: function () { + localStorageService.clearAll(); + }, + cleanAuthData() { + localStorageService.remove('USER_ID', 'APPLICATION_STATE', 'LOGIN_STATE_UUID', 'ALLOWED_NAMESPACES'); + }, + storeKubernetesSummaryToggle(value) { + localStorageService.set('kubernetes_summary_expanded', value); + }, + getKubernetesSummaryToggle() { + return localStorageService.get('kubernetes_summary_expanded'); + }, + }; + }, +]); diff --git a/app/portainer/services/nameValidator.js b/app/portainer/services/nameValidator.js new file mode 100644 index 0000000..bf3ca58 --- /dev/null +++ b/app/portainer/services/nameValidator.js @@ -0,0 +1,19 @@ +import angular from 'angular'; +import { getEnvironments } from '@/react/portainer/environments/environment.service'; + +angular.module('portainer.app').factory('NameValidator', NameValidatorFactory); +/* @ngInject */ +function NameValidatorFactory(Notifications) { + return { + validateEnvironmentName, + }; + + async function validateEnvironmentName(name) { + try { + const endpoints = await getEnvironments({ limit: 1, name }); + return endpoints.value.length > 0; + } catch (err) { + Notifications.error('Failure', err, 'Unable to retrieve environment details'); + } + } +} diff --git a/app/portainer/services/notifications.test.ts b/app/portainer/services/notifications.test.ts new file mode 100644 index 0000000..01d85ae --- /dev/null +++ b/app/portainer/services/notifications.test.ts @@ -0,0 +1,60 @@ +import toastr from 'toastr'; + +import { notifyError, notifySuccess, notifyWarning } from './notifications'; + +vi.spyOn(console, 'error').mockImplementation(() => vi.fn()); + +afterEach(() => { + vi.resetAllMocks(); +}); + +it('calling success should show success message', () => { + const title = 'title'; + const text = 'text'; + + notifySuccess(title, text); + + expect(toastr.success).toHaveBeenCalledWith(text, title); +}); + +it('calling error with Error should show error message', () => { + const consoleErrorFn = vi + .spyOn(console, 'error') + .mockImplementation(() => vi.fn()); + const title = 'title'; + const errorMessage = 'message'; + const fallback = 'fallback'; + + notifyError(title, new Error(errorMessage), fallback); + + expect(toastr.error).toHaveBeenCalledWith( + errorMessage, + title, + expect.anything() + ); + + consoleErrorFn.mockRestore(); +}); + +it('calling error without Error should show fallback message', () => { + const consoleErrorFn = vi + .spyOn(console, 'error') + .mockImplementation(() => vi.fn()); + const title = 'title'; + + const fallback = 'fallback'; + + notifyError(title, undefined, fallback); + + expect(toastr.error).toHaveBeenCalledWith(fallback, title, expect.anything()); + consoleErrorFn.mockRestore(); +}); + +it('calling warning should show warning message', () => { + const title = 'title'; + const text = 'text'; + + notifyWarning(title, text); + + expect(toastr.warning).toHaveBeenCalledWith(text, title, expect.anything()); +}); diff --git a/app/portainer/services/notifications.ts b/app/portainer/services/notifications.ts new file mode 100644 index 0000000..77d6e92 --- /dev/null +++ b/app/portainer/services/notifications.ts @@ -0,0 +1,115 @@ +import _ from 'lodash'; +import toastr from 'toastr'; +import sanitize from 'sanitize-html'; +import { v4 as uuid } from 'uuid'; + +import { get as localStorageGet } from '@/react/hooks/useLocalStorage'; +import { notificationsStore } from '@/react/portainer/notifications/notifications-store'; +import { ToastNotification } from '@/react/portainer/notifications/types'; + +const { addNotification } = notificationsStore.getState(); + +toastr.options = { + timeOut: 3000, + closeButton: true, + progressBar: true, + tapToDismiss: false, + escapeHtml: true, + // custom button, using the lucide icon x.svg inside + closeHtml: ``, +}; + +export function notifySuccess(title: string, text: string) { + saveNotification(title, text, 'success'); + toastr.success(sanitize(_.escape(text)), sanitize(_.escape(title))); +} + +export function notifyWarning(title: string, text: string) { + saveNotification(title, text, 'warning'); + toastr.warning(sanitize(_.escape(text)), sanitize(title), { timeOut: 6000 }); +} + +export function notifyError(title: string, e?: unknown, fallbackText = '') { + const msg = pickErrorMsg(e) || fallbackText; + saveNotification(title, msg, 'error'); + + if (!_.get(e, 'resource.password')) { + // eslint-disable-next-line no-console + console.error(e); + } + + if (msg !== 'Invalid JWT token') { + toastr.error(sanitize(_.escape(msg)), sanitize(title), { timeOut: 6000 }); + } +} + +export const success = notifySuccess; +export const error = notifyError; +export const warning = notifyWarning; + +/* @ngInject */ +export function Notifications() { + return { + success: notifySuccess, + warning: notifyWarning, + error: notifyError, + }; +} + +function pickErrorMsg(e?: unknown) { + if (!e) { + return ''; + } + + const props = [ + 'err.data.details', + 'err.data.message', + 'data.details', + 'data.message', + 'data.content', + 'data.error', + 'message', + 'err.data[0].message', + 'err.data.err', + 'data.err', + 'msg', + ]; + + let msg = ''; + + props.forEach((prop) => { + const val = _.get(e, prop); + if (typeof val === 'string') { + msg = msg || val; + } + }); + + return msg; +} + +function saveNotification(title: string, text: string, type: string) { + const notif: ToastNotification = { + id: uuid(), + title, + details: text, + type, + timeStamp: new Date(), + }; + const userId = localStorageGet('USER_ID', ''); + if (userId !== '') { + addNotification(userId, notif); + } +} diff --git a/app/portainer/services/pagination.js b/app/portainer/services/pagination.js new file mode 100644 index 0000000..02204bd --- /dev/null +++ b/app/portainer/services/pagination.js @@ -0,0 +1,25 @@ +angular.module('portainer.app').factory('PaginationService', [ + 'LocalStorage', + 'PAGINATION_MAX_ITEMS', + function PaginationServiceFactory(LocalStorage, PAGINATION_MAX_ITEMS) { + 'use strict'; + + var service = {}; + + service.getPaginationLimit = function (key) { + var paginationLimit = PAGINATION_MAX_ITEMS; + + var storedLimit = LocalStorage.getPaginationLimit(key); + if (storedLimit !== null) { + paginationLimit = storedLimit; + } + return '' + paginationLimit; + }; + + service.setPaginationLimit = function (key, limit) { + LocalStorage.storePaginationLimit(key, limit); + }; + + return service; + }, +]); diff --git a/app/portainer/services/registryGitlabService.js b/app/portainer/services/registryGitlabService.js new file mode 100644 index 0000000..f6fb306 --- /dev/null +++ b/app/portainer/services/registryGitlabService.js @@ -0,0 +1,88 @@ +import _ from 'lodash-es'; +import { RegistryGitlabProject } from '@/react/portainer/registries/types/gitlabProject'; +import { RegistryRepositoryGitlabViewModel } from '../models/registryRepository'; + +angular.module('portainer.app').factory('RegistryGitlabService', [ + '$async', + 'Gitlab', + function RegistryGitlabServiceFactory($async, Gitlab) { + 'use strict'; + var service = {}; + + /** + * PROJECTS + */ + + async function _getProjectsPage(env, params, projects) { + const response = await Gitlab(env).projects(params).$promise; + projects = _.concat(projects, response.data); + if (response.next) { + params.page = response.next; + projects = await _getProjectsPage(env, params, projects); + } + return projects; + } + + async function projectsAsync(url, token) { + try { + const data = await _getProjectsPage({ url: url, token: token }, { page: 1 }, []); + return _.map(data, (project) => new RegistryGitlabProject(project)); + } catch (error) { + throw { msg: 'Unable to retrieve projects', err: error }; + } + } + + /** + * END PROJECTS + */ + + /** + * REPOSITORIES + */ + + async function _getRepositoriesPage(params, repositories) { + const response = await Gitlab().repositories(params).$promise; + repositories = _.concat(repositories, response.data); + if (response.next) { + params.page = response.next; + repositories = await _getRepositoriesPage(params, repositories); + } + return repositories; + } + + async function repositoriesAsync(registry, endpointId) { + try { + const params = { + id: registry.Id, + endpointId: endpointId, + projectId: registry.Gitlab.ProjectId, + page: 1, + }; + const data = await _getRepositoriesPage(params, []); + return _.map(data, (r) => new RegistryRepositoryGitlabViewModel(r)); + } catch (error) { + throw { msg: 'Unable to retrieve repositories', err: error }; + } + } + + /** + * END REPOSITORIES + */ + + /** + * SERVICE FUNCTIONS DECLARATION + */ + + function projects(url, token) { + return $async(projectsAsync, url, token); + } + + function repositories(registry, endpointId) { + return $async(repositoriesAsync, registry, endpointId); + } + + service.projects = projects; + service.repositories = repositories; + return service; + }, +]); diff --git a/app/portainer/services/registryModalService.js b/app/portainer/services/registryModalService.js new file mode 100644 index 0000000..de4caa8 --- /dev/null +++ b/app/portainer/services/registryModalService.js @@ -0,0 +1,22 @@ +import _ from 'lodash'; +import { selectRegistry } from '@/react/docker/images/ItemView/RegistrySelectPrompt'; + +angular.module('portainer.app').factory('RegistryModalService', RegistryModalService); + +function RegistryModalService(RegistryService) { + const service = {}; + + service.registryModal = async function (repository, registries) { + const registryModel = RegistryService.retrievePorRegistryModelFromRepositoryWithRegistries(repository, registries); + const defaultValue = _.get(registryModel, 'Registry.Id', 0); + + const registryId = await selectRegistry(registries, defaultValue); + if (registryId === undefined) { + return null; + } + + return RegistryService.retrievePorRegistryModelFromRepositoryWithRegistries(repository, registries, registryId); + }; + + return service; +} diff --git a/app/portainer/services/session-storage.js b/app/portainer/services/session-storage.js new file mode 100644 index 0000000..3a5c77d --- /dev/null +++ b/app/portainer/services/session-storage.js @@ -0,0 +1,31 @@ +/** + * clears the sessionStorage + */ +export function clear() { + sessionStorage.clear(); +} + +/** + * stores `value` as string in `sessionStorage[key]` + * + * @param {string} key the key to store value at + * @param {any} value the value to store - will be stringified using JSON.stringify + * + */ +export function save(key, value) { + sessionStorage.setItem(key, JSON.stringify(value)); +} + +/** + * get parses the value stored in sessionStorage[key], if it's not available returns undefined + * + * @param {string} key + */ +export function get(key) { + try { + const value = sessionStorage.getItem(key); + return JSON.parse(value); + } catch (e) { + return; + } +} diff --git a/app/portainer/services/stateManager.js b/app/portainer/services/stateManager.js new file mode 100644 index 0000000..5bf6ef9 --- /dev/null +++ b/app/portainer/services/stateManager.js @@ -0,0 +1,210 @@ +import moment from 'moment'; + +angular.module('portainer.app').factory('StateManager', StateManagerFactory); + +/* @ngInject */ +function StateManagerFactory($async, $q, SystemService, InfoHelper, LocalStorage, SettingsService, StatusService, APPLICATION_CACHE_VALIDITY, AgentPingService, EndpointProvider) { + var manager = {}; + + var state = { + loading: true, + application: {}, + endpoint: {}, + UI: { + dismissedInfoPanels: {}, + dismissedInfoHash: '', + timesPasswordChangeSkipped: {}, + dismissedUpdateVersion: '', + }, + }; + + manager.dismissInformationPanel = function (id) { + state.UI.dismissedInfoPanels[id] = true; + LocalStorage.storeUIState(state.UI); + }; + + manager.dismissImportantInformation = function (hash) { + state.UI.dismissedInfoHash = hash; + LocalStorage.storeUIState(state.UI); + }; + + manager.setRequiredPasswordLength = function (length) { + state.UI.requiredPasswordLength = length; + LocalStorage.storeUIState(state.UI); + }; + + manager.setPasswordChangeSkipped = function (userID) { + state.UI.instanceId = state.UI.instanceId || state.application.instanceId; + state.UI.timesPasswordChangeSkipped[userID] = state.UI.timesPasswordChangeSkipped[userID] + 1 || 1; + LocalStorage.storeUIState(state.UI); + }; + + manager.resetPasswordChangeSkips = function (userID) { + if (!state.UI.timesPasswordChangeSkipped) { + return; + } + if (state.UI.timesPasswordChangeSkipped[userID]) state.UI.timesPasswordChangeSkipped[userID] = 0; + LocalStorage.storeUIState(state.UI); + }; + + manager.clearPasswordChangeSkips = function () { + state.UI.timesPasswordChangeSkipped = {}; + LocalStorage.storeUIState(state.UI); + }; + + manager.getState = function () { + return state; + }; + + manager.clean = function () { + manager.cleanEndpoint(); + state.application = {}; + }; + + manager.cleanEndpoint = function () { + state.endpoint = {}; + EndpointProvider.clean(); + LocalStorage.cleanEndpointState(); + }; + + manager.updateLogo = function (logoURL) { + state.application.logo = logoURL; + LocalStorage.storeApplicationState(state.application); + }; + + manager.updateSnapshotInterval = function (interval) { + state.application.snapshotInterval = interval; + LocalStorage.storeApplicationState(state.application); + }; + + manager.updateEnableEdgeComputeFeatures = function updateEnableEdgeComputeFeatures(enableEdgeComputeFeatures) { + state.application.enableEdgeComputeFeatures = enableEdgeComputeFeatures; + LocalStorage.storeApplicationState(state.application); + }; + + function assignStateFromStatusAndSettings(status, settings) { + state.application.version = status.Version; + state.application.edition = status.Edition; + state.application.instanceId = status.InstanceID; + + state.application.logo = settings.LogoURL; + state.application.snapshotInterval = settings.SnapshotInterval; + state.application.enableEdgeComputeFeatures = settings.EnableEdgeComputeFeatures; + state.application.validity = moment().unix(); + } + + function loadApplicationState() { + var deferred = $q.defer(); + + $q.all({ + settings: SettingsService.publicSettings(), + status: StatusService.status(), + }) + .then(function success(data) { + var status = data.status; + var settings = data.settings; + assignStateFromStatusAndSettings(status, settings); + LocalStorage.storeApplicationState(state.application); + deferred.resolve(state); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve server settings and status', err: err }); + }); + + return deferred.promise; + } + + manager.initialize = initialize; + async function initialize() { + return $async(async () => { + const endpointState = LocalStorage.getEndpointState(); + if (endpointState) { + state.endpoint = endpointState; + } + + const applicationState = LocalStorage.getApplicationState(); + if (isAppStateValid(applicationState)) { + state.application = applicationState; + } else { + await loadApplicationState(); + } + + const UIState = LocalStorage.getUIState(); + if (UIState) { + state.UI = UIState; + if (state.UI.instanceId && state.UI.instanceId !== state.application.instanceId) { + state.UI.instanceId = state.application.instanceId; + state.UI.timesPasswordChangeSkipped = {}; + LocalStorage.storeUIState(state.UI); + } + } + + state.loading = false; + return state; + }); + } + + function isAppStateValid(appState) { + if (!appState || !appState.validity) { + return false; + } + const now = moment().unix(); + const cacheValidity = now - appState.validity; + return cacheValidity < APPLICATION_CACHE_VALIDITY; + } + + manager.updateEndpointState = function (endpoint) { + var deferred = $q.defer(); + + if (endpoint.Type === 3) { + state.endpoint.name = endpoint.Name; + state.endpoint.mode = { provider: 'AZURE' }; + LocalStorage.storeEndpointState(state.endpoint); + deferred.resolve(); + return deferred.promise; + } else if (endpoint.Type === 5 || endpoint.Type === 6 || endpoint.Type === 7) { + state.endpoint.name = endpoint.Name; + state.endpoint.mode = { provider: 'KUBERNETES' }; + LocalStorage.storeEndpointState(state.endpoint); + deferred.resolve(); + return deferred.promise; + } + + $q.all({ + version: SystemService.version(), + info: SystemService.info(), + }) + .then(function success(data) { + var endpointMode = InfoHelper.determineEndpointMode(data.info, endpoint.Type); + var endpointAPIVersion = parseFloat(data.version.ApiVersion); + state.endpoint.mode = endpointMode; + state.endpoint.name = endpoint.Name; + state.endpoint.type = endpoint.Type; + state.endpoint.apiVersion = endpointAPIVersion; + + if (endpointMode.agentProxy && endpoint.Status === 1) { + return AgentPingService.ping(endpoint.Id).then(function onPingSuccess(data) { + state.endpoint.agentApiVersion = data.version; + }); + } + }) + .then(function () { + LocalStorage.storeEndpointState(state.endpoint); + deferred.resolve(); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to connect to the Docker environment', err: err }); + }) + .finally(function final() { + state.loading = false; + }); + + return deferred.promise; + }; + + manager.getAgentApiVersion = function getAgentApiVersion() { + return state.endpoint.agentApiVersion; + }; + + return manager; +} diff --git a/app/portainer/services/terminal-window.ts b/app/portainer/services/terminal-window.ts new file mode 100644 index 0000000..ffbaffb --- /dev/null +++ b/app/portainer/services/terminal-window.ts @@ -0,0 +1,20 @@ +const terminalHeight = 495; + +export function terminalClose() { + update('100%', 'initial'); +} + +export function terminalResize() { + const contentWrapperHeight = window.innerHeight; + const newContentWrapperHeight = contentWrapperHeight - terminalHeight; + update(`${newContentWrapperHeight}px`, 'auto'); +} + +function update(height: string, overflowY: string) { + const pageWrapper = document.getElementById('pageWrapper-wrapper'); + + if (pageWrapper) { + pageWrapper.style.height = height; + pageWrapper.style.overflowY = overflowY; + } +} diff --git a/app/portainer/services/themeManager.js b/app/portainer/services/themeManager.js new file mode 100644 index 0000000..e84ab35 --- /dev/null +++ b/app/portainer/services/themeManager.js @@ -0,0 +1,24 @@ +import { applyTheme } from '@/react/portainer/services/applyTheme'; + +angular.module('portainer.app').service('ThemeManager', ThemeManager); + +// @deprecated use applyTheme instead +export function ThemeManager() { + return { + setTheme, + autoTheme, + defaultTheme, + }; + + function setTheme(theme) { + applyTheme(theme); + } + + function autoTheme() { + applyTheme('auto'); + } + + function defaultTheme() { + document.documentElement.removeAttribute('theme'); + } +} diff --git a/app/portainer/services/types.ts b/app/portainer/services/types.ts new file mode 100644 index 0000000..12978fc --- /dev/null +++ b/app/portainer/services/types.ts @@ -0,0 +1,24 @@ +import { Environment } from '@/react/portainer/environments/types'; + +export interface StateManager { + updateEndpointState(endpoint: Environment): Promise; + updateLogo(logo: string): void; + updateSnapshotInterval(interval: string): void; +} + +export interface IAuthenticationService { + getUserDetails(): { ID: number }; + isAuthenticated(): boolean; + isAdmin(noEnvScope?: boolean): boolean; + isPureAdmin(): boolean; + hasAuthorizations(authorizations: string[]): boolean; + + init(): Promise; + // OAuthLogin, + // login, + // logout, + + // redirectIfUnauthorized, +} + +export type AsyncService = (fn: () => Promise) => Promise; diff --git a/app/portainer/settings/authentication/auth-method-constants.js b/app/portainer/settings/authentication/auth-method-constants.js new file mode 100644 index 0000000..dc9bf0a --- /dev/null +++ b/app/portainer/settings/authentication/auth-method-constants.js @@ -0,0 +1,11 @@ +export const authenticationMethodTypesMap = { + INTERNAL: 1, + LDAP: 2, + OAUTH: 3, +}; + +export const authenticationMethodTypesLabels = { + [authenticationMethodTypesMap.INTERNAL]: 'Internal', + [authenticationMethodTypesMap.LDAP]: 'LDAP', + [authenticationMethodTypesMap.OAUTH]: 'OAuth', +}; diff --git a/app/portainer/settings/authentication/auth-type-constants.js b/app/portainer/settings/authentication/auth-type-constants.js new file mode 100644 index 0000000..84de1d9 --- /dev/null +++ b/app/portainer/settings/authentication/auth-type-constants.js @@ -0,0 +1,11 @@ +export const authenticationActivityTypesMap = { + AuthSuccess: 1, + AuthFailure: 2, + Logout: 3, +}; + +export const authenticationActivityTypesLabels = { + [authenticationActivityTypesMap.AuthSuccess]: 'Authentication success', + [authenticationActivityTypesMap.AuthFailure]: 'Authentication failure', + [authenticationActivityTypesMap.Logout]: 'Logout', +}; diff --git a/app/portainer/settings/authentication/index.js b/app/portainer/settings/authentication/index.js new file mode 100644 index 0000000..fff63a0 --- /dev/null +++ b/app/portainer/settings/authentication/index.js @@ -0,0 +1,8 @@ +import angular from 'angular'; +import ldapModule from './ldap'; +import { saveAuthSettingsButton } from './save-auth-settings-button'; + +export default angular + .module('portainer.settings.authentication', [ldapModule]) + + .component('saveAuthSettingsButton', saveAuthSettingsButton).name; diff --git a/app/portainer/settings/authentication/ldap/ad-settings/ad-settings.controller.js b/app/portainer/settings/authentication/ldap/ad-settings/ad-settings.controller.js new file mode 100644 index 0000000..a43a07f --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ad-settings/ad-settings.controller.js @@ -0,0 +1,77 @@ +import _ from 'lodash-es'; + +import { FeatureId } from '@/react/portainer/feature-flags/enums'; +import { isLimitedToBE } from '@/react/portainer/feature-flags/feature-flags.service'; + +export default class AdSettingsController { + /* @ngInject */ + constructor(LDAPService, $scope) { + this.LDAPService = LDAPService; + this.$scope = $scope; + + this.domainSuffix = ''; + this.limitedFeatureId = FeatureId.HIDE_INTERNAL_AUTH; + this.onTlscaCertChange = this.onTlscaCertChange.bind(this); + this.searchUsers = this.searchUsers.bind(this); + this.searchGroups = this.searchGroups.bind(this); + this.parseDomainName = this.parseDomainName.bind(this); + this.onAccountChange = this.onAccountChange.bind(this); + this.onAutoUserProvisionChange = this.onAutoUserProvisionChange.bind(this); + this.onAutoUserProvisionChange = this.onAutoUserProvisionChange.bind(this); + } + + onAutoUserProvisionChange(value) { + this.$scope.$evalAsync(() => { + this.settings.AutoCreateUsers = value; + }); + } + + parseDomainName(account) { + this.domainName = ''; + + if (!account || !account.includes('@')) { + return; + } + + const [, domainName] = account.split('@'); + if (!domainName) { + return; + } + + const parts = _.compact(domainName.split('.')); + this.domainSuffix = parts.map((part) => `dc=${part}`).join(','); + } + + onAccountChange(account) { + this.parseDomainName(account); + } + + searchUsers() { + return this.LDAPService.users(this.settings); + } + + searchGroups() { + return this.LDAPService.groups(this.settings); + } + + onTlscaCertChange(file) { + this.tlscaCert = file; + } + + addLDAPUrl() { + this.settings.URLs.push(''); + } + + removeLDAPUrl(index) { + this.settings.URLs.splice(index, 1); + } + + isSaveSettingButtonDisabled() { + return isLimitedToBE(this.limitedFeatureId) || !this.isLdapFormValid(); + } + + $onInit() { + this.tlscaCert = this.settings.TLSCACert; + this.parseDomainName(this.settings.ReaderDN); + } +} diff --git a/app/portainer/settings/authentication/ldap/ad-settings/ad-settings.html b/app/portainer/settings/authentication/ldap/ad-settings/ad-settings.html new file mode 100644 index 0000000..7a1207d --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ad-settings/ad-settings.html @@ -0,0 +1,174 @@ + +

+ +
+ + +
+
Information
+
+ When using Microsoft AD authentication, Portainer will delegate user authentication to the Domain Controller(s) configured below; if there is no connectivity, Portainer + will fallback to internal authentication. +
+
+ +
AD configuration
+ +
+
+

+ + You can configure multiple AD Controllers for authentication fallback. Make sure all servers are using the same configuration (i.e. if TLS is enabled, they should all + use the same certificates). +

+
+
+ +
+ +
+
+ + +
+
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ + + + + + + + + + + + + + + +
+
+ diff --git a/app/portainer/settings/authentication/ldap/ad-settings/index.js b/app/portainer/settings/authentication/ldap/ad-settings/index.js new file mode 100644 index 0000000..5308496 --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ad-settings/index.js @@ -0,0 +1,15 @@ +import controller from './ad-settings.controller'; + +export const adSettings = { + templateUrl: './ad-settings.html', + controller, + bindings: { + settings: '=', + tlscaCert: '=', + state: '=', + connectivityCheck: '<', + onSaveSettings: '<', + saveButtonState: '<', + isLdapFormValid: '&?', + }, +}; diff --git a/app/portainer/settings/authentication/ldap/index.js b/app/portainer/settings/authentication/ldap/index.js new file mode 100644 index 0000000..1cd8587 --- /dev/null +++ b/app/portainer/settings/authentication/ldap/index.js @@ -0,0 +1,34 @@ +import angular from 'angular'; + +import { adSettings } from './ad-settings'; +import { ldapSettings } from './ldap-settings'; +import { ldapSettingsCustom } from './ldap-settings-custom'; +import { ldapSettingsOpenLdap } from './ldap-settings-openldap'; + +import { ldapConnectivityCheck } from './ldap-connectivity-check'; +import { ldapGroupSearch } from './ldap-group-search'; +import { ldapGroupSearchItem } from './ldap-group-search-item'; +import { ldapUserSearch } from './ldap-user-search'; +import { ldapUserSearchItem } from './ldap-user-search-item'; +import { ldapCustomGroupSearch } from './ldap-custom-group-search'; +import { ldapSettingsSecurity } from './ldap-settings-security'; +import { ldapCustomUserSearch } from './ldap-custom-user-search'; +import { LDAPService } from './ldap.service'; +import { LDAP } from './ldap.rest'; + +export default angular + .module('portainer.settings.authentication.ldap', []) + .service('LDAPService', LDAPService) + .service('LDAP', LDAP) + .component('ldapConnectivityCheck', ldapConnectivityCheck) + .component('ldapSettings', ldapSettings) + .component('adSettings', adSettings) + .component('ldapGroupSearch', ldapGroupSearch) + .component('ldapGroupSearchItem', ldapGroupSearchItem) + .component('ldapUserSearch', ldapUserSearch) + .component('ldapUserSearchItem', ldapUserSearchItem) + .component('ldapSettingsCustom', ldapSettingsCustom) + .component('ldapCustomGroupSearch', ldapCustomGroupSearch) + .component('ldapSettingsOpenLdap', ldapSettingsOpenLdap) + .component('ldapSettingsSecurity', ldapSettingsSecurity) + .component('ldapCustomUserSearch', ldapCustomUserSearch).name; diff --git a/app/portainer/settings/authentication/ldap/ldap-connectivity-check/index.js b/app/portainer/settings/authentication/ldap/ldap-connectivity-check/index.js new file mode 100644 index 0000000..93784cb --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap-connectivity-check/index.js @@ -0,0 +1,9 @@ +export const ldapConnectivityCheck = { + templateUrl: './ldap-connectivity-check.html', + bindings: { + settings: '<', + state: '<', + connectivityCheck: '<', + limitedFeatureId: '<', + }, +}; diff --git a/app/portainer/settings/authentication/ldap/ldap-connectivity-check/ldap-connectivity-check.html b/app/portainer/settings/authentication/ldap/ldap-connectivity-check/ldap-connectivity-check.html new file mode 100644 index 0000000..9ac8de8 --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap-connectivity-check/ldap-connectivity-check.html @@ -0,0 +1,21 @@ +
+ +
+ +
+
diff --git a/app/portainer/settings/authentication/ldap/ldap-custom-group-search/index.js b/app/portainer/settings/authentication/ldap/ldap-custom-group-search/index.js new file mode 100644 index 0000000..1f51f32 --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap-custom-group-search/index.js @@ -0,0 +1,11 @@ +import controller from './ldap-custom-group-search.controller'; + +export const ldapCustomGroupSearch = { + templateUrl: './ldap-custom-group-search.html', + controller, + bindings: { + settings: '=', + onSearchClick: '<', + limitedFeatureId: '<', + }, +}; diff --git a/app/portainer/settings/authentication/ldap/ldap-custom-group-search/ldap-custom-group-search.controller.js b/app/portainer/settings/authentication/ldap/ldap-custom-group-search/ldap-custom-group-search.controller.js new file mode 100644 index 0000000..4c746f5 --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap-custom-group-search/ldap-custom-group-search.controller.js @@ -0,0 +1,34 @@ +export default class LdapCustomGroupSearchController { + /* @ngInject */ + constructor($async, Notifications) { + Object.assign(this, { $async, Notifications }); + + this.groups = null; + this.showTable = false; + + this.onRemoveClick = this.onRemoveClick.bind(this); + this.onAddClick = this.onAddClick.bind(this); + this.search = this.search.bind(this); + } + + onAddClick() { + this.settings.push({ GroupBaseDN: '', GroupAttribute: '', GroupFilter: '' }); + } + + onRemoveClick(index) { + this.settings.splice(index, 1); + } + + search() { + return this.$async(async () => { + try { + this.groups = null; + this.showTable = true; + this.groups = await this.onSearchClick(); + } catch (error) { + this.showTable = false; + this.Notifications.error('Failure', error, 'Failed to search users'); + } + }); + } +} diff --git a/app/portainer/settings/authentication/ldap/ldap-custom-group-search/ldap-custom-group-search.html b/app/portainer/settings/authentication/ldap/ldap-custom-group-search/ldap-custom-group-search.html new file mode 100644 index 0000000..367f2a1 --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap-custom-group-search/ldap-custom-group-search.html @@ -0,0 +1,86 @@ +
+
Group search configurations
+ + + +
+ Extra search configuration +
+ +
+ +
+ +
+ + +
+ +
+
+
+ +
+ + +
+
+
+ + + Users removal synchronize between groups and teams only available in + business edition. + + +
+
+
+ +
+
+ +
+
+ +
+
+ + +
diff --git a/app/portainer/settings/authentication/ldap/ldap-custom-user-search/index.js b/app/portainer/settings/authentication/ldap/ldap-custom-user-search/index.js new file mode 100644 index 0000000..06fdedd --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap-custom-user-search/index.js @@ -0,0 +1,11 @@ +import controller from './ldap-custom-user-search.controller'; + +export const ldapCustomUserSearch = { + templateUrl: './ldap-custom-user-search.html', + controller, + bindings: { + settings: '=', + onSearchClick: '<', + limitedFeatureId: '<', + }, +}; diff --git a/app/portainer/settings/authentication/ldap/ldap-custom-user-search/ldap-custom-user-search.controller.js b/app/portainer/settings/authentication/ldap/ldap-custom-user-search/ldap-custom-user-search.controller.js new file mode 100644 index 0000000..e672e9e --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap-custom-user-search/ldap-custom-user-search.controller.js @@ -0,0 +1,33 @@ +export default class LdapCustomUserSearchController { + /* @ngInject */ + constructor($async, Notifications) { + Object.assign(this, { $async, Notifications }); + + this.users = null; + + this.onRemoveClick = this.onRemoveClick.bind(this); + this.onAddClick = this.onAddClick.bind(this); + this.search = this.search.bind(this); + } + + onAddClick() { + this.settings.push({ BaseDN: '', UserNameAttribute: '', Filter: '' }); + } + + onRemoveClick(index) { + this.settings.splice(index, 1); + } + + search() { + return this.$async(async () => { + try { + this.users = null; + this.showTable = true; + this.users = await this.onSearchClick(); + } catch (error) { + this.showTable = false; + this.Notifications.error('Failure', error, 'Failed to search users'); + } + }); + } +} diff --git a/app/portainer/settings/authentication/ldap/ldap-custom-user-search/ldap-custom-user-search.html b/app/portainer/settings/authentication/ldap/ldap-custom-user-search/ldap-custom-user-search.html new file mode 100644 index 0000000..319c345 --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap-custom-user-search/ldap-custom-user-search.html @@ -0,0 +1,68 @@ +
+
User search configurations
+ + + +
+ Extra search configuration +
+ +
+ +
+ +
+ + +
+ +
+
+
+ +
+ + +
+
+
+
+ +
+
+ +
+
+ +
+
+ + +
diff --git a/app/portainer/settings/authentication/ldap/ldap-group-search-item/index.js b/app/portainer/settings/authentication/ldap/ldap-group-search-item/index.js new file mode 100644 index 0000000..62fcd5b --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap-group-search-item/index.js @@ -0,0 +1,15 @@ +import controller from './ldap-group-search-item.controller'; + +export const ldapGroupSearchItem = { + templateUrl: './ldap-group-search-item.html', + controller, + bindings: { + config: '=', + index: '<', + domainSuffix: '@', + baseFilter: '@', + + onRemoveClick: '<', + limitedFeatureId: '<', + }, +}; diff --git a/app/portainer/settings/authentication/ldap/ldap-group-search-item/ldap-group-search-item.controller.js b/app/portainer/settings/authentication/ldap/ldap-group-search-item/ldap-group-search-item.controller.js new file mode 100644 index 0000000..8483615 --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap-group-search-item/ldap-group-search-item.controller.js @@ -0,0 +1,53 @@ +export default class LdapSettingsAdGroupSearchItemController { + /* @ngInject */ + constructor(Notifications, $scope) { + Object.assign(this, { Notifications, $scope }); + + this.groups = []; + + this.onChangeBaseDN = this.onChangeBaseDN.bind(this); + } + + onChangeBaseDN(baseDN) { + return this.$scope.$evalAsync(() => { + this.config.GroupBaseDN = baseDN; + }); + } + + addGroup() { + this.groups.push({ type: 'ou', value: '' }); + } + + removeGroup($index) { + this.groups.splice($index, 1); + this.onGroupsChange(); + } + + onGroupsChange() { + const groupsFilter = this.groups.map(({ type, value }) => `(${type}=${value})`).join(''); + this.onFilterChange(groupsFilter ? `(&${this.baseFilter}(|${groupsFilter}))` : `${this.baseFilter}`); + } + + onFilterChange(filter) { + this.config.GroupFilter = filter; + } + + parseGroupFilter() { + const match = this.config.GroupFilter.match(/^\(&\(objectClass=(\w+)\)\(\|((\(\w+=.+\))+)\)\)$/); + if (!match) { + return; + } + + const [, , groupFilter = ''] = match; + + this.groups = groupFilter + .slice(1, -1) + .split(')(') + .map((str) => str.split('=')) + .map(([type, value]) => ({ type, value })); + } + + $onInit() { + this.parseGroupFilter(); + } +} diff --git a/app/portainer/settings/authentication/ldap/ldap-group-search-item/ldap-group-search-item.html b/app/portainer/settings/authentication/ldap/ldap-group-search-item/ldap-group-search-item.html new file mode 100644 index 0000000..c503a25 --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap-group-search-item/ldap-group-search-item.html @@ -0,0 +1,81 @@ +
+
+ Extra search configuration + +
+ + + +
+ +
{{ $ctrl.config.GroupBaseDN }}
+
+ +
+
+ + +
+
+
+
+
+ +
+
+ +
+
+ +
+
+
+
+
+ +
+ +
{{ $ctrl.config.GroupFilter }}
+
+
diff --git a/app/portainer/settings/authentication/ldap/ldap-group-search/index.js b/app/portainer/settings/authentication/ldap/ldap-group-search/index.js new file mode 100644 index 0000000..8b610dd --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap-group-search/index.js @@ -0,0 +1,14 @@ +import controller from './ldap-group-search.controller'; + +export const ldapGroupSearch = { + templateUrl: './ldap-group-search.html', + controller, + bindings: { + settings: '=', + domainSuffix: '@', + baseFilter: '@', + + onSearchClick: '<', + limitedFeatureId: '<', + }, +}; diff --git a/app/portainer/settings/authentication/ldap/ldap-group-search/ldap-group-search.controller.js b/app/portainer/settings/authentication/ldap/ldap-group-search/ldap-group-search.controller.js new file mode 100644 index 0000000..c431bb2 --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap-group-search/ldap-group-search.controller.js @@ -0,0 +1,36 @@ +import _ from 'lodash-es'; + +export default class LdapGroupSearchController { + /* @ngInject */ + constructor($async, Notifications) { + Object.assign(this, { $async, Notifications }); + + this.groups = null; + + this.onRemoveClick = this.onRemoveClick.bind(this); + this.onAddClick = this.onAddClick.bind(this); + this.search = this.search.bind(this); + } + + onAddClick() { + const lastSetting = _.last(this.settings); + this.settings.push({ GroupBaseDN: this.domainSuffix, GroupAttribute: lastSetting.GroupAttribute, GroupFilter: this.baseFilter }); + } + + onRemoveClick(index) { + this.settings.splice(index, 1); + } + + search() { + return this.$async(async () => { + try { + this.groups = null; + this.showTable = true; + this.groups = await this.onSearchClick(); + } catch (error) { + this.showTable = false; + this.Notifications.error('Failure', error, 'Failed to search users'); + } + }); + } +} diff --git a/app/portainer/settings/authentication/ldap/ldap-group-search/ldap-group-search.html b/app/portainer/settings/authentication/ldap/ldap-group-search/ldap-group-search.html new file mode 100644 index 0000000..a285857 --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap-group-search/ldap-group-search.html @@ -0,0 +1,30 @@ +
+
Group search configurations
+ +
+ +
+ +
+
+ +
+
+ +
+
+ + +
diff --git a/app/portainer/settings/authentication/ldap/ldap-settings-custom/index.js b/app/portainer/settings/authentication/ldap/ldap-settings-custom/index.js new file mode 100644 index 0000000..20aab44 --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap-settings-custom/index.js @@ -0,0 +1,19 @@ +import './ldap-settings-custom.css'; +import controller from './ldap-settings-custom.controller'; + +export const ldapSettingsCustom = { + templateUrl: './ldap-settings-custom.html', + controller, + bindings: { + settings: '=', + tlscaCert: '=', + state: '=', + onTlscaCertChange: '<', + connectivityCheck: '<', + onSearchUsersClick: '<', + onSearchGroupsClick: '<', + onSaveSettings: '<', + saveButtonState: '<', + saveButtonDisabled: '<', + }, +}; diff --git a/app/portainer/settings/authentication/ldap/ldap-settings-custom/ldap-settings-custom.controller.js b/app/portainer/settings/authentication/ldap/ldap-settings-custom/ldap-settings-custom.controller.js new file mode 100644 index 0000000..1098b75 --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap-settings-custom/ldap-settings-custom.controller.js @@ -0,0 +1,39 @@ +import { FeatureId } from '@/react/portainer/feature-flags/enums'; + +export default class LdapSettingsCustomController { + /* @ngInject */ + constructor($scope) { + this.$scope = $scope; + this.limitedFeatureId = FeatureId.EXTERNAL_AUTH_LDAP; + + this.onAdminGroupSearchSettingsChange = this.onAdminGroupSearchSettingsChange.bind(this); + this.onAutoPopulateChange = this.onAutoPopulateChange.bind(this); + this.onSelectedAdminGroupsChange = this.onSelectedAdminGroupsChange.bind(this); + } + + addLDAPUrl() { + this.settings.URLs.push(''); + } + + removeLDAPUrl(index) { + this.settings.URLs.splice(index, 1); + } + + onAdminGroupSearchSettingsChange(settings) { + this.$scope.$evalAsync(() => { + this.settings.AdminGroupSearchSettings = settings; + }); + } + + onAutoPopulateChange(value) { + this.$scope.$evalAsync(() => { + this.settings.AdminAutoPopulate = value; + }); + } + + onSelectedAdminGroupsChange(groups) { + this.$scope.$evalAsync(() => { + this.selectedAdminGroups = groups; + }); + } +} diff --git a/app/portainer/settings/authentication/ldap/ldap-settings-custom/ldap-settings-custom.css b/app/portainer/settings/authentication/ldap/ldap-settings-custom/ldap-settings-custom.css new file mode 100644 index 0000000..5bff756 --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap-settings-custom/ldap-settings-custom.css @@ -0,0 +1,3 @@ +label[for='anonymous_mode'].control-label { + padding-top: 0; +} diff --git a/app/portainer/settings/authentication/ldap/ldap-settings-custom/ldap-settings-custom.html b/app/portainer/settings/authentication/ldap/ldap-settings-custom/ldap-settings-custom.html new file mode 100644 index 0000000..573650d --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap-settings-custom/ldap-settings-custom.html @@ -0,0 +1,155 @@ +
+
Information
+
+ When using LDAP authentication, Portainer will delegate user authentication to a LDAP server and fallback to internal authentication if LDAP authentication fails. +
+
+ +
LDAP configuration
+ +
+
+

+ + You can configure multiple LDAP Servers for authentication fallback. Make sure all servers are using the same configuration (i.e. if TLS is enabled, they should all use the + same certificates). +

+
+
+ +
+ +
+
+ + +
+
+
+ +
+
+ +
+ +
+ +
+
+ + +
+
+ +
+ +
+
+ +
+ +
+ +
+
+
+ + + + + + + +
+ + + + + + + +
+ + diff --git a/app/portainer/settings/authentication/ldap/ldap-settings-openldap/index.js b/app/portainer/settings/authentication/ldap/ldap-settings-openldap/index.js new file mode 100644 index 0000000..1ae314b --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap-settings-openldap/index.js @@ -0,0 +1,18 @@ +import controller from './ldap-settings-openldap.controller'; + +export const ldapSettingsOpenLdap = { + templateUrl: './ldap-settings-openldap.html', + controller, + bindings: { + settings: '=', + tlscaCert: '=', + state: '=', + connectivityCheck: '<', + onTlscaCertChange: '<', + onSearchUsersClick: '<', + onSearchGroupsClick: '<', + onSaveSettings: '<', + saveButtonState: '<', + saveButtonDisabled: '<', + }, +}; diff --git a/app/portainer/settings/authentication/ldap/ldap-settings-openldap/ldap-settings-openldap.controller.js b/app/portainer/settings/authentication/ldap/ldap-settings-openldap/ldap-settings-openldap.controller.js new file mode 100644 index 0000000..6fb83ca --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap-settings-openldap/ldap-settings-openldap.controller.js @@ -0,0 +1,45 @@ +import { FeatureId } from '@/react/portainer/feature-flags/enums'; + +export default class LdapSettingsOpenLDAPController { + /* @ngInject */ + constructor() { + this.domainSuffix = ''; + this.limitedFeatureId = FeatureId.EXTERNAL_AUTH_LDAP; + + this.findDomainSuffix = this.findDomainSuffix.bind(this); + this.parseDomainSuffix = this.parseDomainSuffix.bind(this); + this.onAccountChange = this.onAccountChange.bind(this); + } + + findDomainSuffix() { + const serviceAccount = this.settings.ReaderDN; + let domainSuffix = this.parseDomainSuffix(serviceAccount); + if (!domainSuffix && this.settings.SearchSettings.length > 0) { + const searchSettings = this.settings.SearchSettings[0]; + domainSuffix = this.parseDomainSuffix(searchSettings.BaseDN); + } + + this.domainSuffix = domainSuffix; + } + + parseDomainSuffix(string = '') { + const index = string.toLowerCase().indexOf('dc='); + return index !== -1 ? string.substring(index) : ''; + } + + onAccountChange(serviceAccount) { + this.domainSuffix = this.parseDomainSuffix(serviceAccount); + } + + addLDAPUrl() { + this.settings.URLs.push(''); + } + + removeLDAPUrl(index) { + this.settings.URLs.splice(index, 1); + } + + $onInit() { + this.findDomainSuffix(); + } +} diff --git a/app/portainer/settings/authentication/ldap/ldap-settings-openldap/ldap-settings-openldap.html b/app/portainer/settings/authentication/ldap/ldap-settings-openldap/ldap-settings-openldap.html new file mode 100644 index 0000000..b43ba1a --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap-settings-openldap/ldap-settings-openldap.html @@ -0,0 +1,199 @@ + +
+ +
+
+
Information
+
+ When using LDAP authentication, Portainer will delegate user authentication to a LDAP server and fallback to internal authentication if LDAP authentication fails. +
+
+ +
LDAP configuration
+ +
+
+

+ + You can configure multiple LDAP Servers for authentication fallback. Make sure all servers are using the same configuration (i.e. if TLS is enabled, they should all use + the same certificates). +

+
+
+ +
+ +
+
+ + +
+
+
+ + +
+
+ + +
+ +
+
+
+ + +
+
+ +
+ +
+
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+
+ + + + + + + + + + + + + +
+
+
diff --git a/app/portainer/settings/authentication/ldap/ldap-settings-security/index.js b/app/portainer/settings/authentication/ldap/ldap-settings-security/index.js new file mode 100644 index 0000000..35930bf --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap-settings-security/index.js @@ -0,0 +1,89 @@ +export const ldapSettingsSecurity = { + templateUrl: './ldap-settings-security.html', + bindings: { + settings: '=', + tlscaCert: '<', + onTlscaCertChange: '<', + uploadInProgress: '<', + title: '@', + limitedFeatureId: '<', + }, + controller: LdapController, +}; + +// temp controller until the parent component is in react +/* @ngInject */ +function LdapController($scope) { + const self = this; + self.reactValues = { startTLS: false, tls: false, tlsSkipVerify: false, caCertFile: null }; + + self.$onInit = $onInit; + self.$onChanges = $onChanges; + self.getUploadState = getUploadState; + self.onChangeReactValues = onChangeReactValues; + + function $onInit() { + updateReactValuesFile(self.tlscaCert || null); + updateReactValuesFromSettings(self.settings); + } + + function getUploadState() { + if (self.uploadInProgress) { + return 'uploading'; + } + + if (self.tlscaCert && self.tlscaCert === self.settings.TLSConfig.TLSCACert) { + return 'success'; + } + + return undefined; + } + + function updateReactValuesFromSettings(settings) { + self.reactValues = { + ...self.reactValues, + startTLS: settings.StartTLS, + tls: settings.TLSConfig.TLS, + tlsSkipVerify: settings.TLSConfig.TLSSkipVerify, + }; + } + + function updateReactValuesFile(file) { + self.reactValues = { + ...self.reactValues, + caCertFile: file, + }; + } + + function onChangeReactValues(values) { + $scope.$evalAsync(() => { + self.reactValues = { ...self.reactValues, ...values }; + + if ('startTLS' in values) { + self.settings.StartTLS = values.startTLS; + } + + if ('tls' in values) { + self.settings.TLSConfig.TLS = values.tls; + } + + if ('tlsSkipVerify' in values) { + self.settings.TLSConfig.TLSSkipVerify = values.tlsSkipVerify; + } + + if ('caCertFile' in values) { + self.tlscaCert = values.caCertFile; + self.onTlscaCertChange(values.caCertFile); + } + }); + } + + function $onChanges({ settings, tlscaCert }) { + if (settings && settings.currentValue) { + updateReactValuesFromSettings(settings.currentValue); + } + if (tlscaCert) { + updateReactValuesFile(tlscaCert.currentValue || null); + } + } +} diff --git a/app/portainer/settings/authentication/ldap/ldap-settings-security/ldap-settings-security.html b/app/portainer/settings/authentication/ldap/ldap-settings-security/ldap-settings-security.html new file mode 100644 index 0000000..2fdba96 --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap-settings-security/ldap-settings-security.html @@ -0,0 +1,7 @@ + diff --git a/app/portainer/settings/authentication/ldap/ldap-settings.model.js b/app/portainer/settings/authentication/ldap/ldap-settings.model.js new file mode 100644 index 0000000..9334a6a --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap-settings.model.js @@ -0,0 +1,61 @@ +export function buildLdapSettingsModel() { + return { + AnonymousMode: true, + ReaderDN: '', + URLs: [''], + ServerType: 0, + TLSConfig: { + TLS: false, + TLSSkipVerify: false, + }, + StartTLS: false, + SearchSettings: [ + { + BaseDN: '', + Filter: '', + UserNameAttribute: '', + }, + ], + GroupSearchSettings: [ + { + GroupBaseDN: '', + GroupFilter: '', + GroupAttribute: '', + }, + ], + AdminGroupSearchSettings: [ + { + GroupBaseDN: '', + GroupFilter: '', + GroupAttribute: '', + }, + ], + AutoCreateUsers: true, + }; +} + +export function buildAdSettingsModel() { + const settings = buildLdapSettingsModel(); + + settings.ServerType = 2; + settings.AnonymousMode = false; + settings.SearchSettings[0].UserNameAttribute = 'sAMAccountName'; + settings.SearchSettings[0].Filter = '(objectClass=user)'; + settings.GroupSearchSettings[0].GroupAttribute = 'member'; + settings.GroupSearchSettings[0].GroupFilter = '(objectClass=group)'; + + return settings; +} + +export function buildOpenLDAPSettingsModel() { + const settings = buildLdapSettingsModel(); + + settings.ServerType = 1; + settings.AnonymousMode = false; + settings.SearchSettings[0].UserNameAttribute = 'uid'; + settings.SearchSettings[0].Filter = '(objectClass=inetOrgPerson)'; + settings.GroupSearchSettings[0].GroupAttribute = 'member'; + settings.GroupSearchSettings[0].GroupFilter = '(objectClass=groupOfNames)'; + + return settings; +} diff --git a/app/portainer/settings/authentication/ldap/ldap-settings/index.js b/app/portainer/settings/authentication/ldap/ldap-settings/index.js new file mode 100644 index 0000000..f0072f1 --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap-settings/index.js @@ -0,0 +1,15 @@ +import controller from './ldap-settings.controller'; + +export const ldapSettings = { + templateUrl: './ldap-settings.html', + controller, + bindings: { + settings: '=', + tlscaCert: '=', + state: '<', + connectivityCheck: '<', + onSaveSettings: '<', + saveButtonState: '<', + isLdapFormValid: '<', + }, +}; diff --git a/app/portainer/settings/authentication/ldap/ldap-settings/ldap-settings.controller.js b/app/portainer/settings/authentication/ldap/ldap-settings/ldap-settings.controller.js new file mode 100644 index 0000000..322b3ee --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap-settings/ldap-settings.controller.js @@ -0,0 +1,77 @@ +import { buildLdapSettingsModel, buildOpenLDAPSettingsModel } from '@/portainer/settings/authentication/ldap/ldap-settings.model'; +import { options } from '@/react/portainer/settings/AuthenticationView/ldap-options'; + +const SERVER_TYPES = { + CUSTOM: 0, + OPEN_LDAP: 1, + AD: 2, +}; + +const DEFAULT_GROUP_FILTER = '(objectClass=groupOfNames)'; +const DEFAULT_USER_FILTER = '(objectClass=inetOrgPerson)'; + +export default class LdapSettingsController { + /* @ngInject */ + constructor(LDAPService, $scope) { + Object.assign(this, { LDAPService, SERVER_TYPES, $scope }); + + this.tlscaCert = null; + this.settingsDrafts = {}; + + this.boxSelectorOptions = options; + + this.onTlscaCertChange = this.onTlscaCertChange.bind(this); + this.searchUsers = this.searchUsers.bind(this); + this.searchGroups = this.searchGroups.bind(this); + this.onChangeServerType = this.onChangeServerType.bind(this); + this.onAutoUserProvisionChange = this.onAutoUserProvisionChange.bind(this); + this.onAutoUserProvisionChange = this.onAutoUserProvisionChange.bind(this); + } + + onAutoUserProvisionChange(value) { + this.$scope.$evalAsync(() => { + this.settings.AutoCreateUsers = value; + }); + } + onTlscaCertChange(file) { + this.tlscaCert = file; + } + + $onInit() { + this.tlscaCert = this.settings.TLSConfig.TLSCACert; + } + + onChangeServerType(serverType) { + this.settingsDrafts[this.settings.ServerType] = this.settings; + + if (this.settingsDrafts[serverType]) { + this.settings = this.settingsDrafts[serverType]; + return; + } + + switch (serverType) { + case SERVER_TYPES.OPEN_LDAP: + this.settings = buildOpenLDAPSettingsModel(); + break; + case SERVER_TYPES.CUSTOM: + this.settings = buildLdapSettingsModel(); + break; + } + } + + searchUsers() { + const settings = { + ...this.settings, + SearchSettings: this.settings.SearchSettings.map((search) => ({ ...search, Filter: search.Filter || DEFAULT_USER_FILTER })), + }; + return this.LDAPService.users(settings); + } + + searchGroups() { + const settings = { + ...this.settings, + GroupSearchSettings: this.settings.GroupSearchSettings.map((search) => ({ ...search, GroupFilter: search.GroupFilter || DEFAULT_GROUP_FILTER })), + }; + return this.LDAPService.groups(settings); + } +} diff --git a/app/portainer/settings/authentication/ldap/ldap-settings/ldap-settings.html b/app/portainer/settings/authentication/ldap/ldap-settings/ldap-settings.html new file mode 100644 index 0000000..da5b690 --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap-settings/ldap-settings.html @@ -0,0 +1,44 @@ +
+ + + + + + +
diff --git a/app/portainer/settings/authentication/ldap/ldap-user-search-item/index.js b/app/portainer/settings/authentication/ldap/ldap-user-search-item/index.js new file mode 100644 index 0000000..32dd367 --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap-user-search-item/index.js @@ -0,0 +1,15 @@ +import controller from './ldap-user-search-item.controller'; + +export const ldapUserSearchItem = { + templateUrl: './ldap-user-search-item.html', + controller, + bindings: { + config: '=', + index: '<', + showUsernameFormat: '<', + domainSuffix: '@', + baseFilter: '@', + onRemoveClick: '<', + limitedFeatureId: '<', + }, +}; diff --git a/app/portainer/settings/authentication/ldap/ldap-user-search-item/ldap-user-search-item.controller.js b/app/portainer/settings/authentication/ldap/ldap-user-search-item/ldap-user-search-item.controller.js new file mode 100644 index 0000000..8b6f599 --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap-user-search-item/ldap-user-search-item.controller.js @@ -0,0 +1,72 @@ +export default class LdapUserSearchItemController { + /* @ngInject */ + constructor($scope) { + this.$scope = $scope; + this.groups = []; + + this.onBaseDNChange = this.onBaseDNChange.bind(this); + this.onGroupChange = this.onGroupChange.bind(this); + this.onGroupsChange = this.onGroupsChange.bind(this); + this.removeGroup = this.removeGroup.bind(this); + } + + onBaseDNChange(baseDN) { + this.$scope.$evalAsync(() => { + this.config.BaseDN = baseDN; + }); + } + + onGroupChange(index, group) { + this.$scope.$evalAsync(() => { + this.groups = this.groups.map((g, i) => (i === index ? group : g)); + this.onGroupsChange(this.groups); + }); + } + + onGroupsChange(groups) { + this.config.Filter = this.generateUserFilter(groups); + } + + removeGroup(index) { + this.groups.splice(index, 1); + this.onGroupsChange(this.groups); + } + + addGroup() { + this.groups.push(this.domainSuffix ? `cn=,${this.domainSuffix}` : 'cn='); + } + + generateUserFilter(groups) { + const filteredGroups = groups.filter((group) => group !== this.domainSuffix); + + if (!filteredGroups.length) { + return this.baseFilter; + } + + const groupsFilter = filteredGroups.map((group) => `(memberOf=${group})`); + + return `(&${this.baseFilter}${groupsFilter.length > 1 ? `(|${groupsFilter.join('')})` : groupsFilter[0]})`; + } + + parseFilter() { + const filter = this.config.Filter; + if (filter === this.baseFilter) { + return; + } + + if (!filter.includes('|')) { + const index = filter.indexOf('memberOf='); + if (index > -1) { + this.groups = [filter.slice(index + 9, -2)]; + } + return; + } + + const members = filter.slice(filter.indexOf('|') + 2, -3); + this.groups = members.split(')(').map((member) => member.replace('memberOf=', '')); + } + + $onInit() { + this.parseFilter(); + } +} diff --git a/app/portainer/settings/authentication/ldap/ldap-user-search-item/ldap-user-search-item.html b/app/portainer/settings/authentication/ldap/ldap-user-search-item/ldap-user-search-item.html new file mode 100644 index 0000000..8e83957 --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap-user-search-item/ldap-user-search-item.html @@ -0,0 +1,99 @@ +
+
+ Extra search configuration + +
+ +
+
+ +
+
+
+
+ + +
+
+
+
+ +
+ +
+ {{ $ctrl.config.BaseDN }} +
+
+ + + +
+
+ + +
+
+
+ + + + + +
+
+
+ +
+ +
+ {{ $ctrl.config.Filter }} +
+
+
diff --git a/app/portainer/settings/authentication/ldap/ldap-user-search/index.js b/app/portainer/settings/authentication/ldap/ldap-user-search/index.js new file mode 100644 index 0000000..380ddcf --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap-user-search/index.js @@ -0,0 +1,15 @@ +import controller from './ldap-user-search.controller'; + +export const ldapUserSearch = { + templateUrl: './ldap-user-search.html', + controller, + bindings: { + settings: '=', + domainSuffix: '@', + showUsernameFormat: '<', + baseFilter: '@', + limitedFeatureId: '<', + + onSearchClick: '<', + }, +}; diff --git a/app/portainer/settings/authentication/ldap/ldap-user-search/ldap-user-search.controller.js b/app/portainer/settings/authentication/ldap/ldap-user-search/ldap-user-search.controller.js new file mode 100644 index 0000000..6d5ff11 --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap-user-search/ldap-user-search.controller.js @@ -0,0 +1,38 @@ +import _ from 'lodash'; + +export default class LdapUserSearchController { + /* @ngInject */ + constructor($async, Notifications) { + Object.assign(this, { $async, Notifications }); + + this.users = null; + this.showTable = false; + + this.onRemoveClick = this.onRemoveClick.bind(this); + this.onAddClick = this.onAddClick.bind(this); + this.search = this.search.bind(this); + } + + onAddClick() { + const lastSetting = _.last(this.settings); + this.settings.push({ BaseDN: this.domainSuffix, UserNameAttribute: lastSetting.UserNameAttribute, Filter: this.baseFilter }); + } + + onRemoveClick(index) { + this.settings.splice(index, 1); + } + + search() { + return this.$async(async () => { + try { + this.users = null; + this.showTable = true; + const users = await this.onSearchClick(); + this.users = _.compact(users); + } catch (error) { + this.Notifications.error('Failure', error, 'Failed to search users'); + this.showTable = false; + } + }); + } +} diff --git a/app/portainer/settings/authentication/ldap/ldap-user-search/ldap-user-search.html b/app/portainer/settings/authentication/ldap/ldap-user-search/ldap-user-search.html new file mode 100644 index 0000000..fd42564 --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap-user-search/ldap-user-search.html @@ -0,0 +1,37 @@ +
+
User search configurations
+ +
+ +
+ +
+
+ +
+
+ +
+
+ + +
diff --git a/app/portainer/settings/authentication/ldap/ldap.rest.js b/app/portainer/settings/authentication/ldap/ldap.rest.js new file mode 100644 index 0000000..e93d527 --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap.rest.js @@ -0,0 +1,15 @@ +const API_ENDPOINT_LDAP = 'api/ldap'; + +/* @ngInject */ +export function LDAP($resource) { + return $resource( + `${API_ENDPOINT_LDAP}/:action`, + {}, + { + check: { method: 'POST', params: { action: 'check' } }, + users: { method: 'POST', isArray: true, params: { action: 'users' } }, + groups: { method: 'POST', isArray: true, params: { action: 'groups' } }, + testLogin: { method: 'POST', params: { action: 'test' } }, + } + ); +} diff --git a/app/portainer/settings/authentication/ldap/ldap.service.js b/app/portainer/settings/authentication/ldap/ldap.service.js new file mode 100644 index 0000000..875f83a --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap.service.js @@ -0,0 +1,29 @@ +/* @ngInject */ +export function LDAPService(LDAP) { + return { users, groups, check, testLogin }; + + function users(ldapSettings) { + return LDAP.users({ ldapSettings }).$promise; + } + + async function groups(ldapSettings) { + const userGroups = await LDAP.groups({ ldapSettings }).$promise; + return userGroups.map(({ Name, Groups }) => { + let name = Name; + if (Name.includes(',') && Name.includes('=')) { + const [cnName] = Name.split(','); + const split = cnName.split('='); + name = split[1]; + } + return { Groups, Name: name }; + }); + } + + function check(ldapSettings) { + return LDAP.check({ ldapSettings }).$promise; + } + + function testLogin(ldapSettings, username, password) { + return LDAP.testLogin({ ldapSettings, username, password }).$promise; + } +} diff --git a/app/portainer/settings/authentication/save-auth-settings-button/index.js b/app/portainer/settings/authentication/save-auth-settings-button/index.js new file mode 100644 index 0000000..6aa7c94 --- /dev/null +++ b/app/portainer/settings/authentication/save-auth-settings-button/index.js @@ -0,0 +1,11 @@ +export const saveAuthSettingsButton = { + templateUrl: './save-auth-settings-button.html', + bindings: { + onSaveSettings: '<', + saveButtonDisabled: '<', + saveButtonState: '<', + limitedFeatureId: '<', + limitedFeatureClass: '<', + className: '<', + }, +}; diff --git a/app/portainer/settings/authentication/save-auth-settings-button/save-auth-settings-button.html b/app/portainer/settings/authentication/save-auth-settings-button/save-auth-settings-button.html new file mode 100644 index 0000000..5e797f2 --- /dev/null +++ b/app/portainer/settings/authentication/save-auth-settings-button/save-auth-settings-button.html @@ -0,0 +1,17 @@ +
Actions
+
+
+ +
+
diff --git a/app/portainer/settings/general/index.js b/app/portainer/settings/general/index.js new file mode 100644 index 0000000..82d992c --- /dev/null +++ b/app/portainer/settings/general/index.js @@ -0,0 +1,3 @@ +import angular from 'angular'; + +export default angular.module('portainer.settings.general', []).name; diff --git a/app/portainer/settings/index.js b/app/portainer/settings/index.js new file mode 100644 index 0000000..629f9b5 --- /dev/null +++ b/app/portainer/settings/index.js @@ -0,0 +1,6 @@ +import angular from 'angular'; + +import authenticationModule from './authentication'; +import generalModule from './general'; + +export default angular.module('portainer.settings', [authenticationModule, generalModule]).name; diff --git a/app/portainer/tags/queries.ts b/app/portainer/tags/queries.ts new file mode 100644 index 0000000..fe4bac3 --- /dev/null +++ b/app/portainer/tags/queries.ts @@ -0,0 +1,48 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +import { + mutationOptions, + withError, + withInvalidate, +} from '@/react-tools/react-query'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +import { createTag, getTags } from './tags.service'; +import { Tag, TagId } from './types'; + +export const tagKeys = { + all: ['tags'] as const, + tag: (id: TagId) => [...tagKeys.all, id] as const, +}; + +export function useTags({ + select, + enabled = true, +}: { select?: (tags: Tag[]) => T; enabled?: boolean } = {}) { + return useQuery(tagKeys.all, () => getTags(), { + staleTime: 50, + select, + enabled, + ...withError('Failed to retrieve tags'), + }); +} + +export function useTagsForEnvironment(environmentId: EnvironmentId) { + const { data: tags, isLoading } = useTags({ + select: (tags) => tags.filter((tag) => tag.Endpoints[environmentId]), + }); + + return { tags, isLoading }; +} + +export function useCreateTagMutation() { + const queryClient = useQueryClient(); + + return useMutation( + createTag, + mutationOptions( + withError('Unable to create tag'), + withInvalidate(queryClient, [tagKeys.all]) + ) + ); +} diff --git a/app/portainer/tags/tags.service.ts b/app/portainer/tags/tags.service.ts new file mode 100644 index 0000000..f5efaf4 --- /dev/null +++ b/app/portainer/tags/tags.service.ts @@ -0,0 +1,38 @@ +import axios, { parseAxiosError } from '../services/axios/axios'; + +import { Tag, TagId } from './types'; + +export async function getTags() { + try { + const { data } = await axios.get(buildUrl()); + return data; + } catch (err) { + throw parseAxiosError(err as Error, 'Unable to retrieve tags'); + } +} + +export async function createTag(name: string) { + try { + const { data: tag } = await axios.post(buildUrl(), { name }); + return tag; + } catch (err) { + throw parseAxiosError(err as Error, 'Unable to create tag'); + } +} + +export async function deleteTag(id: TagId) { + try { + await axios.delete(buildUrl(id)); + } catch (err) { + throw parseAxiosError(err as Error, 'Unable to delte tag'); + } +} + +function buildUrl(id?: TagId) { + let url = '/tags'; + if (id) { + url += `/${id}`; + } + + return url; +} diff --git a/app/portainer/tags/types.ts b/app/portainer/tags/types.ts new file mode 100644 index 0000000..3440871 --- /dev/null +++ b/app/portainer/tags/types.ts @@ -0,0 +1,7 @@ +export type TagId = number; + +export interface Tag { + ID: TagId; + Name: string; + Endpoints: Record; +} diff --git a/app/portainer/user-activity/auth-logs-view/auth-logs-view.controller.js b/app/portainer/user-activity/auth-logs-view/auth-logs-view.controller.js new file mode 100644 index 0000000..35744f4 --- /dev/null +++ b/app/portainer/user-activity/auth-logs-view/auth-logs-view.controller.js @@ -0,0 +1,106 @@ +import moment from 'moment'; + +import { FeatureId } from '@/react/portainer/feature-flags/enums'; + +export default class AuthLogsViewController { + /* @ngInject */ + constructor($async, Notifications) { + this.$async = $async; + this.Notifications = Notifications; + + this.limitedFeature = FeatureId.ACTIVITY_AUDIT; + this.state = { + keyword: '', + date: { + from: 0, + to: 0, + }, + sort: { + key: 'Timestamp', + desc: true, + }, + contextFilter: [1, 2, 3], + typeFilter: [1, 2, 3], + page: 1, + limit: 10, + totalItems: 0, + logs: null, + }; + + this.today = moment().endOf('day'); + this.minValidDate = moment().subtract(7, 'd').startOf('day'); + + this.onChangeDate = this.onChangeDate.bind(this); + this.onChangeKeyword = this.onChangeKeyword.bind(this); + this.onChangeSort = this.onChangeSort.bind(this); + this.onChangeContextFilter = this.onChangeContextFilter.bind(this); + this.onChangeTypeFilter = this.onChangeTypeFilter.bind(this); + this.loadLogs = this.loadLogs.bind(this); + this.onChangePage = this.onChangePage.bind(this); + this.onChangeLimit = this.onChangeLimit.bind(this); + } + + onChangePage(page) { + this.state.page = page; + this.loadLogs(); + } + + onChangeLimit(limit) { + this.state.page = 1; + this.state.limit = limit; + this.loadLogs(); + } + + onChangeSort(sort) { + this.state.page = 1; + this.state.sort = sort; + this.loadLogs(); + } + + onChangeContextFilter(filterKey, filterState) { + this.state.contextFilter = filterState; + this.loadLogs(); + } + + onChangeTypeFilter(filterKey, filterState) { + this.state.typeFilter = filterState; + this.loadLogs(); + } + + onChangeKeyword(keyword) { + return this.$scope.$evalAsync(() => { + this.state.page = 1; + this.state.keyword = keyword; + this.loadLogs(); + }); + } + + onChangeDate({ startDate, endDate }) { + this.state.page = 1; + this.state.date = { to: endDate, from: startDate }; + this.loadLogs(); + } + + async loadLogs() { + return this.$async(async () => { + this.state.logs = null; + try { + const { logs, totalCount } = { logs: [], totalCount: 0 }; + this.state.logs = decorateLogs(logs); + this.state.totalItems = totalCount; + } catch (err) { + this.Notifications.error('Failure', err, 'Failed loading auth activity logs'); + } + }); + } + + $onInit() { + return this.$async(async () => { + this.loadLogs(); + }); + } +} + +function decorateLogs(logs) { + return logs; +} diff --git a/app/portainer/user-activity/auth-logs-view/auth-logs-view.html b/app/portainer/user-activity/auth-logs-view/auth-logs-view.html new file mode 100644 index 0000000..020ff76 --- /dev/null +++ b/app/portainer/user-activity/auth-logs-view/auth-logs-view.html @@ -0,0 +1,52 @@ + + +
+
+ + +
+
+
+ + +
+
+ +
+ +
+
+
+

+ + Portainer user authentication logs have a maximum retention of 7 days. +

+
+ +
+
+
+
+
+ +
+
+
diff --git a/app/portainer/user-activity/auth-logs-view/auth-logs-view.js b/app/portainer/user-activity/auth-logs-view/auth-logs-view.js new file mode 100644 index 0000000..c5cd52b --- /dev/null +++ b/app/portainer/user-activity/auth-logs-view/auth-logs-view.js @@ -0,0 +1,6 @@ +import controller from './auth-logs-view.controller.js'; + +export const authLogsView = { + templateUrl: './auth-logs-view.html', + controller, +}; diff --git a/app/portainer/user-activity/auth-logs-view/index.js b/app/portainer/user-activity/auth-logs-view/index.js new file mode 100644 index 0000000..5b17122 --- /dev/null +++ b/app/portainer/user-activity/auth-logs-view/index.js @@ -0,0 +1,5 @@ +import angular from 'angular'; + +import { authLogsView } from './auth-logs-view'; + +export default angular.module('portainer.app.user-activity.auth-logs-view', []).component('authLogsView', authLogsView).name; diff --git a/app/portainer/user-activity/index.js b/app/portainer/user-activity/index.js new file mode 100644 index 0000000..0a04640 --- /dev/null +++ b/app/portainer/user-activity/index.js @@ -0,0 +1,61 @@ +import angular from 'angular'; + +import { NotificationsViewAngular } from '@/react/portainer/notifications/NotificationsView'; +import { AccessHeaders } from '../authorization-guard'; +import authLogsViewModule from './auth-logs-view'; +import { UserActivityService } from './user-activity.service'; +import { UserActivity } from './user-activity.rest'; + +export default angular + .module('portainer.app.user-activity', [authLogsViewModule]) + .service('UserActivity', UserActivity) + .service('UserActivityService', UserActivityService) + .component('notifications', NotificationsViewAngular) + .config(config).name; + +/* @ngInject */ +function config($stateRegistryProvider) { + $stateRegistryProvider.register({ + name: 'portainer.authLogs', + url: '/auth-logs', + views: { + 'content@': { + component: 'authLogsView', + }, + }, + data: { + docs: '/admin/logs', + access: AccessHeaders.Admin, + }, + }); + + $stateRegistryProvider.register({ + name: 'portainer.activityLogs', + url: '/activity-logs', + views: { + 'content@': { + component: 'activityLogsView', + }, + }, + data: { + docs: '/admin/logs/activity', + access: AccessHeaders.Admin, + }, + }); + + $stateRegistryProvider.register({ + name: 'portainer.notifications', + url: '/notifications', + views: { + 'content@': { + component: 'notifications', + }, + }, + params: { + id: '', + }, + data: { + docs: '/admin/notifications', + }, + }); +} diff --git a/app/portainer/user-activity/user-activity.rest.js b/app/portainer/user-activity/user-activity.rest.js new file mode 100644 index 0000000..4ed527e --- /dev/null +++ b/app/portainer/user-activity/user-activity.rest.js @@ -0,0 +1,28 @@ +import { baseHref } from '@/portainer/helpers/pathHelper'; + +/* @ngInject */ +export function UserActivity($resource, $http) { + const BASE_URL = baseHref() + 'api/useractivity'; + + const resource = $resource( + `${BASE_URL}/:action`, + {}, + { + authLogs: { method: 'GET', params: { action: 'authlogs' } }, + } + ); + + return { authLogsAsCSV, ...resource }; + + async function authLogsAsCSV(params) { + return $http({ + method: 'GET', + url: `${BASE_URL}/authlogs.csv`, + params, + responseType: 'blob', + headers: { + 'Content-type': 'text/csv', + }, + }); + } +} diff --git a/app/portainer/user-activity/user-activity.service.js b/app/portainer/user-activity/user-activity.service.js new file mode 100644 index 0000000..5c3c454 --- /dev/null +++ b/app/portainer/user-activity/user-activity.service.js @@ -0,0 +1,13 @@ +/* @ngInject */ +export function UserActivityService(FileSaver, UserActivity) { + return { authLogs, saveAuthLogsAsCSV }; + + function authLogs(offset, limit, sort, keyword, date, contexts, types) { + return UserActivity.authLogs({ offset, limit, keyword, before: date.to, after: date.from, sortBy: sort.key, sortDesc: sort.desc, contexts, types }).$promise; + } + + async function saveAuthLogsAsCSV(sort, keyword, date, contexts, types) { + const response = await UserActivity.authLogsAsCSV({ keyword, before: date.to, after: date.from, sortBy: sort.key, sortDesc: sort.desc, limit: 2000, contexts, types }); + return FileSaver.saveAs(response.data, 'logs.csv'); + } +} diff --git a/app/portainer/users/queries.ts b/app/portainer/users/queries.ts new file mode 100644 index 0000000..96fd5f5 --- /dev/null +++ b/app/portainer/users/queries.ts @@ -0,0 +1,61 @@ +import { useQuery } from '@tanstack/react-query'; + +import { TeamRole, TeamMembership } from '@/react/portainer/users/teams/types'; +import { useCurrentUser, useIsEdgeAdmin } from '@/react/hooks/useUser'; + +import { User, UserId } from './types'; +import { getUserMemberships, getUsers } from './user.service'; + +interface UseUserMembershipOptions { + select?(userMemberships: TeamMembership[]): TSelect; + enabled?: boolean; +} + +export function useUserMembership( + userId: UserId, + { enabled, select }: UseUserMembershipOptions = {} +) { + return useQuery( + ['users', userId, 'memberships'], + () => getUserMemberships(userId), + { select, enabled } + ); +} + +export function useIsCurrentUserTeamLeader() { + const { user } = useCurrentUser(); + const isAdminQuery = useIsEdgeAdmin(); + + const query = useUserMembership(user.Id, { + enabled: !isAdminQuery.isLoading && !isAdminQuery.isAdmin, + select: (memberships) => + memberships.some((membership) => membership.Role === TeamRole.Leader), + }); + + if (isAdminQuery.isLoading) { + return false; + } + + return isAdminQuery.isAdmin ? true : !!query.data; +} + +export function useUsers( + includeAdministrator = false, + environmentId = 0, + enabled = true, + select: (data: User[]) => T = (data) => data as unknown as T +) { + const users = useQuery( + ['users', { includeAdministrator, environmentId }], + () => getUsers(includeAdministrator, environmentId), + { + meta: { + error: { title: 'Failure', message: 'Unable to load users' }, + }, + enabled, + select, + } + ); + + return users; +} diff --git a/app/portainer/users/queries/queryKeys.ts b/app/portainer/users/queries/queryKeys.ts new file mode 100644 index 0000000..a31f07f --- /dev/null +++ b/app/portainer/users/queries/queryKeys.ts @@ -0,0 +1,7 @@ +import { UserId } from '../types'; + +export const userQueryKeys = { + base: () => ['users'] as const, + user: (id: UserId) => [...userQueryKeys.base(), id] as const, + me: () => [...userQueryKeys.base(), 'me'] as const, +}; diff --git a/app/portainer/users/queries/useLoadCurrentUser.ts b/app/portainer/users/queries/useLoadCurrentUser.ts new file mode 100644 index 0000000..a2547f9 --- /dev/null +++ b/app/portainer/users/queries/useLoadCurrentUser.ts @@ -0,0 +1,28 @@ +import { useQuery } from '@tanstack/react-query'; + +import axios from '@/react/portainer/services/axios/axios'; +import { withError } from '@/react-tools/react-query'; + +import { buildUrl } from '../user.service'; +import { User } from '../types'; + +import { userQueryKeys } from './queryKeys'; + +export interface CurrentUserResponse extends User { + forceChangePassword?: boolean; +} + +export function useLoadCurrentUser({ staleTime }: { staleTime?: number } = {}) { + return useQuery(userQueryKeys.me(), () => getCurrentUser(), { + ...withError('Unable to retrieve user details'), + staleTime, + }); +} + +export async function getCurrentUser() { + const { data: user } = await axios.get( + buildUrl(undefined, 'me') + ); + + return user; +} diff --git a/app/portainer/users/queries/useUser.ts b/app/portainer/users/queries/useUser.ts new file mode 100644 index 0000000..c8e58e9 --- /dev/null +++ b/app/portainer/users/queries/useUser.ts @@ -0,0 +1,29 @@ +import { useQuery } from '@tanstack/react-query'; + +import axios, { parseAxiosError } from '@/react/portainer/services/axios/axios'; +import { withError } from '@/react-tools/react-query'; + +import { buildUrl } from '../user.service'; +import { User, UserId } from '../types'; + +import { userQueryKeys } from './queryKeys'; + +export function useUser( + id: UserId, + { staleTime }: { staleTime?: number } = {} +) { + return useQuery(userQueryKeys.user(id), () => getUser(id), { + ...withError('Unable to retrieve user details'), + staleTime, + }); +} + +export async function getUser(id: UserId) { + try { + const { data: user } = await axios.get(buildUrl(id)); + + return user; + } catch (e) { + throw parseAxiosError(e as Error, 'Unable to retrieve user details'); + } +} diff --git a/app/portainer/users/types.ts b/app/portainer/users/types.ts new file mode 100644 index 0000000..0b7d991 --- /dev/null +++ b/app/portainer/users/types.ts @@ -0,0 +1,33 @@ +import { EnvironmentId } from '@/react/portainer/environments/types'; +import { AuthorizationMap } from '@/react/portainer/users/RolesView/types'; + +import { type UserId } from './types/user-id'; + +export { type UserId }; + +export enum Role { + Admin = 1, + Standard, + EdgeAdmin, +} + +export const RoleNames: { [key in Role]: string } = { + [Role.Admin]: 'administrator', + [Role.Standard]: 'user', + [Role.EdgeAdmin]: 'edge administrator', +}; + +export type ThemeColor = 'dark' | 'light' | 'highcontrast' | 'auto'; + +export type User = { + Id: UserId; + Username: string; + Role: Role; + EndpointAuthorizations: { + [endpointId: EnvironmentId]: AuthorizationMap; + }; + UseCache: boolean; + ThemeSettings: { + color: ThemeColor; + }; +}; diff --git a/app/portainer/users/types/user-id.ts b/app/portainer/users/types/user-id.ts new file mode 100644 index 0000000..048cd89 --- /dev/null +++ b/app/portainer/users/types/user-id.ts @@ -0,0 +1 @@ +export type UserId = number; diff --git a/app/portainer/users/user.helpers.ts b/app/portainer/users/user.helpers.ts new file mode 100644 index 0000000..c087d4d --- /dev/null +++ b/app/portainer/users/user.helpers.ts @@ -0,0 +1,34 @@ +import { Environment } from '@/react/portainer/environments/types'; +import { isEdgeEnvironment } from '@/react/portainer/environments/utils'; + +import { Role, User } from './types'; + +export function filterNonAdministratorUsers(users: User[]) { + return users.filter((user) => !isPureAdmin(user)); +} + +type UserLike = Pick; + +// To avoid creating divergence between CE and EE +/** + * isEdgeAdmin checks if the user is edge admin or admin + */ +export function isEdgeAdmin( + user: UserLike | undefined, + environment?: Pick | null +): boolean { + return ( + isPureAdmin(user) || + (user?.Role === Role.EdgeAdmin && + (!environment || isEdgeEnvironment(environment.Type))) + ); +} + +// To avoid creating divergence between CE and EE +/** + * isPureAdmin checks if the user is portainer admin. + * See bouncer.IsAdmin and bouncer.PureAdminAccess + */ +export function isPureAdmin(user?: UserLike): boolean { + return !!user && user.Role === Role.Admin; +} diff --git a/app/portainer/users/user.service.ts b/app/portainer/users/user.service.ts new file mode 100644 index 0000000..2a93863 --- /dev/null +++ b/app/portainer/users/user.service.ts @@ -0,0 +1,45 @@ +import axios, { parseAxiosError } from '@/react/portainer/services/axios/axios'; +import { TeamMembership } from '@/react/portainer/users/teams/types'; + +import { User, UserId } from './types'; +import { filterNonAdministratorUsers } from './user.helpers'; + +export async function getUsers( + includeAdministrators = false, + environmentId = 0 +) { + try { + const { data } = await axios.get(buildUrl(), { + params: { environmentId }, + }); + + return includeAdministrators ? data : filterNonAdministratorUsers(data); + } catch (e) { + throw parseAxiosError(e as Error, 'Unable to retrieve users'); + } +} + +export async function getUserMemberships(id: UserId) { + try { + const { data } = await axios.get( + buildUrl(id, 'memberships') + ); + return data; + } catch (err) { + throw parseAxiosError(err as Error, 'Unable to retrieve user memberships'); + } +} + +export function buildUrl(id?: UserId, entity?: string) { + let url = '/users'; + + if (id) { + url += `/${id}`; + } + + if (entity) { + url += `/${entity}`; + } + + return url; +} diff --git a/app/portainer/views/account/account.html b/app/portainer/views/account/account.html new file mode 100644 index 0000000..6631fbf --- /dev/null +++ b/app/portainer/views/account/account.html @@ -0,0 +1,91 @@ + + +
+
+ +
+
+ +
+
+ + + +
+ +
+ +
+ +
+
+ + +
+ +
+ +
+
+ + + +
+ +
+
+ + + + + +
+
+
+
+
+
+ +
+
+ +
+
+ + + + + You cannot change your password when using LDAP authentication. + + + + You cannot change your password when using OAuth authentication. + +
+
+
+

+ + Minimum password length is set here. +

+
+
+
+
+
+
+ + + + + + diff --git a/app/portainer/views/account/accountController.js b/app/portainer/views/account/accountController.js new file mode 100644 index 0000000..8982baa --- /dev/null +++ b/app/portainer/views/account/accountController.js @@ -0,0 +1,109 @@ +import { confirmChangePassword } from '@@/modals/confirm'; +import { openDialog } from '@@/modals/Dialog'; +import { buildConfirmButton } from '@@/modals/utils'; + +angular.module('portainer.app').controller('AccountController', [ + '$scope', + '$state', + 'Authentication', + 'UserService', + 'Notifications', + 'SettingsService', + 'StateManager', + function ($scope, $state, Authentication, UserService, Notifications, SettingsService, StateManager) { + $scope.formValues = { + currentPassword: '', + newPassword: '', + confirmPassword: '', + }; + + $scope.updatePassword = async function () { + const confirmed = await confirmChangePassword(); + if (confirmed) { + try { + await UserService.updateUserPassword($scope.userID, $scope.formValues.currentPassword, $scope.formValues.newPassword); + Notifications.success('Success', 'Password successfully updated'); + StateManager.resetPasswordChangeSkips($scope.userID.toString()); + $scope.forceChangePassword = false; + $state.go('portainer.logout'); + } catch (err) { + Notifications.error('Failure', err, err.msg); + } + } + }; + + $scope.skipPasswordChange = async function () { + try { + if ($scope.userCanSkip()) { + StateManager.setPasswordChangeSkipped($scope.userID.toString()); + $scope.forceChangePassword = false; + $state.go('portainer.home'); + } + } catch (err) { + Notifications.error('Failure', err, err.msg); + } + }; + + $scope.userCanSkip = function () { + return $scope.timesPasswordChangeSkipped < 2; + }; + + this.uiCanExit = (newTransition) => { + if (newTransition) { + if ($scope.userRole === 1 && newTransition.to().name === 'portainer.settings.authentication') { + return true; + } + if (newTransition.to().name === 'portainer.logout') { + return true; + } + } + + if ($scope.forceChangePassword) { + confirmForceChangePassword(); + } + return !$scope.forceChangePassword; + }; + + $scope.uiCanExit = () => { + return this.uiCanExit(); + }; + + function initView() { + const state = StateManager.getState(); + const userDetails = Authentication.getUserDetails(); + $scope.userID = userDetails.ID; + $scope.userRole = Authentication.getUserDetails().role; + $scope.forceChangePassword = userDetails.forceChangePassword; + $scope.isInitialAdmin = userDetails.ID === 1; + + SettingsService.publicSettings() + .then(function success(data) { + $scope.AuthenticationMethod = data.AuthenticationMethod; + + if (state.UI.requiredPasswordLength && state.UI.requiredPasswordLength !== data.RequiredPasswordLength) { + StateManager.clearPasswordChangeSkips(); + } + + $scope.timesPasswordChangeSkipped = + state.UI.timesPasswordChangeSkipped && state.UI.timesPasswordChangeSkipped[$scope.userID.toString()] + ? state.UI.timesPasswordChangeSkipped[$scope.userID.toString()] + : 0; + + $scope.requiredPasswordLength = data.RequiredPasswordLength; + StateManager.setRequiredPasswordLength(data.RequiredPasswordLength); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve application settings'); + }); + } + + initView(); + }, +]); + +function confirmForceChangePassword() { + return openDialog({ + message: 'Please update your password to a stronger password to continue using Portainer', + buttons: [buildConfirmButton('OK')], + }); +} diff --git a/app/portainer/views/auth/auth.html b/app/portainer/views/auth/auth.html new file mode 100644 index 0000000..527b48b --- /dev/null +++ b/app/portainer/views/auth/auth.html @@ -0,0 +1,145 @@ +
+ +
+
+ +
+
+ + +
+ +
+ + +
+

Log in to your account

+

Welcome back! Please enter your details

+
+ + +
+
+ +
+ + + +
+
+
or
+
+
+ + +
+
+
Use internal authentication
+
+
+ + +
+
Username
+ +
+ + +
+
Password
+
+ + +
+
+ +
+ +
+ +
+
+
+ + + +
+ + {{ ctrl.state.AuthenticationError }} +
+
+
+ + +
+
+
+ Authentication in progress... +
+
+
+
+
+ +
diff --git a/app/portainer/views/auth/authController.js b/app/portainer/views/auth/authController.js new file mode 100644 index 0000000..0a84f44 --- /dev/null +++ b/app/portainer/views/auth/authController.js @@ -0,0 +1,284 @@ +import angular from 'angular'; +import uuidv4 from 'uuid/v4'; +import { getEnvironments } from '@/react/portainer/environments/environment.service'; +import { dispatchCacheRefreshEvent } from '@/portainer/services/http-request.helper'; +import { isValidReturnUrl } from '@/portainer/helpers/url-utils'; +import { storeReturnUrl, getReturnUrl, cleanReturnUrl } from '@/react/portainer/helpers/returnUrl'; + +class AuthenticationController { + /* @ngInject */ + constructor($async, $scope, $state, $stateParams, $window, Authentication, UserService, StateManager, Notifications, SettingsService, URLHelper, LocalStorage, StatusService) { + this.$async = $async; + this.$scope = $scope; + this.$state = $state; + this.$stateParams = $stateParams; + this.$window = $window; + this.Authentication = Authentication; + this.UserService = UserService; + this.StateManager = StateManager; + this.Notifications = Notifications; + this.SettingsService = SettingsService; + this.URLHelper = URLHelper; + this.LocalStorage = LocalStorage; + this.StatusService = StatusService; + + this.logo = this.StateManager.getState().application.logo; + this.formValues = { + Username: '', + Password: '', + }; + this.state = { + passwordInputType: 'password', + showOAuthLogin: false, + showStandardLogin: false, + AuthenticationError: '', + loginInProgress: true, + OAuthProvider: '', + }; + + this.checkForEndpointsAsync = this.checkForEndpointsAsync.bind(this); + this.postLoginSteps = this.postLoginSteps.bind(this); + + this.oAuthLoginAsync = this.oAuthLoginAsync.bind(this); + this.internalLoginAsync = this.internalLoginAsync.bind(this); + + this.authenticateUserAsync = this.authenticateUserAsync.bind(this); + + this.manageOauthCodeReturn = this.manageOauthCodeReturn.bind(this); + this.authEnabledFlowAsync = this.authEnabledFlowAsync.bind(this); + this.onInit = this.onInit.bind(this); + } + + /** + * UTILS FUNCTIONS SECTION + */ + + toggleShowPassword() { + this.state.passwordInputType = this.state.passwordInputType === 'text' ? 'password' : 'text'; + } + + // set the password input type to password, so that browser autofills don't treat the input as text + setPasswordInputType(inputType) { + this.state.passwordInputType = inputType; + document.getElementById('password').setAttribute('type', inputType); + } + + logout(error) { + this.Authentication.logout(); + this.state.loginInProgress = false; + this.generateOAuthLoginURI(); + this.LocalStorage.storeLogoutReason(error); + this.$window.location.reload(); + } + + error(err, message) { + this.state.AuthenticationError = message; + if (!err) { + err = {}; + } + this.Notifications.error('Failure', err, message); + this.state.loginInProgress = false; + } + + determineOauthProvider(LoginURI) { + if (LoginURI.indexOf('login.microsoftonline.com') !== -1) { + return 'Microsoft'; + } else if (LoginURI.indexOf('accounts.google.com') !== -1) { + return 'Google'; + } else if (LoginURI.indexOf('github.com') !== -1) { + return 'Github'; + } + return 'OAuth'; + } + + generateState() { + const uuid = uuidv4(); + this.LocalStorage.storeLoginStateUUID(uuid); + return '&state=' + uuid; + } + + generateOAuthLoginURI() { + this.OAuthLoginURI = this.state.OAuthLoginURI + this.generateState(); + } + + hasValidState(state) { + const savedUUID = this.LocalStorage.getLoginStateUUID(); + return savedUUID && state && savedUUID === state; + } + + /** + * END UTILS FUNCTIONS SECTION + */ + + /** + * POST LOGIN STEPS SECTION + */ + + async checkForEndpointsAsync() { + try { + const isAdmin = this.Authentication.isAdmin(); + const endpoints = await getEnvironments({ limit: 1, query: { excludeSnapshots: true } }); + + if (this.Authentication.getUserDetails().forceChangePassword) { + return this.$state.go('portainer.account'); + } + + if (endpoints.value.length === 0 && isAdmin) { + return this.$state.go('portainer.wizard'); + } + + const returnUrl = getReturnUrl(); + cleanReturnUrl(); + if (returnUrl && isValidReturnUrl(returnUrl)) { + this.$window.location.href = returnUrl; + return; + } + + return this.$state.go('portainer.home'); + } catch (err) { + this.error(err, 'Unable to retrieve environments'); + } + } + + async postLoginSteps() { + await this.StateManager.initialize(); + + await this.checkForEndpointsAsync(); + } + /** + * END POST LOGIN STEPS SECTION + */ + + /** + * LOGIN METHODS SECTION + */ + + async oAuthLoginAsync(code) { + try { + await this.Authentication.OAuthLogin(code); + this.URLHelper.cleanParameters(); + } catch (err) { + this.error(err, 'Unable to login via OAuth'); + } + } + + async internalLoginAsync(username, password) { + await this.Authentication.login(username, password); + await this.postLoginSteps(); + } + + /** + * END LOGIN METHODS SECTION + */ + + /** + * AUTHENTICATE USER SECTION + */ + + async authenticateUserAsync() { + try { + var username = this.formValues.Username; + var password = this.formValues.Password; + this.state.loginInProgress = true; + await this.internalLoginAsync(username, password); + } catch (err) { + this.error(err, 'Unable to login'); + } + } + + authenticateUser() { + this.setPasswordInputType('password'); + return this.$async(this.authenticateUserAsync); + } + + /** + * END AUTHENTICATE USER SECTION + */ + + /** + * ON INIT SECTION + */ + async manageOauthCodeReturn(code, state) { + if (this.hasValidState(state)) { + await this.oAuthLoginAsync(code); + } else { + this.error(null, 'Invalid OAuth state, try again.'); + } + } + + async authEnabledFlowAsync() { + try { + const exists = await this.UserService.administratorExists(); + if (!exists) { + this.$state.go('portainer.init.admin'); + } + } catch (err) { + this.error(err, 'Unable to verify administrator account existence'); + } + } + + toggleStandardLogin() { + this.state.showStandardLogin = !this.state.showStandardLogin; + } + + async onInit() { + try { + const settings = await this.SettingsService.publicSettings(); + this.state.showOAuthLogin = settings.AuthenticationMethod === 3; + this.state.showStandardLogin = !this.state.showOAuthLogin; + this.state.OAuthLoginURI = settings.OAuthLoginURI; + this.state.OAuthProvider = this.determineOauthProvider(settings.OAuthLoginURI); + + const returnUrl = new URLSearchParams(this.$window.location.search).get('returnUrl'); + if (returnUrl && isValidReturnUrl(returnUrl)) { + storeReturnUrl(returnUrl); + } + + const code = this.URLHelper.getParameter('code'); + const state = this.URLHelper.getParameter('state'); + if (code && state) { + await this.manageOauthCodeReturn(code, state); + this.generateOAuthLoginURI(); + return; + } + if (!this.logo) { + await this.StateManager.initialize(); + this.logo = this.StateManager.getState().application.logo; + } + this.generateOAuthLoginURI(); + + if (this.$stateParams.logout || this.$stateParams.error) { + this.logout(this.$stateParams.error); + return; + } + const error = this.LocalStorage.getLogoutReason(); + if (error) { + this.state.AuthenticationError = error; + this.LocalStorage.cleanLogoutReason(); + } + + // always clear the kubernetes cache on login + dispatchCacheRefreshEvent(); + + if (this.Authentication.isAuthenticated()) { + await this.postLoginSteps(); + } + this.state.loginInProgress = false; + + await this.authEnabledFlowAsync(); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to retrieve public settings'); + } + } + + $onInit() { + return this.$async(this.onInit); + } + + /** + * END ON INIT SECTION + */ +} + +export default AuthenticationController; +angular.module('portainer.app').controller('AuthenticationController', AuthenticationController); diff --git a/app/portainer/views/endpoints/access/endpointAccess.html b/app/portainer/views/endpoints/access/endpointAccess.html new file mode 100644 index 0000000..47027f2 --- /dev/null +++ b/app/portainer/views/endpoints/access/endpointAccess.html @@ -0,0 +1,55 @@ + + + +
+
+ + + + + + + + + + + + + + + + + + +
Name + {{ ctrl.endpoint.Name }} +
URL + {{ ctrl.endpoint.URL | stripprotocol }} +
Group + {{ ctrl.group.Name }} +
+
+
+
+
+ + + diff --git a/app/portainer/views/endpoints/access/endpointAccessController.js b/app/portainer/views/endpoints/access/endpointAccessController.js new file mode 100644 index 0000000..0cdbbe7 --- /dev/null +++ b/app/portainer/views/endpoints/access/endpointAccessController.js @@ -0,0 +1,49 @@ +import angular from 'angular'; + +import { FeatureId } from '@/react/portainer/feature-flags/enums'; + +class EndpointAccessController { + /* @ngInject */ + constructor($state, $transition$, Notifications, EndpointService, GroupService, $async) { + this.$state = $state; + this.$transition$ = $transition$; + this.Notifications = Notifications; + this.EndpointService = EndpointService; + this.GroupService = GroupService; + this.$async = $async; + + this.limitedFeature = FeatureId.RBAC_ROLES; + + this.updateAccess = this.updateAccess.bind(this); + this.updateAccessAsync = this.updateAccessAsync.bind(this); + } + + async $onInit() { + this.state = { actionInProgress: false }; + try { + this.endpoint = await this.EndpointService.endpoint(this.$transition$.params().id); + this.group = await this.GroupService.group(this.endpoint.GroupId); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to retrieve environment information'); + } + } + + updateAccess() { + return this.$async(this.updateAccessAsync); + } + + async updateAccessAsync() { + try { + this.state.actionInProgress = true; + await this.EndpointService.updateEndpoint(this.$transition$.params().id, this.endpoint); + this.Notifications.success('Success', 'Access successfully updated'); + this.$state.reload(this.$state.current); + } catch (err) { + this.state.actionInProgress = false; + this.Notifications.error('Failure', err, 'Unable to update accesses'); + } + } +} + +export default EndpointAccessController; +angular.module('portainer.app').controller('EndpointAccessController', EndpointAccessController); diff --git a/app/portainer/views/endpoints/helpers.js b/app/portainer/views/endpoints/helpers.js new file mode 100644 index 0000000..45c7bae --- /dev/null +++ b/app/portainer/views/endpoints/helpers.js @@ -0,0 +1,4 @@ +export function getAgentShortVersion(agentVersion) { + const numbers = agentVersion.split('.'); + return numbers[0] + '-' + numbers[1]; +} diff --git a/app/portainer/views/init/admin/initAdmin.html b/app/portainer/views/init/admin/initAdmin.html new file mode 100644 index 0000000..cd62767 --- /dev/null +++ b/app/portainer/views/init/admin/initAdmin.html @@ -0,0 +1,329 @@ +
+ +
+
+ +
+ +
+ + +
+
+ + +
+
+ + + + + +
+ +
+
+ Please create the initial administrator user. +
+
+ + +
+ +
+ +
+
+ + +
+ +
+ +
+
+ + +
+ +
+
+ + + + + +
+
+
+ + +
+ +
+ + +
+
+ + +
+
+

+ + The password must be at least {{ requiredPasswordLength }} characters long. + +

+
+
+ + +
+
+ +
+
+ +
+ +
+
+ + + +
+
+ + + + + +
+ +
+
+ + This will restore the Portainer metadata which contains information about the environments, stacks and applications, as well as the configured users. + +
+
+ + + + +
+ +
+
+ You can upload a backup file from your computer. +
+
+ + +
+
+ + + {{ formValues.BackupFile.name }} + + +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+ +
+ +
+
+ + +
+ +
+ +
+
+ + +
+ +
+ +
+
+ + +
+ +
+ +
+
+ + +
+ +
+ +
+
+ + +
+ +
+ +
+
+ + +
+ +
+ +
+
+ +
+ +
+
+ You are about to restore Portainer from this backup. +
+
+ After restoring has completed, please log in as a user that was known by the Portainer that was restored. +
+
+ + +
+ +
+ + +
+
+ + +
+
+ +
+
+ +
+ +
+
+ +
+
+ +
diff --git a/app/portainer/views/init/admin/initAdminController.js b/app/portainer/views/init/admin/initAdminController.js new file mode 100644 index 0000000..fc1bef4 --- /dev/null +++ b/app/portainer/views/init/admin/initAdminController.js @@ -0,0 +1,176 @@ +import { getEnvironments } from '@/react/portainer/environments/environment.service'; +import { restoreOptions } from '@/react/portainer/init/InitAdminView/restore-options'; + +const REDIRECT_REASON_TIMEOUT = 'AdminInitTimeout'; + +angular.module('portainer.app').controller('InitAdminController', [ + '$scope', + '$state', + 'Notifications', + 'Authentication', + 'StateManager', + 'SettingsService', + 'UserService', + 'BackupService', + 'StatusService', + function ($scope, $state, Notifications, Authentication, StateManager, SettingsService, UserService, BackupService, StatusService) { + $scope.restoreOptions = restoreOptions; + + $scope.uploadBackup = uploadBackup; + + $scope.logo = StateManager.getState().application.logo; + $scope.RESTORE_FORM_TYPES = { S3: 's3', FILE: 'file' }; + + $scope.formValues = { + Username: 'admin', + Password: '', + ConfirmPassword: '', + SetupToken: '', + restoreFormType: $scope.RESTORE_FORM_TYPES.FILE, + }; + + $scope.state = { + actionInProgress: false, + showInitPassword: true, + showRestorePortainer: false, + }; + + createAdministratorFlow(); + + $scope.togglePanel = function () { + $scope.state.showInitPassword = !$scope.state.showInitPassword; + $scope.state.showRestorePortainer = !$scope.state.showRestorePortainer; + }; + + $scope.onChangeRestoreType = onChangeRestoreType; + function onChangeRestoreType(value) { + $scope.$evalAsync(() => { + $scope.formValues.restoreFormType = value; + }); + } + + $scope.createAdminUser = function () { + var username = $scope.formValues.Username; + var password = $scope.formValues.Password; + + $scope.state.actionInProgress = true; + UserService.initAdministrator(username, password, $scope.formValues.SetupToken) + .then(function success() { + return Authentication.login(username, password); + }) + .then(() => { + return StateManager.initialize(); + }) + .then(function () { + return getEnvironments({ limit: 100 }); + }) + .then(function success(data) { + if (data.value.length === 0) { + $state.go('portainer.wizard'); + } else { + $state.go('portainer.home'); + } + }) + .catch(function error(err) { + if (!handleError(err)) { + Notifications.error('Failure', err, 'Unable to create administrator user'); + } + }) + .finally(function final() { + $scope.state.actionInProgress = false; + }); + }; + + function handleError(err) { + if (err.status === 303) { + const headers = err.response?.headers ?? {}; + if (headers['redirect-reason'] === REDIRECT_REASON_TIMEOUT) { + window.location.href = '/timeout.html'; + } + return true; + } + if (err.status === 403) { + Notifications.error('Failure', err, 'Setup token is missing or invalid. Find the current token in the Portainer server logs.'); + return true; + } + return false; + } + + function createAdministratorFlow() { + SettingsService.publicSettings() + .then(function success(data) { + $scope.requiredPasswordLength = data.RequiredPasswordLength; + $scope.requiresSetupToken = data.RequiresSetupToken; + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve application settings'); + }); + + UserService.administratorExists() + .then(function success(exists) { + if (exists) { + $state.go('portainer.wizard'); + } + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to verify administrator account existence'); + }); + } + + async function uploadBackup() { + $scope.state.backupInProgress = true; + + const file = $scope.formValues.BackupFile; + const password = $scope.formValues.Password; + + restoreAndRefresh(() => BackupService.uploadBackup(file, password, $scope.formValues.SetupToken)); + } + + async function restoreAndRefresh(restoreAsyncFn) { + $scope.state.backupInProgress = true; + + try { + await restoreAsyncFn(); + } catch (err) { + if (!handleError(err)) { + Notifications.error('Failure', err, 'Unable to restore the backup'); + } + $scope.state.backupInProgress = false; + + return; + } + + try { + await waitPortainerRestart(); + Notifications.success('Success', 'The backup has successfully been restored'); + $state.go('portainer.auth'); + } catch (err) { + handleError(err); + Notifications.error('Failure', err, 'Unable to check for status'); + await wait(2); + location.reload(); + } + + $scope.state.backupInProgress = false; + } + + async function waitPortainerRestart() { + for (let i = 0; i < 10; i++) { + await wait(5); + try { + const status = await StatusService.status(); + if (status && status.Version) { + return; + } + } catch (e) { + // pass + } + } + throw new Error('Timeout to wait for Portainer restarting'); + } + }, +]); + +function wait(seconds = 0) { + return new Promise((resolve) => setTimeout(resolve, seconds * 1000)); +} diff --git a/app/portainer/views/logout/logout.html b/app/portainer/views/logout/logout.html new file mode 100644 index 0000000..32d55e7 --- /dev/null +++ b/app/portainer/views/logout/logout.html @@ -0,0 +1,19 @@ +
+ +
+
+ +
+
+ + +
+ +
+
+ Logout in progress... + +
+
+
+
diff --git a/app/portainer/views/logout/logoutController.js b/app/portainer/views/logout/logoutController.js new file mode 100644 index 0000000..51b35fb --- /dev/null +++ b/app/portainer/views/logout/logoutController.js @@ -0,0 +1,74 @@ +import angular from 'angular'; +import { dispatchCacheRefreshEvent } from '@/portainer/services/http-request.helper'; +import { cleanReturnUrl } from '@/react/portainer/helpers/returnUrl'; + +class LogoutController { + /* @ngInject */ + constructor($async, $state, $transition$, $window, Authentication, StateManager, Notifications, LocalStorage, SettingsService) { + this.$async = $async; + this.$state = $state; + this.$transition$ = $transition$; + this.$window = $window; + + this.Authentication = Authentication; + this.StateManager = StateManager; + this.Notifications = Notifications; + this.LocalStorage = LocalStorage; + this.SettingsService = SettingsService; + + this.logo = this.StateManager.getState().application.logo; + this.logoutAsync = this.logoutAsync.bind(this); + + this.onInit = this.onInit.bind(this); + } + + /** + * UTILS FUNCTIONS SECTION + */ + async logoutAsync() { + const error = this.$transition$.params().error; + const settings = await this.SettingsService.publicSettings(); + + try { + await this.Authentication.logout(); + } finally { + // always clear the kubernetes cache + dispatchCacheRefreshEvent(); + + cleanReturnUrl(); + this.LocalStorage.storeLogoutReason(error); + if (settings.OAuthLogoutURI && this.Authentication.getUserDetails().ID !== 1) { + this.$window.location.href = settings.OAuthLogoutURI; + } else { + this.$state.go('portainer.auth', { reload: true }); + } + } + } + + logout() { + return this.$async(this.logoutAsync); + } + + /** + * END UTILS FUNCTIONS SECTION + */ + + async onInit() { + try { + await this.logout(); + } catch (err) { + this.Notifications.error('Failure', err, 'An error occurred during logout'); + } + } + + $onInit() { + return this.$async(this.onInit); + } + + /** + * END ON INIT SECTION + */ +} + +export default LogoutController; +angular.module('portainer.app').controller('LogoutController', LogoutController); diff --git a/app/portainer/views/main/mainController.js b/app/portainer/views/main/mainController.js new file mode 100644 index 0000000..355fa9c --- /dev/null +++ b/app/portainer/views/main/mainController.js @@ -0,0 +1,14 @@ +angular.module('portainer.app').controller('MainController', MainController); + +/* @ngInject */ +function MainController($scope, StateManager, ThemeManager, SidebarService) { + /** + * Sidebar Toggle & Cookie Control + */ + + $scope.applicationState = StateManager.getState(); + + $scope.isSidebarOpen = SidebarService.isSidebarOpen; + + ThemeManager.autoTheme(); +} diff --git a/app/portainer/views/settings/authentication/MIGRATION_PLAN.md b/app/portainer/views/settings/authentication/MIGRATION_PLAN.md new file mode 100644 index 0000000..794a02d --- /dev/null +++ b/app/portainer/views/settings/authentication/MIGRATION_PLAN.md @@ -0,0 +1,241 @@ +# Migration Plan: Authentication Settings + +**Linear Issue**: [BE-6604](https://linear.app/portainer/issue/BE-6604/migrate-portainersettingsauthentication) +**Project**: Migrate portainer/settings views to react +**Estimate**: 5 Points +**Status**: Draft → Planning +**Strategy**: See `.claude/skills/angular-react-migration-strategy/SKILL.md` +**Original Component Path**: `package/[ce|ee]/app/portainer/views/settings/authentication/` +**Target Migration Path**: `app/react/portainer/settings/AuthenticationView/` + +## Current Structure Visualization + +``` +PageHeader ✅ React component +Widget ✅ React component + Form + SessionLifetimeSection + Select (dropdown) - needs wrapper + WarningMessage ✅ React component (Icon + text) + + AuthenticationMethodSection + BoxSelector ✅ React component + + InternalAuth ✅ React component (already exists!) + PasswordLengthSlider ✅ + SaveAuthSettingsButton ✅ + + LdapSettings (Angular - needs migration) + AutoUserProvisionToggle (Angular - needs component) + BoxSelector ✅ React component + LdapSettingsCustom (Angular - needs migration) + Multiple input fields, TLS config, connectivity check + LdapSettingsDnBuilder (Angular - needs component) + LdapSettingsGroupDnBuilder (Angular - needs component) + LdapSettingsSecurity (Angular - needs component) + LdapSettingsTestLogin (Angular - needs component) + LdapSettingsOpenLdap (Angular - needs migration) + Similar structure to Custom + [EE] AdminGroupsMultiSelect (needs component) + + AdSettings (Angular - needs migration) + Similar to LdapSettings with AD-specific fields + [EE] BindTypeSelector (kerberos vs simple) + [EE] KerberosConfig (needs component) + + OAuthSettings (Angular - needs migration) + SwitchField ✅ React component + TeamSelector (Angular - needs component) + ClaimMappingsTable (Angular - needs component) + [EE] AdminAutoPopulate (needs component) +``` + +## EE vs CE Differences + +**EE-specific features:** + +- LDAP: `selected-admin-groups` binding (line 50, 62 in EE template) +- LDAP: Admin auto-populate with group selection +- AD: Kerberos bind type (simple vs kerberos) +- AD: Kerberos configuration fields (Realm, Username, Password, Configuration) +- OAuth: Admin group claims regex list mapping +- OAuth: Admin auto-populate toggle + +**Controller differences:** + +- EE has additional validation: `isOAuthAdminMappingFormValid()` (lines 270-281) +- EE has Kerberos initialization logic (lines 299-308) +- EE has admin groups handling in `prepareLDAPSettings()` (lines 207-215) +- EE has Kerberos settings cleanup (lines 190-203) +- EE has OAuth admin settings cleanup (lines 135-137) + +## Migration Strategy + +This is a **complex, multi-step migration** with the following phases: + +### Phase 1: Session Lifetime Section (Simple) + +- Migrate session lifetime dropdown to React +- Reuse existing React Select component +- Keep warning message as-is (already using React Icon component) + +### Phase 2: LDAP Settings (Most Complex) + +LDAP has deeply nested Angular components that need systematic migration: + +- Break down into smallest possible units +- Migrate shared subcomponents first (DN builders, security config) +- Then migrate LDAP Custom and OpenLDAP variants +- Handle EE-specific admin groups last + +### Phase 3: AD Settings (Complex with EE divergence) + +- Similar to LDAP but with AD-specific fields +- EE has additional Kerberos configuration +- Reuse components from Phase 2 where possible + +### Phase 4: OAuth Settings (Moderate) + +- Has team membership mappings +- EE has admin auto-populate +- Table-based claim mappings + +### Phase 5: Final Integration + +- Create container component with Formik +- Wire up all authentication methods +- Handle switching between auth methods +- Update routing + +## Issue Hierarchy + +``` +BE-6604 (Parent: Authentication Settings Migration) +├── Session & Page (Parallel) +│ ├── BE-12583: SessionLifetimeSelect +│ └── BE-12584: AuthenticationMethodSelector +│ +├── BE-12585: AutoUserProvisionToggle (Shared by LDAP/AD/OAuth) +│ +├── BE-12593: LdapSettings Container ⚠️ PARENT ISSUE +│ ├── BE-12586: LdapSettingsDnBuilder +│ ├── BE-12587: LdapSettingsGroupDnBuilder +│ ├── BE-12588: Extend TLSFieldset with StartTLS +│ ├── BE-12589: LdapSettingsTestLogin +│ ├── BE-12590: LdapSettingsCustom (🔒 blocked by 12586-89) +│ ├── BE-12591: LdapSettingsOpenLdap (🔒 blocked by 12586-89) +│ └── BE-12592: [EE] AdminGroupsMultiSelect +│ +├── BE-12596: AdSettings Container ⚠️ PARENT ISSUE (🔒 blocked by LDAP shared) +│ ├── BE-12594: [EE] BindTypeSelector +│ └── BE-12595: [EE] KerberosConfigFields +│ +├── BE-12600: OAuthSettings Container ⚠️ PARENT ISSUE +│ ├── BE-12597: TeamSelector +│ ├── BE-12598: ClaimMappingsTable +│ └── BE-12599: [EE] AdminAutoPopulateSection +│ +├── BE-12601: AuthenticationView Formik Container (🔒 blocked by 12593, 12596, 12600) +│ +└── BE-12602: Final Cleanup (🔒 blocked by 12601) +``` + +## PRs (Nested Structure) + +### Session & Page Structure (Parallel - No Dependencies) + +- [x] PR 1: [BE-12583](https://linear.app/portainer/issue/BE-12583) SessionLifetimeSelect → react2angular bridge +- [x] PR 2: [BE-12584](https://linear.app/portainer/issue/BE-12584) AuthenticationMethodSelector wrapper (BoxSelector is already React) + +### Internal Auth (Already Done!) + +- [x] ✅ Internal Auth is already fully migrated to React + +### [BE-12593: LDAP Settings](https://linear.app/portainer/issue/BE-12593) (Parent Issue) + +- [x] PR 3: [BE-12585](https://linear.app/portainer/issue/BE-12585) AutoUserProvisionToggle → shared component +- **LDAP Shared Components** (Parallel - No Dependencies) + - [x] PR 4: [BE-12586](https://linear.app/portainer/issue/BE-12586) LdapSettingsDnBuilder + - [x] PR 5: [BE-12587](https://linear.app/portainer/issue/BE-12587) LdapSettingsGroupDnBuilder + - [x] PR 6: [BE-12588](https://linear.app/portainer/issue/BE-12588) Extend TLSFieldset with StartTLS + - [x] PR 7: [BE-12589](https://linear.app/portainer/issue/BE-12589) LdapSettingsTestLogin +- **LDAP Variants** (Blocked by PR 4-7) + - [ ] PR 8: [BE-12590](https://linear.app/portainer/issue/BE-12590) LdapSettingsCustom + - [ ] PR 9: [BE-12591](https://linear.app/portainer/issue/BE-12591) LdapSettingsOpenLdap +- **EE Features** + - [x] PR 10: [BE-12592](https://linear.app/portainer/issue/BE-12592) [EE] AdminGroupsMultiSelect +- **Container** (Blocked by PR 3, 8, 9, 10) + - [ ] PR 11: [BE-12593](https://linear.app/portainer/issue/BE-12593) LdapSettings container + +### [BE-12596: AD Settings](https://linear.app/portainer/issue/BE-12596) (Parent Issue, Blocked by LDAP shared components) + +- **AD-Specific Components** (Parallel) + - [ ] PR 12: [BE-12594](https://linear.app/portainer/issue/BE-12594) [EE] BindTypeSelector + - [ ] PR 13: [BE-12595](https://linear.app/portainer/issue/BE-12595) [EE] KerberosConfigFields +- **Container** (Blocked by PR 3, 6, 7, 10, 12, 13) + - [ ] PR 14: [BE-12596](https://linear.app/portainer/issue/BE-12596) AdSettings container + +### [BE-12600: OAuth Settings](https://linear.app/portainer/issue/BE-12600) (Parent Issue) + +- **OAuth Components** (Parallel) + - [ ] PR 3: [BE-12585](https://linear.app/portainer/issue/BE-12585) AutoUserProvisionToggle (reused) + - [ ] PR 15: [BE-12597](https://linear.app/portainer/issue/BE-12597) TeamSelector + - [ ] PR 16: [BE-12598](https://linear.app/portainer/issue/BE-12598) ClaimMappingsTable + - [ ] PR 17: [BE-12599](https://linear.app/portainer/issue/BE-12599) [EE] AdminAutoPopulateSection +- **Container** (Blocked by PR 3, 15, 16, 17) + - [ ] PR 18: [BE-12600](https://linear.app/portainer/issue/BE-12600) OAuthSettings container + +### Final Integration (Blocked by All Parent Issues) + +- [ ] PR 19: [BE-12601](https://linear.app/portainer/issue/BE-12601) AuthenticationView container (Formik) - **Blocked by BE-12593, BE-12596, BE-12600** +- [ ] PR 20: [BE-12602](https://linear.app/portainer/issue/BE-12602) Complete React migration - **Blocked by PR 19** + +## Linear Issue Comparison + +The Linear issue BE-6604 provides a detailed breakdown of the component structure. Our migration plan aligns with it: + +**Linear's structure notes:** + +- ✅ PageHeader - already React +- ✅ Widget - already React +- ✅ InternalAuth - already React +- 📋 Need to migrate: ldap-settings, ad-settings, oauth-settings +- 📋 Need to create: oauth-team-memberships-fieldset, microsoft/google/github/custom-settings +- 📋 Shared components: auto-user-provision-toggle, ldap-connectivity-check, ldap-settings-security, ldap-settings-test-login + +**Our approach difference:** + +- Linear suggests creating vs migrating OAuth provider settings (microsoft, google, github, custom) +- We'll determine during implementation if these exist in Angular and need migration +- Our plan emphasizes bottom-up approach: migrate shared components first (DN builders, security, test login) before composing larger components + +## Decisions + +- **InternalAuth**: Already fully migrated to React, can reuse as-is ✅ +- **BoxSelector**: Already React component, can reuse ✅ +- **TLSFieldset**: Existing component can be extended/wrapped to add StartTLS support ✅ + - LDAP needs: StartTLS toggle + TLS toggle + skip verify + CA cert only (not cert/key) + - Existing TLSFieldset has: TLS + skip verify + CA cert + cert + key with validation + - Solution: Extend TLSFieldset with optional StartTLS prop, make cert/key optional +- **Bottom-up approach**: Start with smallest components (DN builders, toggles) before composing into larger components +- **EE handling**: Each PR must work in both CE and EE builds +- **Form validation**: Complex validation logic in controller needs to move into React components (each component exports its validation schema) +- **File upload**: LDAP TLS certificate upload needs special handling +- **Testing**: Focus on unit tests for each component (1-2 tests per file) + +## Key Challenges + +1. **Deep nesting**: LDAP settings has 3-4 levels of nested components +2. **Shared logic**: DN builders and security config shared between LDAP variants +3. **EE divergence**: Significant differences in LDAP/AD/OAuth between CE and EE +4. **Validation complexity**: Form validation depends on multiple interdependent fields +5. **State management**: Auth method switching, form state, and validation state +6. **File upload**: TLS certificate upload for LDAP + +## Technical Notes + +- **Validation**: `isLDAPFormValid()` has complex logic checking URLs, auth mode, TLS config, admin groups +- **Password handling**: Special logic for edit mode to avoid requiring password re-entry +- **URL formatting**: Automatically adds port (389 or 636) if not specified +- **Server type switching**: Switching between LDAP and AD affects which fields are shown/validated +- **Kerberos (EE)**: Bind type selector determines which credential fields are shown diff --git a/app/portainer/views/settings/authentication/settingsAuthentication.html b/app/portainer/views/settings/authentication/settingsAuthentication.html new file mode 100644 index 0000000..925e9fc --- /dev/null +++ b/app/portainer/views/settings/authentication/settingsAuthentication.html @@ -0,0 +1,63 @@ + + +
+
+ + + +
+
Configuration
+ + + +
+ + + Changing from default is only recommended if you have additional layers of authentication in front of Portainer. + +
+ + + + + + + + + + +
+
+
+
+
diff --git a/app/portainer/views/settings/authentication/settingsAuthenticationController.js b/app/portainer/views/settings/authentication/settingsAuthenticationController.js new file mode 100644 index 0000000..611139f --- /dev/null +++ b/app/portainer/views/settings/authentication/settingsAuthenticationController.js @@ -0,0 +1,253 @@ +import angular from 'angular'; +import _ from 'lodash-es'; + +import { buildLdapSettingsModel, buildAdSettingsModel } from '@/portainer/settings/authentication/ldap/ldap-settings.model'; +import { SERVER_TYPES } from '@/react/portainer/settings/AuthenticationView/ldap-options'; +import { AuthenticationMethod } from '@/react/portainer/settings/types'; +import { getDefaultValue as getDefaultSessionValue } from '@/react/portainer/settings/AuthenticationView/SessionLifetimeSelect'; + +angular.module('portainer.app').controller('SettingsAuthenticationController', SettingsAuthenticationController); + +function SettingsAuthenticationController($q, $scope, $state, Notifications, SettingsService, FileUploadService, TeamService, LDAPService) { + $scope.authMethod = 1; + + $scope.state = { + uploadInProgress: false, + actionInProgress: false, + }; + + $scope.formValues = { + UserSessionTimeout: getDefaultSessionValue(), + TLSCACert: '', + ldap: { + serverType: 0, + adSettings: buildAdSettingsModel(), + ldapSettings: buildLdapSettingsModel(), + }, + }; + + $scope.onChangeAuthMethod = function onChangeAuthMethod(value) { + $scope.$evalAsync(() => { + $scope.authMethod = value; + + if (value === 4) { + $scope.settings.AuthenticationMethod = AuthenticationMethod.LDAP; + $scope.formValues.ldap.serverType = SERVER_TYPES.AD; + return; + } + + if (value === 2) { + $scope.settings.AuthenticationMethod = AuthenticationMethod.LDAP; + $scope.formValues.ldap.serverType = $scope.formValues.ldap.ldapSettings.ServerType; + return; + } + $scope.settings.AuthenticationMethod = value; + }); + }; + + $scope.onChangePasswordLength = function onChangePasswordLength(value) { + $scope.$evalAsync(() => { + $scope.settings.InternalAuthSettings = { RequiredPasswordLength: value }; + }); + }; + + $scope.onChangeSessionLifetime = function onChangeSessionLifetime(value) { + $scope.$evalAsync(() => { + $scope.settings.UserSessionTimeout = value; + }); + }; + + $scope.authenticationMethodSelected = function authenticationMethodSelected(value) { + if (!$scope.settings) { + return false; + } + + if (value === AuthenticationMethod.AD) { + return $scope.settings.AuthenticationMethod === AuthenticationMethod.LDAP && $scope.formValues.ldap.serverType === SERVER_TYPES.AD; + } + + if (value === AuthenticationMethod.LDAP) { + return $scope.settings.AuthenticationMethod === AuthenticationMethod.LDAP && $scope.formValues.ldap.serverType !== SERVER_TYPES.AD; + } + + return $scope.settings.AuthenticationMethod === value; + }; + + $scope.isOauthEnabled = function isOauthEnabled() { + return $scope.settings && $scope.settings.AuthenticationMethod === AuthenticationMethod.OAuth; + }; + + $scope.LDAPConnectivityCheck = LDAPConnectivityCheck; + function LDAPConnectivityCheck() { + const settings = angular.copy($scope.settings); + + const { settings: ldapSettings, uploadRequired, tlscaFile } = prepareLDAPSettings(); + settings.LDAPSettings = ldapSettings; + $scope.state.uploadInProgress = uploadRequired; + + $scope.state.connectivityCheckInProgress = true; + + $q.when(!uploadRequired || FileUploadService.uploadLDAPTLSFiles(tlscaFile, null, null)) + .then(function success() { + return LDAPService.check(settings.LDAPSettings); + }) + .then(function success() { + $scope.state.failedConnectivityCheck = false; + $scope.state.successfulConnectivityCheck = true; + Notifications.success('Success', 'Connection to LDAP successful'); + }) + .catch(function error(err) { + $scope.state.failedConnectivityCheck = true; + $scope.state.successfulConnectivityCheck = false; + Notifications.error('Failure', err, 'Connection to LDAP failed'); + }) + .finally(function final() { + $scope.state.uploadInProgress = false; + $scope.state.connectivityCheckInProgress = false; + }); + } + + $scope.saveSettings = function () { + const settings = angular.copy($scope.settings); + + const { settings: ldapSettings, uploadRequired, tlscaFile } = prepareLDAPSettings(); + settings.LDAPSettings = ldapSettings; + $scope.state.uploadInProgress = uploadRequired; + + $scope.state.actionInProgress = true; + + $q.when(!uploadRequired || FileUploadService.uploadLDAPTLSFiles(tlscaFile, null, null)) + .then(function success() { + return SettingsService.update(settings); + }) + .then(function success() { + Notifications.success('Success', 'Authentication settings updated'); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to update authentication settings'); + }) + .finally(function final() { + $scope.state.uploadInProgress = false; + $scope.state.actionInProgress = false; + }); + }; + + function prepareLDAPSettings() { + const tlscaCert = $scope.formValues.TLSCACert; + + const tlscaFile = tlscaCert !== $scope.settings.LDAPSettings.TLSConfig.TLSCACert ? tlscaCert : null; + + const isADServer = $scope.formValues.ldap.serverType === SERVER_TYPES.AD; + + const settings = isADServer ? $scope.formValues.ldap.adSettings : $scope.formValues.ldap.ldapSettings; + + if (settings.AnonymousMode && !isADServer) { + settings.ReaderDN = ''; + settings.Password = ''; + } + + if (isADServer) { + settings.AnonymousMode = false; + } + + settings.URLs = settings.URLs.map((url) => { + if (url === undefined || url === '') { + return; + } + + if (url.includes(':')) { + return url; + } + return url + (settings.TLSConfig.TLS ? ':636' : ':389'); + }); + + const uploadRequired = (settings.TLSConfig.TLS || settings.StartTLS) && !settings.TLSConfig.TLSSkipVerify; + + settings.URL = settings.URLs[0]; + + return { settings, uploadRequired, tlscaFile }; + } + + $scope.isLDAPFormValid = isLDAPFormValid; + function isLDAPFormValid() { + const ldapSettings = $scope.formValues.ldap.serverType === SERVER_TYPES.AD ? $scope.formValues.ldap.adSettings : $scope.formValues.ldap.ldapSettings; + const isTLSMode = ldapSettings.TLSConfig.TLS || ldapSettings.StartTLS; + + return ( + _.compact(ldapSettings.URLs).length && + (ldapSettings.AnonymousMode || (ldapSettings.ReaderDN && isLDAPPasswordValid(ldapSettings.Password))) && + (!isTLSMode || (isTLSMode && $scope.formValues.TLSCACert) || ldapSettings.TLSConfig.TLSSkipVerify) && + (!$scope.settings.LDAPSettings.AdminAutoPopulate || ($scope.settings.LDAPSettings.AdminAutoPopulate && $scope.formValues.selectedAdminGroups.length > 0)) + ); + } + + // isLDAPPasswordValid is used to validate the password field in the LDAP settings form, and avoids the password field to be required when the form is in edit mode + function isLDAPPasswordValid(password) { + // if isEdit and it doesn't switch between AD and LDAP, then an empty password is valid + if ($scope.state.isEditLDAP && $scope.state.initialServerType === $scope.formValues.ldap.serverType) { + return true; + } + // otherwise the password is required + return !!password; + } + + $scope.isOAuthTeamMembershipFormValid = isOAuthTeamMembershipFormValid; + function isOAuthTeamMembershipFormValid() { + if ($scope.settings && $scope.settings.OAuthSettings.OAuthAutoMapTeamMemberships && $scope.settings.OAuthSettings.TeamMemberships) { + if (!$scope.settings.OAuthSettings.TeamMemberships.OAuthClaimName) { + return false; + } + + const hasInvalidMapping = $scope.settings.OAuthSettings.TeamMemberships.OAuthClaimMappings.some((m) => !(m.ClaimValRegex && m.Team)); + if (hasInvalidMapping) { + return false; + } + } + return true; + } + + function initView() { + $q.all({ + settings: SettingsService.settings(), + teams: TeamService.teams(), + }) + .then(function success(data) { + var settings = data.settings; + $scope.teams = data.teams; + $scope.settings = settings; + + $scope.OAuthSettings = settings.OAuthSettings; + $scope.authMethod = settings.AuthenticationMethod; + if (settings.AuthenticationMethod === AuthenticationMethod.LDAP && settings.LDAPSettings.ServerType === SERVER_TYPES.AD) { + $scope.authMethod = AuthenticationMethod.AD; + } + + if (settings.LDAPSettings.URL) { + settings.LDAPSettings.URLs = [settings.LDAPSettings.URL]; + } + if (!settings.LDAPSettings.URLs) { + settings.LDAPSettings.URLs = []; + } + if (!settings.LDAPSettings.URLs.length) { + settings.LDAPSettings.URLs.push(''); + } + if (!settings.LDAPSettings.ServerType) { + settings.LDAPSettings.ServerType = SERVER_TYPES.CUSTOM; + } + + $scope.formValues.ldap.serverType = settings.LDAPSettings.ServerType; + if (settings.LDAPSettings.ServerType === SERVER_TYPES.AD) { + $scope.formValues.ldap.adSettings = settings.LDAPSettings; + } else { + $scope.formValues.ldap.ldapSettings = Object.assign($scope.formValues.ldap.ldapSettings, settings.LDAPSettings); + } + $scope.state.isEditLDAP = settings.LDAPSettings.ServerType === SERVER_TYPES.AD || settings.LDAPSettings.ServerType === SERVER_TYPES.LDAP; + $scope.state.initialServerType = settings.LDAPSettings.ServerType; + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve application settings'); + }); + } + + initView(); +} diff --git a/app/portainer/views/settings/edge-compute/index.js b/app/portainer/views/settings/edge-compute/index.js new file mode 100644 index 0000000..e9c9e52 --- /dev/null +++ b/app/portainer/views/settings/edge-compute/index.js @@ -0,0 +1,7 @@ +import angular from 'angular'; +import controller from './settingsEdgeComputeController'; + +angular.module('portainer.app').component('settingsEdgeComputeView', { + templateUrl: './settingsEdgeCompute.html', + controller, +}); diff --git a/app/portainer/views/settings/edge-compute/settingsEdgeCompute.html b/app/portainer/views/settings/edge-compute/settingsEdgeCompute.html new file mode 100644 index 0000000..e3462b6 --- /dev/null +++ b/app/portainer/views/settings/edge-compute/settingsEdgeCompute.html @@ -0,0 +1,7 @@ + + +
+
+ +
+
diff --git a/app/portainer/views/settings/edge-compute/settingsEdgeComputeController.js b/app/portainer/views/settings/edge-compute/settingsEdgeComputeController.js new file mode 100644 index 0000000..f5abcf4 --- /dev/null +++ b/app/portainer/views/settings/edge-compute/settingsEdgeComputeController.js @@ -0,0 +1,47 @@ +import _ from 'lodash-es'; +import angular from 'angular'; + +angular.module('portainer.app').controller('SettingsEdgeComputeController', SettingsEdgeComputeController); + +/* @ngInject */ +export default function SettingsEdgeComputeController($q, $async, $state, Notifications, SettingsService, StateManager) { + var ctrl = this; + + this.onSubmitEdgeCompute = async function (settings) { + try { + await SettingsService.update(settings); + Notifications.success('Success', 'Settings updated'); + StateManager.updateEnableEdgeComputeFeatures(settings.EnableEdgeComputeFeatures); + $state.reload(); + } catch (err) { + Notifications.error('Failure', err, 'Unable to update settings'); + } + }; + + function initView() { + $async(async () => { + try { + const settings = await SettingsService.settings(); + + const defaultMTLS = { + ..._.get(settings, 'Edge.MTLS', {}), + UseSeparateCert: _.get(settings, 'Edge.MTLS.UseSeparateCert', false), + }; + + ctrl.settings = { + ...settings, + EnableEdgeComputeFeatures: !!settings.EnableEdgeComputeFeatures, + EnforceEdgeID: !!settings.EnforceEdgeID, + Edge: { + ...settings.Edge, + MTLS: defaultMTLS, + }, + }; + } catch (err) { + Notifications.error('Failure', err, 'Unable to retrieve application settings'); + } + }); + } + + initView(); +} diff --git a/app/portainer/views/stacks/stacks.html b/app/portainer/views/stacks/stacks.html new file mode 100644 index 0000000..a0ebe45 --- /dev/null +++ b/app/portainer/views/stacks/stacks.html @@ -0,0 +1,9 @@ + + + diff --git a/app/portainer/views/stacks/stacksController.js b/app/portainer/views/stacks/stacksController.js new file mode 100644 index 0000000..287e817 --- /dev/null +++ b/app/portainer/views/stacks/stacksController.js @@ -0,0 +1,63 @@ +import { processItemsInBatches } from '@/react/common/processItemsInBatches'; + +angular.module('portainer.app').controller('StacksController', StacksController); + +/* @ngInject */ +function StacksController($scope, $state, Notifications, StackService, Authentication, endpoint) { + $scope.removeAction = function (selectedItems) { + return deleteSelectedStacks(selectedItems); + }; + + async function deleteSelectedStacks(selectedItems) { + const endpointId = endpoint.Id; + + async function doRemove(stack) { + return StackService.remove(stack, stack.External, endpointId) + .then(function success() { + Notifications.success('Stack successfully removed', stack.Name); + var index = $scope.stacks.indexOf(stack); + $scope.stacks.splice(index, 1); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to remove stack ' + stack.Name); + }); + } + + await processItemsInBatches(selectedItems, doRemove); + $state.reload(); + } + + $scope.createEnabled = false; + + $scope.getStacks = getStacks; + + function getStacks() { + const endpointMode = $scope.applicationState.endpoint.mode; + const endpointId = endpoint.Id; + + const includeOrphanedStacks = Authentication.isAdmin(); + StackService.stacks(true, endpointMode.provider === 'DOCKER_SWARM_MODE' && endpointMode.role === 'MANAGER', endpointId, includeOrphanedStacks) + .then(function success(stacks) { + $scope.stacks = stacks; + }) + .catch(function error(err) { + $scope.stacks = []; + Notifications.error('Failure', err, 'Unable to retrieve stacks'); + }); + } + + async function canManageStacks() { + return endpoint.SecuritySettings.allowStackManagementForRegularUsers || Authentication.isAdmin(); + } + + async function initView() { + // if the user is not an admin, and stack management is disabled for non admins, then take the user to the dashboard + $scope.createEnabled = await canManageStacks(); + if (!$scope.createEnabled) { + $state.go('docker.dashboard'); + } + getStacks(); + } + + initView(); +} diff --git a/app/portainer/views/tags/tags.html b/app/portainer/views/tags/tags.html new file mode 100644 index 0000000..fbbd65c --- /dev/null +++ b/app/portainer/views/tags/tags.html @@ -0,0 +1,64 @@ + + +
+
+ + + +
+ +
+ +
+ +
+
+
+

+ + This field is required. +

+

+ + This tag already exists. +

+
+
+
+
+
+ +
+
+ +
+
+
+
+
+
+
+ + diff --git a/app/portainer/views/tags/tagsController.js b/app/portainer/views/tags/tagsController.js new file mode 100644 index 0000000..ae86039 --- /dev/null +++ b/app/portainer/views/tags/tagsController.js @@ -0,0 +1,71 @@ +import angular from 'angular'; +import _ from 'lodash-es'; + +angular.module('portainer.app').controller('TagsController', TagsController); + +function TagsController($scope, $state, $async, TagService, Notifications) { + $scope.state = { + actionInProgress: false, + }; + + $scope.formValues = { + Name: '', + }; + + $scope.checkNameValidity = function (form) { + var valid = true; + for (var i = 0; i < $scope.tags.length; i++) { + if ($scope.formValues.Name === $scope.tags[i].Name) { + valid = false; + break; + } + } + form.name.$setValidity('validName', valid); + }; + + $scope.removeAction = removeAction; + + function removeAction(tags) { + return $async(removeActionAsync, tags); + } + + async function removeActionAsync(tags) { + for (let tag of tags) { + try { + await TagService.deleteTag(tag.Id); + + Notifications.success('Tag successfully removed', tag.Name); + _.remove($scope.tags, tag); + } catch (err) { + Notifications.error('Failure', err, 'Unable to remove tag'); + } + } + + $state.reload(); + } + + $scope.createTag = function () { + var tagName = $scope.formValues.Name; + TagService.createTag(tagName) + .then(function success() { + Notifications.success('Tag successfully created', tagName); + $state.reload(); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to create tag'); + }); + }; + + function initView() { + TagService.tags() + .then(function success(data) { + $scope.tags = data; + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve tags'); + $scope.tags = []; + }); + } + + initView(); +} diff --git a/app/portainer/views/users/edit/user.html b/app/portainer/views/users/edit/user.html new file mode 100644 index 0000000..b51c9be --- /dev/null +++ b/app/portainer/views/users/edit/user.html @@ -0,0 +1,86 @@ + + +
+
+ + + +
+
+ +
+ +
+
+ +
+
+ +
+
+ +
+
+ + +
+
+
+
+
+
+
+ +
+
+ + + +
+ +
+ +
+ +
+
+ + +
+ +
+
+ + + + + +
+
+
+ + +
+
+ +
+
+
+
+
+
+
diff --git a/app/portainer/views/users/edit/userController.js b/app/portainer/views/users/edit/userController.js new file mode 100644 index 0000000..e27667c --- /dev/null +++ b/app/portainer/views/users/edit/userController.js @@ -0,0 +1,135 @@ +import { ModalType } from '@@/modals'; +import { buildConfirmButton } from '@@/modals/utils'; +import { confirm, confirmChangePassword, confirmDelete } from '@@/modals/confirm'; + +angular.module('portainer.app').controller('UserController', [ + '$q', + '$scope', + '$state', + '$transition$', + 'UserService', + 'Notifications', + 'SettingsService', + 'Authentication', + function ($q, $scope, $state, $transition$, UserService, Notifications, SettingsService, Authentication) { + $scope.state = { + updatePasswordError: '', + }; + + $scope.formValues = { + username: '', + newPassword: '', + confirmPassword: '', + Administrator: false, + }; + + $scope.handleAdministratorChange = function (checked) { + return $scope.$evalAsync(() => { + $scope.formValues.Administrator = checked; + }); + }; + + $scope.deleteUser = function () { + confirmDelete('Do you want to remove this user? This user will not be able to login into Portainer anymore.').then((confirmed) => { + if (!confirmed) { + return; + } + deleteUser(); + }); + }; + + $scope.updateUser = async function () { + const role = $scope.formValues.Administrator ? 1 : 2; + const oldUsername = $scope.user.Username; + const username = $scope.formValues.username; + + if (username != oldUsername) { + const confirmed = await confirm({ + title: 'Are you sure?', + modalType: ModalType.Warn, + message: `Are you sure you want to rename the user ${oldUsername} to ${username}?`, + confirmButton: buildConfirmButton('Update'), + }); + + if (!confirmed) { + return; + } + } + + UserService.updateUser($scope.user.Id, { role, username }) + .then(function success() { + Notifications.success('Success', 'User successfully updated'); + $state.reload(); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to update user permissions'); + }); + }; + + $scope.updatePassword = async function () { + const isCurrentUser = Authentication.getUserDetails().ID === $scope.user.Id; + const confirmed = !isCurrentUser || (await confirmChangePassword()); + if (!confirmed) { + return; + } + UserService.updateUser($scope.user.Id, { newPassword: $scope.formValues.newPassword }) + .then(function success() { + Notifications.success('Success', 'Password successfully updated'); + + if (isCurrentUser) { + $state.go('portainer.logout'); + } else { + $state.reload(); + } + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to update user password'); + }); + }; + + function deleteUser() { + UserService.deleteUser($scope.user.Id) + .then(function success() { + Notifications.success('User successfully deleted', $scope.user.Username); + $state.go('portainer.users'); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to remove user'); + }); + } + + $scope.isSubmitEnabled = isSubmitEnabled; + function isSubmitEnabled() { + const { user, formValues } = $scope; + return user && (user.Username !== formValues.username || (formValues.Administrator && user.Role !== 1) || (!formValues.Administrator && user.Role === 1)); + } + + $scope.isDeleteDisabled = isDeleteDisabled; + function isDeleteDisabled() { + const { user } = $scope; + return user && user.Id === 1; + } + + function initView() { + $scope.isAdmin = Authentication.isAdmin(); + + $q.all({ + user: UserService.user($transition$.params().id), + settings: SettingsService.publicSettings(), + }) + .then(function success(data) { + var user = data.user; + $scope.user = user; + $scope.formValues.Administrator = user.Role === 1; + $scope.formValues.username = user.Username; + $scope.AuthenticationMethod = data.settings.AuthenticationMethod; + $scope.requiredPasswordLength = data.settings.RequiredPasswordLength; + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve user information'); + }); + } + + initView(); + }, +]); diff --git a/app/react-table-config.d.ts b/app/react-table-config.d.ts new file mode 100644 index 0000000..276e9ba --- /dev/null +++ b/app/react-table-config.d.ts @@ -0,0 +1,10 @@ +import '@tanstack/react-table'; + +declare module '@tanstack/react-table' { + interface ColumnMeta { + className?: string; + filter?: Filter; + width?: number | 'auto' | string; + minWidth?: string; + } +} diff --git a/app/react-tools/apply-set-state-action.ts b/app/react-tools/apply-set-state-action.ts new file mode 100644 index 0000000..5c84bff --- /dev/null +++ b/app/react-tools/apply-set-state-action.ts @@ -0,0 +1,12 @@ +import { SetStateAction } from 'react'; + +export function applySetStateAction(applier: SetStateAction, values: T) { + if (isFunction(applier)) { + return applier(values); + } + return applier; + + function isFunction(value: unknown): value is (prevState: T) => T { + return typeof value === 'function'; + } +} diff --git a/app/react-tools/react-query.ts b/app/react-tools/react-query.ts new file mode 100644 index 0000000..2156b36 --- /dev/null +++ b/app/react-tools/react-query.ts @@ -0,0 +1,124 @@ +import { + MutationCache, + MutationOptions, + QueryCache, + QueryClient, + QueryKey, + QueryOptions, +} from '@tanstack/react-query'; + +import { notifyError } from '@/portainer/services/notifications'; +import { isAxiosError } from '@/react/portainer/services/axios/utils/isAxiosError'; +import { parseAxiosError } from '@/react/portainer/services/axios/utils/parseAxiosError'; + +export function withError(fallbackMessage?: string, title = 'Failure') { + return { + meta: { + error: { message: fallbackMessage, title }, + }, + }; +} + +type OptionalReadonly = T | Readonly; + +export function withInvalidate( + queryClient: QueryClient, + queryKeysToInvalidate: Array>>, + // skipRefresh will set the mutation state to success without waiting for the invalidated queries to refresh + // see the following for info: https://tkdodo.eu/blog/mastering-mutations-in-react-query#awaited-promises + { skipRefresh }: { skipRefresh?: boolean } = {} +) { + return { + onSuccess() { + const promise = Promise.all( + queryKeysToInvalidate.map((keys) => queryClient.invalidateQueries(keys)) + ); + return skipRefresh + ? undefined // don't wait for queries to refresh before setting state to success + : promise; // stay loading until all queries are refreshed + }, + }; +} + +export function mutationOptions< + TData = unknown, + TError = unknown, + TVariables = void, + TContext = unknown, +>(...options: MutationOptions[]) { + return mergeOptions(options); +} + +export function queryOptions< + TQueryFnData = unknown, + TError = unknown, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +>(...options: QueryOptions[]) { + return mergeOptions(options); +} + +function mergeOptions(options: T[]) { + return options.reduce( + (acc, option) => ({ + ...acc, + ...option, + }), + {} as T + ); +} + +export function createQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { + networkMode: 'offlineFirst', + staleTime: 20, + }, + }, + mutationCache: new MutationCache({ + onError: (error, variable, context, mutation) => { + handleError(error, mutation.meta?.error); + }, + }), + queryCache: new QueryCache({ + onError: (error, mutation) => { + handleError(error, mutation.meta?.error); + }, + }), + }); +} + +function handleError(error: unknown, errorMeta?: unknown) { + const meta = extractErrorMeta(errorMeta); + + // suppress error when `meta.error` is falsy + if (!meta) { + return; + } + + const parsedError = isAxiosError(error) + ? parseAxiosError(error, meta?.message ?? '') + : error; + + notifyError(meta?.title || 'Failure', parsedError, meta?.message); +} + +function extractErrorMeta(errorMeta?: unknown) { + if (!errorMeta || typeof errorMeta !== 'object') { + return undefined; + } + + let title = ''; + if ('title' in errorMeta && typeof errorMeta.title === 'string') { + title = errorMeta.title; + } + let message = ''; + if ('message' in errorMeta && typeof errorMeta.message === 'string') { + message = errorMeta.message; + } + + return { title, message }; +} + +export const queryClient = createQueryClient(); diff --git a/app/react-tools/react2angular.tsx b/app/react-tools/react2angular.tsx new file mode 100644 index 0000000..f00ebb5 --- /dev/null +++ b/app/react-tools/react2angular.tsx @@ -0,0 +1,81 @@ +import ReactDOM from 'react-dom'; +import { IComponentOptions, IController } from 'angular'; +import { StrictMode } from 'react'; +import _ from 'lodash'; + +function toProps( + propNames: string[], + controller: IController, + $q: ng.IQService +) { + return Object.fromEntries( + propNames.map((key) => { + const prop = controller[key]; + if (typeof prop !== 'function') { + return [key, prop]; + } + + return [ + key, + (...args: unknown[]) => + $q((resolve) => resolve(controller[key](...args))), + ]; + }) + ); +} + +export type PropNames = Exclude; + +/** + * react2angular is used to bind a React component to an AngularJS component + * it used in an AngularJS module definition: + * + * `.component('componentName', react2angular(ComponentName, ['prop1', 'prop2']))` + * + * if the second parameter has any ts errors check that the component has the correct props + */ +export function react2angular[]>( + Component: React.ComponentType, + propNames: U & ([PropNames] extends [U[number]] ? unknown : PropNames) +): IComponentOptions & { name: string } { + const bindings = Object.fromEntries(propNames.map((key) => [key, '<'])); + + return { + bindings, + controller: Controller, + name: _.camelCase(Component.displayName || Component.name), + }; + + /* @ngInject */ + function Controller( + this: IController, + $element: HTMLElement[], + $q: ng.IQService + ) { + let isDestroyed = false; + const el = $element[0]; + + this.$onChanges = () => { + if (!isDestroyed) { + const props = toProps(propNames, this, $q); + ReactDOM.render( + + {/* eslint-disable-next-line react/jsx-props-no-spreading */} + + , + + el + ); + } + }; + + this.$onDestroy = () => { + if (!isDestroyed) { + isDestroyed = true; + ReactDOM.unmountComponentAtNode(el); + } + }; + } +} + +export const r2a = react2angular; diff --git a/app/react-tools/test-mocks.ts b/app/react-tools/test-mocks.ts new file mode 100644 index 0000000..90c3b12 --- /dev/null +++ b/app/react-tools/test-mocks.ts @@ -0,0 +1,294 @@ +import _ from 'lodash'; +import { QueryObserverResult } from '@tanstack/react-query'; + +import { Team } from '@/react/portainer/users/teams/types'; +import { Role, User, UserId } from '@/portainer/users/types'; +import { + ContainerEngine, + Environment, +} from '@/react/portainer/environments/types'; +import { Stack, StackStatus, StackType } from '@/react/common/stacks/types'; +import { ContainerDetailsViewModel } from '@/docker/models/containerDetails'; +import { EnvironmentGroup } from '@/react/portainer/environments/environment-groups/types'; + +export function createMockUser(overrides: Partial = {}) { + return { + Id: 1, + Username: 'user', + RoleName: '', + AuthenticationMethod: '', + Checked: false, + EndpointAuthorizations: {}, + PortainerAuthorizations: {}, + UseCache: false, + ThemeSettings: { + color: 'auto', + subtleUpgradeButton: false, + ...overrides.ThemeSettings, + }, + ...overrides, + } as User; +} + +export function createMockUsers( + count: number, + roles: Role | Role[] | ((id: UserId) => Role) +): User[] { + return _.range(1, count + 1).map((value) => + createMockUser({ + Id: value, + Username: `user${value}`, + Role: getRoles(roles, value), + }) + ); +} + +function getRoles( + roles: Role | Role[] | ((id: UserId) => Role), + id: UserId +): Role { + if (typeof roles === 'function') { + return roles(id); + } + + if (typeof roles === 'number') { + return roles; + } + + // Roles is an array + if (roles.length === 0) { + throw new Error('No roles provided'); + } + + // The number of roles is not necessarily the same length as the number of users + // so we need to distribute the roles evenly and consistently + return roles[(id - 1) % roles.length]; +} + +export function createMockTeams(count: number): Team[] { + return _.range(1, count + 1).map((value) => ({ + Id: value, + Name: `team${value}`, + })); +} + +export function createMockSubscriptions(count: number) { + const subscriptions = _.range(1, count + 1).map((x) => ({ + id: `/subscriptions/subscription-${x}`, + subscriptionId: `subscription-${x}`, + })); + + return { value: subscriptions }; +} + +export function createMockResourceGroups(subscription: string, count: number) { + const resourceGroups = _.range(1, count + 1).map((x) => ({ + id: `/subscriptions/${subscription}/resourceGroups/resourceGroup-${x}`, + name: `resourcegroup-${x}`, + })); + + return { value: resourceGroups }; +} + +export function createMockEnvironment( + overrides: Partial = {} +): Environment { + return { + TagIds: [], + GroupId: 1, + Type: 1, + ContainerEngine: ContainerEngine.Docker, + Name: 'environment', + Status: 1, + URL: 'url', + Snapshots: [], + Kubernetes: { + Flags: { + IsServerMetricsDetected: true, + IsServerIngressClassDetected: true, + IsServerStorageDetected: true, + }, + Snapshots: [], + Configuration: { + IngressClasses: [], + IngressAvailabilityPerNamespace: false, + AllowNoneIngressClass: false, + }, + }, + UserAccessPolicies: {}, + TeamAccessPolicies: {}, + ComposeSyntaxMaxVersion: '0', + EdgeKey: '', + EnableGPUManagement: false, + Id: 3, + UserTrusted: false, + Edge: { + AsyncMode: false, + PingInterval: 0, + CommandInterval: 0, + SnapshotInterval: 0, + }, + SecuritySettings: { + allowBindMountsForRegularUsers: false, + allowPrivilegedModeForRegularUsers: false, + allowContainerCapabilitiesForRegularUsers: false, + allowDeviceMappingForRegularUsers: false, + allowHostNamespaceForRegularUsers: false, + allowStackManagementForRegularUsers: false, + allowSysctlSettingForRegularUsers: false, + allowSecurityOptForRegularUsers: false, + allowVolumeBrowserForRegularUsers: false, + enableHostManagementFeatures: false, + }, + DeploymentOptions: { + overrideGlobalOptions: false, + hideAddWithForm: true, + hideWebEditor: false, + hideFileUpload: false, + }, + Gpus: [], + Agent: { Version: '1.0.0' }, + EnableImageNotification: false, + ChangeWindow: { + Enabled: false, + EndTime: '', + StartTime: '', + }, + StatusMessage: { + detail: '', + summary: '', + }, + AzureCredentials: { + ApplicationID: '', + AuthenticationKey: '', + TenantID: '', + }, + EdgeCheckinInterval: 0, + PublicURL: '', + TLSConfig: { + TLS: false, + TLSSkipVerify: false, + }, + LastCheckInDate: 0, + ...overrides, + }; +} + +export function createMockEnvironmentGroup( + overrides: Partial = {} +): EnvironmentGroup { + return { + Id: 1, + Name: 'Unassigned', + Description: '', + ...overrides, + }; +} + +export function createMockQueryResult( + data: TData, + overrides?: Partial> +) { + const defaultResult = { + data, + dataUpdatedAt: 0, + error: null, + errorUpdatedAt: 0, + failureCount: 0, + errorUpdateCount: 0, + failureReason: null, + isError: false, + isFetched: true, + isFetchedAfterMount: true, + isFetching: false, + isInitialLoading: false, + isLoading: false, + isLoadingError: false, + isPaused: false, + isPlaceholderData: false, + isPreviousData: false, + isRefetchError: false, + isRefetching: false, + isStale: false, + isSuccess: true, + refetch: async () => defaultResult, + remove: () => {}, + status: 'success', + fetchStatus: 'idle', + }; + + return { ...defaultResult, ...overrides }; +} + +export function createMockStack(overrides?: Partial): Stack { + return { + Id: 1, + Name: 'test-stack', + Type: StackType.DockerCompose, + EndpointId: 1, + SwarmId: '', + EntryPoint: 'docker-compose.yml', + Env: [], + Status: StackStatus.Active, + ProjectPath: '/data/compose/1', + CreationDate: Date.now(), + CreatedBy: 'admin', + UpdateDate: Date.now(), + UpdatedBy: 'admin', + FromAppTemplate: false, + IsComposeFormat: true, + SupportRelativePath: false, + FilesystemPath: '/data/compose/1', + StackFileVersion: 1, + PreviousDeploymentInfo: undefined, + ...overrides, + }; +} + +export function createMockContainer( + overrides?: Partial +): ContainerDetailsViewModel { + return _.merge( + { + Id: 'container-id-123', + Image: 'sha256:abcd1234', + State: { + Status: 'running', + Running: true, + Paused: false, + Restarting: false, + OOMKilled: false, + Dead: false, + Pid: 1234, + ExitCode: 0, + Error: '', + StartedAt: '2024-01-01T00:00:00Z', + FinishedAt: '0001-01-01T00:00:00Z', + Health: undefined, + }, + Created: '2024-01-01T00:00:00Z', + Name: '/test-container', + NetworkSettings: { + Ports: { + '80/tcp': [{ HostIp: '0.0.0.0', HostPort: '8080' }], + }, + }, + Args: [], + Config: { + Image: 'nginx:latest', + Cmd: ['nginx', '-g', 'daemon off;'], + Entrypoint: [], + Env: ['PATH=/usr/local/bin', 'NODE_ENV=production'], + Labels: { 'com.example.label': 'value' }, + }, + HostConfig: { + RestartPolicy: { Name: 'always', MaximumRetryCount: 0 }, + Sysctls: { 'net.ipv4.ip_forward': '1' }, + DeviceRequests: [], + }, + Mounts: [], + Model: {} as ContainerDetailsViewModel['Model'], + }, + overrides + ) as ContainerDetailsViewModel; +} diff --git a/app/react-tools/useDebugPropChanges.ts b/app/react-tools/useDebugPropChanges.ts new file mode 100644 index 0000000..80d133e --- /dev/null +++ b/app/react-tools/useDebugPropChanges.ts @@ -0,0 +1,54 @@ +/* eslint-disable react-hooks/refs */ +/* eslint-disable no-console */ +import { intersection } from 'lodash'; +import { useEffect, useRef } from 'react'; + +function logPropDifferences( + newProps: Record, + lastProps: Record, + verbose: boolean +) { + const allKeys = intersection(Object.keys(newProps), Object.keys(lastProps)); + + const changedKeys: string[] = []; + + allKeys.forEach((key) => { + const newValue = newProps[key]; + const lastValue = lastProps[key]; + if (newValue !== lastValue) { + changedKeys.push(key); + } + }); + + if (changedKeys.length) { + if (verbose) { + changedKeys.forEach((key) => { + const newValue = newProps[key]; + const lastValue = lastProps[key]; + console.log('Key [', key, '] changed'); + console.log('From: ', lastValue); + console.log('To: ', newValue); + console.log('------'); + }); + } else { + console.log('Changed keys: ', changedKeys.join()); + } + } +} + +export function useDebugPropChanges( + newProps: Record, + verbose: boolean = true +) { + const lastProps = useRef>(); + // Should only run when the component re-mounts + useEffect(() => { + console.log('Mounted'); + }, []); + if (lastProps.current) { + logPropDifferences(newProps, lastProps.current, verbose); + } + lastProps.current = newProps; +} +/* eslint-enable react-hooks/refs */ +/* eslint-enable no-console */ diff --git a/app/react-tools/withControlledInput.tsx b/app/react-tools/withControlledInput.tsx new file mode 100644 index 0000000..7ef5226 --- /dev/null +++ b/app/react-tools/withControlledInput.tsx @@ -0,0 +1,137 @@ +import { ComponentType, useEffect, useMemo, useState } from 'react'; + +type KeyRecord = Record; + +type KeysOfFunctions = keyof { + [K in keyof T as T[K] extends (v: never) => void ? K : never]: never; +}; + +type KeysWithoutFunctions = Exclude>; + +/** + * React component wrapper that will sync AngularJS bound variables in React state. + * This wrapper is mandatory to allow the binding of AngularJS variables directly in controlled components. + * + * Without this the component will be redrawn everytime the value changes in AngularJS (outside of React control) + * and the redraw will create issues when typing in the inputs. + * + * Examples + * --- + * SINGLE PAIR + * ```tsx + * type Props = { + * value: unknown; + * onChange: (v: unknown) => void; + * } + * + * function ReactComponent({ value, onChange }: Props) { + * return + * } + * + * r2a(withControlledInput(ReactComponent), ['value', 'onChange']); + * + * ``` + * --- + * MULTIPLE PAIRS + * ```tsx + * type Props = { + * valueStr: string; + * onChangeStr: (v: string) => void; + * valueNum: number; + * onChangeNum: (v: number) => void; + * } + * + * function ReactComponent({ valueStr, onChangeStr, valueNum, onChangeNum }: Props) { + * return ( + * <> + * + * + * + * ); + * } + * + * r2a(withControlledInput(ReactComponent, { + * valueStr: 'onChangeStr', + * valueNum: 'onChangeNum' + * }), + * ['valueStr', 'onChangeStr', 'valueNum', 'onChangeNum'] + * ) + * + * ``` + * + * @param WrappedComponent The React component to wrap + * @param controlledValueOnChangePairs A map of `(value:onChange)-like` pairs. + * @returns WrappedComponent + */ +export function withControlledInput( + WrappedComponent: ComponentType, + controlledValueOnChangePairs: + | { + [Key in KeysWithoutFunctions]?: KeysOfFunctions; + } + | { value?: 'onChange' } = { value: 'onChange' } +): ComponentType { + // Try to create a nice displayName for React Dev Tools. + const displayName = + WrappedComponent.displayName || WrappedComponent.name || 'Component'; + + // extract keys of values that will be updated outside of React lifecycle and their handler functions + const keysToControl = Object.entries(controlledValueOnChangePairs) as [ + keyof T, + keyof T, + ][]; + + function WrapperComponent(props: T) { + // map of key = value for all tracked values that will be changed outside of React lifecycle + // e.g. save in React state the values that will be changed in AngularJS + const [controlledValues, setControlledValues] = useState>( + {} as KeyRecord + ); + + // generate a map of `funckey = (v: typeof props[value_key]) => void` to wrapp all handler functions. + // each wrapped handler is referencing the key of the value it is changing so we can't use a single sync func + const handlers = useMemo( + () => + Object.fromEntries( + keysToControl.map(([valueKey, onChangeKey]) => [ + onChangeKey, + // generate and save a func that uses the key of the updated value + (value: T[keyof T]) => { + // update the state with the value coming from WrappedComponent + setControlledValues( + (c) => + ({ + ...c, + [valueKey]: value, + }) as KeyRecord + ); + + // call the bound handler func to update the value outside of React + + const onChange = props[onChangeKey]; + if (typeof onChange === 'function') { + onChange(value); + } + }, + ]) + ), + [props] + ); + + // update the React state when a prop changes from outside of React lifecycle + // limit keys to update to only tracked values ; ignore untracked props and handler functions + useEffect(() => { + const toUpdate = Object.fromEntries( + keysToControl.map(([key]) => [key, props[key]]) + ) as KeyRecord; + + setControlledValues(toUpdate); + }, [props]); + + return ; + } + + WrapperComponent.displayName = `withControlledInput(${displayName})`; + + return WrapperComponent; +} diff --git a/app/react-tools/withCurrentUser.tsx b/app/react-tools/withCurrentUser.tsx new file mode 100644 index 0000000..41a48f1 --- /dev/null +++ b/app/react-tools/withCurrentUser.tsx @@ -0,0 +1,27 @@ +import { ComponentType } from 'react'; + +import { UserProvider } from '@/react/hooks/useUser'; + +import { withReactQuery } from './withReactQuery'; + +export function withCurrentUser( + WrappedComponent: ComponentType +): ComponentType { + // Try to create a nice displayName for React Dev Tools. + const displayName = + WrappedComponent.displayName || WrappedComponent.name || 'Component'; + + function WrapperComponent(props: T & JSX.IntrinsicAttributes) { + return ( + + + + ); + } + + WrapperComponent.displayName = `withCurrentUser(${displayName})`; + + // User provider makes a call to the API to get the current user. + // We need to wrap it with React Query to make that call. + return withReactQuery(WrapperComponent); +} diff --git a/app/react-tools/withFormValidation.ts b/app/react-tools/withFormValidation.ts new file mode 100644 index 0000000..90ab642 --- /dev/null +++ b/app/react-tools/withFormValidation.ts @@ -0,0 +1,164 @@ +import { IFormController, IComponentOptions, IModule } from 'angular'; +import { FormikErrors } from 'formik'; +import { SchemaOf, object } from 'yup'; +import _ from 'lodash'; +import { ComponentType } from 'react'; + +import { PropNames, r2a } from '@/react-tools/react2angular'; + +import { validateForm } from '@@/form-components/validate-form'; +import { ArrayError } from '@@/form-components/InputList/InputList'; + +interface FormFieldProps { + onChange(values: TValue): void; // update the values for the entire form object used in yup validation, not just one input. + values: TValue; // current values + errors?: FormikErrors | ArrayError; +} + +type WithFormFieldProps = TProps & FormFieldProps; + +type ValidationResult = FormikErrors | undefined; + +/** + * This utility function is used for wrapping React components with form validation. + * When used inside an Angular form, it sets the form to invalid if the component values are invalid. + * This function registers two AngularJS components: + * 1. The React component with r2a wrapping: + * - `onChange` and `values` must be manually passed to the React component from an Angular view. + * - `errors` will be automatically passed to the React component and updated by the validation component. + * 2. An AngularJS component that handles form validation, based on a yup validation schema: + * - `validationData` can optionally be passed to the React component from an Angular view, which can be used in validation. + * + * @example + * // Usage in Angular view + * + * + */ +export function withFormValidation( + ngModule: IModule, + Component: ComponentType>, + componentName: string, + propNames: PropNames[], + schemaBuilder: (validationData?: TData) => SchemaOf, + isPrimitive = false +) { + const reactComponentName = `react${_.upperFirst(componentName)}`; + + ngModule + .component( + reactComponentName, + r2a(Component, ['errors', 'onChange', 'values', ...propNames]) + ) + .component( + componentName, + createFormValidationComponent( + reactComponentName, + propNames, + schemaBuilder, + isPrimitive + ) + ); +} + +export function createFormValidationComponent( + componentName: string, + propNames: Array, + schemaBuilder: (validationData?: TData) => SchemaOf, + isPrimitive = false +): IComponentOptions { + const kebabName = _.kebabCase(componentName); + const propsWithErrors = [...propNames, 'errors', 'values']; + + return { + template: ` + <${kebabName} ${propsWithErrors + .filter((p) => p !== 'onChange') + .map((p) => `${_.kebabCase(p)}="$ctrl.${p}"`) + .join(' ')} + on-change="($ctrl.handleChange)" + > + `, + controller: createFormValidatorController(schemaBuilder, isPrimitive), + bindings: Object.fromEntries( + [...propsWithErrors, 'validationData', 'onChange'].map((p) => [p, '<']) + ), + }; +} + +function createFormValidatorController( + schemaBuilder: (validationData?: TData) => SchemaOf, + isPrimitive = false +) { + return class FormValidatorController { + errors?: FormikErrors; + + $async: (fn: () => Promise) => Promise; + + form?: IFormController; + + values?: TFormModel; + + validationData?: TData; + + onChange?: (value: TFormModel) => void; + + /* @ngInject */ + constructor($async: (fn: () => Promise) => Promise) { + this.$async = $async; + + this.handleChange = this.handleChange.bind(this); + this.runValidation = this.runValidation.bind(this); + } + + async handleChange(newValues: TFormModel) { + return this.$async(async () => { + this.onChange?.(newValues); + await this.runValidation(newValues); + }); + } + + async runValidation(value: TFormModel) { + return this.$async(async () => { + this.form?.$setValidity('form', true, this.form); + + const schema = schemaBuilder(this.validationData); + this.errors = await validate(schema, value, isPrimitive); + + if (this.errors && Object.keys(this.errors).length > 0) { + this.form?.$setValidity('form', false, this.form); + } + }); + } + + async $onChanges(changes: { + values?: { currentValue: TFormModel }; + validationData?: { currentValue: TData }; + }) { + if (changes.values) { + await this.runValidation(changes.values.currentValue); + } + // also run validation if validationData changes + if (changes.validationData) { + await this.runValidation(this.values!); + } + } + }; +} + +async function validate( + schema: SchemaOf, + value: TFormModel, + isPrimitive: boolean +): Promise> { + if (isPrimitive) { + const result = await validateForm<{ value: TFormModel }>( + () => object({ value: schema }), + { value } + ); + return result?.value as ValidationResult; + } + return validateForm(() => schema, value); +} diff --git a/app/react-tools/withI18nSuspense.tsx b/app/react-tools/withI18nSuspense.tsx new file mode 100644 index 0000000..c704149 --- /dev/null +++ b/app/react-tools/withI18nSuspense.tsx @@ -0,0 +1,21 @@ +import { ComponentType, Suspense } from 'react'; + +export function withI18nSuspense( + WrappedComponent: ComponentType +): ComponentType { + // Try to create a nice displayName for React Dev Tools. + const displayName = + WrappedComponent.displayName || WrappedComponent.name || 'Component'; + + function WrapperComponent(props: T & JSX.IntrinsicAttributes) { + return ( + + + + ); + } + + WrapperComponent.displayName = `withI18nSuspense(${displayName})`; + + return WrapperComponent; +} diff --git a/app/react-tools/withReactQuery.tsx b/app/react-tools/withReactQuery.tsx new file mode 100644 index 0000000..e574a5f --- /dev/null +++ b/app/react-tools/withReactQuery.tsx @@ -0,0 +1,25 @@ +import { ComponentType } from 'react'; +import { QueryClientProvider } from '@tanstack/react-query'; + +import { queryClient as defaultQueryClient } from './react-query'; + +export function withReactQuery( + WrappedComponent: ComponentType, + queryClient = defaultQueryClient +): ComponentType { + // Try to create a nice displayName for React Dev Tools. + const displayName = + WrappedComponent.displayName || WrappedComponent.name || 'Component'; + + function WrapperComponent(props: T & JSX.IntrinsicAttributes) { + return ( + + + + ); + } + + WrapperComponent.displayName = `withReactQuery(${displayName})`; + + return WrapperComponent; +} diff --git a/app/react-tools/withUIRouter.tsx b/app/react-tools/withUIRouter.tsx new file mode 100644 index 0000000..3046d98 --- /dev/null +++ b/app/react-tools/withUIRouter.tsx @@ -0,0 +1,22 @@ +import { ComponentType } from 'react'; +import { UIRouterContextComponent } from '@uirouter/react-hybrid'; + +export function withUIRouter( + WrappedComponent: ComponentType +): ComponentType { + // Try to create a nice displayName for React Dev Tools. + const displayName = + WrappedComponent.displayName || WrappedComponent.name || 'Component'; + + function WrapperComponent(props: T & JSX.IntrinsicAttributes) { + return ( + + + + ); + } + + WrapperComponent.displayName = `withUIRouter(${displayName})`; + + return WrapperComponent; +} diff --git a/app/react-tools/yup-schemas.ts b/app/react-tools/yup-schemas.ts new file mode 100644 index 0000000..4f614e2 --- /dev/null +++ b/app/react-tools/yup-schemas.ts @@ -0,0 +1,19 @@ +import { NumberSchema, number } from 'yup'; + +/** + * Returns a Yup schema for a number that can also be NaN. + * + * This function is a workaround for a known issue in Yup where it throws type errors + * when the number input is empty, having a value NaN. Yup doesn't like NaN values. + * More details can be found in these GitHub issues: + * https://github.com/jquense/yup/issues/1330 + * https://github.com/jquense/yup/issues/211 + * + * @param errorMessage The custom error message to display when the value is required. + * @returns A Yup number schema with a custom type error message. + */ +export function nanNumberSchema( + errorMessage = 'Value is required' +): NumberSchema { + return number().typeError(errorMessage); +} diff --git a/app/react/auth/.keep b/app/react/auth/.keep new file mode 100644 index 0000000..e69de29 diff --git a/app/react/auth/InternalLoginView/.keep b/app/react/auth/InternalLoginView/.keep new file mode 100644 index 0000000..e69de29 diff --git a/app/react/auth/LoginView/.keep b/app/react/auth/LoginView/.keep new file mode 100644 index 0000000..e69de29 diff --git a/app/react/azure/DashboardView/DashboardView.test.tsx b/app/react/azure/DashboardView/DashboardView.test.tsx new file mode 100644 index 0000000..58bc892 --- /dev/null +++ b/app/react/azure/DashboardView/DashboardView.test.tsx @@ -0,0 +1,152 @@ +import { http, HttpResponse } from 'msw'; +import { render, within } from '@testing-library/react'; + +import { UserViewModel } from '@/portainer/models/user'; +import { server } from '@/setup-tests/server'; +import { + createMockEnvironment, + createMockResourceGroups, + createMockSubscriptions, +} from '@/react-tools/test-mocks'; +import { withUserProvider } from '@/react/test-utils/withUserProvider'; +import { withTestRouter } from '@/react/test-utils/withRouter'; +import { withTestQueryProvider } from '@/react/test-utils/withTestQuery'; + +import { DashboardView } from './DashboardView'; + +vi.mock('@uirouter/react', async (importOriginal: () => Promise) => ({ + ...(await importOriginal()), + useCurrentStateAndParams: vi.fn(() => ({ + params: { endpointId: 1 }, + })), +})); + +test('dashboard items should render correctly', async () => { + const { findByLabelText } = await renderComponent(); + + const subscriptionsItem = await findByLabelText('Subscription'); + expect(subscriptionsItem).toBeVisible(); + + const subscriptionElements = within(subscriptionsItem); + expect(subscriptionElements.getByLabelText('value')).toBeVisible(); + + expect(subscriptionElements.getByLabelText('resourceType')).toHaveTextContent( + 'Subscriptions' + ); + + const resourceGroupsItem = await findByLabelText('Resource group'); + expect(resourceGroupsItem).toBeVisible(); + + const resourceGroupElements = within(resourceGroupsItem); + expect(resourceGroupElements.getByLabelText('value')).toBeVisible(); + + expect( + resourceGroupElements.getByLabelText('resourceType') + ).toHaveTextContent('Resource groups'); +}); + +test('when there are no subscriptions, should show 0 subscriptions and 0 resource groups', async () => { + const { findByLabelText } = await renderComponent(); + + const subscriptionElements = within(await findByLabelText('Subscription')); + expect(subscriptionElements.getByLabelText('value')).toHaveTextContent('0'); + + const resourceGroupElements = within(await findByLabelText('Resource group')); + expect(resourceGroupElements.getByLabelText('value')).toHaveTextContent('0'); +}); + +test('when there is subscription & resource group data, should display these', async () => { + const { findByLabelText } = await renderComponent(1, { 'subscription-1': 2 }); + + const subscriptionElements = within(await findByLabelText('Subscription')); + expect(subscriptionElements.getByLabelText('value')).toHaveTextContent('1'); + + const resourceGroupElements = within(await findByLabelText('Resource group')); + expect(resourceGroupElements.getByLabelText('value')).toHaveTextContent('2'); +}); + +test('should correctly show total number of resource groups across multiple subscriptions', async () => { + const { findByLabelText } = await renderComponent(2, { + 'subscription-1': 2, + 'subscription-2': 3, + }); + + const resourceGroupElements = within(await findByLabelText('Resource group')); + expect(resourceGroupElements.getByLabelText('value')).toHaveTextContent('5'); +}); + +test("when only subscriptions fail to load, don't show the dashboard", async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + + const { queryByLabelText } = await renderComponent( + 1, + { 'subscription-1': 1 }, + 500, + 200 + ); + expect(queryByLabelText('Subscription')).not.toBeInTheDocument(); + expect(queryByLabelText('Resource group')).not.toBeInTheDocument(); +}); + +test('when only resource groups fail to load, still show the subscriptions', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + + const { queryByLabelText, findByLabelText } = await renderComponent( + 1, + { 'subscription-1': 1 }, + 200, + 500 + ); + await expect(findByLabelText('Subscription')).resolves.toBeInTheDocument(); + expect(queryByLabelText('Resource group')).not.toBeInTheDocument(); +}); + +async function renderComponent( + subscriptionsCount = 0, + resourceGroups: Record = {}, + subscriptionsStatus = 200, + resourceGroupsStatus = 200 +) { + const user = new UserViewModel({ Username: 'user' }); + + server.use( + http.get('/api/endpoints/1', () => + HttpResponse.json(createMockEnvironment()) + ), + + http.get('/api/endpoints/:endpointId/azure/subscriptions', () => + HttpResponse.json(createMockSubscriptions(subscriptionsCount), { + status: subscriptionsStatus, + }) + ), + http.get( + '/api/endpoints/:endpointId/azure/subscriptions/:subscriptionId/resourcegroups', + ({ params }) => { + if (typeof params.subscriptionId !== 'string') { + throw new Error("Provided subscriptionId must be of type: 'string'"); + } + + const { subscriptionId } = params; + return HttpResponse.json( + createMockResourceGroups( + subscriptionId, + resourceGroups[subscriptionId] || 0 + ), + { + status: resourceGroupsStatus, + } + ); + } + ) + ); + + const Wrapped = withTestQueryProvider( + withUserProvider(withTestRouter(DashboardView), user) + ); + + const renderResult = render(); + + await expect(renderResult.findByText(/Home/)).resolves.toBeVisible(); + + return renderResult; +} diff --git a/app/react/azure/DashboardView/DashboardView.tsx b/app/react/azure/DashboardView/DashboardView.tsx new file mode 100644 index 0000000..05ebc0a --- /dev/null +++ b/app/react/azure/DashboardView/DashboardView.tsx @@ -0,0 +1,57 @@ +import { Package } from 'lucide-react'; + +import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; +import Subscription from '@/assets/ico/subscription.svg?c'; + +import { PageHeader } from '@@/PageHeader'; +import { DashboardItem } from '@@/DashboardItem'; +import { DashboardGrid } from '@@/DashboardItem/DashboardGrid'; + +import { useResourceGroups } from '../queries/useResourceGroups'; +import { useSubscriptions } from '../queries/useSubscriptions'; + +export function DashboardView() { + const environmentId = useEnvironmentId(); + + const subscriptionsQuery = useSubscriptions(environmentId); + + const resourceGroupsQuery = useResourceGroups( + environmentId, + subscriptionsQuery.data + ); + + const subscriptionsCount = subscriptionsQuery.data?.length; + const resourceGroupsCount = Object.values( + resourceGroupsQuery.resourceGroups + ).flatMap((x) => Object.values(x)).length; + + return ( + <> + + +
+ {subscriptionsQuery.data && ( + + + {!resourceGroupsQuery.isError && !resourceGroupsQuery.isLoading && ( + + )} + + )} +
+ + ); +} diff --git a/app/react/azure/DashboardView/index.ts b/app/react/azure/DashboardView/index.ts new file mode 100644 index 0000000..ea829db --- /dev/null +++ b/app/react/azure/DashboardView/index.ts @@ -0,0 +1 @@ +export { DashboardView } from './DashboardView'; diff --git a/app/react/azure/container-instances/CreateView/CreateContainerInstanceForm.test.tsx b/app/react/azure/container-instances/CreateView/CreateContainerInstanceForm.test.tsx new file mode 100644 index 0000000..d55d808 --- /dev/null +++ b/app/react/azure/container-instances/CreateView/CreateContainerInstanceForm.test.tsx @@ -0,0 +1,71 @@ +import userEvent from '@testing-library/user-event'; +import { HttpResponse, http } from 'msw'; +import { render, screen } from '@testing-library/react'; + +import { UserViewModel } from '@/portainer/models/user'; +import { withUserProvider } from '@/react/test-utils/withUserProvider'; +import { withTestRouter } from '@/react/test-utils/withRouter'; +import { withTestQueryProvider } from '@/react/test-utils/withTestQuery'; +import { server } from '@/setup-tests/server'; +import { createMockEnvironment } from '@/react-tools/test-mocks'; + +import { CreateContainerInstanceForm } from './CreateContainerInstanceForm'; + +vi.mock('@uirouter/react', async (importOriginal: () => Promise) => ({ + ...(await importOriginal()), + useCurrentStateAndParams: vi.fn(() => ({ + params: { endpointId: 5 }, + })), +})); + +function renderComponent() { + const user = new UserViewModel({ Username: 'user' }); + const Wrapped = withTestQueryProvider( + withUserProvider(withTestRouter(CreateContainerInstanceForm), user) + ); + + return render(); +} + +describe('CreateContainerInstanceForm', () => { + beforeEach(() => { + server.use( + http.get('/api/endpoints/5', () => + HttpResponse.json(createMockEnvironment()) + ) + ); + }); + + // TODO: from R8S-730 - enable this test once it passes + it.skip('should not display any visible error messages on initial load', async () => { + renderComponent(); + + const errors = await screen.findByRole('alert'); + + // Check that no error messages (role="alert") are visible + expect(errors).not.toBeInTheDocument(); + }); + + it('submit button should be disabled when name or image is missing', async () => { + const { findByText, getByText, getByLabelText } = renderComponent(); + + await expect(findByText(/Azure settings/)).resolves.toBeVisible(); + + const button = getByText(/Deploy the container/); + expect(button).toBeVisible(); + expect(button).toBeDisabled(); + + const nameInput = getByLabelText(/name/i, { selector: 'input' }); + await userEvent.type(nameInput, 'name'); + + const imageInput = getByLabelText(/image/i, { selector: 'input' }); + await userEvent.type(imageInput, 'image'); + + await expect(findByText(/Deploy the container/)).resolves.toBeEnabled(); + + expect(nameInput).toHaveValue('name'); + await userEvent.clear(nameInput); + + await expect(findByText(/Deploy the container/)).resolves.toBeDisabled(); + }); +}); diff --git a/app/react/azure/container-instances/CreateView/CreateContainerInstanceForm.tsx b/app/react/azure/container-instances/CreateView/CreateContainerInstanceForm.tsx new file mode 100644 index 0000000..b5e3ebe --- /dev/null +++ b/app/react/azure/container-instances/CreateView/CreateContainerInstanceForm.tsx @@ -0,0 +1,74 @@ +import { Formik } from 'formik'; +import { useRouter } from '@uirouter/react'; + +import { ContainerInstanceFormValues } from '@/react/azure/types'; +import * as notifications from '@/portainer/services/notifications'; +import { useCurrentUser } from '@/react/hooks/useUser'; +import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; + +import { validationSchema } from './CreateContainerInstanceForm.validation'; +import { useFormState, useLoadFormState } from './useLoadFormState'; +import { useCreateInstanceMutation } from './useCreateInstanceMutation'; +import { CreateContainerInstanceInnerForm } from './CreateContainerInstanceInnerForm'; + +export function CreateContainerInstanceForm({ + defaultValues, +}: { + defaultValues?: Partial; +}) { + const environmentId = useEnvironmentId(); + const { isPureAdmin } = useCurrentUser(); + + const { providers, subscriptions, resourceGroups, isLoading } = + useLoadFormState(environmentId); + + const { initialValues, subscriptionOptions } = useFormState( + subscriptions, + resourceGroups, + providers, + defaultValues + ); + + const router = useRouter(); + + const { mutateAsync } = useCreateInstanceMutation( + resourceGroups, + environmentId + ); + + if (isLoading) { + return null; + } + + return ( + + initialValues={initialValues} + validationSchema={() => validationSchema(isPureAdmin)} + onSubmit={onSubmit} + validateOnMount + validateOnChange + enableReinitialize + > + {(formikProps) => ( + + )} + + ); + + async function onSubmit(values: ContainerInstanceFormValues) { + try { + await mutateAsync(values); + notifications.success('Container successfully created', values.name); + router.stateService.go('azure.containerinstances'); + } catch (e) { + notifications.error('Failure', e as Error, 'Unable to create container'); + } + } +} diff --git a/app/react/azure/container-instances/CreateView/CreateContainerInstanceForm.validation.ts b/app/react/azure/container-instances/CreateView/CreateContainerInstanceForm.validation.ts new file mode 100644 index 0000000..ddf2c98 --- /dev/null +++ b/app/react/azure/container-instances/CreateView/CreateContainerInstanceForm.validation.ts @@ -0,0 +1,38 @@ +import { object, string, number, boolean, array } from 'yup'; + +import { validationSchema as accessControlSchema } from '@/react/portainer/access-control/AccessControlForm/AccessControlForm.validation'; + +import { buildUniquenessTest } from '@@/form-components/validate-unique'; + +import { validationSchema as portsSchema } from './PortsMappingField.validation'; + +export function validationSchema(isAdmin: boolean) { + return object().shape({ + name: string().required('Name is required.'), + image: string().required('Image is required.'), + subscription: string().required('Subscription is required.'), + resourceGroup: string().required('Resource group is required.'), + location: string().required('Location is required.'), + os: string().oneOf(['Linux', 'Windows']), + cpu: number().positive(), + memory: number().positive(), + allocatePublicIP: boolean(), + ports: portsSchema(), + accessControl: accessControlSchema(isAdmin), + env: array() + .of( + object().shape({ + name: string().required('Environment variable name is required.'), + value: string().required('Environment variable value is required.'), + }) + ) + .test( + 'unique', + 'This environment variable is already defined', + buildUniquenessTest( + () => 'This environment variable is already defined', + 'name' + ) + ), + }); +} diff --git a/app/react/azure/container-instances/CreateView/CreateContainerInstanceInnerForm.tsx b/app/react/azure/container-instances/CreateView/CreateContainerInstanceInnerForm.tsx new file mode 100644 index 0000000..7dc00a8 --- /dev/null +++ b/app/react/azure/container-instances/CreateView/CreateContainerInstanceInnerForm.tsx @@ -0,0 +1,182 @@ +import { Field, Form, FormikProps } from 'formik'; +import { Plus } from 'lucide-react'; + +import { + ContainerInstanceFormValues, + ProviderViewModel, + ResourceGroup, +} from '@/react/azure/types'; +import { + getSubscriptionLocations, + getSubscriptionResourceGroups, +} from '@/react/azure/container-instances/CreateView/utils'; +import { PortsMappingField } from '@/react/azure/container-instances/CreateView/PortsMappingField'; +import { AccessControlForm } from '@/react/portainer/access-control'; + +import { FormSectionTitle } from '@@/form-components/FormSectionTitle'; +import { FormControl } from '@@/form-components/FormControl'; +import { Input, Select } from '@@/form-components/Input'; +import { EnvironmentVariablesPanel } from '@@/form-components/EnvironmentVariablesFieldset'; +import { LoadingButton } from '@@/buttons'; +import { Option } from '@@/form-components/PortainerSelect'; + +type Props = FormikProps & { + subscriptionOptions: Option[]; + environmentId: number; + resourceGroups: Record; + providers: Record; +}; + +export function CreateContainerInstanceInnerForm({ + errors, + handleSubmit, + isSubmitting, + isValid, + values, + setFieldValue, + environmentId, + subscriptionOptions, + resourceGroups, + providers, +}: Props) { + return ( +
+ Azure settings + + + + + + + + + + + + + Container configuration + + + + + + + + + + + + + + setFieldValue('ports', value)} + errors={errors.ports} + /> + + setFieldValue('env', env)} + errors={errors.env} + /> + +
+
+ This will automatically deploy a container with a public IP address +
+
+ + Container Resources + + + + + + + + + + setFieldValue('accessControl', values)} + values={values.accessControl} + errors={errors.accessControl} + environmentId={environmentId} + /> + +
+
+ + Deploy the container + +
+
+ + ); +} diff --git a/app/react/azure/container-instances/CreateView/CreateView.tsx b/app/react/azure/container-instances/CreateView/CreateView.tsx new file mode 100644 index 0000000..be1d66a --- /dev/null +++ b/app/react/azure/container-instances/CreateView/CreateView.tsx @@ -0,0 +1,29 @@ +import { PageHeader } from '@@/PageHeader'; +import { Widget, WidgetBody } from '@@/Widget'; + +import { CreateContainerInstanceForm } from './CreateContainerInstanceForm'; + +export function CreateView() { + return ( + <> + + +
+
+ + + + + +
+
+ + ); +} diff --git a/app/react/azure/container-instances/CreateView/PortsMappingField.module.css b/app/react/azure/container-instances/CreateView/PortsMappingField.module.css new file mode 100644 index 0000000..101ea17 --- /dev/null +++ b/app/react/azure/container-instances/CreateView/PortsMappingField.module.css @@ -0,0 +1,13 @@ +.item { + display: flex; + flex-direction: column; + position: relative; +} + +.item .inputs { +} + +.item .errors { + position: absolute; + bottom: -20px; +} diff --git a/app/react/azure/container-instances/CreateView/PortsMappingField.tsx b/app/react/azure/container-instances/CreateView/PortsMappingField.tsx new file mode 100644 index 0000000..c6e5fa2 --- /dev/null +++ b/app/react/azure/container-instances/CreateView/PortsMappingField.tsx @@ -0,0 +1,127 @@ +import { FormikErrors } from 'formik'; +import { ArrowRight } from 'lucide-react'; + +import { ButtonSelector } from '@@/form-components/ButtonSelector/ButtonSelector'; +import { FormError } from '@@/form-components/FormError'; +import { InputGroup } from '@@/form-components/InputGroup'; +import { InputList } from '@@/form-components/InputList'; +import { ItemProps } from '@@/form-components/InputList/InputList'; +import { Icon } from '@@/Icon'; + +import styles from './PortsMappingField.module.css'; + +type Protocol = 'TCP' | 'UDP'; + +export interface PortMapping { + host?: number; + container?: number; + protocol: Protocol; +} + +interface Props { + value: PortMapping[]; + onChange?(value: PortMapping[]): void; + errors?: FormikErrors[] | string | string[]; + disabled?: boolean; + readOnly?: boolean; +} + +export function PortsMappingField({ + value, + onChange = () => {}, + errors, + disabled, + readOnly, +}: Props) { + return ( + <> + + label="Port mapping" + value={value} + onChange={onChange} + addLabel="map additional port" + itemBuilder={() => ({ + host: 0, + container: 0, + protocol: 'TCP', + })} + item={Item} + errors={errors} + disabled={disabled} + readOnly={readOnly} + data-cy="aci-ports-mapping" + /> + {typeof errors === 'string' && ( +
+ {errors} +
+ )} + + ); +} + +function Item({ + onChange, + item, + error, + disabled, + readOnly, + index, +}: ItemProps) { + return ( +
+
+ + host + + handleChange('host', parseInt(e.target.value || '0', 10)) + } + disabled={disabled} + readOnly={readOnly} + type="number" + data-cy={`aci-ports-mapping-host-input_${index}`} + /> + + + + + + + + container + + handleChange('container', parseInt(e.target.value || '0', 10)) + } + disabled={disabled} + readOnly={readOnly} + type="number" + data-cy={`aci-ports-mapping-container-port-input_${index}`} + /> + + + + onChange={(value) => handleChange('protocol', value)} + value={item.protocol} + options={[{ value: 'TCP' }, { value: 'UDP' }]} + disabled={disabled} + readOnly={readOnly} + /> +
+ {!!error && ( +
+ {Object.values(error)[0]} +
+ )} +
+ ); + + function handleChange(name: keyof PortMapping, value: string | number) { + onChange({ ...item, [name]: value }); + } +} diff --git a/app/react/azure/container-instances/CreateView/PortsMappingField.validation.ts b/app/react/azure/container-instances/CreateView/PortsMappingField.validation.ts new file mode 100644 index 0000000..e872889 --- /dev/null +++ b/app/react/azure/container-instances/CreateView/PortsMappingField.validation.ts @@ -0,0 +1,11 @@ +import { array, object, string } from 'yup'; + +export function validationSchema() { + return array( + object().shape({ + host: string().required('host is required'), + container: string().required('container is required'), + protocol: string().oneOf(['TCP', 'UDP']), + }) + ).min(1, 'At least one port binding is required'); +} diff --git a/app/react/azure/container-instances/CreateView/index.ts b/app/react/azure/container-instances/CreateView/index.ts new file mode 100644 index 0000000..74e5921 --- /dev/null +++ b/app/react/azure/container-instances/CreateView/index.ts @@ -0,0 +1 @@ +export { CreateView } from './CreateView'; diff --git a/app/react/azure/container-instances/CreateView/useCreateInstanceMutation.tsx b/app/react/azure/container-instances/CreateView/useCreateInstanceMutation.tsx new file mode 100644 index 0000000..74c78f4 --- /dev/null +++ b/app/react/azure/container-instances/CreateView/useCreateInstanceMutation.tsx @@ -0,0 +1,62 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { createContainerGroup } from '@/react/azure/services/container-groups.service'; +import { queryKeys } from '@/react/azure/queries/query-keys'; +import { EnvironmentId } from '@/react/portainer/environments/types'; +import PortainerError from '@/portainer/error'; +import { + ContainerGroup, + ContainerInstanceFormValues, + ResourceGroup, +} from '@/react/azure/types'; +import { applyResourceControl } from '@/react/portainer/access-control/access-control.service'; + +import { getSubscriptionResourceGroups } from './utils'; + +export function useCreateInstanceMutation( + resourceGroups: { + [k: string]: ResourceGroup[]; + }, + environmentId: EnvironmentId +) { + const queryClient = useQueryClient(); + return useMutation( + (values) => { + if (!values.subscription) { + throw new PortainerError('subscription is required'); + } + + const subscriptionResourceGroup = getSubscriptionResourceGroups( + values.subscription, + resourceGroups + ); + const resourceGroup = subscriptionResourceGroup.find( + (r) => r.value === values.resourceGroup + ); + if (!resourceGroup) { + throw new PortainerError('resource group not found'); + } + + return createContainerGroup( + values, + environmentId, + values.subscription, + resourceGroup.label + ); + }, + { + async onSuccess(containerGroup, values) { + const resourceControl = containerGroup.Portainer?.ResourceControl; + if (!resourceControl) { + throw new PortainerError('resource control expected after creation'); + } + + const accessControlData = values.accessControl; + await applyResourceControl(accessControlData, resourceControl.Id); + return queryClient.invalidateQueries( + queryKeys.subscriptions(environmentId) + ); + }, + } + ); +} diff --git a/app/react/azure/container-instances/CreateView/useLoadFormState.ts b/app/react/azure/container-instances/CreateView/useLoadFormState.ts new file mode 100644 index 0000000..b4629f6 --- /dev/null +++ b/app/react/azure/container-instances/CreateView/useLoadFormState.ts @@ -0,0 +1,88 @@ +import { EnvironmentId } from '@/react/portainer/environments/types'; +import { + ContainerInstanceFormValues, + ProviderViewModel, + ResourceGroup, + Subscription, +} from '@/react/azure/types'; +import { parseAccessControlFormData } from '@/react/portainer/access-control/utils'; +import { useCurrentUser } from '@/react/hooks/useUser'; +import { useProvider } from '@/react/azure/queries/useProvider'; +import { useResourceGroups } from '@/react/azure/queries/useResourceGroups'; +import { useSubscriptions } from '@/react/azure/queries/useSubscriptions'; + +import { + getSubscriptionLocations, + getSubscriptionResourceGroups, +} from './utils'; + +export function useLoadFormState(environmentId: EnvironmentId) { + const { data: subscriptions, isLoading: isLoadingSubscriptions } = + useSubscriptions(environmentId); + const { resourceGroups, isLoading: isLoadingResourceGroups } = + useResourceGroups(environmentId, subscriptions); + const { providers, isLoading: isLoadingProviders } = useProvider( + environmentId, + subscriptions + ); + + const isLoading = + isLoadingSubscriptions || isLoadingResourceGroups || isLoadingProviders; + + return { isLoading, subscriptions, resourceGroups, providers }; +} + +export function useFormState( + subscriptions: Subscription[] = [], + resourceGroups: Record = {}, + providers: Record = {}, + defaultValues?: Partial +) { + const { user, isPureAdmin } = useCurrentUser(); + + const subscriptionOptions = subscriptions.map((s) => ({ + value: s.subscriptionId, + label: s.displayName, + })); + + const initSubscriptionId = getFirstValue(subscriptionOptions); + + const subscriptionResourceGroups = getSubscriptionResourceGroups( + initSubscriptionId, + resourceGroups + ); + + const subscriptionLocations = getSubscriptionLocations( + initSubscriptionId, + providers + ); + + const initialValues: ContainerInstanceFormValues = { + name: '', + location: getFirstValue(subscriptionLocations), + subscription: initSubscriptionId, + resourceGroup: getFirstValue(subscriptionResourceGroups), + image: '', + os: 'Linux', + memory: 1, + cpu: 1, + ports: [{ container: 80, host: 80, protocol: 'TCP' }], + allocatePublicIP: true, + accessControl: parseAccessControlFormData(isPureAdmin, user.Id), + env: [], + ...defaultValues, + }; + + return { + initialValues, + subscriptionOptions, + }; + + function getFirstValue(arr: { value: T }[]) { + if (arr.length === 0) { + return undefined; + } + + return arr[0].value; + } +} diff --git a/app/react/azure/container-instances/CreateView/utils.ts b/app/react/azure/container-instances/CreateView/utils.ts new file mode 100644 index 0000000..ff5e27d --- /dev/null +++ b/app/react/azure/container-instances/CreateView/utils.ts @@ -0,0 +1,34 @@ +import { ProviderViewModel, ResourceGroup } from '@/react/azure/types'; + +export function getSubscriptionResourceGroups( + subscriptionId?: string, + resourceGroups?: Record +) { + if (!subscriptionId || !resourceGroups || !resourceGroups[subscriptionId]) { + return []; + } + + return resourceGroups[subscriptionId].map(({ name, id }) => ({ + value: id, + label: name, + })); +} + +export function getSubscriptionLocations( + subscriptionId?: string, + containerInstanceProviders?: Record +) { + if (!subscriptionId || !containerInstanceProviders) { + return []; + } + + const provider = containerInstanceProviders[subscriptionId]; + if (!provider) { + return []; + } + + return provider.locations.map((location) => ({ + value: location, + label: location, + })); +} diff --git a/app/react/azure/container-instances/ItemView/ItemView.tsx b/app/react/azure/container-instances/ItemView/ItemView.tsx new file mode 100644 index 0000000..a9c5177 --- /dev/null +++ b/app/react/azure/container-instances/ItemView/ItemView.tsx @@ -0,0 +1,306 @@ +import { useCurrentStateAndParams } from '@uirouter/react'; +import { useQueryClient } from '@tanstack/react-query'; + +import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; +import { AccessControlPanel } from '@/react/portainer/access-control/AccessControlPanel/AccessControlPanel'; +import { ResourceControlViewModel } from '@/react/portainer/access-control/models/ResourceControlViewModel'; +import { ResourceControlType } from '@/react/portainer/access-control/types'; +import { + ContainerGroup, + ResourceGroup, + Subscription, +} from '@/react/azure/types'; +import { useContainerGroup } from '@/react/azure/queries/useContainerGroup'; +import { useResourceGroup } from '@/react/azure/queries/useResourceGroup'; +import { useSubscription } from '@/react/azure/queries/useSubscription'; + +import { Input } from '@@/form-components/Input'; +import { Widget, WidgetBody } from '@@/Widget'; +import { PageHeader } from '@@/PageHeader'; +import { FormSectionTitle } from '@@/form-components/FormSectionTitle'; +import { FormControl } from '@@/form-components/FormControl'; + +import { PortsMappingField } from '../CreateView/PortsMappingField'; + +export function ItemView() { + const { + params: { id }, + } = useCurrentStateAndParams(); + const { subscriptionId, resourceGroupId, containerGroupId } = parseId(id); + + const environmentId = useEnvironmentId(); + + const queryClient = useQueryClient(); + + const subscriptionQuery = useSubscription(environmentId, subscriptionId); + const resourceGroupQuery = useResourceGroup( + environmentId, + subscriptionId, + resourceGroupId + ); + + const containerQuery = useContainerGroup( + environmentId, + subscriptionId, + resourceGroupId, + containerGroupId + ); + + if ( + !subscriptionQuery.isSuccess || + !resourceGroupQuery.isSuccess || + !containerQuery.isSuccess + ) { + return null; + } + + const container = aggregateContainerData( + subscriptionQuery.data, + resourceGroupQuery.data, + containerQuery.data + ); + + return ( + <> + + +
+
+ + + Azure settings + + + + + + + + + + + + + Container configuration + + + + + + + + + + + + + + + + + + + + Container Resources + + + + + + + + + {container.environmentVariables && + container.environmentVariables.length > 0 && ( + <> + Environment Variables + +
+ {container.environmentVariables.map((env) => ( + <> + {/* show redacted values for secure values and values */} + {/* when I add secure values in azure, the 'secureValue' and 'value' fields are ommitted (tested on current and latest, 2025-09-01 api versions) */} + {env.secureValue || env.value === undefined ? ( +
{env.name} = ********
+ ) : ( +
+ {env.name} = {env.value} +
+ )} + + ))} +
+
+ + )} +
+
+
+
+ + + queryClient.invalidateQueries([ + 'azure', + environmentId, + 'subscriptions', + subscriptionId, + 'resourceGroups', + resourceGroupQuery.data.name, + 'containerGroups', + containerQuery.data.name, + ]) + } + resourceId={id} + resourceControl={container.resourceControl} + resourceType={ResourceControlType.ContainerGroup} + environmentId={environmentId} + /> + + ); +} + +function parseId(id: string) { + const match = id.match( + /^\/subscriptions\/(.+)\/resourceGroups\/(.+)\/providers\/(.+)\/containerGroups\/(.+)$/ + ); + + if (!match) { + throw new Error('container id is missing details'); + } + + const [, subscriptionId, resourceGroupId, , containerGroupId] = match; + + return { subscriptionId, resourceGroupId, containerGroupId }; +} + +function aggregateContainerData( + subscription: Subscription, + resourceGroup: ResourceGroup, + containerGroup: ContainerGroup +) { + const containerInstanceData = aggregateContainerInstance(); + + const resourceControl = containerGroup.Portainer?.ResourceControl + ? new ResourceControlViewModel(containerGroup.Portainer.ResourceControl) + : undefined; + + return { + name: containerGroup.name, + subscriptionName: subscription.displayName, + resourceGroupName: resourceGroup.name, + location: containerGroup.location, + osType: containerGroup.properties.osType, + ipAddress: containerGroup.properties.ipAddress.ip, + resourceControl, + ...containerInstanceData, + }; + + function aggregateContainerInstance() { + const containerInstanceData = containerGroup.properties.containers[0]; + + if (!containerInstanceData) { + return { + ports: [], + }; + } + + const containerInstanceProperties = containerInstanceData.properties; + + const containerPorts = containerInstanceProperties.ports; + + const imageName = containerInstanceProperties.image; + + const ports = containerGroup.properties.ipAddress.ports.map( + (binding, index) => { + const port = + containerPorts && containerPorts[index] + ? containerPorts[index].port + : undefined; + return { + container: port, + host: binding.port, + protocol: binding.protocol, + }; + } + ); + + const { environmentVariables } = containerInstanceProperties; + + return { + imageName, + ports, + cpu: containerInstanceProperties.resources.cpu, + memory: containerInstanceProperties.resources.memoryInGB, + environmentVariables, + }; + } +} diff --git a/app/react/azure/container-instances/ItemView/index.ts b/app/react/azure/container-instances/ItemView/index.ts new file mode 100644 index 0000000..a09ab2d --- /dev/null +++ b/app/react/azure/container-instances/ItemView/index.ts @@ -0,0 +1 @@ +export { ItemView } from './ItemView'; diff --git a/app/react/azure/container-instances/ListView/ContainersDatatable.tsx b/app/react/azure/container-instances/ListView/ContainersDatatable.tsx new file mode 100644 index 0000000..59f07de --- /dev/null +++ b/app/react/azure/container-instances/ListView/ContainersDatatable.tsx @@ -0,0 +1,58 @@ +import { Box } from 'lucide-react'; + +import { ContainerGroup } from '@/react/azure/types'; +import { Authorized } from '@/react/hooks/useUser'; + +import { Datatable } from '@@/datatables'; +import { AddButton } from '@@/buttons'; +import { createPersistedStore } from '@@/datatables/types'; +import { useTableState } from '@@/datatables/useTableState'; +import { DeleteButton } from '@@/buttons/DeleteButton'; + +import { columns } from './columns'; + +const tableKey = 'containergroups'; + +const settingsStore = createPersistedStore(tableKey, 'name'); +export interface Props { + dataset: ContainerGroup[]; + onRemoveClick(containerIds: string[]): void; +} + +export function ContainersDatatable({ dataset, onRemoveClick }: Props) { + const tableState = useTableState(settingsStore, tableKey); + + return ( + container.id} + data-cy="containers-datatable" + renderTableActions={(selectedRows) => ( +
+ + + handleRemoveClick(selectedRows.map((r) => r.id)) + } + confirmMessage="Are you sure you want to delete the selected containers?" + /> + + + + Add container + +
+ )} + /> + ); + + async function handleRemoveClick(containerIds: string[]) { + return onRemoveClick(containerIds); + } +} diff --git a/app/react/azure/container-instances/ListView/ListView.tsx b/app/react/azure/container-instances/ListView/ListView.tsx new file mode 100644 index 0000000..78ac058 --- /dev/null +++ b/app/react/azure/container-instances/ListView/ListView.tsx @@ -0,0 +1,86 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { deleteContainerGroup } from '@/react/azure/services/container-groups.service'; +import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; +import { notifyError, notifySuccess } from '@/portainer/services/notifications'; +import { EnvironmentId } from '@/react/portainer/environments/types'; +import { promiseSequence } from '@/portainer/helpers/promise-utils'; +import { useContainerGroups } from '@/react/azure/queries/useContainerGroups'; +import { useSubscriptions } from '@/react/azure/queries/useSubscriptions'; + +import { PageHeader } from '@@/PageHeader'; + +import { ContainersDatatable } from './ContainersDatatable'; + +export function ListView() { + const environmentId = useEnvironmentId(); + + const subscriptionsQuery = useSubscriptions(environmentId); + + const groupsQuery = useContainerGroups( + environmentId, + subscriptionsQuery.data, + subscriptionsQuery.isSuccess + ); + + const { handleRemove } = useRemoveMutation(environmentId); + + if (groupsQuery.isLoading || subscriptionsQuery.isLoading) { + return null; + } + + return ( + <> + + + + + ); +} + +function useRemoveMutation(environmentId: EnvironmentId) { + const queryClient = useQueryClient(); + + const deleteMutation = useMutation( + (containerGroupIds: string[]) => + promiseSequence( + containerGroupIds.map( + (id) => () => deleteContainerGroup(environmentId, id) + ) + ), + + { + onSuccess() { + return queryClient.invalidateQueries([ + 'azure', + environmentId, + 'subscriptions', + ]); + }, + onError(err) { + notifyError( + 'Failure', + err as Error, + 'Unable to remove container groups' + ); + }, + } + ); + + return { handleRemove }; + + async function handleRemove(groupIds: string[]) { + deleteMutation.mutate(groupIds, { + onSuccess: () => { + notifySuccess('Success', 'Container groups successfully removed'); + }, + }); + } +} diff --git a/app/react/azure/container-instances/ListView/columns/helper.ts b/app/react/azure/container-instances/ListView/columns/helper.ts new file mode 100644 index 0000000..0090f07 --- /dev/null +++ b/app/react/azure/container-instances/ListView/columns/helper.ts @@ -0,0 +1,5 @@ +import { createColumnHelper } from '@tanstack/react-table'; + +import { ContainerGroup } from '@/react/azure/types'; + +export const columnHelper = createColumnHelper(); diff --git a/app/react/azure/container-instances/ListView/columns/index.ts b/app/react/azure/container-instances/ListView/columns/index.ts new file mode 100644 index 0000000..835b816 --- /dev/null +++ b/app/react/azure/container-instances/ListView/columns/index.ts @@ -0,0 +1,6 @@ +import { name } from './name'; +import { location } from './location'; +import { ports } from './ports'; +import { ownership } from './ownership'; + +export const columns = [name, location, ports, ownership]; diff --git a/app/react/azure/container-instances/ListView/columns/location.ts b/app/react/azure/container-instances/ListView/columns/location.ts new file mode 100644 index 0000000..ddeffe5 --- /dev/null +++ b/app/react/azure/container-instances/ListView/columns/location.ts @@ -0,0 +1,5 @@ +import { columnHelper } from './helper'; + +export const location = columnHelper.accessor('location', { + header: 'Location', +}); diff --git a/app/react/azure/container-instances/ListView/columns/name.tsx b/app/react/azure/container-instances/ListView/columns/name.tsx new file mode 100644 index 0000000..bb4ed33 --- /dev/null +++ b/app/react/azure/container-instances/ListView/columns/name.tsx @@ -0,0 +1,29 @@ +import { CellContext } from '@tanstack/react-table'; + +import { ContainerGroup } from '@/react/azure/types'; + +import { Link } from '@@/Link'; + +import { columnHelper } from './helper'; + +export const name = columnHelper.accessor('name', { + header: 'Name', + cell: NameCell, +}); + +export function NameCell({ + getValue, + row: { original: container }, +}: CellContext) { + const name = getValue(); + return ( + + {name} + + ); +} diff --git a/app/react/azure/container-instances/ListView/columns/ownership.tsx b/app/react/azure/container-instances/ListView/columns/ownership.tsx new file mode 100644 index 0000000..29843b3 --- /dev/null +++ b/app/react/azure/container-instances/ListView/columns/ownership.tsx @@ -0,0 +1,37 @@ +import clsx from 'clsx'; +import { CellContext } from '@tanstack/react-table'; + +import { ResourceControlOwnership } from '@/react/portainer/access-control/types'; +import { ContainerGroup } from '@/react/azure/types'; +import { determineOwnership } from '@/react/portainer/access-control/models/ResourceControlViewModel'; +import { ownershipIcon } from '@/react/docker/components/datatable/createOwnershipColumn'; + +import { columnHelper } from './helper'; + +export const ownership = columnHelper.accessor( + (row) => + row.Portainer && row.Portainer.ResourceControl + ? determineOwnership(row.Portainer.ResourceControl) + : ResourceControlOwnership.ADMINISTRATORS, + { + header: 'Ownership', + cell: OwnershipCell, + id: 'ownership', + } +); + +function OwnershipCell({ + getValue, +}: CellContext) { + const value = getValue(); + + return ( + <> +