Compare commits

...

202 Commits

Author SHA1 Message Date
vvzvlad
9e9bb1bbff ci: install client deps with --no-frozen-lockfile explicitly
Some checks are pending
Build image / build (push) Waiting to run
pnpm ignores the npm_config_frozen_lockfile env var, so the previous fix did not take effect and CI still ran a frozen install. Add an explicit 'pnpm install --no-frozen-lockfile' step before 'make build-all' to reconcile the lockfile (missing pnpmfileChecksum for configDependencies); the subsequent frozen install in 'make client-deps' then succeeds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 19:21:11 +03:00
vvzvlad
d0a0395337 ci: disable pnpm frozen-lockfile in build workflow
CI enables --frozen-lockfile by default, which fails with ERR_PNPM_LOCKFILE_CONFIG_MISMATCH because the committed pnpm-lock.yaml does not record the pnpmfileChecksum for the configDependencies declared in package.json. Set npm_config_frozen_lockfile=false so the bare 'pnpm install' run by 'make client-deps' reconciles the lockfile instead of failing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 19:18:59 +03:00
vvzvlad
88589e4cb3 ci: add GitHub Actions workflow to build and publish fork image to GHCR
Builds the CE fork (frontend + Go server) and publishes a drop-in compatible image to ghcr.io/vvzvlad/portainer-ce on pushes to develop, v* tags, and manual dispatch. Single-arch linux/amd64, alpine base, production frontend build (ENV=production), tags <package.json version> and latest, GHCR auth via the built-in GITHUB_TOKEN.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 19:14:36 +03:00
vvzvlad
af74986e66 chore: add VS Code workspace file 2026-06-28 18:55:18 +03:00
andres-portainer
e664bf0e19 fix(helm): add missing SSRF protections BE-13136 (#3001) 2026-06-22 20:25:10 -03:00
nickl-portainer
152c89972b chore(eslint): update eslint to latest v9 [R8S-1090] (#2954) 2026-06-23 11:04:33 +12:00
Oscar Zhou
25c69c6e9b fix(ui): update server installation timeout redirect link [BE-13124] (#2991) 2026-06-23 08:49:43 +12:00
andres-portainer
a6370808ae fix(ssrf): disable HTTP/2 for some specific cases BE-13121 (#2996) 2026-06-22 16:13:43 -03:00
Chaim Lev-Ari
6bfd2360d8 docs(security): add FAQ link to setup token messages [BE-13125] (#2995)
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-22 20:30:49 +03:00
Chaim Lev-Ari
872d1e03f6 feat(gitops): add "create new source" button to GitSourceSelector [BE-13054] (#2960)
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-22 17:19:53 +03:00
Chaim Lev-Ari
a5cacd712d refactor(gitops): remove manual credential entry from git form [BE-13047] (#2951)
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-22 15:42:09 +03:00
Phil Calder
f596c862b3 fix(websocket): enforce environment authorization on kubernetes-shell [BE-13027] (#2774)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: oscarzhou <oscar.zhou@portainer.io>
2026-06-22 15:09:41 +12:00
bernard-portainer
5395dee4c6 feat(gpu-stats): add gpu stats to environments [C9S-200] (#2735) 2026-06-22 09:21:43 +12:00
Josiah Clumont
217fe870ef fix(git): use ListContext instead of List when fetching remote refs [C9S-263] (#2939) 2026-06-22 08:30:20 +12:00
andres-portainer
26334e9088 feat(ssrf): add missing transport wrappings and more checks BE-13021 (#2968) 2026-06-19 20:26:03 -03:00
RHCowan
cc45af2873 fix(lint): enforce consistent golangci-lint version across CI and pre-commit [PLA-777] (#2966) 2026-06-19 11:45:12 +12:00
RHCowan
37bd8c06b5 fix(security): gate docker dashboard and edge async command routes [R8S-1057] (#2953) 2026-06-19 11:08:01 +12:00
andres-portainer
c821a1c59f fix(git): avoid cloning to memory and bypassing symlinking restriction BE-13115 (#2961) 2026-06-18 16:21:09 -03:00
Dakota Walsh
f5d0b3d849 feat(kubernetes): Gateway api client included in kubeclient [C9S-244] (#2884) 2026-06-18 14:37:42 +12:00
nickl-portainer
0dfd27f08c fix(pnpm): pnpm format command failing [R8S-1071] (#2932) 2026-06-18 13:27:01 +12:00
nickl-portainer
0dfa0266c7 fix(webpack): update shell-quote [R8S-1074] (#2934) 2026-06-17 10:50:48 +12:00
nickl-portainer
9b807ca314 fix(axios): update axios [R8S-1075] (#2935) 2026-06-17 10:50:34 +12:00
nickl-portainer
de5d84ade4 fix(kubernetes): handling undefined responseStatus [R8S-1072] (#2933) 2026-06-17 09:32:59 +12:00
Chaim Lev-Ari
4d539a691d feat(custom-templates): reuse existing git sources in create/update [BE-13053] (#2925)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 21:45:35 +03:00
Chaim Lev-Ari
ee8e73d7f9 feat(edge/stacks): use source ID for edge stack git creation [BE-13044] (#2926)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 17:33:19 +03:00
Chaim Lev-Ari
32c6bedb98 feat(stacks): use source for kubernetes manifest git stacks [BE-13045] (#2915)
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-16 14:35:16 +03:00
Ali
cd9bb18ba1 feat(policies): reuse filter status component, give consistent styles [c9s-210] (#2723) 2026-06-16 15:58:33 +12:00
nickl-portainer
f365035563 fix(git): update lint-staged to v17 [R8S-1071] (#2907) 2026-06-16 15:14:57 +12:00
Chaim Lev-Ari
d9673e33ec feat(helm): reuse existing git sources in Kubernetes Helm-from-git install [BE-13046] (#2900)
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-15 22:01:31 +03:00
Chaim Lev-Ari
491df61fbf chore(hey-api): disable api validator [BE-13102] (#2918) 2026-06-15 21:35:19 +03:00
Chaim Lev-Ari
ca1d9dc6a2 fix(edge/stacks): load envs by id [BE-13097] (#2917) 2026-06-15 21:23:49 +03:00
andres-portainer
16b5554f66 fix(customtemplates): add resource controls BE-13019 (#2897) 2026-06-15 14:59:07 -03:00
Chaim Lev-Ari
fcdd6b4510 feat(stacks): use source id to create git stacks [BE-13043] (#2870)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 18:49:26 +03:00
Chaim Lev-Ari
04048c3818 fix(api): update environment status field to be optional [BE-13070] (#2847) 2026-06-15 13:36:12 +03:00
Chaim Lev-Ari
1afbc621a4 fix(editor): restore yaml syntax highlighting in web editor [BE-13073] (#2848)
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-15 10:50:02 +03:00
Devon Steenberg
ef807950f1 fix(compose-unpacker): port swarm commands to use libstack [BE-12915] (#2890) 2026-06-15 11:43:29 +12:00
Phil Calder
d37f3aa504 chore(server-ce): update Code of Conduct contact to contribute@portainer.io (#2889)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 12:44:41 +12:00
Oscar Zhou
39b3eb3d64 fix(registry): standard user with access permission cannot browse and delete private images [BE-13072] (#2877) 2026-06-13 19:52:03 +12:00
Devon Steenberg
8b21dfc318 feat(ssrf): add ssrf allow list to settings [BE-13021] (#2858) 2026-06-12 15:16:06 +12:00
Steven Kang
f87fec6d61 fix(omni): prevent partial cluster creation on Talos/Kubernetes mismatch [R8S-1058] (#2849)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 11:50:16 +12:00
Ali
391eb22d98 fix(ui): ui consistency and bug fixes [r8s-1061] (#2880) 2026-06-12 11:49:45 +12:00
andres-portainer
0da42c01b6 feat(gitcredential): remove GitCredential BE-12919 (#2838) 2026-06-11 18:53:24 -03:00
Cara Ryan
f3f0ca8e21 fix(rbac): Filter get namespace by allowed namespace list [SEC-61] (#2743) 2026-06-11 15:51:32 +12:00
RHCowan
96dc79e253 feat(alerting): add Kubernetes API server high request latency alert [R8S-1049] (#2765)
Co-authored-by: Steven Kang <skan070@gmail.com>
2026-06-11 15:44:33 +12:00
Xing
ac3416c5a2 feat(policies): define ObservabilityK8s policy type with deploy-and-connect and connect-only modes [C9S-121] (#2706)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 15:09:33 +12:00
Cara Ryan
ade5b2a3db feat(rbac): Add toggle for additive kubernetes RBAC policy [C9S-177] (#2814) 2026-06-11 13:44:43 +12:00
Steven Kang
1cd6017df6 fix(api): add endpoint authorization check to /api/kubernetes/{id}/* route - develop [R8S-1056] (#2829) 2026-06-11 09:49:50 +12:00
Oscar Zhou
06caea7b16 fix(security): bump golang to 1.26.4 [DEV-91] (#2866) 2026-06-11 09:31:35 +12:00
Oscar Zhou
114779d3af fix(security): bump go-git/v5 to 5.19.1 [DEV-92] (#2863) 2026-06-11 09:31:07 +12:00
nickl-portainer
96d694b66b fix(storybook): add row id for example data for datatable story [R8S-1062] (#2856) 2026-06-11 09:10:29 +12:00
andres-portainer
babb4ffb37 fix(nolint): remove unnecessary nolint directives BE-13074 (#2852) 2026-06-10 15:35:08 -03:00
LP B
0c2f07988a feat(app/sources): source create view (#2680)
Co-authored-by: Chaim Lev-Ari <chaim.lev-ari@portainer.io>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 21:34:46 +03:00
Ali
d7a1d34be7 feat(policies): docker cleanup policy [c9s-87] (#2681) 2026-06-10 16:17:23 +12:00
nickl-portainer
6a465637d4 feat(components): add new FilePicker component [R8S-1050] (#2754) 2026-06-10 10:34:14 +12:00
andres-portainer
154c19403a fix(chisel): release a lock earlier to avoid a deadlock and clean stale tunnels immediately BE-13050 (#2815) 2026-06-09 10:41:05 -03:00
bernard-portainer
c9e1467244 fix(stats-items): ensure stats items have consistent widths [C92-215] (#2844) 2026-06-09 16:53:17 +12:00
andres-portainer
1765e41fd4 feat(ssrf): implement an SSRF protection mechanism BE-13021 (#2818) 2026-06-09 00:41:42 -03:00
Phil Calder
d34ee82754 docs(contributing): fix reversed markdown link syntax for swag docs [DEV-89] (#2841)
Co-authored-by: ferreiraborgesaxel-design <ferreiraborgesaxel-design@users.noreply.github.com>
2026-06-09 14:25:50 +12:00
Oscar Zhou
5cdd0023d7 fix(registry): suppress ecr token pre-validation error with warning log [BE-13059] (#2827) 2026-06-09 10:58:50 +12:00
andres-portainer
df7a4b5d6f feat(gitops): improve the data model BE-12919 (#2819) 2026-06-08 15:01:55 -03:00
Chaim Lev-Ari
63eb96859d fix(endpoints): parse endpoint on GET [BE-13049] (#2824) 2026-06-08 16:59:09 +03:00
Josiah Clumont
e3e2a3b782 fix(environments): Environment Groups detail view environment breakdown regression [BE-13051] (#2828) 2026-06-08 16:03:32 +12:00
Steven Kang
eeafa5e0a5 fix(security): bump containerd to 1.7.32 and containerd/v2 to 2.2.4 - develop [DEV-79] (#2812) 2026-06-08 11:09:34 +12:00
Chaim Lev-Ari
7e5e71ae67 chore(deps): bump patch/minor frontend dependencies [BE-13004] (#2808)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 15:45:58 +03:00
andres-portainer
8daf0bb2a9 feat(customtemplates): use Sources for CustomTemplates BE-12919 (#2759) 2026-06-05 01:51:18 -03:00
nickl-portainer
a779c839b7 feat(kubernetes): set application list default to expanded [R8S-1040] (#2758) 2026-06-05 14:35:00 +12:00
Josiah Clumont
0da57f8747 fix(environment-groups): restore dark mode badge colors and fix empty list regression [C9S-231] (#2811) 2026-06-05 11:58:54 +12:00
Steven Kang
d01d241af1 fix(security): bump golang.org/x/net to v0.55.0 - develop [DEV-77] (#2797) 2026-06-05 10:46:41 +12:00
Steven Kang
dd08d09d14 fix(security): bump Go to 1.26.3 to remediate 8 stdlib CVEs - develop [DEV-78] (#2800) 2026-06-05 10:46:22 +12:00
Ali
0143393a8c chore(policies): use generic policy reconcile system so more than helm can be used [c9s-88] (#2613) 2026-06-05 07:47:25 +12:00
Chaim Lev-Ari
d2b56efcb4 feat(security): require setup token for admin init and restore [BE-13029] (#2770) 2026-06-04 09:15:23 +03:00
Chaim Lev-Ari
dab0cf48c6 docs(api): document CE type generation [BE-13031] (#2794)
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-04 09:13:45 +03:00
Hannah Cooper
916367dccb fix(api-docs): time.Duration bounds fix + linting fixes [C9S-223] (#2762) 2026-06-04 15:14:07 +12:00
Hannah Cooper
580a9fdfcf Add version '2.39.3' to bug report template (#2801) 2026-06-04 12:38:26 +12:00
Chaim Lev-Ari
2ba8b582e2 feat(api): use generated api client [BE-12901] (#2727) 2026-06-03 14:37:39 +03:00
Chaim Lev-Ari
bc81eb7a22 feat(sources): allow user to edit source [BE-12956] (#2748) 2026-06-03 12:52:41 +03:00
Oscar Zhou
a54fc041b0 fix(stacks): git polling failures caused by cancelled deployment context [BE-12980] (#2751) 2026-06-03 16:12:07 +12:00
Phil Calder
10a2b25527 chore(deps): bump vitest to 4.1.x to address CVE-2026-47429 [DEV-74] (#2779)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 15:00:21 +12:00
Steven Kang
cf476953d6 fix(environment): fixed typo of kubesolo to KubeSolo (#2788) 2026-06-03 13:16:38 +12:00
Steven Kang
b233453cf7 feat(kubernetes): display cached images per node [R8S-898] (#2068) 2026-06-03 10:40:14 +12:00
andres-portainer
bc5136a197 fix(images): improve the image status indicator performance BE-13033 (#2781) 2026-06-02 19:25:51 -03:00
Xing
e08ee08fd8 fix(policies): implement Helm release conflict detection for chart-based policies [C9S-204] (#2690) 2026-06-03 10:22:03 +12:00
Steven Kang
eb5ee3bfdb fix(kubernetes): improve PVC deletion UX based on workload usage [R8S-1046] (#2766) 2026-06-03 09:43:07 +12:00
Steven Kang
86a84c3c6a fix(kubernetes): updated wrong tooltip for container restart feature-gate [R8S-1037] (#2721) 2026-06-03 09:26:04 +12:00
andres-portainer
edb348c273 feat(useractivity): fix a goroutine leak BE-12969 (#2767) 2026-06-02 18:23:01 -03:00
Josiah Clumont
ba91b41d36 fix(table): restore horizontal margins on table views [C9S-227] (#2785) 2026-06-03 08:14:30 +12:00
andres-portainer
99547044bc feat(boltdb): optimize encrypted connections BE-12995 (#2769) 2026-06-02 14:58:05 -03:00
andres-portainer
1fa756372e feat(gitops): general improvements BE-12919 (#2780) 2026-06-02 09:44:57 -03:00
Josiah Clumont
484af3c2c8 feat(environment group) detail view update v1 [c9s-206] (#2722)
Last system-test failure is also on dev
2026-06-02 16:59:18 +12:00
Devon Steenberg
742551e592 fix(registries): make gitlab proxy endpoint admin only [BE-13018] (#2764) 2026-06-02 15:45:57 +12:00
Steven Kang
50081cbdaa feat(environment): environment support for kubesolo [R8S-983] (#2648) 2026-06-02 09:21:08 +12:00
andres-portainer
61198a0c04 fix(otel): upgrade go.opentelemetry.io/otel to v1.43.0 to fix CVE-2026-39883 CVE-2026-39882 BE-12988 (#2713) 2026-06-01 09:46:41 -03:00
Chaim Lev-Ari
67590aa27d feat(api): auto generate typescript definition from api docs [BE-9222] (#2468) 2026-05-31 14:51:52 +03:00
Ali
6c059c41f9 chore: bump version to 2.43.0 (#2760) 2026-05-30 16:56:17 +12:00
andres-portainer
f1db82934d fix(security): fix a short-circuit condition that can lead to improper access control BE-13020 (#2756) 2026-05-29 20:47:59 -03:00
Hannah Cooper
28dd6b767f fix(api-docs): API docs fixes / improvements [C9S-208] (#2717) 2026-05-29 11:33:06 +12:00
Josiah Clumont
98b1d7f585 feat(environment-groups): replace Datatable with SortableList and update list UI [R8S-827] (#2661) 2026-05-29 10:08:35 +12:00
nickl-portainer
f7b8e3d84b fix(kubernetes): unify all application container actions as icon with tooltip hover [R8S-1034] (#2711) 2026-05-28 13:24:38 +12:00
bernard-portainer
4b4fa39670 fix(endpoint-summary): fix incorrect counts in CE [C9S-190] (#2744) 2026-05-28 10:38:06 +12:00
andres-portainer
ab4626e7de feat(workflows): introduce Artifacts BE-12919 (#2740) 2026-05-26 16:17:32 -03:00
Xing
7164146626 fix(policies): add NoWait flag to PolicyChartBundle for externally sourced charts [C9S-207] (#2710) 2026-05-26 14:36:58 +12:00
andres-portainer
3b4f688223 feat(dataservices): reduce the allocations BE-12995 (#2733) 2026-05-25 22:57:38 -03:00
Devon Steenberg
ee2706c5ee fix(swarm): service creation networks [BE-12996] (#2736) 2026-05-26 13:49:20 +12:00
Josiah Clumont
2d9fc5d8af feat(home): add environment card storybook stories with Talos permutations [C9S-216] (#2739) 2026-05-26 13:25:25 +12:00
andres-portainer
49c9a4fdd3 fix(crypto): upgrade golang.org/x/crypto to v0.52.0 to fix CVE-2026-39830 CVE-2026-39831 CVE-2026-39832 CVE-2026-39833 CVE-2026-39834 CVE-2026-42508 CVE-2026-46595 BE-12992 (#2728) 2026-05-25 19:08:14 -03:00
Chaim Lev-Ari
bafdbc8313 fix(gitops): clear file path only for helm [BE-12990] (#2720) 2026-05-25 10:31:18 +03:00
bernard-portainer
eca28fd4b5 fix(node-count) update docker node count for non-swarm [C9S-182] (#2702) 2026-05-25 14:19:17 +12:00
andres-portainer
3d09c70e13 feat(sources): add sources and workflows to the backend BE-12919 (#2666) 2026-05-20 20:42:10 -03:00
Hannah Cooper
4cd8c04691 Update bug report template to include 2.42.0 (#2709) 2026-05-21 10:52:09 +12:00
nickl-portainer
f7764cd5cb fix(system-tests): Update changed data-cy attribute for k8s volumes page [R8S-1033] (#2703) 2026-05-21 08:30:19 +12:00
nickl-portainer
afae689ea9 fix(kubernetes): PersistentVolumeClaims datatable system resource filter [R8S-1031] (#2701) 2026-05-20 12:08:53 +12:00
andres-portainer
e2d7491bc9 fix(edge-stacks): fix webhook ID creation in the frontend BE-12983 (#2682) 2026-05-19 12:03:57 -03:00
RHCowan
4c55508f01 fix(alerting): remove kube-scheduler and kube-controller-manager alert rules [R8S-1030] (#2695)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 19:07:11 +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
1268 changed files with 92992 additions and 18893 deletions

View File

@@ -3,13 +3,13 @@ body:
attributes:
value: |
# Welcome!
Thanks for suggesting an idea for Portainer!
Before opening a new idea or feature request, make sure that we do not have any duplicates already open. You can ensure this by [searching this discussion category](https://github.com/orgs/portainer/discussions/categories/ideas). If there is a duplicate, please add a comment to the existing idea instead.
Also, be sure to check our [knowledge base](https://portal.portainer.io/knowledge) and [documentation](https://docs.portainer.io) as they may point you toward a solution.
**DO NOT FILE DUPLICATE REQUESTS.**
- type: textarea

View File

@@ -94,7 +94,12 @@ 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.42.0'
- '2.41.1'
- '2.41.0'
- '2.40.0'
- '2.39.3'
- '2.39.2'
- '2.39.1'
- '2.39.0'
- '2.38.1'
@@ -103,6 +108,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 +117,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

86
.github/workflows/build-image.yml vendored Normal file
View File

@@ -0,0 +1,86 @@
name: Build image
on:
push:
branches: [develop]
tags: ['v*']
workflow_dispatch: {}
env:
IMAGE: ghcr.io/vvzvlad/portainer-ce
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Resolve version
id: ver
run: echo "version=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT"
- name: Install client dependencies
# CI forces pnpm into --frozen-lockfile, which fails with
# ERR_PNPM_LOCKFILE_CONFIG_MISMATCH because the committed lockfile lacks
# the pnpmfileChecksum for the configDependencies in package.json.
# Reconcile the lockfile explicitly; the later frozen install in
# `make client-deps` then finds a matching lockfile. pnpm ignores the
# npm_config_frozen_lockfile env var, so an explicit flag is required.
run: pnpm install --no-frozen-lockfile
- name: Build client and server
env:
SKIP_GO_GET: "true"
CONTAINER_IMAGE_TAG: ${{ steps.ver.outputs.version }}
BUILDNUMBER: ${{ github.run_number }}
# Pin the embedded commit to the full SHA so it matches the image
# GIT_COMMIT build-arg and does not depend on the shallow checkout.
GIT_COMMIT_HASH: ${{ github.sha }}
# ENV=production selects webpack/webpack.production.js (minified bundle),
# matching the official CE image; the Makefile default is development.
run: make build-all ENV=production
- name: Ensure storybook directory exists
# make build-all does not produce dist/storybook, but alpine.Dockerfile
# has `COPY dist/storybook* /storybook/`; without a match the docker build fails.
run: mkdir -p dist/storybook
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push image (linux/amd64, alpine base)
uses: docker/build-push-action@v6
with:
context: .
file: build/linux/alpine.Dockerfile
platforms: linux/amd64
push: true
tags: |
${{ env.IMAGE }}:${{ steps.ver.outputs.version }}
${{ env.IMAGE }}:latest
build-args: |
GIT_COMMIT=${{ github.sha }}

1
.gitignore vendored
View File

@@ -4,6 +4,7 @@ dist
portainer-checksum.txt
api/cmd/portainer/portainer*
storybook-static
debug-storybook.log
.tmp
**/.vscode/settings.json
**/.vscode/tasks.json

View File

@@ -1,4 +1,4 @@
version: "2"
version: '2'
linters:
default: none
enable:

View File

@@ -1,10 +1,11 @@
version: "2"
version: '2'
run:
allow-parallel-runners: true
linters:
default: none
enable:
- gocritic
- bodyclose
- copyloopvar
- depguard
@@ -31,7 +32,7 @@ linters:
- exptostd
settings:
staticcheck:
checks: ["all", "-ST1003", "-ST1005", "-ST1016", "-SA1019", "-QF1003"]
checks: ['all', '-ST1003', '-ST1005', '-ST1016', '-SA1019', '-QF1003']
depguard:
rules:
main:
@@ -76,6 +77,13 @@ linters:
desc: use github.com/Masterminds/semver/v3
- pkg: github.com/hashicorp/go-version
desc: use github.com/Masterminds/semver/v3
gocritic:
disable-all: true
enabled-checks:
- ruleguard
settings:
ruleguard:
rules: './analysis/ssrf.go,./analysis/git.go'
forbidigo:
forbid:
- pattern: ^tls\.Config$
@@ -83,9 +91,11 @@ linters:
- pattern: ^tls\.Config\.(InsecureSkipVerify|MinVersion|MaxVersion|CipherSuites|CurvePreferences)$
msg: Do not set this field directly, use crypto.CreateTLSConfiguration() instead
- pattern: ^object\.(Commit|Tag)\.Verify$
msg: "Not allowed because of FIPS mode"
msg: 'Not allowed because of FIPS mode'
- pattern: ^(types\.SystemContext\.)?(DockerDaemonInsecureSkipTLSVerify|DockerInsecureSkipTLSVerify|OCIInsecureSkipTLSVerify)$
msg: "Not allowed because of FIPS mode"
msg: 'Not allowed because of FIPS mode'
- pattern: ^git\.PlainClone(Context|WithOptions)?$
msg: Use git.CloneContext with NewNoSymlinkFS to prevent symlink traversal attacks
analyze-types: true
exclusions:
generated: lax
@@ -93,6 +103,14 @@ linters:
- comments
- common-false-positives
- legacy
rules:
- path: pkg/libhttp/ssrf
linters:
- gocritic
text: ruleguard
- path: pkg/libhttp/ssrf/builder\.go
linters:
- forbidigo
paths:
- third_party$
- builtin$

View File

@@ -1,3 +1,5 @@
dist
api/datastore/test_data
coverage
coverage
pnpm-lock.yaml

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

@@ -8,19 +8,19 @@ In the interest of fostering an open and welcoming environment, we as contributo
Examples of behavior that contributes to creating a positive environment include:
* Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members
- Using welcoming and inclusive language
- Being respectful of differing viewpoints and experiences
- Gracefully accepting constructive criticism
- Focusing on what is best for the community
- Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
* The use of sexualized language or imagery and unwelcome sexual attention or advances
* Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or electronic address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a professional setting
- The use of sexualized language or imagery and unwelcome sexual attention or advances
- Trolling, insulting/derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or electronic address, without explicit permission
- Other conduct which could reasonably be considered inappropriate in a professional setting
## Our Responsibilities
@@ -34,7 +34,7 @@ This Code of Conduct applies both within project spaces and in public spaces whe
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at anthony.lapenna@portainer.io. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately.
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at contribute@portainer.io. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately.
Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership.

View File

@@ -147,7 +147,9 @@ When adding a new route to an existing handler use the following as a template (
// @router /{id} [get]
```
explanation about each line can be found (here)[https://github.com/swaggo/swag#api-operation]
explanation about each line can be found [here](https://github.com/swaggo/swag#api-operation)
After changing these annotations, regenerate the TypeScript API client and types — see [Generating API types](./README.md#generating-api-types).
## Licensing

View File

@@ -3,9 +3,10 @@ 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)
GOLANGCI_LINT_VERSION := $(shell cat $(shell git rev-parse --show-toplevel)/.golangci-version)
# Don't change anything below this line unless you know what you're doing
.DEFAULT_GOAL := help
@@ -36,8 +37,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
@@ -90,13 +91,25 @@ format-server: ## Format server code
go fmt ./...
##@ Lint
.PHONY: lint lint-client lint-server
.PHONY: lint lint-client lint-server check-lint-version
lint: lint-client lint-server ## Lint all code
lint-client: ## Lint client code
pnpm run lint
lint-server: tidy ## Lint server code
check-lint-version:
@installed=v$$(golangci-lint --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1); \
if [ "$$installed" = "v" ]; then \
echo "ERROR: golangci-lint not found, need $(GOLANGCI_LINT_VERSION)"; \
echo "Install: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION)"; \
exit 1; \
elif [ "$$installed" != "$(GOLANGCI_LINT_VERSION)" ]; then \
echo "ERROR: golangci-lint $$installed installed, need $(GOLANGCI_LINT_VERSION)"; \
echo "Install: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION)"; \
exit 1; \
fi
lint-server: tidy check-lint-version ## Lint server code
golangci-lint run --timeout=10m -c .golangci.yaml
golangci-lint run --timeout=10m --new-from-rev=HEAD~ -c .golangci-forward.yaml
@@ -108,8 +121,8 @@ 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
cd api && $(SWAG) init -o "../dist/docs" -ot "yaml" -g ./http/handler/handler.go --parseDependency --parseInternal --parseDepth 2 -p pascalcase --markdownFiles ./
go mod download
cd api && $(SWAG) init -o "../dist/docs" -ot "yaml" -g ./http/handler/handler.go --parseDependency --parseInternal --parseDepth 2 -p pascalcase --markdownFiles ./ --overridesFile .swaggo
docs-validate: docs-build ## Validate docs
pnpm swagger2openapi --warnOnly dist/docs/swagger.yaml -o dist/docs/openapi.yaml
@@ -121,6 +134,10 @@ docs-serve: docs-build ## Serve docs locally with Swagger UI on port 8080
-e SWAGGER_JSON=/foo/swagger.yaml \
-v $(PWD)/dist/docs:/foo \
swaggerapi/swagger-ui
.PHONY: generate-api
generate-api: docs-validate ## Generate API client and types from OpenAPI spec
pnpm generate-api
##@ Helpers
.PHONY: help

View File

@@ -44,6 +44,32 @@ You can join the Portainer Community by visiting [https://www.portainer.io/join-
- Want to report a bug or request a feature? Please open [an issue](https://github.com/portainer/portainer/issues/new).
- Want to help us build **_portainer_**? Follow our [contribution guidelines](https://docs.portainer.io/contribute/contribute) to build it locally and make a pull request.
## Generating API types
The frontend consumes a TypeScript API client (SDK functions and request/response types) that is generated from the Go API's Swagger annotations. Regenerate it after any API change — a new endpoint, a changed request/response shape, or a removed endpoint:
```bash
make generate-api
```
This runs the following pipeline:
```
Go Swagger annotations
→ dist/docs/swagger.yaml (make docs-build, via swaggo/swag)
→ dist/docs/openapi.yaml (swagger2openapi + validation)
→ app/react/portainer/generated-api/portainer/ (hey-api/openapi-ts)
```
The generator is configured in [`openapi-ts.config.ts`](./openapi-ts.config.ts), which controls the output path, plugins, and tag filters (for example, `deprecated` endpoints and `edge_agent`-tagged routes are excluded).
The generated files live in `app/react/portainer/generated-api/portainer/` and must **not** be edited by hand — your changes would be overwritten on the next run. Import the generated SDK functions and types instead of writing direct HTTP calls:
- `@api/sdk.gen` — SDK functions
- `@api/types.gen` — request/response types
See [Adding api docs](./CONTRIBUTING.md#adding-api-docs) for how to annotate handlers so they are picked up by the generator.
## Security
For information about reporting security vulnerabilities, please see our [Security Policy](SECURITY.md).

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

18
analysis/git.go Normal file
View File

@@ -0,0 +1,18 @@
//go:build ignore
package gorules
import "github.com/quasilyte/go-ruleguard/dsl"
// inMemoryCloneWithWorktree flags git clone calls that use memory.NewStorage() as
// the storer while also writing files to a real worktree. This holds all git objects
// in heap for the duration of the clone, which is unbounded for user-supplied repos.
func inMemoryCloneWithWorktree(m dsl.Matcher) {
m.Match(`git.CloneContext($_, memory.NewStorage(), $wt, $_)`).
Where(m["wt"].Text != "nil").
Report(`git.CloneContext with memory.NewStorage() holds all git objects in heap; use gogitfs.NewStorage with a filesystem storer instead`)
m.Match(`git.Clone(memory.NewStorage(), $wt, $_)`).
Where(m["wt"].Text != "nil").
Report(`git.Clone with memory.NewStorage() holds all git objects in heap; use gogitfs.NewStorage with a filesystem storer instead`)
}

75
analysis/ssrf.go Normal file
View File

@@ -0,0 +1,75 @@
//go:build ignore
package gorules
import "github.com/quasilyte/go-ruleguard/dsl"
// unwrappedHTTPTransport flags any bare http.Transport composite literal.
// All transports must be created via ssrf.NewTransport or ssrf.NewInternalTransport,
// which clone http.DefaultTransport and handle SSRF protection internally.
func unwrappedHTTPTransport(m dsl.Matcher) {
m.Match(`$f(&http.Transport{$*_})`).
Report(`$f receives a bare *http.Transport; use ssrf.NewTransport(tlsConfig) or ssrf.NewInternalTransport(tlsConfig) instead`)
m.Match(`$_ := &http.Transport{$*_}`).
Report(`bare *http.Transport variable; use ssrf.NewTransport(tlsConfig) or ssrf.NewInternalTransport(tlsConfig) instead`)
m.Match(`$_.Transport = &http.Transport{$*_}`).
Report(`bare *http.Transport field assignment; use ssrf.NewTransport(tlsConfig) or ssrf.NewInternalTransport(tlsConfig) instead`)
}
// helmGetterTransport flags getter.WithTransport calls that receive a bare *http.Transport.
// Helm v4 installs its own transport and bypasses http.DefaultTransport, so the transport
// passed here must be created via ssrf.NewTransport.
func helmGetterTransport(m dsl.Matcher) {
m.Match(`getter.WithTransport(&http.Transport{$*_})`).
Report(`getter.WithTransport called with a bare *http.Transport; use ssrf.NewTransport(tlsConfig) as Helm v4 bypasses http.DefaultTransport`)
}
// cloneDefaultTransport flags direct clones of *http.Transport outside main.go.
// The one legitimate clone is in main.go where http.DefaultTransport is globally
// wrapped with SSRF protection at server startup.
func cloneDefaultTransport(m dsl.Matcher) {
m.Match(`$_.(*http.Transport).Clone()`).
Where(!m.File().Name.Matches(`^main\.go$`)).
Report(`cloning *http.Transport directly is forbidden; use ssrf.NewTransport(tlsConfig) or ssrf.NewInternalTransport(tlsConfig) instead`)
}
// internalTransportMisuse flags calls to NewInternalTransport outside the proxy
// factory files where Chisel-tunnel and in-cluster K8s destinations are valid exemptions.
func internalTransportMisuse(m dsl.Matcher) {
m.Match(`ssrf.NewInternalTransport($*_)`).
Where(
!(m.File().PkgPath.Matches(`proxy/factory`) &&
m.File().Name.Matches(`^(docker|agent|local_transport|edge_transport|docker_unix|docker_windows)\.go$`))).
Report(`NewInternalTransport bypasses SSRF validation; only valid in the proxy factory files for local sockets and internally-routed endpoints`)
}
// dialerOverride flags direct assignments to any of the dialer fields on a transport.
// The only valid assignments are in docker_unix.go and docker_windows.go where a
// custom dialer is required for unix sockets and named pipes.
func dialerOverride(m dsl.Matcher) {
m.Match(`$_.DialContext = $*_`).
Where(
!(m.File().PkgPath.Matches(`proxy/factory`) &&
m.File().Name.Matches(`^(docker_unix|docker_windows)\.go$`))).
Report(`direct DialContext assignment replaces the transport dialer; use ssrf.NewTransport or ssrf.NewInternalTransport instead`)
m.Match(`$_.Dial = $*_`).
Where(
!(m.File().PkgPath.Matches(`proxy/factory`) &&
m.File().Name.Matches(`^(docker_unix|docker_windows)\.go$`))).
Report(`direct Dial assignment replaces the transport dialer; use ssrf.NewTransport or ssrf.NewInternalTransport instead`)
m.Match(`$_.DialTLSContext = $*_`).
Where(
!(m.File().PkgPath.Matches(`proxy/factory`) &&
m.File().Name.Matches(`^(docker_unix|docker_windows)\.go$`))).
Report(`direct DialTLSContext assignment replaces the transport dialer; use ssrf.NewTransport or ssrf.NewInternalTransport instead`)
m.Match(`$_.DialTLS = $*_`).
Where(
!(m.File().PkgPath.Matches(`proxy/factory`) &&
m.File().Name.Matches(`^(docker_unix|docker_windows)\.go$`))).
Report(`direct DialTLS assignment replaces the transport dialer; use ssrf.NewTransport or ssrf.NewInternalTransport instead`)
}

5
analysis/tools.go Normal file
View File

@@ -0,0 +1,5 @@
//go:build tools
package gorules
import _ "github.com/quasilyte/go-ruleguard/dsl"

1
api/.swaggo Normal file
View File

@@ -0,0 +1 @@
replace k8s.io/apimachinery/pkg/apis/meta/v1.Duration string

View File

@@ -1,6 +1,7 @@
package agent
import (
"context"
"crypto/tls"
"errors"
"fmt"
@@ -11,6 +12,7 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/url"
"github.com/portainer/portainer/pkg/libhttp/ssrf"
"github.com/rs/zerolog/log"
)
@@ -19,10 +21,14 @@ import (
//
// it sends a ping to the agent and parses the version and platform from the headers
func GetAgentVersionAndPlatform(endpointUrl string, tlsConfig *tls.Config) (portainer.AgentPlatform, string, error) { //nolint:forbidigo
if err := ssrf.CheckURL(context.Background(), endpointUrl); err != nil {
return 0, "", err
}
httpCli := &http.Client{Timeout: 3 * time.Second}
if tlsConfig != nil {
httpCli.Transport = &http.Transport{TLSClientConfig: tlsConfig}
httpCli.Transport = ssrf.NewTransport(tlsConfig)
}
parsedURL, err := url.ParseURL(endpointUrl + "/ping")

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,32 @@ 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")
if service.dataStore.IsErrObjectNotFound(err) {
service.close(endpointID)
}
return false
}
return true
}
func (service *Service) markSnapshotTaken(endpointID portainer.EndpointID) {
service.mu.Lock()
defer service.mu.Unlock()
if tun, ok := service.activeTunnels[endpointID]; ok {
tun.HasSnapshot = true
}
}
func (service *Service) snapshotEnvironment(endpointID portainer.EndpointID, tunnelPort int) error {
endpoint, err := service.dataStore.Endpoint().Endpoint(endpointID)
if err != nil {

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,158 @@ 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 TestCheckTunnelsClosesStaleEntryForDeletedEndpoint(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
// Endpoint is not created in the store, simulates deletion while tunnel stays open.
s := NewService(store, nil, nil)
s.activeTunnels[1] = &portainer.TunnelDetails{
Status: portainer.EdgeAgentManagementRequired,
Port: 50010,
LastActivity: time.Now(),
}
s.checkTunnels()
require.Nil(t, s.activeTunnels[1], "stale tunnel for deleted endpoint must be removed immediately")
}
func TestCheckTunnelsClosesIdleTunnelAndSnapshots(t *testing.T) {
t.Parallel()
endpoint := newEdgeEndpoint(5)
_, store := datastore.MustNewTestStore(t, false, true)
err := store.Endpoint().Create(endpoint)
require.NoError(t, err)
snapshotCalled := false
svc := &mockSnapshotService{
snapshotFn: func(_ *portainer.Endpoint) error {
snapshotCalled = true
return nil
},
}
s := NewService(store, nil, nil)
s.snapshotService = svc
s.activeTunnels[endpoint.ID] = &portainer.TunnelDetails{
Status: portainer.EdgeAgentManagementRequired,
Port: 50002,
LastActivity: time.Now().Add(-(activeTimeout + time.Second)),
}
s.checkTunnels()
require.True(t, snapshotCalled)
require.Nil(t, s.activeTunnels[endpoint.ID], "tunnel must be closed after idle timeout")
}

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"
@@ -81,17 +82,24 @@ func (s *Service) Open(endpoint *portainer.Endpoint) error {
return nil
}
// close removes the tunnel from the map so the agent will close it
// close removes the tunnel from the map so the agent will close it.
// The lock is released before cleaning up the chisel user and proxy to avoid
// blocking Config/Open callers while DeleteUser interacts with chisel internals.
func (s *Service) close(endpointID portainer.EndpointID) {
s.mu.Lock()
defer s.mu.Unlock()
tun, ok := s.activeTunnels[endpointID]
if !ok {
s.mu.Unlock()
return
}
if len(tun.Credentials) > 0 && s.chiselServer != nil {
delete(s.activeTunnels, endpointID)
cache.Del(endpointID)
s.mu.Unlock()
if s.chiselServer != nil {
user, _, _ := strings.Cut(tun.Credentials, ":")
s.chiselServer.DeleteUser(user)
}
@@ -99,10 +107,6 @@ func (s *Service) close(endpointID portainer.EndpointID) {
if s.ProxyManager != nil {
s.ProxyManager.DeleteEndpointProxy(endpointID)
}
delete(s.activeTunnels, endpointID)
cache.Del(endpointID)
}
// Config returns the tunnel details needed for the agent to connect
@@ -237,3 +241,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

@@ -56,6 +56,8 @@ func CLIFlags() *portainer.CLIFlags {
TrustedOrigins: kingpin.Flag("trusted-origins", "List of trusted origins for CSRF protection. Separate multiple origins with a comma.").Envar(portainer.TrustedOriginsEnvVar).String(),
CSP: kingpin.Flag("csp", "Content Security Policy (CSP) header").Envar(portainer.CSPEnvVar).Default("true").Bool(),
CompactDB: kingpin.Flag("compact-db", "Enable database compaction on startup").Envar(portainer.CompactDBEnvVar).Default("false").Bool(),
NoSetupToken: kingpin.Flag("no-setup-token", "Disable the setup token requirement for admin initialization and restore on an uninitialized instance").Envar(portainer.NoSetupTokenEnvVar).Bool(),
SetupToken: kingpin.Flag("setup-token", "Set a custom setup token for admin initialization and restore on an uninitialized instance (overrides auto-generation)").Envar(portainer.SetupTokenEnvVar).String(),
}
}
@@ -94,13 +96,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

@@ -4,6 +4,7 @@ import (
"cmp"
"context"
"crypto/sha256"
nethttp "net/http"
"os"
"path"
"strings"
@@ -26,10 +27,10 @@ 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"
"github.com/portainer/portainer/api/http/security/setuptoken"
"github.com/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/internal/edge/edgestacks"
"github.com/portainer/portainer/api/internal/endpointutils"
@@ -52,9 +53,15 @@ import (
"github.com/portainer/portainer/pkg/featureflags"
"github.com/portainer/portainer/pkg/fips"
"github.com/portainer/portainer/pkg/libhelm"
"github.com/portainer/portainer/pkg/libhttp/ssrf"
"github.com/portainer/portainer/pkg/libstack/compose"
libswarm "github.com/portainer/portainer/pkg/libstack/swarm"
"github.com/portainer/portainer/pkg/validate"
gogitclient "github.com/go-git/go-git/v5/plumbing/transport/client"
gogitraw "github.com/go-git/go-git/v5/plumbing/transport/git"
gogithttp "github.com/go-git/go-git/v5/plumbing/transport/http"
gogitssh "github.com/go-git/go-git/v5/plumbing/transport/ssh"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
)
@@ -225,6 +232,32 @@ func initSnapshotService(
return snapshotService, nil
}
func resolveSetupToken(tx dataservices.DataStoreTx, providedToken string) (string, error) {
admins, err := tx.User().UsersByRole(portainer.AdministratorRole)
if err != nil {
return "", err
}
if len(admins) > 0 {
return "", nil
}
if providedToken != "" {
log.Info().Msg("using custom setup token; admin initialization and backup restore require this token in the X-Setup-Token header")
return providedToken, nil
}
token, err := setuptoken.Generate()
if err != nil {
return "", err
}
log.Info().
Str("setup_token", token).
Msg("no administrator account configured; admin initialization and backup restore require this setup token in the X-Setup-Token header. Start with --no-setup-token to disable.")
return token, nil
}
func initStatus(instanceID string) *portainer.Status {
return &portainer.Status{
Version: portainer.APIVersion,
@@ -243,6 +276,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 +371,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)
}
@@ -371,6 +407,19 @@ func buildServer(flags *portainer.CLIFlags, shutdownCtx context.Context, shutdow
log.Fatal().Msg("The database schema version does not align with the server version. Please consider reverting to the previous server version or addressing the database migration issue.")
}
if err := ssrf.Configure(dataStore.AllowList()); err != nil {
log.Fatal().Err(err).Msg("failed initializing ssrf service")
}
if !ssrf.WrapDefaultTransport() {
log.Fatal().Msg("failed to wrap default HTTP transport with SSRF protection")
}
gogithttp.DefaultClient = gogithttp.NewClient(&nethttp.Client{Transport: nethttp.DefaultTransport})
gogitclient.InstallProtocol("git", git.NewSSRFGitTransport(gogitraw.DefaultClient))
gogitclient.InstallProtocol("ssh", git.NewSSRFGitTransport(gogitssh.DefaultClient))
gogitclient.InstallProtocol("file", nil)
instanceID, err := dataStore.Version().InstanceID()
if err != nil {
log.Fatal().Err(err).Msg("failed getting instance id")
@@ -394,9 +443,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 +483,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)
@@ -515,6 +556,17 @@ func buildServer(flags *portainer.CLIFlags, shutdownCtx context.Context, shutdow
}
}
setupToken := ""
if adminPasswordHash == "" && !*flags.NoSetupToken {
if err := dataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
var txErr error
setupToken, txErr = resolveSetupToken(tx, *flags.SetupToken)
return txErr
}); err != nil {
log.Fatal().Err(err).Msg("failed initializing setup token")
}
}
if err := reverseTunnelService.StartTunnelServer(*flags.TunnelAddr, *flags.TunnelPort, snapshotService); err != nil {
log.Fatal().Err(err).Msg("failed starting tunnel server")
}
@@ -589,7 +641,6 @@ func buildServer(flags *portainer.CLIFlags, shutdownCtx context.Context, shutdow
LDAPService: ldapService,
OAuthService: oauthService,
GitService: gitService,
OpenAMTService: openAMTService,
ProxyManager: proxyManager,
KubernetesTokenCacheManager: kubernetesTokenCacheManager,
KubeClusterAccessService: kubeClusterAccessService,
@@ -607,6 +658,7 @@ func buildServer(flags *portainer.CLIFlags, shutdownCtx context.Context, shutdow
PlatformService: platformService,
PullLimitCheckDisabled: *flags.PullLimitCheckDisabled,
TrustedOrigins: trustedOrigins,
SetupToken: setupToken,
}
}

View File

@@ -4,16 +4,56 @@ import (
"os"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/filesystem"
"github.com/portainer/portainer/api/internal/testhelpers"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_resolveSetupToken(t *testing.T) {
t.Parallel()
t.Run("admin already exists — returns empty token", func(t *testing.T) {
admin := portainer.User{Role: portainer.AdministratorRole}
store := testhelpers.NewDatastore(testhelpers.WithUsers([]portainer.User{admin}))
token, err := resolveSetupToken(store, "")
require.NoError(t, err)
assert.Empty(t, token)
})
t.Run("no admin — generates a 64-char hex token", func(t *testing.T) {
store := testhelpers.NewDatastore(testhelpers.WithUsers([]portainer.User{}))
token, err := resolveSetupToken(store, "")
require.NoError(t, err)
assert.Len(t, token, 64)
token2, err := resolveSetupToken(store, "")
require.NoError(t, err)
assert.NotEqual(t, token, token2)
})
t.Run("no admin — uses provided token", func(t *testing.T) {
store := testhelpers.NewDatastore(testhelpers.WithUsers([]portainer.User{}))
token, err := resolveSetupToken(store, "mysecrettoken")
require.NoError(t, err)
assert.Equal(t, "mysecrettoken", token)
})
t.Run("admin already exists — ignores provided token", func(t *testing.T) {
admin := portainer.User{Role: portainer.AdministratorRole}
store := testhelpers.NewDatastore(testhelpers.WithUsers([]portainer.User{admin}))
token, err := resolveSetupToken(store, "mysecrettoken")
require.NoError(t, err)
assert.Empty(t, token)
})
}
const secretFileName = "secret.txt"
func createPasswordFile(t *testing.T, secretPath, password string) string {
err := os.WriteFile(secretPath, []byte(password), 0600)
err := os.WriteFile(secretPath, []byte(password), 0o600)
require.NoError(t, err)
return secretPath
}
@@ -40,6 +80,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

@@ -46,7 +46,7 @@ type Connection interface {
IsEncryptedStore() bool
NeedsEncryptionMigration() (bool, error)
SetEncrypted(encrypted bool)
SetEncrypted(encrypted bool) error
BackupMetadata() (map[string]any, error)
RestoreMetadata(s map[string]any) error

View File

@@ -4,6 +4,7 @@ import (
"crypto/rand"
"errors"
"io"
"slices"
)
type Nonce struct {
@@ -45,7 +46,7 @@ func (n *Nonce) Value() []byte {
func (n *Nonce) Increment() error {
// Start incrementing from the least significant byte
for i := len(n.val) - 1; i >= 0; i-- {
for i := range slices.Backward(n.val) {
// Increment the current byte
n.val[i]++

View File

@@ -1,6 +1,8 @@
package boltdb
import (
"crypto/aes"
"crypto/cipher"
"encoding/binary"
"errors"
"fmt"
@@ -40,6 +42,8 @@ type DbConnection struct {
isEncrypted bool
Compact bool
gcm cipher.AEAD
*bolt.DB
}
@@ -75,8 +79,28 @@ func (connection *DbConnection) GetDatabaseFileSize() (int64, error) {
return file.Size(), nil
}
func (connection *DbConnection) SetEncrypted(flag bool) {
func (connection *DbConnection) SetEncrypted(flag bool) error {
connection.isEncrypted = flag
if !flag || connection.EncryptionKey == nil {
connection.gcm = nil
return nil
}
block, err := aes.NewCipher(connection.EncryptionKey)
if err != nil {
return fmt.Errorf("creating AES cipher for database encryption: %w", err)
}
gcm, err := cipher.NewGCMWithRandomNonce(block)
if err != nil {
return fmt.Errorf("creating GCM cipher for database encryption: %w", err)
}
connection.gcm = gcm
return nil
}
// Return true if the database is encrypted
@@ -100,7 +124,9 @@ func (connection *DbConnection) NeedsEncryptionMigration() (bool, error) {
// If we have a loaded encryption key, always set encrypted
if connection.EncryptionKey != nil {
connection.SetEncrypted(true)
if err := connection.SetEncrypted(true); err != nil {
return false, err
}
}
// Check for portainer.db

View File

@@ -131,7 +131,7 @@ func Test_NeedsEncryptionMigration(t *testing.T) {
}
if tc.key {
connection.EncryptionKey = []byte("secret")
connection.EncryptionKey = secretToEncryptionKey("secret")
}
result, err := connection.NeedsEncryptionMigration()
@@ -142,6 +142,57 @@ func Test_NeedsEncryptionMigration(t *testing.T) {
}
}
func TestSetEncrypted_InvalidKeyReturnsError(t *testing.T) {
t.Parallel()
conn := DbConnection{EncryptionKey: []byte("bad")}
err := conn.SetEncrypted(true)
require.Error(t, err)
require.Nil(t, conn.gcm)
}
func TestSetEncrypted_NilKeyDoesNotSetGCM(t *testing.T) {
t.Parallel()
conn := DbConnection{}
err := conn.SetEncrypted(true)
require.NoError(t, err)
require.Nil(t, conn.gcm)
}
func TestSetEncrypted_EnableThenDisableStopsEncryption(t *testing.T) {
t.Parallel()
key := secretToEncryptionKey(passphrase)
conn := DbConnection{EncryptionKey: key}
err := conn.SetEncrypted(true)
require.NoError(t, err)
require.NotNil(t, conn.gcm)
err = conn.SetEncrypted(false)
require.NoError(t, err)
require.Nil(t, conn.gcm)
// MarshalObject must return plaintext after encryption is disabled
data, err := conn.MarshalObject("hello")
require.NoError(t, err)
require.Equal(t, "hello", string(data))
}
func TestNeedsEncryptionMigration_InvalidKeyError(t *testing.T) {
t.Parallel()
conn := DbConnection{
Path: t.TempDir(),
EncryptionKey: []byte("bad"),
}
result, err := conn.NeedsEncryptionMigration()
require.Error(t, err)
require.False(t, result)
}
func TestDBCompaction(t *testing.T) {
t.Parallel()
db := &DbConnection{Path: t.TempDir()}

View File

@@ -2,7 +2,6 @@ package boltdb
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"github.com/pkg/errors"
@@ -28,18 +27,18 @@ func (connection *DbConnection) MarshalObject(object any) ([]byte, error) {
}
}
if connection.getEncryptionKey() == nil {
if connection.gcm == nil {
return buf.Bytes(), nil
}
return encrypt(buf.Bytes(), connection.getEncryptionKey())
return encrypt(buf.Bytes(), connection.gcm), nil
}
// UnmarshalObject decodes an object from binary data
func (connection *DbConnection) UnmarshalObject(data []byte, object any) error {
var err error
if connection.getEncryptionKey() != nil {
data, err = decrypt(data, connection.getEncryptionKey())
if connection.gcm != nil {
data, err = decrypt(data, connection.gcm)
if err != nil {
return errors.Wrap(err, "Failed decrypting object")
}
@@ -59,48 +58,23 @@ func (connection *DbConnection) UnmarshalObject(data []byte, object any) error {
return err
}
// mmm, don't have a KMS .... aes GCM seems the most likely from
// https://gist.github.com/atoponce/07d8d4c833873be2f68c34f9afc5a78a#symmetric-encryption
func encrypt(plaintext []byte, passphrase []byte) (encrypted []byte, err error) {
block, err := aes.NewCipher(passphrase)
if err != nil {
return encrypted, err
}
// NewGCMWithRandomNonce in go 1.24 handles setting up the nonce and adding it to the encrypted output
gcm, err := cipher.NewGCMWithRandomNonce(block)
if err != nil {
return encrypted, err
}
return gcm.Seal(nil, nil, plaintext, nil), nil
func encrypt(plaintext []byte, gcm cipher.AEAD) []byte {
return gcm.Seal(nil, nil, plaintext, nil)
}
func decrypt(encrypted []byte, passphrase []byte) (plaintextByte []byte, err error) {
func decrypt(encrypted []byte, gcm cipher.AEAD) ([]byte, error) {
if string(encrypted) == "false" {
return []byte("false"), nil
}
block, err := aes.NewCipher(passphrase)
if err != nil {
return encrypted, errors.Wrap(err, "Error creating cypher block")
}
// NewGCMWithRandomNonce in go 1.24 handles reading the nonce from the encrypted input for us
gcm, err := cipher.NewGCMWithRandomNonce(block)
if err != nil {
return encrypted, errors.Wrap(err, "Error creating GCM")
}
if len(encrypted) < gcm.NonceSize() {
if len(encrypted) < gcm.Overhead() {
return encrypted, errEncryptedStringTooShort
}
plaintextByte, err = gcm.Open(nil, nil, encrypted, nil)
plaintextByte, err := gcm.Open(nil, nil, encrypted, nil)
if err != nil {
return encrypted, errors.Wrap(err, "Error decrypting text")
}
return plaintextByte, err
return plaintextByte, nil
}

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"
)
@@ -170,7 +170,10 @@ func Test_ObjectMarshallingEncrypted(t *testing.T) {
}
key := secretToEncryptionKey(passphrase)
conn := DbConnection{EncryptionKey: key, isEncrypted: true}
conn := DbConnection{EncryptionKey: key}
err := conn.SetEncrypted(true)
require.NoError(t, err)
for _, test := range tests {
t.Run(fmt.Sprintf("%s -> %s", test.object, test.expected), func(t *testing.T) {
@@ -232,13 +235,16 @@ func Test_NonceSources(t *testing.T) {
return plaintext, err
}
encryptNewFn := encrypt
decryptNewFn := decrypt
passphrase := make([]byte, 32)
_, err := io.ReadFull(rand.Reader, passphrase)
require.NoError(t, err)
block, err := aes.NewCipher(passphrase)
require.NoError(t, err)
gcm, err := cipher.NewGCMWithRandomNonce(block)
require.NoError(t, err)
junk := make([]byte, 1024)
_, err = io.ReadFull(rand.Reader, junk)
require.NoError(t, err)
@@ -263,13 +269,12 @@ func Test_NonceSources(t *testing.T) {
enc, err = encryptOldFn(plain, passphrase)
require.NoError(t, err)
dec, err = decryptNewFn(enc, passphrase)
dec, err = decrypt(enc, gcm)
require.NoError(t, err)
require.Equal(t, plain, dec)
enc, err = encryptNewFn(plain, passphrase)
require.NoError(t, err)
enc = encrypt(plain, gcm)
dec, err = decryptOldFn(enc, passphrase)
require.NoError(t, err)
@@ -277,3 +282,110 @@ func Test_NonceSources(t *testing.T) {
require.Equal(t, plain, dec)
}
}
func TestDecrypt_FalseStringBypassesDecryption(t *testing.T) {
t.Parallel()
key := secretToEncryptionKey(passphrase)
block, err := aes.NewCipher(key)
require.NoError(t, err)
gcm, err := cipher.NewGCMWithRandomNonce(block)
require.NoError(t, err)
result, err := decrypt([]byte("false"), gcm)
require.NoError(t, err)
require.Equal(t, []byte("false"), result)
}
func TestDecrypt_ShortDataReturnsError(t *testing.T) {
t.Parallel()
key := secretToEncryptionKey(passphrase)
block, err := aes.NewCipher(key)
require.NoError(t, err)
gcm, err := cipher.NewGCMWithRandomNonce(block)
require.NoError(t, err)
short := []byte("short")
result, err := decrypt(short, gcm)
require.ErrorIs(t, err, errEncryptedStringTooShort)
require.Equal(t, short, result)
}
func TestDecrypt_CorruptDataReturnsError(t *testing.T) {
t.Parallel()
key := secretToEncryptionKey(passphrase)
block, err := aes.NewCipher(key)
require.NoError(t, err)
gcm, err := cipher.NewGCMWithRandomNonce(block)
require.NoError(t, err)
// 30 bytes passes the length check but fails authentication
corrupted := make([]byte, 30)
_, err = io.ReadFull(rand.Reader, corrupted)
require.NoError(t, err)
result, err := decrypt(corrupted, gcm)
require.Error(t, err)
require.Equal(t, corrupted, result)
}
// BenchmarkEncryptCachedCipher measures the new approach: cipher created once and reused.
func BenchmarkEncryptCachedCipher(b *testing.B) {
key := secretToEncryptionKey(passphrase)
conn := DbConnection{EncryptionKey: key}
err := conn.SetEncrypted(true)
require.NoError(b, err)
data := []byte(jsonobject)
b.ResetTimer()
for b.Loop() {
_ = encrypt(data, conn.gcm)
}
}
// BenchmarkEncryptPerCallCipher measures the old approach: cipher created on every call.
func BenchmarkEncryptPerCallCipher(b *testing.B) {
key := secretToEncryptionKey(passphrase)
data := []byte(jsonobject)
b.ResetTimer()
for b.Loop() {
block, err := aes.NewCipher(key)
if err != nil {
b.Fatal(err)
}
gcm, err := cipher.NewGCMWithRandomNonce(block)
if err != nil {
b.Fatal(err)
}
_ = gcm.Seal(nil, nil, data, nil)
}
}
// BenchmarkEncryptCachedCipherParallel verifies the cached cipher is safe for concurrent use.
func BenchmarkEncryptCachedCipherParallel(b *testing.B) {
key := secretToEncryptionKey(passphrase)
conn := DbConnection{EncryptionKey: key}
err := conn.SetEncrypted(true)
require.NoError(b, err)
data := []byte(jsonobject)
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
_ = encrypt(data, conn.gcm)
}
})
}

View File

@@ -40,10 +40,10 @@ func (tx *DbTransaction) GetRawBytes(bucketName string, key []byte) ([]byte, err
return nil, fmt.Errorf("%w (bucket=%s, key=%s)", dserrors.ErrObjectNotFound, bucketName, keyToString(key))
}
if tx.conn.getEncryptionKey() != nil {
if tx.conn.gcm != nil {
var err error
if value, err = decrypt(value, tx.conn.getEncryptionKey()); err != nil {
if value, err = decrypt(value, tx.conn.gcm); err != nil {
return value, errors.Wrap(err, "Failed decrypting object")
}
}

View File

@@ -2,6 +2,7 @@ package boltdb
import (
"errors"
"strconv"
"testing"
portainer "github.com/portainer/portainer/api"
@@ -23,10 +24,10 @@ func TestTxs(t *testing.T) {
err := conn.Open()
require.NoError(t, err)
defer func() {
t.Cleanup(func() {
err := conn.Close()
require.NoError(t, err)
}()
})
// Error propagation
err = conn.UpdateTx(func(tx portainer.Transaction) error {
@@ -103,3 +104,57 @@ func TestTxs(t *testing.T) {
})
require.Error(t, err)
}
func BenchmarkGetAll(b *testing.B) {
const endpointBucket = "endpoints"
const n = 10000
conn := DbConnection{Path: b.TempDir()}
err := conn.Open()
require.NoError(b, err)
b.Cleanup(func() {
err := conn.Close()
require.NoError(b, err)
})
err = conn.UpdateTx(func(tx portainer.Transaction) error {
if err := tx.SetServiceName(endpointBucket); err != nil {
return err
}
for i := 1; i <= n; i++ {
ep := portainer.Endpoint{
ID: portainer.EndpointID(i),
Name: "env-" + strconv.Itoa(i),
Type: portainer.DockerEnvironment,
URL: "tcp://192.168.1." + strconv.Itoa(i%254+1) + ":2375",
PublicURL: "https://env-" + strconv.Itoa(i) + ".example.com",
GroupID: portainer.EndpointGroupID(i%10 + 1),
TagIDs: []portainer.TagID{portainer.TagID(i%5 + 1), portainer.TagID(i%3 + 1)},
LastCheckInDate: int64(i) * 1000,
EdgeID: "edge-" + strconv.Itoa(i),
}
if err := tx.CreateObjectWithId(endpointBucket, i, &ep); err != nil {
return err
}
}
return nil
})
require.NoError(b, err)
b.ResetTimer()
b.ReportAllocs()
for b.Loop() {
var collection []portainer.Endpoint
if err := conn.ViewTx(func(tx portainer.Transaction) error {
return tx.GetAll(endpointBucket, new(portainer.Endpoint), dataservices.AppendFn(&collection))
}); err != nil {
b.Fatal(err)
}
}
}

View File

@@ -0,0 +1,131 @@
package allowlist
import (
"fmt"
lru "github.com/hashicorp/golang-lru"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/pkg/libhttp/ssrf"
)
const (
BucketName = "allowlist"
)
type Service struct {
baseService dataservices.BaseDataService[portainer.AllowList, portainer.AllowListKey]
cache *lru.Cache
}
func (service *Service) BucketName() string {
return service.baseService.BucketName()
}
func NewService(connection portainer.Connection) (*Service, error) {
err := connection.SetServiceName(BucketName)
if err != nil {
return nil, err
}
service := &Service{
baseService: dataservices.BaseDataService[portainer.AllowList, portainer.AllowListKey]{
Bucket: BucketName,
Connection: connection,
},
}
err = service.populateCache()
return service, err
}
func (service *Service) populateCache() error {
allowListKeys := []portainer.AllowListKey{portainer.AllowListSSRF}
cache, err := lru.New(len(allowListKeys))
if err != nil {
return err
}
for _, k := range allowListKeys {
allowList, err := service.baseService.Read(k)
if dataservices.IsErrObjectNotFound(err) {
allowList = &portainer.AllowList{
ID: k,
Mode: portainer.SSRFModeOff,
Entries: []string{},
}
} else if err != nil {
return err
}
parsedAllowList := ssrf.ParseAllowedHosts(allowList.Entries)
parsedAllowList.Mode = allowList.Mode
cache.Add(k, &parsedAllowList)
}
service.cache = cache
return nil
}
func (service *Service) Tx(tx portainer.Transaction) *ServiceTx {
return &ServiceTx{
baseService: service.baseService.Tx(tx),
cache: service.cache,
}
}
func (service *Service) Read(id portainer.AllowListKey) (*portainer.AllowList, error) {
var result *portainer.AllowList
if err := service.baseService.Connection.ViewTx(func(tx portainer.Transaction) error {
var err error
result, err = service.Tx(tx).Read(id)
return err
}); err != nil {
return nil, err
}
return result, nil
}
func (service *Service) ReadAll() ([]portainer.AllowList, error) {
var result []portainer.AllowList
if err := service.baseService.Connection.ViewTx(func(tx portainer.Transaction) error {
var err error
result, err = service.Tx(tx).ReadAll()
return err
}); err != nil {
return nil, err
}
return result, nil
}
func (service *Service) ReadParsed(id portainer.AllowListKey) (*portainer.ParsedAllowList, error) {
allowListAny, ok := service.cache.Get(id)
if ok {
allowList, ok := allowListAny.(*portainer.ParsedAllowList)
if !ok {
return nil, fmt.Errorf("expected ParsedAllowList in cache but got %T", allowListAny)
}
return allowList, nil
}
var result *portainer.ParsedAllowList
err := service.baseService.Connection.ViewTx(func(tx portainer.Transaction) error {
var err error
result, err = service.Tx(tx).ReadParsed(id)
return err
})
return result, err
}
func (service *Service) Update(id portainer.AllowListKey, allowList *portainer.AllowList) error {
return service.baseService.Connection.UpdateTx(func(tx portainer.Transaction) error {
return service.Tx(tx).Update(id, allowList)
})
}

View File

@@ -0,0 +1,89 @@
package allowlist_test
import (
"net"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/datastore"
"github.com/stretchr/testify/require"
)
func TestAllowListReadEmpty(t *testing.T) {
t.Parallel()
_, ds := datastore.MustNewTestStore(t, false, false)
got, err := ds.AllowList().Read(portainer.AllowListSSRF)
expected := &portainer.AllowList{
ID: portainer.AllowListSSRF,
Mode: portainer.SSRFModeOff,
Entries: []string{},
}
require.NoError(t, err)
require.Equal(t, expected, got)
}
func TestAllowListUpdate(t *testing.T) {
t.Parallel()
_, ds := datastore.MustNewTestStore(t, false, false)
expected := &portainer.AllowList{
ID: portainer.AllowListSSRF,
Mode: portainer.SSRFModeEnforce,
Entries: []string{"example.com", "10.0.0.0/8"},
}
require.NoError(t, ds.AllowList().Update(portainer.AllowListSSRF, expected))
got, err := ds.AllowList().Read(portainer.AllowListSSRF)
require.NoError(t, err)
require.Equal(t, expected, got)
}
func TestAllowListReadAllEmpty(t *testing.T) {
t.Parallel()
_, ds := datastore.MustNewTestStore(t, false, false)
got, err := ds.AllowList().ReadAll()
require.NoError(t, err)
require.Equal(t, []portainer.AllowList{}, got)
}
func TestAllowListReadAllAfterUpdate(t *testing.T) {
t.Parallel()
_, ds := datastore.MustNewTestStore(t, false, false)
expected := portainer.AllowList{
ID: portainer.AllowListSSRF,
Mode: portainer.SSRFModeEnforce,
Entries: []string{"example.com", "10.0.0.0/8"},
}
require.NoError(t, ds.AllowList().Update(portainer.AllowListSSRF, &expected))
got, err := ds.AllowList().ReadAll()
require.NoError(t, err)
require.Equal(t, []portainer.AllowList{expected}, got)
}
func TestAllowListReadParsedAfterUpdate(t *testing.T) {
t.Parallel()
_, ds := datastore.MustNewTestStore(t, false, false)
require.NoError(t, ds.AllowList().Update(portainer.AllowListSSRF, &portainer.AllowList{
ID: portainer.AllowListSSRF,
Mode: portainer.SSRFModeEnforce,
Entries: []string{"example.com"},
}))
expected := &portainer.ParsedAllowList{
Mode: portainer.SSRFModeEnforce,
Nets: []*net.IPNet{},
Hosts: map[string]bool{
"example.com": true,
},
}
got, err := ds.AllowList().ReadParsed(portainer.AllowListSSRF)
require.NoError(t, err)
require.Equal(t, expected, got)
}

View File

@@ -0,0 +1,77 @@
package allowlist
import (
"fmt"
lru "github.com/hashicorp/golang-lru"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/pkg/libhttp/ssrf"
)
type ServiceTx struct {
baseService dataservices.BaseDataServiceTx[portainer.AllowList, portainer.AllowListKey]
cache *lru.Cache
}
func (service *ServiceTx) BucketName() string {
return service.baseService.BucketName()
}
func (service *ServiceTx) ReadParsed(id portainer.AllowListKey) (*portainer.ParsedAllowList, error) {
allowListAny, ok := service.cache.Get(id)
if ok {
allowList, ok := allowListAny.(*portainer.ParsedAllowList)
if !ok {
return nil, fmt.Errorf("expected ParsedAllowList in cache but got %T", allowListAny)
}
return allowList, nil
}
allowList, err := service.Read(id)
if err != nil {
return nil, err
}
parsed := ssrf.ParseAllowedHosts(allowList.Entries)
parsed.Mode = allowList.Mode
service.cache.Add(id, &parsed)
return &parsed, nil
}
func (service *ServiceTx) Read(id portainer.AllowListKey) (*portainer.AllowList, error) {
allowList, err := service.baseService.Read(id)
if dataservices.IsErrObjectNotFound(err) {
allowList = &portainer.AllowList{
ID: id,
Mode: portainer.SSRFModeOff,
Entries: []string{},
}
} else if err != nil {
return nil, err
}
return allowList, nil
}
func (service *ServiceTx) ReadAll() ([]portainer.AllowList, error) {
allowLists, err := service.baseService.ReadAll()
if err != nil && !dataservices.IsErrObjectNotFound(err) {
return nil, err
}
return allowLists, nil
}
func (service *ServiceTx) Update(id portainer.AllowListKey, allowList *portainer.AllowList) error {
if err := service.baseService.Update(id, allowList); err != nil {
return err
}
parsed := ssrf.ParseAllowedHosts(allowList.Entries)
parsed.Mode = allowList.Mode
service.cache.Add(id, &parsed)
return nil
}

View File

@@ -0,0 +1,92 @@
package allowlist_test
import (
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/datastore"
"github.com/stretchr/testify/require"
)
func TestAllowListReadTx(t *testing.T) {
t.Parallel()
_, ds := datastore.MustNewTestStore(t, false, false)
var got *portainer.AllowList
require.NoError(t, ds.ViewTx(func(tx dataservices.DataStoreTx) error {
var err error
got, err = tx.AllowList().Read(portainer.AllowListSSRF)
return err
}))
expected := &portainer.AllowList{
ID: portainer.AllowListSSRF,
Mode: portainer.SSRFModeOff,
Entries: []string{},
}
require.Equal(t, expected, got)
}
func TestAllowListReadAllEmptyTx(t *testing.T) {
t.Parallel()
_, ds := datastore.MustNewTestStore(t, false, false)
var got []portainer.AllowList
require.NoError(t, ds.ViewTx(func(tx dataservices.DataStoreTx) error {
var err error
got, err = tx.AllowList().ReadAll()
return err
}))
require.Equal(t, []portainer.AllowList{}, got)
}
func TestAllowListReadAllAfterUpdateTx(t *testing.T) {
t.Parallel()
_, ds := datastore.MustNewTestStore(t, false, false)
expected := portainer.AllowList{
ID: portainer.AllowListSSRF,
Mode: portainer.SSRFModeEnforce,
Entries: []string{"example.com"},
}
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
return tx.AllowList().Update(portainer.AllowListSSRF, &expected)
}))
var got []portainer.AllowList
require.NoError(t, ds.ViewTx(func(tx dataservices.DataStoreTx) error {
var err error
got, err = tx.AllowList().ReadAll()
return err
}))
require.Equal(t, []portainer.AllowList{expected}, got)
}
func TestAllowListUpdateTx(t *testing.T) {
t.Parallel()
_, ds := datastore.MustNewTestStore(t, false, false)
expected := &portainer.AllowList{
ID: portainer.AllowListSSRF,
Mode: portainer.SSRFModeEnforce,
Entries: []string{"example.com"},
}
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
return tx.AllowList().Update(portainer.AllowListSSRF, expected)
}))
var got *portainer.AllowList
require.NoError(t, ds.ViewTx(func(tx dataservices.DataStoreTx) error {
var err error
got, err = tx.AllowList().Read(portainer.AllowListSSRF)
return err
}))
require.Equal(t, expected, got)
}

View File

@@ -2,13 +2,10 @@ package apikeyrepository
import (
"errors"
"fmt"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
dserrors "github.com/portainer/portainer/api/dataservices/errors"
"github.com/rs/zerolog/log"
)
// BucketName represents the name of the bucket where this service stores data.
@@ -40,19 +37,10 @@ func (service *Service) GetAPIKeysByUserID(userID portainer.UserID) ([]portainer
err := service.Connection.GetAll(
BucketName,
&portainer.APIKey{},
func(obj any) (any, error) {
record, ok := obj.(*portainer.APIKey)
if !ok {
log.Debug().Str("obj", fmt.Sprintf("%#v", obj)).Msg("failed to convert to APIKey object")
return nil, fmt.Errorf("failed to convert to APIKey object: %s", obj)
}
if record.UserID == userID {
result = append(result, *record)
}
return &portainer.APIKey{}, nil
})
dataservices.FilterFn(&result, func(record portainer.APIKey) bool {
return record.UserID == userID
}),
)
return result, err
}
@@ -60,27 +48,18 @@ func (service *Service) GetAPIKeysByUserID(userID portainer.UserID) ([]portainer
// GetAPIKeyByDigest returns the API key for the associated digest.
// Note: there is a 1-to-1 mapping of api-key and digest
func (service *Service) GetAPIKeyByDigest(digest string) (*portainer.APIKey, error) {
var k *portainer.APIKey
stop := errors.New("ok")
var found portainer.APIKey
err := service.Connection.GetAll(
BucketName,
&portainer.APIKey{},
func(obj any) (any, error) {
key, ok := obj.(*portainer.APIKey)
if !ok {
log.Debug().Str("obj", fmt.Sprintf("%#v", obj)).Msg("failed to convert to APIKey object")
return nil, fmt.Errorf("failed to convert to APIKey object: %s", obj)
}
if key.Digest == digest {
k = key
return nil, stop
}
dataservices.FirstFn(&found, func(key portainer.APIKey) bool {
return key.Digest == digest
}),
)
return &portainer.APIKey{}, nil
})
if errors.Is(err, stop) {
return k, nil
if errors.Is(err, dataservices.ErrStop) {
return &found, nil
}
if err == nil {

View File

@@ -1,11 +1,8 @@
package edgestack
import (
"fmt"
portainer "github.com/portainer/portainer/api"
"github.com/rs/zerolog/log"
"github.com/portainer/portainer/api/dataservices"
)
type ServiceTx struct {
@@ -24,17 +21,8 @@ func (service ServiceTx) EdgeStacks() ([]portainer.EdgeStack, error) {
err := service.tx.GetAll(
BucketName,
&portainer.EdgeStack{},
func(obj any) (any, error) {
stack, ok := obj.(*portainer.EdgeStack)
if !ok {
log.Debug().Str("obj", fmt.Sprintf("%#v", obj)).Msg("failed to convert to EdgeStack object")
return nil, fmt.Errorf("failed to convert to EdgeStack object: %s", obj)
}
stacks = append(stacks, *stack)
return &portainer.EdgeStack{}, nil
})
dataservices.AppendFn(&stacks),
)
return stacks, err
}

View File

@@ -1,6 +1,8 @@
package edgestackstatus
import (
"encoding/binary"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
)
@@ -85,5 +87,9 @@ func (s *Service) Clear(edgeStackID portainer.EdgeStackID, relatedEnvironmentsID
}
func (s *Service) key(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID) []byte {
return append(s.conn.ConvertToKey(int(edgeStackID)), s.conn.ConvertToKey(int(endpointID))...)
k := make([]byte, 16)
binary.BigEndian.PutUint64(k[:8], uint64(edgeStackID))
binary.BigEndian.PutUint64(k[8:], uint64(endpointID))
return k
}

View File

@@ -27,7 +27,10 @@ func AppendFn[T any](collection *[]T) func(obj any) (any, error) {
*collection = append(*collection, *element)
return new(T), nil
var zero T
*element = zero
return element, nil
}
}
@@ -44,7 +47,10 @@ func FilterFn[T any](collection *[]T, predicate func(T) bool) func(obj any) (any
*collection = append(*collection, *element)
}
return new(T), nil
var zero T
*element = zero
return element, nil
}
}
@@ -60,9 +66,12 @@ func FirstFn[T any](element *T, predicate func(T) bool) func(obj any) (any, erro
if predicate(*e) {
*element = *e
return new(T), ErrStop
return e, ErrStop
}
return new(T), nil
var zero T
*e = zero
return e, nil
}
}

View File

@@ -8,6 +8,7 @@ import (
type (
DataStoreTx interface {
IsErrObjectNotFound(err error) bool
AllowList() AllowListService
CustomTemplate() CustomTemplateService
EdgeGroup() EdgeGroupService
EdgeJob() EdgeJobService
@@ -24,6 +25,7 @@ type (
Settings() SettingsService
Snapshot() SnapshotService
SSLSettings() SSLSettingsService
Source() SourceService
Stack() StackService
Tag() TagService
TeamMembership() TeamMembershipService
@@ -32,6 +34,7 @@ type (
User() UserService
Version() VersionService
Webhook() WebhookService
Workflow() WorkflowService
PendingActions() PendingActionsService
}
@@ -51,6 +54,15 @@ type (
DataStoreTx
}
// AllowListService represents a service for managing the URL allow list
AllowListService interface {
Read(id portainer.AllowListKey) (*portainer.AllowList, error)
ReadAll() ([]portainer.AllowList, error)
ReadParsed(id portainer.AllowListKey) (*portainer.ParsedAllowList, error)
Update(id portainer.AllowListKey, allowList *portainer.AllowList) error
BucketName() string
}
// CustomTemplateService represents a service to manage custom templates
CustomTemplateService interface {
BaseCRUD[portainer.CustomTemplate, portainer.CustomTemplateID]
@@ -183,6 +195,11 @@ type (
BucketName() string
}
// SourceService represents a service for managing GitOps source data
SourceService interface {
BaseCRUD[portainer.Source, portainer.SourceID]
}
// StackService represents a service for managing stack data
StackService interface {
BaseCRUD[portainer.Stack, portainer.StackID]
@@ -245,4 +262,9 @@ type (
WebhookByResourceID(resourceID string) (*portainer.Webhook, error)
WebhookByToken(token string) (*portainer.Webhook, error)
}
// WorkflowService represents a service for managing GitOps workflow data
WorkflowService interface {
BaseCRUD[portainer.Workflow, portainer.WorkflowID]
}
)

View File

@@ -2,13 +2,10 @@ package resourcecontrol
import (
"errors"
"fmt"
"slices"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/rs/zerolog/log"
)
// BucketName represents the name of the bucket where this service stores data.
@@ -48,35 +45,26 @@ func (service *Service) Tx(tx portainer.Transaction) ServiceTx {
// to the main ResourceID or in SubResourceIDs. It also performs a check on the resource type. Return nil
// if no ResourceControl was found.
func (service *Service) ResourceControlByResourceIDAndType(resourceID string, resourceType portainer.ResourceControlType) (*portainer.ResourceControl, error) {
var resourceControl *portainer.ResourceControl
stop := errors.New("ok")
var found portainer.ResourceControl
err := service.Connection.GetAll(
BucketName,
&portainer.ResourceControl{},
func(obj any) (any, error) {
rc, ok := obj.(*portainer.ResourceControl)
if !ok {
log.Debug().Str("obj", fmt.Sprintf("%#v", obj)).Msg("failed to convert to ResourceControl object")
return nil, fmt.Errorf("failed to convert to ResourceControl object: %s", obj)
}
dataservices.FirstFn(&found, func(rc portainer.ResourceControl) bool {
return (rc.ResourceID == resourceID && rc.Type == resourceType) ||
slices.Contains(rc.SubResourceIDs, resourceID)
}),
)
if rc.ResourceID == resourceID && rc.Type == resourceType {
resourceControl = rc
return nil, stop
}
if slices.Contains(rc.SubResourceIDs, resourceID) {
resourceControl = rc
return nil, stop
}
return &portainer.ResourceControl{}, nil
})
if errors.Is(err, stop) {
return resourceControl, nil
if errors.Is(err, dataservices.ErrStop) {
return &found, nil
}
return nil, err
if err != nil {
return nil, err
}
return nil, nil
}
// CreateResourceControl creates a new ResourceControl object

View File

@@ -2,13 +2,10 @@ package resourcecontrol
import (
"errors"
"fmt"
"slices"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/rs/zerolog/log"
)
type ServiceTx struct {
@@ -19,35 +16,26 @@ type ServiceTx struct {
// to the main ResourceID or in SubResourceIDs. It also performs a check on the resource type. Return nil
// if no ResourceControl was found.
func (service ServiceTx) ResourceControlByResourceIDAndType(resourceID string, resourceType portainer.ResourceControlType) (*portainer.ResourceControl, error) {
var resourceControl *portainer.ResourceControl
stop := errors.New("ok")
var found portainer.ResourceControl
err := service.Tx.GetAll(
BucketName,
&portainer.ResourceControl{},
func(obj any) (any, error) {
rc, ok := obj.(*portainer.ResourceControl)
if !ok {
log.Debug().Str("obj", fmt.Sprintf("%#v", obj)).Msg("failed to convert to ResourceControl object")
return nil, fmt.Errorf("failed to convert to ResourceControl object: %s", obj)
}
dataservices.FirstFn(&found, func(rc portainer.ResourceControl) bool {
return (rc.ResourceID == resourceID && rc.Type == resourceType) ||
slices.Contains(rc.SubResourceIDs, resourceID)
}),
)
if rc.ResourceID == resourceID && rc.Type == resourceType {
resourceControl = rc
return nil, stop
}
if slices.Contains(rc.SubResourceIDs, resourceID) {
resourceControl = rc
return nil, stop
}
return &portainer.ResourceControl{}, nil
})
if errors.Is(err, stop) {
return resourceControl, nil
if errors.Is(err, dataservices.ErrStop) {
return &found, nil
}
return nil, err
if err != nil {
return nil, err
}
return nil, nil
}
// CreateResourceControl creates a new ResourceControl object

View File

@@ -0,0 +1,50 @@
package source
import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
)
// BucketName represents the name of the bucket where this service stores data.
const BucketName = "sources"
// Service represents a service for managing GitOps source data.
type Service struct {
dataservices.BaseDataService[portainer.Source, portainer.SourceID]
}
// NewService creates a new instance of a service.
func NewService(connection portainer.Connection) (*Service, error) {
err := connection.SetServiceName(BucketName)
if err != nil {
return nil, err
}
return &Service{
BaseDataService: dataservices.BaseDataService[portainer.Source, portainer.SourceID]{
Bucket: BucketName,
Connection: connection,
},
}, nil
}
func (service *Service) Tx(tx portainer.Transaction) ServiceTx {
return ServiceTx{
BaseDataServiceTx: dataservices.BaseDataServiceTx[portainer.Source, portainer.SourceID]{
Bucket: BucketName,
Connection: service.Connection,
Tx: tx,
},
}
}
// Create creates a new source.
func (service *Service) Create(source *portainer.Source) error {
return service.Connection.CreateObject(
BucketName,
func(id uint64) (int, any) {
source.ID = portainer.SourceID(id)
return int(source.ID), source
},
)
}

View File

@@ -0,0 +1,21 @@
package source
import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
)
type ServiceTx struct {
dataservices.BaseDataServiceTx[portainer.Source, portainer.SourceID]
}
// Create creates a new source.
func (service ServiceTx) Create(source *portainer.Source) error {
return service.Tx.CreateObject(
BucketName,
func(id uint64) (int, any) {
source.ID = portainer.SourceID(id)
return int(source.ID), source
},
)
}

View File

@@ -7,6 +7,8 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
dserrors "github.com/portainer/portainer/api/dataservices/errors"
"github.com/rs/zerolog/log"
)
// BucketName represents the name of the bucket where this service stores data.
@@ -81,9 +83,21 @@ func (service *Service) GetNextIdentifier() int {
// CreateStack creates a new stack.
func (service *Service) Create(stack *portainer.Stack) error {
if stack.GitConfig != nil {
log.Warn().Int("stackID", int(stack.ID)).Str("url", stack.GitConfig.URL).Msg("stack persisted with non-nil GitConfig; GitConfig is deprecated, use WorkflowID/Source instead")
}
return service.Connection.CreateObjectWithId(BucketName, int(stack.ID), stack)
}
func (service *Service) Update(ID portainer.StackID, stack *portainer.Stack) error {
if stack.GitConfig != nil {
log.Warn().Int("stackID", int(ID)).Str("url", stack.GitConfig.URL).Msg("stack persisted with non-nil GitConfig; GitConfig is deprecated, use WorkflowID/Source instead")
}
return service.BaseDataService.Update(ID, stack)
}
// StackByWebhookID returns a pointer to a stack object by webhook ID.
// It returns nil, errors.ErrObjectNotFound if there's no stack associated with the webhook ID.
func (service *Service) StackByWebhookID(id string) (*portainer.Stack, error) {
@@ -116,7 +130,7 @@ func (service *Service) RefreshableStacks() ([]portainer.Stack, error) {
BucketName,
&portainer.Stack{},
dataservices.FilterFn(&stacks, func(e portainer.Stack) bool {
return e.AutoUpdate != nil && e.AutoUpdate.Interval != ""
return e.WorkflowID != 0 && e.AutoUpdate != nil && e.AutoUpdate.Interval != ""
}),
)
}

View File

@@ -93,14 +93,15 @@ func Test_RefreshableStacks(t *testing.T) {
staticStack := portainer.Stack{ID: 1}
stackWithWebhook := portainer.Stack{ID: 2, AutoUpdate: &portainer.AutoUpdateSettings{Webhook: "webhook"}}
refreshableStack := portainer.Stack{ID: 3, AutoUpdate: &portainer.AutoUpdateSettings{Interval: "1m"}}
intervalNoWorkflow := portainer.Stack{ID: 3, AutoUpdate: &portainer.AutoUpdateSettings{Interval: "1m"}}
refreshableStack := portainer.Stack{ID: 4, WorkflowID: 1, AutoUpdate: &portainer.AutoUpdateSettings{Interval: "1m"}}
for _, stack := range []*portainer.Stack{&staticStack, &stackWithWebhook, &refreshableStack} {
for _, stack := range []*portainer.Stack{&staticStack, &stackWithWebhook, &intervalNoWorkflow, &refreshableStack} {
err := store.Stack().Create(stack)
require.NoError(t, err)
}
stacks, err := store.Stack().RefreshableStacks()
require.NoError(t, err)
assert.ElementsMatch(t, []portainer.Stack{refreshableStack}, stacks)
require.ElementsMatch(t, []portainer.Stack{refreshableStack}, stacks)
}

View File

@@ -7,6 +7,8 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
dserrors "github.com/portainer/portainer/api/dataservices/errors"
"github.com/rs/zerolog/log"
)
type ServiceTx struct {
@@ -56,9 +58,21 @@ func (service ServiceTx) GetNextIdentifier() int {
// CreateStack creates a new stack.
func (service ServiceTx) Create(stack *portainer.Stack) error {
if stack.GitConfig != nil {
log.Warn().Int("stackID", int(stack.ID)).Str("url", stack.GitConfig.URL).Msg("stack persisted with non-nil GitConfig; GitConfig is deprecated, use WorkflowID/Source instead")
}
return service.Tx.CreateObjectWithId(BucketName, int(stack.ID), stack)
}
func (service ServiceTx) Update(ID portainer.StackID, stack *portainer.Stack) error {
if stack.GitConfig != nil {
log.Warn().Int("stackID", int(ID)).Str("url", stack.GitConfig.URL).Msg("stack persisted with non-nil GitConfig; GitConfig is deprecated, use WorkflowID/Source instead")
}
return service.BaseDataServiceTx.Update(ID, stack)
}
// StackByWebhookID returns a pointer to a stack object by webhook ID.
// It returns nil, errors.ErrObjectNotFound if there's no stack associated with the webhook ID.
func (service ServiceTx) StackByWebhookID(id string) (*portainer.Stack, error) {
@@ -92,7 +106,7 @@ func (service ServiceTx) RefreshableStacks() ([]portainer.Stack, error) {
BucketName,
&portainer.Stack{},
dataservices.FilterFn(&stacks, func(e portainer.Stack) bool {
return e.AutoUpdate != nil && e.AutoUpdate.Interval != ""
return e.WorkflowID != 0 && e.AutoUpdate != nil && e.AutoUpdate.Interval != ""
}),
)
}

View File

@@ -0,0 +1,46 @@
package workflow
import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
)
const BucketName = "workflows"
type Service struct {
dataservices.BaseDataService[portainer.Workflow, portainer.WorkflowID]
}
func NewService(connection portainer.Connection) (*Service, error) {
err := connection.SetServiceName(BucketName)
if err != nil {
return nil, err
}
return &Service{
BaseDataService: dataservices.BaseDataService[portainer.Workflow, portainer.WorkflowID]{
Bucket: BucketName,
Connection: connection,
},
}, nil
}
func (service *Service) Tx(tx portainer.Transaction) ServiceTx {
return ServiceTx{
BaseDataServiceTx: dataservices.BaseDataServiceTx[portainer.Workflow, portainer.WorkflowID]{
Bucket: BucketName,
Connection: service.Connection,
Tx: tx,
},
}
}
func (service *Service) Create(workflow *portainer.Workflow) error {
return service.Connection.CreateObject(
BucketName,
func(id uint64) (int, any) {
workflow.ID = portainer.WorkflowID(id)
return int(workflow.ID), workflow
},
)
}

View File

@@ -0,0 +1,20 @@
package workflow
import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
)
type ServiceTx struct {
dataservices.BaseDataServiceTx[portainer.Workflow, portainer.WorkflowID]
}
func (service ServiceTx) Create(workflow *portainer.Workflow) error {
return service.Tx.CreateObject(
BucketName,
func(id uint64) (int, any) {
workflow.ID = portainer.WorkflowID(id)
return int(workflow.ID), workflow
},
)
}

View File

@@ -130,7 +130,8 @@ func TestBackupDBFileUsesCorrectPath(t *testing.T) {
_, store := MustNewTestStore(t, true, false)
t.Run("backs up unencrypted db when encrypted flag is false", func(t *testing.T) {
store.connection.SetEncrypted(false)
err := store.connection.SetEncrypted(false)
require.NoError(t, err)
backupFilename, err := store.backupDBFile("")
require.NoError(t, err)

View File

@@ -35,7 +35,9 @@ func (store *Store) Open() (newStore bool, err error) {
// NeedsEncryptionMigration() sets encrypted=true as a side effect when a key exists.
// We need to set it back to false so GetDatabaseFilePath() returns the path to the
// actual unencrypted file (portainer.db) that we want to back up.
store.connection.SetEncrypted(false)
if err := store.connection.SetEncrypted(false); err != nil {
return false, err
}
// Use backupDBFile directly since connection isn't open yet
// and we don't want to trigger the close/open cycle of Backup()
@@ -124,7 +126,10 @@ func (store *Store) Rollback(force bool) error {
}
func (store *Store) encryptDB() error {
store.connection.SetEncrypted(false)
if err := store.connection.SetEncrypted(false); err != nil {
return err
}
if err := store.connection.Open(); err != nil {
return err
}

View File

@@ -72,12 +72,16 @@ func newEndpoint(endpointType portainer.EndpointType, id portainer.EndpointID, n
TLSConfig: portainer.TLSConfiguration{
TLS: false,
},
UserAccessPolicies: portainer.UserAccessPolicies{},
TeamAccessPolicies: portainer.TeamAccessPolicies{},
TagIDs: []portainer.TagID{},
Status: portainer.EndpointStatusUp,
Snapshots: []portainer.DockerSnapshot{},
Kubernetes: portainer.KubernetesDefault(),
TagIDs: nil,
Status: portainer.EndpointStatusUp,
Snapshots: nil,
Kubernetes: portainer.KubernetesData{
Configuration: portainer.KubernetesConfiguration{
UseLoadBalancer: false,
UseServerMetrics: false,
EnableResourceOverCommit: true,
},
},
}
if TLS {

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

@@ -88,6 +88,9 @@ func (store *Store) newMigratorParameters(version *models.Version, flags *portai
EdgeGroupService: store.EdgeGroupService,
TunnelServerService: store.TunnelServerService,
PendingActionsService: store.PendingActionsService,
CustomTemplateService: store.CustomTemplateService,
SourceService: store.SourceService,
WorkflowService: store.WorkflowService,
}
}

View File

@@ -0,0 +1,250 @@
package migrator
import (
"fmt"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices/stack"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/rs/zerolog/log"
)
type legacyRepoConfig struct {
URL string
ReferenceName string
ConfigFilePath string
Authentication *legacyGitAuthentication
ConfigHash string
TLSSkipVerify bool
}
type legacyGitAuthentication struct {
Username string
Password string
Provider int `json:",omitempty"`
AuthorizationType int `json:",omitempty"`
}
func (lrc *legacyRepoConfig) toRepoConfig() *gittypes.RepoConfig {
if lrc == nil {
return nil
}
cfg := &gittypes.RepoConfig{
URL: lrc.URL,
ReferenceName: lrc.ReferenceName,
ConfigFilePath: lrc.ConfigFilePath,
ConfigHash: lrc.ConfigHash,
TLSSkipVerify: lrc.TLSSkipVerify,
}
if lrc.Authentication != nil {
cfg.Authentication = &gittypes.GitAuthentication{
Username: lrc.Authentication.Username,
Password: lrc.Authentication.Password,
Provider: gittypes.GitProvider(lrc.Authentication.Provider),
AuthorizationType: gittypes.GitCredentialAuthType(lrc.Authentication.AuthorizationType),
}
}
return cfg
}
type legacyStack struct {
ID int `json:"Id"`
GitConfig *legacyRepoConfig `json:"GitConfig"`
WorkflowID *int
}
// sourceDedupeKey is the identity used to detect duplicate Sources during migration.
// Two stacks sharing the same URL and credentials must reuse the same Source record.
type sourceDedupeKey struct {
url string
username string
password string
}
func gitSourceKey(cfg *gittypes.RepoConfig) sourceDedupeKey {
key := sourceDedupeKey{url: cfg.URL}
if cfg.Authentication != nil {
key.username = cfg.Authentication.Username
key.password = cfg.Authentication.Password
}
return key
}
func (m *Migrator) migrateGitConfigToSources_2_43_0() error {
log.Info().Msg("migrating git-backed stacks to Source+Workflow records")
var legacyStacks []legacyStack
err := m.stackService.Connection.GetAll(
stack.BucketName,
new(legacyStack),
func(obj any) (any, error) {
s, ok := obj.(*legacyStack)
if !ok {
return nil, fmt.Errorf("unexpected type reading stack bucket: %T", obj)
}
legacyStacks = append(legacyStacks, *s)
return new(legacyStack), nil
},
)
if err != nil {
return err
}
existingSources, err := m.sourceService.ReadAll()
if err != nil {
return err
}
sourcesByKey := make(map[sourceDedupeKey]portainer.SourceID, len(existingSources))
for _, src := range existingSources {
if src.Git != nil {
sourcesByKey[gitSourceKey(src.Git)] = src.ID
}
}
for _, ls := range legacyStacks {
if ls.GitConfig == nil || (ls.WorkflowID != nil && *ls.WorkflowID != 0) {
continue
}
cfg := ls.GitConfig.toRepoConfig()
cfg.URL = gittypes.SanitizeURL(cfg.URL)
key := gitSourceKey(cfg)
var newSrcID portainer.SourceID
if err := m.stackService.Connection.UpdateTx(func(tx portainer.Transaction) error {
srcID, exists := sourcesByKey[key]
if !exists {
src := &portainer.Source{
Name: gittypes.RepoName(cfg.URL),
Type: portainer.SourceTypeGit,
Git: cfg,
}
if err := m.sourceService.Tx(tx).Create(src); err != nil {
return fmt.Errorf("failed to create source for stack %d: %w", ls.ID, err)
}
srcID = src.ID
newSrcID = src.ID
}
liveStack, err := m.stackService.Tx(tx).Read(portainer.StackID(ls.ID))
if err != nil {
return fmt.Errorf("failed to read stack %d: %w", ls.ID, err)
}
wf := &portainer.Workflow{
Name: liveStack.Name,
Artifacts: []portainer.Artifact{{
StackID: portainer.StackID(ls.ID),
Files: []portainer.ArtifactFile{{
SourceID: srcID,
Path: cfg.ConfigFilePath,
Ref: cfg.ReferenceName,
Hash: cfg.ConfigHash,
}},
}},
}
if err := m.workflowService.Tx(tx).Create(wf); err != nil {
return fmt.Errorf("failed to create workflow for stack %d: %w", ls.ID, err)
}
liveStack.WorkflowID = wf.ID
liveStack.GitConfig = nil
return m.stackService.Tx(tx).Update(portainer.StackID(ls.ID), liveStack)
}); err != nil {
return fmt.Errorf("failed to migrate stack %d: %w", ls.ID, err)
}
if newSrcID != 0 {
sourcesByKey[key] = newSrcID
}
}
return nil
}
func (m *Migrator) migrateCustomTemplateGitConfigToSources_2_43_0() error {
log.Info().Msg("migrating git-backed custom templates to Source records")
templates, err := m.customTemplateService.ReadAll()
if err != nil {
return err
}
existingSources, err := m.sourceService.ReadAll()
if err != nil {
return err
}
sourcesByKey := make(map[sourceDedupeKey]portainer.SourceID, len(existingSources))
for _, src := range existingSources {
if src.Git != nil {
sourcesByKey[gitSourceKey(src.Git)] = src.ID
}
}
for i := range templates {
t := &templates[i]
if t.GitConfig == nil || t.Artifact != nil {
continue
}
cfg := &gittypes.RepoConfig{
URL: gittypes.SanitizeURL(t.GitConfig.URL),
Authentication: t.GitConfig.Authentication,
TLSSkipVerify: t.GitConfig.TLSSkipVerify,
}
key := gitSourceKey(cfg)
var newSrcID portainer.SourceID
if err := m.stackService.Connection.UpdateTx(func(tx portainer.Transaction) error {
srcID, exists := sourcesByKey[key]
if !exists {
src := &portainer.Source{
Name: gittypes.RepoName(cfg.URL),
Type: portainer.SourceTypeGit,
Git: cfg,
}
if err := m.sourceService.Tx(tx).Create(src); err != nil {
return fmt.Errorf("failed to create source for custom template %d: %w", t.ID, err)
}
srcID = src.ID
newSrcID = src.ID
}
t.Artifact = &portainer.Artifact{
Files: []portainer.ArtifactFile{{
SourceID: srcID,
Path: t.GitConfig.ConfigFilePath,
Ref: t.GitConfig.ReferenceName,
Hash: t.GitConfig.ConfigHash,
}},
}
t.GitConfig = nil
return m.customTemplateService.Tx(tx).Update(t.ID, t)
}); err != nil {
return fmt.Errorf("failed to migrate custom template %d: %w", t.ID, err)
}
if newSrcID != 0 {
sourcesByKey[key] = newSrcID
}
}
return nil
}

View File

@@ -0,0 +1,462 @@
package migrator
import (
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/database/boltdb"
"github.com/portainer/portainer/api/dataservices/customtemplate"
"github.com/portainer/portainer/api/dataservices/source"
"github.com/portainer/portainer/api/dataservices/stack"
"github.com/portainer/portainer/api/dataservices/workflow"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/logs"
"github.com/stretchr/testify/require"
)
func TestMigrateGitConfigToSources_2_43_0_GitStackMigrated(t *testing.T) {
t.Parallel()
conn := &boltdb.DbConnection{Path: t.TempDir()}
err := conn.Open()
require.NoError(t, err)
defer logs.CloseAndLogErr(conn)
stackSvc, err := stack.NewService(conn)
require.NoError(t, err)
sourceSvc, err := source.NewService(conn)
require.NoError(t, err)
workflowSvc, err := workflow.NewService(conn)
require.NoError(t, err)
m := NewMigrator(&MigratorParameters{
StackService: stackSvc,
SourceService: sourceSvc,
WorkflowService: workflowSvc,
})
gitStack := &portainer.Stack{
ID: 1,
Name: "git-stack",
GitConfig: &gittypes.RepoConfig{
URL: "https://github.com/example/repo",
ReferenceName: "refs/heads/main",
ConfigHash: "abc123",
},
}
err = conn.CreateObjectWithId(stack.BucketName, int(gitStack.ID), gitStack)
require.NoError(t, err)
err = m.migrateGitConfigToSources_2_43_0()
require.NoError(t, err)
migrated, err := stackSvc.Read(gitStack.ID)
require.NoError(t, err)
require.NotZero(t, migrated.WorkflowID)
require.Nil(t, migrated.GitConfig)
wf, err := workflowSvc.Read(migrated.WorkflowID)
require.NoError(t, err)
require.Len(t, wf.Artifacts, 1)
require.Len(t, wf.Artifacts[0].Files, 1)
src, err := sourceSvc.Read(wf.Artifacts[0].Files[0].SourceID)
require.NoError(t, err)
require.Equal(t, portainer.SourceTypeGit, src.Type)
require.Equal(t, gitStack.GitConfig.URL, src.Git.URL)
require.Equal(t, gitStack.GitConfig.ReferenceName, src.Git.ReferenceName)
}
func TestMigrateGitConfigToSources_2_43_0_NonGitStackUntouched(t *testing.T) {
t.Parallel()
conn := &boltdb.DbConnection{Path: t.TempDir()}
err := conn.Open()
require.NoError(t, err)
defer logs.CloseAndLogErr(conn)
stackSvc, err := stack.NewService(conn)
require.NoError(t, err)
sourceSvc, err := source.NewService(conn)
require.NoError(t, err)
workflowSvc, err := workflow.NewService(conn)
require.NoError(t, err)
m := NewMigrator(&MigratorParameters{
StackService: stackSvc,
SourceService: sourceSvc,
WorkflowService: workflowSvc,
})
plainStack := &portainer.Stack{
ID: 1,
Name: "plain-stack",
}
err = conn.CreateObjectWithId(stack.BucketName, int(plainStack.ID), plainStack)
require.NoError(t, err)
err = m.migrateGitConfigToSources_2_43_0()
require.NoError(t, err)
result, err := stackSvc.Read(plainStack.ID)
require.NoError(t, err)
require.Zero(t, result.WorkflowID)
require.Nil(t, result.GitConfig)
sources, err := sourceSvc.ReadAll()
require.NoError(t, err)
require.Empty(t, sources)
workflows, err := workflowSvc.ReadAll()
require.NoError(t, err)
require.Empty(t, workflows)
}
func TestMigrateGitConfigToSources_2_43_0_DuplicateSourcesDeduped(t *testing.T) {
t.Parallel()
conn := &boltdb.DbConnection{Path: t.TempDir()}
err := conn.Open()
require.NoError(t, err)
defer logs.CloseAndLogErr(conn)
stackSvc, err := stack.NewService(conn)
require.NoError(t, err)
sourceSvc, err := source.NewService(conn)
require.NoError(t, err)
workflowSvc, err := workflow.NewService(conn)
require.NoError(t, err)
m := NewMigrator(&MigratorParameters{
StackService: stackSvc,
SourceService: sourceSvc,
WorkflowService: workflowSvc,
})
sharedURL := "https://github.com/example/shared-repo"
stack1 := &portainer.Stack{
ID: 1,
Name: "stack-a",
GitConfig: &gittypes.RepoConfig{
URL: sharedURL,
ReferenceName: "refs/heads/main",
},
}
stack2 := &portainer.Stack{
ID: 2,
Name: "stack-b",
GitConfig: &gittypes.RepoConfig{
URL: sharedURL,
ReferenceName: "refs/heads/develop",
},
}
err = conn.CreateObjectWithId(stack.BucketName, int(stack1.ID), stack1)
require.NoError(t, err)
err = conn.CreateObjectWithId(stack.BucketName, int(stack2.ID), stack2)
require.NoError(t, err)
err = m.migrateGitConfigToSources_2_43_0()
require.NoError(t, err)
sources, err := sourceSvc.ReadAll()
require.NoError(t, err)
require.Len(t, sources, 1, "two stacks with the same URL must share one Source")
workflows, err := workflowSvc.ReadAll()
require.NoError(t, err)
require.Len(t, workflows, 2, "each stack must get its own Workflow")
sharedSourceID := sources[0].ID
for _, wf := range workflows {
require.Len(t, wf.Artifacts, 1)
require.Len(t, wf.Artifacts[0].Files, 1)
require.Equal(t, sharedSourceID, wf.Artifacts[0].Files[0].SourceID)
}
}
func TestMigrateGitConfigToSources_2_43_0_Idempotent(t *testing.T) {
t.Parallel()
conn := &boltdb.DbConnection{Path: t.TempDir()}
err := conn.Open()
require.NoError(t, err)
defer logs.CloseAndLogErr(conn)
stackSvc, err := stack.NewService(conn)
require.NoError(t, err)
sourceSvc, err := source.NewService(conn)
require.NoError(t, err)
workflowSvc, err := workflow.NewService(conn)
require.NoError(t, err)
m := NewMigrator(&MigratorParameters{
StackService: stackSvc,
SourceService: sourceSvc,
WorkflowService: workflowSvc,
})
gitStack := &portainer.Stack{
ID: 1,
Name: "git-stack",
GitConfig: &gittypes.RepoConfig{
URL: "https://github.com/example/repo",
},
}
err = conn.CreateObjectWithId(stack.BucketName, int(gitStack.ID), gitStack)
require.NoError(t, err)
err = m.migrateGitConfigToSources_2_43_0()
require.NoError(t, err)
// Second run must not create duplicate Source/Workflow records
err = m.migrateGitConfigToSources_2_43_0()
require.NoError(t, err)
sources, err := sourceSvc.ReadAll()
require.NoError(t, err)
require.Len(t, sources, 1)
workflows, err := workflowSvc.ReadAll()
require.NoError(t, err)
require.Len(t, workflows, 1)
}
func TestMigrateCustomTemplateGitConfigToSources_2_43_0_GitTemplateMigrated(t *testing.T) {
t.Parallel()
conn := &boltdb.DbConnection{Path: t.TempDir()}
err := conn.Open()
require.NoError(t, err)
defer logs.CloseAndLogErr(conn)
stackSvc, err := stack.NewService(conn)
require.NoError(t, err)
sourceSvc, err := source.NewService(conn)
require.NoError(t, err)
customTemplateSvc, err := customtemplate.NewService(conn)
require.NoError(t, err)
m := NewMigrator(&MigratorParameters{
StackService: stackSvc,
SourceService: sourceSvc,
CustomTemplateService: customTemplateSvc,
})
tmpl := &portainer.CustomTemplate{
ID: 1,
GitConfig: &gittypes.RepoConfig{
URL: "https://github.com/example/repo",
ReferenceName: "refs/heads/main",
ConfigFilePath: "docker-compose.yml",
ConfigHash: "abc123",
},
}
err = conn.CreateObjectWithId(customtemplate.BucketName, int(tmpl.ID), tmpl)
require.NoError(t, err)
err = m.migrateCustomTemplateGitConfigToSources_2_43_0()
require.NoError(t, err)
migrated, err := customTemplateSvc.Read(tmpl.ID)
require.NoError(t, err)
require.NotNil(t, migrated.Artifact)
require.Nil(t, migrated.GitConfig)
require.Len(t, migrated.Artifact.Files, 1)
require.Equal(t, "refs/heads/main", migrated.Artifact.Files[0].Ref)
require.Equal(t, "docker-compose.yml", migrated.Artifact.Files[0].Path)
require.Equal(t, "abc123", migrated.Artifact.Files[0].Hash)
src, err := sourceSvc.Read(migrated.Artifact.Files[0].SourceID)
require.NoError(t, err)
require.Equal(t, portainer.SourceTypeGit, src.Type)
require.Equal(t, "https://github.com/example/repo", src.Git.URL)
}
func TestMigrateCustomTemplateGitConfigToSources_2_43_0_NonGitTemplateUntouched(t *testing.T) {
t.Parallel()
conn := &boltdb.DbConnection{Path: t.TempDir()}
err := conn.Open()
require.NoError(t, err)
defer logs.CloseAndLogErr(conn)
stackSvc, err := stack.NewService(conn)
require.NoError(t, err)
sourceSvc, err := source.NewService(conn)
require.NoError(t, err)
customTemplateSvc, err := customtemplate.NewService(conn)
require.NoError(t, err)
m := NewMigrator(&MigratorParameters{
StackService: stackSvc,
SourceService: sourceSvc,
CustomTemplateService: customTemplateSvc,
})
tmpl := &portainer.CustomTemplate{ID: 1, Title: "plain-template"}
err = conn.CreateObjectWithId(customtemplate.BucketName, int(tmpl.ID), tmpl)
require.NoError(t, err)
err = m.migrateCustomTemplateGitConfigToSources_2_43_0()
require.NoError(t, err)
result, err := customTemplateSvc.Read(tmpl.ID)
require.NoError(t, err)
require.Nil(t, result.Artifact)
require.Nil(t, result.GitConfig)
sources, err := sourceSvc.ReadAll()
require.NoError(t, err)
require.Empty(t, sources)
}
func TestMigrateCustomTemplateGitConfigToSources_2_43_0_AlreadyMigratedSkipped(t *testing.T) {
t.Parallel()
conn := &boltdb.DbConnection{Path: t.TempDir()}
err := conn.Open()
require.NoError(t, err)
defer logs.CloseAndLogErr(conn)
stackSvc, err := stack.NewService(conn)
require.NoError(t, err)
sourceSvc, err := source.NewService(conn)
require.NoError(t, err)
customTemplateSvc, err := customtemplate.NewService(conn)
require.NoError(t, err)
m := NewMigrator(&MigratorParameters{
StackService: stackSvc,
SourceService: sourceSvc,
CustomTemplateService: customTemplateSvc,
})
// Template already has Artifact set (already migrated)
srcID := portainer.SourceID(99)
tmpl := &portainer.CustomTemplate{
ID: 1,
GitConfig: &gittypes.RepoConfig{
URL: "https://github.com/example/repo",
},
Artifact: &portainer.Artifact{
Files: []portainer.ArtifactFile{{SourceID: srcID}},
},
}
err = conn.CreateObjectWithId(customtemplate.BucketName, int(tmpl.ID), tmpl)
require.NoError(t, err)
err = m.migrateCustomTemplateGitConfigToSources_2_43_0()
require.NoError(t, err)
sources, err := sourceSvc.ReadAll()
require.NoError(t, err)
require.Empty(t, sources, "no new sources should be created for already-migrated templates")
}
func TestMigrateCustomTemplateGitConfigToSources_2_43_0_DuplicateSourcesDeduped(t *testing.T) {
t.Parallel()
conn := &boltdb.DbConnection{Path: t.TempDir()}
err := conn.Open()
require.NoError(t, err)
defer logs.CloseAndLogErr(conn)
stackSvc, err := stack.NewService(conn)
require.NoError(t, err)
sourceSvc, err := source.NewService(conn)
require.NoError(t, err)
customTemplateSvc, err := customtemplate.NewService(conn)
require.NoError(t, err)
m := NewMigrator(&MigratorParameters{
StackService: stackSvc,
SourceService: sourceSvc,
CustomTemplateService: customTemplateSvc,
})
sharedURL := "https://github.com/example/shared-repo"
tmpl1 := &portainer.CustomTemplate{
ID: 1,
Title: "template-a",
GitConfig: &gittypes.RepoConfig{
URL: sharedURL,
ReferenceName: "refs/heads/main",
},
}
tmpl2 := &portainer.CustomTemplate{
ID: 2,
Title: "template-b",
GitConfig: &gittypes.RepoConfig{
URL: sharedURL,
ReferenceName: "refs/heads/develop",
},
}
err = conn.CreateObjectWithId(customtemplate.BucketName, int(tmpl1.ID), tmpl1)
require.NoError(t, err)
err = conn.CreateObjectWithId(customtemplate.BucketName, int(tmpl2.ID), tmpl2)
require.NoError(t, err)
err = m.migrateCustomTemplateGitConfigToSources_2_43_0()
require.NoError(t, err)
sources, err := sourceSvc.ReadAll()
require.NoError(t, err)
require.Len(t, sources, 1, "two templates with the same URL must share one Source")
sharedSrcID := sources[0].ID
migrated1, err := customTemplateSvc.Read(tmpl1.ID)
require.NoError(t, err)
require.NotNil(t, migrated1.Artifact)
require.Equal(t, sharedSrcID, migrated1.Artifact.Files[0].SourceID)
migrated2, err := customTemplateSvc.Read(tmpl2.ID)
require.NoError(t, err)
require.NotNil(t, migrated2.Artifact)
require.Equal(t, sharedSrcID, migrated2.Artifact.Files[0].SourceID)
}
func TestMigrateCustomTemplateGitConfigToSources_2_43_0_Idempotent(t *testing.T) {
t.Parallel()
conn := &boltdb.DbConnection{Path: t.TempDir()}
err := conn.Open()
require.NoError(t, err)
defer logs.CloseAndLogErr(conn)
stackSvc, err := stack.NewService(conn)
require.NoError(t, err)
sourceSvc, err := source.NewService(conn)
require.NoError(t, err)
customTemplateSvc, err := customtemplate.NewService(conn)
require.NoError(t, err)
m := NewMigrator(&MigratorParameters{
StackService: stackSvc,
SourceService: sourceSvc,
CustomTemplateService: customTemplateSvc,
})
tmpl := &portainer.CustomTemplate{
ID: 1,
GitConfig: &gittypes.RepoConfig{
URL: "https://github.com/example/repo",
},
}
err = conn.CreateObjectWithId(customtemplate.BucketName, int(tmpl.ID), tmpl)
require.NoError(t, err)
err = m.migrateCustomTemplateGitConfigToSources_2_43_0()
require.NoError(t, err)
// Second run must not create duplicate Source records
err = m.migrateCustomTemplateGitConfigToSources_2_43_0()
require.NoError(t, err)
sources, err := sourceSvc.ReadAll()
require.NoError(t, err)
require.Len(t, sources, 1)
}

View File

@@ -5,6 +5,7 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/database/models"
"github.com/portainer/portainer/api/dataservices/customtemplate"
"github.com/portainer/portainer/api/dataservices/dockerhub"
"github.com/portainer/portainer/api/dataservices/edgegroup"
"github.com/portainer/portainer/api/dataservices/edgejob"
@@ -21,12 +22,14 @@ import (
"github.com/portainer/portainer/api/dataservices/schedule"
"github.com/portainer/portainer/api/dataservices/settings"
"github.com/portainer/portainer/api/dataservices/snapshot"
"github.com/portainer/portainer/api/dataservices/source"
"github.com/portainer/portainer/api/dataservices/stack"
"github.com/portainer/portainer/api/dataservices/tag"
"github.com/portainer/portainer/api/dataservices/teammembership"
"github.com/portainer/portainer/api/dataservices/tunnelserver"
"github.com/portainer/portainer/api/dataservices/user"
"github.com/portainer/portainer/api/dataservices/version"
"github.com/portainer/portainer/api/dataservices/workflow"
"github.com/portainer/portainer/api/internal/authorization"
"github.com/Masterminds/semver/v3"
@@ -64,6 +67,9 @@ type (
edgeGroupService *edgegroup.Service
TunnelServerService *tunnelserver.Service
pendingActionsService *pendingactions.Service
customTemplateService *customtemplate.Service
sourceService *source.Service
workflowService *workflow.Service
}
// MigratorParameters represents the required parameters to create a new Migrator instance.
@@ -94,6 +100,9 @@ type (
EdgeGroupService *edgegroup.Service
TunnelServerService *tunnelserver.Service
PendingActionsService *pendingactions.Service
CustomTemplateService *customtemplate.Service
SourceService *source.Service
WorkflowService *workflow.Service
}
)
@@ -126,6 +135,9 @@ func NewMigrator(parameters *MigratorParameters) *Migrator {
edgeGroupService: parameters.EdgeGroupService,
TunnelServerService: parameters.TunnelServerService,
pendingActionsService: parameters.PendingActionsService,
customTemplateService: parameters.CustomTemplateService,
sourceService: parameters.SourceService,
workflowService: parameters.WorkflowService,
}
migrator.initMigrations()
@@ -260,6 +272,11 @@ func (m *Migrator) initMigrations() {
m.addMigrations("2.40.0", m.migrateRegistryAccessSASecrets_2_40_0)
m.addMigrations("2.43.0",
m.migrateGitConfigToSources_2_43_0,
m.migrateCustomTemplateGitConfigToSources_2_43_0,
)
// WARNING: do not change migrations that have already been released!
// Add new migrations above...

View File

@@ -7,6 +7,7 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/database/models"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/dataservices/allowlist"
"github.com/portainer/portainer/api/dataservices/apikeyrepository"
"github.com/portainer/portainer/api/dataservices/customtemplate"
"github.com/portainer/portainer/api/dataservices/dockerhub"
@@ -26,6 +27,7 @@ import (
"github.com/portainer/portainer/api/dataservices/schedule"
"github.com/portainer/portainer/api/dataservices/settings"
"github.com/portainer/portainer/api/dataservices/snapshot"
"github.com/portainer/portainer/api/dataservices/source"
"github.com/portainer/portainer/api/dataservices/ssl"
"github.com/portainer/portainer/api/dataservices/stack"
"github.com/portainer/portainer/api/dataservices/tag"
@@ -35,6 +37,7 @@ import (
"github.com/portainer/portainer/api/dataservices/user"
"github.com/portainer/portainer/api/dataservices/version"
"github.com/portainer/portainer/api/dataservices/webhook"
"github.com/portainer/portainer/api/dataservices/workflow"
"github.com/rs/zerolog/log"
"github.com/segmentio/encoding/json"
@@ -49,6 +52,7 @@ type Store struct {
connection portainer.Connection
fileService portainer.FileService
AllowListService *allowlist.Service
CustomTemplateService *customtemplate.Service
DockerHubService *dockerhub.Service
EdgeGroupService *edgegroup.Service
@@ -67,6 +71,7 @@ type Store struct {
ScheduleService *schedule.Service
SettingsService *settings.Service
SnapshotService *snapshot.Service
SourceService *source.Service
SSLSettingsService *ssl.Service
StackService *stack.Service
TagService *tag.Service
@@ -76,10 +81,17 @@ type Store struct {
UserService *user.Service
VersionService *version.Service
WebhookService *webhook.Service
WorkflowService *workflow.Service
PendingActionsService *pendingactions.Service
}
func (store *Store) initServices() error {
allowListService, err := allowlist.NewService(store.connection)
if err != nil {
return err
}
store.AllowListService = allowListService
authorizationsetService, err := role.NewService(store.connection)
if err != nil {
return err
@@ -179,6 +191,12 @@ func (store *Store) initServices() error {
}
store.SnapshotService = snapshotService
sourceService, err := source.NewService(store.connection)
if err != nil {
return err
}
store.SourceService = sourceService
sslSettingsService, err := ssl.NewService(store.connection)
if err != nil {
return err
@@ -239,6 +257,12 @@ func (store *Store) initServices() error {
}
store.WebhookService = webhookService
workflowService, err := workflow.NewService(store.connection)
if err != nil {
return err
}
store.WorkflowService = workflowService
scheduleService, err := schedule.NewService(store.connection)
if err != nil {
return err
@@ -259,6 +283,11 @@ func (store *Store) PendingActions() dataservices.PendingActionsService {
return store.PendingActionsService
}
// AllowList gives access to the AllowList data management layer
func (store *Store) AllowList() dataservices.AllowListService {
return store.AllowListService
}
// CustomTemplate gives access to the CustomTemplate data management layer
func (store *Store) CustomTemplate() dataservices.CustomTemplateService {
return store.CustomTemplateService
@@ -332,6 +361,11 @@ func (store *Store) Snapshot() dataservices.SnapshotService {
return store.SnapshotService
}
// Source gives access to the Source data management layer
func (store *Store) Source() dataservices.SourceService {
return store.SourceService
}
// SSLSettings gives access to the SSL Settings data management layer
func (store *Store) SSLSettings() dataservices.SSLSettingsService {
return store.SSLSettingsService
@@ -377,6 +411,11 @@ func (store *Store) Webhook() dataservices.WebhookService {
return store.WebhookService
}
// Workflow gives access to the Workflow data management layer
func (store *Store) Workflow() dataservices.WorkflowService {
return store.WorkflowService
}
type storeExport struct {
CustomTemplate []portainer.CustomTemplate `json:"customtemplates,omitempty"`
EdgeGroup []portainer.EdgeGroup `json:"edgegroups,omitempty"`
@@ -394,6 +433,7 @@ type storeExport struct {
Settings portainer.Settings `json:"settings,omitzero"`
Snapshot []portainer.Snapshot `json:"snapshots,omitempty"`
SSLSettings portainer.SSLSettings `json:"ssl,omitzero"`
Source []portainer.Source `json:"sources,omitempty"`
Stack []portainer.Stack `json:"stacks,omitempty"`
Tag []portainer.Tag `json:"tags,omitempty"`
TeamMembership []portainer.TeamMembership `json:"team_membership,omitempty"`
@@ -402,6 +442,7 @@ type storeExport struct {
User []portainer.User `json:"users,omitempty"`
Version models.Version `json:"version,omitzero"`
Webhook []portainer.Webhook `json:"webhooks,omitempty"`
Workflow []portainer.Workflow `json:"workflows,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
}
@@ -536,6 +577,14 @@ func (store *Store) Export(filename string) (err error) {
backup.SSLSettings = *settings
}
if s, err := store.Source().ReadAll(); err != nil {
if !store.IsErrObjectNotFound(err) {
log.Error().Err(err).Msg("exporting Sources")
}
} else {
backup.Source = s
}
if t, err := store.Stack().ReadAll(); err != nil {
if !store.IsErrObjectNotFound(err) {
log.Error().Err(err).Msg("exporting Stacks")
@@ -592,6 +641,14 @@ func (store *Store) Export(filename string) (err error) {
backup.Webhook = webhooks
}
if w, err := store.Workflow().ReadAll(); err != nil {
if !store.IsErrObjectNotFound(err) {
log.Error().Err(err).Msg("exporting Workflows")
}
} else {
backup.Workflow = w
}
if version, err := store.Version().Version(); err != nil {
if !store.IsErrObjectNotFound(err) {
log.Error().Err(err).Msg("exporting Version")
@@ -610,7 +667,7 @@ func (store *Store) Export(filename string) (err error) {
return err
}
return os.WriteFile(filename, b, 0600)
return os.WriteFile(filename, b, 0o600)
}
func (store *Store) Import(filename string) (err error) {
@@ -710,6 +767,18 @@ func (store *Store) Import(filename string) (err error) {
}
}
for _, v := range backup.Source {
if err := store.Source().Update(v.ID, &v); err != nil {
log.Warn().Err(err).Msg("failed to update the source in the database")
}
}
for _, v := range backup.Workflow {
if err := store.Workflow().Update(v.ID, &v); err != nil {
log.Warn().Err(err).Msg("failed to update the workflow in the database")
}
}
for _, v := range backup.Stack {
if err := store.Stack().Update(v.ID, &v); err != nil {
log.Warn().Err(err).Msg("failed to update the stack in the database")

View File

@@ -14,6 +14,10 @@ func (tx *StoreTx) IsErrObjectNotFound(err error) bool {
return tx.store.IsErrObjectNotFound(err)
}
func (tx *StoreTx) AllowList() dataservices.AllowListService {
return tx.store.AllowListService.Tx(tx.tx)
}
func (tx *StoreTx) CustomTemplate() dataservices.CustomTemplateService {
return tx.store.CustomTemplateService.Tx(tx.tx)
}
@@ -74,6 +78,10 @@ func (tx *StoreTx) Snapshot() dataservices.SnapshotService {
return tx.store.SnapshotService.Tx(tx.tx)
}
func (tx *StoreTx) Source() dataservices.SourceService {
return tx.store.SourceService.Tx(tx.tx)
}
func (tx *StoreTx) SSLSettings() dataservices.SSLSettingsService {
return tx.store.SSLSettingsService.Tx(tx.tx)
}
@@ -102,3 +110,7 @@ func (tx *StoreTx) User() dataservices.UserService {
func (tx *StoreTx) Version() dataservices.VersionService { return nil }
func (tx *StoreTx) Webhook() dataservices.WebhookService { return nil }
func (tx *StoreTx) Workflow() dataservices.WorkflowService {
return tx.store.WorkflowService.Tx(tx.tx)
}

View File

@@ -1,4 +1,5 @@
{
"allowlist": null,
"api_key": null,
"customtemplates": null,
"dockerhub": [
@@ -33,11 +34,7 @@
],
"endpoints": [
{
"Agent": {
"Version": ""
},
"AuthorizedTeams": null,
"AuthorizedUsers": null,
"Agent": {},
"AzureCredentials": {
"ApplicationID": "",
"AuthenticationKey": "",
@@ -53,7 +50,6 @@
},
"EdgeCheckinInterval": 0,
"EdgeKey": "",
"Gpus": [],
"GroupId": 1,
"Heartbeat": false,
"Id": 1,
@@ -62,10 +58,8 @@
"AllowNoneIngressClass": false,
"EnableResourceOverCommit": false,
"IngressAvailabilityPerNamespace": true,
"IngressClasses": null,
"ResourceOverCommitPercentage": 0,
"RestrictDefaultNamespace": false,
"StorageClasses": null,
"UseLoadBalancer": false,
"UseServerMetrics": false
},
@@ -73,8 +67,7 @@
"IsServerIngressClassDetected": false,
"IsServerMetricsDetected": false,
"IsServerStorageDetected": false
},
"Snapshots": []
}
},
"LastCheckInDate": 0,
"Name": "local",
@@ -96,18 +89,13 @@
"allowVolumeBrowserForRegularUsers": false,
"enableHostManagementFeatures": false
},
"Snapshots": [],
"Status": 1,
"TLSConfig": {
"TLS": false,
"TLSSkipVerify": false
},
"TagIds": [],
"Tags": null,
"TeamAccessPolicies": {},
"Type": 1,
"URL": "unix:///var/run/docker.sock",
"UserAccessPolicies": {}
"URL": "unix:///var/run/docker.sock"
}
],
"extension": null,
@@ -607,6 +595,7 @@
"EnableEdgeComputeFeatures": false,
"EnforceEdgeID": false,
"FeatureFlagSettings": null,
"ForceSecureCookies": false,
"GlobalDeploymentOptions": {
"hideStacksFunctionality": false
},
@@ -615,7 +604,7 @@
"RequiredPasswordLength": 12
},
"KubeconfigExpiry": "0",
"KubectlShellImage": "portainer/kubectl-shell:2.41.0",
"KubectlShellImage": "portainer/kubectl-shell:2.43.0",
"LDAPSettings": {
"AnonymousMode": true,
"AutoCreateUsers": true,
@@ -660,18 +649,7 @@
"SnapshotInterval": "5m",
"TemplatesURL": "",
"TrustOnFirstConnect": false,
"UserSessionTimeout": "8h",
"openAMTConfiguration": {
"certFileContent": "",
"certFileName": "",
"certFilePassword": "",
"domainName": "",
"enabled": false,
"mpsPassword": "",
"mpsServer": "",
"mpsToken": "",
"mpsUser": ""
}
"UserSessionTimeout": "8h"
},
"snapshots": [
{
@@ -679,8 +657,6 @@
"ContainerCount": 0,
"DiagnosticsData": {},
"DockerSnapshotRaw": {
"Containers": null,
"Images": null,
"Info": {
"Architecture": "",
"CDISpecDirs": null,
@@ -756,7 +732,6 @@
"SystemTime": "",
"Warnings": null
},
"Networks": null,
"Version": {
"ApiVersion": "",
"Arch": "",
@@ -775,12 +750,10 @@
},
"DockerVersion": "20.10.13",
"GpuUseAll": false,
"GpuUseList": null,
"HealthyContainerCount": 0,
"ImageCount": 9,
"IsPodman": false,
"NodeCount": 0,
"PerformanceMetrics": null,
"RunningContainerCount": 5,
"ServiceCount": 0,
"StackCount": 2,
@@ -796,6 +769,7 @@
"Kubernetes": null
}
],
"sources": null,
"ssl": {
"certPath": "",
"httpEnabled": true,
@@ -947,7 +921,8 @@
}
],
"version": {
"VERSION": "{\"SchemaVersion\":\"2.41.0\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
"VERSION": "{\"SchemaVersion\":\"2.43.0\",\"MigratorCount\":2,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
},
"webhooks": null
"webhooks": null,
"workflows": null
}

View File

@@ -11,6 +11,8 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/crypto"
"github.com/portainer/portainer/pkg/libhttp/ssrf"
"github.com/rs/zerolog/log"
"github.com/docker/docker/api/types/image"
@@ -88,7 +90,7 @@ func createTCPClient(endpoint *portainer.Endpoint, timeout *time.Duration) (*cli
client.WithHTTPClient(httpCli),
}
if nnTransport, ok := httpCli.Transport.(*NodeNameTransport); ok && nnTransport.TLSClientConfig != nil {
if endpoint.TLSConfig.TLS {
opts = append(opts, client.WithScheme("https"))
}
@@ -122,7 +124,7 @@ func createAgentClient(endpoint *portainer.Endpoint, endpointURL string, signatu
client.WithHTTPHeaders(headers),
}
if nnTransport, ok := httpCli.Transport.(*NodeNameTransport); ok && nnTransport.TLSClientConfig != nil {
if endpoint.TLSConfig.TLS {
opts = append(opts, client.WithScheme("https"))
}
@@ -184,17 +186,20 @@ func (t *NodeNameTransport) RoundTrip(req *http.Request) (*http.Response, error)
}
func httpClient(endpoint *portainer.Endpoint, timeout *time.Duration) (*http.Client, error) {
transport := &NodeNameTransport{
Transport: &http.Transport{},
}
var transport *NodeNameTransport
if endpoint.TLSConfig.TLS {
tlsConfig, err := crypto.CreateTLSConfigurationFromDisk(endpoint.TLSConfig)
if err != nil {
return nil, err
}
transport.TLSClientConfig = tlsConfig
transport = &NodeNameTransport{
Transport: ssrf.NewTransport(tlsConfig),
}
} else {
transport = &NodeNameTransport{
Transport: ssrf.NewTransport(nil),
}
}
clientTimeout := defaultDockerRequestTimeout

View File

@@ -5,9 +5,10 @@ import (
"strings"
"time"
dockerclient "github.com/portainer/portainer/api/docker/client"
portainer "github.com/portainer/portainer/api"
"github.com/docker/docker/api/types/image"
dockerclient "github.com/docker/docker/client"
"github.com/opencontainers/go-digest"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
@@ -15,28 +16,33 @@ import (
imagetypes "go.podman.io/image/v5/types"
)
// Options holds docker registry object options
type Options struct {
Auth imagetypes.DockerAuthConfig
Timeout time.Duration
const digestFetchTimeout = 5 * time.Second
// ClientFactory creates Docker clients for a given environment.
type ClientFactory interface {
CreateClient(endpoint *portainer.Endpoint, nodeName string, timeout *time.Duration) (*dockerclient.Client, error)
}
// RegistryAuthProvider looks up registry credentials for an image.
type RegistryAuthProvider interface {
RegistryAuth(image Image) (string, string, error)
}
type DigestClient struct {
clientFactory *dockerclient.ClientFactory
opts Options
clientFactory ClientFactory
sysCtx *imagetypes.SystemContext
registryClient *RegistryClient
registryClient RegistryAuthProvider
}
func NewClientWithRegistry(registryClient *RegistryClient, clientFactory *dockerclient.ClientFactory) *DigestClient {
func NewClientWithRegistry(registryClient RegistryAuthProvider, clientFactory ClientFactory) *DigestClient {
return &DigestClient{
clientFactory: clientFactory,
registryClient: registryClient,
}
}
func (c *DigestClient) RemoteDigest(image Image) (digest.Digest, error) {
ctx, cancel := c.timeoutContext()
func (c *DigestClient) RemoteDigest(ctx context.Context, image Image) (digest.Digest, error) {
ctx, cancel := context.WithTimeout(ctx, digestFetchTimeout)
defer cancel()
// Docker references with both a tag and digest are currently not supported
@@ -170,14 +176,3 @@ func ParseRepoTag(repoTag string) *Image {
return &image
}
func (c *DigestClient) timeoutContext() (context.Context, context.CancelFunc) {
ctx := context.Background()
var cancel context.CancelFunc = func() {}
if c.opts.Timeout > 0 {
ctx, cancel = context.WithTimeout(ctx, c.opts.Timeout)
}
return ctx, cancel
}

View File

@@ -24,14 +24,22 @@ func NewRegistryClient(dataStore dataservices.DataStore) *RegistryClient {
}
func (c *RegistryClient) RegistryAuth(image Image) (string, string, error) {
registries, err := c.dataStore.Registry().ReadAll()
registry, err := cachedRegistry(image.Opts.Name)
if err != nil {
return "", "", err
}
var registries []portainer.Registry
err = c.dataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
var err error
registries, err = tx.Registry().ReadAll()
return err
})
if err != nil {
return "", "", err
}
registry, err := findBestMatchRegistry(image.Opts.Name, registries)
if err != nil {
return "", "", err
registry, err = findBestMatchRegistry(image.Opts.Name, registries)
if err != nil {
return "", "", err
}
}
if !registry.Authentication {
@@ -54,14 +62,22 @@ func (c *RegistryClient) CertainRegistryAuth(registry *portainer.Registry) (stri
}
func (c *RegistryClient) EncodedRegistryAuth(image Image) (string, error) {
registries, err := c.dataStore.Registry().ReadAll()
registry, err := cachedRegistry(image.Opts.Name)
if err != nil {
return "", err
}
var registries []portainer.Registry
err = c.dataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
var err error
registries, err = tx.Registry().ReadAll()
return err
})
if err != nil {
return "", err
}
registry, err := findBestMatchRegistry(image.Opts.Name, registries)
if err != nil {
return "", err
registry, err = findBestMatchRegistry(image.Opts.Name, registries)
if err != nil {
return "", err
}
}
if !registry.Authentication {
@@ -121,7 +137,7 @@ func findBestMatchRegistry(repository string, registries []portainer.Registry) (
return nil, errors.New("no registries matched")
}
registriesCache.Set(repository, match, 0)
registriesCache.Set(repository, *match, 0)
return match, nil
}

View File

@@ -57,6 +57,21 @@ func TestFindBestMatchNeedAuthRegistry(t *testing.T) {
})
}
func TestFindBestMatchRegistryCachesResult(t *testing.T) {
t.Parallel()
repository := "caching-test/nginx:latest"
registries := []portainer.Registry{createNewRegistry("docker.io", "", true)}
r, err := findBestMatchRegistry(repository, registries)
require.NoError(t, err)
cached, err := cachedRegistry(repository)
require.NoError(t, err)
require.Equal(t, r.URL, cached.URL)
require.Equal(t, r.Authentication, cached.Authentication)
}
func createNewRegistry(domain, username string, auth bool) portainer.Registry {
registry := portainer.Registry{
URL: domain,

View File

@@ -16,6 +16,7 @@ import (
"github.com/patrickmn/go-cache"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
"golang.org/x/sync/errgroup"
)
// Status constants
@@ -28,6 +29,11 @@ const (
Error = Status("error")
)
const (
errorStatusCacheTTL = 5 * time.Minute
maxConcurrentStatusChecks = 8
)
var (
statusCache = cache.New(24*time.Hour, 24*time.Hour)
remoteDigestCache = cache.New(5*time.Second, 5*time.Second)
@@ -46,13 +52,17 @@ func (c *DigestClient) ContainersImageStatus(ctx context.Context, containers []t
}
statuses := make([]Status, len(containers))
for i, ct := range containers {
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(maxConcurrentStatusChecks)
containerStatus := func(ct types.Container) Status {
var nodeName string
if swarmNodeId := ct.Labels[consts.SwarmNodeIDLabel]; swarmNodeId != "" {
if swarmNodeName, ok := swarmID2NameCache.Get(swarmNodeId); ok {
nodeName, _ = swarmNodeName.(string)
} else {
node, _, err := cli.NodeInspectWithRaw(ctx, ct.Labels[consts.SwarmNodeIDLabel])
node, _, err := cli.NodeInspectWithRaw(ctx, swarmNodeId)
if err != nil {
return Error
}
@@ -64,23 +74,26 @@ func (c *DigestClient) ContainersImageStatus(ctx context.Context, containers []t
s, err := c.ContainerImageStatus(ctx, ct.ID, endpoint, nodeName)
if err != nil {
statuses[i] = Error
log.Warn().Str("containerId", ct.ID).Err(err).Msg("error when fetching image status for container")
continue
return Error
}
statuses[i] = s
if s == Outdated || s == Processing {
break
}
return s
}
return FigureOut(statuses)
for i, ct := range containers {
g.Go(func() error {
statuses[i] = containerStatus(ct)
return nil
})
}
_ = g.Wait()
return AggregateImageStatus(statuses)
}
func FigureOut(statuses []Status) Status {
func AggregateImageStatus(statuses []Status) Status {
if allMatch(statuses, Skipped) {
return Skipped
}
@@ -141,7 +154,7 @@ func (c *DigestClient) ContainerImageStatus(ctx context.Context, containerID str
images = append(images, ParseRepoTags(imageInspect.RepoTags)...)
}
s, err := c.checkStatus(images, digs)
s, err := c.checkStatus(ctx, images, digs)
if err != nil {
log.Debug().Str("image", container.Image).Err(err).Msg("fetching a certain image status")
return Error, err
@@ -191,7 +204,7 @@ func (c *DigestClient) ServiceImageStatus(ctx context.Context, serviceID string,
return c.ContainersImageStatus(ctx, nonExistedOrStoppedContainers, endpoint), nil
}
func (c *DigestClient) checkStatus(images []*Image, digests []digest.Digest) (Status, error) {
func (c *DigestClient) checkStatus(ctx context.Context, images []*Image, digests []digest.Digest) (Status, error) {
if digests == nil {
digests = make([]digest.Digest, 0)
}
@@ -216,7 +229,7 @@ func (c *DigestClient) checkStatus(images []*Image, digests []digest.Digest) (St
remoteDigest, _ = rd.(digest.Digest)
}
if remoteDigest == "" {
remoteDigest, err = c.RemoteDigest(*img)
remoteDigest, err = c.RemoteDigest(ctx, *img)
if err != nil {
log.Error().Str("image", img.String()).Msg("error when fetch remote digest for image")
return Error, err
@@ -263,6 +276,10 @@ func CacheResourceImageStatus(resourceID string, status Status) {
statusCache.Set(resourceID, status, 0)
}
func CacheErrorImageStatus(resourceID string) {
statusCache.Set(resourceID, Error, errorStatusCacheTTL)
}
func CachedImageDigest(resourceID string) (Status, error) {
if s, ok := statusCache.Get(resourceID); ok {
return s.(Status), nil

View File

@@ -0,0 +1,63 @@
package images
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestAggregateImageStatus(t *testing.T) {
t.Parallel()
f := func(statuses []Status, expected Status) {
t.Helper()
require.Equal(t, expected, AggregateImageStatus(statuses))
}
f([]Status{Skipped, Skipped, Skipped}, Skipped)
f([]Status{Preparing, Preparing}, Preparing)
f([]Status{Updated, Outdated, Processing, Error}, Outdated)
f([]Status{Updated, Processing, Error}, Processing)
f([]Status{Updated, Error}, Error)
f([]Status{Updated, Updated}, Updated)
f([]Status{}, Updated)
f([]Status{Updated, Skipped}, Updated)
}
func TestCachedResourceImageStatusMiss(t *testing.T) {
t.Parallel()
_, err := CachedResourceImageStatus("status-test-miss-key")
require.Error(t, err)
}
func TestCachedResourceImageStatusHitAndEvict(t *testing.T) {
t.Parallel()
key := "status-test-hit-evict-key"
CacheResourceImageStatus(key, Updated)
s, err := CachedResourceImageStatus(key)
require.NoError(t, err)
require.Equal(t, Updated, s)
EvictImageStatus(key)
_, err = CachedResourceImageStatus(key)
require.Error(t, err)
}
func TestCacheErrorImageStatus(t *testing.T) {
t.Parallel()
key := "status-test-error-key"
CacheErrorImageStatus(key)
s, err := CachedResourceImageStatus(key)
require.NoError(t, err)
require.Equal(t, Error, s)
EvictImageStatus(key)
}

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.RefreshAndPersistECRTokens with a real DataStoreTx). This function
// intentionally performs no DB writes to avoid write-lock contention when called inside
// an active BoltDB write transaction.
func portainerRegistriesToAuthConfigs(registries []portainer.Registry) []types.AuthConfig {
var authConfigs []types.AuthConfig
for _, r := range registries {
ac := types.AuthConfig{
Username: r.Username,
Password: r.Password,
ServerAddress: r.URL,
}
if r.Authentication {
var err error
ac.Username, ac.Password, err = getEffectiveRegUsernamePassword(&r)
if err != nil {
continue
}
}
authConfigs = append(authConfigs, ac)
}
return authConfigs
}
func getEffectiveRegUsernamePassword(registry *portainer.Registry) (string, string, error) {
username, password, err := registryutils.GetRegEffectiveCredential(registry)
if err != nil {
log.Warn().
Err(err).
Str("RegistryName", registry.Name).
Msg("Failed to get effective credential. Skip logging with this registry.")
}
return username, password, err
}

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"
@@ -91,7 +89,7 @@ func JoinPaths(trustedRoot string, untrustedPaths ...string) string {
trustedRoot = "."
}
p := filepath.Join(trustedRoot, filepath.Join(append([]string{"/"}, untrustedPaths...)...)) //nolint:forbidigo
p := filepath.Join(trustedRoot, filepath.Join(append([]string{"/"}, untrustedPaths...)...))
// avoid setting a volume name from the untrusted paths
vnp := filepath.VolumeName(p)
@@ -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

@@ -15,7 +15,7 @@ type DirEntry struct {
Name string
Content string
IsFile bool
Permissions os.FileMode
Permissions os.FileMode `swaggertype:"integer"`
}
// FilterDirForEntryFile filers the given dirEntries, returns entries of the entryFile and .env file

View File

@@ -14,12 +14,13 @@ import (
"github.com/portainer/portainer/api/crypto"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/logs"
"github.com/rs/zerolog/log"
"github.com/portainer/portainer/pkg/libhttp/ssrf"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing/filemode"
githttp "github.com/go-git/go-git/v5/plumbing/transport/http"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
"github.com/segmentio/encoding/json"
)
@@ -64,15 +65,10 @@ func NewAzureClient() *azureClient {
}
func newHttpClientForAzure(insecureSkipVerify bool) *http.Client {
httpsCli := &http.Client{
Transport: &http.Transport{
TLSClientConfig: crypto.CreateTLSConfiguration(insecureSkipVerify),
Proxy: http.ProxyFromEnvironment,
},
Timeout: 300 * time.Second,
return &http.Client{
Transport: ssrf.NewTransport(crypto.CreateTLSConfiguration(insecureSkipVerify)),
Timeout: 300 * time.Second,
}
return httpsCli
}
func (a *azureClient) Download(ctx context.Context, destination string, opt *git.CloneOptions) error {

View File

@@ -3,6 +3,7 @@ package git
import (
"context"
"os"
"path/filepath"
"strings"
"github.com/portainer/portainer/api/filesystem"
@@ -47,11 +48,19 @@ func NewGitClient(preserveGitDir bool) *gitClient {
}
func (c *gitClient) Download(ctx context.Context, dst string, opt *git.CloneOptions) error {
resolved, err := filepath.EvalSymlinks(dst)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return errors.Wrap(err, "failed to resolve destination path")
}
if err == nil {
dst = resolved
}
wt := NewNoSymlinkFS(osfs.New(dst))
dot := osfs.New(filesystem.JoinPaths(dst, ".git"))
storer := gogitfs.NewStorage(dot, cache.NewObjectLRU(0))
_, err := git.CloneContext(ctx, storer, wt, opt)
_, err = git.CloneContext(ctx, storer, wt, opt)
if err != nil {
if err.Error() == "authentication required" {
return gittypes.ErrAuthenticationFailure
@@ -77,7 +86,7 @@ func (c *gitClient) LatestCommitID(ctx context.Context, repositoryUrl, reference
URLs: []string{repositoryUrl},
})
refs, err := remote.List(opt)
refs, err := remote.ListContext(ctx, opt)
if err != nil {
if err.Error() == "authentication required" {
return "", gittypes.ErrAuthenticationFailure
@@ -109,7 +118,7 @@ func (c *gitClient) ListRefs(ctx context.Context, repositoryUrl string, opt *git
URLs: []string{repositoryUrl},
})
refs, err := rem.List(opt)
refs, err := rem.ListContext(ctx, opt)
if err != nil {
return nil, checkGitError(err)
}

View File

@@ -99,6 +99,19 @@ func Test_ClonePublicRepository_NoGitDirectory(t *testing.T) {
assert.NoDirExists(t, filesystem.JoinPaths(dir, ".git"))
}
func Test_ClonePublicRepository_NonExistentDst(t *testing.T) {
t.Parallel()
service := Service{git: NewGitClient(false)}
repositoryURL := setup(t)
referenceName := "refs/heads/main"
dir := filesystem.JoinPaths(t.TempDir(), "sub", "dir")
err := service.CloneRepository(t.Context(), dir, repositoryURL, referenceName, "", "", false)
require.NoError(t, err)
assert.DirExists(t, dir)
assert.NoDirExists(t, filesystem.JoinPaths(dir, ".git"))
}
func Test_latestCommitID(t *testing.T) {
t.Parallel()
service := Service{git: NewGitClient(true)} // no need for http client since the test access the repo via file system.
@@ -262,6 +275,7 @@ func createBareRepoWithSymlink(t *testing.T) string {
}
func Test_Download_RejectsSymlink(t *testing.T) {
t.Parallel()
client := NewGitClient(false)
repoURL := createBareRepoWithSymlink(t)

53
api/git/ssrf_transport.go Normal file
View File

@@ -0,0 +1,53 @@
package git
import (
"context"
"fmt"
"net"
"strconv"
"github.com/portainer/portainer/pkg/libhttp/ssrf"
gittransport "github.com/go-git/go-git/v5/plumbing/transport"
)
const gitDefaultPort = 9418
// ssrfGitTransport wraps a git:// transport and validates the resolved IP
// against the SSRF policy before establishing connections.
type ssrfGitTransport struct {
inner gittransport.Transport
}
// NewSSRFGitTransport wraps inner and blocks connections to private IP ranges
// according to the active SSRF policy.
func NewSSRFGitTransport(inner gittransport.Transport) gittransport.Transport {
return &ssrfGitTransport{inner: inner}
}
func (t *ssrfGitTransport) NewUploadPackSession(ep *gittransport.Endpoint, auth gittransport.AuthMethod) (gittransport.UploadPackSession, error) {
if err := checkEndpointSSRF(ep); err != nil {
return nil, err
}
return t.inner.NewUploadPackSession(ep, auth)
}
func (t *ssrfGitTransport) NewReceivePackSession(ep *gittransport.Endpoint, auth gittransport.AuthMethod) (gittransport.ReceivePackSession, error) {
if err := checkEndpointSSRF(ep); err != nil {
return nil, err
}
return t.inner.NewReceivePackSession(ep, auth)
}
func checkEndpointSSRF(ep *gittransport.Endpoint) error {
port := ep.Port
if port <= 0 {
port = gitDefaultPort
}
rawURL := fmt.Sprintf("git://%s/", net.JoinHostPort(ep.Host, strconv.Itoa(port)))
return ssrf.CheckURL(context.Background(), rawURL)
}

View File

@@ -1,7 +1,11 @@
package gittypes
import (
"cmp"
"errors"
"net/url"
"path"
"strings"
)
var (
@@ -10,6 +14,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
@@ -20,18 +28,72 @@ type RepoConfig struct {
// NOTE: For stacks, this mirrors Stack.EntryPoint and the two are kept in sync by stackUpdateGit.
ConfigFilePath string `example:"docker-compose.yml"`
// Git credentials
Authentication *GitAuthentication
Authentication *GitAuthentication `json:",omitempty"`
// Repository hash
ConfigHash string `example:"bc4c183d756879ea4d173315338110b31004b8e0"`
// TLSSkipVerify skips SSL verification when cloning the Git repository
TLSSkipVerify bool `example:"false"`
}
type GitAuthentication struct {
Username string
Password string
// 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
GitCredentialID int `example:"0"`
// RepoName extracts the repository name from a git URL for use as a display name.
// e.g. "https://github.com/org/app-config.git" results in "app-config"
func RepoName(rawURL string) string {
return strings.TrimSuffix(path.Base(rawURL), ".git")
}
// NormalizeURL returns a canonical form of rawURL for deduplication purposes:
// scheme and host are lowercased, embedded credentials are removed, trailing
// slashes and the .git suffix are stripped from the path. If the scheme is
// absent it defaults to https.
func NormalizeURL(rawURL string) (string, error) {
u, err := url.Parse(rawURL)
if err != nil {
return "", err
}
u.Scheme = strings.ToLower(cmp.Or(u.Scheme, "https"))
u.Host = strings.ToLower(u.Host)
u.User = nil
u.Path = strings.TrimSuffix(strings.TrimRight(u.Path, "/"), ".git")
return u.String(), nil
}
// SanitizeURL strips any userinfo (username/password) embedded in rawURL,
// returning a URL safe to store or return to clients.
func SanitizeURL(rawURL string) string {
u, err := url.Parse(rawURL)
if err != nil || u.User == nil {
return rawURL
}
u.User = nil
return u.String()
}
// SanitizeRepoConfig returns a copy of gc with the URL sanitized and password cleared,
// safe to return to clients.
func SanitizeRepoConfig(gc *RepoConfig) *RepoConfig {
if gc == nil {
return nil
}
result := *gc
result.URL = SanitizeURL(result.URL)
if result.Authentication != nil && result.Authentication.Password != "" {
auth := *result.Authentication
auth.Password = ""
result.Authentication = &auth
}
return &result
}
type GitAuthentication struct {
Username string
Password string
Provider GitProvider `json:",omitempty"`
AuthorizationType GitCredentialAuthType `json:",omitempty"`
}

View File

@@ -0,0 +1,26 @@
package gittypes
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestNormalizeURL(t *testing.T) {
t.Parallel()
f := func(input, expected string) {
t.Helper()
got, err := NormalizeURL(input)
require.NoError(t, err)
require.Equal(t, expected, got)
}
f("https://github.com/org/repo.git", "https://github.com/org/repo")
f("https://github.com/org/repo/", "https://github.com/org/repo")
f("https://github.com/org/repo.git/", "https://github.com/org/repo")
f("HTTPS://github.com/org/repo", "https://github.com/org/repo")
f("https://GitHub.COM/org/repo", "https://github.com/org/repo")
f("https://user:pass@github.com/org/repo.git", "https://github.com/org/repo")
f("https://github.com/org/repo", "https://github.com/org/repo")
}

View File

@@ -2,7 +2,9 @@ package update
import (
"context"
"os"
"strings"
"time"
"github.com/pkg/errors"
@@ -27,15 +29,21 @@ func UpdateGitObject(ctx context.Context, gitService portainer.GitService, objId
username, password := git.GetCredentials(gitConfig.Authentication)
fetchCtx, cancel := context.WithTimeout(ctx, time.Minute)
newHash, err := gitService.LatestCommitID(
ctx,
fetchCtx,
gitConfig.URL,
gitConfig.ReferenceName,
username,
password,
gitConfig.TLSSkipVerify,
)
cancel()
if err != nil {
if fetchCtx.Err() == context.DeadlineExceeded {
log.Error().Str("object", objId).Msg("git fetch timed out after 1 minute")
}
return false, "", errors.WithMessagef(err, "failed to fetch latest commit id of %v", objId)
}
@@ -71,6 +79,11 @@ func UpdateGitObject(ctx context.Context, gitService portainer.GitService, objId
}
if err := cloneGitRepository(ctx, gitService, cloneParams); err != nil {
if enableVersionFolder {
if removeErr := os.RemoveAll(toDir); removeErr != nil {
log.Warn().Err(removeErr).Str("dir", toDir).Msg("failed to remove partial clone directory")
}
}
return false, "", errors.WithMessagef(err, "failed to do a fresh clone of %v", objId)
}

View File

@@ -0,0 +1,55 @@
package sources
import (
portainer "github.com/portainer/portainer/api"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/pkg/fips"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
)
// RepoConfigInput holds the raw payload fields needed to resolve a git RepoConfig.
// Set SourceID to resolve URL/auth from a stored source; otherwise provide the inline fields.
type RepoConfigInput struct {
SourceID portainer.SourceID
ReferenceName string
ConfigFilePath string
RepositoryURL string
TLSSkipVerify bool
RepositoryAuthentication bool
Username string
Password string
Provider gittypes.GitProvider
AuthorizationType gittypes.GitCredentialAuthType
}
// ResolveRepoConfig builds a RepoConfig from either a SourceID or inline URL/auth fields.
func ResolveRepoConfig(tx gitSourceStore, input RepoConfigInput) (gittypes.RepoConfig, *httperror.HandlerError) {
cfg := gittypes.RepoConfig{
ReferenceName: input.ReferenceName,
ConfigFilePath: input.ConfigFilePath,
}
if input.SourceID != 0 {
src, httpErr := ValidateGitSourceAccess(tx, input.SourceID)
if httpErr != nil {
return gittypes.RepoConfig{}, httpErr
}
cfg.URL = src.Git.URL
cfg.Authentication = src.Git.Authentication
cfg.TLSSkipVerify = src.Git.TLSSkipVerify
} else {
cfg.URL = input.RepositoryURL
cfg.TLSSkipVerify = input.TLSSkipVerify
if input.RepositoryAuthentication {
cfg.Authentication = &gittypes.GitAuthentication{
Username: input.Username,
Password: input.Password,
Provider: input.Provider,
AuthorizationType: input.AuthorizationType,
}
}
}
cfg.TLSSkipVerify = cfg.TLSSkipVerify && fips.CanTLSSkipVerify()
return cfg, nil
}

View File

@@ -0,0 +1,70 @@
package sources
import (
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/datastore"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/pkg/fips"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func init() {
fips.InitFIPS(false)
}
func TestResolveRepoConfig_WithSourceID_ReturnsSourceConfig(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, false)
src := &portainer.Source{
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{
URL: "https://github.com/org/repo",
TLSSkipVerify: true,
Authentication: &gittypes.GitAuthentication{
Username: "user",
Password: "token",
},
},
}
require.NoError(t, store.Source().Create(src))
cfg, httpErr := ResolveRepoConfig(store, RepoConfigInput{
SourceID: src.ID,
ReferenceName: "refs/heads/main",
ConfigFilePath: "docker-compose.yml",
RepositoryURL: "https://ignored.example.com",
})
require.Nil(t, httpErr)
assert.Equal(t, src.Git.URL, cfg.URL)
assert.Equal(t, src.Git.Authentication, cfg.Authentication)
assert.Equal(t, src.Git.TLSSkipVerify, cfg.TLSSkipVerify)
assert.Equal(t, "refs/heads/main", cfg.ReferenceName)
assert.Equal(t, "docker-compose.yml", cfg.ConfigFilePath)
}
func TestResolveRepoConfig_WithInlineURL_ReturnsInlineConfig(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, false)
cfg, httpErr := ResolveRepoConfig(store, RepoConfigInput{
ReferenceName: "refs/heads/main",
ConfigFilePath: "docker-compose.yml",
RepositoryURL: "https://github.com/org/repo",
TLSSkipVerify: true,
RepositoryAuthentication: true,
Username: "user",
Password: "pass",
})
require.Nil(t, httpErr)
assert.Equal(t, "https://github.com/org/repo", cfg.URL)
assert.True(t, cfg.TLSSkipVerify)
require.NotNil(t, cfg.Authentication)
assert.Equal(t, "user", cfg.Authentication.Username)
assert.Equal(t, "pass", cfg.Authentication.Password)
}

View File

@@ -0,0 +1,38 @@
package sources
import (
"fmt"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
)
// gitSourceStore is the minimal intersection of CE and EE DataStoreTx that these functions need.
// Both EE and CE DataStoreTx satisfy it, even though they are incompatible as full interface types.
type gitSourceStore interface {
Source() dataservices.SourceService
IsErrObjectNotFound(err error) bool
}
// ValidateGitSourceAccess checks that the given Source exists and is a git Source, and returns it.
// TODO(BE-12905): enforce per-user access policies once Source ownership is introduced.
func ValidateGitSourceAccess(tx gitSourceStore, sourceID portainer.SourceID) (*portainer.Source, *httperror.HandlerError) {
src, err := tx.Source().Read(sourceID)
if err != nil {
if tx.IsErrObjectNotFound(err) {
return nil, httperror.NotFound("Source not found", err)
}
return nil, httperror.InternalServerError("Unable to read source", err)
}
if src.Type != portainer.SourceTypeGit {
return nil, httperror.BadRequest(fmt.Sprintf("source %d is not a git source", sourceID), nil)
}
if src.Git == nil {
return nil, httperror.BadRequest("Source has no git configuration", nil)
}
return src, nil
}

View File

@@ -0,0 +1,49 @@
package sources
import (
"net/http"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/datastore"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestValidateSourceForStack_ValidGitSource_ReturnsNil(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, false)
src := &portainer.Source{
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{URL: "https://github.com/org/repo"},
}
require.NoError(t, store.Source().Create(src))
_, httpErr := ValidateGitSourceAccess(store, src.ID)
assert.Nil(t, httpErr)
}
func TestValidateSourceForStack_SourceNotFound_Returns404(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, false)
_, httpErr := ValidateGitSourceAccess(store, portainer.SourceID(999))
require.NotNil(t, httpErr)
assert.Equal(t, http.StatusNotFound, httpErr.StatusCode)
}
func TestValidateSourceForStack_NonGitSource_Returns400(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, false)
src := &portainer.Source{
Type: portainer.SourceType(99), // not a git source
}
require.NoError(t, store.Source().Create(src))
_, httpErr := ValidateGitSourceAccess(store, src.ID)
require.NotNil(t, httpErr)
assert.Equal(t, http.StatusBadRequest, httpErr.StatusCode)
}

View File

@@ -0,0 +1,203 @@
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,
tx dataservices.DataStoreTx,
gitService portainer.GitService,
k8sFactory *cli.ClientFactory,
sc *security.RestrictedRequestContext,
endpointIDSet set.Set[portainer.EndpointID],
) ([]Workflow, error) {
gitConfigs := map[portainer.StackID]*gittypes.RepoConfig{}
stacks, err := tx.Stack().ReadAll(func(s portainer.Stack) bool {
return s.WorkflowID != 0 && (len(endpointIDSet) == 0 || endpointIDSet.Contains(s.EndpointID))
})
if err != nil {
return nil, err
}
endpointMap, err := buildEndpointMap(tx, stacks)
if err != nil {
return nil, err
}
stacks, err = filterDockerStacksByAccess(tx, stacks, sc)
if err != nil {
return nil, err
}
// First pass: filter by endpoint/stack-type match and collect workflow IDs.
preFiltered := make([]portainer.Stack, 0, len(stacks))
workflowIDSet := make(set.Set[portainer.WorkflowID], len(stacks))
for _, stack := range stacks {
if ep, ok := endpointMap[stack.EndpointID]; ok && !EndpointMatchesStackType(ep, stack.Type) {
continue
}
preFiltered = append(preFiltered, stack)
workflowIDSet.Add(stack.WorkflowID)
}
workflowMap, sourceMap, err := LoadWorkflowAndSourceMaps(tx, workflowIDSet)
if err != nil {
return nil, err
}
// Second pass: build filtered list using in-memory lookups.
var filtered []portainer.Stack
for _, stack := range preFiltered {
wf := workflowMap[stack.WorkflowID]
outer:
for _, as := range wf.Artifacts {
if as.StackID != stack.ID {
continue
}
for _, f := range as.Files {
src := sourceMap[f.SourceID]
if src.Type == portainer.SourceTypeGit {
gitConfigs[stack.ID] = MergeSourceAndFile(&src, &f)
break outer
}
}
}
filtered = append(filtered, stack)
}
stacks = filtered
accessMap, err := buildEndpointAccessMap(k8sFactory, sc, endpointMap)
if err != nil {
return nil, err
}
stacks, err = filterK8SStacks(stacks, endpointMap, k8sFactory, accessMap)
if err != nil {
return nil, err
}
items := make([]Workflow, 0, len(stacks))
for _, stack := range stacks {
gitConfig := gitConfigs[stack.ID]
source, artifact := ComputeGitPhasesForConfig(ctx, gitService, gitConfig)
items = append(items, MapStackToWorkflow(stack, gitConfig, source, artifact))
}
return items, nil
}
// SourceStats holds aggregated statistics for a GitOps source.
type SourceStats struct {
WorkflowCount int
EndpointIDs set.Set[portainer.EndpointID]
LastSync int64
}
// FetchSourceStats returns all sources and per-source stats for sources accessible to the given user.
// It applies the same access control as FetchWorkflows but skips git phase checks.
func FetchSourceStats(
tx dataservices.DataStoreTx,
k8sFactory *cli.ClientFactory,
sc *security.RestrictedRequestContext,
) ([]portainer.Source, map[portainer.SourceID]SourceStats, error) {
sources, err := tx.Source().ReadAll()
if err != nil {
return nil, nil, err
}
allStacks, err := tx.Stack().ReadAll(func(s portainer.Stack) bool { return s.WorkflowID != 0 })
if err != nil {
return nil, nil, err
}
endpointMap, err := buildEndpointMap(tx, allStacks)
if err != nil {
return nil, nil, err
}
allStacks, err = filterDockerStacksByAccess(tx, allStacks, sc)
if err != nil {
return nil, nil, err
}
workflowIDSet := make(set.Set[portainer.WorkflowID], len(allStacks))
preFiltered := make([]portainer.Stack, 0, len(allStacks))
for _, stack := range allStacks {
if ep, ok := endpointMap[stack.EndpointID]; ok && !EndpointMatchesStackType(ep, stack.Type) {
continue
}
preFiltered = append(preFiltered, stack)
workflowIDSet.Add(stack.WorkflowID)
}
wfMap, err := LoadWorkflowMap(tx, workflowIDSet)
if err != nil {
return nil, nil, err
}
wfSources := make(map[portainer.WorkflowID][]portainer.SourceID, len(wfMap))
for id, wf := range wfMap {
for _, as := range wf.Artifacts {
for _, f := range as.Files {
wfSources[id] = append(wfSources[id], f.SourceID)
}
}
}
stackSourceIDs := make(map[portainer.StackID][]portainer.SourceID)
for _, stack := range preFiltered {
if srcIDs := wfSources[stack.WorkflowID]; len(srcIDs) > 0 {
stackSourceIDs[stack.ID] = srcIDs
}
}
accessMap, err := buildEndpointAccessMap(k8sFactory, sc, endpointMap)
if err != nil {
return nil, nil, err
}
stacks, err := filterK8SStacks(preFiltered, endpointMap, k8sFactory, accessMap)
if err != nil {
return nil, nil, err
}
stats := make(map[portainer.SourceID]SourceStats)
for _, stack := range stacks {
var epIDs []portainer.EndpointID
if stack.EndpointID != 0 {
epIDs = []portainer.EndpointID{stack.EndpointID}
}
addSourceStats(stats, stackSourceIDs[stack.ID], epIDs, StackLastSyncDate(stack))
}
return sources, stats, nil
}
func addSourceStats(result map[portainer.SourceID]SourceStats, srcIDs []portainer.SourceID, epIDs []portainer.EndpointID, lastSync int64) {
for _, srcID := range srcIDs {
st := result[srcID]
if st.EndpointIDs == nil {
st.EndpointIDs = make(set.Set[portainer.EndpointID])
}
st.WorkflowCount++
for _, epID := range epIDs {
st.EndpointIDs.Add(epID)
}
st.LastSync = max(lastSync, st.LastSync)
result[srcID] = st
}
}

View File

@@ -0,0 +1,282 @@
package workflows
import (
"strconv"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/datastore"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/set"
"github.com/stretchr/testify/require"
)
func adminContext() *security.RestrictedRequestContext {
return &security.RestrictedRequestContext{IsAdmin: true, UserID: 1}
}
func mustCreateGitWorkflow(t *testing.T, tx dataservices.DataStoreTx, stack *portainer.Stack) {
t.Helper()
cfg := stack.GitConfig
src := &portainer.Source{Type: portainer.SourceTypeGit, Git: cfg}
require.NoError(t, tx.Source().Create(src))
wf := &portainer.Workflow{Artifacts: []portainer.Artifact{{
StackID: stack.ID,
Files: []portainer.ArtifactFile{{SourceID: src.ID}},
}}}
require.NoError(t, tx.Workflow().Create(wf))
stack.WorkflowID = wf.ID
stack.GitConfig = nil
require.NoError(t, tx.Stack().Create(stack))
}
func TestAddSourceStats_NoOp(t *testing.T) {
t.Parallel()
result := make(map[portainer.SourceID]SourceStats)
addSourceStats(result, nil, nil, 0)
require.Empty(t, result)
}
func TestAddSourceStats_AccumulatesWorkflowCount(t *testing.T) {
t.Parallel()
result := make(map[portainer.SourceID]SourceStats)
addSourceStats(result, []portainer.SourceID{1}, nil, 0)
addSourceStats(result, []portainer.SourceID{1}, nil, 0)
require.Equal(t, 2, result[1].WorkflowCount)
}
func TestAddSourceStats_CollectsUniqueEndpointIDs(t *testing.T) {
t.Parallel()
result := make(map[portainer.SourceID]SourceStats)
addSourceStats(result, []portainer.SourceID{1}, []portainer.EndpointID{10, 20}, 0)
addSourceStats(result, []portainer.SourceID{1}, []portainer.EndpointID{20, 30}, 0)
require.Len(t, result[1].EndpointIDs, 3)
require.True(t, result[1].EndpointIDs[10])
require.True(t, result[1].EndpointIDs[20])
require.True(t, result[1].EndpointIDs[30])
}
func TestAddSourceStats_MaxLastSync(t *testing.T) {
t.Parallel()
result := make(map[portainer.SourceID]SourceStats)
addSourceStats(result, []portainer.SourceID{1}, nil, 100)
addSourceStats(result, []portainer.SourceID{1}, nil, 500)
addSourceStats(result, []portainer.SourceID{1}, nil, 200)
require.Equal(t, int64(500), result[1].LastSync)
}
func TestAddSourceStats_MultipleSourceIDs(t *testing.T) {
t.Parallel()
result := make(map[portainer.SourceID]SourceStats)
addSourceStats(result, []portainer.SourceID{1, 2}, []portainer.EndpointID{10}, 100)
require.Equal(t, 1, result[1].WorkflowCount)
require.Equal(t, 1, result[2].WorkflowCount)
require.True(t, result[1].EndpointIDs[10])
require.True(t, result[2].EndpointIDs[10])
}
func TestFetchWorkflows_ReturnsOnlyGitopsStacks(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
mustCreateGitWorkflow(t, tx, &portainer.Stack{
ID: 1,
Name: "gitops-stack",
GitConfig: &gittypes.RepoConfig{URL: "https://github.com/x/repo"},
})
require.NoError(t, tx.Stack().Create(&portainer.Stack{ID: 2, Name: "plain-stack"}))
return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})
}))
var items []Workflow
require.NoError(t, store.ViewTx(func(tx dataservices.DataStoreTx) error {
var err error
items, err = FetchWorkflows(t.Context(), tx, nil, nil, adminContext(), nil)
return err
}))
require.Len(t, items, 1)
require.Equal(t, "gitops-stack", items[0].Name)
}
func TestFetchWorkflows_FiltersByEndpointID(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
for i := 1; i <= 3; i++ {
mustCreateGitWorkflow(t, tx, &portainer.Stack{
ID: portainer.StackID(i),
Name: "stack-" + strconv.Itoa(i),
EndpointID: portainer.EndpointID(i),
GitConfig: &gittypes.RepoConfig{URL: "https://github.com/x/" + strconv.Itoa(i)},
})
}
return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})
}))
var items []Workflow
require.NoError(t, store.ViewTx(func(tx dataservices.DataStoreTx) error {
var err error
items, err = FetchWorkflows(t.Context(), tx, nil, nil, adminContext(), set.ToSet([]portainer.EndpointID{1, 2}))
return err
}))
require.Len(t, items, 2)
names := []string{items[0].Name, items[1].Name}
require.Contains(t, names, "stack-1")
require.Contains(t, names, "stack-2")
}
func TestFetchWorkflows_EmptyWhenNoGitopsStacks(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
require.NoError(t, tx.Stack().Create(&portainer.Stack{ID: 1, Name: "plain-1"}))
require.NoError(t, tx.Stack().Create(&portainer.Stack{ID: 2, Name: "plain-2"}))
return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})
}))
var items []Workflow
require.NoError(t, store.ViewTx(func(tx dataservices.DataStoreTx) error {
var err error
items, err = FetchWorkflows(t.Context(), tx, nil, nil, adminContext(), nil)
return err
}))
require.Empty(t, items)
}
func TestFetchWorkflows_NilEndpointSetReturnsAll(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
for i := 1; i <= 3; i++ {
mustCreateGitWorkflow(t, tx, &portainer.Stack{
ID: portainer.StackID(i),
Name: "stack-" + strconv.Itoa(i),
EndpointID: portainer.EndpointID(i),
GitConfig: &gittypes.RepoConfig{URL: "https://github.com/x/" + strconv.Itoa(i)},
})
}
return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})
}))
var items []Workflow
require.NoError(t, store.ViewTx(func(tx dataservices.DataStoreTx) error {
var err error
items, err = FetchWorkflows(t.Context(), tx, nil, nil, adminContext(), nil)
return err
}))
require.Len(t, items, 3)
}
func TestFetchSourceStats_ReturnsAllSources(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
require.NoError(t, tx.Source().Create(&portainer.Source{Name: "source-1", Type: portainer.SourceTypeGit}))
require.NoError(t, tx.Source().Create(&portainer.Source{Name: "source-2", Type: portainer.SourceTypeGit}))
return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})
}))
var sources []portainer.Source
require.NoError(t, store.ViewTx(func(tx dataservices.DataStoreTx) error {
var err error
sources, _, err = FetchSourceStats(tx, nil, adminContext())
return err
}))
require.Len(t, sources, 2)
}
func TestFetchSourceStats_TracksWorkflowCountAndEndpoints(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
var srcID portainer.SourceID
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
src := &portainer.Source{Name: "shared", Type: portainer.SourceTypeGit}
require.NoError(t, tx.Source().Create(src))
srcID = src.ID
for i := 1; i <= 2; i++ {
wf := &portainer.Workflow{Artifacts: []portainer.Artifact{{Files: []portainer.ArtifactFile{{SourceID: srcID}}}}}
require.NoError(t, tx.Workflow().Create(wf))
require.NoError(t, tx.Stack().Create(&portainer.Stack{
ID: portainer.StackID(i),
Name: "stack-" + strconv.Itoa(i),
EndpointID: portainer.EndpointID(i),
WorkflowID: wf.ID,
}))
}
return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})
}))
var stats map[portainer.SourceID]SourceStats
require.NoError(t, store.ViewTx(func(tx dataservices.DataStoreTx) error {
var err error
_, stats, err = FetchSourceStats(tx, nil, adminContext())
return err
}))
st := stats[srcID]
require.Equal(t, 2, st.WorkflowCount)
require.Len(t, st.EndpointIDs, 2)
}
func TestFetchSourceStats_UnusedSourceHasZeroStats(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
var unusedID portainer.SourceID
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
src := &portainer.Source{Name: "unused", Type: portainer.SourceTypeGit}
require.NoError(t, tx.Source().Create(src))
unusedID = src.ID
return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})
}))
var stats map[portainer.SourceID]SourceStats
require.NoError(t, store.ViewTx(func(tx dataservices.DataStoreTx) error {
var err error
_, stats, err = FetchSourceStats(tx, nil, adminContext())
return err
}))
st := stats[unusedID]
require.Zero(t, st.WorkflowCount)
require.Empty(t, st.EndpointIDs)
}

View File

@@ -0,0 +1,183 @@
package workflows
import (
"fmt"
"slices"
"strconv"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/models/kubernetes"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/internal/endpointutils"
"github.com/portainer/portainer/api/internal/snapshot"
"github.com/portainer/portainer/api/kubernetes/cli"
"github.com/portainer/portainer/api/set"
"github.com/portainer/portainer/api/slicesx"
"github.com/portainer/portainer/api/stacks/stackutils"
"github.com/rs/zerolog/log"
)
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 {
log.Warn().Err(err).Str("context", "buildEndpointAccessMap").Int("endpoint_id", int(epID)).Msg("Failed to resolve kube access for endpoint, skipping")
continue
}
result[epID] = access
}
return result, nil
}
// lookup only if env is kube and either not edge or (edge + not async)
func ShouldPerformEnvLookup(endpoint *portainer.Endpoint) bool {
return endpointutils.IsKubernetesEndpoint(endpoint) &&
(!endpointutils.IsEdgeEndpoint(endpoint) ||
(endpointutils.IsEdgeEndpoint(endpoint) && !endpoint.Edge.AsyncMode))
}
func filterK8SStacks(items []portainer.Stack, endpointMap map[portainer.EndpointID]portainer.Endpoint, k8sFactory *cli.ClientFactory, accessMap map[portainer.EndpointID]endpointAccess) ([]portainer.Stack, error) {
k8sStacks, result := slicesx.Partition(items, func(s portainer.Stack) bool {
return s.Type == portainer.KubernetesStack
})
groupedByEnvId := slicesx.GroupBy(k8sStacks, func(s portainer.Stack) portainer.EndpointID {
return s.EndpointID
})
for envID, stacks := range groupedByEnvId {
ep, ok := endpointMap[envID]
if !ok || !ShouldPerformEnvLookup(&ep) {
continue
}
kcl, err := k8sFactory.GetPrivilegedKubeClient(&ep)
if err != nil {
log.Warn().Err(err).Str("context", "filterK8SStacks").Int("endpoint_id", int(envID)).Msg("Failed to get kube client for endpoint, skipping")
continue
}
access := accessMap[envID]
kcl.SetIsKubeAdmin(access.isKubeAdmin)
kcl.SetClientNonAdminNamespaces(access.nonAdminNamespaces)
apps, err := kcl.GetApplications("", "")
if err != nil {
log.Warn().Err(err).Str("context", "filterK8SStacks").Int("endpoint_id", int(envID)).Msg("Failed to get kube applications for endpoint, skipping")
continue
}
for _, s := range stacks {
idx := slices.IndexFunc(apps, func(app kubernetes.K8sApplication) bool {
return app.StackKind != "edge" && app.StackID == strconv.Itoa(int(s.ID))
})
if idx == -1 {
continue
}
app := apps[idx]
s.Name = app.Name
s.Namespace = app.ResourcePool
result = append(result, s)
}
}
return result, nil
}

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,127 @@
package workflows
import (
"context"
"fmt"
"path"
"slices"
portainer "github.com/portainer/portainer/api"
gittypes "github.com/portainer/portainer/api/git/types"
)
// ListRefsFunc lists all git refs for a repository.
type ListRefsFunc func(ctx context.Context) ([]string, error)
// ListFilesFunc lists files in a repository branch filtered by extension.
type ListFilesFunc func(ctx context.Context, exts []string, dirOnly bool) ([]string, error)
// GitEntries represents a git entry which can be either a file or a directory.
type GitEntries struct {
Name string
IsFile bool
}
// ComputeGitPhasesForConfig computes source and artifact phases from a RepoConfig and a GitService.
func ComputeGitPhasesForConfig(ctx context.Context, gitSvc portainer.GitService, cfg *gittypes.RepoConfig) (source, artifact WorkflowPhaseStatus) {
if gitSvc == nil || cfg == nil {
return WorkflowPhaseStatus{Status: StatusUnknown}, WorkflowPhaseStatus{Status: StatusUnknown}
}
username, password := gitCredentials(cfg)
return ComputeGitPhases(ctx, cfg.ReferenceName, []GitEntries{{Name: cfg.ConfigFilePath, IsFile: true}},
func(ctx context.Context) ([]string, error) {
return gitSvc.ListRefs(ctx, cfg.URL, username, password, false, cfg.TLSSkipVerify)
},
func(ctx context.Context, exts []string, dirOnly bool) ([]string, error) {
return gitSvc.ListFiles(ctx, cfg.URL, cfg.ReferenceName, username, password, dirOnly, false, exts, cfg.TLSSkipVerify)
},
)
}
func gitCredentials(cfg *gittypes.RepoConfig) (username, password string) {
if cfg.Authentication != nil {
return cfg.Authentication.Username, cfg.Authentication.Password
}
return "", ""
}
// ComputeGitPhases checks source (ref reachability) and artifact (config file presence).
// If source fails, artifact is returned as unknown without making a network call.
func ComputeGitPhases(ctx context.Context, referenceName string, configFilePath []GitEntries, listRefs ListRefsFunc, listFiles ListFilesFunc) (source, artifact WorkflowPhaseStatus) {
source = computeSourcePhase(ctx, referenceName, listRefs)
if source.Status == StatusError {
return source, WorkflowPhaseStatus{Status: StatusUnknown}
}
return source, computeArtifactPhase(ctx, configFilePath, listFiles)
}
func computeSourcePhase(ctx context.Context, referenceName string, listRefs ListRefsFunc) WorkflowPhaseStatus {
refs, err := listRefs(ctx)
if err != nil {
return WorkflowPhaseStatus{Status: StatusError, Error: err.Error()}
}
if referenceName == "" {
return WorkflowPhaseStatus{Status: StatusHealthy}
}
if !slices.Contains(refs, referenceName) {
return WorkflowPhaseStatus{Status: StatusError, Error: fmt.Sprintf("ref %q not found", referenceName)}
}
return WorkflowPhaseStatus{Status: StatusHealthy}
}
func computeArtifactPhase(ctx context.Context, gitEntries []GitEntries, listFiles ListFilesFunc) WorkflowPhaseStatus {
if len(gitEntries) == 0 {
return WorkflowPhaseStatus{Status: StatusError, Error: "no config file path specified"}
}
var (
exts []string
fileEntries []string
dirEntries []string
)
for _, gitEntry := range gitEntries {
if gitEntry.IsFile {
ext := path.Ext(gitEntry.Name)
if len(ext) > 0 {
ext = ext[1:]
exts = append(exts, ext)
}
fileEntries = append(fileEntries, gitEntry.Name)
continue
}
dirEntries = append(dirEntries, gitEntry.Name)
}
// Check file entries
if len(fileEntries) > 0 {
files, err := listFiles(ctx, exts, false)
if err != nil {
return WorkflowPhaseStatus{Status: StatusError, Error: err.Error()}
}
for _, fileEntry := range fileEntries {
if !slices.Contains(files, fileEntry) {
return WorkflowPhaseStatus{Status: StatusError, Error: fmt.Sprintf("file %q not found", fileEntry)}
}
}
}
// Check directory entries
if len(dirEntries) > 0 {
dirs, err := listFiles(ctx, nil, true)
if err != nil {
return WorkflowPhaseStatus{Status: StatusError, Error: err.Error()}
}
for _, dirEntry := range dirEntries {
if !slices.Contains(dirs, dirEntry) {
return WorkflowPhaseStatus{Status: StatusError, Error: fmt.Sprintf("directory %q not found", dirEntry)}
}
}
}
return WorkflowPhaseStatus{Status: StatusHealthy}
}

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

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