Compare commits

...

91 Commits

Author SHA1 Message Date
portainer-bot[bot]
862a80c69b fix(kubernetes): PersistentVolumeClaims datatable system resource filter [R8S-1031] (#2700)
Co-authored-by: nickl-portainer <nicholas.loomans@portainer.io>
2026-05-20 20:33:37 +00:00
RHCowan
5b5956574f fix(alerting): remove kube-scheduler and kube-controller-manager alert rules [R8S-1030] (#2695) (#2696)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 09:01:38 +12:00
Nick Wilkinson
064a4304cc chore: bump version to 2.42.0 (#2654)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 13:34:13 +12:00
Devon Steenberg
09c6222ecd fix(edge-environments): edge environment creation [BE-12984] (#2683) 2026-05-19 12:24:01 +12:00
Oscar Zhou
cad197266d fix(ui): deployment failed progress indicator is missing [BE-12985] (#2684) 2026-05-19 12:22:06 +12:00
Steven Kang
5b9976433f feat(k8s): Refactor Volumes page (#2510)
Co-authored-by: Nicholas Loomans <nicholas.loomans@portainer.io>
Co-authored-by: Robbie Cowan <robert.cowan@portainer.io>
Co-authored-by: RHCowan <50324595+RHCowan@users.noreply.github.com>
2026-05-19 10:39:24 +12:00
RHCowan
df48afff17 feat(alerting): add kube-scheduler and kube-controller-manager health alerts [R8S-992] (#2671) 2026-05-19 09:53:20 +12:00
Devon Steenberg
e4e8cf4942 fix(docker): remove docker binary from ce/ee images [BE-12917] (#2674) 2026-05-19 09:37:42 +12:00
Oscar Zhou
c89f34770f fix(gitops): incorrect workflow status for git-based helm edge stack [BE-12978] (#2678) 2026-05-19 09:07:35 +12:00
Chaim Lev-Ari
ca5f695459 feat(gitops): introduce sources details view [BE-12911] (#2627)
Co-authored-by: andres-portainer <91705312+andres-portainer@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 18:01:36 -03:00
Devon Steenberg
10e0185c49 fix(libstack): swarm relative path env files [BE-12975] (#2662) 2026-05-19 07:48:58 +12:00
Steven Kang
8cdc2f49d8 feat(kube): backend handlers for pod delete, pod restart, and capabil… (#2491)
Co-authored-by: Nicholas Loomans <nicholas.loomans@portainer.io>
2026-05-18 19:59:01 +12:00
bernard-portainer
29db3df98d fix(url-state) sync state of list between URL and local storage [C9S-191] (#2647) 2026-05-18 16:51:49 +12:00
Devon Steenberg
52d9fbc9f2 fix(libstack): use compose service to pull images [BE-12951] (#2658) 2026-05-18 09:25:22 +12:00
Chaim Lev-Ari
7e80d88bce feat(ui): add theme selector to user menu [BE-12961] (#2625)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 13:50:51 +03:00
Oscar Zhou
6163008108 fix(auth): set Secure attribute on auth cookies based on HTTPS detection [BE-12938] (#2621) 2026-05-16 11:09:03 +12:00
andres-portainer
6945fa4496 fix(otel): upgrade go.opentelemetry.io/otel to v1.43.0 to fix CVE-2026-39883 CVE-2026-39882 BE-12967 (#2637) 2026-05-15 17:06:55 -03:00
andres-portainer
06ad0b2d78 fix(go-ntlmssp): upgrade github.com/Azure/go-ntlmssp to v0.1.1 to fix CVE-2026-32952 BE-12971 (#2651) 2026-05-15 10:42:18 -03:00
andres-portainer
2570a30a15 fix(prometheus): upgrade github.com/prometheus/prometheus to v0.311.3 to fix CVE-2026-40179 GHSA-fw8g-cg8f-9j28 CVE-2026-42151 CVE-2026-42151 BE-12972 (#2653) 2026-05-14 22:11:59 -03:00
RHCowan
93e5486db3 feat(alerting): add built-in alert for Kubernetes API server TLS certificate expiry [R8S-991] (#2559) 2026-05-15 12:11:39 +12:00
Oscar Zhou
49ef33d9f3 fix(stack): defer git metadata write until after deployment [BE-12946] (#2626) 2026-05-15 10:57:13 +12:00
andres-portainer
ca8201b023 fix(in-toto-golang): upgrade github.com/in-toto/in-toto-golang to v0.11.0 to fix GHSA-pmwq-pjrm-6p5r BE-12968 (#2638) 2026-05-14 19:02:00 -03:00
andres-portainer
2cb94116a3 fix(net): upgrade golang.org/x/net to v0.54.0 to fix CVE-2026-27141 CVE-2026-33814 BE-12965 (#2631) 2026-05-14 15:46:43 -03:00
Hannah Cooper
a81b66c6b0 feat(api-docs): Introduce API docs groupings [C9S-96] (#2656) 2026-05-14 15:09:22 +12:00
Devon Steenberg
c9d24c3684 fix(libstack): replace filepath.Join with filesystem.JoinPaths [BE-11476] (#2655) 2026-05-14 13:57:29 +12:00
Oscar Zhou
8a22e05284 fix(stack): git stack edit validation and repo credential lookup [BE-12899] (#2594) 2026-05-14 12:27:20 +12:00
Devon Steenberg
3b0f1eca4b feat(swarm): port swarm to use libstack [BE-11476] (#2486) 2026-05-14 10:13:19 +12:00
bernard-portainer
a66f114f24 fix(sidebar) override button padding to keep sidebar parent items in line [C9S-184] (#2641) 2026-05-14 08:39:06 +12:00
andres-portainer
2c00f4d40b fix(go-git): upgrade github.com/go-git/go-git/v5 to v5.19.0 to fix CVE-2026-34165 GHSA-3xc5-wrhm-f963 CVE-2026-33762 BE-12966 (#2634) 2026-05-13 13:10:19 -03:00
andres-portainer
2e88f7a245 fix(chisel): add another mechanism to ensure snapshot collection BE-12896 (#2628) 2026-05-13 10:50:58 -03:00
Chaim Lev-Ari
dd68560ad0 chore(deps): upgrade prettier (#2592)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 16:39:58 +03:00
RHCowan
d1b702ef37 feat(alerting): add etcd health metric and built-in alert rule [R8S-999] (#2538) 2026-05-13 18:56:09 +12:00
Oscar Zhou
7f3389d6f4 chore(version): bump develop version to 2.41.1 (#2646)
Co-authored-by: Nicholas Loomans <nicholas.loomans@portainer.io>
2026-05-13 16:23:35 +12:00
Chaim Lev-Ari
d9a415f011 feat(gitops): introduce sources list view [BE-12902] (#2550) 2026-05-12 15:32:46 +03:00
Ali
edff47fd41 feat(environments): offer edge connectivity test before adding edge environments [c9s-149] (#2527) 2026-05-12 16:25:39 +12:00
bernard-portainer
b3a9386607 fix(edgeEnv) edge envs that haven't checked in can't be outdated [C9S-168] (#2608) 2026-05-12 15:14:58 +12:00
bernard-portainer
300a8abc97 fix(DockerDetails) replace missing icon on host panel [C9S-170] (#2612) 2026-05-12 10:41:42 +12:00
andres-portainer
2bb2b78e82 chore(csrf): remove gorilla/csrf BE-12948 (#2618) 2026-05-11 19:41:26 -03:00
andres-portainer
540c9ba6d5 fix(chisel): upgrade Chisel to v1.11.6 to avoid a panic because of a negative waitgroup counter BE-12743 (#2619) 2026-05-11 19:40:54 -03:00
Josiah Clumont
872b824dc6 feat(design-system): introduce ResourceDetailHeader [BE-12848] (#2536)
Ignored some flaky tests
2026-05-12 10:23:58 +12:00
Oscar Zhou
9ecd8d3efb fix(environment): reject TLS config for Edge Agent environment creation and update [BE-12700] (#2609) 2026-05-12 08:50:41 +12:00
andres-portainer
080d75acae chore(openamt): remove OpenAMT completely BE-12950 (#2616) 2026-05-11 15:48:39 -03:00
andres-portainer
62f4d47ee5 chore(internal): export endpoints and authorizations so they can be shared between CE and EE BE-12893 (#2464) 2026-05-11 10:44:09 -03:00
Chaim Lev-Ari
c0ac6c56ac feat(ui): introduce design system primitives [DEV-52] (#2535) 2026-05-11 08:45:59 +03:00
Hannah Cooper
3e60c2306c Update bug report template to include 2.41.1 (#2611) 2026-05-11 16:34:54 +12:00
bernard-portainer
59614d31f2 fix(edgeEnvironments) update displayed edge agent URLs [C9S-167] (#2602)
* Remove URL when rendering edge agent in list as it was displaying the server URL
* Add server and tunnel URL to information panel in environment display
2026-05-11 14:52:11 +12:00
Oscar Zhou
a117e514e4 fix(stack): persist CreatedBy before deployment to prevent broken auto update [BE-12939] (#2588) 2026-05-11 12:54:04 +12:00
Josiah Clumont
8d098a2bb9 style(dropdown-menu): fix count badge alignment and uniform width [C9S-116] (#2605) 2026-05-11 12:51:18 +12:00
Josiah Clumont
899e4b6f67 refactor(dropdown-menu): update styling to align with designs [C9S-116] (#2596) 2026-05-11 10:25:15 +12:00
LP B
dba86594e1 fix(app/kubernetes): kube edit app buttons (#2565) 2026-05-09 11:00:17 +02:00
Chaim Lev-Ari
8885038b7e refactor(settings/auth): migrate admin group section to react [BE-12592] (#2472) 2026-05-08 10:51:12 +03:00
bernard-portainer
76f525fd38 refactor(home): refactor Environment List to use SortableList component [C9S-131] (#2522)
- Migrate `EnvironmentList` from `GroupSortTable` to `SortableList`, removing ~1,700 lines of duplicated component code
- Move health sort ranking to the backend (`sort.go`), adding `Health` and `Id` sort keys
- Delete `GroupSortTable`, `GroupSortTableGroupRow`, `useGroupSortTableState`, and `store` — functionality absorbed by `SortableList`
- Add `useHomeViewState` hook to centralise home view URL state (`groupBy`, `groupFilter`, `order`, `page`, `search`)
- Update `useTableStateFromUrl` to support `groupBy` and `groupFilter` URL params with a `buildExtra` callback
- Rename URL param `filter` → `groupFilter` for clarity; add `search` and `order` to `/home` route definition
- Simplify `EnvironmentList` props — remove `headerFilter` / `onHeaderFilterChange`, leaving only `onClickBrowse`
- Add `computeSortDesc` pure utility to `SortableList` and cover all toggle/reset cases with unit tests
- Update `SortableListHeader` to use `activeKey` prop (renamed from `sortBy`); fix all callsites and stories
- Fix `SortableList` sort-key normalisation to be case-insensitive; update tests to reflect no-match behaviour
2026-05-08 16:55:40 +12:00
Cara Ryan
3d741ad58d fix(users): Fix for users effective access viewer not including policies [C9S-109] (#2539) 2026-05-08 15:00:17 +12:00
RHCowan
ff169ed356 feat(alerting): expand tiered rules into per-severity evaluators with state aggregation [R8S-1003] (#2586) 2026-05-08 14:50:59 +12:00
Hannah Cooper
ed7f074380 Update bug report template to include 2.39.2 (#2587) 2026-05-07 16:20:36 +12:00
Ali
9eb6ebfe9b fix(wizard): ensure select renders on top of footer [c9s-169] (#2577) 2026-05-07 14:15:21 +12:00
Hannah Cooper
29cfde99ae Update bug report template to include 2.33.8 (#2583) 2026-05-07 13:11:08 +12:00
Oscar Zhou
c3b0b9a2e0 fix(ecr): prevent deadlock on ECR token refresh during stack deployment [BE-12842] (#2564) 2026-05-07 08:34:19 +12:00
Devon Steenberg
e7ec69708e fix(libstack): pull images sequentially and respect COMPOSE_PARALLEL_LIMIT [BE-12930] (#2556) 2026-05-06 15:16:41 +12:00
Ali
ff9c10f641 feat(docker): show host disk usage in the UI [C9S-144] (#2517) 2026-05-05 22:40:16 +12:00
Ali
0eba817aab fix(environments): align Linux/Windows labels for edge agent and Docker API [c9s-157] (#2558) 2026-05-05 22:01:13 +12:00
Ali
6cb6f2e9b4 fix(change-confirmation): add git dry run and docker resize to the excluded urls [c9s-159] (#2562) 2026-05-05 18:00:03 +12:00
Devon Steenberg
6faa0939d8 fix(kubectl-shell): kubectl-shell-image flag [BE-12929] (#2542) 2026-05-05 13:50:40 +12:00
Josiah Clumont
68f93fb281 feature(storybook): Storybook usability upgrades [C9S-140] (#2482) 2026-05-05 09:25:09 +12:00
bernard-portainer
1ea8c1cb4e feat(homeView) add age sort option as default [C9S-150] (#2546) 2026-05-05 08:17:06 +12:00
andres-portainer
d749d05359 fix(datastore): change EnforceEdgeID default to true BE-12925 (#2547) 2026-05-04 15:29:58 -03:00
Chaim Lev-Ari
b18b4418c8 fix(kube/app): get stack only for managed stacks [BE-12927] (#2516) 2026-05-03 09:15:20 +03:00
Ali
a3935ce445 feat(secrets): allow linking secrets to service accounts as imagepullsecrets [c9s-49] (#2488) 2026-05-01 22:54:33 +12:00
Oscar Zhou
92bbfb8fa3 chore(remote): add log for resolved unpacker image [BE-12884] (#2459) 2026-05-01 17:03:40 +12:00
RHCowan
6c097dcf51 feat(alerting): propagate edge annotations for meaningful Kubernetes summaries [R8S-993] (#2514) 2026-05-01 08:13:07 +12:00
LP B
0688e6bbdd fix(api/workflows): kubernetes UAC (#2508)
Co-authored-by: andres-portainer <91705312+andres-portainer@users.noreply.github.com>
2026-04-30 10:54:38 -03:00
Hannah Cooper
c49e682df4 Update bug report template to include 2.41.0 (#2511) 2026-04-30 13:53:32 +12:00
RHCowan
538d57fe19 fix(agent): correct Podman container engine header in sync edge client [BE-12887] (#2498) 2026-04-30 08:47:44 +12:00
LP B
3053990411 fix(api/workflows): move filterK8SStacks outside of transaction (#2505) 2026-04-29 17:56:57 +02:00
RHCowan
49011d4d03 feat(alerting): Add built-in alert for Kubernetes nodes in NotReady state [R8S-990] (#2485) 2026-04-29 15:44:09 +12:00
Cara Ryan
6a30138b3c feat(home): environment home page ui improvements to highlight groups [C9S-23] (#2487)
Signed-off-by: Bernard Setz <bernard.setz@portainer.io>
Co-authored-by: bernard-portainer <bernard.setz@portainer.io>
Co-authored-by: Ali <83188384+testA113@users.noreply.github.com>
Co-authored-by: Yajith Dayarathna <yajith.dayarathna@portainer.io>
Co-authored-by: Chaim Lev-Ari <chiptus@users.noreply.github.com>
Co-authored-by: andres-portainer <91705312+andres-portainer@users.noreply.github.com>
Co-authored-by: Josiah Clumont <josiah.clumont@portainer.io>
Co-authored-by: Dakota Walsh <101994734+dakota-portainer@users.noreply.github.com>
2026-04-29 14:59:39 +12:00
Xing
6aac4f38e4 fix(test): isolate registry config in OCI client tests to fix env-dependent failures [C9S-119] (#2401) 2026-04-29 10:18:52 +12:00
LP B
bc6c5da2dc feat(api/gitops): list and filter kubernetes git workflows (#2474) 2026-04-27 15:24:39 -03:00
andres-portainer
1c55555ad0 chore(tests): increase code coverage BE-12877 (#2431) 2026-04-27 12:32:44 -03:00
Chaim Lev-Ari
3f8fcb3914 fix(ui/sortable-list): remove 1 as page size option [BE-12900] (#2469) 2026-04-27 17:01:12 +03:00
andres-portainer
24a879add6 fix(docker): enforce resource controls on /containers/{id}/attach/ws BE-12891 (#2448) 2026-04-27 09:17:28 -03:00
Chaim Lev-Ari
ae1b6b8a71 feat(gitops): show live git validity status in workflow overview [BE-12885] (#2447)
Co-authored-by: Claude <noreply@anthropic.com>
2026-04-27 13:11:55 +03:00
Chaim Lev-Ari
da36002d37 fix(gitops): align list component with current design [BE-12888] (#2443)
Co-authored-by: Bernard Setz <bernard.setz@portainer.io>
2026-04-26 16:48:45 +03:00
Chaim Lev-Ari
a611e12b5c fix(kube/stacks): allow empty stack name [BE-12889] (#2444) 2026-04-26 12:14:45 +03:00
andres-portainer
d4114c510d fix(factory): clear the output raw path to avoid forwarding a different path than the validated one BE-12880 (#2442) 2026-04-24 09:46:46 -03:00
nickl-portainer
5eaf145eda chore(react-query): update all deprecated withError to use withGlobalError [R8S-968] (#2461)
Co-authored-by: Ali <83188384+testA113@users.noreply.github.com>
2026-04-24 16:01:59 +12:00
Josiah Clumont
2c2ec6f6e6 feat(recommendations): completeness recommendations [C9S-18] (#2262) 2026-04-24 10:46:47 +12:00
Ali
39ac164890 fix(ui): use uuidv4 instead of cryptorandomuuid to support non-secure browsers [c9s-133] (#2432) 2026-04-24 08:41:51 +12:00
andres-portainer
8140c834ca fix(docker): add exec restrictions BE-12878 (#2429) 2026-04-23 15:29:03 -03:00
Ali
742523de17 feat(docker): add docker builder prune as option [C9S-128] (#2423) 2026-04-23 09:06:47 +12:00
Chaim Lev-Ari
dd1c1071ce feat(gitops): introduce workflows view [BE-12807] (#2391) 2026-04-22 10:17:37 -03:00
790 changed files with 32307 additions and 10147 deletions

View File

@@ -94,7 +94,10 @@ body:
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.41.1'
- '2.41.0'
- '2.40.0'
- '2.39.2'
- '2.39.1'
- '2.39.0'
- '2.38.1'
@@ -103,6 +106,7 @@ body:
- '2.36.0'
- '2.35.0'
- '2.34.0'
- '2.33.8'
- '2.33.7'
- '2.33.6'
- '2.33.5'
@@ -111,38 +115,7 @@ body:
- '2.33.2'
- '2.33.1'
- '2.33.0'
- '2.32.0'
- '2.31.3'
- '2.31.2'
- '2.31.1'
- '2.31.0'
- '2.30.1'
- '2.30.0'
- '2.29.2'
- '2.29.1'
- '2.29.0'
- '2.28.1'
- '2.28.0'
- '2.27.9'
- '2.27.8'
- '2.27.7'
- '2.27.6'
- '2.27.5'
- '2.27.4'
- '2.27.3'
- '2.27.2'
- '2.27.1'
- '2.27.0'
- '2.26.1'
- '2.26.0'
- '2.25.1'
- '2.25.0'
- '2.24.1'
- '2.24.0'
- '2.23.0'
- '2.22.0'
- '2.21.5'
- '2.21.4'
validations:
required: true

View File

@@ -1,15 +1,21 @@
import path from 'path';
// 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-essentials',
'@storybook/addon-webpack5-compiler-swc',
'@chromatic-com/storybook',
{
@@ -44,6 +50,7 @@ const config: StorybookConfig = {
],
},
},
'@storybook/addon-docs',
],
webpackFinal: (config) => {
const rules = config?.module?.rules || [];
@@ -96,12 +103,7 @@ const config: StorybookConfig = {
},
staticDirs: ['./public'],
typescript: {
reactDocgen: 'react-docgen-typescript',
reactDocgenTypescriptOptions: {
compilerOptions: {
outDir: path.resolve(__dirname, '..', 'dist/public'),
},
},
reactDocgen: 'react-docgen',
},
framework: {
name: '@storybook/react-webpack5',

View File

@@ -1,9 +1,10 @@
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';
import { Preview } from '@storybook/react-webpack5';
initMSW(
{
@@ -26,13 +27,43 @@ const testQueryClient = new QueryClient({
});
const preview: Preview = {
decorators: (Story) => (
<QueryClientProvider client={testQueryClient}>
<UIRouter plugins={[pushStateLocationPlugin]}>
<Story />
</UIRouter>
</QueryClientProvider>
),
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 (
<QueryClientProvider client={testQueryClient}>
<UIRouter plugins={[pushStateLocationPlugin]}>
<Story />
</UIRouter>
</QueryClientProvider>
);
},
loaders: [mswLoader],
parameters: {
options: {

View File

@@ -3,7 +3,7 @@ ENV=development
WEBPACK_CONFIG=webpack/webpack.$(ENV).js
TAG=local
SWAG=go run github.com/swaggo/swag/cmd/swag@v1.16.2
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)
@@ -36,8 +36,8 @@ build-storybook: ## Build and serve the storybook files
.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
@./build/download_binaries.sh $(PLATFORM) $(ARCH)
client-deps: ## Install client dependencies
pnpm install
@@ -108,7 +108,7 @@ dev-extension: build-server build-client ## Run the extension in development mod
##@ Docs
.PHONY: docs-build docs-validate docs-clean docs-validate-clean
docs-build: init-dist ## Build docs
go mod download -x
go mod download
cd api && $(SWAG) init -o "../dist/docs" -ot "yaml" -g ./http/handler/handler.go --parseDependency --parseInternal --parseDepth 2 -p pascalcase --markdownFiles ./
docs-validate: docs-build ## Validate docs

View File

@@ -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<HTMLDivElement>;
label: string;
setLabel: (v: string) => void;
};
const MenuCtx = createContext<MenuCtxType | null>(null);
export function Menu({ children }: { children?: ReactNode }) {
const [isOpen, setOpen] = useState(false);
const [label, setLabel] = useState('');
const menuRef = useRef<HTMLDivElement>(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 (
<MenuCtx.Provider value={{ isOpen, setOpen, menuRef, label, setLabel }}>
<div ref={menuRef}>{children}</div>
</MenuCtx.Provider>
);
}
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 (
<button type="button" onClick={handleClick} {...props}>
{children}
</button>
);
}
export function MenuList({
children,
className,
}: {
children?: ReactNode;
className?: string;
}) {
const ctx = useContext(MenuCtx);
if (!ctx?.isOpen) return null;
return (
<div role="menu" aria-label={ctx.label || undefined} className={className}>
{children}
</div>
);
}
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
<div role="menuitem" onClick={handleClick} className={className}>
{children}
</div>
);
}

119
api/agent/version_test.go Normal file
View File

@@ -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)
}

View File

@@ -1,64 +0,0 @@
Portainer API is an HTTP API served by Portainer. It is used by the Portainer UI and everything you can do with the UI can be done using the HTTP API.
Examples are available at https://documentation.portainer.io/api/api-examples/
You can find out more about Portainer at [http://portainer.io](http://portainer.io) and get some support on [Slack](http://portainer.io/slack/).
# Authentication
Most of the API environments(endpoints) require to be authenticated as well as some level of authorization to be used.
Portainer API uses JSON Web Token to manage authentication and thus requires you to provide a token in the **Authorization** header of each request
with the **Bearer** authentication mechanism.
Example:
```
Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidXNlcm5hbWUiOiJhZG1pbiIsInJvbGUiOjEsImV4cCI6MTQ5OTM3NjE1NH0.NJ6vE8FY1WG6jsRQzfMqeatJ4vh2TWAeeYfDhP71YEE
```
# Security
Each API environment(endpoint) has an associated access policy, it is documented in the description of each environment(endpoint).
Different access policies are available:
- Public access
- Authenticated access
- Restricted access
- Administrator access
### Public access
No authentication is required to access the environments(endpoints) with this access policy.
### Authenticated access
Authentication is required to access the environments(endpoints) with this access policy.
### Restricted access
Authentication is required to access the environments(endpoints) with this access policy.
Extra-checks might be added to ensure access to the resource is granted. Returned data might also be filtered.
### Administrator access
Authentication as well as an administrator role are required to access the environments(endpoints) with this access policy.
# Execute Docker requests
Portainer **DO NOT** expose specific environments(endpoints) to manage your Docker resources (create a container, remove a volume, etc...).
Instead, it acts as a reverse-proxy to the Docker HTTP API. This means that you can execute Docker requests **via** the Portainer HTTP API.
To do so, you can use the `/endpoints/{id}/docker` Portainer API environment(endpoint) (which is not documented below due to Swagger limitations). This environment(endpoint) has a restricted access policy so you still need to be authenticated to be able to query this environment(endpoint). Any query on this environment(endpoint) will be proxied to the Docker API of the associated environment(endpoint) (requests and responses objects are the same as documented in the Docker API).
# Private Registry
Using private registry, you will need to pass a based64 encoded JSON string ‘{"registryId":\<registryID value\>}’ inside the Request Header. The parameter name is "X-Registry-Auth".
\<registryID value\> - The registry ID where the repository was created.
Example:
```
eyJyZWdpc3RyeUlkIjoxfQ==
```
**NOTE**: You can find more information on how to query the Docker API in the [Docker official documentation](https://docs.docker.com/engine/api/v1.30/) as well as in [this Portainer example](https://documentation.portainer.io/api/api-examples/).

61
api/api.md Normal file
View File

@@ -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":\<registryId\>}’ where `<registryId>` is the ID of the registry where the repository was created.
Example encoded value:
```
eyJyZWdpc3RyeUlkIjoxfQ==
```

View File

@@ -8,8 +8,8 @@ import (
"time"
)
func (s *Service) GetEncodedAuthorizationToken() (token *string, expiry *time.Time, err error) {
getAuthorizationTokenOutput, err := s.client.GetAuthorizationToken(context.TODO(), nil)
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
}
@@ -27,8 +27,8 @@ func (s *Service) GetEncodedAuthorizationToken() (token *string, expiry *time.Ti
return
}
func (s *Service) GetAuthorizationToken() (token *string, expiry *time.Time, err error) {
tokenEncodedStr, expiry, err := s.GetEncodedAuthorizationToken()
func (s *Service) GetAuthorizationToken(ctx context.Context) (token *string, expiry *time.Time, err error) {
tokenEncodedStr, expiry, err := s.GetEncodedAuthorizationToken(ctx)
if err != nil {
return
}

274
api/backup/backup_test.go Normal file
View File

@@ -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)
}

View File

@@ -243,8 +243,9 @@ func (service *Service) startTunnelVerificationLoop() {
})
}
// checkTunnels finds the first tunnel that has not had any activity recently
// and attempts to take a snapshot, then closes it and returns
// 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()
@@ -255,12 +256,32 @@ func (service *Service) checkTunnels() {
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
}
tunnelPort := tunnel.Port
service.mu.RUnlock()
log.Debug().
@@ -269,13 +290,7 @@ func (service *Service) checkTunnels() {
Float64("timeout_seconds", activeTimeout.Seconds()).
Msg("last activity timeout exceeded")
if err := service.snapshotEnvironment(endpointID, tunnelPort); err != nil {
log.Error().
Int("endpoint_id", int(endpointID)).
Err(err).
Msg("unable to snapshot Edge environment")
}
service.snapshotAndLog(endpointID, tunnelPort)
service.close(endpointID)
return
@@ -284,6 +299,28 @@ func (service *Service) checkTunnels() {
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")
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 {

View File

@@ -1,6 +1,8 @@
package chisel
import (
"context"
"errors"
"net"
"net/http"
"testing"
@@ -17,14 +19,36 @@ func init() {
fips.InitFIPS(false)
}
func TestPingAgentPanic(t *testing.T) {
t.Parallel()
endpoint := &portainer.Endpoint{
ID: 1,
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)
@@ -57,3 +81,140 @@ func TestPingAgentPanic(t *testing.T) {
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 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")
}

View File

@@ -9,6 +9,7 @@ import (
"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"
@@ -237,3 +238,18 @@ func encryptCredentials(username, password, key string) (string, error) {
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
}

View File

@@ -94,13 +94,20 @@ func (Service) ParseFlags(version string) (*portainer.CLIFlags, error) {
flags.TLSKey = tlsKeyFlag.String()
flags.TLSCacert = kingpin.Flag("tlscacert", "Path to the CA").Default(defaultTLSCACertPath).String()
flags.KubectlShellImage = kingpin.Flag(
var hasKubectlShellImageFlag bool
kubectlShellImageFlag := kingpin.Flag(
"kubectl-shell-image",
"Kubectl shell image",
).Envar(portainer.KubectlShellImageEnvVar).Default(portainer.DefaultKubectlShellImage).String()
).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 {

View File

@@ -6,6 +6,7 @@ import (
"strings"
"testing"
portainer "github.com/portainer/portainer/api"
zerolog "github.com/rs/zerolog/log"
"github.com/stretchr/testify/require"
)
@@ -26,6 +27,59 @@ func TestOptionParser(t *testing.T) {
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

View File

@@ -26,7 +26,6 @@ import (
"github.com/portainer/portainer/api/exec"
"github.com/portainer/portainer/api/filesystem"
"github.com/portainer/portainer/api/git"
"github.com/portainer/portainer/api/hostmanagement/openamt"
"github.com/portainer/portainer/api/http"
"github.com/portainer/portainer/api/http/proxy"
kubeproxy "github.com/portainer/portainer/api/http/proxy/factory/kubernetes"
@@ -53,6 +52,7 @@ import (
"github.com/portainer/portainer/pkg/fips"
"github.com/portainer/portainer/pkg/libhelm"
"github.com/portainer/portainer/pkg/libstack/compose"
libswarm "github.com/portainer/portainer/pkg/libstack/swarm"
"github.com/portainer/portainer/pkg/validate"
"github.com/google/uuid"
@@ -243,6 +243,10 @@ func updateSettingsFromFlags(dataStore dataservices.DataStore, flags *portainer.
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
}
@@ -334,7 +338,6 @@ func loadEncryptionSecretKey(keyfilename string) []byte {
}
func buildServer(flags *portainer.CLIFlags, shutdownCtx context.Context, shutdownTrigger context.CancelFunc) portainer.Server {
if flags.FeatureFlags != nil {
featureflags.Parse(*flags.FeatureFlags, portainer.SupportedFeatureFlags)
}
@@ -394,9 +397,6 @@ func buildServer(flags *portainer.CLIFlags, shutdownCtx context.Context, shutdow
gitService := git.NewService(shutdownCtx)
// Setting insecureSkipVerify to true to preserve the old behaviour.
openAMTService := openamt.NewService(true)
cryptoService := crypto.Service{}
signatureService := initDigitalSignatureService()
@@ -437,16 +437,11 @@ func buildServer(flags *portainer.CLIFlags, shutdownCtx context.Context, shutdow
reverseTunnelService.ProxyManager = proxyManager
dockerConfigPath := fileService.GetDockerConfigPath()
composeDeployer := compose.NewComposeDeployer()
composeStackManager := exec.NewComposeStackManager(composeDeployer, proxyManager, dataStore)
composeStackManager := exec.NewComposeStackManager(composeDeployer, proxyManager)
swarmStackManager, err := exec.NewSwarmStackManager(*flags.Assets, dockerConfigPath, signatureService, fileService, reverseTunnelService, dataStore)
if err != nil {
log.Fatal().Err(err).Msg("failed initializing swarm stack manager")
}
swarmStackManager := exec.NewSwarmStackManager(libswarm.NewSwarmDeployer(), proxyManager)
kubernetesDeployer := initKubernetesDeployer(kubernetesTokenCacheManager, kubernetesClientFactory, dataStore, reverseTunnelService, signatureService, proxyManager)
@@ -589,7 +584,6 @@ func buildServer(flags *portainer.CLIFlags, shutdownCtx context.Context, shutdow
LDAPService: ldapService,
OAuthService: oauthService,
GitService: gitService,
OpenAMTService: openAMTService,
ProxyManager: proxyManager,
KubernetesTokenCacheManager: kubernetesTokenCacheManager,
KubeClusterAccessService: kubeClusterAccessService,

View File

@@ -4,7 +4,9 @@ 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"
@@ -13,7 +15,7 @@ import (
const secretFileName = "secret.txt"
func createPasswordFile(t *testing.T, secretPath, password string) string {
err := os.WriteFile(secretPath, []byte(password), 0600)
err := os.WriteFile(secretPath, []byte(password), 0o600)
require.NoError(t, err)
return secretPath
}
@@ -40,6 +42,65 @@ func TestLoadEncryptionSecretKey(t *testing.T) {
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 {

View File

@@ -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)
}

View File

@@ -17,7 +17,7 @@ import (
)
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="},"OpenAMTConfiguration":{"Enabled":false,"MPSServer":"","MPSUser":"","MPSPassword":"","MPSToken":"","CertFileContent":"","CertFileName":"","CertFilePassword":"","DomainName":""},"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}`
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"
)

View File

@@ -59,6 +59,7 @@ func (store *Store) checkOrCreateDefaultSettings() error {
KubectlShellImage: *store.flags.KubectlShellImage,
IsDockerDesktopExtension: isDDExtention,
EnforceEdgeID: true,
}
return store.SettingsService.UpdateSettings(defaultSettings)

View File

@@ -607,6 +607,7 @@
"EnableEdgeComputeFeatures": false,
"EnforceEdgeID": false,
"FeatureFlagSettings": null,
"ForceSecureCookies": false,
"GlobalDeploymentOptions": {
"hideStacksFunctionality": false
},
@@ -615,7 +616,7 @@
"RequiredPasswordLength": 12
},
"KubeconfigExpiry": "0",
"KubectlShellImage": "portainer/kubectl-shell:2.41.0",
"KubectlShellImage": "portainer/kubectl-shell:2.42.0",
"LDAPSettings": {
"AnonymousMode": true,
"AutoCreateUsers": true,
@@ -660,18 +661,7 @@
"SnapshotInterval": "5m",
"TemplatesURL": "",
"TrustOnFirstConnect": false,
"UserSessionTimeout": "8h",
"openAMTConfiguration": {
"certFileContent": "",
"certFileName": "",
"certFilePassword": "",
"domainName": "",
"enabled": false,
"mpsPassword": "",
"mpsServer": "",
"mpsToken": "",
"mpsUser": ""
}
"UserSessionTimeout": "8h"
},
"snapshots": [
{
@@ -947,7 +937,7 @@
}
],
"version": {
"VERSION": "{\"SchemaVersion\":\"2.41.0\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
"VERSION": "{\"SchemaVersion\":\"2.42.0\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
},
"webhooks": null
}

View File

@@ -1,5 +1,79 @@
package exec
import "regexp"
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.ValidateRegistriesECRTokens 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
}

View File

@@ -6,35 +6,25 @@ import (
"io"
"os"
"path"
"strings"
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/internal/registryutils"
"github.com/portainer/portainer/api/logs"
"github.com/portainer/portainer/api/stacks/stackutils"
"github.com/portainer/portainer/pkg/libstack"
"github.com/docker/cli/cli/config/types"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
)
// ComposeStackManager is a wrapper for docker-compose binary
type ComposeStackManager struct {
deployer libstack.Deployer
proxyManager *proxy.Manager
dataStore dataservices.DataStore
}
// NewComposeStackManager returns a Compose stack manager
func NewComposeStackManager(deployer libstack.Deployer, proxyManager *proxy.Manager, dataStore dataservices.DataStore) *ComposeStackManager {
func NewComposeStackManager(deployer libstack.Deployer, proxyManager *proxy.Manager) *ComposeStackManager {
return &ComposeStackManager{
deployer: deployer,
proxyManager: proxyManager,
dataStore: dataStore,
}
}
@@ -45,9 +35,9 @@ func (manager *ComposeStackManager) ComposeSyntaxMaxVersion() string {
// 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 := manager.fetchEndpointProxy(endpoint)
url, proxy, err := fetchEndpointProxy(manager.proxyManager, endpoint)
if err != nil {
return errors.Wrap(err, "failed to fetch environment proxy")
return fmt.Errorf("failed to fetch environment proxy: %w", err)
}
if proxy != nil {
@@ -56,30 +46,32 @@ func (manager *ComposeStackManager) Up(ctx context.Context, stack *portainer.Sta
envFilePath, err := createEnvFile(stack)
if err != nil {
return errors.Wrap(err, "failed to create env file")
return fmt.Errorf("failed to create env file: %w", err)
}
filePaths := stackutils.GetStackFilePaths(stack, true)
err = manager.deployer.Deploy(ctx, filePaths, libstack.DeployOptions{
if err = manager.deployer.Deploy(ctx, filePaths, libstack.DeployOptions{
Options: libstack.Options{
WorkingDir: stack.ProjectPath,
EnvFilePath: envFilePath,
Host: url,
ProjectName: stack.Name,
Registries: portainerRegistriesToAuthConfigs(manager.dataStore, options.Registries),
Registries: portainerRegistriesToAuthConfigs(options.Registries),
},
ForceRecreate: options.ForceRecreate,
AbortOnContainerExit: options.AbortOnContainerExit,
RemoveOrphans: options.Prune,
})
return errors.Wrap(err, "failed to deploy a stack")
}); 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 := manager.fetchEndpointProxy(endpoint)
url, proxy, err := fetchEndpointProxy(manager.proxyManager, endpoint)
if err != nil {
return errors.Wrap(err, "failed to fetch environment proxy")
return fmt.Errorf("failed to fetch environment proxy: %w", err)
}
if proxy != nil {
@@ -88,86 +80,78 @@ func (manager *ComposeStackManager) Run(ctx context.Context, stack *portainer.St
envFilePath, err := createEnvFile(stack)
if err != nil {
return errors.Wrap(err, "failed to create env file")
return fmt.Errorf("failed to create env file: %w", err)
}
filePaths := stackutils.GetStackFilePaths(stack, true)
err = manager.deployer.Run(ctx, filePaths, serviceName, libstack.RunOptions{
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(manager.dataStore, options.Registries),
Registries: portainerRegistriesToAuthConfigs(options.Registries),
},
Remove: options.Remove,
Args: options.Args,
Detached: options.Detached,
})
return errors.Wrap(err, "failed to deploy a stack")
}); 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 := manager.fetchEndpointProxy(endpoint)
url, proxy, err := fetchEndpointProxy(manager.proxyManager, endpoint)
if err != nil {
return err
return fmt.Errorf("failed to fetch environment proxy: %w", err)
} else if proxy != nil {
defer proxy.Close()
}
err = manager.deployer.Remove(ctx, stack.Name, nil, libstack.RemoveOptions{
if err = manager.deployer.Remove(ctx, stack.Name, nil, libstack.RemoveOptions{
Options: libstack.Options{
WorkingDir: "",
Host: url,
},
})
return errors.Wrap(err, "failed to remove a stack")
}); 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 := manager.fetchEndpointProxy(endpoint)
url, proxy, err := fetchEndpointProxy(manager.proxyManager, endpoint)
if err != nil {
return err
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 errors.Wrap(err, "failed to create env file")
return fmt.Errorf("failed to create env file: %w", err)
}
filePaths := stackutils.GetStackFilePaths(stack, true)
err = manager.deployer.Pull(ctx, filePaths, libstack.Options{
if err = manager.deployer.Pull(ctx, filePaths, libstack.Options{
WorkingDir: stack.ProjectPath,
EnvFilePath: envFilePath,
Host: url,
ProjectName: stack.Name,
Registries: portainerRegistriesToAuthConfigs(manager.dataStore, options.Registries),
})
return errors.Wrap(err, "failed to pull images of the stack")
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 stackNameNormalizeRegex.ReplaceAllString(strings.ToLower(name), "")
}
func (manager *ComposeStackManager) fetchEndpointProxy(endpoint *portainer.Endpoint) (string, *factory.ProxyServer, error) {
if strings.HasPrefix(endpoint.URL, "unix://") || strings.HasPrefix(endpoint.URL, "npipe://") {
return "", nil, nil
}
proxy, err := manager.proxyManager.CreateAgentProxyServer(endpoint)
if err != nil {
return "", nil, err
}
return fmt.Sprintf("tcp://127.0.0.1:%d", proxy.Port), proxy, nil
return normalizeStackName(name)
}
// createEnvFile creates a file that would hold both "in-place" and default environment variables.
@@ -178,7 +162,7 @@ func createEnvFile(stack *portainer.Stack) (string, error) {
}
envFilePath := path.Join(stack.ProjectPath, "stack.env")
envfile, err := os.OpenFile(envFilePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
envfile, err := os.OpenFile(envFilePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
if err != nil {
return "", err
}
@@ -229,49 +213,3 @@ func copyConfigEnvVars(w io.Writer, envs []portainer.Pair) error {
return nil
}
func portainerRegistriesToAuthConfigs(tx dataservices.DataStoreTx, 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(tx, &r)
if err != nil {
continue
}
}
authConfigs = append(authConfigs, ac)
}
return authConfigs
}
func getEffectiveRegUsernamePassword(tx dataservices.DataStoreTx, registry *portainer.Registry) (string, string, error) {
if err := registryutils.EnsureRegTokenValid(tx, registry); err != nil {
log.Warn().
Err(err).
Str("RegistryName", registry.Name).
Msg("Failed to validate registry token. Skip logging with this registry.")
return "", "", err
}
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
}

View File

@@ -48,7 +48,7 @@ func Test_UpAndDown(t *testing.T) {
deployer := compose.NewComposeDeployer()
w := NewComposeStackManager(deployer, nil, nil)
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)

View File

@@ -4,6 +4,7 @@ import (
"io"
"os"
"testing"
"time"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/filesystem"
@@ -95,3 +96,74 @@ func Test_createEnvFile_mergesDefultAndInplaceEnvVars(t *testing.T) {
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)
})
}

View File

@@ -1,258 +1,93 @@
package exec
import (
"bytes"
"context"
"errors"
"os"
"os/exec"
"path"
"runtime"
"strings"
"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/stacks/stackutils"
"github.com/rs/zerolog/log"
"github.com/segmentio/encoding/json"
"github.com/portainer/portainer/pkg/libstack/swarm"
)
// SwarmStackManager represents a service for managing stacks.
type SwarmStackManager struct {
binaryPath string
configPath string
signatureService portainer.DigitalSignatureService
fileService portainer.FileService
reverseTunnelService portainer.ReverseTunnelService
dataStore dataservices.DataStore
deployer swarm.Deployer
proxyManager *proxy.Manager
}
// NewSwarmStackManager initializes a new SwarmStackManager service.
// It also updates the configuration of the Docker CLI binary.
// NewSwarmStackManager creates a new SwarmStackManager.
func NewSwarmStackManager(
binaryPath, configPath string,
signatureService portainer.DigitalSignatureService,
fileService portainer.FileService,
reverseTunnelService portainer.ReverseTunnelService,
datastore dataservices.DataStore,
) (*SwarmStackManager, error) {
manager := &SwarmStackManager{
binaryPath: binaryPath,
configPath: configPath,
signatureService: signatureService,
fileService: fileService,
reverseTunnelService: reverseTunnelService,
dataStore: datastore,
deployer swarm.Deployer,
proxyManager *proxy.Manager,
) *SwarmStackManager {
return &SwarmStackManager{
deployer: deployer,
proxyManager: proxyManager,
}
if err := manager.updateDockerCLIConfiguration(manager.configPath); err != nil {
return nil, err
}
return manager, nil
}
// Login executes the docker login command against a list of registries (including DockerHub).
func (manager *SwarmStackManager) Login(ctx context.Context, registries []portainer.Registry, endpoint *portainer.Endpoint) error {
command, args, err := manager.prepareDockerCommandAndArgs(manager.binaryPath, manager.configPath, endpoint)
// 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 err
return fmt.Errorf("failed to fetch environment proxy: %w", err)
}
for _, registry := range registries {
if registry.Authentication {
username, password, err := getEffectiveRegUsernamePassword(manager.dataStore, &registry)
if err != nil {
continue
}
registryArgs := append(args, "login", "--username", username, "--password", password, registry.URL)
if err := runCommandAndCaptureStdErr(ctx, command, registryArgs, nil, ""); err != nil {
log.Warn().
Err(err).
Str("RegistryName", registry.Name).
Msg("Failed to login.")
}
}
if proxy != nil {
defer proxy.Close()
}
return nil
}
// Logout executes the docker logout command.
func (manager *SwarmStackManager) Logout(ctx context.Context, endpoint *portainer.Endpoint) error {
command, args, err := manager.prepareDockerCommandAndArgs(manager.binaryPath, manager.configPath, endpoint)
if err != nil {
return err
}
args = append(args, "logout")
return runCommandAndCaptureStdErr(ctx, command, args, nil, "")
}
// Deploy executes the docker stack deploy command.
func (manager *SwarmStackManager) Deploy(ctx context.Context, stack *portainer.Stack, prune bool, pullImage bool, endpoint *portainer.Endpoint) error {
filePaths := stackutils.GetStackFilePaths(stack, true)
command, args, err := manager.prepareDockerCommandAndArgs(manager.binaryPath, manager.configPath, endpoint)
env := make([]string, 0, len(stack.Env))
for _, ev := range stack.Env {
env = append(env, ev.Name+"="+ev.Value)
}
return manager.deployer.Deploy(context.TODO(), filePaths, swarm.DeployOptions{
Options: swarm.Options{
ProjectName: stack.Name,
Host: url,
Env: env,
WorkingDir: stack.ProjectPath,
Registries: portainerRegistriesToAuthConfigs(registries),
},
RemoveOrphans: prune,
PullImage: pullImage,
})
}
// 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 err
return fmt.Errorf("failed to fetch environment proxy: %w", err)
}
if prune {
args = append(args, "stack", "deploy", "--prune", "--with-registry-auth")
} else {
args = append(args, "stack", "deploy", "--with-registry-auth")
if proxy != nil {
defer proxy.Close()
}
if !pullImage {
args = append(args, "--resolve-image=never")
}
args = configureFilePaths(args, filePaths)
args = append(args, stack.Name)
env := make([]string, 0)
for _, envvar := range stack.Env {
env = append(env, envvar.Name+"="+envvar.Value)
}
return runCommandAndCaptureStdErr(ctx, command, args, env, stack.ProjectPath)
}
// Remove executes the docker stack rm command.
func (manager *SwarmStackManager) Remove(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint) error {
command, args, err := manager.prepareDockerCommandAndArgs(manager.binaryPath, manager.configPath, endpoint)
if err != nil {
return err
}
args = append(args, "stack", "rm", "--detach=false", stack.Name)
return runCommandAndCaptureStdErr(ctx, command, args, nil, "")
}
func runCommandAndCaptureStdErr(ctx context.Context, command string, args []string, env []string, workingDir string) error {
var stderr bytes.Buffer
var stdout bytes.Buffer
cmd := exec.CommandContext(ctx, command, args...)
cmd.Stderr = &stderr
cmd.Stdout = &stdout
if workingDir != "" {
cmd.Dir = workingDir
}
if env != nil {
cmd.Env = os.Environ()
cmd.Env = append(cmd.Env, env...)
}
if err := cmd.Run(); err != nil {
errMsg := strings.TrimSpace(stderr.String())
if errMsg == "" {
errMsg = strings.TrimSpace(stdout.String())
}
if errMsg == "" {
errMsg = err.Error()
}
return errors.New(errMsg)
}
return nil
}
func (manager *SwarmStackManager) prepareDockerCommandAndArgs(binaryPath, configPath string, endpoint *portainer.Endpoint) (string, []string, error) {
// Assume Linux as a default
command := path.Join(binaryPath, "docker")
if runtime.GOOS == "windows" {
command = path.Join(binaryPath, "docker.exe")
}
args := make([]string, 0)
args = append(args, "--config", configPath)
endpointURL := endpoint.URL
if endpoint.Type == portainer.EdgeAgentOnDockerEnvironment {
tunnelAddr, err := manager.reverseTunnelService.TunnelAddr(endpoint)
if err != nil {
return "", nil, err
}
endpointURL = "tcp://" + tunnelAddr
}
args = append(args, "-H", endpointURL)
if endpoint.TLSConfig.TLS {
args = append(args, "--tls")
if !endpoint.TLSConfig.TLSSkipVerify {
args = append(args, "--tlsverify", "--tlscacert", endpoint.TLSConfig.TLSCACertPath)
} else {
args = append(args, "--tlscacert", "")
}
if endpoint.TLSConfig.TLSCertPath != "" && endpoint.TLSConfig.TLSKeyPath != "" {
args = append(args, "--tlscert", endpoint.TLSConfig.TLSCertPath, "--tlskey", endpoint.TLSConfig.TLSKeyPath)
}
}
return command, args, nil
}
func (manager *SwarmStackManager) updateDockerCLIConfiguration(configPath string) error {
configFilePath := path.Join(configPath, "config.json")
config, err := manager.retrieveConfigurationFromDisk(configFilePath)
if err != nil {
log.Warn().Err(err).Msg("unable to retrieve the Swarm configuration from disk, proceeding without it")
}
signature, err := manager.signatureService.CreateSignature(portainer.PortainerAgentSignatureMessage)
if err != nil {
return err
}
if config["HttpHeaders"] == nil {
config["HttpHeaders"] = make(map[string]any)
}
headersObject := config["HttpHeaders"].(map[string]any)
headersObject["X-PortainerAgent-ManagerOperation"] = "1"
headersObject["X-PortainerAgent-Signature"] = signature
headersObject["X-PortainerAgent-PublicKey"] = manager.signatureService.EncodedPublicKey()
return manager.fileService.WriteJSONToFile(configFilePath, config)
}
func (manager *SwarmStackManager) retrieveConfigurationFromDisk(path string) (map[string]any, error) {
var config map[string]any
raw, err := manager.fileService.GetFileContent(path, "")
if err != nil {
return make(map[string]any), nil
}
if err := json.Unmarshal(raw, &config); err != nil {
return nil, err
}
return config, nil
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 stackNameNormalizeRegex.ReplaceAllString(strings.ToLower(name), "")
}
func configureFilePaths(args []string, filePaths []string) []string {
for _, path := range filePaths {
args = append(args, "--compose-file", path)
}
return args
return normalizeStackName(name)
}

View File

@@ -1,86 +0,0 @@
package exec
import (
"context"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestConfigFilePaths(t *testing.T) {
t.Parallel()
args := []string{"stack", "deploy", "--with-registry-auth"}
filePaths := []string{"dir/file", "dir/file-two", "dir/file-three"}
expected := []string{"stack", "deploy", "--with-registry-auth", "--compose-file", "dir/file", "--compose-file", "dir/file-two", "--compose-file", "dir/file-three"}
output := configureFilePaths(args, filePaths)
assert.ElementsMatch(t, expected, output, "wrong output file paths")
}
func TestPrepareDockerCommandAndArgs(t *testing.T) {
t.Parallel()
binaryPath := "/test/dist"
configPath := "/test/config"
manager := &SwarmStackManager{
binaryPath: binaryPath,
configPath: configPath,
}
endpoint := &portainer.Endpoint{
URL: "tcp://test:9000",
TLSConfig: portainer.TLSConfiguration{
TLS: true,
TLSSkipVerify: true,
},
}
command, args, err := manager.prepareDockerCommandAndArgs(binaryPath, configPath, endpoint)
require.NoError(t, err)
expectedCommand := "/test/dist/docker"
expectedArgs := []string{"--config", "/test/config", "-H", "tcp://test:9000", "--tls", "--tlscacert", ""}
require.Equal(t, expectedCommand, command)
require.Equal(t, expectedArgs, args)
}
func TestRunCommandAndCaptureStdErr(t *testing.T) {
t.Parallel()
t.Run("should return nil on successful command", func(t *testing.T) {
err := runCommandAndCaptureStdErr(context.Background(), "echo", []string{"hello"}, nil, "")
require.NoError(t, err)
})
t.Run("should capture stderr on failure", func(t *testing.T) {
err := runCommandAndCaptureStdErr(context.Background(), "sh", []string{"-c", "echo 'stderr error' >&2; exit 1"}, nil, "")
require.Error(t, err)
assert.Contains(t, err.Error(), "stderr error")
})
t.Run("should fall back to stdout when stderr is empty", func(t *testing.T) {
err := runCommandAndCaptureStdErr(context.Background(), "sh", []string{"-c", "echo 'stdout error'; exit 1"}, nil, "")
require.Error(t, err)
assert.Contains(t, err.Error(), "stdout error")
})
t.Run("should fall back to exec error when both are empty", func(t *testing.T) {
err := runCommandAndCaptureStdErr(context.Background(), "sh", []string{"-c", "exit 1"}, nil, "")
require.Error(t, err)
assert.NotEmpty(t, err.Error())
assert.Contains(t, err.Error(), "exit status 1")
})
t.Run("should prefer stderr over stdout", func(t *testing.T) {
err := runCommandAndCaptureStdErr(context.Background(), "sh", []string{"-c", "echo 'stdout msg'; echo 'stderr msg' >&2; exit 1"}, nil, "")
require.Error(t, err)
assert.Contains(t, err.Error(), "stderr msg")
assert.NotContains(t, err.Error(), "stdout msg")
})
t.Run("should return error for non-existent command", func(t *testing.T) {
err := runCommandAndCaptureStdErr(context.Background(), "nonexistent-cmd-12345", nil, nil, "")
require.Error(t, err)
})
}

View File

@@ -46,8 +46,6 @@ const (
BinaryStorePath = "bin"
// EdgeJobStorePath represents the subfolder where schedule files are stored.
EdgeJobStorePath = "edge_jobs"
// DockerConfigPath represents the subfolder where docker configuration is stored.
DockerConfigPath = "docker_config"
// ExtensionRegistryManagementStorePath represents the subfolder where files related to the
// registry management extension are stored.
ExtensionRegistryManagementStorePath = "extensions"
@@ -135,11 +133,6 @@ func NewService(dataStorePath, fileStorePath string) (*Service, error) {
return nil, err
}
err = service.createDirectoryInStore(DockerConfigPath)
if err != nil {
return nil, err
}
return service, nil
}
@@ -148,11 +141,6 @@ func (service *Service) GetBinaryFolder() string {
return JoinPaths(service.fileStorePath, BinaryStorePath)
}
// GetDockerConfigPath returns the full path to the docker config store on the filesystem
func (service *Service) GetDockerConfigPath() string {
return JoinPaths(service.fileStorePath, DockerConfigPath)
}
// RemoveDirectory removes a directory on the filesystem.
func (service *Service) RemoveDirectory(directoryPath string) error {
return os.RemoveAll(directoryPath)

View File

@@ -2,6 +2,9 @@ package gittypes
import (
"errors"
"net/url"
"path"
"strings"
)
var (
@@ -10,6 +13,10 @@ var (
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
@@ -27,9 +34,30 @@ type RepoConfig struct {
TLSSkipVerify bool `example:"false"`
}
// 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")
}
// 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()
}
type GitAuthentication struct {
Username string
Password string
Username string
Password string
Provider GitProvider `json:",omitempty"`
AuthorizationType GitCredentialAuthType `json:",omitempty"`
// Git credentials identifier when the value is not 0
// When the value is 0, Username and Password are set without using saved credential
// This is introduced since 2.15.0

View File

@@ -0,0 +1,106 @@
package workflows
import (
"context"
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/portainer/portainer/api/kubernetes/cli"
"github.com/portainer/portainer/api/set"
)
// FetchWorkflows returns all GitOps workflows visible to the given user.
func FetchWorkflows(
ctx context.Context,
dataStore dataservices.DataStore,
gitService portainer.GitService,
k8sFactory *cli.ClientFactory,
sc *security.RestrictedRequestContext,
endpointIDSet set.Set[portainer.EndpointID],
) ([]Workflow, error) {
var entries []portainer.Stack
var endpointMap map[portainer.EndpointID]portainer.Endpoint
err := dataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
stacks, err := tx.Stack().ReadAll(func(s portainer.Stack) bool {
return s.GitConfig != nil && (len(endpointIDSet) == 0 || endpointIDSet.Contains(s.EndpointID))
})
if err != nil {
return err
}
endpointMap, err = buildEndpointMap(tx, stacks)
if err != nil {
return err
}
stacks, err = filterDockerStacksByAccess(tx, stacks, sc)
if err != nil {
return err
}
for i := range stacks {
s := stacks[i]
if ep, ok := endpointMap[s.EndpointID]; ok && !EndpointMatchesStackType(ep, s.Type) {
continue
}
entries = append(entries, s)
}
return nil
})
if err != nil {
return nil, err
}
accessMap, err := buildEndpointAccessMap(k8sFactory, sc, endpointMap)
if err != nil {
return nil, err
}
entries, err = filterK8SStacks(entries, endpointMap, k8sFactory, accessMap)
if err != nil {
return nil, err
}
items := make([]Workflow, 0, len(entries))
for _, s := range entries {
gitEntries := []GitEntries{
{Name: s.GitConfig.ConfigFilePath, IsFile: true},
}
for _, additionalPath := range s.AdditionalFiles {
gitEntries = append(gitEntries, GitEntries{Name: additionalPath, IsFile: true})
}
source, artifact := computePhases(ctx, gitService, s.GitConfig, gitEntries)
items = append(items, MapStackToWorkflow(s, s.GitConfig, source, artifact))
}
return items, nil
}
func computePhases(ctx context.Context, gitSvc portainer.GitService, cfg *gittypes.RepoConfig, gitEntries []GitEntries) (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,
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 "", ""
}

View File

@@ -0,0 +1,178 @@
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"
)
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
}
}
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
}
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
}
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 {
return nil, err
}
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 {
return nil, err
}
access := accessMap[envID]
kcl.SetIsKubeAdmin(access.isKubeAdmin)
kcl.SetClientNonAdminNamespaces(access.nonAdminNamespaces)
apps, err := kcl.GetApplications("", "")
if err != nil {
return nil, err
}
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
}

View File

@@ -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)
}

View File

@@ -0,0 +1,100 @@
package workflows
import (
"context"
"fmt"
"path"
"slices"
)
// 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
}
// 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}
}

View File

@@ -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")
}

View File

@@ -0,0 +1,151 @@
package workflows
import (
portainer "github.com/portainer/portainer/api"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/set"
)
// 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, gitConfig *gittypes.RepoConfig, source, artifact WorkflowPhaseStatus) Workflow {
return Workflow{
ID: int(s.ID),
Name: s.Name,
Type: TypeStack,
Platform: platformFromStackType(s.Type),
Status: WorkflowStatusObject{
Source: source,
Artifact: artifact,
Target: deriveStackTargetState(s),
},
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(es portainer.EdgeStack, 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: int(es.ID),
Name: es.Name,
Type: TypeEdgeStack,
Platform: platform,
Status: WorkflowStatusObject{
Source: source,
Artifact: artifact,
Target: deriveEdgeStackTargetState(statuses),
},
GitConfig: gitConfig,
Target: Target{
EdgeGroupIDs: es.EdgeGroups,
GroupStatus: edgeStackTargetStatuses(es.EdgeGroups, statuses, groupEndpoints),
ResolvedEndpointIDs: resolveEdgeGroupEndpoints(es.EdgeGroups, groupEndpoints),
},
CreationDate: es.CreationDate,
LastSyncDate: edgeStackLastSyncDate(statuses),
}
}
func stackLastSyncDate(s portainer.Stack) int64 {
for i := len(s.DeploymentStatus) - 1; i >= 0; i-- {
if s.DeploymentStatus[i].Status == portainer.StackStatusActive {
return s.DeploymentStatus[i].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 i := len(epStatus.Status) - 1; i >= 0; i-- {
if isEdgeStackHealthyStatus(epStatus.Status[i].Type) {
return epStatus.Status[i].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
}

View File

@@ -0,0 +1,149 @@
package workflows
import (
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/stretchr/testify/assert"
)
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)])
})
}

View File

@@ -0,0 +1,112 @@
package workflows
import portainer "github.com/portainer/portainer/api"
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
}
}

View File

@@ -0,0 +1,151 @@
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 → 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)
})
}
}

View File

@@ -0,0 +1,100 @@
package workflows
import (
"fmt"
portainer "github.com/portainer/portainer/api"
gittypes "github.com/portainer/portainer/api/git/types"
)
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 int `json:"id"`
Name string `json:"name"`
Type Type `json:"type"`
Platform DeploymentPlatform `json:"platform"`
Status WorkflowStatusObject `json:"status"`
GitConfig *gittypes.RepoConfig `json:"gitConfig,omitempty"`
AutoUpdate *portainer.AutoUpdateSettings `json:"autoUpdate,omitempty"`
Target Target `json:"target"`
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"`
}

View File

@@ -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)
})
}

View File

@@ -1,60 +0,0 @@
package openamt
import (
"bytes"
"fmt"
"io"
"net/http"
portainer "github.com/portainer/portainer/api"
"github.com/rs/zerolog/log"
"github.com/segmentio/encoding/json"
)
type authenticationResponse struct {
Token string `json:"token"`
}
func (service *Service) Authorization(configuration portainer.OpenAMTConfiguration) (string, error) {
loginURL := fmt.Sprintf("https://%s/mps/login/api/v1/authorize", configuration.MPSServer)
payload := map[string]string{
"username": configuration.MPSUser,
"password": configuration.MPSPassword,
}
jsonValue, _ := json.Marshal(payload)
req, err := http.NewRequest(http.MethodPost, loginURL, bytes.NewBuffer(jsonValue))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
response, err := service.httpsClient.Do(req)
if err != nil {
return "", err
}
defer func() {
if err := response.Body.Close(); err != nil {
log.Warn().Err(err).Msg("failed to close response body")
}
}()
responseBody, readErr := io.ReadAll(response.Body)
if readErr != nil {
return "", readErr
}
errorResponse := parseError(responseBody)
if errorResponse != nil {
return "", errorResponse
}
var token authenticationResponse
if err := json.Unmarshal(responseBody, &token); err != nil {
return "", err
}
return token.Token, nil
}

View File

@@ -1,151 +0,0 @@
package openamt
import (
"encoding/base64"
"encoding/pem"
"fmt"
"io"
"net"
"net/http"
"strings"
portainer "github.com/portainer/portainer/api"
"github.com/rs/zerolog/log"
"github.com/segmentio/encoding/json"
)
type CIRAConfig struct {
ConfigName string `json:"configName"`
MPSServerAddress string `json:"mpsServerAddress"`
ServerAddressFormat int `json:"serverAddressFormat"`
CommonName string `json:"commonName"`
MPSPort int `json:"mpsPort"`
Username string `json:"username"`
MPSRootCertificate string `json:"mpsRootCertificate"`
RegeneratePassword bool `json:"regeneratePassword"`
AuthMethod int `json:"authMethod"`
}
func (service *Service) createOrUpdateCIRAConfig(configuration portainer.OpenAMTConfiguration, configName string) (*CIRAConfig, error) {
ciraConfig, err := service.getCIRAConfig(configuration, configName)
if err != nil {
return nil, err
}
method := http.MethodPost
if ciraConfig != nil {
method = http.MethodPatch
}
ciraConfig, err = service.saveCIRAConfig(method, configuration, configName)
if err != nil {
return nil, err
}
return ciraConfig, nil
}
func (service *Service) getCIRAConfig(configuration portainer.OpenAMTConfiguration, configName string) (*CIRAConfig, error) {
url := fmt.Sprintf("https://%s/rps/api/v1/admin/ciraconfigs/%s", configuration.MPSServer, configName)
responseBody, err := service.executeGetRequest(url, configuration.MPSToken)
if err != nil {
return nil, err
}
if responseBody == nil {
return nil, nil
}
var result CIRAConfig
err = json.Unmarshal(responseBody, &result)
if err != nil {
return nil, err
}
return &result, nil
}
func (service *Service) saveCIRAConfig(method string, configuration portainer.OpenAMTConfiguration, configName string) (*CIRAConfig, error) {
url := fmt.Sprintf("https://%s/rps/api/v1/admin/ciraconfigs", configuration.MPSServer)
certificate, err := service.getCIRACertificate(configuration)
if err != nil {
return nil, err
}
addressFormat, err := addressFormat(configuration.MPSServer)
if err != nil {
return nil, err
}
config := CIRAConfig{
ConfigName: configName,
MPSServerAddress: configuration.MPSServer,
CommonName: configuration.MPSServer,
ServerAddressFormat: addressFormat,
MPSPort: 4433,
Username: "admin",
MPSRootCertificate: certificate,
RegeneratePassword: false,
AuthMethod: 2,
}
payload, _ := json.Marshal(config)
responseBody, err := service.executeSaveRequest(method, url, configuration.MPSToken, payload)
if err != nil {
return nil, err
}
var result CIRAConfig
err = json.Unmarshal(responseBody, &result)
if err != nil {
return nil, err
}
return &result, nil
}
func addressFormat(url string) (int, error) {
ip := net.ParseIP(url)
if ip == nil {
return 201, nil // FQDN
}
if strings.Contains(url, ".") {
return 3, nil // IPV4
}
if strings.Contains(url, ":") {
return 4, nil // IPV6
}
return 0, fmt.Errorf("could not determine server address format for %s", url)
}
func (service *Service) getCIRACertificate(configuration portainer.OpenAMTConfiguration) (string, error) {
loginURL := fmt.Sprintf("https://%s/mps/api/v1/ciracert", configuration.MPSServer)
req, err := http.NewRequest(http.MethodGet, loginURL, nil)
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bearer "+configuration.MPSToken)
response, err := service.httpsClient.Do(req)
if err != nil {
return "", 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 {
return "", fmt.Errorf("unexpected status code %s", response.Status)
}
certificate, err := io.ReadAll(response.Body)
if err != nil {
return "", err
}
block, _ := pem.Decode(certificate)
return base64.StdEncoding.EncodeToString(block.Bytes), nil
}

View File

@@ -1,88 +0,0 @@
package openamt
import (
"fmt"
"strings"
portainer "github.com/portainer/portainer/api"
"github.com/segmentio/encoding/json"
)
type Device struct {
GUID string `json:"guid"`
HostName string `json:"hostname"`
ConnectionStatus bool `json:"connectionStatus"`
}
type DevicePowerState struct {
State portainer.PowerState `json:"powerstate"`
}
type DeviceEnabledFeatures struct {
Redirection bool `json:"redirection"`
KVM bool `json:"KVM"`
SOL bool `json:"SOL"`
IDER bool `json:"IDER"`
UserConsent string `json:"userConsent"`
}
func (service *Service) getDevice(configuration portainer.OpenAMTConfiguration, deviceGUID string) (*Device, error) {
url := fmt.Sprintf("https://%s/mps/api/v1/devices/%s", configuration.MPSServer, deviceGUID)
responseBody, err := service.executeGetRequest(url, configuration.MPSToken)
if err != nil {
if strings.EqualFold(err.Error(), "invalid value") {
return nil, nil
}
return nil, err
}
if responseBody == nil {
return nil, nil
}
var result Device
err = json.Unmarshal(responseBody, &result)
if err != nil {
return nil, err
}
return &result, nil
}
func (service *Service) getDevicePowerState(configuration portainer.OpenAMTConfiguration, deviceGUID string) (*DevicePowerState, error) {
url := fmt.Sprintf("https://%s/mps/api/v1/amt/power/state/%s", configuration.MPSServer, deviceGUID)
responseBody, err := service.executeGetRequest(url, configuration.MPSToken)
if err != nil {
return nil, err
}
if responseBody == nil {
return nil, nil
}
var result DevicePowerState
err = json.Unmarshal(responseBody, &result)
if err != nil {
return nil, err
}
return &result, nil
}
func (service *Service) getDeviceEnabledFeatures(configuration portainer.OpenAMTConfiguration, deviceGUID string) (*DeviceEnabledFeatures, error) {
url := fmt.Sprintf("https://%s/mps/api/v1/amt/features/%s", configuration.MPSServer, deviceGUID)
responseBody, err := service.executeGetRequest(url, configuration.MPSToken)
if err != nil {
return nil, err
}
if responseBody == nil {
return nil, nil
}
var result DeviceEnabledFeatures
err = json.Unmarshal(responseBody, &result)
if err != nil {
return nil, err
}
return &result, nil
}

View File

@@ -1,82 +0,0 @@
package openamt
import (
"fmt"
"net/http"
portainer "github.com/portainer/portainer/api"
"github.com/segmentio/encoding/json"
)
type (
Domain struct {
DomainName string `json:"profileName"`
DomainSuffix string `json:"domainSuffix"`
ProvisioningCert string `json:"provisioningCert"`
ProvisioningCertPassword string `json:"provisioningCertPassword"`
ProvisioningCertStorageFormat string `json:"provisioningCertStorageFormat"`
}
)
func (service *Service) createOrUpdateDomain(configuration portainer.OpenAMTConfiguration) (*Domain, error) {
domain, err := service.getDomain(configuration)
if err != nil {
return nil, err
}
method := http.MethodPost
if domain != nil {
method = http.MethodPatch
}
domain, err = service.saveDomain(method, configuration)
if err != nil {
return nil, err
}
return domain, nil
}
func (service *Service) getDomain(configuration portainer.OpenAMTConfiguration) (*Domain, error) {
url := fmt.Sprintf("https://%s/rps/api/v1/admin/domains/%s", configuration.MPSServer, configuration.DomainName)
responseBody, err := service.executeGetRequest(url, configuration.MPSToken)
if err != nil {
return nil, err
}
if responseBody == nil {
return nil, nil
}
var result Domain
err = json.Unmarshal(responseBody, &result)
if err != nil {
return nil, err
}
return &result, nil
}
func (service *Service) saveDomain(method string, configuration portainer.OpenAMTConfiguration) (*Domain, error) {
url := fmt.Sprintf("https://%s/rps/api/v1/admin/domains", configuration.MPSServer)
profile := Domain{
DomainName: configuration.DomainName,
DomainSuffix: configuration.DomainName,
ProvisioningCert: configuration.CertFileContent,
ProvisioningCertPassword: configuration.CertFilePassword,
ProvisioningCertStorageFormat: "string",
}
payload, _ := json.Marshal(profile)
responseBody, err := service.executeSaveRequest(method, url, configuration.MPSToken, payload)
if err != nil {
return nil, err
}
var result Domain
err = json.Unmarshal(responseBody, &result)
if err != nil {
return nil, err
}
return &result, nil
}

View File

@@ -1,97 +0,0 @@
package openamt
import (
"fmt"
"net/http"
portainer "github.com/portainer/portainer/api"
"github.com/segmentio/encoding/json"
)
type (
Profile struct {
ProfileName string `json:"profileName"`
Activation string `json:"activation"`
CIRAConfigName *string `json:"ciraConfigName"`
GenerateRandomAMTPassword bool `json:"generateRandomPassword"`
AMTPassword string `json:"amtPassword"`
GenerateRandomMEBxPassword bool `json:"generateRandomMEBxPassword"`
MEBXPassword string `json:"mebxPassword"`
Tags []string `json:"tags"`
DHCPEnabled bool `json:"dhcpEnabled"`
TenantId string `json:"tenantId"`
WIFIConfigs []ProfileWifiConfig `json:"wifiConfigs"`
}
ProfileWifiConfig struct {
Priority int `json:"priority"`
ProfileName string `json:"profileName"`
}
)
func (service *Service) createOrUpdateAMTProfile(configuration portainer.OpenAMTConfiguration, profileName string, ciraConfigName string) (*Profile, error) {
profile, err := service.getAMTProfile(configuration, profileName)
if err != nil {
return nil, err
}
method := http.MethodPost
if profile != nil {
method = http.MethodPatch
}
profile, err = service.saveAMTProfile(method, configuration, profileName, ciraConfigName)
if err != nil {
return nil, err
}
return profile, nil
}
func (service *Service) getAMTProfile(configuration portainer.OpenAMTConfiguration, profileName string) (*Profile, error) {
url := fmt.Sprintf("https://%s/rps/api/v1/admin/profiles/%s", configuration.MPSServer, profileName)
responseBody, err := service.executeGetRequest(url, configuration.MPSToken)
if err != nil {
return nil, err
}
if responseBody == nil {
return nil, nil
}
var result Profile
err = json.Unmarshal(responseBody, &result)
if err != nil {
return nil, err
}
return &result, nil
}
func (service *Service) saveAMTProfile(method string, configuration portainer.OpenAMTConfiguration, profileName string, ciraConfigName string) (*Profile, error) {
url := fmt.Sprintf("https://%s/rps/api/v1/admin/profiles", configuration.MPSServer)
profile := Profile{
ProfileName: profileName,
Activation: "acmactivate",
GenerateRandomAMTPassword: false,
GenerateRandomMEBxPassword: false,
AMTPassword: configuration.MPSPassword,
MEBXPassword: configuration.MPSPassword,
CIRAConfigName: &ciraConfigName,
Tags: []string{},
DHCPEnabled: true,
}
payload, _ := json.Marshal(profile)
responseBody, err := service.executeSaveRequest(method, url, configuration.MPSToken, payload)
if err != nil {
return nil, err
}
var result Profile
err = json.Unmarshal(responseBody, &result)
if err != nil {
return nil, err
}
return &result, nil
}

View File

@@ -1,56 +0,0 @@
package openamt
import (
"fmt"
"net/http"
"strings"
portainer "github.com/portainer/portainer/api"
"github.com/segmentio/encoding/json"
)
type ActionResponse struct {
Body struct {
ReturnValue int `json:"ReturnValue"`
ReturnValueStr string `json:"ReturnValueStr"`
} `json:"Body"`
}
func (service *Service) executeDeviceAction(configuration portainer.OpenAMTConfiguration, deviceGUID string, action int) error {
url := fmt.Sprintf("https://%s/mps/api/v1/amt/power/action/%s", configuration.MPSServer, deviceGUID)
payload := map[string]int{
"action": action,
}
jsonValue, _ := json.Marshal(payload)
responseBody, err := service.executeSaveRequest(http.MethodPost, url, configuration.MPSToken, jsonValue)
if err != nil {
return err
}
var response ActionResponse
err = json.Unmarshal(responseBody, &response)
if err != nil {
return err
}
if response.Body.ReturnValue != 0 {
return fmt.Errorf("failed to execute action, error status %v: %s", response.Body.ReturnValue, response.Body.ReturnValueStr)
}
return nil
}
func parseAction(actionRaw string) (portainer.PowerState, error) {
if strings.EqualFold(actionRaw, "power on") {
return powerOnState, nil
} else if strings.EqualFold(actionRaw, "power off") {
return powerOffState, nil
} else if strings.EqualFold(actionRaw, "restart") {
return restartState, nil
}
return 0, fmt.Errorf("unsupported device action %s", actionRaw)
}

View File

@@ -1,27 +0,0 @@
package openamt
import (
"fmt"
"net/http"
portainer "github.com/portainer/portainer/api"
"github.com/segmentio/encoding/json"
)
func (service *Service) enableDeviceFeatures(configuration portainer.OpenAMTConfiguration, deviceGUID string, features portainer.OpenAMTDeviceEnabledFeatures) error {
url := fmt.Sprintf("https://%s/mps/api/v1/amt/features/%s", configuration.MPSServer, deviceGUID)
payload := map[string]any{
"enableSOL": features.SOL,
"enableIDER": features.IDER,
"enableKVM": features.KVM,
"redirection": features.Redirection,
"userConsent": features.UserConsent,
}
jsonValue, _ := json.Marshal(payload)
_, err := service.executeSaveRequest(http.MethodPost, url, configuration.MPSToken, jsonValue)
return err
}

View File

@@ -1,262 +0,0 @@
package openamt
import (
"bytes"
"errors"
"fmt"
"io"
"net/http"
"time"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/crypto"
"github.com/rs/zerolog/log"
"github.com/segmentio/encoding/json"
"golang.org/x/sync/errgroup"
)
const (
DefaultCIRAConfigName = "ciraConfigDefault"
DefaultProfileName = "profileAMTDefault"
httpClientTimeout = 5 * time.Minute
powerOnState portainer.PowerState = 2
powerOffState portainer.PowerState = 8
restartState portainer.PowerState = 5
)
// Service represents a service for managing an OpenAMT server.
type Service struct {
httpsClient *http.Client
}
// NewService initializes a new service.
func NewService(insecureSkipVerify bool) *Service {
return &Service{
httpsClient: &http.Client{
Timeout: httpClientTimeout,
Transport: &http.Transport{
TLSClientConfig: crypto.CreateTLSConfiguration(insecureSkipVerify),
},
},
}
}
type openAMTError struct {
ErrorMsg string `json:"message"`
Errors []struct {
ErrorMsg string `json:"msg"`
} `json:"errors"`
}
func parseError(responseBody []byte) error {
var errorResponse openAMTError
err := json.Unmarshal(responseBody, &errorResponse)
if err != nil {
return err
}
if len(errorResponse.Errors) > 0 {
return errors.New(errorResponse.Errors[0].ErrorMsg)
}
if errorResponse.ErrorMsg != "" {
return errors.New(errorResponse.ErrorMsg)
}
return nil
}
func (service *Service) Configure(configuration portainer.OpenAMTConfiguration) error {
token, err := service.Authorization(configuration)
if err != nil {
return err
}
configuration.MPSToken = token
ciraConfig, err := service.createOrUpdateCIRAConfig(configuration, DefaultCIRAConfigName)
if err != nil {
return err
}
_, err = service.createOrUpdateAMTProfile(configuration, DefaultProfileName, ciraConfig.ConfigName)
if err != nil {
return err
}
_, err = service.createOrUpdateDomain(configuration)
return err
}
func (service *Service) executeSaveRequest(method string, url string, token string, payload []byte) ([]byte, error) {
req, err := http.NewRequest(method, url, bytes.NewBuffer(payload))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
response, err := service.httpsClient.Do(req)
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")
}
}()
responseBody, readErr := io.ReadAll(response.Body)
if readErr != nil {
return nil, readErr
}
if response.StatusCode < 200 || response.StatusCode > 300 {
errorResponse := parseError(responseBody)
if errorResponse != nil {
return nil, errorResponse
}
return nil, fmt.Errorf("unexpected status code %s", response.Status)
}
return responseBody, nil
}
func (service *Service) executeGetRequest(url string, token string) ([]byte, error) {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
response, err := service.httpsClient.Do(req)
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")
}
}()
responseBody, readErr := io.ReadAll(response.Body)
if readErr != nil {
return nil, readErr
}
if response.StatusCode < 200 || response.StatusCode > 300 {
if response.StatusCode == http.StatusNotFound {
return nil, nil
}
errorResponse := parseError(responseBody)
if errorResponse != nil {
return nil, errorResponse
}
return nil, fmt.Errorf("unexpected status code %s", response.Status)
}
return responseBody, nil
}
func (service *Service) DeviceInformation(configuration portainer.OpenAMTConfiguration, deviceGUID string) (*portainer.OpenAMTDeviceInformation, error) {
token, err := service.Authorization(configuration)
if err != nil {
return nil, err
}
configuration.MPSToken = token
var g errgroup.Group
var resultDevice *Device
var resultPowerState *DevicePowerState
var resultEnabledFeatures *DeviceEnabledFeatures
g.Go(func() error {
device, err := service.getDevice(configuration, deviceGUID)
if err != nil {
return err
}
if device == nil {
return fmt.Errorf("device %s not found", deviceGUID)
}
resultDevice = device
return nil
})
g.Go(func() error {
powerState, err := service.getDevicePowerState(configuration, deviceGUID)
if err != nil {
return err
}
resultPowerState = powerState
return nil
})
g.Go(func() error {
enabledFeatures, err := service.getDeviceEnabledFeatures(configuration, deviceGUID)
if err != nil {
return err
}
resultEnabledFeatures = enabledFeatures
return nil
})
if err := g.Wait(); err != nil {
return nil, err
}
deviceInformation := &portainer.OpenAMTDeviceInformation{
GUID: resultDevice.GUID,
HostName: resultDevice.HostName,
ConnectionStatus: resultDevice.ConnectionStatus,
}
if resultPowerState != nil {
deviceInformation.PowerState = resultPowerState.State
}
if resultEnabledFeatures != nil {
deviceInformation.EnabledFeatures = &portainer.OpenAMTDeviceEnabledFeatures{
Redirection: resultEnabledFeatures.Redirection,
KVM: resultEnabledFeatures.KVM,
SOL: resultEnabledFeatures.SOL,
IDER: resultEnabledFeatures.IDER,
UserConsent: resultEnabledFeatures.UserConsent,
}
}
return deviceInformation, nil
}
func (service *Service) ExecuteDeviceAction(configuration portainer.OpenAMTConfiguration, deviceGUID string, action string) error {
parsedAction, err := parseAction(action)
if err != nil {
return err
}
token, err := service.Authorization(configuration)
if err != nil {
return err
}
configuration.MPSToken = token
return service.executeDeviceAction(configuration, deviceGUID, int(parsedAction))
}
func (service *Service) EnableDeviceFeatures(configuration portainer.OpenAMTConfiguration, deviceGUID string, features portainer.OpenAMTDeviceEnabledFeatures) (string, error) {
token, err := service.Authorization(configuration)
if err != nil {
return "", err
}
configuration.MPSToken = token
err = service.enableDeviceFeatures(configuration, deviceGUID, features)
if err != nil {
return "", err
}
return token, nil
}

View File

@@ -1,19 +0,0 @@
package openamt
import (
"net/http"
"testing"
"github.com/portainer/portainer/pkg/fips"
"github.com/stretchr/testify/require"
)
func TestNewService(t *testing.T) {
t.Parallel()
fips.InitFIPS(false)
service := NewService(true)
require.NotNil(t, service)
require.True(t, service.httpsClient.Transport.(*http.Transport).TLSClientConfig.InsecureSkipVerify) //nolint:forbidigo
}

View File

@@ -1,30 +1,13 @@
package csrf
import (
"crypto/rand"
"errors"
"fmt"
"net/http"
"net/url"
"os"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/pkg/featureflags"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
gcsrf "github.com/gorilla/csrf"
"github.com/rs/zerolog/log"
"github.com/urfave/negroni"
)
const csrfSkipHeader = "X-CSRF-Token-Skip"
// SkipCSRFToken signals that the X-CSRF-Token header should not be sent in the response.
// Deprecated: only meaningful when the "legacy-csrf" feature flag is enabled.
func SkipCSRFToken(w http.ResponseWriter) {
w.Header().Set(csrfSkipHeader, "1")
}
func WithProtect(handler http.Handler, trustedOrigins []string) (http.Handler, error) {
// DOCKER_EXTENSION=1 is set in build/docker-extension/docker-compose.yml
isDockerDesktopExtension := false
@@ -32,10 +15,6 @@ func WithProtect(handler http.Handler, trustedOrigins []string) (http.Handler, e
isDockerDesktopExtension = true
}
if featureflags.IsEnabled("legacy-csrf") {
return withLegacyProtect(handler, trustedOrigins, isDockerDesktopExtension)
}
cop := http.NewCrossOriginProtection()
for _, origin := range trustedOrigins {
if err := cop.AddTrustedOrigin(origin); err != nil {
@@ -58,14 +37,7 @@ func WithProtect(handler http.Handler, trustedOrigins []string) (http.Handler, e
protected := cop.Handler(handler)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
skip, err := security.ShouldSkipCSRFCheck(r, isDockerDesktopExtension)
if err != nil {
httperror.WriteError(w, http.StatusForbidden, err.Error(), err)
return
}
if skip {
if isDockerDesktopExtension {
handler.ServeHTTP(w, r)
return
@@ -74,99 +46,3 @@ func WithProtect(handler http.Handler, trustedOrigins []string) (http.Handler, e
protected.ServeHTTP(w, r)
}), nil
}
// Deprecated: use WithProtect without the "legacy-csrf" feature flag instead.
func withLegacyProtect(handler http.Handler, trustedOrigins []string, isDockerDesktopExtension bool) (http.Handler, error) {
handler = withLegacySendCSRFToken(handler)
token := make([]byte, 32)
if _, err := rand.Read(token); err != nil {
return nil, fmt.Errorf("failed to generate CSRF token: %w", err)
}
// gorilla/csrf compares referer.Host against trusted origin entries, so it
// needs bare host[:port] values rather than full scheme://host[:port] origins.
legacyOrigins := make([]string, len(trustedOrigins))
for i, origin := range trustedOrigins {
parsed, err := url.Parse(origin)
if err != nil {
return nil, fmt.Errorf("failed to parse trusted origin %q: %w", origin, err)
}
legacyOrigins[i] = parsed.Host
}
handler = gcsrf.Protect(
token,
gcsrf.Path("/"),
gcsrf.Secure(false),
gcsrf.TrustedOrigins(legacyOrigins),
gcsrf.ErrorHandler(withLegacyErrorHandler(trustedOrigins)),
)(handler)
return withLegacySkipCSRF(handler, isDockerDesktopExtension), nil
}
// Deprecated: use WithProtect without the "legacy-csrf" feature flag instead.
func withLegacySendCSRFToken(handler http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sw := negroni.NewResponseWriter(w)
sw.Before(func(sw negroni.ResponseWriter) {
if len(sw.Header().Get(csrfSkipHeader)) > 0 {
sw.Header().Del(csrfSkipHeader)
return
}
if statusCode := sw.Status(); statusCode >= 200 && statusCode < 300 {
sw.Header().Set("X-CSRF-Token", gcsrf.Token(r))
}
})
handler.ServeHTTP(sw, r)
})
}
// Deprecated: use WithProtect without the "legacy-csrf" feature flag instead.
func withLegacySkipCSRF(handler http.Handler, isDockerDesktopExtension bool) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
skip, err := security.ShouldSkipCSRFCheck(r, isDockerDesktopExtension)
if err != nil {
httperror.WriteError(w, http.StatusForbidden, err.Error(), err)
return
}
if skip {
r = gcsrf.UnsafeSkipCheck(r)
}
handler.ServeHTTP(w, r)
})
}
// Deprecated: use WithProtect without the "legacy-csrf" feature flag instead.
func withLegacyErrorHandler(trustedOrigins []string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
err := gcsrf.FailureReason(r)
if errors.Is(err, gcsrf.ErrBadOrigin) || errors.Is(err, gcsrf.ErrBadReferer) || errors.Is(err, gcsrf.ErrNoReferer) {
log.Error().Err(err).
Str("request_url", r.URL.String()).
Str("host", r.Host).
Str("x_forwarded_proto", r.Header.Get("X-Forwarded-Proto")).
Str("forwarded", r.Header.Get("Forwarded")).
Str("origin", r.Header.Get("Origin")).
Str("referer", r.Header.Get("Referer")).
Strs("trusted_origins", trustedOrigins).
Msg("Failed to validate Origin or Referer")
}
http.Error(
w,
http.StatusText(http.StatusForbidden)+" - "+err.Error(),
http.StatusForbidden,
)
})
}

View File

@@ -154,51 +154,6 @@ func TestWithProtect_allowsPostFromTrustedOrigin(t *testing.T) {
require.Equal(t, http.StatusOK, rr.Code)
}
func TestWithProtect_skipsCsrfForApiKey(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.Header.Set("X-API-KEY", "my-api-key")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
require.Equal(t, http.StatusOK, rr.Code)
}
func TestWithProtect_skipsCsrfForBearerToken(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.Header.Set("Authorization", "Bearer some-token")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
require.Equal(t, http.StatusOK, rr.Code)
}
func TestWithProtect_forbidsBothApiKeyAndBearerToken(t *testing.T) {
t.Parallel()
handler, err := WithProtect(okHandler, nil)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/", nil)
req.Header.Set("X-API-KEY", "my-api-key")
req.Header.Set("Authorization", "Bearer some-token")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
require.Equal(t, http.StatusForbidden, rr.Code)
}
func TestWithProtect_enforcesCsrfForCookieAuth(t *testing.T) {
t.Parallel()
@@ -213,88 +168,3 @@ func TestWithProtect_enforcesCsrfForCookieAuth(t *testing.T) {
handler.ServeHTTP(rr, req)
require.Equal(t, http.StatusForbidden, rr.Code)
}
func TestWithLegacyProtect_noError_noOrigins(t *testing.T) {
t.Parallel()
_, err := withLegacyProtect(okHandler, nil, false)
require.NoError(t, err)
}
func TestWithLegacyProtect_noError_schemeHostOrigin(t *testing.T) {
t.Parallel()
_, err := withLegacyProtect(okHandler, []string{"https://example.com"}, false)
require.NoError(t, err)
}
func TestWithLegacyProtect_noError_schemeHostPortOrigin(t *testing.T) {
t.Parallel()
_, err := withLegacyProtect(okHandler, []string{"https://example.com:3000"}, false)
require.NoError(t, err)
}
func TestWithLegacyProtect_noError_multipleOrigins(t *testing.T) {
t.Parallel()
_, err := withLegacyProtect(okHandler, []string{"https://example.com", "http://internal.example.com:8080"}, false)
require.NoError(t, err)
}
func TestWithLegacyProtect_safeMethodsAlwaysAllowed(t *testing.T) {
t.Parallel()
handler, err := withLegacyProtect(okHandler, nil, false)
require.NoError(t, err)
for _, method := range []string{http.MethodGet, http.MethodHead, http.MethodOptions} {
req := httptest.NewRequest(method, "/", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
require.Equal(t, http.StatusOK, rr.Code, "method %s should be allowed", method)
}
}
func TestWithLegacyProtect_blocksPostWithoutToken(t *testing.T) {
t.Parallel()
handler, err := withLegacyProtect(okHandler, nil, false)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/", nil)
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 TestWithLegacyProtect_skipsCsrfForApiKey(t *testing.T) {
t.Parallel()
handler, err := withLegacyProtect(okHandler, nil, false)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/", nil)
req.Header.Set("X-API-KEY", "my-api-key")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
require.Equal(t, http.StatusOK, rr.Code)
}
func TestWithLegacyProtect_skipsCsrfForBearerToken(t *testing.T) {
t.Parallel()
handler, err := withLegacyProtect(okHandler, nil, false)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/", nil)
req.Header.Set("Authorization", "Bearer some-token")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
require.Equal(t, http.StatusOK, rr.Code)
}

View File

@@ -6,7 +6,9 @@ import (
"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"
@@ -60,8 +62,12 @@ func (handler *Handler) authenticate(rw http.ResponseWriter, r *http.Request) *h
return httperror.BadRequest("Invalid request payload", err)
}
settings, err := handler.DataStore.Settings().Settings()
if err != nil {
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)
}
@@ -89,7 +95,7 @@ func (handler *Handler) authenticate(rw http.ResponseWriter, r *http.Request) *h
}
if user != nil && isUserInitialAdmin(user) || settings.AuthenticationMethod == portainer.AuthenticationInternal {
return handler.authenticateInternal(rw, user, payload.Password)
return handler.authenticateInternal(rw, r, user, payload.Password, settings.ForceSecureCookies)
}
if settings.AuthenticationMethod == portainer.AuthenticationOAuth {
@@ -97,7 +103,7 @@ func (handler *Handler) authenticate(rw http.ResponseWriter, r *http.Request) *h
}
if settings.AuthenticationMethod == portainer.AuthenticationLDAP {
return handler.authenticateLDAP(rw, user, payload.Username, payload.Password, &settings.LDAPSettings)
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)
@@ -107,17 +113,17 @@ func isUserInitialAdmin(user *portainer.User) bool {
return int(user.ID) == 1
}
func (handler *Handler) authenticateInternal(w http.ResponseWriter, user *portainer.User, password string) *httperror.HandlerError {
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, user, forceChangePassword)
return handler.writeToken(w, r, user, forceChangePassword, forceSecureCookies)
}
func (handler *Handler) authenticateLDAP(w http.ResponseWriter, user *portainer.User, username, password string, ldapSettings *portainer.LDAPSettings) *httperror.HandlerError {
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)
@@ -142,26 +148,30 @@ func (handler *Handler) authenticateLDAP(w http.ResponseWriter, user *portainer.
log.Warn().Err(err).Msg("unable to automatically sync user teams with ldap")
}
return handler.writeToken(w, user, false)
return handler.writeToken(w, r, user, false, forceSecureCookies)
}
func (handler *Handler) writeToken(w http.ResponseWriter, user *portainer.User, forceChangePassword bool) *httperror.HandlerError {
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, tokenData)
return handler.persistAndWriteToken(w, r, tokenData, forceSecureCookies)
}
func (handler *Handler) persistAndWriteToken(w http.ResponseWriter, tokenData *portainer.TokenData) *httperror.HandlerError {
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)
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 {

View File

@@ -6,6 +6,7 @@ import (
"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"
@@ -62,8 +63,12 @@ func (handler *Handler) validateOAuth(w http.ResponseWriter, r *http.Request) *h
return httperror.BadRequest("Invalid request payload", err)
}
settings, err := handler.DataStore.Settings().Settings()
if err != nil {
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)
}
@@ -113,5 +118,5 @@ func (handler *Handler) validateOAuth(w http.ResponseWriter, r *http.Request) *h
}
return handler.writeToken(w, user, false)
return handler.writeToken(w, r, user, false, settings.ForceSecureCookies)
}

View File

@@ -4,6 +4,8 @@ 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"
@@ -29,7 +31,16 @@ func (handler *Handler) logout(w http.ResponseWriter, r *http.Request) *httperro
handler.bouncer.RevokeJWT(tokenData.Token)
}
security.RemoveAuthCookie(w)
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)
}

View File

@@ -6,6 +6,8 @@ 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/proxy/factory/kubernetes"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/testhelpers"
@@ -34,6 +36,7 @@ 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
@@ -48,6 +51,7 @@ func TestLogout(t *testing.T) {
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)
@@ -55,3 +59,51 @@ func TestLogoutNoPanic(t *testing.T) {
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)
})
}
}

View File

@@ -34,5 +34,6 @@ func NewHandler(bouncer security.BouncerService) *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
}

View File

@@ -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
}

View File

@@ -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{}

View File

@@ -90,6 +90,10 @@ func (payload *endpointCreatePayload) Validate(r *http.Request) error {
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

View File

@@ -116,6 +116,61 @@ func TestCreateEndpointFailure(t *testing.T) {
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)

View File

@@ -100,7 +100,7 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht
}
filteredEndpoints = security.FilterEndpoints(filteredEndpoints, endpointGroups, securityContext)
sortEnvironmentsByField(filteredEndpoints, endpointGroups, getSortKey(sortField), sortOrder == "desc")
sortEnvironmentsByField(filteredEndpoints, endpointGroups, getSortKey(sortField), sortOrder == "desc", settings)
filteredEndpointCount := len(filteredEndpoints)

View File

@@ -32,13 +32,16 @@ func Test_EndpointList_AgentVersion(t *testing.T) {
Type: portainer.AgentOnDockerEnvironment,
Agent: struct {
Version string `example:"1.0.0"`
}{
Version: "1.0.0",
},
}{Version: "1.0.0"},
}
version2Endpoint := portainer.Endpoint{
ID: 2,
GroupID: 1,
Type: portainer.AgentOnDockerEnvironment,
Agent: struct {
Version string `example:"1.0.0"`
}{Version: "2.0.0"},
}
version2Endpoint := portainer.Endpoint{ID: 2, GroupID: 1, Type: portainer.AgentOnDockerEnvironment, Agent: struct {
Version string `example:"1.0.0"`
}{Version: "2.0.0"}}
noVersionEndpoint := portainer.Endpoint{ID: 3, Type: portainer.AgentOnDockerEnvironment, GroupID: 1}
notAgentEnvironments := portainer.Endpoint{ID: 4, Type: portainer.DockerEnvironment, GroupID: 1}

View File

@@ -0,0 +1,230 @@
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 counts EnvironmentSummaryCountsResponse
err := handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) 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)
}
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)
// Filter out untrusted edge endpoints to match the environment list behavior
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++
}
// Both counts.* and healthCounts.* are non-exclusive: an outdated env
// contributes to its connection bucket (Up / Down) and to Outdated.
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 nil
})
return response.TxResponse(w, counts, err)
}
// 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
}

View File

@@ -0,0 +1,380 @@
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 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(" "))
}

View File

@@ -94,6 +94,10 @@ func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) *
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 {
@@ -247,7 +251,8 @@ func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) *
}
}
if !endpointutils.IsLocalEndpoint(endpoint) && endpointutils.IsKubernetesEndpoint(endpoint) {
isStandardKubeAgent := !endpointutils.IsLocalEndpoint(endpoint) && endpointutils.IsKubernetesEndpoint(endpoint) && !endpointutils.IsEdgeEndpoint(endpoint)
if isStandardKubeAgent {
endpoint.TLSConfig.TLS = true
endpoint.TLSConfig.TLSSkipVerify = true
}

View File

@@ -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)
})
}
}

View File

@@ -15,6 +15,8 @@ import (
"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"
@@ -23,6 +25,7 @@ import (
type EnvironmentsQuery struct {
search string
types []portainer.EndpointType
platformTypes []portainer.PlatformType
tagIds []portainer.TagID
endpointIds []portainer.EndpointID
tagsPartialMatch bool
@@ -34,6 +37,7 @@ type EnvironmentsQuery struct {
excludeSnapshots bool
name string
agentVersions []string
outdated bool
edgeCheckInPassedSeconds int
edgeStackId portainer.EdgeStackID
edgeStackStatus *portainer.EdgeStackStatusType
@@ -64,6 +68,11 @@ func parseQuery(r *http.Request) (EnvironmentsQuery, error) {
return EnvironmentsQuery{}, err
}
platformTypes, err := getNumberArrayQueryParameter[portainer.PlatformType](r, "platformTypes")
if err != nil {
return EnvironmentsQuery{}, err
}
tagIDs, err := getNumberArrayQueryParameter[portainer.TagID](r, "tagIds")
if err != nil {
return EnvironmentsQuery{}, err
@@ -98,6 +107,8 @@ func parseQuery(r *http.Request) (EnvironmentsQuery, error) {
agentVersions := getArrayQueryParameter(r, "agentVersions")
outdated, _ := request.RetrieveBooleanQueryParameter(r, "outdated", true)
name, _ := request.RetrieveQueryParameter(r, "name", true)
var edgeAsync *bool
@@ -122,6 +133,7 @@ func parseQuery(r *http.Request) (EnvironmentsQuery, error) {
return EnvironmentsQuery{
search: search,
types: endpointTypes,
platformTypes: platformTypes,
tagIds: tagIDs,
endpointIds: endpointIDs,
excludeIds: excludeIDs,
@@ -134,6 +146,7 @@ func parseQuery(r *http.Request) (EnvironmentsQuery, error) {
excludeSnapshots: excludeSnapshots,
name: name,
agentVersions: agentVersions,
outdated: outdated,
edgeCheckInPassedSeconds: edgeCheckInPassedSeconds,
edgeStackId: portainer.EdgeStackID(edgeStackId),
edgeStackStatus: edgeStackStatus,
@@ -249,6 +262,10 @@ func (handler *Handler) filterEndpointsByQuery(
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)
}
@@ -258,6 +275,13 @@ func (handler *Handler) filterEndpointsByQuery(
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 {
@@ -553,20 +577,19 @@ func edgeGroupMatchSearchCriteria(
}
func filterEndpointsByTypes(endpoints []portainer.Endpoint, endpointTypes []portainer.EndpointType) []portainer.Endpoint {
typeSet := map[portainer.EndpointType]bool{}
for _, endpointType := range endpointTypes {
typeSet[endpointType] = true
}
typeSet := set.ToSet(endpointTypes)
n := 0
for _, endpoint := range endpoints {
if typeSet[endpoint.Type] {
endpoints[n] = endpoint
n++
}
}
return slicesx.Filter(endpoints, func(e portainer.Endpoint) bool {
return typeSet[e.Type]
})
}
return endpoints[:n]
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 {

View File

@@ -549,3 +549,180 @@ func TestGetShortestAsyncInterval(t *testing.T) {
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)
}

View File

@@ -61,6 +61,8 @@ func NewHandler(bouncer security.BouncerService) *Handler {
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}",

View File

@@ -5,6 +5,7 @@ import (
"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
@@ -19,7 +20,21 @@ func stringComp(a, b string) int {
}
}
func sortEnvironmentsByField(environments []portainer.Endpoint, environmentGroups []portainer.EndpointGroup, sortField sortKey, isSortDesc bool) {
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
}
@@ -61,6 +76,18 @@ func sortEnvironmentsByField(environments []portainer.Endpoint, environmentGroup
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 {
@@ -82,11 +109,14 @@ const (
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}, fieldAsSortKey) {
if slices.Contains([]sortKey{sortKeyName, sortKeyGroup, sortKeyStatus, sortKeyLastCheckInDate, sortKeyEdgeID, sortKeyPlatformType, sortKeyHealth, sortKeyId}, fieldAsSortKey) {
return fieldAsSortKey
}

View File

@@ -2,6 +2,7 @@ package endpoints
import (
"testing"
"time"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/slicesx"
@@ -149,14 +150,24 @@ func TestSortEndpointsByField(t *testing.T) {
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) // reset to default sort order
sortEnvironmentsByField(environments, environmentGroups, "Name", false, nil) // reset to default sort order
sortEnvironmentsByField(environments, environmentGroups, tt.sortField, tt.isSortDesc)
sortEnvironmentsByField(environments, environmentGroups, tt.sortField, tt.isSortDesc, nil)
is.Equal(tt.expected, getEndpointIDs(environments))
})
@@ -168,3 +179,65 @@ func getEndpointIDs(environments []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"))
}

View File

@@ -6,9 +6,13 @@ 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/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
@@ -19,7 +23,7 @@ type Handler struct {
fileService portainer.FileService
}
func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStore, gitService portainer.GitService, fileService portainer.FileService) *Handler {
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,
@@ -32,5 +36,11 @@ func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStor
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
}

View File

@@ -0,0 +1,112 @@
package sources
import (
"net/http"
portainer "github.com/portainer/portainer/api"
gittypes "github.com/portainer/portainer/api/git/types"
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/request"
"github.com/portainer/portainer/pkg/libhttp/response"
)
type connectionInfo struct {
ReferenceName string `json:"referenceName"`
ConfigFilePath string `json:"configFilePath"`
TLSSkipVerify bool `json:"tlsSkipVerify"`
Authentication bool `json:"authentication,omitempty"`
}
type autoUpdateInfo struct {
Mechanism string `json:"mechanism,omitempty"`
FetchInterval string `json:"fetchInterval,omitempty"`
}
// SourceDetail extends Source with connection settings and linked workflows.
type SourceDetail struct {
Source
Connection *connectionInfo `json:"connection,omitempty"`
AutoUpdate *autoUpdateInfo `json:"autoUpdate,omitempty"`
Workflows []ce.Workflow `json:"workflows"`
}
// @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**: admin
// @tags gitops
// @security ApiKeyAuth
// @security jwt
// @produce json
// @param id path string true "Source ID"
// @success 200 {object} SourceDetail
// @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 {
id, err := request.RetrieveRouteVariableValue(r, "id")
if err != nil {
return httperror.BadRequest("Invalid source ID", err)
}
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve info from request context", err)
}
workflows, err := ce.FetchWorkflows(r.Context(), h.dataStore, h.gitService, h.k8sFactory, securityContext, nil)
if err != nil {
return httperror.InternalServerError("Unable to retrieve workflows", err)
}
byID := workflowsBySourceID(workflows)
wfs, ok := byID[id]
if !ok || len(wfs) == 0 || wfs[0].GitConfig == nil {
return httperror.NotFound("Source not found", nil)
}
url := wfs[0].GitConfig.URL
detail := SourceDetail{
Source: buildSource(id, url, wfs),
Connection: buildConnectionInfo(wfs[0].GitConfig),
AutoUpdate: buildAutoUpdateInfo(wfs[0].AutoUpdate),
Workflows: redactWorkflowCredentials(wfs),
}
return response.JSON(w, detail)
}
func buildConnectionInfo(cfg *gittypes.RepoConfig) *connectionInfo {
if cfg == nil {
return nil
}
return &connectionInfo{
ReferenceName: cfg.ReferenceName,
ConfigFilePath: cfg.ConfigFilePath,
TLSSkipVerify: cfg.TLSSkipVerify,
Authentication: cfg.Authentication != nil,
}
}
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
}
}

View File

@@ -0,0 +1,115 @@
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/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, "nonexistent-id"))
assert.Equal(t, http.StatusNotFound, rr.Code)
}
func TestGetSource_ReturnsDetail(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
cfg := &gittypes.RepoConfig{
URL: "https://github.com/org/repo",
ReferenceName: "refs/heads/main",
ConfigFilePath: "docker-compose.yml",
TLSSkipVerify: true,
}
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
require.NoError(t, tx.Stack().Create(&portainer.Stack{ID: 1, Name: "my-stack", GitConfig: cfg}))
return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})
}))
id := sourceID(gitSourceKey(cfg))
h := newTestHandler(t, store)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, buildGetReq(t, 1, id))
detail := decodeSourceDetail(t, rr)
assert.Equal(t, id, detail.ID)
assert.Equal(t, "repo", detail.Name)
assert.Equal(t, 1, detail.UsedBy)
require.NotNil(t, detail.Connection)
assert.Equal(t, "refs/heads/main", detail.Connection.ReferenceName)
assert.Equal(t, "docker-compose.yml", detail.Connection.ConfigFilePath)
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.RepoConfig{
URL: "https://github.com/org/secure",
Authentication: &gittypes.GitAuthentication{Username: "user", Password: "s3cr3t"},
}
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
require.NoError(t, tx.Stack().Create(&portainer.Stack{ID: 1, Name: "secure-stack", GitConfig: cfg}))
return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})
}))
id := sourceID(gitSourceKey(cfg))
h := newTestHandler(t, store)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, buildGetReq(t, 1, id))
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")
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
require.NoError(t, tx.Stack().Create(&portainer.Stack{
ID: 1,
Name: "polled-stack",
GitConfig: cfg,
AutoUpdate: &portainer.AutoUpdateSettings{Interval: "5m"},
}))
return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})
}))
id := sourceID(gitSourceKey(cfg))
h := newTestHandler(t, store)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, buildGetReq(t, 1, id))
detail := decodeSourceDetail(t, rr)
require.NotNil(t, detail.AutoUpdate)
assert.Equal(t, "Interval", detail.AutoUpdate.Mechanism)
assert.Equal(t, "5m", detail.AutoUpdate.FetchInterval)
}

View File

@@ -0,0 +1,46 @@
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,
}
adminRouter := h.PathPrefix("/gitops/sources").Subrouter()
adminRouter.Use(bouncer.AdminAccess)
adminRouter.Handle("", httperror.LoggerHandler(h.list)).Methods(http.MethodGet)
adminRouter.Handle("/summary", httperror.LoggerHandler(h.summary)).Methods(http.MethodGet)
adminRouter.Handle("/{id}", httperror.LoggerHandler(h.getSource)).Methods(http.MethodGet)
return h
}

View File

@@ -0,0 +1,65 @@
package sources
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/portainer/portainer/api/internal/testhelpers"
"github.com/segmentio/encoding/json"
"github.com/stretchr/testify/require"
)
func newTestHandler(t *testing.T, store dataservices.DataStore) *Handler {
t.Helper()
return NewHandler(testhelpers.NewTestRequestBouncer(), store, nil, nil)
}
func buildListReq(t *testing.T, userID portainer.UserID, query string) *http.Request {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/gitops/sources?"+query, nil)
req = req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ID: userID}))
req = req.WithContext(security.StoreRestrictedRequestContext(req, &security.RestrictedRequestContext{
UserID: userID, IsAdmin: true,
}))
return req
}
func buildGetReq(t *testing.T, userID portainer.UserID, id string) *http.Request {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/gitops/sources/"+id, nil)
req = req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ID: userID}))
req = req.WithContext(security.StoreRestrictedRequestContext(req, &security.RestrictedRequestContext{
UserID: userID, IsAdmin: true,
}))
return req
}
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.RepoConfig {
return &gittypes.RepoConfig{
URL: url,
ConfigFilePath: "docker-compose.yml",
ReferenceName: "refs/heads/main",
}
}

View File

@@ -0,0 +1,122 @@
package sources
import (
"context"
"net/http"
"slices"
"strconv"
"strings"
gocache "github.com/patrickmn/go-cache"
ceWorkflows "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"
)
// @id GitOpsSourcesList
// @summary List all GitOps sources
// @description Returns a deduplicated list of git repositories used across all GitOps workflows.
// @description **Access policy**: admin
// @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 := ceWorkflows.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(a.Type, 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) {
workflows, err := ceWorkflows.FetchWorkflows(ctx, h.dataStore, h.gitService, h.k8sFactory, sc, nil)
if err != nil {
return nil, err
}
byID := workflowsBySourceID(workflows)
sources := make([]Source, 0, len(byID))
for id, wfs := range byID {
sources = append(sources, buildSource(id, wfs[0].GitConfig.URL, wfs))
}
return sources, nil
}

View File

@@ -0,0 +1,111 @@
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 {
require.NoError(t, tx.Stack().Create(&portainer.Stack{
ID: 1, Name: "stack-a", GitConfig: gitCfg("https://github.com/org/repo"),
}))
require.NoError(t, tx.Stack().Create(&portainer.Stack{
ID: 2, Name: "stack-b", GitConfig: gitCfg("https://github.com/org/repo"),
}))
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"}
require.NoError(t, tx.Stack().Create(&portainer.Stack{ID: 1, Name: "stack-a", GitConfig: cfg1}))
require.NoError(t, tx.Stack().Create(&portainer.Stack{ID: 2, Name: "stack-b", GitConfig: cfg2}))
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 {
require.NoError(t, tx.Stack().Create(&portainer.Stack{
ID: 1, GitConfig: gitCfg("https://github.com/org/app"),
}))
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 {
require.NoError(t, tx.Stack().Create(&portainer.Stack{
ID: 1, GitConfig: gitCfg("https://github.com/org/app"),
}))
require.NoError(t, tx.Stack().Create(&portainer.Stack{
ID: 2, GitConfig: gitCfg("https://github.com/org/infra"),
}))
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)
}

View File

@@ -0,0 +1,58 @@
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**: admin
// @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)
}
if !securityContext.IsAdmin {
return httperror.Forbidden("Access denied", nil)
}
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)
}

View File

@@ -0,0 +1,86 @@
package sources
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"path"
"strings"
gittypes "github.com/portainer/portainer/api/git/types"
"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 string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
URL string `json:"url"`
Status workflows.Status `json:"status"`
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) (string, error) {
switch SourceType(s) {
case SourceTypeGit, SourceTypeHelm, SourceTypeOCI:
return s, nil
default:
return "", fmt.Errorf("invalid source type %q: must be git, helm, or oci", s)
}
}
type sourceGroupKey struct {
URL string
Username string
Password string
}
func gitSourceKey(cfg *gittypes.RepoConfig) sourceGroupKey {
key := sourceGroupKey{URL: cfg.URL}
if cfg.Authentication != nil {
key.Username = cfg.Authentication.Username
key.Password = cfg.Authentication.Password
}
return key
}
func sourceID(key sourceGroupKey) string {
h := sha256.Sum256([]byte(key.URL + "\x00" + key.Username + "\x00" + key.Password))
return hex.EncodeToString(h[:8])
}
// repoName extracts the repository name from a URL.
// e.g. "https://github.com/org/app-config.git" → "app-config"
func repoName(rawURL string) string {
base := path.Base(rawURL)
return strings.TrimSuffix(base, ".git")
}
func worstCaseStatus(statuses []workflows.Status) workflows.Status {
priority := map[workflows.Status]int{
workflows.StatusError: 4,
workflows.StatusSyncing: 3,
workflows.StatusPaused: 2,
workflows.StatusHealthy: 1,
workflows.StatusUnknown: 0,
}
worst := workflows.StatusUnknown
for _, s := range statuses {
if priority[s] > priority[worst] {
worst = s
}
}
return worst
}

View File

@@ -0,0 +1,67 @@
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"
"github.com/portainer/portainer/api/set"
)
func workflowsBySourceID(workflows []ce.Workflow) map[string][]ce.Workflow {
byID := make(map[string][]ce.Workflow)
for _, wf := range workflows {
if wf.GitConfig != nil {
id := sourceID(gitSourceKey(wf.GitConfig))
byID[id] = append(byID[id], wf)
}
}
return byID
}
func buildSource(id, url string, wfs []ce.Workflow) Source {
statuses := make([]ce.Status, 0, len(wfs))
var sourceError string
var lastSync int64
endpointIDs := make(set.Set[portainer.EndpointID])
for _, wf := range wfs {
statuses = append(statuses, wf.Status.Source.Status)
if sourceError == "" && wf.Status.Source.Status == ce.StatusError {
sourceError = wf.Status.Source.Error
}
if wf.LastSyncDate > lastSync {
lastSync = wf.LastSyncDate
}
if wf.Target.EndpointID != 0 {
endpointIDs.Add(wf.Target.EndpointID)
}
for _, id := range wf.Target.ResolvedEndpointIDs {
endpointIDs.Add(id)
}
}
return Source{
ID: id,
Name: repoName(url),
Type: "git",
URL: gittypes.SanitizeURL(url),
Status: worstCaseStatus(statuses),
Error: sourceError,
UsedBy: len(wfs),
Environments: len(endpointIDs),
LastSync: 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
}

View File

@@ -0,0 +1,170 @@
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 TestWorkflowsBySourceID(t *testing.T) {
t.Parallel()
t.Run("groups workflows with same URL and credentials", func(t *testing.T) {
t.Parallel()
cfg := gitCfg("https://github.com/org/repo")
wfs := []ce.Workflow{{GitConfig: cfg}, {GitConfig: cfg}}
byID := workflowsBySourceID(wfs)
assert.Len(t, byID, 1)
for _, group := range byID {
assert.Len(t, group, 2)
}
})
t.Run("separates workflows with same URL but different credentials", func(t *testing.T) {
t.Parallel()
wfs := []ce.Workflow{
{GitConfig: &gittypes.RepoConfig{URL: "https://github.com/org/repo",
Authentication: &gittypes.GitAuthentication{Username: "alice", Password: "pass1"}}},
{GitConfig: &gittypes.RepoConfig{URL: "https://github.com/org/repo",
Authentication: &gittypes.GitAuthentication{Username: "bob", Password: "pass2"}}},
}
byID := workflowsBySourceID(wfs)
assert.Len(t, byID, 2)
})
t.Run("skips workflows with nil GitConfig", func(t *testing.T) {
t.Parallel()
wfs := []ce.Workflow{{}, {GitConfig: gitCfg("https://github.com/org/repo")}}
byID := workflowsBySourceID(wfs)
assert.Len(t, byID, 1)
})
}
func TestBuildSource(t *testing.T) {
t.Parallel()
t.Run("status is the worst across all workflows", func(t *testing.T) {
t.Parallel()
wfs := []ce.Workflow{
{Status: ce.WorkflowStatusObject{Source: ce.WorkflowPhaseStatus{Status: ce.StatusHealthy}}},
{Status: ce.WorkflowStatusObject{Source: ce.WorkflowPhaseStatus{Status: ce.StatusError, Error: "boom"}}},
}
s := buildSource("id", "https://github.com/org/repo.git", wfs)
assert.Equal(t, ce.StatusError, s.Status)
assert.Equal(t, "boom", s.Error)
})
t.Run("usedBy equals the number of workflows", func(t *testing.T) {
t.Parallel()
wfs := make([]ce.Workflow, 3)
s := buildSource("id", "https://github.com/org/repo", wfs)
assert.Equal(t, 3, s.UsedBy)
})
t.Run("environments deduplicates endpoint IDs", func(t *testing.T) {
t.Parallel()
wfs := []ce.Workflow{
{Target: ce.Target{EndpointID: portainer.EndpointID(1)}},
{Target: ce.Target{EndpointID: portainer.EndpointID(1)}}, // duplicate
{Target: ce.Target{EndpointID: portainer.EndpointID(2)}},
}
s := buildSource("id", "https://github.com/org/repo", wfs)
assert.Equal(t, 2, s.Environments)
})
t.Run("name is extracted from URL", func(t *testing.T) {
t.Parallel()
s := buildSource("id", "https://github.com/org/my-app.git", []ce.Workflow{{}})
assert.Equal(t, "my-app", s.Name)
})
t.Run("lastSync is the maximum across all workflows", func(t *testing.T) {
t.Parallel()
wfs := []ce.Workflow{
{LastSyncDate: 100},
{LastSyncDate: 300},
{LastSyncDate: 200},
}
s := buildSource("id", "https://github.com/org/repo", wfs)
assert.Equal(t, int64(300), s.LastSync)
})
}
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.Nil(t, buildConnectionInfo(nil))
cfg := &gittypes.RepoConfig{
ReferenceName: "refs/heads/main",
ConfigFilePath: "docker-compose.yml",
TLSSkipVerify: true,
Authentication: &gittypes.GitAuthentication{Username: "user"},
}
got := buildConnectionInfo(cfg)
require.NotNil(t, got)
assert.Equal(t, "refs/heads/main", got.ReferenceName)
assert.Equal(t, "docker-compose.yml", got.ConfigFilePath)
assert.True(t, got.TLSSkipVerify)
assert.True(t, got.Authentication)
got = buildConnectionInfo(&gittypes.RepoConfig{})
assert.False(t, got.Authentication)
}

View File

@@ -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
require.NoError(t, store.StackService.Create(&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"
require.NoError(t, store.StackService.Create(&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)
}

View File

@@ -0,0 +1,42 @@
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)
return h
}

View File

@@ -0,0 +1,42 @@
package workflows
import (
"net/http"
"net/http/httptest"
"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/portainer/portainer/api/http/security"
"github.com/segmentio/encoding/json"
"github.com/stretchr/testify/require"
)
// 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),
})
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"}
}

View File

@@ -0,0 +1,157 @@
package workflows
import (
"cmp"
"context"
"net/http"
"slices"
"strconv"
"strings"
gocache "github.com/patrickmn/go-cache"
portainer "github.com/portainer/portainer/api"
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
}
result, err := h.fetchWorkflows(ctx, sc, set.ToSet(endpointIDs))
if err != nil {
return nil, err
}
h.cache.Set(key, result, gocache.DefaultExpiration)
return slices.Clone(result), nil
}
func (h *Handler) fetchWorkflows(ctx context.Context, sc *security.RestrictedRequestContext, endpointIDSet set.Set[portainer.EndpointID]) ([]svc.Workflow, error) {
return svc.FetchWorkflows(ctx, h.dataStore, h.gitService, h.k8sFactory, sc, endpointIDSet)
}
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, ",")
}

View File

@@ -0,0 +1,386 @@
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 {
require.NoError(t, tx.Stack().Create(&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++ {
require.NoError(t, tx.Stack().Create(&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++ {
require.NoError(t, tx.Stack().Create(&portainer.Stack{
ID: portainer.StackID(i),
Name: fmt.Sprintf("stack-%d", i),
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, "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")},
} {
require.NoError(t, tx.Stack().Create(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 {
require.NoError(t, tx.Stack().Create(&portainer.Stack{
ID: 1, Name: "stack-org1",
GitConfig: gitConfig("https://github.com/org1/repo"),
}))
require.NoError(t, tx.Stack().Create(&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"} {
require.NoError(t, tx.Stack().Create(&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 {
require.NoError(t, tx.Stack().Create(&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.
require.NoError(t, store.StackService.Create(&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 {
require.NoError(t, tx.Stack().Create(
&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 {
require.NoError(t, tx.Stack().Create(&portainer.Stack{
ID: 1, Name: "env1-stack", EndpointID: 1,
GitConfig: gitConfig("https://github.com/x/1"),
}))
require.NoError(t, tx.Stack().Create(&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 {
require.NoError(t, tx.Stack().Create(&portainer.Stack{
ID: 1, Name: "healthy-stack",
GitConfig: gitConfig("https://github.com/x/1"),
}))
require.NoError(t, tx.Stack().Create(&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 {
require.NoError(t, tx.Stack().Create(&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)
}

View File

@@ -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 → healthy",
expectedStatus: ce.StatusHealthy,
},
{
name: "active → healthy",
deployStatus: []portainer.StackDeploymentStatus{{Status: portainer.StackStatusActive}},
expectedStatus: ce.StatusHealthy,
},
{
name: "error → error",
deployStatus: []portainer.StackDeploymentStatus{{Status: portainer.StackStatusError}},
expectedStatus: ce.StatusError,
},
{
name: "deploying → syncing",
deployStatus: []portainer.StackDeploymentStatus{{Status: portainer.StackStatusDeploying}},
expectedStatus: ce.StatusSyncing,
},
{
name: "inactive → 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 {
require.NoError(t, tx.Stack().Create(&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)
})
}
}

View File

@@ -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))
}

View File

@@ -18,7 +18,6 @@ import (
"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/hostmanagement/openamt"
"github.com/portainer/portainer/api/http/handler/kubernetes"
"github.com/portainer/portainer/api/http/handler/ldap"
"github.com/portainer/portainer/api/http/handler/motd"
@@ -65,7 +64,6 @@ type Handler struct {
RoleHandler *roles.Handler
SettingsHandler *settings.Handler
SSLHandler *ssl.Handler
OpenAMTHandler *openamt.Handler
StackHandler *stacks.Handler
StorybookHandler *storybook.Handler
SystemHandler *system.Handler
@@ -81,8 +79,9 @@ type Handler struct {
}
// @title PortainerCE API
// @version 2.41.0
// @version 2.42.0
// @description.markdown api-description.md
// @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_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
@@ -104,70 +103,100 @@ type Handler struct {
// @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 environment(endpoint) settings
// @tag.description Manage Edge related settings
// @tag.x-displayName Edge settings
// @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(endpoint) groups
// @tag.description Manage environment groups
// @tag.x-displayName Environment groups
// @tag.name endpoints
// @tag.description Manage Docker environments(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.name intel
// @tag.description Manage Intel AMT settings
// @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) {
@@ -244,8 +273,6 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
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/open_amt"):
http.StripPrefix("/api", h.OpenAMTHandler).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"):

View File

@@ -1,70 +0,0 @@
package openamt
import (
"errors"
"net/http"
portainer "github.com/portainer/portainer/api"
"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 openAMTActivate
// @summary Activate OpenAMT device and associate to agent endpoint
// @description Activate OpenAMT device and associate to agent endpoint
// @description **Access policy**: administrator
// @tags intel
// @security jwt
// @produce json
// @param id path int true "Environment identifier"
// @deprecated
// @success 200 "Success"
// @failure 400 "Invalid request"
// @failure 403 "Permission denied to access settings"
// @failure 500 "Server error"
// @router /open_amt/{id}/activate [post]
func (handler *Handler) openAMTActivate(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 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)
} else if !endpointutils.IsAgentEndpoint(endpoint) {
errMsg := endpoint.Name + " is not an agent environment"
return httperror.BadRequest(errMsg, errors.New(errMsg))
}
settings, err := handler.DataStore.Settings().Settings()
if err != nil {
return httperror.InternalServerError("Unable to retrieve settings from the database", err)
}
if err := handler.activateDevice(endpoint, *settings); err != nil {
return httperror.InternalServerError("Unable to activate device", err)
}
hostInfo, _, err := handler.getEndpointAMTInfo(endpoint)
if err != nil {
return httperror.InternalServerError("Unable to retrieve AMT information", err)
}
if hostInfo.ControlModeRaw < 1 {
return httperror.InternalServerError("Failed to activate device", errors.New("failed to activate device"))
}
if hostInfo.UUID == "" {
return httperror.InternalServerError("Unable to retrieve device UUID", errors.New("unable to retrieve device UUID"))
}
endpoint.AMTDeviceGUID = hostInfo.UUID
if err := handler.DataStore.Endpoint().UpdateEndpoint(endpoint.ID, endpoint); err != nil {
return httperror.InternalServerError("Unable to persist environment changes inside the database", err)
}
return response.Empty(w)
}

View File

@@ -1,193 +0,0 @@
package openamt
import (
"encoding/base64"
"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"
"github.com/portainer/portainer/pkg/libhttp/response"
"github.com/rs/zerolog/log"
"software.sslmate.com/src/go-pkcs12"
)
type openAMTConfigurePayload struct {
Enabled bool
MPSServer string
MPSUser string
MPSPassword string
DomainName string
CertFileName string
CertFileContent string
CertFilePassword string
}
func (payload *openAMTConfigurePayload) Validate(r *http.Request) error {
if payload.Enabled {
if payload.MPSServer == "" {
return errors.New("MPS Server must be provided")
}
if payload.MPSUser == "" {
return errors.New("MPS User must be provided")
}
if payload.MPSPassword == "" {
return errors.New("MPS Password must be provided")
}
if payload.DomainName == "" {
return errors.New("domain name must be provided")
}
if payload.CertFileContent == "" {
return errors.New("certificate file must be provided")
}
if payload.CertFileName == "" {
return errors.New("certificate file name must be provided")
}
if payload.CertFilePassword == "" {
return errors.New("certificate password must be provided")
}
}
return nil
}
// @id OpenAMTConfigure
// @summary Enable Portainer's OpenAMT capabilities
// @description Enable Portainer's OpenAMT capabilities
// @description **Access policy**: administrator
// @tags intel
// @security jwt
// @accept json
// @produce json
// @deprecated
// @param body body openAMTConfigurePayload true "OpenAMT Settings"
// @success 204 "Success"
// @failure 400 "Invalid request"
// @failure 403 "Permission denied to access settings"
// @failure 500 "Server error"
// @router /open_amt [post]
func (handler *Handler) openAMTConfigure(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
var payload openAMTConfigurePayload
err := request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
log.Error().Err(err).Msg("invalid request payload")
return httperror.BadRequest("Invalid request payload", err)
}
if payload.Enabled {
certificateErr := validateCertificate(payload.CertFileContent, payload.CertFilePassword)
if certificateErr != nil {
return httperror.BadRequest("Error validating certificate", certificateErr)
}
err = handler.enableOpenAMT(payload)
if err != nil {
return httperror.BadRequest("Error enabling OpenAMT", err)
}
return response.Empty(w)
}
err = handler.disableOpenAMT()
if err != nil {
return httperror.BadRequest("Error disabling OpenAMT", err)
}
return response.Empty(w)
}
func validateCertificate(certificateRaw string, certificatePassword string) error {
certificateData, err := base64.StdEncoding.Strict().DecodeString(certificateRaw)
if err != nil {
return err
}
_, certificate, _, err := pkcs12.DecodeChain(certificateData, certificatePassword)
if err != nil {
return err
}
if certificate == nil {
return errors.New("certificate could not be decoded")
}
issuer := certificate.Issuer.CommonName
if !isValidIssuer(issuer) {
return fmt.Errorf("certificate issuer is invalid: %v", issuer)
}
return nil
}
func isValidIssuer(issuer string) bool {
formattedIssuer := strings.ToLower(strings.ReplaceAll(issuer, " ", ""))
return strings.Contains(formattedIssuer, "comodo") ||
strings.Contains(formattedIssuer, "digicert") ||
strings.Contains(formattedIssuer, "entrust") ||
strings.Contains(formattedIssuer, "godaddy")
}
func (handler *Handler) enableOpenAMT(configurationPayload openAMTConfigurePayload) error {
configuration := portainer.OpenAMTConfiguration{
Enabled: true,
MPSServer: configurationPayload.MPSServer,
MPSUser: configurationPayload.MPSUser,
MPSPassword: configurationPayload.MPSPassword,
CertFileContent: configurationPayload.CertFileContent,
CertFileName: configurationPayload.CertFileName,
CertFilePassword: configurationPayload.CertFilePassword,
DomainName: configurationPayload.DomainName,
}
err := handler.OpenAMTService.Configure(configuration)
if err != nil {
log.Error().Err(err).Msg("error configuring OpenAMT server")
return err
}
err = handler.saveConfiguration(configuration)
if err != nil {
log.Error().Err(err).Msg("error updating OpenAMT configurations")
return err
}
log.Info().Msg("OpenAMT successfully enabled")
return nil
}
func (handler *Handler) saveConfiguration(configuration portainer.OpenAMTConfiguration) error {
settings, err := handler.DataStore.Settings().Settings()
if err != nil {
return err
}
configuration.MPSToken = ""
settings.OpenAMTConfiguration = configuration
return handler.DataStore.Settings().UpdateSettings(settings)
}
func (handler *Handler) disableOpenAMT() error {
settings, err := handler.DataStore.Settings().Settings()
if err != nil {
return err
}
settings.OpenAMTConfiguration.Enabled = false
err = handler.DataStore.Settings().UpdateSettings(settings)
if err != nil {
return err
}
log.Info().Msg("OpenAMT successfully disabled")
return nil
}

View File

@@ -1,192 +0,0 @@
package openamt
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"
"github.com/rs/zerolog/log"
)
// @id OpenAMTDevices
// @summary Fetch OpenAMT managed devices information for endpoint
// @description Fetch OpenAMT managed devices information for endpoint
// @description **Access policy**: administrator
// @tags intel
// @security jwt
// @produce json
// @param id path int true "Environment(Endpoint) identifier"
// @deprecated
// @success 200 "Success"
// @failure 400 "Invalid request"
// @failure 403 "Permission denied to access settings"
// @failure 500 "Server error"
// @router /open_amt/{id}/devices [get]
func (handler *Handler) openAMTDevices(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 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 endpoint.AMTDeviceGUID == "" {
return response.JSON(w, []portainer.OpenAMTDeviceInformation{})
}
settings, err := handler.DataStore.Settings().Settings()
if err != nil {
return httperror.InternalServerError("Unable to retrieve settings from the database", err)
}
device, err := handler.OpenAMTService.DeviceInformation(settings.OpenAMTConfiguration, endpoint.AMTDeviceGUID)
if err != nil {
return httperror.InternalServerError("Unable to retrieve device information", err)
}
devices := []portainer.OpenAMTDeviceInformation{
*device,
}
return response.JSON(w, devices)
}
type deviceActionPayload struct {
Action string
}
func (payload *deviceActionPayload) Validate(r *http.Request) error {
if payload.Action == "" {
return errors.New("device action must be provided")
}
return nil
}
// @id DeviceAction
// @summary Execute out of band action on an AMT managed device
// @description Execute out of band action on an AMT managed device
// @description **Access policy**: administrator
// @tags intel
// @security jwt
// @accept json
// @produce json
// @deprecated
// @param id path int true "Environment identifier"
// @param deviceId path int true "Device identifier"
// @param body body deviceActionPayload true "Device Action"
// @success 204 "Success"
// @failure 400 "Invalid request"
// @failure 403 "Permission denied to access settings"
// @failure 500 "Server error"
// @router /open_amt/{id}/devices/{deviceId}/action [post]
func (handler *Handler) deviceAction(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
deviceID, err := request.RetrieveRouteVariableValue(r, "deviceId")
if err != nil {
return httperror.BadRequest("Invalid device identifier route variable", err)
}
var payload deviceActionPayload
err = request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
log.Error().Err(err).Msg("invalid request payload")
return httperror.BadRequest("Invalid request payload", err)
}
settings, err := handler.DataStore.Settings().Settings()
if err != nil {
return httperror.InternalServerError("Unable to retrieve settings from the database", err)
}
err = handler.OpenAMTService.ExecuteDeviceAction(settings.OpenAMTConfiguration, deviceID, payload.Action)
if err != nil {
log.Error().Err(err).Msg("error executing device action")
return httperror.BadRequest("Error executing device action", err)
}
return response.Empty(w)
}
type deviceFeaturesPayload struct {
Features portainer.OpenAMTDeviceEnabledFeatures
}
func (payload *deviceFeaturesPayload) Validate(r *http.Request) error {
if payload.Features.UserConsent == "" {
return errors.New("device user consent status must be provided")
}
return nil
}
type AuthorizationResponse struct {
Server string
Token string
}
// @id DeviceFeatures
// @summary Enable features on an AMT managed device
// @description Enable features on an AMT managed device
// @description **Access policy**: administrator
// @tags intel
// @security jwt
// @accept json
// @produce json
// @deprecated
// @param id path int true "Environment identifier"
// @param deviceId path int true "Device identifier"
// @param body body deviceFeaturesPayload true "Device Features"
// @success 204 "Success"
// @failure 400 "Invalid request"
// @failure 403 "Permission denied to access settings"
// @failure 500 "Server error"
// @router /open_amt/{id}/devices_features/{deviceId} [post]
func (handler *Handler) deviceFeatures(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
deviceID, err := request.RetrieveRouteVariableValue(r, "deviceId")
if err != nil {
return httperror.BadRequest("Invalid device identifier route variable", err)
}
var payload deviceFeaturesPayload
err = request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
log.Error().Err(err).Msg("invalid request payload")
return httperror.BadRequest("Invalid request payload", err)
}
settings, err := handler.DataStore.Settings().Settings()
if err != nil {
return httperror.InternalServerError("Unable to retrieve settings from the database", err)
}
_, err = handler.OpenAMTService.DeviceInformation(settings.OpenAMTConfiguration, deviceID)
if err != nil {
return httperror.InternalServerError("Unable to retrieve device information", err)
}
token, err := handler.OpenAMTService.EnableDeviceFeatures(settings.OpenAMTConfiguration, deviceID, payload.Features)
if err != nil {
log.Error().Err(err).Msg("error executing device action")
return httperror.BadRequest("Error executing device action", err)
}
authorizationResponse := AuthorizationResponse{
Server: settings.OpenAMTConfiguration.MPSServer,
Token: token,
}
return response.JSON(w, authorizationResponse)
}

View File

@@ -1,306 +0,0 @@
package openamt
import (
"context"
"fmt"
"io"
"net/http"
"time"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/hostmanagement/openamt"
"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/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/api/types/image"
"github.com/docker/docker/api/types/network"
"github.com/docker/docker/client"
"github.com/rs/zerolog/log"
"github.com/segmentio/encoding/json"
)
type HostInfo struct {
EndpointID portainer.EndpointID `json:"EndpointID"`
RawOutput string `json:"RawOutput"`
AMT string `json:"AMT"`
UUID string `json:"UUID"`
DNSSuffix string `json:"DNS Suffix"`
BuildNumber string `json:"Build Number"`
ControlMode string `json:"Control Mode"`
ControlModeRaw int `json:"Control Mode (Raw)"`
}
const (
// TODO: this should get extracted to some configurable - don't assume Docker Hub is everyone's global namespace, or that they're allowed to pull images from the internet
rpcGoImageName = "ptrrd/openamt:rpc-go-json"
rpcGoContainerName = "openamt-rpc-go"
dockerClientTimeout = 5 * time.Minute
)
// @id OpenAMTHostInfo
// @summary Request OpenAMT info from a node
// @description Request OpenAMT info from a node
// @description **Access policy**: administrator
// @tags intel
// @security jwt
// @produce json
// @param id path int true "Environment identifier"
// @deprecated
// @success 200 "Success"
// @failure 400 "Invalid request"
// @failure 403 "Permission denied to access settings"
// @failure 500 "Server error"
// @router /open_amt/{id}/info [get]
func (handler *Handler) openAMTHostInfo(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)
}
log.Info().Int("endpointID", endpointID).Msg("OpenAMTHostInfo")
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)
}
amtInfo, output, err := handler.getEndpointAMTInfo(endpoint)
if err != nil {
return httperror.InternalServerError(output, err)
}
return response.JSON(w, amtInfo)
}
func (handler *Handler) getEndpointAMTInfo(endpoint *portainer.Endpoint) (*HostInfo, string, error) {
ctx := context.TODO()
// pull the image so we can check if there's a new one
// TODO: these should be able to be over-ridden (don't hardcode the assumption that secure users can access Docker Hub, or that its even the orchestrator's "global namespace")
cmdLine := []string{"amtinfo", "--json"}
output, err := handler.PullAndRunContainer(ctx, endpoint, rpcGoImageName, rpcGoContainerName, cmdLine)
if err != nil {
return nil, output, err
}
amtInfo := HostInfo{}
_ = json.Unmarshal([]byte(output), &amtInfo)
amtInfo.EndpointID = endpoint.ID
amtInfo.RawOutput = output
return &amtInfo, "", nil
}
func (handler *Handler) PullAndRunContainer(ctx context.Context, endpoint *portainer.Endpoint, imageName, containerName string, cmdLine []string) (output string, err error) {
// TODO: this should not be Docker specific
// TODO: extract from this Handler into something global.
// TODO: start
// docker run --rm -it --privileged ptrrd/openamt:rpc-go amtinfo
// on the Docker standalone node (one per env :)
// and later, on the specified node in the swarm, or kube.
nodeName := ""
timeout := dockerClientTimeout
docker, err := handler.DockerClientFactory.CreateClient(endpoint, nodeName, &timeout)
if err != nil {
return "Unable to create Docker Client connection", err
}
defer func() {
if err := docker.Close(); err != nil {
log.Warn().Err(err).Msg("failed to close docker client")
}
}()
if err := pullImage(ctx, docker, imageName); err != nil {
return "Could not pull image from registry", err
}
output, err = runContainer(ctx, docker, imageName, containerName, cmdLine)
if err != nil {
return "Could not run container", err
}
return output, nil
}
// TODO: ideally, pullImage and runContainer will become a simple version of the use compose abstraction that can be called from withing Portainer.
// TODO: the idea being that if we have an internal struct of a parsed compose file, we can also populate that struct programmatically, and run it to get the result I'm getting here.
// TODO: likely an upgrade and abstraction of DeployComposeStack/DeploySwarmStack/DeployKubernetesStack
// pullImage will pull the image to the specified environment
// TODO: add k8s implementation
// TODO: work out registry auth
func pullImage(ctx context.Context, docker *client.Client, imageName string) error {
out, err := docker.ImagePull(ctx, imageName, image.PullOptions{})
if err != nil {
log.Error().Str("image_name", imageName).Err(err).Msg("could not pull image from registry")
return err
}
defer logs.CloseAndLogErr(out)
outputBytes, err := io.ReadAll(out)
if err != nil {
log.Error().Str("image_name", imageName).Err(err).Msg("could not read image pull output")
return err
}
log.Debug().Str("image_name", imageName).Str("output", string(outputBytes)).Msg("image pulled")
return nil
}
// TODO: ideally, pullImage and runContainer will become a simple version of the use compose abstraction that can be called from withing Portainer.
// runContainer should be used to run a short command that returns information to stdout
// TODO: add k8s support
func runContainer(ctx context.Context, docker *client.Client, imageName, containerName string, cmdLine []string) (output string, err error) {
opts := container.ListOptions{All: true}
opts.Filters = filters.NewArgs()
opts.Filters.Add("name", containerName)
existingContainers, err := docker.ContainerList(ctx, opts)
if err != nil {
log.Error().
Str("image_name", imageName).
Str("container_name", containerName).
Err(err).
Msg("listing existing container")
return "", err
}
if len(existingContainers) > 0 {
err = docker.ContainerRemove(ctx, existingContainers[0].ID, container.RemoveOptions{Force: true})
if err != nil {
log.Error().
Str("image_name", imageName).
Str("container_name", containerName).
Err(err).
Msg("removing existing container")
return "", err
}
}
created, err := docker.ContainerCreate(
ctx,
&container.Config{
Image: imageName,
Cmd: cmdLine,
Env: []string{},
Tty: true,
OpenStdin: true,
AttachStdout: true,
AttachStderr: true,
},
&container.HostConfig{
Privileged: true,
},
&network.NetworkingConfig{},
nil,
containerName,
)
if err != nil {
log.Error().
Str("image_name", imageName).
Str("container_name", containerName).
Err(err).
Msg("creating container")
return "", err
}
err = docker.ContainerStart(ctx, created.ID, container.StartOptions{})
if err != nil {
log.Error().
Str("image_name", imageName).
Str("container_name", containerName).
Err(err).
Msg("starting container")
return "", err
}
log.Debug().Str("container_name", containerName).Msg("container created and started")
statusCh, errCh := docker.ContainerWait(ctx, created.ID, container.WaitConditionNotRunning)
var statusCode int64
select {
case err := <-errCh:
if err != nil {
log.Error().
Str("image_name", imageName).
Str("container_name", containerName).
Err(err).
Msg("starting container")
return "", err
}
case status := <-statusCh:
statusCode = status.StatusCode
}
log.Debug().Int64("status", statusCode).Msg("container wait status")
out, err := docker.ContainerLogs(ctx, created.ID, container.LogsOptions{ShowStdout: true})
if err != nil {
log.Error().Err(err).Str("image_name", imageName).Str("container_name", containerName).Msg("getting container log")
return "", err
}
err = docker.ContainerRemove(ctx, created.ID, container.RemoveOptions{})
if err != nil {
log.Error().
Str("image_name", imageName).
Str("container_name", containerName).
Err(err).
Msg("removing container")
return "", err
}
outputBytes, err := io.ReadAll(out)
if err != nil {
log.Error().
Str("image_name", imageName).
Str("container_name", containerName).
Err(err).
Msg("read container output")
return "", err
}
log.Debug().
Str("container_name", containerName).
Str("output", string(outputBytes)).
Msg("container finished with output")
return string(outputBytes), nil
}
func (handler *Handler) activateDevice(endpoint *portainer.Endpoint, settings portainer.Settings) error {
ctx := context.TODO()
config := settings.OpenAMTConfiguration
cmdLine := []string{
"activate",
"-n",
"-v",
"-u", fmt.Sprintf("wss://%s/activate", config.MPSServer),
"-profile", openamt.DefaultProfileName,
"-d", config.DomainName,
"-password", config.MPSPassword,
}
_, err := handler.PullAndRunContainer(ctx, endpoint, rpcGoImageName, rpcGoContainerName, cmdLine)
return err
}

View File

@@ -1,39 +0,0 @@
package openamt
import (
"net/http"
"github.com/gorilla/mux"
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/middlewares"
"github.com/portainer/portainer/api/http/security"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
)
// Handler is the HTTP handler used to handle OpenAMT operations.
type Handler struct {
*mux.Router
OpenAMTService portainer.OpenAMTService
DataStore dataservices.DataStore
DockerClientFactory *dockerclient.ClientFactory
}
// NewHandler returns a new Handler
func NewHandler(bouncer security.BouncerService) *Handler {
h := &Handler{
Router: mux.NewRouter(),
}
h.Use(middlewares.DeprecatedSimple)
h.Handle("/open_amt/configure", bouncer.AdminAccess(httperror.LoggerHandler(h.openAMTConfigure))).Methods(http.MethodPost)
h.Handle("/open_amt/{id}/info", bouncer.AdminAccess(httperror.LoggerHandler(h.openAMTHostInfo))).Methods(http.MethodGet)
h.Handle("/open_amt/{id}/activate", bouncer.AdminAccess(httperror.LoggerHandler(h.openAMTActivate))).Methods(http.MethodPost)
h.Handle("/open_amt/{id}/devices", bouncer.AdminAccess(httperror.LoggerHandler(h.openAMTDevices))).Methods(http.MethodGet)
h.Handle("/open_amt/{id}/devices/{deviceId}/action", bouncer.AdminAccess(httperror.LoggerHandler(h.deviceAction))).Methods(http.MethodPost)
h.Handle("/open_amt/{id}/devices/{deviceId}/features", bouncer.AdminAccess(httperror.LoggerHandler(h.deviceFeatures))).Methods(http.MethodPost)
return h
}

View File

@@ -2,7 +2,6 @@ package kubernetes
import (
"net/http"
"strconv"
"github.com/portainer/portainer/api/http/middlewares"
"github.com/portainer/portainer/api/http/security"
@@ -33,7 +32,7 @@ func (handler *Handler) prepareKubeClient(r *http.Request) (*cli.KubeClient, *ht
return nil, httperror.InternalServerError("Unable to retrieve token data associated to the request.", err)
}
pcli, err := handler.KubernetesClientFactory.GetPrivilegedUserKubeClient(endpoint, strconv.Itoa(int(tokenData.ID)))
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)

View File

@@ -94,6 +94,17 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza
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)
@@ -106,6 +117,7 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza
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/{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?)
@@ -125,8 +137,13 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza
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)

View File

@@ -0,0 +1,227 @@
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)
}
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)
}
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)
}

View File

@@ -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)
}

View File

@@ -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
}

Some files were not shown because too many files have changed in this diff Show More