Compare commits

..

431 Commits

Author SHA1 Message Date
portainer-bot[bot] 862a80c69b fix(kubernetes): PersistentVolumeClaims datatable system resource filter [R8S-1031] (#2700)
Co-authored-by: nickl-portainer <nicholas.loomans@portainer.io>
2026-05-20 20:33:37 +00:00
RHCowan 5b5956574f fix(alerting): remove kube-scheduler and kube-controller-manager alert rules [R8S-1030] (#2695) (#2696)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 09:01:38 +12:00
Nick Wilkinson 064a4304cc chore: bump version to 2.42.0 (#2654)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 13:34:13 +12:00
Devon Steenberg 09c6222ecd fix(edge-environments): edge environment creation [BE-12984] (#2683) 2026-05-19 12:24:01 +12:00
Oscar Zhou cad197266d fix(ui): deployment failed progress indicator is missing [BE-12985] (#2684) 2026-05-19 12:22:06 +12:00
Steven Kang 5b9976433f feat(k8s): Refactor Volumes page (#2510)
Co-authored-by: Nicholas Loomans <nicholas.loomans@portainer.io>
Co-authored-by: Robbie Cowan <robert.cowan@portainer.io>
Co-authored-by: RHCowan <50324595+RHCowan@users.noreply.github.com>
2026-05-19 10:39:24 +12:00
RHCowan df48afff17 feat(alerting): add kube-scheduler and kube-controller-manager health alerts [R8S-992] (#2671) 2026-05-19 09:53:20 +12:00
Devon Steenberg e4e8cf4942 fix(docker): remove docker binary from ce/ee images [BE-12917] (#2674) 2026-05-19 09:37:42 +12:00
Oscar Zhou c89f34770f fix(gitops): incorrect workflow status for git-based helm edge stack [BE-12978] (#2678) 2026-05-19 09:07:35 +12:00
Chaim Lev-Ari ca5f695459 feat(gitops): introduce sources details view [BE-12911] (#2627)
Co-authored-by: andres-portainer <91705312+andres-portainer@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 18:01:36 -03:00
Devon Steenberg 10e0185c49 fix(libstack): swarm relative path env files [BE-12975] (#2662) 2026-05-19 07:48:58 +12:00
Steven Kang 8cdc2f49d8 feat(kube): backend handlers for pod delete, pod restart, and capabil… (#2491)
Co-authored-by: Nicholas Loomans <nicholas.loomans@portainer.io>
2026-05-18 19:59:01 +12:00
bernard-portainer 29db3df98d fix(url-state) sync state of list between URL and local storage [C9S-191] (#2647) 2026-05-18 16:51:49 +12:00
Devon Steenberg 52d9fbc9f2 fix(libstack): use compose service to pull images [BE-12951] (#2658) 2026-05-18 09:25:22 +12:00
Chaim Lev-Ari 7e80d88bce feat(ui): add theme selector to user menu [BE-12961] (#2625)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 13:50:51 +03:00
Oscar Zhou 6163008108 fix(auth): set Secure attribute on auth cookies based on HTTPS detection [BE-12938] (#2621) 2026-05-16 11:09:03 +12:00
andres-portainer 6945fa4496 fix(otel): upgrade go.opentelemetry.io/otel to v1.43.0 to fix CVE-2026-39883 CVE-2026-39882 BE-12967 (#2637) 2026-05-15 17:06:55 -03:00
andres-portainer 06ad0b2d78 fix(go-ntlmssp): upgrade github.com/Azure/go-ntlmssp to v0.1.1 to fix CVE-2026-32952 BE-12971 (#2651) 2026-05-15 10:42:18 -03:00
andres-portainer 2570a30a15 fix(prometheus): upgrade github.com/prometheus/prometheus to v0.311.3 to fix CVE-2026-40179 GHSA-fw8g-cg8f-9j28 CVE-2026-42151 CVE-2026-42151 BE-12972 (#2653) 2026-05-14 22:11:59 -03:00
RHCowan 93e5486db3 feat(alerting): add built-in alert for Kubernetes API server TLS certificate expiry [R8S-991] (#2559) 2026-05-15 12:11:39 +12:00
Oscar Zhou 49ef33d9f3 fix(stack): defer git metadata write until after deployment [BE-12946] (#2626) 2026-05-15 10:57:13 +12:00
andres-portainer ca8201b023 fix(in-toto-golang): upgrade github.com/in-toto/in-toto-golang to v0.11.0 to fix GHSA-pmwq-pjrm-6p5r BE-12968 (#2638) 2026-05-14 19:02:00 -03:00
andres-portainer 2cb94116a3 fix(net): upgrade golang.org/x/net to v0.54.0 to fix CVE-2026-27141 CVE-2026-33814 BE-12965 (#2631) 2026-05-14 15:46:43 -03:00
Hannah Cooper a81b66c6b0 feat(api-docs): Introduce API docs groupings [C9S-96] (#2656) 2026-05-14 15:09:22 +12:00
Devon Steenberg c9d24c3684 fix(libstack): replace filepath.Join with filesystem.JoinPaths [BE-11476] (#2655) 2026-05-14 13:57:29 +12:00
Oscar Zhou 8a22e05284 fix(stack): git stack edit validation and repo credential lookup [BE-12899] (#2594) 2026-05-14 12:27:20 +12:00
Devon Steenberg 3b0f1eca4b feat(swarm): port swarm to use libstack [BE-11476] (#2486) 2026-05-14 10:13:19 +12:00
bernard-portainer a66f114f24 fix(sidebar) override button padding to keep sidebar parent items in line [C9S-184] (#2641) 2026-05-14 08:39:06 +12:00
andres-portainer 2c00f4d40b fix(go-git): upgrade github.com/go-git/go-git/v5 to v5.19.0 to fix CVE-2026-34165 GHSA-3xc5-wrhm-f963 CVE-2026-33762 BE-12966 (#2634) 2026-05-13 13:10:19 -03:00
andres-portainer 2e88f7a245 fix(chisel): add another mechanism to ensure snapshot collection BE-12896 (#2628) 2026-05-13 10:50:58 -03:00
Chaim Lev-Ari dd68560ad0 chore(deps): upgrade prettier (#2592)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 16:39:58 +03:00
RHCowan d1b702ef37 feat(alerting): add etcd health metric and built-in alert rule [R8S-999] (#2538) 2026-05-13 18:56:09 +12:00
Oscar Zhou 7f3389d6f4 chore(version): bump develop version to 2.41.1 (#2646)
Co-authored-by: Nicholas Loomans <nicholas.loomans@portainer.io>
2026-05-13 16:23:35 +12:00
Chaim Lev-Ari d9a415f011 feat(gitops): introduce sources list view [BE-12902] (#2550) 2026-05-12 15:32:46 +03:00
Ali edff47fd41 feat(environments): offer edge connectivity test before adding edge environments [c9s-149] (#2527) 2026-05-12 16:25:39 +12:00
bernard-portainer b3a9386607 fix(edgeEnv) edge envs that haven't checked in can't be outdated [C9S-168] (#2608) 2026-05-12 15:14:58 +12:00
bernard-portainer 300a8abc97 fix(DockerDetails) replace missing icon on host panel [C9S-170] (#2612) 2026-05-12 10:41:42 +12:00
andres-portainer 2bb2b78e82 chore(csrf): remove gorilla/csrf BE-12948 (#2618) 2026-05-11 19:41:26 -03:00
andres-portainer 540c9ba6d5 fix(chisel): upgrade Chisel to v1.11.6 to avoid a panic because of a negative waitgroup counter BE-12743 (#2619) 2026-05-11 19:40:54 -03:00
Josiah Clumont 872b824dc6 feat(design-system): introduce ResourceDetailHeader [BE-12848] (#2536)
Ignored some flaky tests
2026-05-12 10:23:58 +12:00
Oscar Zhou 9ecd8d3efb fix(environment): reject TLS config for Edge Agent environment creation and update [BE-12700] (#2609) 2026-05-12 08:50:41 +12:00
andres-portainer 080d75acae chore(openamt): remove OpenAMT completely BE-12950 (#2616) 2026-05-11 15:48:39 -03:00
andres-portainer 62f4d47ee5 chore(internal): export endpoints and authorizations so they can be shared between CE and EE BE-12893 (#2464) 2026-05-11 10:44:09 -03:00
Chaim Lev-Ari c0ac6c56ac feat(ui): introduce design system primitives [DEV-52] (#2535) 2026-05-11 08:45:59 +03:00
Hannah Cooper 3e60c2306c Update bug report template to include 2.41.1 (#2611) 2026-05-11 16:34:54 +12:00
bernard-portainer 59614d31f2 fix(edgeEnvironments) update displayed edge agent URLs [C9S-167] (#2602)
* Remove URL when rendering edge agent in list as it was displaying the server URL
* Add server and tunnel URL to information panel in environment display
2026-05-11 14:52:11 +12:00
Oscar Zhou a117e514e4 fix(stack): persist CreatedBy before deployment to prevent broken auto update [BE-12939] (#2588) 2026-05-11 12:54:04 +12:00
Josiah Clumont 8d098a2bb9 style(dropdown-menu): fix count badge alignment and uniform width [C9S-116] (#2605) 2026-05-11 12:51:18 +12:00
Josiah Clumont 899e4b6f67 refactor(dropdown-menu): update styling to align with designs [C9S-116] (#2596) 2026-05-11 10:25:15 +12:00
LP B dba86594e1 fix(app/kubernetes): kube edit app buttons (#2565) 2026-05-09 11:00:17 +02:00
Chaim Lev-Ari 8885038b7e refactor(settings/auth): migrate admin group section to react [BE-12592] (#2472) 2026-05-08 10:51:12 +03:00
bernard-portainer 76f525fd38 refactor(home): refactor Environment List to use SortableList component [C9S-131] (#2522)
- Migrate `EnvironmentList` from `GroupSortTable` to `SortableList`, removing ~1,700 lines of duplicated component code
- Move health sort ranking to the backend (`sort.go`), adding `Health` and `Id` sort keys
- Delete `GroupSortTable`, `GroupSortTableGroupRow`, `useGroupSortTableState`, and `store` — functionality absorbed by `SortableList`
- Add `useHomeViewState` hook to centralise home view URL state (`groupBy`, `groupFilter`, `order`, `page`, `search`)
- Update `useTableStateFromUrl` to support `groupBy` and `groupFilter` URL params with a `buildExtra` callback
- Rename URL param `filter` → `groupFilter` for clarity; add `search` and `order` to `/home` route definition
- Simplify `EnvironmentList` props — remove `headerFilter` / `onHeaderFilterChange`, leaving only `onClickBrowse`
- Add `computeSortDesc` pure utility to `SortableList` and cover all toggle/reset cases with unit tests
- Update `SortableListHeader` to use `activeKey` prop (renamed from `sortBy`); fix all callsites and stories
- Fix `SortableList` sort-key normalisation to be case-insensitive; update tests to reflect no-match behaviour
2026-05-08 16:55:40 +12:00
Cara Ryan 3d741ad58d fix(users): Fix for users effective access viewer not including policies [C9S-109] (#2539) 2026-05-08 15:00:17 +12:00
RHCowan ff169ed356 feat(alerting): expand tiered rules into per-severity evaluators with state aggregation [R8S-1003] (#2586) 2026-05-08 14:50:59 +12:00
Hannah Cooper ed7f074380 Update bug report template to include 2.39.2 (#2587) 2026-05-07 16:20:36 +12:00
Ali 9eb6ebfe9b fix(wizard): ensure select renders on top of footer [c9s-169] (#2577) 2026-05-07 14:15:21 +12:00
Hannah Cooper 29cfde99ae Update bug report template to include 2.33.8 (#2583) 2026-05-07 13:11:08 +12:00
Oscar Zhou c3b0b9a2e0 fix(ecr): prevent deadlock on ECR token refresh during stack deployment [BE-12842] (#2564) 2026-05-07 08:34:19 +12:00
Devon Steenberg e7ec69708e fix(libstack): pull images sequentially and respect COMPOSE_PARALLEL_LIMIT [BE-12930] (#2556) 2026-05-06 15:16:41 +12:00
Ali ff9c10f641 feat(docker): show host disk usage in the UI [C9S-144] (#2517) 2026-05-05 22:40:16 +12:00
Ali 0eba817aab fix(environments): align Linux/Windows labels for edge agent and Docker API [c9s-157] (#2558) 2026-05-05 22:01:13 +12:00
Ali 6cb6f2e9b4 fix(change-confirmation): add git dry run and docker resize to the excluded urls [c9s-159] (#2562) 2026-05-05 18:00:03 +12:00
Devon Steenberg 6faa0939d8 fix(kubectl-shell): kubectl-shell-image flag [BE-12929] (#2542) 2026-05-05 13:50:40 +12:00
Josiah Clumont 68f93fb281 feature(storybook): Storybook usability upgrades [C9S-140] (#2482) 2026-05-05 09:25:09 +12:00
bernard-portainer 1ea8c1cb4e feat(homeView) add age sort option as default [C9S-150] (#2546) 2026-05-05 08:17:06 +12:00
andres-portainer d749d05359 fix(datastore): change EnforceEdgeID default to true BE-12925 (#2547) 2026-05-04 15:29:58 -03:00
Chaim Lev-Ari b18b4418c8 fix(kube/app): get stack only for managed stacks [BE-12927] (#2516) 2026-05-03 09:15:20 +03:00
Ali a3935ce445 feat(secrets): allow linking secrets to service accounts as imagepullsecrets [c9s-49] (#2488) 2026-05-01 22:54:33 +12:00
Oscar Zhou 92bbfb8fa3 chore(remote): add log for resolved unpacker image [BE-12884] (#2459) 2026-05-01 17:03:40 +12:00
RHCowan 6c097dcf51 feat(alerting): propagate edge annotations for meaningful Kubernetes summaries [R8S-993] (#2514) 2026-05-01 08:13:07 +12:00
LP B 0688e6bbdd fix(api/workflows): kubernetes UAC (#2508)
Co-authored-by: andres-portainer <91705312+andres-portainer@users.noreply.github.com>
2026-04-30 10:54:38 -03:00
Hannah Cooper c49e682df4 Update bug report template to include 2.41.0 (#2511) 2026-04-30 13:53:32 +12:00
RHCowan 538d57fe19 fix(agent): correct Podman container engine header in sync edge client [BE-12887] (#2498) 2026-04-30 08:47:44 +12:00
LP B 3053990411 fix(api/workflows): move filterK8SStacks outside of transaction (#2505) 2026-04-29 17:56:57 +02:00
RHCowan 49011d4d03 feat(alerting): Add built-in alert for Kubernetes nodes in NotReady state [R8S-990] (#2485) 2026-04-29 15:44:09 +12:00
Cara Ryan 6a30138b3c feat(home): environment home page ui improvements to highlight groups [C9S-23] (#2487)
Signed-off-by: Bernard Setz <bernard.setz@portainer.io>
Co-authored-by: bernard-portainer <bernard.setz@portainer.io>
Co-authored-by: Ali <83188384+testA113@users.noreply.github.com>
Co-authored-by: Yajith Dayarathna <yajith.dayarathna@portainer.io>
Co-authored-by: Chaim Lev-Ari <chiptus@users.noreply.github.com>
Co-authored-by: andres-portainer <91705312+andres-portainer@users.noreply.github.com>
Co-authored-by: Josiah Clumont <josiah.clumont@portainer.io>
Co-authored-by: Dakota Walsh <101994734+dakota-portainer@users.noreply.github.com>
2026-04-29 14:59:39 +12:00
Xing 6aac4f38e4 fix(test): isolate registry config in OCI client tests to fix env-dependent failures [C9S-119] (#2401) 2026-04-29 10:18:52 +12:00
LP B bc6c5da2dc feat(api/gitops): list and filter kubernetes git workflows (#2474) 2026-04-27 15:24:39 -03:00
andres-portainer 1c55555ad0 chore(tests): increase code coverage BE-12877 (#2431) 2026-04-27 12:32:44 -03:00
Chaim Lev-Ari 3f8fcb3914 fix(ui/sortable-list): remove 1 as page size option [BE-12900] (#2469) 2026-04-27 17:01:12 +03:00
andres-portainer 24a879add6 fix(docker): enforce resource controls on /containers/{id}/attach/ws BE-12891 (#2448) 2026-04-27 09:17:28 -03:00
Chaim Lev-Ari ae1b6b8a71 feat(gitops): show live git validity status in workflow overview [BE-12885] (#2447)
Co-authored-by: Claude <noreply@anthropic.com>
2026-04-27 13:11:55 +03:00
Chaim Lev-Ari da36002d37 fix(gitops): align list component with current design [BE-12888] (#2443)
Co-authored-by: Bernard Setz <bernard.setz@portainer.io>
2026-04-26 16:48:45 +03:00
Chaim Lev-Ari a611e12b5c fix(kube/stacks): allow empty stack name [BE-12889] (#2444) 2026-04-26 12:14:45 +03:00
andres-portainer d4114c510d fix(factory): clear the output raw path to avoid forwarding a different path than the validated one BE-12880 (#2442) 2026-04-24 09:46:46 -03:00
nickl-portainer 5eaf145eda chore(react-query): update all deprecated withError to use withGlobalError [R8S-968] (#2461)
Co-authored-by: Ali <83188384+testA113@users.noreply.github.com>
2026-04-24 16:01:59 +12:00
Josiah Clumont 2c2ec6f6e6 feat(recommendations): completeness recommendations [C9S-18] (#2262) 2026-04-24 10:46:47 +12:00
Ali 39ac164890 fix(ui): use uuidv4 instead of cryptorandomuuid to support non-secure browsers [c9s-133] (#2432) 2026-04-24 08:41:51 +12:00
andres-portainer 8140c834ca fix(docker): add exec restrictions BE-12878 (#2429) 2026-04-23 15:29:03 -03:00
Ali 742523de17 feat(docker): add docker builder prune as option [C9S-128] (#2423) 2026-04-23 09:06:47 +12:00
Chaim Lev-Ari dd1c1071ce feat(gitops): introduce workflows view [BE-12807] (#2391) 2026-04-22 10:17:37 -03:00
nickl-portainer b9713f7e9e chore(version): bump version to 2.41.0 (#2421) 2026-04-22 17:11:30 +12:00
Steven Kang 9c0a13a828 fix(stacks): fix Swarm stack migration to Kubernetes hanging and empt… (#2417) 2026-04-22 13:38:06 +12:00
Robbie Cowan dc56aae7b8 fix(rebase): run go mod tidy to prepare for merge into develop 2026-04-22 10:59:12 +12:00
RHCowan ba11fe920b fix(alerting) Use prometheus scrape manager [R8S-940] (#2198) 2026-04-22 10:06:45 +12:00
RHCowan 7f2da7811c feat/r8s 900/r8s 929/ee alerting foundations (#2167) 2026-04-22 10:06:44 +12:00
RHCowan 62cf2e42d5 feat(alerting): add shared CE Prometheus foundation and alert-state contracts [R8S-927] (#2129) 2026-04-22 10:06:44 +12:00
RHCowan 64745e70d0 feat(alerting): wire K8s metrics collection and alert push transport [R8S-901] (#1993) 2026-04-22 10:06:43 +12:00
RHCowan f49cd6e932 feat(alerting): distribute enabled alert rules to edge agents via poll response [R8S-903] (#2007) 2026-04-22 10:06:43 +12:00
Steven Kang ac1e333dde feat(alerts): removal of snapshot reliance [R8S-902] (#1994) 2026-04-22 10:06:43 +12:00
RHCowan b5bc5f65ad feat(alerting): Add edge alert ingestion endpoint skeleton [R8S-895] (#1991) 2026-04-22 10:06:40 +12:00
Oscar Zhou 463d539194 refactor(stack): change stack update flow to async model [BE-12741] (#2306) 2026-04-22 10:05:17 +12:00
andres-portainer 7e544ee449 fix(docker): add more bind mount restriction checks BE-12771 (#2409) 2026-04-21 17:56:17 -03:00
Ali 1f320c976f chore(docs): update docs, skills and Claude.md to avoid repeating review comments [r8s-971] (#2400) 2026-04-22 08:21:31 +12:00
andres-portainer 825a7669a6 fix(csrf): use the proper format for trusted origins BE-12810 (#2398) 2026-04-21 11:52:58 -03:00
andres-portainer f6a72b089c fix(kubernetes): enforce admin permissions in /system BE-12862 (#2396) 2026-04-21 09:43:06 -03:00
LP B 73ea33f36c fix(app/container): handle no healthcheck logs output (#2387) 2026-04-21 13:46:36 +02:00
Chaim Lev-Ari 744a31a354 feat(stacks): allow edit of kube git stacks [BE-12671] (#2194) 2026-04-21 11:05:37 +03:00
Chaim Lev-Ari 42c7f10e79 feat(ui): introduce SortableList [BE-12806] (#2367)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 10:38:38 +03:00
Chaim Lev-Ari 3e57bc5aa0 fix(containers): show volume label when selected [BE-12819] (#2369) 2026-04-21 09:48:26 +03:00
Chaim Lev-Ari 4880e61e0f fix(containers): show ports in wrapping rows [BE-12709] (#2370) 2026-04-21 09:47:52 +03:00
Steven Kang 79a93cfd01 fix(security): upgrade Docker binary from v29.3.0 to v29.4.1 (#2356) 2026-04-21 10:55:14 +12:00
Steven Kang 0af7bc2004 fix(security): bump github.com/moby/spdystream to 0.5.1 (#2355) 2026-04-21 10:19:53 +12:00
Steven Kang ada103e910 fix(security): bump helm.sh/helm/v4 to 4.1.4 (#2354) 2026-04-21 10:06:22 +12:00
Steven Kang a0e964c27d fix(security): bump Go toolchain to 1.26.2 (#2352) 2026-04-21 10:05:12 +12:00
Cara Ryan a2624b7467 fix(helm): Resolve content cache must be set error when using helm dependencies [C9S-115] (#2376) 2026-04-21 09:51:02 +12:00
andres-portainer 9abd7eaeea fix(endpoints): enforce admin permissions when updating endpoint relations BE-12861 (#2394)
Co-authored-by: andres-portainer <andres-portainer@users.noreply.github.com>
2026-04-20 14:19:18 -03:00
LP B 3502ed0293 fix(api): deny plugin related changes to regular users (#2284) 2026-04-20 17:07:28 +02:00
Chaim Lev-Ari 3101738adc refactor(git): ee service extends ce service [BE-12825] (#2280) 2026-04-19 10:44:23 +03:00
andres-portainer 0b390dd274 fix(tests): do all the path handling using filesystem.JoinPaths() BE-12828 (#2336) 2026-04-18 01:54:14 -03:00
andres-portainer 9d3f7b710d fix(tests): enable more parallel tests BE-12801 (#2316) 2026-04-18 01:53:10 -03:00
andres-portainer 3a8ed40943 fix(docker): enforce bind mount restrictions for Mounts field BE-12770 (#2363) 2026-04-18 01:28:24 -03:00
andres-portainer aef1d982c2 fix(docker): add missing restrictions for Swarm BE-12772 (#2226) 2026-04-18 01:27:14 -03:00
andres-portainer b287961758 fix(git): forbid the usage of symlinks BE-12768 (#2365) 2026-04-18 01:26:15 -03:00
andres-portainer 8d5675a7d7 fix(csrf): add CSRF protection from the stdlib BE-12810 (#2250) 2026-04-17 10:51:04 -03:00
Ali 544e302fe1 feat(docker): support docker image prune [c9s-91] (#2314) 2026-04-17 14:22:36 +12:00
andres-portainer b417b04a69 fix(websocket): add proper locking and avoid goroutine leakage BE-12835 (#2303) 2026-04-16 14:08:51 -03:00
Xing 6ecb99898d fix(k8s): yaml malformed document [dev-7] (#1976)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-16 13:38:34 +12:00
Xing 236c5e2415 chore(dev): use separate data dirs for CE and EE [C9S-102] (#2328) 2026-04-16 10:17:59 +12:00
Josiah Clumont 2d2b68e867 fix(tests): Fixed the breadcrumb failing tests due to update's on how the first item is rendered [C9S-98] (#2338) 2026-04-16 09:29:51 +12:00
Chaim Lev-Ari f841ea527a fix(terminal): close terminal on ctrl+d [BE-12823] (#2271) 2026-04-15 17:08:15 +12:00
Josiah Clumont 169548cc4c (feature) fix header padding [C9S-98] (#2315) 2026-04-15 14:24:15 +12:00
Chaim Lev-Ari 8f93a1a8cf chore(deps): upgrade eslint [BE-12837] (#2313) 2026-04-15 05:12:52 +03:00
nickl-portainer 8e85fa9f83 fix(policies): datatable new row behaviours [C9S-64] (#2130) 2026-04-15 14:11:11 +12:00
Chaim Lev-Ari 181a83a889 chore(deps): upgrade ts to v6 [BE-12820] (#2268) 2026-04-15 03:55:34 +03:00
andres-portainer b78504aa04 fix(websocket): remove the JWT token query string parameter BE-12833 (#2301) 2026-04-14 19:41:08 -03:00
Chaim Lev-Ari a21ec9299b feat(stacks): add redeploy git button [BE-12783] (#2278) 2026-04-14 17:49:56 +03:00
Chaim Lev-Ari 7708ace1d8 feat(gitops): add api for workflows [BE-12805] (#2273)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 13:25:37 +03:00
Steven Kang 218b5d5900 feat(kubernetes): add edit (yaml) and describe button [R8S-921] (#2079) 2026-04-14 14:01:41 +12:00
nickl-portainer 2983b94cf7 fix(css): add restriction on modal height [R8S-947] (#2305) 2026-04-14 13:31:21 +12:00
Josiah Clumont 25e082ea63 feat(design-system): add HeaderLayout component [C9S-95] (#2291) 2026-04-14 11:44:30 +12:00
Josiah Clumont 3313376fac feat(design-system): add StatusSummaryBar and FilterBar components [DEV-41] (#2288)
Co-authored-by: Chaim Lev-Ari <chiptus@users.noreply.github.com>
2026-04-14 08:28:44 +12:00
andres-portainer a96c6efcbd chore(code): add rule to mitigate the introduction of path traversal vulnerabilities BE-12828 (#2299) 2026-04-13 11:45:14 -03:00
andres-portainer 4dd6b88cdf chore(tests): simplify the code BE-12818 (#2285) 2026-04-13 11:32:07 -03:00
Ali 0d836f1e30 chore(tailwind): support tailwind class ordering in clsx functions [r8s-949] (#2292) 2026-04-13 17:13:40 +12:00
Ali ab3e0956a4 chore(tailwind): format tailwind class order [r8s-949] (#2289) 2026-04-13 16:01:10 +12:00
Josiah Clumont 615fceb4a5 feat(navigation-bar): Update the navigation bar [C9S-90] (#2263) 2026-04-13 13:54:54 +12:00
andres-portainer 68453ebcb8 chore(stackbuilders): simplify the code BE-12800 (#2230) 2026-04-09 17:45:24 -03:00
Chaim Lev-Ari 635c49d04d fix(stacks): save git credentials if required [BE-12773] (#2237) 2026-04-09 09:25:31 +03:00
RHCowan 886af7d55a feat(ci): optimise build pipeline with frontend caching and scoped validation (#1995) 2026-04-09 15:57:04 +12:00
andres-portainer 8f563220df chore(code): clean-up the code BE-12818 (#2260) 2026-04-08 20:04:27 -03:00
andres-portainer def415b6f3 chore(code): consolidate code between CE and EE BE-12818 (#2261) 2026-04-08 19:36:54 -03:00
andres-portainer c21d043183 fix(code): remove nil-pointer dereference errors BE-12817 (#2259) 2026-04-08 19:36:06 -03:00
Ali 769ea73cec feat(registries): add registry access notice to app create/edit views [c9s-39] (#2190) 2026-04-09 09:04:45 +12:00
andres-portainer d140726c46 fix(kube): use transactional code for initial detections BE-545 (#2228) 2026-04-08 16:11:23 -03:00
andres-portainer 1f42559279 fix(endpoints): fix a use-after-close data-race BE-12604 (#2214) 2026-04-08 13:04:13 -03:00
andres-portainer b6d6c7fd2a fix(containers): avoid using the request context BE-12870 (#2216) 2026-04-08 12:39:52 -03:00
andres-portainer 1298fc629e chore(tests): allow for the tests to run in parallel BE-12801 (#2231) 2026-04-07 17:38:22 -03:00
andres-portainer 30ca5e298c chore(tests): avoid initializing the DB data when not needed BE-12801 (#2233) 2026-04-07 15:49:57 -03:00
andres-portainer 2240d0516c chore(tests): speed up the time by using synctest BE-12801 (#2234) 2026-04-07 15:49:30 -03:00
Chaim Lev-Ari b87095dc7a fix(terminal): allow tui apps [BE-12674] (#2024) 2026-04-07 10:45:26 +03:00
Oscar Zhou d30503a40c feat(helm/edge): support helm repository for edge stack [BE-12480] (#2180) 2026-04-07 18:40:07 +12:00
Chaim Lev-Ari 7fbda4fe54 refactor(settings/auth): migrate group builder to react [BE-12587] (#2102) 2026-04-07 07:55:24 +03:00
Ali 24a2b29f70 fix(ui): make banner border wrap screen height [c9s-63] (#2224) 2026-04-07 16:05:52 +12:00
Oscar Zhou ca9e197d12 fix(stack): add stack creation success toast [BE-12813] (#2245) 2026-04-07 14:26:40 +12:00
Cara Ryan 51f86eb4c6 feat(api): Claude skill to validate and write api annotations and example subset run to fix helm endpoints (#2246) 2026-04-07 13:56:17 +12:00
Oscar Zhou 5aba61cc49 refactor(stack): create stack and deploy stack in async flow CE [BE-12650] (#2238) 2026-04-07 09:18:54 +12:00
andres-portainer fcf9888677 feat(git): consolidate the mocked Git service to simplify the tests BE-12799 (#544) 2026-04-06 14:24:19 -03:00
andres-portainer 9c9caeb57a chore(code): unnest some code BE-12798 (#2229) 2026-04-06 14:23:33 -03:00
Chaim Lev-Ari a58ad25533 fix(stacks): stack.env can be null [BE-12736] (#2239) 2026-04-06 16:55:06 +03:00
Oscar Zhou 11f5150190 refactor(stack): create stack and deploy stack in async flow [BE-12650] (#2048) 2026-04-05 21:18:29 +12:00
Chaim Lev-Ari 1c72dfe5ad fix(gitops): fix various gitops errors [BE-12787] (#2200) 2026-04-05 09:18:33 +03:00
Oscar Zhou b49830db8f chore: add Makefile command to host swagger ui locally [BE-12791] (#2223) 2026-04-03 12:22:52 +13:00
andres-portainer e035c490dc fix(docker): fix a data race in serviceRestore BE-12790 (#2219) 2026-04-02 11:04:02 -03:00
Phil Calder 0d8544b3ee fix(CronJobs): remove non-functional Items per page (#2166)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 10:37:09 +13:00
andres-portainer 50056bef70 fix(context): clean up context usage BE-12766 (#2164) 2026-04-01 18:02:48 -03:00
Ali e68e14787b chore(logs): add log view smoke tests [PLA-681] (#2206) 2026-04-01 16:39:24 +13:00
Chaim Lev-Ari 0ab2c5cf98 feat(react-query): suppress error when meta.error is falsy [BE-12776] (#2199) 2026-03-31 16:53:00 +03:00
Oscar Zhou 1ca56fd027 fix(git): failed git repo url returns html error page [BE-12757] (#2191) 2026-03-31 10:31:12 +13:00
Oscar Zhou c4cc9cf1c7 fix(ui): display invisible special characters in web editor [BE-12777] (#2176) 2026-03-31 10:15:47 +13:00
Chaim Lev-Ari b53684a89e chore(deps): remove unused client dependencies [BE-12749] (#2172) 2026-03-30 14:54:50 +03:00
Oscar Zhou d93508a272 fix(edge/helm): support custom namespace [BE-12678] (#2171) 2026-03-27 10:02:48 +13:00
Chaim Lev-Ari ad9b9cf5b1 fix(stacks): fix(stacks): prevent git file load before clone [BE-12764] (#2162) 2026-03-26 15:10:14 +02:00
Chaim Lev-Ari ac5fb731bc feat(motd): cache motd in server [BE-12711] (#2159) 2026-03-26 15:01:48 +02:00
Chaim Lev-Ari d36799020b refactor: remove Kubernetes ts import [BE-12730] (#2157) 2026-03-26 14:09:13 +02:00
Chaim Lev-Ari 7aa08053e0 refactor(axios): remove the need for parseAxiosError [BE-12703] (#2158) 2026-03-26 13:50:14 +02:00
andres-portainer 61b9bc248f fix(schedule): abstract simple loops with RunOnInterval() BE-12765 (#2163) 2026-03-26 07:47:54 -03:00
Chaim Lev-Ari e33f9573e8 refactor: remove Portainer ts import [BE-12732] (#2156) 2026-03-26 12:18:15 +02:00
Chaim Lev-Ari 186624d267 refactor: remove Docker ts import [BE-12731] (#2155) 2026-03-26 09:44:26 +02:00
Hannah Cooper 7c9d4cd7d8 Update bug report template to include 2.40.0 (#2168) 2026-03-26 13:52:40 +13:00
Phil Calder 541b8df735 fix(kubernetes): filter CronJob executions by namespace [DEV-19] (#2144)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 13:21:09 +13:00
andres-portainer 2900bfa1d6 chore(code): remove unused code BE-12744 (#2112) 2026-03-25 10:19:17 -03:00
andres-portainer 5ea0f682a6 fix(apikey): fix the return value of InvalidateUserKeyCache() BE-12755 (#2124) 2026-03-25 09:04:54 -03:00
andres-portainer 019cbfd972 fix(websocket): avoid leaking goroutines BE-12754 (#2123) 2026-03-25 09:04:23 -03:00
RHCowan 792c95b8bb chore: bump version to 2.40.0 and set API version support to STS (#2160) 2026-03-25 19:59:41 +13:00
Robbie Cowan 4d1f432266 Revert "chore: bump version to 2.40.0"
This reverts commit 11af66a4fce8ab98253afb2e637a946a8939747f.
2026-03-25 18:47:01 +13:00
Robbie Cowan 1e00a58b57 chore: bump version to 2.40.0 2026-03-25 18:15:54 +13:00
Phil Calder 0a26ac0279 fix(security): bump google.golang.org/grpc to v1.79.3 [DEV-22] (#2151)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 17:26:25 +13:00
Ali 63b0802ad7 fix(UI): revert global css changes targetting firefox banner fix + cleanup [C9S 71] (#2152) 2026-03-25 15:51:36 +13:00
Ali a5062dbe35 fix(ui): handle verticle alignment selectors for production build [c9s-71] (#2147) 2026-03-25 13:37:20 +13:00
Ali f84e657707 feat(registries): support service accounts with registry secrets for cluster level [C9S 37] (#2120) 2026-03-25 11:00:13 +13:00
Chaim Lev-Ari cd8a42edaf fix(settings/auth): allow dashes in ldap dn (#2141) 2026-03-24 16:00:59 -03:00
Chaim Lev-Ari e37f8a5eb9 fix(stacks): save entry point [BE-12670] (#2132) 2026-03-24 16:38:31 +02:00
Ali 7fc8d3f2b1 fix(ui): ensure when two+ angular components are in a #view, they don't all stretch to fill space [c9s-71] (#2133) 2026-03-24 23:03:24 +13:00
Ali 6f2d1a2b49 fix(ui): ensure centered pages stay centered [c9s-71] (#2131) 2026-03-24 20:02:01 +13:00
Chaim Lev-Ari d5a3e46791 feat(stacks): update git url and config path [BE-12670] (#2099)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Devon Steenberg <devon.steenberg@portainer.io>
2026-03-24 15:01:46 +13:00
andres-portainer 1f4724c537 fix(environments): fix the TLS certificate uploading BE-12719 (#2101)
Co-authored-by: andres-portainer <andres-portainer@users.noreply.github.com>
2026-03-24 14:01:49 +13:00
Ali e6f8736cae fix(policies): fix page styles for firefox banner [C9S-63] (#2128) 2026-03-24 13:24:43 +13:00
Oscar Zhou 54fbe54953 fix(edge/agent): deleting k8s edge agent disconnect environment [BE-12723] (#2109) 2026-03-24 08:33:06 +13:00
Ali 3e92a2881a chore(ci): improve regular PR CI reporting for playwright [r8s-925] (#2114) 2026-03-23 22:01:47 +13:00
Devon Steenberg bd9c3c1593 feat(gitops): tidy up git auth [BE-12666] (#2026) 2026-03-23 13:53:04 +13:00
Ali f199d0882f feat(serviceaccount): service account details view [C9S-36] (#2082) 2026-03-23 09:22:56 +13:00
Chaim Lev-Ari a2fee4fc4c fix(stacks): pass prune option through the deploy pipeline [BE-12738] (#2098)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 12:37:40 +02:00
Chaim Lev-Ari 5670216d7e feat(stacks): show planned deployment info [BE-12737] (#2097)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 09:34:45 +02:00
Ali 7569266e46 fix(rbac): update namespace rbac test id to match EE [C9S-61] (#2111) 2026-03-22 20:26:19 +13:00
Hannah Cooper 23f6cb8bae Update bug report template to include 2.39.1 (#2108) 2026-03-20 13:53:32 +13:00
Ali 931c2b3ddb feat(policies): Introduce change confirmation policy template, and clear default values for custom policy [C9S-52] (#2087) 2026-03-20 09:55:41 +13:00
Oscar Zhou 8b3edb4e28 fix(docker): show correct container state for restarting and removing [BE-12707] (#2088) 2026-03-20 08:49:59 +13:00
Chaim Lev-Ari a0b03d36bd refactor(settings/auth): migrate ldap-dn-builder to react [BE-12586] (#2025) 2026-03-19 17:56:15 +02:00
andres-portainer df1cd0af2e fix(endpointrelation): add locking to RegisterUpdateStackFunction() BE-12608 (#2096) 2026-03-19 12:38:36 -03:00
Chaim Lev-Ari 5df7146828 fix(stacks): disabled edit button while submit [BE-12681] (#2094) 2026-03-19 14:56:08 +02:00
Chaim Lev-Ari bec5d829f1 refactor(settings/auth): migrate ldap security settings to react [BE-12588] (#2029) 2026-03-19 12:13:05 +02:00
Chaim Lev-Ari ee0e9f6ff8 feat(settings/auth): migrate ldap test login to react [BE-12589] (#2036) 2026-03-19 11:23:58 +02:00
Ali 9c7eef3144 feat(secrets): update secrets to show related registry [c9s-35] (#2065) 2026-03-19 15:18:35 +13:00
Chaim Lev-Ari 3110fe4e74 refactor(axios): move axios into react folder [BE-12728] (#2084) 2026-03-19 10:21:25 +13:00
Chaim Lev-Ari 565ac2c15a fix(ts): add back feature imports [BE-12733] (#2083) 2026-03-18 13:46:52 +02:00
nickl-portainer 9cba6c7475 chore(tsconfig): remove obsolete aliases (#2078) 2026-03-18 16:01:17 +13:00
nickl-portainer 07b3bdb62d chore(axios): move out axios utils into helpers and utils folder [R8S-871] (#2077) 2026-03-18 13:59:51 +13:00
nickl-portainer ac7ff0fff4 chore(axios): move axios into own folder CE [R8S-871] (#2075) 2026-03-18 13:24:44 +13:00
Chaim Lev-Ari 0d20839d5f fix(stacks): validate stacks with env vars [BE-12689] (#2050)
Co-authored-by: andres-portainer <andres-portainer@users.noreply.github.com>
2026-03-17 18:53:13 -03:00
Oscar Zhou 13fb3118ee fix(edge/docker): add bind mount volume label for restarting the specific service [BE-12575] (#1821) 2026-03-18 08:44:50 +13:00
andres-portainer 364027054c fix(websocket): simplify the logout disconnection logic BE-12605 (#1868) 2026-03-17 13:21:28 -03:00
andres-portainer 31a861394f fix(otel): upgrade to v1.42.0 BE-12724 (#2072) 2026-03-17 13:02:54 -03:00
LP B 0fccc0357e fix(api/uac): panic on external stacks UAC eval (#2073) 2026-03-17 16:00:35 +01:00
andres-portainer 5550a71dea fix(docker): upgrade Docker binary to v29.3.0 to mitigate CVE-2025-68121 BE-12720 (#2064) 2026-03-16 19:02:56 -03:00
Steven Kang 0ec6f638a1 feat(kompose): support docker to kubernetes migration - [R8S-723] (#1977) 2026-03-17 09:37:18 +13:00
andres-portainer 748b4bcf19 chore(go): upgrade to v1.26.1 BE-12630 (#1855) 2026-03-16 15:52:36 -03:00
Ali 33cc29fa3c fix(sidebar): set helper anchor color to match the other items [C9S-47] (#2058) 2026-03-16 15:50:59 +13:00
Chaim Lev-Ari 5e2eb667b4 fix(kube/app): enable edit button for regular apps [BE-12690] (#2039) 2026-03-15 11:22:09 +02:00
Ali 1f9c9b082f feat(policies): banner and confirmation on change policy [C9S-20] (#1988) 2026-03-13 14:11:53 +13:00
Cara Ryan 722c1875af chore(helm): upgrade sdk to v4 [R8S-840] (#2000) 2026-03-13 11:34:28 +13:00
Ali 68471d0225 fix(stacks): use widget-tabs consistently [c9s-33] (#2038) 2026-03-13 08:30:45 +13:00
Phil Calder a6900545b0 Report a vulnerability via email or GitHub (#2037) 2026-03-12 12:30:40 +13:00
Chaim Lev-Ari 808ceba848 feat(docker): allow user to specify security-opts (#2022)
Co-authored-by: dylan <dfldylan@qq.com>
Co-authored-by: jerry-yuan <i@jerryzone.cn>
2026-03-11 08:56:42 +02:00
Oscar Zhou a796a03a15 fix(edge/helm): helm edge stack is marked as external [BE-12653] (#1974) 2026-03-11 12:51:07 +13:00
andres-portainer 5a5dc67209 fix(golang-lru): consolidate the dependencies BE-12695 (#2021) 2026-03-10 18:57:49 -03:00
andres-portainer 69ae54b523 fix(zerolog): consolidate the dependencies BE-12695 (#2030) 2026-03-10 18:30:21 -03:00
andres-portainer b405227d51 fix(jwt): consolidate the dependencies BE-12695 (#2020) 2026-03-10 15:14:21 -03:00
andres-portainer 44be39a9a4 fix(mapstructure): consolidate the dependencies BE-12695 (#2019) 2026-03-10 14:48:37 -03:00
andres-portainer 5de0cc199c fix(kingpin): consolidate dependencies BE-12695 (#2018) 2026-03-10 14:33:10 -03:00
andres-portainer 0c9e408eda fix(ldap): consolidate dependencies BE-12695 (#2017) 2026-03-10 14:18:06 -03:00
Chaim Lev-Ari 1007f1f740 feat(ui): create shared terminal component [BE-12697] (#1979) 2026-03-10 18:17:29 +02:00
Chaim Lev-Ari 774e3d5948 fix(ws): remove limit on docker console [BE-12660] (#2023) 2026-03-10 15:26:33 +02:00
andres-portainer 4d866d066a fix(uuid): consolidate dependencies BE-12695 (#2016) 2026-03-10 10:12:42 -03:00
andres-portainer da6544e981 fix(semver): consolidate dependencies BE-12695 (#2014) 2026-03-09 15:33:45 -03:00
bernard-portainer 3af9a7646d fix(ui): add getRowId to expandable storage component [R8S-538] (#2008) 2026-03-09 15:37:40 +13:00
andres-portainer 0e2cf82e3e fix(yaml): consolidate dependencies BE-12695 (#2015) 2026-03-06 18:21:12 -03:00
andres-portainer 97e69b9887 fix(GO-2026-4550): upgrade circl to v1.6.3 BE-12694 (#2011) 2026-03-06 14:29:15 -03:00
andres-portainer 692f91263b fix(GO-2026-4473): upgrade go-git to v5.17.0 BE-12693 (#2010) 2026-03-06 11:23:52 -03:00
LP B 8b61d8a9d2 fix(app/container): query env registries instead of system registries (#1996) 2026-03-06 15:03:11 +01:00
LP B 25d51f9515 fix(app): paginate nested tables (#1998) 2026-03-06 15:01:52 +01:00
LP B 20b971dc1f fix(app/stack): virtual grouping in EnvSelector for non admins (#2001) 2026-03-06 15:00:01 +01:00
andres-portainer 7a76d749e3 fix(GO-2026-4394): upgrade opentelemetry to v1.41.0 BE-12692 (#2003) 2026-03-06 09:47:20 -03:00
LP B 123afd9462 fix(api/custom_template): validate UAC when retrieving custom template file (#1980) 2026-03-04 13:22:14 +01:00
Xing ad83478b77 fix(oauth): tolerate malformed Content-Type headers from resource ept (#1969)
Co-authored-by: Mike Spook <16549186+mikespook@user.noreply.gitee.com>
Co-authored-by: Oscar Zhou <100548325+oscarzhou-portainer@users.noreply.github.com>
Co-authored-by: RHCowan <50324595+RHCowan@users.noreply.github.com>

Thanks @srikanth-karthi for the original PR.
2026-03-02 10:59:02 +13:00
nickl-portainer 2ad0a65613 feat(policies): add inline editing ability to datatable for docker RBAC policies [R8S-717] (#1955) 2026-03-02 09:12:13 +13:00
Chaim Lev-Ari 1f5762b8c8 fix(settings/auth): fix a11y labels (#1963) 2026-03-01 12:14:47 +02:00
RHCowan 0370b09ad0 fix(policy) avoid URL length limit when adding environments to large groups [R8S-893] (#1970) 2026-02-27 11:45:15 +13:00
Oscar Zhou 5869a8948d refactor(stack): change stack creation flow to save stack first [BE-12650] (#1959) 2026-02-27 10:14:17 +13:00
Chaim Lev-Ari 56a840e207 feat(settings): migrate SessionLifetimeSelect to React [BE-12583] (#1829)
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-26 15:39:08 +02:00
Chaim Lev-Ari a01dd005fd refactor(settings/auth): migrate auto user provision toggle to react [BE-12585] (#1865)
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-26 14:18:48 +02:00
Chaim Lev-Ari 9ad6c16d43 feat(settings): migrate authentication method selector to React [BE-12584] (#1830)
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-26 10:52:39 +02:00
Hannah Cooper 9cc3e16db9 Update bug_report to include 2.39.0 (#1964) 2026-02-26 12:30:42 +13:00
andres-portainer d02bcdba29 fix(postinit): optimize PostInitMigrate() BE-12659 (#1958) 2026-02-25 16:03:26 -03:00
Steven Kang c708fe577c fix(kubernetes): local exec to fall back to SPDY - develop [R8S-873] (#1946) 2026-02-25 15:46:15 +13:00
Oscar Zhou c92161bb22 feat(edge/helm): support per device configuration [BE-12633] (#1901) 2026-02-25 10:00:37 +13:00
Ali 138aa13fdc fix(environment-groups): allow bulk selecting environments on create and edit [r8s-872] (#1954)
Merging because the failed system tests are related to helm and not environment groups
2026-02-24 17:53:16 +13:00
Steven Kang 988a795def fix(environment): collapsing More options breaking the style for podman - develop [R8S-874] (#1942) 2026-02-24 10:11:31 +13:00
Oscar Zhou 3f7a3053ff fix(stack): avoid removing running service if stack deployment fails [BE-12542] (#1940) 2026-02-24 08:41:42 +13:00
Oscar Zhou 0c8c6865be refactor(error): standardize multi errors handling [BE-12647] (#1933) 2026-02-23 09:40:01 +13:00
Chaim Lev-Ari 2bbcae39b6 feat: clean frontend test logs (#1894) 2026-02-22 09:42:49 +02:00
andres-portainer caf6b2aa0c fix(policies): fixes for async edge R8S-661 (#1917) 2026-02-20 17:45:45 -03:00
Steven Kang a00f05fe32 feat(environment): reorder options - develop [R8S-524] (#1822) 2026-02-20 14:58:01 +13:00
Chaim Lev-Ari 9fcac1ab4f chore(deps): upgrade axios [BE-12632] (#1864) 2026-02-19 15:38:08 +13:00
Josiah Clumont ae24ad4693 Bump version to 2.39.0 for LTS (#1910) 2026-02-19 15:29:08 +13:00
RHCowan 0f721b60a9 fix(policy) Improve policy status performance [R8S-710] (#1878) 2026-02-19 15:24:14 +13:00
RHCowan e8b49f53e1 fix(policy) fix policy group pagination issues [R8S-855] (#1898) 2026-02-19 13:29:01 +13:00
andres-portainer 27531a802b fix(fips): ensure custom registries cannot use HTTP without TLS BE-12511 (#1885)
Co-authored-by: andres-portainer <andres-portainer@users.noreply.github.com>
2026-02-19 11:51:11 +13:00
Josiah Clumont 4bbf0ce0c0 fix(docker): Update the docker binary version that uses 1.25.6 to fix CVE-2025-61726 - for 2.39.0-LTS [R8S-818] (#1791) 2026-02-19 09:46:14 +13:00
Josiah Clumont e0c22ea3eb fix(copy): Fixed an issue with the downgrade links [R8S-832] (#1907) 2026-02-19 09:38:04 +13:00
nickl-portainer b7eb2ba068 fix(policies) convert all warnings to use PolicyOverrideAlert [R8S-837] (#1890) 2026-02-19 09:12:54 +13:00
Ali affdb69568 fix(policies): show registry policy banner, and disable registry selector when policy applies [R8S-853] (#1891) 2026-02-19 08:37:02 +13:00
LP B 763b7da65c fix(api/docker): do not rewrite HTTP code in responses of create requests (#1854) 2026-02-18 19:26:29 +01:00
Chaim Lev-Ari 42e9165347 fix(stacks): generate webhook id for stacks (#1876) 2026-02-17 10:38:18 +02:00
Ali 16dd08a359 feat(widget): update widget tab styling product wide [r8s-850] (#1881) 2026-02-17 10:33:43 +13:00
Ali 936494615c fix(select): stop react-select overlapping with footer [R8S-794] (#1880) 2026-02-17 08:53:50 +13:00
andres-portainer 5769c0b98e fix(kubernetes): add missing returns BE-12582 (#1883) 2026-02-16 12:47:27 -03:00
andres-portainer b7e1caa8c6 fix(boltdb): fix error handling BE-12582 (#1882) 2026-02-16 12:47:00 -03:00
andres-portainer e02ae6b2fb fix(archive): prevent file traversal vulnerability BE-12582 (#1875) 2026-02-16 11:26:51 -03:00
testA113 d9f131a2c5 Revert "feat(widget): update widget tab styling product wide [r8s-850]"
This reverts commit d882c3b8fa4a03bf85b4e9fb1da729fabf903cb6.
2026-02-17 00:05:24 +13:00
testA113 ad1f7dbaa5 feat(widget): update widget tab styling product wide [r8s-850] 2026-02-17 00:01:07 +13:00
Devon Steenberg aa6da0f6d3 feat(api-testing): add api testing framework [BE-12571] (#1824)
Co-authored-by: Chaim Lev-Ari <chiptus@users.noreply.github.com>
2026-02-16 09:35:06 +13:00
Oscar Zhou 376071e408 feat(edge/helm): add atomic and timeout options [BE-12481] (#1849) 2026-02-16 09:21:19 +13:00
Chaim Lev-Ari d3544fb9b3 refactor(tests): mock ws server (#1853) 2026-02-15 08:58:24 +02:00
Chaim Lev-Ari c8497b3944 chore(deps): upgrade html-loader (#1863) 2026-02-15 08:55:33 +02:00
andres-portainer 5aa92b8413 fix(webhooks): use transactions to check for webhook uniqueness BE-12613 (#1872) 2026-02-13 12:48:17 -03:00
Hannah Cooper bccb6694d4 Update bug_report to include 2.38.1 (#1866) 2026-02-13 12:42:08 +13:00
Hannah Cooper 506a11c658 Update bug_report to include 2.33.7 (#1836) 2026-02-13 12:28:05 +13:00
Ali bdc315a59d fix(helm): helm release not found error [r8s-842] (#1857) 2026-02-13 08:07:23 +13:00
andres-portainer ec7d3bddfc fix(endpoints): fix transaction usage BE-12612 (#1838) 2026-02-11 12:34:46 -03:00
Chaim Lev-Ari 762c1ccf28 chore(deps): upgrade vitest and msw (#1852) 2026-02-11 17:14:04 +02:00
Malcolm Lockyer 8e44c8fa06 fix(webpack): fix common cfg after webpack-dev-server upgrade [r8s-841] (#1848) 2026-02-11 18:34:14 +13:00
Chaim Lev-Ari 20db102327 chore(deps): upgrade webpack (#1802) 2026-02-10 18:01:03 +02:00
Chaim Lev-Ari 1643cb8165 fix(environments): handle unix:// urls [BE-12610] (#1837)
Co-authored-by: Nicholas Loomans <nicholas.loomans@portainer.io>
2026-02-10 15:21:25 +02:00
Ali 49e623dfeb feat(policy-RBAC): ensure RBAC policy overrides existing RBAC settings [R8S-777] (#1718) 2026-02-10 23:44:44 +13:00
Steven Kang a1208974ac fix(policy): pod security constraints - develop [R8S-808] (#1758)
Co-authored-by: Phil Calder <4473109+predlac@users.noreply.github.com>
Co-authored-by: Viktor Pettersson <viktor.pettersson@portainer.io>
Co-authored-by: Yajith Dayarathna <yajith.dayarathna@portainer.io>
Co-authored-by: Chaim Lev-Ari <chiptus@users.noreply.github.com>
Co-authored-by: nickl-portainer <nicholas.loomans@portainer.io>
2026-02-10 08:46:02 +09:00
Chaim Lev-Ari d611087513 chore(deps): upgrade storybook 8 (#1811) 2026-02-08 09:59:08 +02:00
andres-portainer ac7cb2ee19 fix(security): fix CVE-2025-68121 by upgrading Go compiler BE-12581 (#1813) 2026-02-06 13:17:12 -03:00
Oscar Zhou f866572cbf fix(edge/helm): helm config section shows for other type [BE-12580] (#1808) 2026-02-06 09:13:06 +13:00
Chaim Lev-Ari 4c6942f60b fix(environments): update associated group [BE-12559] (#1760) 2026-02-05 18:48:02 +02:00
nickl-portainer d939897524 feat(menu) add policies to environment settings submenu [R8S-806] (#1805) 2026-02-05 14:39:41 +13:00
nickl-portainer 66c5589fd7 fix(environment-list) resize kubeconfig download modal [R8S-814] (#1786)
Co-authored-by: Phil Calder <4473109+predlac@users.noreply.github.com>
Co-authored-by: Steven Kang <skan070@gmail.com>
Co-authored-by: Viktor Pettersson <viktor.pettersson@portainer.io>
Co-authored-by: Yajith Dayarathna <yajith.dayarathna@portainer.io>
Co-authored-by: Chaim Lev-Ari <chiptus@users.noreply.github.com>
Co-authored-by: Malcolm Lockyer <segfault88@users.noreply.github.com>
Co-authored-by: Ali <83188384+testA113@users.noreply.github.com>
Co-authored-by: RHCowan <50324595+RHCowan@users.noreply.github.com>
2026-02-05 14:39:23 +13:00
Oscar Zhou 379b1d611b feat(edge/helm): support helm chart via git repository in edge stack [BE-12448] (#1649) 2026-02-05 13:22:31 +13:00
Chaim Lev-Ari f16221f385 docs(claude): optimize memory files (#1777) 2026-02-05 04:28:36 +05:30
RHCowan 9b82560270 fix(policy) Fetch new status after policy update [R8S-711] (#1775) 2026-02-04 18:23:26 +13:00
Oscar Zhou 7271af03e6 fix(docker): dashboard api return 500 error [BE-12567] (#1784) 2026-02-04 08:32:01 +13:00
RHCowan 4d564bbce2 feat(policy): Display last attempt timestamp for policy installations [R8S-667] (#1774) 2026-02-03 12:32:22 +13:00
Oscar Zhou d7afdf214b refactor(k8s): replace kubectl delete with delete api [BE-12560] (#1768) 2026-02-03 08:36:08 +13:00
Chaim Lev-Ari 18e445ea02 refactor(environments): migrate item view to react [BE-6632] (#1747) 2026-01-31 15:05:11 +07:00
nickl-portainer cb70c705a3 fix(react): namespace selects sort alphabetically [R8S-765] (#1671) 2026-01-30 08:23:01 +13:00
Ali 9a77eb9872 chore(environment-groups): migrate environment groups to react [R8S-771] (#1741) 2026-01-29 14:17:33 +13:00
Hannah Cooper ec82f646a0 Add 2.38.0 to bug report (#1756) 2026-01-29 12:45:23 +13:00
andres-portainer 2f0e384240 fix(database): use Exists() where possible to improve performance BE-12557 (#1752) 2026-01-28 18:49:32 -03:00
Ali 19a1426869 chore(webpack): cache dependencies and use lighter sourcemap [R8S-791] (#1715) 2026-01-29 09:52:11 +13:00
andres-portainer cc5cd8db6b fix(pendingactions): clean up and optimize the code BE-12556 (#1750) 2026-01-28 15:36:54 -03:00
andres-portainer e384e2edda fix(pendingactions): fix transaction handling BE-12556 (#1749) 2026-01-28 14:11:35 -03:00
Chaim Lev-Ari dca044873f feat(environments): migrate edge form to react BE-12529 (#1676) 2026-01-28 15:35:13 +07:00
nickl-portainer 8aadddcc68 test(react): add test coverage for forms to enforce no errors showing on initial load [R8S-730] (#1696) 2026-01-28 08:12:12 +13:00
andres-portainer 2e95229c51 fix(oauth): add a timeout to GetResource() BE-12258 (#1456) 2026-01-27 10:24:45 -03:00
Phil Calder 8a1d02c23f Bump version to 2.38.0 (#1727) 2026-01-27 16:26:14 +13:00
Josiah Clumont d6bca4ea79 chore(icon): Update sidebar icon & favicon to align with branding (#1737) 2026-01-27 15:11:28 +13:00
LP B 7b567a66ed fix(app/stack): remove unauthorizedRedirect from stack details view (#1720) 2026-01-26 22:21:41 +01:00
Chaim Lev-Ari 2c8126e244 refactor(environments): migrate general environment form to react (#1706) 2026-01-26 14:40:01 -03:00
Chaim Lev-Ari 1b70fe5770 feat(registries): enable ecr registry for fips BE-12539 (#1665) 2026-01-26 14:38:57 -03:00
andres-portainer 71c000756b chore(linters): enforce error checking in CE BE-12527 (#1723) 2026-01-26 14:37:55 -03:00
Yajith Dayarathna a2a7ead82a chore(ci): updates to pnpm lint and gofmt (#1730) 2026-01-27 06:14:20 +13:00
Malcolm Lockyer ef0f1b10cc fix(database): fix encryption of existing database [r8s-537] (#1663)
Co-authored-by: Gorbasch <mbegerau@users.noreply.github.com>
2026-01-25 17:45:38 +13:00
RHCowan 42bedce9c0 feat(policy) add policy status filter to endpoint list [R8S-736] (#1682) 2026-01-23 12:03:05 +13:00
Devon Steenberg afcd44abad fix(kubectl-shell): enable kubectl shell in fips mode [BE-12422] (#1702)
Co-authored-by: Yajith Dayarathna <yajith.dayarathna@portainer.io>
2026-01-23 09:38:26 +13:00
Josiah Clumont 274830f533 fix(policy): Policy status bar doesn't use correct colours (#1714) 2026-01-23 08:12:45 +13:00
Ali 9cb139d190 fix(access): handle access view loading and error states [R8S-779] (#1709) 2026-01-22 13:04:43 +13:00
Josiah Clumont d681481ae9 feat(policy): rework the environment type row in the policy view [R8S-695] (#1698) 2026-01-22 09:43:55 +13:00
Oscar Zhou 5d377e602f fix(edgestack): EntryFileName not found [BE-12499] (#1578) 2026-01-22 08:44:31 +13:00
Ali f535c814d9 feat(policies): UI stepper in policy create and environment wizard [R8S-718] (#1672) 2026-01-21 09:37:39 +13:00
andres-portainer 4f5073cd9e chore(refactor): clean up the code R8S-661 (#1687) 2026-01-16 16:10:00 -03:00
LP B 9cd2340007 fix(app/home): display API error message instead of generic error when env is unreachable (#1670) 2026-01-16 14:38:28 +01:00
Chaim Lev-Ari 9ca036e393 feat(pnpm): add system-tests to workspace PLA-567 (#1664) 2026-01-15 12:45:23 +02:00
andres-portainer 5340ecb6df refactor(stackutils): consolidate validation code BE-12391 (#1667) 2026-01-14 18:00:01 -03:00
Chaim Lev-Ari 1248d52161 refactor(environment): migrate azure form to react BE-12528 (#1642)
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-14 18:20:15 +02:00
andres-portainer 3e2fdb1891 fix(swarm): fix environment security checks BE-12541 (#1666) 2026-01-14 12:25:50 -03:00
andres-portainer ac8fa7672e fix(environments): improve the default environment security settings BE-12391 (#1656) 2026-01-14 10:36:42 -03:00
LP B db57716130 fix(api): remove overly verbose log on startup (#1655) 2026-01-13 19:39:35 +01:00
LP B b162814bd9 fix(uac): async SnapshotRaw data not filtered by UAC (#1540) 2026-01-13 17:17:06 +01:00
LP B a889d57013 fix(app/edge): UI form error on edge stack update (#1643) 2026-01-13 17:15:51 +01:00
Chaim Lev-Ari c6e9cdbf35 fix(stacks): save registries when creating stack BE-12526 (#1633) 2026-01-13 09:00:48 +02:00
Phil Calder 2a00d90134 chore(docs): Adds a SECURITY.md to repos (#1636)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-13 13:07:50 +13:00
andres-portainer 2676cd7219 chore(linters): add the unused, zerologlint and exptostd linters BE-12527 (#1645) 2026-01-12 10:28:17 -03:00
Chaim Lev-Ari 4f76b1fda4 refactor(environments): prepare common fields for edit env form BE-12531 (#1641)
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-11 19:01:28 +02:00
Chaim Lev-Ari 1c56d5c59e fix(environments): fix issues in edit page (#1640) 2026-01-09 16:41:39 +02:00
Chaim Lev-Ari be44eedeb8 feat(environments): migrate KubeConfigInfo to React (PR 8 of 10) [BE-12524] (#1625)
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 14:37:21 +02:00
Chaim Lev-Ari 36296d2f5d fix(docker/configs): delete config from item view BE-12525 (#1628) 2026-01-09 14:36:24 +02:00
andres-portainer b4db75fb55 chore(linters): add the unconvert linter BE-12527 (#1635) 2026-01-09 09:22:13 -03:00
Chaim Lev-Ari 565c36040d feat(environments): migrate edge agent deployment to React [BE-12522] (#1626)
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 13:32:05 +02:00
Ali 36e7f821e8 fix(namespace): fix namespace user access calls and parsing [r8s-726] (#1610) 2026-01-09 13:15:57 +13:00
Ali 009e1e25f5 fix(k8s deploy): ensure namespace from deploy form/api call can be used [r8s-747] (#1632) 2026-01-09 12:57:03 +13:00
Ali 69715ed1c8 fix(helm): avoid widget title error thrown for helm edit/upgrade [r8s-746] (#1630) 2026-01-09 10:25:51 +13:00
andres-portainer e8cee12384 chore(linters): add the modernize linter BE-12527 (#1634) 2026-01-08 16:35:18 -03:00
andres-portainer f2fd2c157c chore(errcheck): ensure errcheck scans everything BE-12183 (#1094) 2026-01-08 14:41:40 -03:00
Chaim Lev-Ari 3f6cee5ded feat(portainer): migrate EdgeInformationPanel to React BE-12521 (#1624)
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-08 15:27:27 +02:00
Devon Steenberg b1cb95c3b0 fix(docker): bump docker max api version [BE-12462] (#1556) 2026-01-08 14:22:48 +13:00
LP B 372bc3c97c fix(app): generate a container name when names list is empty (#1615) 2026-01-07 20:20:28 +01:00
Chaim Lev-Ari fa684f95e0 feat(portainer): migrate Environment basic config section to React BE-12520 (#1620) 2026-01-07 18:37:19 +02:00
Chaim Lev-Ari e8fb8a6f88 feat(portainer): migrate AzureEndpointConfigSection to React BE-12519 (#1619)
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-07 17:36:52 +02:00
andres-portainer 93901336bb fix(git): upgrade go-git to v5.16.4 BE-12512 (#1607) 2026-01-07 09:18:21 -03:00
RHCowan 660f2095af fix(policy) Show all policy types in selector [R8S-735] (#1591) 2026-01-07 19:12:30 +13:00
Ali 13b27cf77a feat(aci): environment variable support [r8s-675] (#1445)
Merging because the playwright tests don't relate to the container instance changes in this PR
2026-01-07 15:49:54 +13:00
Oscar Zhou d1eb5a8466 fix(stack/k8s): kubectl command memory leak [BE-12455] (#1582) 2026-01-07 11:51:28 +13:00
andres-portainer 5d0aefb07a fix(registryproxy): consolidate the TLS initialization code BE-12511 (#1601) 2026-01-06 10:59:38 -03:00
andres-portainer 78a23bb722 fix(frontend): update dependencies to fix vulnerabilities BE-12506 (#1595)
Co-authored-by: andres-portainer <andres-portainer@users.noreply.github.com>
Co-authored-by: Chaim Lev-Ari <chaim.lev-ari@portainer.io>
2026-01-06 10:58:46 -03:00
Chaim Lev-Ari 38c42cb47b refactor(containers): migrate container item view to react BE-6582 (#1606)
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-06 12:33:24 +02:00
Chaim Lev-Ari c9c779d5d5 refactor(containers): migrate volume section to react BE-12495 (#1605) 2026-01-06 10:18:51 +02:00
Chaim Lev-Ari dabfd4249e refactor(containers): migrate container details section to react BE-12494 (#1602) 2026-01-06 08:05:30 +02:00
Ali e62db5f1d9 chore(pre-commit hooks): allow golangci-lint to run concurrently for CE and EE for pre commit hook [R8S-737] (#1608) 2026-01-06 16:57:03 +13:00
Chaim Lev-Ari 50c01c97ee fix(proxy): add error handler to print error to user (#1593) 2026-01-05 14:40:35 +02:00
andres-portainer 68600dddf0 fix(security): fix a nil pointer dereference error in FilterEndpoints() BE-12509 (#1598) 2026-01-02 16:08:17 -03:00
andres-portainer c80464d072 fix(edgegroups): fix a nil pointer dereference BE-12487 (#1573) 2026-01-02 15:26:53 -03:00
andres-portainer 02a083fa02 fix(filesystem): fix a nil pointer dereference error in CopyPath() BE-12508 (#1597) 2026-01-02 15:18:21 -03:00
andres-portainer 36ff24c301 fix(endpointgroups): fix a nil pointer dereference error in deleteEndpointGroup BE-12510 (#1599) 2026-01-02 15:17:51 -03:00
Chaim Lev-Ari 935f3b8754 refactor(containers): migrate image section to react BE-12493 (#1594) 2026-01-01 11:12:05 +02:00
Chaim Lev-Ari eac9f649cf chore(build): introduce pnpm workspaces (#1584) 2025-12-31 18:52:58 +02:00
Chaim Lev-Ari 8bcd27e042 refactor(containers): migrate status section to react BE-12492 (#1583) 2025-12-31 10:12:37 +02:00
Chaim Lev-Ari c3dbf51a16 feat(docker): migrate ContainerActionsSection to React (PR 2 of 7) (#1576) 2025-12-30 11:41:49 +02:00
Chaim Lev-Ari 36417a0726 chore(build): migrate to pnpm (#1558) 2025-12-29 10:14:57 +02:00
Yajith Dayarathna 20b87f8bb9 fix(build): adding fixes for docker buildx build warnings in ci (#1567) 2025-12-29 10:31:51 +13:00
Chaim Lev-Ari a1bac5a133 refactor(stacks): migrate create view to react [BE-6630] (#1538) 2025-12-26 16:50:55 +02:00
Chaim Lev-Ari 177da24e47 feat(docker): migrate RestartPolicySection to React BE-12490 (#1570) 2025-12-24 18:38:52 +02:00
Chaim Lev-Ari 37ba8d17bf fix(stacks): confirm rename with modal BE-12497 (#1571) 2025-12-24 17:45:27 +02:00
andres-portainer ee8b78fd3c chore(segmentio/encoding): upgrade to v0.5.3 BE-12500 (#1575) 2025-12-24 12:09:01 -03:00
Chaim Lev-Ari 83bc685e75 fix(stacks): allow renaming stack in swarm BE-12496 (#1572) 2025-12-24 16:41:37 +02:00
andres-portainer 3781897e39 fix(compose): upgrade compose-go to v2.40.3 to fix a nil panic BE-12424 (#1550) 2025-12-23 22:26:25 -03:00
Chaim Lev-Ari 0efed6d8d3 fix(stacks): invalidate only stack cache on update BE-12476 (#1566) 2025-12-23 15:27:26 +02:00
Chaim Lev-Ari 8f2c33aec3 chore(node): upgrade node version in CI [BE-12465] (#1525) 2025-12-23 10:22:48 +02:00
Chaim Lev-Ari 433b5bc974 fix(ci): run eslint and typecheck without symlinks (#1564) 2025-12-22 17:38:42 +02:00
Chaim Lev-Ari aef27f475d feat(analytics): remove setting for collection analytics [BE-12402] (#1559) 2025-12-22 15:59:08 +02:00
Viktor Pettersson 28ccf19874 fix(docs): ensure all docs related dependencies, such as struct types are available before building swagger docs PLA-542 (#1562) 2025-12-22 15:02:56 +13:00
Yajith Dayarathna 7e54f40033 chore: ci workflow(round3) and Dockerfile update (#1542) 2025-12-22 10:54:51 +13:00
Chaim Lev-Ari bf8ccbcec6 Revert "feat(frontend): import CE code to EE" (#1557) 2025-12-18 13:45:26 +02:00
Chaim Lev-Ari 2f5b083c5c feat(frontend): import CE code to EE (#1365) 2025-12-17 13:02:19 +02:00
James Carppe 5640e8c11a Version bump for 2.33.6 (#1548) 2025-12-17 18:25:29 +13:00
Devon Steenberg c239445454 fix(swarm): stack deployments [BE-12478] (#1546)
This commit https://github.com/docker/cli/commit/9b9d103b297cdff32e35dde771c8c392c7caabeb, introduced in docker 29, changed the behaviour of how the --tlsXXX flags are handled. Before this change leading and trailing quotes would be stripped. This meant that an invalid path that we were passing for the tls ca cert was being cleaned up to be an empty string. To preserve the old behaviour we now pass an empty string.
2025-12-17 14:21:49 +13:00
Chaim Lev-Ari a7b7ddbe76 fix(containers): clear mac address on edit/duplicate [BE-12436] (#1524) 2025-12-15 09:59:47 +02:00
andres-portainer d859272d43 chore(compress): upgrade klauspost/compress to v1.18.2 (#1534) 2025-12-12 12:30:00 -03:00
Oscar Zhou d59a16a9a1 fix(stack): stack start failed with private image [BE-12464] (#1523) 2025-12-12 10:55:03 +13:00
andres-portainer 79f524865f fix(yaml): switch from gopkg.in/yaml.v3 to go.yaml.in/yaml/v3 BE-12340 (#1527) 2025-12-11 16:44:56 -03:00
Chaim Lev-Ari 6d0a09402b refactor(stacks): migrate item view to react [BE-6629] (#1444) 2025-12-11 10:21:43 +02:00
Steven Kang 4bb160b281 fix(security): cve-2025-47914 and 58181 - develop [R8S-714] (#1516) 2025-12-11 15:22:22 +09:00
Hannah Cooper 24d27f421b Update bug_report to include 2.37.0 (#1518) 2025-12-11 12:41:05 +13:00
Chaim Lev-Ari 3d0b8ec5f0 feat(update): prevent the creation of updater network [BE-12441] (#1517) 2025-12-10 18:45:46 +02:00
Chaim Lev-Ari 79e6271041 refactor(docker/images): migrate list view to react [BE-6562] (#1451) 2025-12-09 15:27:20 +02:00
Chaim Lev-Ari ecac526810 feat(analytics): remove frontend analytics module (#1459) 2025-12-09 09:27:51 +02:00
Oscar Zhou ad8d5a8694 version: bump version to 2.37.0 (#1501) 2025-12-09 13:06:50 +13:00
2407 changed files with 107091 additions and 44634 deletions
-3
View File
@@ -1,3 +0,0 @@
node_modules/
dist/
test/
-151
View File
@@ -1,151 +0,0 @@
env:
browser: true
jquery: true
node: true
es6: true
globals:
angular: true
extends:
- 'eslint:recommended'
- 'plugin:storybook/recommended'
- 'plugin:import/typescript'
- prettier
plugins:
- import
parserOptions:
ecmaVersion: latest
sourceType: module
project: './tsconfig.json'
ecmaFeatures:
modules: true
rules:
no-console: error
no-alert: error
no-control-regex: 'off'
no-empty: warn
no-empty-function: warn
no-useless-escape: 'off'
import/named: error
import/order:
[
'error',
{
pathGroups:
[
{ pattern: '@@/**', group: 'internal', position: 'after' },
{ pattern: '@/**', group: 'internal' },
{ pattern: '{Kubernetes,Portainer,Agent,Azure,Docker}/**', group: 'internal' },
],
groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index'],
pathGroupsExcludedImportTypes: ['internal'],
},
]
no-restricted-imports:
- error
- patterns:
- group:
- '@/react/test-utils/*'
message: 'These utils are just for test files'
settings:
'import/resolver':
alias:
map:
- ['@@', './app/react/components']
- ['@', './app']
extensions: ['.js', '.ts', '.tsx']
typescript: true
node: true
overrides:
- files:
- app/**/*.ts{,x}
parserOptions:
project: './tsconfig.json'
parser: '@typescript-eslint/parser'
plugins:
- '@typescript-eslint'
- 'regex'
extends:
- airbnb
- airbnb-typescript
- 'plugin:eslint-comments/recommended'
- 'plugin:react-hooks/recommended'
- 'plugin:react/jsx-runtime'
- 'plugin:@typescript-eslint/recommended'
- 'plugin:@typescript-eslint/eslint-recommended'
- 'plugin:promise/recommended'
- 'plugin:storybook/recommended'
- prettier # should be last
settings:
react:
version: 'detect'
rules:
no-console: error
import/order:
[
'error',
{
pathGroups: [{ pattern: '@@/**', group: 'internal', position: 'after' }, { pattern: '@/**', group: 'internal' }],
groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index'],
'newlines-between': 'always',
},
]
no-plusplus: off
func-style: [error, 'declaration']
import/prefer-default-export: off
no-use-before-define: 'off'
'@typescript-eslint/no-use-before-define': ['error', { functions: false, 'allowNamedExports': true }]
no-shadow: 'off'
'@typescript-eslint/no-shadow': off
jsx-a11y/no-autofocus: warn
react/forbid-prop-types: off
react/require-default-props: off
react/no-array-index-key: off
no-underscore-dangle: off
react/jsx-filename-extension: [0]
import/no-extraneous-dependencies: ['error', { devDependencies: true }]
'@typescript-eslint/explicit-module-boundary-types': off
'@typescript-eslint/no-unused-vars': 'error'
'@typescript-eslint/no-explicit-any': 'error'
'jsx-a11y/label-has-associated-control': ['error', { 'assert': 'either', controlComponents: ['Input', 'Checkbox'] }]
'react/function-component-definition': ['error', { 'namedComponents': 'function-declaration' }]
'react/jsx-no-bind': off
'no-await-in-loop': 'off'
'react/jsx-no-useless-fragment': ['error', { allowExpressions: true }]
'regex/invalid': ['error', [{ 'regex': '<Icon icon="(.*)"', 'message': 'Please directly import the `lucide-react` icon instead of using the string' }]]
'@typescript-eslint/no-restricted-imports':
- error
- patterns:
- group:
- '@/react/test-utils/*'
message: 'These utils are just for test files'
overrides: # allow props spreading for hoc files
- files:
- app/**/with*.ts{,x}
rules:
'react/jsx-props-no-spreading': off
- files:
- app/**/*.test.*
extends:
- 'plugin:vitest/recommended'
env:
'vitest/env': true
rules:
'react/jsx-no-constructed-context-values': off
'@typescript-eslint/no-restricted-imports': off
no-restricted-imports: off
'react/jsx-props-no-spreading': off
- files:
- app/**/*.stories.*
rules:
'no-alert': off
'@typescript-eslint/no-restricted-imports': off
no-restricted-imports: off
'react/jsx-props-no-spreading': off
+14 -37
View File
@@ -22,7 +22,7 @@ body:
options:
- label: Yes, I've searched similar issues on [GitHub](https://github.com/portainer/portainer/issues).
required: true
- label: Yes, I've checked whether this issue is covered in the Portainer [documentation](https://docs.portainer.io) or [knowledge base](https://portal.portainer.io/knowledge).
- label: Yes, I've checked whether this issue is covered in the Portainer [documentation](https://docs.portainer.io).
required: true
- type: markdown
@@ -94,51 +94,28 @@ body:
description: We only provide support for current versions of Portainer as per the lifecycle policy linked above. If you are on an older version of Portainer we recommend [updating first](https://docs.portainer.io/start/upgrade) in case your bug has already been fixed.
multiple: false
options:
- '2.41.1'
- '2.41.0'
- '2.40.0'
- '2.39.2'
- '2.39.1'
- '2.39.0'
- '2.38.1'
- '2.38.0'
- '2.37.0'
- '2.36.0'
- '2.35.0'
- '2.34.0'
- '2.33.8'
- '2.33.7'
- '2.33.6'
- '2.33.5'
- '2.33.4'
- '2.33.3'
- '2.33.2'
- '2.33.1'
- '2.33.0'
- '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'
- '2.21.3'
- '2.21.2'
- '2.21.1'
- '2.21.0'
validations:
required: true
+2
View File
@@ -18,3 +18,5 @@ api/docs
.env
go.work.sum
.vitest
+3 -6
View File
@@ -6,11 +6,8 @@ linters:
settings:
forbidigo:
forbid:
- pattern: ^dataservices.DataStore.(EdgeGroup|EdgeJob|EdgeStack|EndpointRelation|Endpoint|GitCredential|Registry|ResourceControl|Role|Settings|Snapshot|Stack|Tag|User)$
- pattern: ^dataservices.DataStore.(EdgeGroup|EdgeJob|EdgeStack|EndpointRelation|Endpoint|GitCredential|Registry|ResourceControl|Role|Settings|Snapshot|SSLSettings|Stack|Tag|User)$
msg: Use a transaction instead
- pattern: ^(filepath|path)\.Join$
msg: Use filesystem.JoinPaths() from github.com/portainer/portainer/api/filesystem to prevent path traversal attacks
analyze-types: true
exclusions:
rules:
- path: _test\.go
linters:
- forbidigo
+36 -1
View File
@@ -1,10 +1,14 @@
version: "2"
run:
allow-parallel-runners: true
linters:
default: none
enable:
- bodyclose
- copyloopvar
- depguard
- errcheck
- errorlint
- forbidigo
- govet
@@ -17,8 +21,14 @@ linters:
- durationcheck
- errorlint
- govet
- usetesting
- zerologlint
- testifylint
- modernize
- unconvert
- unused
- zerologlint
- exptostd
settings:
staticcheck:
checks: ["all", "-ST1003", "-ST1005", "-ST1016", "-SA1019", "-QF1003"]
@@ -42,6 +52,30 @@ linters:
desc: golang.org/x/crypto is not allowed because of FIPS mode
- pkg: github.com/ProtonMail/go-crypto/openpgp
desc: github.com/ProtonMail/go-crypto/openpgp is not allowed because of FIPS mode
- pkg: github.com/cosi-project/runtime
desc: github.com/cosi-project/runtime is not allowed because of FIPS mode
- pkg: gopkg.in/yaml.v2
desc: use go.yaml.in/yaml/v3 instead
- pkg: gopkg.in/yaml.v3
desc: use go.yaml.in/yaml/v3 instead
- pkg: github.com/golang-jwt/jwt/v4
desc: use github.com/golang-jwt/jwt/v5 instead
- pkg: github.com/mitchellh/mapstructure
desc: use github.com/go-viper/mapstructure/v2 instead
- pkg: gopkg.in/alecthomas/kingpin.v2
desc: use github.com/alecthomas/kingpin/v2 instead
- pkg: github.com/jcmturner/gokrb5$
desc: use github.com/jcmturner/gokrb5/v8 instead
- pkg: github.com/gofrs/uuid
desc: use github.com/google/uuid
- pkg: github.com/Masterminds/semver$
desc: use github.com/Masterminds/semver/v3
- pkg: github.com/blang/semver
desc: use github.com/Masterminds/semver/v3
- pkg: github.com/coreos/go-semver
desc: use github.com/Masterminds/semver/v3
- pkg: github.com/hashicorp/go-version
desc: use github.com/Masterminds/semver/v3
forbidigo:
forbid:
- pattern: ^tls\.Config$
@@ -59,12 +93,13 @@ linters:
- comments
- common-false-positives
- legacy
- std-error-handling
paths:
- third_party$
- builtin$
- examples$
formatters:
enable:
- gofmt
exclusions:
generated: lax
paths:
+1 -1
View File
@@ -1,4 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
cd $(dirname -- "$0") && yarn lint-staged
cd $(dirname -- "$0") && pnpm lint-staged
+2 -1
View File
@@ -1,2 +1,3 @@
dist
api/datastore/test_data
api/datastore/test_data
coverage
+6 -9
View File
@@ -5,21 +5,18 @@
"trailingComma": "es5",
"overrides": [
{
"files": [
"*.html"
],
"files": ["*.html"],
"options": {
"parser": "angular"
}
},
{
"files": [
"*.{j,t}sx",
"*.ts"
],
"files": ["*.{j,t}sx", "*.ts"],
"options": {
"printWidth": 80
}
}
]
}
],
"plugins": ["prettier-plugin-tailwindcss"],
"tailwindFunctions": ["clsx"]
}
+42 -20
View File
@@ -1,30 +1,56 @@
// 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 TsconfigPathsPlugin from 'tsconfig-paths-webpack-plugin';
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',
{
name: '@storybook/addon-styling',
name: '@storybook/addon-styling-webpack',
options: {
cssLoaderOptions: {
importLoaders: 1,
modules: {
localIdentName: '[path][name]__[local]',
auto: true,
exportLocalsConvention: 'camelCaseOnly',
rules: [
{
test: /\.css$/,
sideEffects: true,
use: [
require.resolve('style-loader'),
{
loader: require.resolve('css-loader'),
options: {
importLoaders: 1,
modules: {
localIdentName: '[path][name]__[local]',
auto: true,
exportLocalsConvention: 'camelCaseOnly',
},
},
},
{
loader: require.resolve('postcss-loader'),
options: {
implementation: postcss,
},
},
],
},
},
postCss: {
implementation: postcss,
},
],
},
},
'@storybook/addon-docs',
],
webpackFinal: (config) => {
const rules = config?.module?.rules || [];
@@ -67,12 +93,7 @@ const config: StorybookConfig = {
...config,
resolve: {
...config.resolve,
plugins: [
...(config.resolve?.plugins || []),
new TsconfigPathsPlugin({
extensions: config.resolve?.extensions,
}),
],
tsconfig: path.resolve(__dirname, '..', 'tsconfig.json'),
},
module: {
...config.module,
@@ -82,12 +103,13 @@ const config: StorybookConfig = {
},
staticDirs: ['./public'],
typescript: {
reactDocgen: 'react-docgen-typescript',
reactDocgen: 'react-docgen',
},
framework: {
name: '@storybook/react-webpack5',
options: {},
},
docs: {},
};
export default config;
+59 -24
View File
@@ -1,9 +1,10 @@
import { useEffect } from 'react';
import '../app/assets/css';
import React from 'react';
import { pushStateLocationPlugin, UIRouter } from '@uirouter/react';
import { initialize as initMSW, mswLoader } from 'msw-storybook-addon';
import { handlers } from '../app/setup-tests/server-handlers';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Preview } from '@storybook/react-webpack5';
initMSW(
{
@@ -21,31 +22,65 @@ initMSW(
handlers
);
export const parameters = {
actions: { argTypesRegex: '^on[A-Z].*' },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
msw: {
handlers,
},
};
const testQueryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
export const decorators = [
(Story) => (
<QueryClientProvider client={testQueryClient}>
<UIRouter plugins={[pushStateLocationPlugin]}>
<Story />
</UIRouter>
</QueryClientProvider>
),
];
const preview: Preview = {
globalTypes: {
theme: {
description: 'Portainer color theme',
toolbar: {
title: 'Theme',
icon: 'paintbrush',
items: [
{ value: 'light', title: 'Light', icon: 'sun' },
{ value: 'dark', title: 'Dark', icon: 'moon' },
{ value: 'highcontrast', title: 'High Contrast', icon: 'eye' },
],
dynamicTitle: true,
},
},
},
initialGlobals: {
theme: 'light',
},
decorators: (Story, context) => {
const theme = context.globals.theme;
export const loaders = [mswLoader];
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: {
storySort: {
order: ['Design System', 'Components', '*'],
},
},
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
msw: {
handlers,
},
},
};
export default preview;
+126 -74
View File
@@ -2,26 +2,26 @@
/* tslint:disable */
/**
* Mock Service Worker (2.0.11).
* Mock Service Worker.
* @see https://github.com/mswjs/msw
* - Please do NOT modify this file.
* - Please do NOT serve this file on production.
*/
const INTEGRITY_CHECKSUM = 'c5f7f8e188b673ea4e677df7ea3c5a39';
const PACKAGE_VERSION = '2.12.10';
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82';
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse');
const activeClientIds = new Set();
self.addEventListener('install', function () {
addEventListener('install', function () {
self.skipWaiting();
});
self.addEventListener('activate', function (event) {
addEventListener('activate', function (event) {
event.waitUntil(self.clients.claim());
});
self.addEventListener('message', async function (event) {
const clientId = event.source.id;
addEventListener('message', async function (event) {
const clientId = Reflect.get(event.source || {}, 'id');
if (!clientId || !self.clients) {
return;
@@ -48,7 +48,10 @@ self.addEventListener('message', async function (event) {
case 'INTEGRITY_CHECK_REQUEST': {
sendToClient(client, {
type: 'INTEGRITY_CHECK_RESPONSE',
payload: INTEGRITY_CHECKSUM,
payload: {
packageVersion: PACKAGE_VERSION,
checksum: INTEGRITY_CHECKSUM,
},
});
break;
}
@@ -58,16 +61,16 @@ self.addEventListener('message', async function (event) {
sendToClient(client, {
type: 'MOCKING_ENABLED',
payload: true,
payload: {
client: {
id: client.id,
frameType: client.frameType,
},
},
});
break;
}
case 'MOCK_DEACTIVATE': {
activeClientIds.delete(clientId);
break;
}
case 'CLIENT_CLOSED': {
activeClientIds.delete(clientId);
@@ -85,72 +88,91 @@ self.addEventListener('message', async function (event) {
}
});
self.addEventListener('fetch', function (event) {
const { request } = event;
addEventListener('fetch', function (event) {
const requestInterceptedAt = Date.now();
// Bypass navigation requests.
if (request.mode === 'navigate') {
if (event.request.mode === 'navigate') {
return;
}
// Opening the DevTools triggers the "only-if-cached" request
// that cannot be handled by the worker. Bypass such requests.
if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') {
if (event.request.cache === 'only-if-cached' && event.request.mode !== 'same-origin') {
return;
}
// Bypass all requests when there are no active clients.
// Prevents the self-unregistered worked from handling requests
// after it's been deleted (still remains active until the next reload).
// after it's been terminated (still remains active until the next reload).
if (activeClientIds.size === 0) {
return;
}
// Generate unique request ID.
const requestId = crypto.randomUUID();
event.respondWith(handleRequest(event, requestId));
event.respondWith(handleRequest(event, requestId, requestInterceptedAt));
});
async function handleRequest(event, requestId) {
/**
* @param {FetchEvent} event
* @param {string} requestId
* @param {number} requestInterceptedAt
*/
async function handleRequest(event, requestId, requestInterceptedAt) {
const client = await resolveMainClient(event);
const response = await getResponse(event, client, requestId);
const requestCloneForEvents = event.request.clone();
const response = await getResponse(event, client, requestId, requestInterceptedAt);
// Send back the response clone for the "response:*" life-cycle events.
// Ensure MSW is active and ready to handle the message, otherwise
// this message will pend indefinitely.
if (client && activeClientIds.has(client.id)) {
(async function () {
const responseClone = response.clone();
const serializedRequest = await serializeRequest(requestCloneForEvents);
sendToClient(
client,
{
type: 'RESPONSE',
payload: {
requestId,
isMockedResponse: IS_MOCKED_RESPONSE in response,
// Clone the response so both the client and the library could consume it.
const responseClone = response.clone();
sendToClient(
client,
{
type: 'RESPONSE',
payload: {
isMockedResponse: IS_MOCKED_RESPONSE in response,
request: {
id: requestId,
...serializedRequest,
},
response: {
type: responseClone.type,
status: responseClone.status,
statusText: responseClone.statusText,
body: responseClone.body,
headers: Object.fromEntries(responseClone.headers.entries()),
body: responseClone.body,
},
},
[responseClone.body]
);
})();
},
responseClone.body ? [serializedRequest.body, responseClone.body] : []
);
}
return response;
}
// Resolve the main client for the given event.
// Client that issues a request doesn't necessarily equal the client
// that registered the worker. It's with the latter the worker should
// communicate with during the response resolving phase.
/**
* Resolve the main client for the given event.
* Client that issues a request doesn't necessarily equal the client
* that registered the worker. It's with the latter the worker should
* communicate with during the response resolving phase.
* @param {FetchEvent} event
* @returns {Promise<Client | undefined>}
*/
async function resolveMainClient(event) {
const client = await self.clients.get(event.clientId);
if (activeClientIds.has(event.clientId)) {
return client;
}
if (client?.frameType === 'top-level') {
return client;
}
@@ -171,20 +193,37 @@ async function resolveMainClient(event) {
});
}
async function getResponse(event, client, requestId) {
const { request } = event;
/**
* @param {FetchEvent} event
* @param {Client | undefined} client
* @param {string} requestId
* @param {number} requestInterceptedAt
* @returns {Promise<Response>}
*/
async function getResponse(event, client, requestId, requestInterceptedAt) {
// Clone the request because it might've been already used
// (i.e. its body has been read and sent to the client).
const requestClone = request.clone();
const requestClone = event.request.clone();
function passthrough() {
const headers = Object.fromEntries(requestClone.headers.entries());
// Cast the request headers to a new Headers instance
// so the headers can be manipulated with.
const headers = new Headers(requestClone.headers);
// Remove internal MSW request header so the passthrough request
// complies with any potential CORS preflight checks on the server.
// Some servers forbid unknown request headers.
delete headers['x-msw-intention'];
// Remove the "accept" header value that marked this request as passthrough.
// This prevents request alteration and also keeps it compliant with the
// user-defined CORS policies.
const acceptHeader = headers.get('accept');
if (acceptHeader) {
const values = acceptHeader.split(',').map((value) => value.trim());
const filteredValues = values.filter((value) => value !== 'msw/passthrough');
if (filteredValues.length > 0) {
headers.set('accept', filteredValues.join(', '));
} else {
headers.delete('accept');
}
}
return fetch(requestClone, { headers });
}
@@ -202,37 +241,19 @@ async function getResponse(event, client, requestId) {
return passthrough();
}
// Bypass requests with the explicit bypass header.
// Such requests can be issued by "ctx.fetch()".
const mswIntention = request.headers.get('x-msw-intention');
if (['bypass', 'passthrough'].includes(mswIntention)) {
return passthrough();
}
// Notify the client that a request has been intercepted.
const requestBuffer = await request.arrayBuffer();
const serializedRequest = await serializeRequest(event.request);
const clientMessage = await sendToClient(
client,
{
type: 'REQUEST',
payload: {
id: requestId,
url: request.url,
mode: request.mode,
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
cache: request.cache,
credentials: request.credentials,
destination: request.destination,
integrity: request.integrity,
redirect: request.redirect,
referrer: request.referrer,
referrerPolicy: request.referrerPolicy,
body: requestBuffer,
keepalive: request.keepalive,
interceptedAt: requestInterceptedAt,
...serializedRequest,
},
},
[requestBuffer]
[serializedRequest.body]
);
switch (clientMessage.type) {
@@ -240,7 +261,7 @@ async function getResponse(event, client, requestId) {
return respondWithMock(clientMessage.data);
}
case 'MOCK_NOT_FOUND': {
case 'PASSTHROUGH': {
return passthrough();
}
}
@@ -248,6 +269,12 @@ async function getResponse(event, client, requestId) {
return passthrough();
}
/**
* @param {Client} client
* @param {any} message
* @param {Array<Transferable>} transferrables
* @returns {Promise<any>}
*/
function sendToClient(client, message, transferrables = []) {
return new Promise((resolve, reject) => {
const channel = new MessageChannel();
@@ -260,11 +287,15 @@ function sendToClient(client, message, transferrables = []) {
resolve(event.data);
};
client.postMessage(message, [channel.port2].concat(transferrables.filter(Boolean)));
client.postMessage(message, [channel.port2, ...transferrables.filter(Boolean)]);
});
}
async function respondWithMock(response) {
/**
* @param {Response} response
* @returns {Response}
*/
function respondWithMock(response) {
// Setting response status code to 0 is a no-op.
// However, when responding with a "Response.error()", the produced Response
// instance will have status code set to 0. Since it's not possible to create
@@ -282,3 +313,24 @@ async function respondWithMock(response) {
return mockedResponse;
}
/**
* @param {Request} request
*/
async function serializeRequest(request) {
return {
url: request.url,
mode: request.mode,
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
cache: request.cache,
credentials: request.credentials,
destination: request.destination,
integrity: request.integrity,
redirect: request.redirect,
referrer: request.referrer,
referrerPolicy: request.referrerPolicy,
body: await request.arrayBuffer(),
keepalive: request.keepalive,
};
}
+59
View File
@@ -0,0 +1,59 @@
# Portainer Community Edition
Open-source container management platform with full Docker and Kubernetes support.
## Project Structure
For a detailed breakdown of frontend and backend directory layout, feature locations, and common development tasks, see [docs/guidelines/project-structure.md](../../docs/guidelines/project-structure.md).
## Frontend Guidelines
- [docs/guidelines/frontend-conventions.md](../../docs/guidelines/frontend-conventions.md) — component structure, React Query patterns, shared components, forms, theming
- [docs/guidelines/typescript-conventions.md](../../docs/guidelines/typescript-conventions.md) — types, anti-patterns, union types, named constants
- [docs/guidelines/frontend-unit-testing.md](../../docs/guidelines/frontend-unit-testing.md) — Vitest, React Testing Library
## Backend Guidelines
- [docs/guidelines/go-conventions.md](../../docs/guidelines/go-conventions.md) — error handling, naming, testing, code style
- [docs/guidelines/server-architecture.md](../../docs/guidelines/server-architecture.md) — Clean Architecture layers, transactions, CE/EE sharing patterns
- [docs/guidelines/logging.md](../../docs/guidelines/logging.md) — zerolog usage, log levels, message style
- [docs/guidelines/backend-code-reusability.md](../../docs/guidelines/backend-code-reusability.md) — how CE and EE share backend code
## Package Manager
- **PNPM** 10+ (for frontend)
- **Go** 1.26.1 (for backend)
## Build Commands
```bash
# Full build
make build # Build both client and server
make build-client # Build React/AngularJS frontend
make build-server # Build Go binary
make build-image # Build Docker image
# Development
make dev # Run both in dev mode
make dev-client # Start webpack-dev-server (port 8999)
make dev-server # Run containerized Go server
# Frontend
pnpm dev # Webpack dev server
pnpm build # Build frontend with webpack
pnpm typecheck # Run typecheck for frontend (with tsc)
pnpm lint # lint frontend (with eslint)
pnpm test # test frontend (with vitest)
pnpm format # format frontend (with prettier)
# Testing
make test # All tests (backend + frontend)
make test-server # Backend tests only
make lint # Lint all code
make format # Format code
```
## Development Servers
- Frontend: http://localhost:8999
- Backend: http://localhost:9000 (HTTP) / https://localhost:9443 (HTTPS)
+1 -1
View File
@@ -77,7 +77,7 @@ The feature request process is similar to the bug report process but has an extr
## Build and run Portainer locally
Ensure you have Docker, Node.js, yarn, and Golang installed in the correct versions.
Ensure you have Docker, Node.js, pnpm, and Golang installed in the correct versions.
Install dependencies:
+24 -13
View File
@@ -3,8 +3,9 @@ ENV=development
WEBPACK_CONFIG=webpack/webpack.$(ENV).js
TAG=local
SWAG=go run github.com/swaggo/swag/cmd/swag@v1.16.2
GOTESTSUM=go run gotest.tools/gotestsum@latest
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)
# Don't change anything below this line unless you know what you're doing
.DEFAULT_GOAL := help
@@ -20,7 +21,7 @@ all: tidy deps build-server build-client ## Build the client, server and downloa
build-all: all ## Alias for the 'all' target (used by CI)
build-client: init-dist ## Build the client
export NODE_ENV=$(ENV) && yarn build --config $(WEBPACK_CONFIG)
export NODE_ENV=$(ENV) && pnpm run build --config $(WEBPACK_CONFIG)
build-server: init-dist ## Build the server binary
./build/build_binary.sh "$(PLATFORM)" "$(ARCH)"
@@ -29,17 +30,17 @@ build-image: build-all ## Build the Portainer image locally
docker buildx build --load -t portainerci/portainer-ce:$(TAG) -f build/linux/Dockerfile .
build-storybook: ## Build and serve the storybook files
yarn storybook:build
pnpm run storybook:build
##@ Build dependencies
.PHONY: deps server-deps client-deps tidy
deps: server-deps client-deps ## Download all client and server build dependancies
## This is empty because the pipeline requires it but ce has no server deps
server-deps: init-dist ## Download dependant server binaries
@./build/download_binaries.sh $(PLATFORM) $(ARCH)
client-deps: ## Install client dependencies
yarn
pnpm install
tidy: ## Tidy up the go.mod file
@go mod tidy
@@ -55,10 +56,12 @@ clean: ## Remove all build and download artifacts
test: test-server test-client ## Run all tests
test-client: ## Run client tests
yarn test $(ARGS) --coverage
pnpm run test $(ARGS) --coverage
TEST_PACKAGES?=./...
test-server: ## Run server tests
$(GOTESTSUM) --format pkgname-and-test-fails --format-hide-empty-pkg --hide-summary skipped -- -cover -covermode=atomic -coverprofile=coverage.out ./...
$(GOTESTSUM) --format pkgname-and-test-fails --format-hide-empty-pkg --hide-summary skipped -- -cover -covermode=atomic -coverprofile=coverage.out $(TEST_PACKAGES)
##@ Dev
.PHONY: dev dev-client dev-server
@@ -67,7 +70,7 @@ dev: ## Run both the client and server in development mode
make dev-client
dev-client: ## Run the client in development mode
yarn dev
pnpm install && pnpm run dev
dev-server: build-server ## Run the server in development mode
@./dev/run_container.sh
@@ -81,7 +84,7 @@ dev-server-podman: build-server ## Run the server in development mode
format: format-client format-server ## Format all code
format-client: ## Format client code
yarn format
pnpm run format
format-server: ## Format server code
go fmt ./...
@@ -91,7 +94,7 @@ format-server: ## Format server code
lint: lint-client lint-server ## Lint all code
lint-client: ## Lint client code
yarn lint
pnpm run lint
lint-server: tidy ## Lint server code
golangci-lint run --timeout=10m -c .golangci.yaml
@@ -105,11 +108,19 @@ 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
cd api && $(SWAG) init -o "../dist/docs" -ot "yaml" -g ./http/handler/handler.go --parseDependency --parseInternal --parseDepth 2 -p pascalcase --markdownFiles ./
docs-validate: docs-build ## Validate docs
yarn swagger2openapi --warnOnly dist/docs/swagger.yaml -o dist/docs/openapi.yaml
yarn swagger-cli validate dist/docs/openapi.yaml
pnpm swagger2openapi --warnOnly dist/docs/swagger.yaml -o dist/docs/openapi.yaml
pnpm swagger-cli validate dist/docs/openapi.yaml
.PHONY: docs-serve
docs-serve: docs-build ## Serve docs locally with Swagger UI on port 8080
docker run -p 8080:8080 \
-e SWAGGER_JSON=/foo/swagger.yaml \
-v $(PWD)/dist/docs:/foo \
swaggerapi/swagger-ui
##@ Helpers
.PHONY: help
+1 -1
View File
@@ -46,7 +46,7 @@ You can join the Portainer Community by visiting [https://www.portainer.io/join-
## Security
- Here at Portainer, we believe in [responsible disclosure](https://en.wikipedia.org/wiki/Responsible_disclosure) of security issues. If you have found a security issue, please report it to <security@portainer.io>.
For information about reporting security vulnerabilities, please see our [Security Policy](SECURITY.md).
## Work for us
+60
View File
@@ -0,0 +1,60 @@
# Security Policy
## Supported Versions
Portainer maintains both Short-Term Support (STS) and Long-Term Support (LTS) versions in accordance with our official [Portainer Lifecycle Policy](https://docs.portainer.io/start/lifecycle).
| Version Type | Support Status |
| ------------------------ | ------------------------------------------- |
| LTS (Long-Term Support) | Supported for critical security fixes |
| STS (Short-Term Support) | Supported until the next STS or LTS release |
| Legacy / EOL | Not supported |
For a detailed breakdown of current versions and their specific End of Life (EOL) dates,
please refer to the [Portainer Lifecycle Policy](https://docs.portainer.io/start/lifecycle).
## Reporting a Vulnerability
The Portainer team takes the security of our products seriously. If you believe you have found a security vulnerability in any Portainer-owned repository, please report it to us responsibly.
**Please do not report security vulnerabilities via public GitHub issues.**
### Disclosure Process
1. **Report**: You can report in one of two ways:
- **GitHub**: Use the **Report a vulnerability** button on the **Security** tab of this repository.
- **Email**: Send your findings to security@portainer.io.
2. **Details**: To help us verify the issue, please include:
- A description of the vulnerability and its potential impact.
- Step-by-step instructions to reproduce the issue (e.g. proof-of-concept code, scripts, or screenshots).
- The version of the software and the environment in which it was found.
3. **Acknowledge**: We will acknowledge receipt of your report and provide an initial assessment.
4. **Resolution**: We will work to resolve the issue as quickly as possible. We request that you do not disclose the vulnerability publicly until we have released a fix and notified affected users.
## Our Commitment
If you follow the responsible disclosure process, we will:
- Respond to your report in a timely manner.
- Provide an estimated timeline for remediation.
- Notify you when the vulnerability has been patched.
- Give credit for the discovery (if desired) once the fix is public.
We will make every effort to promptly address any security weaknesses. Security advisories and fixes will be published through GitHub Security Advisories and other channels as needed.
Thank you for helping keep Portainer and our community secure.
## Resources
- [Contributing to Portainer](https://docs.portainer.io/contribute/contribute#contributing-to-the-portainer-ce-codebase)
+118
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>
);
}
+4 -8
View File
@@ -19,24 +19,22 @@ const RedirectReasonAdminInitTimeout string = "AdminInitTimeout"
type Monitor struct {
timeout time.Duration
datastore dataservices.DataStore
shutdownCtx context.Context
cancellationFunc context.CancelFunc
mu sync.RWMutex
adminInitDisabled bool
}
// New creates a monitor that when started will wait for the timeout duration and then shutdown the application unless it has been initialized.
func New(timeout time.Duration, datastore dataservices.DataStore, shutdownCtx context.Context) *Monitor {
func New(timeout time.Duration, datastore dataservices.DataStore) *Monitor {
return &Monitor{
timeout: timeout,
datastore: datastore,
shutdownCtx: shutdownCtx,
adminInitDisabled: false,
}
}
// Starts starts the monitor. Active monitor could be stopped or shuttted down by cancelling the shutdown context.
func (m *Monitor) Start() {
// Start starts the monitor. The monitor will stop when ctx is cancelled, or when Stop is called.
func (m *Monitor) Start(ctx context.Context) {
m.mu.Lock()
defer m.mu.Unlock()
@@ -44,7 +42,7 @@ func (m *Monitor) Start() {
return
}
cancellationCtx, cancellationFunc := context.WithCancel(context.Background())
cancellationCtx, cancellationFunc := context.WithCancel(ctx)
m.cancellationFunc = cancellationFunc
go func() {
@@ -69,8 +67,6 @@ func (m *Monitor) Start() {
}
case <-cancellationCtx.Done():
log.Debug().Msg("canceling initialization monitor")
case <-m.shutdownCtx.Done():
log.Debug().Msg("shutting down initialization monitor")
}
}()
}
+19 -10
View File
@@ -1,8 +1,8 @@
package adminmonitor
import (
"context"
"testing"
"testing/synctest"
"time"
portainer "github.com/portainer/portainer/api"
@@ -11,21 +11,28 @@ import (
)
func Test_stopWithoutStarting(t *testing.T) {
monitor := New(1*time.Minute, nil, nil)
t.Parallel()
monitor := New(1*time.Minute, nil)
monitor.Stop()
}
func Test_stopCouldBeCalledMultipleTimes(t *testing.T) {
monitor := New(1*time.Minute, nil, nil)
t.Parallel()
monitor := New(1*time.Minute, nil)
monitor.Stop()
monitor.Stop()
}
func Test_startOrStopCouldBeCalledMultipleTimesConcurrently(t *testing.T) {
monitor := New(1*time.Minute, nil, context.Background())
t.Parallel()
synctest.Test(t, test_startOrStopCouldBeCalledMultipleTimesConcurrently)
}
go monitor.Start()
monitor.Start()
func test_startOrStopCouldBeCalledMultipleTimesConcurrently(t *testing.T) {
monitor := New(1*time.Minute, nil)
go monitor.Start(t.Context())
monitor.Start(t.Context())
go monitor.Stop()
monitor.Stop()
@@ -34,8 +41,9 @@ func Test_startOrStopCouldBeCalledMultipleTimesConcurrently(t *testing.T) {
}
func Test_canStopStartedMonitor(t *testing.T) {
monitor := New(1*time.Minute, nil, context.Background())
monitor.Start()
t.Parallel()
monitor := New(1*time.Minute, nil)
monitor.Start(t.Context())
assert.NotNil(t, monitor.cancellationFunc, "cancellation function is missing in started monitor")
monitor.Stop()
@@ -43,11 +51,12 @@ func Test_canStopStartedMonitor(t *testing.T) {
}
func Test_start_shouldDisableInstanceAfterTimeout_ifNotInitialized(t *testing.T) {
t.Parallel()
timeout := 10 * time.Millisecond
datastore := i.NewDatastore(i.WithUsers([]portainer.User{}))
monitor := New(timeout, datastore, context.Background())
monitor.Start()
monitor := New(timeout, datastore)
monitor.Start(t.Context())
<-time.After(20 * timeout)
assert.True(t, monitor.WasInstanceDisabled(), "monitor should have been timeout and instance is disabled")
+8 -8
View File
@@ -11,20 +11,18 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/url"
"github.com/rs/zerolog/log"
)
// GetAgentVersionAndPlatform returns the agent version and platform
//
// it sends a ping to the agent and parses the version and platform from the headers
func GetAgentVersionAndPlatform(endpointUrl string, tlsConfig *tls.Config) (portainer.AgentPlatform, string, error) { //nolint:forbidigo
httpCli := &http.Client{
Timeout: 3 * time.Second,
}
httpCli := &http.Client{Timeout: 3 * time.Second}
if tlsConfig != nil {
httpCli.Transport = &http.Transport{
TLSClientConfig: tlsConfig,
}
httpCli.Transport = &http.Transport{TLSClientConfig: tlsConfig}
}
parsedURL, err := url.ParseURL(endpointUrl + "/ping")
@@ -44,8 +42,10 @@ func GetAgentVersionAndPlatform(endpointUrl string, tlsConfig *tls.Config) (port
return 0, "", err
}
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
_, _ = io.Copy(io.Discard, resp.Body)
if err := resp.Body.Close(); err != nil {
log.Warn().Err(err).Msg("failed to close response body")
}
if resp.StatusCode != http.StatusNoContent {
return 0, "", fmt.Errorf("Failed request with status %d", resp.StatusCode)
+119
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)
}
-64
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
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==
```
+1
View File
@@ -7,6 +7,7 @@ import (
)
func Test_generateRandomKey(t *testing.T) {
t.Parallel()
is := assert.New(t)
tests := []struct {
+1 -1
View File
@@ -71,7 +71,7 @@ func (c *ApiKeyCache[T]) InvalidateUserKeyCache(userId portainer.UserID) bool {
for _, k := range c.cache.Keys() {
user, _, _ := c.Get(k.(string))
if c.userCmpFn(user, userId) {
present = c.cache.Remove(k)
present = c.cache.Remove(k) || present
}
}
+5
View File
@@ -8,6 +8,7 @@ import (
)
func Test_apiKeyCacheGet(t *testing.T) {
t.Parallel()
is := assert.New(t)
keyCache := NewAPIKeyCache(10, compareUser)
@@ -43,6 +44,7 @@ func Test_apiKeyCacheGet(t *testing.T) {
}
func Test_apiKeyCacheSet(t *testing.T) {
t.Parallel()
is := assert.New(t)
keyCache := NewAPIKeyCache(10, compareUser)
@@ -68,6 +70,7 @@ func Test_apiKeyCacheSet(t *testing.T) {
}
func Test_apiKeyCacheDelete(t *testing.T) {
t.Parallel()
is := assert.New(t)
keyCache := NewAPIKeyCache(10, compareUser)
@@ -87,6 +90,7 @@ func Test_apiKeyCacheDelete(t *testing.T) {
}
func Test_apiKeyCacheLRU(t *testing.T) {
t.Parallel()
is := assert.New(t)
tests := []struct {
@@ -148,6 +152,7 @@ func Test_apiKeyCacheLRU(t *testing.T) {
}
func Test_apiKeyCacheInvalidateUserKeyCache(t *testing.T) {
t.Parallel()
is := assert.New(t)
keyCache := NewAPIKeyCache(10, compareUser)
+12 -1
View File
@@ -17,11 +17,13 @@ import (
)
func Test_SatisfiesAPIKeyServiceInterface(t *testing.T) {
t.Parallel()
is := assert.New(t)
is.Implements((*APIKeyService)(nil), NewAPIKeyService(nil, nil))
}
func Test_GenerateApiKey(t *testing.T) {
t.Parallel()
is := assert.New(t)
_, store := datastore.MustNewTestStore(t, true, true)
@@ -75,6 +77,7 @@ func Test_GenerateApiKey(t *testing.T) {
}
func Test_GetAPIKey(t *testing.T) {
t.Parallel()
is := assert.New(t)
_, store := datastore.MustNewTestStore(t, true, true)
@@ -94,6 +97,7 @@ func Test_GetAPIKey(t *testing.T) {
}
func Test_GetAPIKeys(t *testing.T) {
t.Parallel()
is := assert.New(t)
_, store := datastore.MustNewTestStore(t, true, true)
@@ -114,6 +118,7 @@ func Test_GetAPIKeys(t *testing.T) {
}
func Test_GetDigestUserAndKey(t *testing.T) {
t.Parallel()
is := assert.New(t)
_, store := datastore.MustNewTestStore(t, true, true)
@@ -149,6 +154,7 @@ func Test_GetDigestUserAndKey(t *testing.T) {
}
func Test_UpdateAPIKey(t *testing.T) {
t.Parallel()
is := assert.New(t)
_, store := datastore.MustNewTestStore(t, true, true)
@@ -157,7 +163,10 @@ func Test_UpdateAPIKey(t *testing.T) {
t.Run("Successfully updates the api-key LastUsed time", func(t *testing.T) {
user := portainer.User{ID: 1}
store.User().Create(&user)
err := store.User().Create(&user)
require.NoError(t, err)
_, apiKey, err := service.GenerateApiKey(user, "test-x")
require.NoError(t, err)
@@ -194,6 +203,7 @@ func Test_UpdateAPIKey(t *testing.T) {
}
func Test_DeleteAPIKey(t *testing.T) {
t.Parallel()
is := assert.New(t)
_, store := datastore.MustNewTestStore(t, true, true)
@@ -234,6 +244,7 @@ func Test_DeleteAPIKey(t *testing.T) {
}
func Test_InvalidateUserKeyCache(t *testing.T) {
t.Parallel()
is := assert.New(t)
_, store := datastore.MustNewTestStore(t, true, true)
+6 -14
View File
@@ -17,18 +17,15 @@ func TarFileInBuffer(fileContent []byte, fileName string, mode int64) ([]byte, e
Size: int64(len(fileContent)),
}
err := tarWriter.WriteHeader(header)
if err != nil {
if err := tarWriter.WriteHeader(header); err != nil {
return nil, err
}
_, err = tarWriter.Write(fileContent)
if err != nil {
if _, err := tarWriter.Write(fileContent); err != nil {
return nil, err
}
err = tarWriter.Close()
if err != nil {
if err := tarWriter.Close(); err != nil {
return nil, err
}
@@ -43,10 +40,7 @@ type tarFileInBuffer struct {
func NewTarFileInBuffer() *tarFileInBuffer {
var b bytes.Buffer
return &tarFileInBuffer{
b: &b,
w: tar.NewWriter(&b),
}
return &tarFileInBuffer{b: &b, w: tar.NewWriter(&b)}
}
// Put puts a single file to tar archive buffer.
@@ -61,11 +55,9 @@ func (t *tarFileInBuffer) Put(fileContent []byte, fileName string, mode int64) e
return err
}
if _, err := t.w.Write(fileContent); err != nil {
return err
}
_, err := t.w.Write(fileContent)
return nil
return err
}
// Bytes returns the archive as a byte array.
+10 -6
View File
@@ -9,6 +9,9 @@ import (
"os"
"path/filepath"
"strings"
"github.com/portainer/portainer/api/filesystem"
"github.com/portainer/portainer/api/logs"
)
// TarGzDir creates a tar.gz archive and returns it's path.
@@ -20,12 +23,13 @@ func TarGzDir(absolutePath string) (string, error) {
if err != nil {
return "", err
}
defer outFile.Close()
defer logs.CloseAndLogErr(outFile)
zipWriter := gzip.NewWriter(outFile)
defer zipWriter.Close()
defer logs.CloseAndLogErr(zipWriter)
tarWriter := tar.NewWriter(zipWriter)
defer tarWriter.Close()
defer logs.CloseAndLogErr(tarWriter)
err = filepath.Walk(absolutePath, func(path string, info os.FileInfo, err error) error {
if err != nil {
@@ -86,7 +90,7 @@ func ExtractTarGz(r io.Reader, outputDirPath string) error {
if err != nil {
return err
}
defer zipReader.Close()
defer logs.CloseAndLogErr(zipReader)
tarReader := tar.NewReader(zipReader)
@@ -105,7 +109,7 @@ func ExtractTarGz(r io.Reader, outputDirPath string) error {
case tar.TypeDir:
// skip, dir will be created with a file
case tar.TypeReg:
p := filepath.Clean(filepath.Join(outputDirPath, header.Name))
p := filesystem.JoinPaths(outputDirPath, header.Name)
if err := os.MkdirAll(filepath.Dir(p), 0o744); err != nil {
return fmt.Errorf("Failed to extract dir %s", filepath.Dir(p))
}
@@ -116,7 +120,7 @@ func ExtractTarGz(r io.Reader, outputDirPath string) error {
if _, err := io.Copy(outFile, tarReader); err != nil {
return fmt.Errorf("Failed to extract file %s", header.Name)
}
outFile.Close()
logs.CloseAndLogErr(outFile)
default:
return fmt.Errorf("tar: unknown type: %v in %s",
header.Typeflag,
+76 -15
View File
@@ -1,12 +1,15 @@
package archive
import (
"archive/tar"
"compress/gzip"
"os"
"os/exec"
"path"
"path/filepath"
"testing"
"github.com/portainer/portainer/api/filesystem"
"github.com/rs/zerolog/log"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -14,7 +17,7 @@ import (
func listFiles(dir string) []string {
items := make([]string, 0)
filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if path == dir {
return nil
}
@@ -22,30 +25,33 @@ func listFiles(dir string) []string {
items = append(items, path)
return nil
})
}); err != nil {
log.Warn().Err(err).Msg("failed to list files in directory")
}
return items
}
func Test_shouldCreateArchive(t *testing.T) {
t.Parallel()
tmpdir := t.TempDir()
content := []byte("content")
err := os.WriteFile(path.Join(tmpdir, "outer"), content, 0600)
err := os.WriteFile(filesystem.JoinPaths(tmpdir, "outer"), content, 0600)
require.NoError(t, err)
os.MkdirAll(path.Join(tmpdir, "dir"), 0700)
err = os.MkdirAll(filesystem.JoinPaths(tmpdir, "dir"), 0700)
require.NoError(t, err)
err = os.WriteFile(path.Join(tmpdir, "dir", ".dotfile"), content, 0600)
err = os.WriteFile(filesystem.JoinPaths(tmpdir, "dir", ".dotfile"), content, 0600)
require.NoError(t, err)
err = os.WriteFile(path.Join(tmpdir, "dir", "inner"), content, 0600)
err = os.WriteFile(filesystem.JoinPaths(tmpdir, "dir", "inner"), content, 0600)
require.NoError(t, err)
gzPath, err := TarGzDir(tmpdir)
require.NoError(t, err)
assert.Equal(t, filepath.Join(tmpdir, filepath.Base(tmpdir)+".tar.gz"), gzPath)
assert.Equal(t, filesystem.JoinPaths(tmpdir, filepath.Base(tmpdir)+".tar.gz"), gzPath)
extractionDir := t.TempDir()
cmd := exec.Command("tar", "-xzf", gzPath, "-C", extractionDir)
@@ -55,7 +61,7 @@ func Test_shouldCreateArchive(t *testing.T) {
extractedFiles := listFiles(extractionDir)
wasExtracted := func(p string) {
fullpath := path.Join(extractionDir, p)
fullpath := filesystem.JoinPaths(extractionDir, p)
assert.Contains(t, extractedFiles, fullpath)
copyContent, err := os.ReadFile(fullpath)
require.NoError(t, err)
@@ -68,24 +74,25 @@ func Test_shouldCreateArchive(t *testing.T) {
}
func Test_shouldCreateArchive2(t *testing.T) {
t.Parallel()
tmpdir := t.TempDir()
content := []byte("content")
err := os.WriteFile(path.Join(tmpdir, "outer"), content, 0600)
err := os.WriteFile(filesystem.JoinPaths(tmpdir, "outer"), content, 0600)
require.NoError(t, err)
err = os.MkdirAll(path.Join(tmpdir, "dir"), 0700)
err = os.MkdirAll(filesystem.JoinPaths(tmpdir, "dir"), 0700)
require.NoError(t, err)
err = os.WriteFile(path.Join(tmpdir, "dir", ".dotfile"), content, 0600)
err = os.WriteFile(filesystem.JoinPaths(tmpdir, "dir", ".dotfile"), content, 0600)
require.NoError(t, err)
err = os.WriteFile(path.Join(tmpdir, "dir", "inner"), content, 0600)
err = os.WriteFile(filesystem.JoinPaths(tmpdir, "dir", "inner"), content, 0600)
require.NoError(t, err)
gzPath, err := TarGzDir(tmpdir)
require.NoError(t, err)
assert.Equal(t, filepath.Join(tmpdir, filepath.Base(tmpdir)+".tar.gz"), gzPath)
assert.Equal(t, filesystem.JoinPaths(tmpdir, filepath.Base(tmpdir)+".tar.gz"), gzPath)
extractionDir := t.TempDir()
r, _ := os.Open(gzPath)
@@ -95,7 +102,7 @@ func Test_shouldCreateArchive2(t *testing.T) {
extractedFiles := listFiles(extractionDir)
wasExtracted := func(p string) {
fullpath := path.Join(extractionDir, p)
fullpath := filesystem.JoinPaths(extractionDir, p)
assert.Contains(t, extractedFiles, fullpath)
copyContent, _ := os.ReadFile(fullpath)
assert.Equal(t, content, copyContent)
@@ -105,3 +112,57 @@ func Test_shouldCreateArchive2(t *testing.T) {
wasExtracted("dir/inner")
wasExtracted("dir/.dotfile")
}
func TestExtractTarGzPathTraversal(t *testing.T) {
t.Parallel()
testDir := t.TempDir()
// Create an evil file with a path traversal attempt
tarPath := filesystem.JoinPaths(testDir, "evil.tar.gz")
evilFile, err := os.Create(tarPath)
require.NoError(t, err)
gzWriter := gzip.NewWriter(evilFile)
tarWriter := tar.NewWriter(gzWriter)
content := []byte("evil content")
header := &tar.Header{
Name: "../evil.txt",
Mode: 0600,
Size: int64(len(content)),
Typeflag: tar.TypeReg,
}
err = tarWriter.WriteHeader(header)
require.NoError(t, err)
_, err = tarWriter.Write(content)
require.NoError(t, err)
err = tarWriter.Close()
require.NoError(t, err)
err = gzWriter.Close()
require.NoError(t, err)
err = evilFile.Close()
require.NoError(t, err)
// Attempt to extract the evil file
extractionDir := filesystem.JoinPaths(testDir, "extraction")
err = os.Mkdir(extractionDir, 0700)
require.NoError(t, err)
tarFile, err := os.Open(tarPath)
require.NoError(t, err)
// Check that the file didn't escape
err = ExtractTarGz(tarFile, extractionDir)
require.NoError(t, err)
require.NoFileExists(t, filesystem.JoinPaths(testDir, "evil.txt"))
err = tarFile.Close()
require.NoError(t, err)
}
+8 -4
View File
@@ -8,6 +8,8 @@ import (
"path/filepath"
"strings"
"github.com/portainer/portainer/api/logs"
"github.com/pkg/errors"
)
@@ -18,7 +20,7 @@ func UnzipFile(src string, dest string) error {
if err != nil {
return err
}
defer r.Close()
defer logs.CloseAndLogErr(r)
for _, f := range r.File {
p := filepath.Join(dest, f.Name)
@@ -30,7 +32,9 @@ func UnzipFile(src string, dest string) error {
if f.FileInfo().IsDir() {
// Make Folder
os.MkdirAll(p, os.ModePerm)
if err := os.MkdirAll(p, os.ModePerm); err != nil {
return err
}
continue
}
@@ -53,13 +57,13 @@ func unzipFile(f *zip.File, p string) error {
if err != nil {
return errors.Wrapf(err, "unzipFile: can't create file %s", p)
}
defer outFile.Close()
defer logs.CloseAndLogErr(outFile)
rc, err := f.Open()
if err != nil {
return errors.Wrapf(err, "unzipFile: can't open zip file %s in the archive", f.Name)
}
defer rc.Close()
defer logs.CloseAndLogErr(rc)
if _, err = io.Copy(outFile, rc); err != nil {
return errors.Wrapf(err, "unzipFile: can't copy an archived file content")
+6 -4
View File
@@ -1,14 +1,16 @@
package archive
import (
"path/filepath"
"testing"
"github.com/portainer/portainer/api/filesystem"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestUnzipFile(t *testing.T) {
t.Parallel()
dir := t.TempDir()
/*
Archive structure.
@@ -23,8 +25,8 @@ func TestUnzipFile(t *testing.T) {
require.NoError(t, err)
archiveDir := dir + "/sample_archive"
assert.FileExists(t, filepath.Join(archiveDir, "0.txt"))
assert.FileExists(t, filepath.Join(archiveDir, "0", "1.txt"))
assert.FileExists(t, filepath.Join(archiveDir, "0", "1", "2.txt"))
assert.FileExists(t, filesystem.JoinPaths(archiveDir, "0.txt"))
assert.FileExists(t, filesystem.JoinPaths(archiveDir, "0", "1.txt"))
assert.FileExists(t, filesystem.JoinPaths(archiveDir, "0", "1", "2.txt"))
}
+4 -4
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
}
+9
View File
@@ -6,6 +6,15 @@ import (
"github.com/aws/aws-sdk-go-v2/service/ecr"
)
// Registry represents an ECR registry endpoint information.
// This struct is used to parse and validate ECR endpoint URLs.
type Registry struct {
ID string // AWS account ID (empty for accountless endpoints like "ecr-fips.us-west-1.amazonaws.com")
FIPS bool // Whether this is a FIPS endpoint (contains "-fips" in the URL)
Region string // AWS region (e.g., "us-east-1", "us-gov-west-1")
Public bool // Whether this is ecr-public.aws.com
}
type (
Service struct {
accessKey string
+70
View File
@@ -0,0 +1,70 @@
package ecr
import (
"fmt"
"net/url"
"regexp"
"strings"
)
// ecrEndpointPattern matches all valid ECR endpoints including account-prefixed and accountless formats.
// Based on AWS ECR credential helper regex but extended to support accountless endpoints.
//
// Supported formats:
// - Account-prefixed: 123456789012.dkr.ecr-fips.us-east-1.amazonaws.com
// - Account-prefixed (hyphen): 123456789012.dkr-ecr-fips.us-west-1.on.aws
// - Accountless service: ecr-fips.us-west-1.amazonaws.com
// - Accountless API: ecr-fips.us-east-1.api.aws
// - Non-FIPS variants: All formats above without "-fips"
//
// Regex groups:
// - Group 1: Full account prefix (optional) - e.g., "123456789012.dkr." or "123456789012.dkr-"
// - Group 2: Account ID (optional) - e.g., "123456789012"
// - Group 3: FIPS flag (optional) - either "-fips" or empty string
// - Group 4: Region - e.g., "us-east-1", "us-gov-west-1"
// - Group 5: Domain suffix - e.g., "amazonaws.com", "api.aws"
var ecrEndpointPattern = regexp.MustCompile(
`^((\d{12})\.dkr[\.\-])?ecr(\-fips)?\.([a-zA-Z0-9][a-zA-Z0-9-_]*)\.(amazonaws\.(?:com(?:\.cn)?|eu)|api\.aws|on\.(?:aws|amazonwebservices\.com\.cn)|sc2s\.sgov\.gov|c2s\.ic\.gov|cloud\.adc-e\.uk|csp\.hci\.ic\.gov)$`,
)
// ParseECREndpoint parses an ECR registry URL and extracts registry information.
// This function replaces the AWS ECR credential helper library's ExtractRegistry function,
// which only supports account-prefixed endpoints.
//
// Reference: https://docs.aws.amazon.com/general/latest/gr/ecr.html
func ParseECREndpoint(urlStr string) (*Registry, error) {
// Normalize URL by adding https:// prefix if not present
if !strings.HasPrefix(urlStr, "https://") && !strings.HasPrefix(urlStr, "http://") {
urlStr = "https://" + urlStr
}
u, err := url.Parse(urlStr)
if err != nil {
return nil, fmt.Errorf("invalid URL: %w", err)
}
hostname := u.Hostname()
// Special case: ECR Public
// ECR Public uses a different domain and doesn't have FIPS variant
if hostname == "ecr-public.aws.com" {
return &Registry{
FIPS: false,
Public: true,
}, nil
}
// Parse standard ECR endpoints using regex
matches := ecrEndpointPattern.FindStringSubmatch(hostname)
if len(matches) == 0 {
return nil, fmt.Errorf("not a valid ECR endpoint: %s", hostname)
}
return &Registry{
ID: matches[2], // Account ID (may be empty for accountless endpoints)
FIPS: matches[3] == "-fips", // Check if "-fips" is present
Region: matches[4], // AWS region
Public: false,
}, nil
}
+254
View File
@@ -0,0 +1,254 @@
package ecr
import (
"testing"
)
func TestParseECREndpoint(t *testing.T) {
t.Parallel()
tests := []struct {
name string
url string
want *Registry
wantError bool
}{
// Standard AWS Commercial - Account-prefixed FIPS
{
name: "account-prefixed FIPS us-east-1",
url: "123456789012.dkr.ecr-fips.us-east-1.amazonaws.com",
want: &Registry{
ID: "123456789012",
FIPS: true,
Region: "us-east-1",
Public: false,
},
},
{
name: "account-prefixed FIPS us-west-2",
url: "123456789012.dkr.ecr-fips.us-west-2.amazonaws.com",
want: &Registry{
ID: "123456789012",
FIPS: true,
Region: "us-west-2",
Public: false,
},
},
// Accountless FIPS service endpoints
{
name: "accountless FIPS us-west-1",
url: "ecr-fips.us-west-1.amazonaws.com",
want: &Registry{
ID: "",
FIPS: true,
Region: "us-west-1",
Public: false,
},
},
{
name: "accountless FIPS us-east-2",
url: "ecr-fips.us-east-2.amazonaws.com",
want: &Registry{
ID: "",
FIPS: true,
Region: "us-east-2",
Public: false,
},
},
// Accountless FIPS API endpoints
{
name: "accountless FIPS API us-west-1",
url: "ecr-fips.us-west-1.api.aws",
want: &Registry{
ID: "",
FIPS: true,
Region: "us-west-1",
Public: false,
},
},
{
name: "accountless FIPS API us-east-1",
url: "ecr-fips.us-east-1.api.aws",
want: &Registry{
ID: "",
FIPS: true,
Region: "us-east-1",
Public: false,
},
},
// on.aws domain with hyphen separator
{
name: "account-prefixed FIPS hyphen us-west-1",
url: "123456789012.dkr-ecr-fips.us-west-1.on.aws",
want: &Registry{
ID: "123456789012",
FIPS: true,
Region: "us-west-1",
Public: false,
},
},
{
name: "account-prefixed FIPS hyphen us-east-2",
url: "123456789012.dkr-ecr-fips.us-east-2.on.aws",
want: &Registry{
ID: "123456789012",
FIPS: true,
Region: "us-east-2",
Public: false,
},
},
// AWS GovCloud
{
name: "account-prefixed FIPS us-gov-east-1",
url: "123456789012.dkr.ecr-fips.us-gov-east-1.amazonaws.com",
want: &Registry{
ID: "123456789012",
FIPS: true,
Region: "us-gov-east-1",
Public: false,
},
},
{
name: "account-prefixed FIPS us-gov-west-1",
url: "123456789012.dkr.ecr-fips.us-gov-west-1.amazonaws.com",
want: &Registry{
ID: "123456789012",
FIPS: true,
Region: "us-gov-west-1",
Public: false,
},
},
{
name: "accountless FIPS us-gov-west-1",
url: "ecr-fips.us-gov-west-1.amazonaws.com",
want: &Registry{
ID: "",
FIPS: true,
Region: "us-gov-west-1",
Public: false,
},
},
{
name: "accountless FIPS API us-gov-east-1",
url: "ecr-fips.us-gov-east-1.api.aws",
want: &Registry{
ID: "",
FIPS: true,
Region: "us-gov-east-1",
Public: false,
},
},
// ECR Public
{
name: "ecr-public",
url: "ecr-public.aws.com",
want: &Registry{
ID: "",
FIPS: false,
Region: "",
Public: true,
},
},
// Non-FIPS endpoints (valid ECR but FIPS=false)
{
name: "account-prefixed non-FIPS us-east-1",
url: "123456789012.dkr.ecr.us-east-1.amazonaws.com",
want: &Registry{
ID: "123456789012",
FIPS: false,
Region: "us-east-1",
Public: false,
},
},
{
name: "accountless non-FIPS us-west-1",
url: "ecr.us-west-1.amazonaws.com",
want: &Registry{
ID: "",
FIPS: false,
Region: "us-west-1",
Public: false,
},
},
{
name: "accountless non-FIPS API us-east-2",
url: "ecr.us-east-2.api.aws",
want: &Registry{
ID: "",
FIPS: false,
Region: "us-east-2",
Public: false,
},
},
// URLs with https:// prefix
{
name: "with https prefix",
url: "https://ecr-fips.us-west-1.amazonaws.com",
want: &Registry{
ID: "",
FIPS: true,
Region: "us-west-1",
Public: false,
},
},
// Invalid endpoints
{
name: "not an ECR URL",
url: "not-an-ecr-url.com",
wantError: true,
},
{
name: "invalid account ID length",
url: "123.dkr.ecr-fips.us-east-1.amazonaws.com",
wantError: true,
},
{
name: "empty string",
url: "",
wantError: true,
},
{
name: "docker hub",
url: "docker.io",
wantError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseECREndpoint(tt.url)
if tt.wantError {
if err == nil {
t.Errorf("ParseECREndpoint() expected error but got none")
}
return
}
if err != nil {
t.Errorf("ParseECREndpoint() unexpected error: %v", err)
return
}
if got.ID != tt.want.ID {
t.Errorf("ParseECREndpoint() ID = %v, want %v", got.ID, tt.want.ID)
}
if got.FIPS != tt.want.FIPS {
t.Errorf("ParseECREndpoint() FIPS = %v, want %v", got.FIPS, tt.want.FIPS)
}
if got.Region != tt.want.Region {
t.Errorf("ParseECREndpoint() Region = %v, want %v", got.Region, tt.want.Region)
}
if got.Public != tt.want.Public {
t.Errorf("ParseECREndpoint() Public = %v, want %v", got.Public, tt.want.Public)
}
})
}
}
+3 -4
View File
@@ -12,6 +12,7 @@ import (
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/filesystem"
"github.com/portainer/portainer/api/http/offlinegate"
"github.com/portainer/portainer/api/logs"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
@@ -97,7 +98,7 @@ func encrypt(path string, passphrase string) (string, error) {
if err != nil {
return "", err
}
defer in.Close()
defer logs.CloseAndLogErr(in)
outFileName := path + ".encrypted"
out, err := os.Create(outFileName)
@@ -105,7 +106,5 @@ func encrypt(path string, passphrase string) (string, error) {
return "", err
}
err = crypto.AesEncrypt(in, out, []byte(passphrase))
return outFileName, err
return outFileName, crypto.AesEncrypt(in, out, []byte(passphrase))
}
+274
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)
}
+19 -11
View File
@@ -16,6 +16,8 @@ import (
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/filesystem"
"github.com/portainer/portainer/api/http/offlinegate"
"github.com/rs/zerolog/log"
)
var filesToRestore = append(filesToBackup, "portainer.db")
@@ -31,17 +33,20 @@ func RestoreArchive(archive io.Reader, password string, filestorePath string, ga
}
restorePath := filepath.Join(filestorePath, "restore", time.Now().Format("20060102150405"))
defer os.RemoveAll(filepath.Dir(restorePath))
defer func() {
if err := os.RemoveAll(filepath.Dir(restorePath)); err != nil {
log.Warn().Err(err).Msg("failed to clean up restore files")
}
}()
err = extractArchive(archive, restorePath)
if err != nil {
if err := extractArchive(archive, restorePath); err != nil {
return errors.Wrap(err, "cannot extract files from the archive. Please ensure the password is correct and try again")
}
unlock := gate.Lock()
defer unlock()
if err = datastore.Close(); err != nil {
if err := datastore.Close(); err != nil {
return errors.Wrap(err, "Failed to stop db")
}
@@ -51,7 +56,7 @@ func RestoreArchive(archive io.Reader, password string, filestorePath string, ga
return errors.Wrap(err, "failed to restore from backup. Portainer database missing from backup file")
}
if err = restoreFiles(restorePath, filestorePath); err != nil {
if err := restoreFiles(restorePath, filestorePath); err != nil {
return errors.Wrap(err, "failed to restore the system state")
}
@@ -89,8 +94,7 @@ func getRestoreSourcePath(dir string) (string, error) {
func restoreFiles(srcDir string, destinationDir string) error {
for _, filename := range filesToRestore {
err := filesystem.CopyPath(filepath.Join(srcDir, filename), destinationDir)
if err != nil {
if err := filesystem.CopyPath(filepath.Join(srcDir, filename), destinationDir); err != nil {
return err
}
}
@@ -98,14 +102,18 @@ func restoreFiles(srcDir string, destinationDir string) error {
// TODO: This is very boltdb module specific once again due to the filename. Move to bolt module? Refactor for another day
// Prevent the possibility of having both databases. Remove any default new instance
os.Remove(filepath.Join(destinationDir, boltdb.DatabaseFileName))
os.Remove(filepath.Join(destinationDir, boltdb.EncryptedDatabaseFileName))
if err := os.Remove(filepath.Join(destinationDir, boltdb.DatabaseFileName)); err != nil && !os.IsNotExist(err) {
return err
}
if err := os.Remove(filepath.Join(destinationDir, boltdb.EncryptedDatabaseFileName)); err != nil && !os.IsNotExist(err) {
return err
}
// Now copy the database. It'll be either portainer.db or portainer.edb
// Note: CopyPath does not return an error if the source file doesn't exist
err := filesystem.CopyPath(filepath.Join(srcDir, boltdb.EncryptedDatabaseFileName), destinationDir)
if err != nil {
if err := filesystem.CopyPath(filepath.Join(srcDir, boltdb.EncryptedDatabaseFileName), destinationDir); err != nil {
return err
}
+1
View File
@@ -6,6 +6,7 @@ import (
)
func TestGenerateGo119CompatibleKey(t *testing.T) {
t.Parallel()
type args struct {
seed string
}
+56 -30
View File
@@ -11,6 +11,7 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/proxy"
"github.com/portainer/portainer/pkg/schedule"
chserver "github.com/jpillora/chisel/server"
"github.com/jpillora/chisel/share/ccrypto"
@@ -89,10 +90,8 @@ func (service *Service) pingAgent(endpointID portainer.EndpointID) error {
return err
}
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
return nil
_, _ = io.Copy(io.Discard, resp.Body)
return resp.Body.Close()
}
// KeepTunnelAlive keeps the tunnel of the given environment for maxAlive duration, or until ctx is done
@@ -235,27 +234,18 @@ func (service *Service) startTunnelVerificationLoop() {
Float64("check_interval_seconds", tunnelCleanupInterval.Seconds()).
Msg("starting tunnel management process")
ticker := time.NewTicker(tunnelCleanupInterval)
schedule.RunOnInterval(service.shutdownCtx, tunnelCleanupInterval, service.checkTunnels, func() {
log.Debug().Msg("shutting down tunnel service")
for {
select {
case <-ticker.C:
service.checkTunnels()
case <-service.shutdownCtx.Done():
log.Debug().Msg("shutting down tunnel service")
if err := service.StopTunnelServer(); err != nil {
log.Debug().Err(err).Msg("stopped tunnel service")
}
ticker.Stop()
return
if err := service.StopTunnelServer(); err != nil {
log.Debug().Err(err).Msg("stopped tunnel service")
}
}
})
}
// 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()
@@ -266,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().
@@ -280,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
@@ -295,6 +299,28 @@ func (service *Service) checkTunnels() {
service.mu.RUnlock()
}
func (service *Service) snapshotAndLog(endpointID portainer.EndpointID, tunnelPort int) bool {
if err := service.snapshotEnvironment(endpointID, tunnelPort); err != nil {
log.Error().
Int("endpoint_id", int(endpointID)).
Err(err).
Msg("unable to snapshot Edge environment")
return false
}
return true
}
func (service *Service) markSnapshotTaken(endpointID portainer.EndpointID) {
service.mu.Lock()
defer service.mu.Unlock()
if tun, ok := service.activeTunnels[endpointID]; ok {
tun.HasSnapshot = true
}
}
func (service *Service) snapshotEnvironment(endpointID portainer.EndpointID, tunnelPort int) error {
endpoint, err := service.dataStore.Endpoint().Endpoint(endpointID)
if err != nil {
+166 -5
View File
@@ -2,6 +2,7 @@ package chisel
import (
"context"
"errors"
"net"
"net/http"
"testing"
@@ -18,15 +19,38 @@ func init() {
fips.InitFIPS(false)
}
func TestPingAgentPanic(t *testing.T) {
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,
}
}
_, store := datastore.MustNewTestStore(t, true, true)
func TestPingAgentPanic(t *testing.T) {
t.Parallel()
endpoint := newEdgeEndpoint(1)
_, store := datastore.MustNewTestStore(t, false, true)
s := NewService(store, nil, nil)
@@ -54,6 +78,143 @@ func TestPingAgentPanic(t *testing.T) {
s.activeTunnels[endpoint.ID].Port = ln.Addr().(*net.TCPAddr).Port
require.Error(t, s.pingAgent(endpoint.ID))
require.NoError(t, srv.Shutdown(context.Background()))
require.NoError(t, srv.Shutdown(t.Context()))
require.ErrorIs(t, <-errCh, http.ErrServerClosed)
}
func TestOpenDefaultsHasSnapshotToFalse(t *testing.T) {
t.Parallel()
endpoint := newEdgeEndpoint(1)
_, store := datastore.MustNewTestStore(t, false, true)
s := NewService(store, nil, nil)
err := s.Open(endpoint)
require.NoError(t, err)
require.False(t, s.activeTunnels[endpoint.ID].HasSnapshot)
}
func TestCheckTunnelsSetsHasSnapshotWhenSnapshotExists(t *testing.T) {
t.Parallel()
endpoint := newEdgeEndpoint(2)
_, store := datastore.MustNewTestStore(t, false, true)
err := store.Endpoint().Create(endpoint)
require.NoError(t, err)
snap := &portainer.Snapshot{
EndpointID: endpoint.ID,
Docker: &portainer.DockerSnapshot{},
}
err = store.Snapshot().Create(snap)
require.NoError(t, err)
s := NewService(store, nil, nil)
s.activeTunnels[endpoint.ID] = &portainer.TunnelDetails{
Status: portainer.EdgeAgentManagementRequired,
Port: 50003,
LastActivity: time.Now(),
}
s.checkTunnels()
require.NotNil(t, s.activeTunnels[endpoint.ID], "tunnel must remain open")
require.True(t, s.activeTunnels[endpoint.ID].HasSnapshot)
}
func TestCheckTunnelsSnapshotsActiveEnvironmentAndKeepsTunnelAlive(t *testing.T) {
t.Parallel()
endpoint := newEdgeEndpoint(3)
_, store := datastore.MustNewTestStore(t, false, true)
err := store.Endpoint().Create(endpoint)
require.NoError(t, err)
snapshotCalled := false
svc := &mockSnapshotService{
snapshotFn: func(_ *portainer.Endpoint) error {
snapshotCalled = true
return nil
},
}
s := NewService(store, nil, nil)
s.snapshotService = svc
s.activeTunnels[endpoint.ID] = &portainer.TunnelDetails{
Status: portainer.EdgeAgentManagementRequired,
Port: 50000,
LastActivity: time.Now(),
}
s.checkTunnels()
require.True(t, snapshotCalled)
require.NotNil(t, s.activeTunnels[endpoint.ID], "tunnel must remain open after snapshot")
require.True(t, s.activeTunnels[endpoint.ID].HasSnapshot)
}
func TestCheckTunnelsKeepsHasSnapshotFalseOnSnapshotFailure(t *testing.T) {
t.Parallel()
endpoint := newEdgeEndpoint(4)
_, store := datastore.MustNewTestStore(t, false, true)
err := store.Endpoint().Create(endpoint)
require.NoError(t, err)
svc := &mockSnapshotService{
snapshotFn: func(_ *portainer.Endpoint) error {
return errors.New("snapshot failed")
},
}
s := NewService(store, nil, nil)
s.snapshotService = svc
s.activeTunnels[endpoint.ID] = &portainer.TunnelDetails{
Status: portainer.EdgeAgentManagementRequired,
Port: 50001,
LastActivity: time.Now(),
}
s.checkTunnels()
require.NotNil(t, s.activeTunnels[endpoint.ID], "tunnel must remain open after failed snapshot")
require.False(t, s.activeTunnels[endpoint.ID].HasSnapshot, "HasSnapshot must stay false after failure")
}
func TestCheckTunnelsClosesIdleTunnelAndSnapshots(t *testing.T) {
t.Parallel()
endpoint := newEdgeEndpoint(5)
_, store := datastore.MustNewTestStore(t, false, true)
err := store.Endpoint().Create(endpoint)
require.NoError(t, err)
snapshotCalled := false
svc := &mockSnapshotService{
snapshotFn: func(_ *portainer.Endpoint) error {
snapshotCalled = true
return nil
},
}
s := NewService(store, nil, nil)
s.snapshotService = svc
s.activeTunnels[endpoint.ID] = &portainer.TunnelDetails{
Status: portainer.EdgeAgentManagementRequired,
Port: 50002,
LastActivity: time.Now().Add(-(activeTimeout + time.Second)),
}
s.checkTunnels()
require.True(t, snapshotCalled)
require.Nil(t, s.activeTunnels[endpoint.ID], "tunnel must be closed after idle timeout")
}
+19 -1
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"
@@ -142,7 +143,9 @@ func (s *Service) TunnelAddr(endpoint *portainer.Endpoint) (string, error) {
continue
}
conn.Close()
if err := conn.Close(); err != nil {
log.Warn().Err(err).Msg("failed to close tcp connection")
}
break
}
@@ -235,3 +238,18 @@ func encryptCredentials(username, password, key string) (string, error) {
return base64.RawStdEncoding.EncodeToString(encryptedCredentials), nil
}
func endpointHasSnapshot(dataStore dataservices.DataStore, endpointID portainer.EndpointID) bool {
var hasSnapshot bool
_ = dataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
s, err := tx.Snapshot().Read(endpointID)
if err != nil {
return err
}
hasSnapshot = s.Docker != nil || s.Kubernetes != nil
return nil
})
return hasSnapshot
}
+1
View File
@@ -28,6 +28,7 @@ func (s *testStore) Settings() dataservices.SettingsService {
}
func TestGetUnusedPort(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
existingTunnels map[portainer.EndpointID]*portainer.TunnelDetails
+17 -6
View File
@@ -32,7 +32,7 @@ func CLIFlags() *portainer.CLIFlags {
Assets: kingpin.Flag("assets", "Path to the assets").Default(defaultAssetsDirectory).Short('a').String(),
Data: kingpin.Flag("data", "Path to the folder where the data is stored").Default(defaultDataDirectory).Short('d').String(),
EndpointURL: kingpin.Flag("host", "Environment URL").Short('H').String(),
FeatureFlags: kingpin.Flag("feat", "List of feature flags").Strings(),
FeatureFlags: kingpin.Flag("feat", "List of feature flags").Envar(portainer.FeatureFlagEnvVar).Strings(),
EnableEdgeComputeFeatures: kingpin.Flag("edge-compute", "Enable Edge Compute features").Bool(),
NoAnalytics: kingpin.Flag("no-analytics", "Disable Analytics in app (deprecated)").Bool(),
TLSSkipVerify: kingpin.Flag("tlsskipverify", "Disable TLS server verification").Default(defaultTLSSkipVerify).Bool(),
@@ -52,7 +52,6 @@ func CLIFlags() *portainer.CLIFlags {
SecretKeyName: kingpin.Flag("secret-key-name", "Secret key name for encryption and will be used as /run/secrets/<secret-key-name>.").Default(defaultSecretKeyName).String(),
LogLevel: kingpin.Flag("log-level", "Set the minimum logging level to show").Default("INFO").Enum("DEBUG", "INFO", "WARN", "ERROR"),
LogMode: kingpin.Flag("log-mode", "Set the logging output mode").Default("PRETTY").Enum("NOCOLOR", "PRETTY", "JSON"),
KubectlShellImage: kingpin.Flag("kubectl-shell-image", "Kubectl shell image").Envar(portainer.KubectlShellImageEnvVar).Default(portainer.DefaultKubectlShellImage).String(),
PullLimitCheckDisabled: kingpin.Flag("pull-limit-check-disabled", "Pull limit check").Envar(portainer.PullLimitCheckDisabledEnvVar).Default(defaultPullLimitCheckDisabled).Bool(),
TrustedOrigins: kingpin.Flag("trusted-origins", "List of trusted origins for CSRF protection. Separate multiple origins with a comma.").Envar(portainer.TrustedOriginsEnvVar).String(),
CSP: kingpin.Flag("csp", "Content Security Policy (CSP) header").Envar(portainer.CSPEnvVar).Default("true").Bool(),
@@ -95,8 +94,20 @@ func (Service) ParseFlags(version string) (*portainer.CLIFlags, error) {
flags.TLSKey = tlsKeyFlag.String()
flags.TLSCacert = kingpin.Flag("tlscacert", "Path to the CA").Default(defaultTLSCACertPath).String()
var hasKubectlShellImageFlag bool
kubectlShellImageFlag := kingpin.Flag(
"kubectl-shell-image",
"Kubectl shell image",
).Envar(portainer.KubectlShellImageEnvVar).
Default(portainer.DefaultKubectlShellImage).
IsSetByUser(&hasKubectlShellImageFlag)
flags.KubectlShellImage = kubectlShellImageFlag.String()
kingpin.Parse()
_, kubectlShellImageEnvVarSet := os.LookupEnv(portainer.KubectlShellImageEnvVar)
flags.KubectlShellImageSet = hasKubectlShellImageFlag || kubectlShellImageEnvVarSet
if !filepath.IsAbs(*flags.Assets) {
ex, err := os.Executable()
if err != nil {
@@ -148,11 +159,11 @@ func (Service) ParseFlags(version string) (*portainer.CLIFlags, error) {
func (Service) ValidateFlags(flags *portainer.CLIFlags) error {
displayDeprecationWarnings(flags)
if err := validateEndpointURL(*flags.EndpointURL); err != nil {
if err := ValidateEndpointURL(*flags.EndpointURL); err != nil {
return err
}
if err := validateSnapshotInterval(*flags.SnapshotInterval); err != nil {
if err := ValidateSnapshotInterval(*flags.SnapshotInterval); err != nil {
return err
}
@@ -169,7 +180,7 @@ func displayDeprecationWarnings(flags *portainer.CLIFlags) {
}
}
func validateEndpointURL(endpointURL string) error {
func ValidateEndpointURL(endpointURL string) error {
if endpointURL == "" {
return nil
}
@@ -194,7 +205,7 @@ func validateEndpointURL(endpointURL string) error {
return nil
}
func validateSnapshotInterval(snapshotInterval string) error {
func ValidateSnapshotInterval(snapshotInterval string) error {
if snapshotInterval == "" {
return nil
}
+54
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
-1
View File
@@ -1,5 +1,4 @@
//go:build !windows
// +build !windows
package cli
+75 -47
View File
@@ -7,6 +7,7 @@ import (
"os"
"path"
"strings"
"time"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/apikey"
@@ -25,7 +26,6 @@ import (
"github.com/portainer/portainer/api/exec"
"github.com/portainer/portainer/api/filesystem"
"github.com/portainer/portainer/api/git"
"github.com/portainer/portainer/api/hostmanagement/openamt"
"github.com/portainer/portainer/api/http"
"github.com/portainer/portainer/api/http/proxy"
kubeproxy "github.com/portainer/portainer/api/http/proxy/factory/kubernetes"
@@ -51,11 +51,11 @@ import (
"github.com/portainer/portainer/pkg/featureflags"
"github.com/portainer/portainer/pkg/fips"
"github.com/portainer/portainer/pkg/libhelm"
libhelmtypes "github.com/portainer/portainer/pkg/libhelm/types"
"github.com/portainer/portainer/pkg/libstack/compose"
libswarm "github.com/portainer/portainer/pkg/libstack/swarm"
"github.com/portainer/portainer/pkg/validate"
"github.com/gofrs/uuid"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
)
@@ -119,7 +119,7 @@ func initDataStore(flags *portainer.CLIFlags, secretKey []byte, fileService port
}
if isNew {
instanceId, err := uuid.NewV4()
instanceId, err := uuid.NewRandom()
if err != nil {
log.Fatal().Err(err).Msg("failed generating instance id")
}
@@ -134,15 +134,16 @@ func initDataStore(flags *portainer.CLIFlags, secretKey []byte, fileService port
InstanceID: instanceId.String(),
MigratorCount: migratorCount,
}
store.VersionService.UpdateVersion(&v)
if err := store.VersionService.UpdateVersion(&v); err != nil {
log.Fatal().Err(err).Msg("failed to update version")
}
if err := updateSettingsFromFlags(store, flags); err != nil {
log.Fatal().Err(err).Msg("failed updating settings from flags")
}
} else {
if err := store.MigrateData(); err != nil {
log.Fatal().Err(err).Msg("failed migration")
}
} else if err := store.MigrateData(); err != nil {
log.Fatal().Err(err).Msg("failed migration")
}
if err := updateSettingsFromFlags(store, flags); err != nil {
@@ -153,7 +154,7 @@ func initDataStore(flags *portainer.CLIFlags, secretKey []byte, fileService port
go func() {
<-shutdownCtx.Done()
defer connection.Close()
defer logs.CloseAndLogErr(connection)
}()
return store
@@ -173,10 +174,6 @@ func initKubernetesDeployer(kubernetesTokenCacheManager *kubeproxy.TokenCacheMan
return exec.NewKubernetesDeployer(kubernetesTokenCacheManager, kubernetesClientFactory, dataStore, reverseTunnelService, signatureService, proxyManager)
}
func initHelmPackageManager() (libhelmtypes.HelmPackageManager, error) {
return libhelm.NewHelmPackageManager()
}
func initAPIKeyService(datastore dataservices.DataStore) apikey.APIKeyService {
return apikey.NewAPIKeyService(datastore.APIKeyRepository(), datastore.User())
}
@@ -215,13 +212,12 @@ func initSnapshotService(
dataStore dataservices.DataStore,
dockerClientFactory *dockerclient.ClientFactory,
kubernetesClientFactory *kubecli.ClientFactory,
shutdownCtx context.Context,
pendingActionsService *pendingactions.PendingActionsService,
) (portainer.SnapshotService, error) {
dockerSnapshotter := docker.NewSnapshotter(dockerClientFactory)
kubernetesSnapshotter := kubernetes.NewSnapshotter(kubernetesClientFactory)
snapshotService, err := snapshot.NewService(snapshotIntervalFromFlag, dataStore, dockerSnapshotter, kubernetesSnapshotter, shutdownCtx, pendingActionsService)
snapshotService, err := snapshot.NewService(snapshotIntervalFromFlag, dataStore, dockerSnapshotter, kubernetesSnapshotter, pendingActionsService)
if err != nil {
return nil, err
}
@@ -247,6 +243,10 @@ func updateSettingsFromFlags(dataStore dataservices.DataStore, flags *portainer.
settings.EnableEdgeComputeFeatures = cmp.Or(*flags.EnableEdgeComputeFeatures, settings.EnableEdgeComputeFeatures)
settings.TemplatesURL = cmp.Or(*flags.Templates, settings.TemplatesURL)
if flags.KubectlShellImageSet {
settings.KubectlShellImage = *flags.KubectlShellImage
}
if *flags.Labels != nil {
settings.BlackListedLabels = *flags.Labels
}
@@ -337,9 +337,7 @@ func loadEncryptionSecretKey(keyfilename string) []byte {
return hash[:]
}
func buildServer(flags *portainer.CLIFlags) portainer.Server {
shutdownCtx, shutdownTrigger := context.WithCancel(context.Background())
func buildServer(flags *portainer.CLIFlags, shutdownCtx context.Context, shutdownTrigger context.CancelFunc) portainer.Server {
if flags.FeatureFlags != nil {
featureflags.Parse(*flags.FeatureFlags, portainer.SupportedFeatureFlags)
}
@@ -347,9 +345,9 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
trustedOrigins := []string{}
if *flags.TrustedOrigins != "" {
// validate if the trusted origins are valid urls
for _, origin := range strings.Split(*flags.TrustedOrigins, ",") {
for origin := range strings.SplitSeq(*flags.TrustedOrigins, ",") {
if !validate.IsTrustedOrigin(origin) {
log.Fatal().Str("trusted_origin", origin).Msg("invalid url for trusted origin. Please check the trusted origins flag.")
log.Fatal().Str("trusted_origin", origin).Msg("invalid trusted origin: must be scheme://host or scheme://host:port (e.g. https://example.com)")
}
trustedOrigins = append(trustedOrigins, origin)
@@ -399,9 +397,6 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
gitService := git.NewService(shutdownCtx)
// Setting insecureSkipVerify to true to preserve the old behaviour.
openAMTService := openamt.NewService(true)
cryptoService := crypto.Service{}
signatureService := initDigitalSignatureService()
@@ -442,16 +437,11 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
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)
@@ -460,19 +450,16 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
pendingActionsService.RegisterHandler(actions.DeletePortainerK8sRegistrySecrets, handlers.NewHandlerDeleteRegistrySecrets(authorizationService, dataStore, kubernetesClientFactory))
pendingActionsService.RegisterHandler(actions.PostInitMigrateEnvironment, handlers.NewHandlerPostInitMigrateEnvironment(authorizationService, dataStore, kubernetesClientFactory, dockerClientFactory, *flags.Assets, kubernetesDeployer))
snapshotService, err := initSnapshotService(*flags.SnapshotInterval, dataStore, dockerClientFactory, kubernetesClientFactory, shutdownCtx, pendingActionsService)
snapshotService, err := initSnapshotService(*flags.SnapshotInterval, dataStore, dockerClientFactory, kubernetesClientFactory, pendingActionsService)
if err != nil {
log.Fatal().Err(err).Msg("failed initializing snapshot service")
}
snapshotService.Start()
snapshotService.Start(shutdownCtx)
proxyManager.NewProxyFactory(dataStore, signatureService, reverseTunnelService, dockerClientFactory, kubernetesClientFactory, kubernetesTokenCacheManager, gitService, snapshotService, jwtService)
helmPackageManager, err := initHelmPackageManager()
if err != nil {
log.Fatal().Err(err).Msg("failed initializing helm package manager")
}
helmPackageManager := libhelm.NewHelmPackageManager()
applicationStatus := initStatus(instanceID)
@@ -529,17 +516,16 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
scheduler := scheduler.NewScheduler(shutdownCtx)
stackDeployer := deployments.NewStackDeployer(swarmStackManager, composeStackManager, kubernetesDeployer, dockerClientFactory, dataStore)
deployments.StartStackSchedules(scheduler, stackDeployer, dataStore, gitService)
if err := deployments.StartStackSchedules(scheduler, stackDeployer, dataStore, gitService); err != nil {
log.Fatal().Err(err).Msg("failed to start stack scheduler")
}
sslDBSettings, err := dataStore.SSLSettings().Settings()
if err != nil {
log.Fatal().Msg("failed to fetch SSL settings from DB")
}
platformService, err := platform.NewService(dataStore)
if err != nil {
log.Fatal().Err(err).Msg("failed initializing platform service")
}
platformService := platform.NewService(dataStore)
upgradeService, err := upgrade.NewService(
*flags.Assets,
@@ -569,6 +555,13 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
log.Fatal().Err(err).Msg("failure during post init migrations")
}
if err := dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
return recoverStaleDeployingStacks(tx)
}); err != nil {
log.Info().Err(err).
Msg("Error recovering stale deploying stacks")
}
return &http.Server{
AuthorizationService: authorizationService,
ReverseTunnelService: reverseTunnelService,
@@ -591,7 +584,6 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
LDAPService: ldapService,
OAuthService: oauthService,
GitService: gitService,
OpenAMTService: openAMTService,
ProxyManager: proxyManager,
KubernetesTokenCacheManager: kubernetesTokenCacheManager,
KubeClusterAccessService: kubeClusterAccessService,
@@ -601,7 +593,6 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
DockerClientFactory: dockerClientFactory,
KubernetesClientFactory: kubernetesClientFactory,
Scheduler: scheduler,
ShutdownCtx: shutdownCtx,
ShutdownTrigger: shutdownTrigger,
StackDeployer: stackDeployer,
UpgradeService: upgradeService,
@@ -623,20 +614,57 @@ func main() {
logs.SetLoggingMode(*flags.LogMode)
for {
server := buildServer(flags)
shutdownCtx, shutdownTrigger := context.WithCancel(context.Background())
server := buildServer(flags, shutdownCtx, shutdownTrigger)
log.Info().
Str("version", portainer.APIVersion).
Str("build_number", build.BuildNumber).
Str("image_tag", build.ImageTag).
Str("nodejs_version", build.NodejsVersion).
Str("yarn_version", build.YarnVersion).
Str("pnpm_version", build.PnpmVersion).
Str("webpack_version", build.WebpackVersion).
Str("go_version", build.GoVersion).
Msg("starting Portainer")
err := server.Start()
err := server.Start(shutdownCtx)
log.Info().Err(err).Msg("HTTP server exited")
}
}
// recoverStaleDeployingStacks resets any stack that was left in the Deploying state
// (e.g. because the server was restarted mid-deployment) to the Error state so the
// user can retry.
func recoverStaleDeployingStacks(tx dataservices.DataStoreTx) error {
stacks, err := tx.Stack().ReadAll(func(s portainer.Stack) bool {
return s.Status == portainer.StackStatusDeploying
})
if err != nil {
return err
}
for _, stack := range stacks {
stack.Status = portainer.StackStatusError
stack.DeploymentStatus = append(stack.DeploymentStatus, portainer.StackDeploymentStatus{
Status: portainer.StackStatusError,
Time: time.Now().Unix(),
Message: "Deployment interrupted by server restart",
})
if err := tx.Stack().Update(stack.ID, &stack); err != nil {
log.Warn().Err(err).
Int("stack_id", int(stack.ID)).
Str("context", "RecoverStaleDeployingStacks").
Msg("Unable to recover stale deploying stack")
continue
}
log.Debug().
Int("stack_id", int(stack.ID)).
Str("stack_name", stack.Name).
Str("context", "RecoverStaleDeployingStacks").
Msg("Recovered stale deploying stack to error state")
}
return nil
}
+67 -3
View File
@@ -2,9 +2,12 @@ package main
import (
"os"
"path"
"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"
)
@@ -12,14 +15,15 @@ import (
const secretFileName = "secret.txt"
func createPasswordFile(t *testing.T, secretPath, password string) string {
err := os.WriteFile(secretPath, []byte(password), 0600)
err := os.WriteFile(secretPath, []byte(password), 0o600)
require.NoError(t, err)
return secretPath
}
func TestLoadEncryptionSecretKey(t *testing.T) {
t.Parallel()
tempDir := t.TempDir()
secretPath := path.Join(tempDir, secretFileName)
secretPath := filesystem.JoinPaths(tempDir, secretFileName)
// first pointing to file that does not exist, gives nil hash (no encryption)
encryptionKey := loadEncryptionSecretKey(secretPath)
@@ -38,7 +42,67 @@ 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 {
keyFilenameFlag string
expected string
+149
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)
}
-1
View File
@@ -53,5 +53,4 @@ type Connection interface {
UpdateObjectFunc(bucketName string, key []byte, object any, updateFn func()) error
ConvertToKey(v int) []byte
ConvertStringToKey(v string) []byte
}
+6 -2
View File
@@ -164,7 +164,9 @@ func aesEncryptGCM(input io.Reader, output io.Writer, passphrase []byte) error {
return err
}
nonce.Increment()
if err := nonce.Increment(); err != nil {
return err
}
}
return nil
@@ -235,7 +237,9 @@ func aesDecryptGCM(input io.Reader, passphrase []byte) (io.Reader, error) {
return nil, err
}
nonce.Increment()
if err := nonce.Increment(); err != nil {
return nil, err
}
}
return &buf, nil
+81 -57
View File
@@ -6,9 +6,10 @@ import (
"io"
"math/rand"
"os"
"path/filepath"
"testing"
"github.com/portainer/portainer/api/filesystem"
"github.com/portainer/portainer/api/logs"
"github.com/portainer/portainer/pkg/fips"
"github.com/stretchr/testify/assert"
@@ -41,22 +42,23 @@ func Test_encryptAndDecrypt_withTheSamePassword(t *testing.T) {
tmpdir := t.TempDir()
var (
originFilePath = filepath.Join(tmpdir, "origin")
encryptedFilePath = filepath.Join(tmpdir, "encrypted")
decryptedFilePath = filepath.Join(tmpdir, "decrypted")
originFilePath = filesystem.JoinPaths(tmpdir, "origin")
encryptedFilePath = filesystem.JoinPaths(tmpdir, "encrypted")
decryptedFilePath = filesystem.JoinPaths(tmpdir, "decrypted")
)
content := randBytes(1024*1024*100 + 523)
os.WriteFile(originFilePath, content, 0600)
err := os.WriteFile(originFilePath, content, 0600)
require.NoError(t, err)
originFile, _ := os.Open(originFilePath)
defer originFile.Close()
defer logs.CloseAndLogErr(originFile)
encryptedFileWriter, _ := os.Create(encryptedFilePath)
err := encrypt(originFile, encryptedFileWriter, []byte(passphrase))
err = encrypt(originFile, encryptedFileWriter, []byte(passphrase))
require.NoError(t, err, "Failed to encrypt a file")
encryptedFileWriter.Close()
logs.CloseAndLogErr(encryptedFileWriter)
encryptedContent, err := os.ReadFile(encryptedFilePath)
require.NoError(t, err, "Couldn't read encrypted file")
@@ -64,11 +66,11 @@ func Test_encryptAndDecrypt_withTheSamePassword(t *testing.T) {
encryptedFileReader, err := os.Open(encryptedFilePath)
require.NoError(t, err)
defer encryptedFileReader.Close()
defer logs.CloseAndLogErr(encryptedFileReader)
decryptedFileWriter, err := os.Create(decryptedFilePath)
require.NoError(t, err)
defer decryptedFileWriter.Close()
defer logs.CloseAndLogErr(decryptedFileWriter)
decryptedReader, err := decrypt(encryptedFileReader, []byte(passphrase))
if !decryptShouldSucceed {
@@ -76,9 +78,11 @@ func Test_encryptAndDecrypt_withTheSamePassword(t *testing.T) {
} else {
require.NoError(t, err, "Failed to decrypt file indicated by decryptShouldSucceed")
io.Copy(decryptedFileWriter, decryptedReader)
_, err = io.Copy(decryptedFileWriter, decryptedReader)
require.NoError(t, err)
decryptedContent, _ := os.ReadFile(decryptedFilePath)
decryptedContent, err := os.ReadFile(decryptedFilePath)
require.NoError(t, err)
assert.Equal(t, content, decryptedContent, "Original and decrypted content should match")
}
}
@@ -137,45 +141,53 @@ func Test_encryptAndDecrypt_withTheSamePassword(t *testing.T) {
}
func Test_encryptAndDecrypt_withStrongPassphrase(t *testing.T) {
t.Parallel()
const passphrase = "A strong passphrase with special characters: !@#$%^&*()_+"
testFunc := func(t *testing.T, encrypt encryptFunc, decrypt decryptFunc) {
tmpdir := t.TempDir()
var (
originFilePath = filepath.Join(tmpdir, "origin2")
encryptedFilePath = filepath.Join(tmpdir, "encrypted2")
decryptedFilePath = filepath.Join(tmpdir, "decrypted2")
originFilePath = filesystem.JoinPaths(tmpdir, "origin2")
encryptedFilePath = filesystem.JoinPaths(tmpdir, "encrypted2")
decryptedFilePath = filesystem.JoinPaths(tmpdir, "decrypted2")
)
content := randBytes(500)
os.WriteFile(originFilePath, content, 0600)
originFile, _ := os.Open(originFilePath)
defer originFile.Close()
err := os.WriteFile(originFilePath, content, 0600)
require.NoError(t, err)
originFile, err := os.Open(originFilePath)
require.NoError(t, err)
defer logs.CloseAndLogErr(originFile)
encryptedFileWriter, _ := os.Create(encryptedFilePath)
err := encrypt(originFile, encryptedFileWriter, []byte(passphrase))
err = encrypt(originFile, encryptedFileWriter, []byte(passphrase))
require.NoError(t, err, "Failed to encrypt a file")
encryptedFileWriter.Close()
logs.CloseAndLogErr(encryptedFileWriter)
encryptedContent, err := os.ReadFile(encryptedFilePath)
require.NoError(t, err, "Couldn't read encrypted file")
assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted")
encryptedFileReader, _ := os.Open(encryptedFilePath)
defer encryptedFileReader.Close()
encryptedFileReader, err := os.Open(encryptedFilePath)
require.NoError(t, err)
defer logs.CloseAndLogErr(encryptedFileReader)
decryptedFileWriter, _ := os.Create(decryptedFilePath)
defer decryptedFileWriter.Close()
decryptedFileWriter, err := os.Create(decryptedFilePath)
require.NoError(t, err)
defer logs.CloseAndLogErr(decryptedFileWriter)
decryptedReader, err := decrypt(encryptedFileReader, []byte(passphrase))
require.NoError(t, err, "Failed to decrypt file")
io.Copy(decryptedFileWriter, decryptedReader)
_, err = io.Copy(decryptedFileWriter, decryptedReader)
require.NoError(t, err)
decryptedContent, _ := os.ReadFile(decryptedFilePath)
decryptedContent, err := os.ReadFile(decryptedFilePath)
require.NoError(t, err)
assert.Equal(t, content, decryptedContent, "Original and decrypted content should match")
}
@@ -189,26 +201,30 @@ func Test_encryptAndDecrypt_withStrongPassphrase(t *testing.T) {
}
func Test_encryptAndDecrypt_withTheSamePasswordSmallFile(t *testing.T) {
t.Parallel()
testFunc := func(t *testing.T, encrypt encryptFunc, decrypt decryptFunc) {
tmpdir := t.TempDir()
var (
originFilePath = filepath.Join(tmpdir, "origin2")
encryptedFilePath = filepath.Join(tmpdir, "encrypted2")
decryptedFilePath = filepath.Join(tmpdir, "decrypted2")
originFilePath = filesystem.JoinPaths(tmpdir, "origin2")
encryptedFilePath = filesystem.JoinPaths(tmpdir, "encrypted2")
decryptedFilePath = filesystem.JoinPaths(tmpdir, "decrypted2")
)
content := randBytes(500)
os.WriteFile(originFilePath, content, 0600)
err := os.WriteFile(originFilePath, content, 0600)
require.NoError(t, err)
originFile, _ := os.Open(originFilePath)
defer originFile.Close()
originFile, err := os.Open(originFilePath)
require.NoError(t, err)
defer logs.CloseAndLogErr(originFile)
encryptedFileWriter, _ := os.Create(encryptedFilePath)
encryptedFileWriter, err := os.Create(encryptedFilePath)
require.NoError(t, err)
err := encrypt(originFile, encryptedFileWriter, []byte("passphrase"))
err = encrypt(originFile, encryptedFileWriter, []byte("passphrase"))
require.NoError(t, err, "Failed to encrypt a file")
encryptedFileWriter.Close()
logs.CloseAndLogErr(encryptedFileWriter)
encryptedContent, err := os.ReadFile(encryptedFilePath)
require.NoError(t, err, "Couldn't read encrypted file")
@@ -216,11 +232,11 @@ func Test_encryptAndDecrypt_withTheSamePasswordSmallFile(t *testing.T) {
encryptedFileReader, err := os.Open(encryptedFilePath)
require.NoError(t, err)
defer encryptedFileReader.Close()
defer logs.CloseAndLogErr(encryptedFileReader)
decryptedFileWriter, err := os.Create(decryptedFilePath)
require.NoError(t, err)
defer decryptedFileWriter.Close()
defer logs.CloseAndLogErr(decryptedFileWriter)
decryptedReader, err := decrypt(encryptedFileReader, []byte("passphrase"))
require.NoError(t, err, "Failed to decrypt file")
@@ -243,13 +259,14 @@ func Test_encryptAndDecrypt_withTheSamePasswordSmallFile(t *testing.T) {
}
func Test_encryptAndDecrypt_withEmptyPassword(t *testing.T) {
t.Parallel()
testFunc := func(t *testing.T, encrypt encryptFunc, decrypt decryptFunc) {
tmpdir := t.TempDir()
var (
originFilePath = filepath.Join(tmpdir, "origin")
encryptedFilePath = filepath.Join(tmpdir, "encrypted")
decryptedFilePath = filepath.Join(tmpdir, "decrypted")
originFilePath = filesystem.JoinPaths(tmpdir, "origin")
encryptedFilePath = filesystem.JoinPaths(tmpdir, "encrypted")
decryptedFilePath = filesystem.JoinPaths(tmpdir, "decrypted")
)
content := randBytes(1024 * 50)
@@ -258,11 +275,11 @@ func Test_encryptAndDecrypt_withEmptyPassword(t *testing.T) {
originFile, err := os.Open(originFilePath)
require.NoError(t, err)
defer originFile.Close()
defer logs.CloseAndLogErr(originFile)
encryptedFileWriter, err := os.Create(encryptedFilePath)
require.NoError(t, err)
defer encryptedFileWriter.Close()
defer logs.CloseAndLogErr(encryptedFileWriter)
err = encrypt(originFile, encryptedFileWriter, []byte(""))
require.NoError(t, err, "Failed to encrypt a file")
@@ -273,11 +290,11 @@ func Test_encryptAndDecrypt_withEmptyPassword(t *testing.T) {
encryptedFileReader, err := os.Open(encryptedFilePath)
require.NoError(t, err)
defer encryptedFileReader.Close()
defer logs.CloseAndLogErr(encryptedFileReader)
decryptedFileWriter, err := os.Create(decryptedFilePath)
require.NoError(t, err)
defer decryptedFileWriter.Close()
defer logs.CloseAndLogErr(decryptedFileWriter)
decryptedReader, err := decrypt(encryptedFileReader, []byte(""))
require.NoError(t, err, "Failed to decrypt file")
@@ -300,35 +317,41 @@ func Test_encryptAndDecrypt_withEmptyPassword(t *testing.T) {
}
func Test_decryptWithDifferentPassphrase_shouldProduceWrongResult(t *testing.T) {
t.Parallel()
testFunc := func(t *testing.T, encrypt encryptFunc, decrypt decryptFunc) {
tmpdir := t.TempDir()
var (
originFilePath = filepath.Join(tmpdir, "origin")
encryptedFilePath = filepath.Join(tmpdir, "encrypted")
decryptedFilePath = filepath.Join(tmpdir, "decrypted")
originFilePath = filesystem.JoinPaths(tmpdir, "origin")
encryptedFilePath = filesystem.JoinPaths(tmpdir, "encrypted")
decryptedFilePath = filesystem.JoinPaths(tmpdir, "decrypted")
)
content := randBytes(1034)
os.WriteFile(originFilePath, content, 0600)
err := os.WriteFile(originFilePath, content, 0600)
require.NoError(t, err)
originFile, _ := os.Open(originFilePath)
defer originFile.Close()
originFile, err := os.Open(originFilePath)
require.NoError(t, err)
defer logs.CloseAndLogErr(originFile)
encryptedFileWriter, _ := os.Create(encryptedFilePath)
defer encryptedFileWriter.Close()
encryptedFileWriter, err := os.Create(encryptedFilePath)
require.NoError(t, err)
defer logs.CloseAndLogErr(encryptedFileWriter)
err := encrypt(originFile, encryptedFileWriter, []byte("passphrase"))
err = encrypt(originFile, encryptedFileWriter, []byte("passphrase"))
require.NoError(t, err, "Failed to encrypt a file")
encryptedContent, err := os.ReadFile(encryptedFilePath)
require.NoError(t, err, "Couldn't read encrypted file")
assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted")
encryptedFileReader, _ := os.Open(encryptedFilePath)
defer encryptedFileReader.Close()
encryptedFileReader, err := os.Open(encryptedFilePath)
require.NoError(t, err)
defer logs.CloseAndLogErr(encryptedFileReader)
decryptedFileWriter, _ := os.Create(decryptedFilePath)
defer decryptedFileWriter.Close()
decryptedFileWriter, err := os.Create(decryptedFilePath)
require.NoError(t, err)
defer logs.CloseAndLogErr(decryptedFileWriter)
_, err = decrypt(encryptedFileReader, []byte("garbage"))
require.Error(t, err, "Should not allow decrypt with wrong passphrase")
@@ -366,6 +389,7 @@ func legacyAesEncrypt(input io.Reader, output io.Writer, passphrase []byte) erro
}
func Test_hasEncryptedHeader(t *testing.T) {
t.Parallel()
tests := []struct {
name string
data []byte
+1
View File
@@ -7,6 +7,7 @@ import (
)
func TestCreateSignature(t *testing.T) {
t.Parallel()
var s = NewECDSAService("secret")
privKey, pubKey, err := s.GenerateKeyPair()
+2
View File
@@ -7,6 +7,7 @@ import (
)
func TestService_Hash(t *testing.T) {
t.Parallel()
var s = Service{}
type args struct {
@@ -55,6 +56,7 @@ func TestService_Hash(t *testing.T) {
}
func TestHash(t *testing.T) {
t.Parallel()
s := Service{}
hash, err := s.Hash("Passw0rd!")
+3 -1
View File
@@ -92,7 +92,9 @@ func CreateTLSConfigurationFromDisk(config portainer.TLSConfiguration) (*tls.Con
}
func createTLSConfigurationFromDisk(fipsEnabled bool, config portainer.TLSConfiguration) (*tls.Config, error) { //nolint:forbidigo
if !config.TLS {
if !config.TLS && fipsEnabled {
return nil, fips.ErrTLSRequired
} else if !config.TLS {
return nil, nil
}
+5
View File
@@ -10,6 +10,7 @@ import (
)
func TestCreateTLSConfiguration(t *testing.T) {
t.Parallel()
// InsecureSkipVerify = false
config := CreateTLSConfiguration(false)
require.Equal(t, config.MinVersion, uint16(tls.VersionTLS12)) //nolint:forbidigo
@@ -22,6 +23,7 @@ func TestCreateTLSConfiguration(t *testing.T) {
}
func TestCreateTLSConfigurationFIPS(t *testing.T) {
t.Parallel()
fips := true
fipsCipherSuites := []uint16{
@@ -42,6 +44,7 @@ func TestCreateTLSConfigurationFIPS(t *testing.T) {
}
func TestCreateTLSConfigurationFromBytes(t *testing.T) {
t.Parallel()
// No TLS
config, err := CreateTLSConfigurationFromBytes(false, nil, nil, nil, false, false)
require.NoError(t, err)
@@ -59,6 +62,7 @@ func TestCreateTLSConfigurationFromBytes(t *testing.T) {
}
func TestCreateTLSConfigurationFromDisk(t *testing.T) {
t.Parallel()
// No TLS
config, err := CreateTLSConfigurationFromDisk(portainer.TLSConfiguration{})
require.NoError(t, err)
@@ -74,6 +78,7 @@ func TestCreateTLSConfigurationFromDisk(t *testing.T) {
}
func TestCreateTLSConfigurationFromDiskFIPS(t *testing.T) {
t.Parallel()
fips := true
// Skipping TLS verifications cannot be done in FIPS mode
-4
View File
@@ -233,10 +233,6 @@ func (connection *DbConnection) ConvertToKey(v int) []byte {
return b
}
func (connection *DbConnection) ConvertStringToKey(v string) []byte {
return []byte(v)
}
// keyToString Converts a key to a string value suitable for logging
func keyToString(b []byte) string {
if len(b) != 8 {
+31 -11
View File
@@ -2,7 +2,6 @@ package boltdb
import (
"os"
"path"
"testing"
"github.com/portainer/portainer/api/filesystem"
@@ -13,6 +12,7 @@ import (
)
func Test_NeedsEncryptionMigration(t *testing.T) {
t.Parallel()
// Test the specific scenarios mentioned in NeedsEncryptionMigration
// i.e.
@@ -96,20 +96,38 @@ func Test_NeedsEncryptionMigration(t *testing.T) {
if tc.dbname == "both" {
// Special case. If portainer.db and portainer.edb exist.
dbFile1 := path.Join(connection.Path, DatabaseFileName)
dbFile1 := filesystem.JoinPaths(connection.Path, DatabaseFileName)
f, _ := os.Create(dbFile1)
f.Close()
defer os.Remove(dbFile1)
dbFile2 := path.Join(connection.Path, EncryptedDatabaseFileName)
err := f.Close()
require.NoError(t, err)
defer func() {
err := os.Remove(dbFile1)
require.NoError(t, err)
}()
dbFile2 := filesystem.JoinPaths(connection.Path, EncryptedDatabaseFileName)
f, _ = os.Create(dbFile2)
f.Close()
defer os.Remove(dbFile2)
err = f.Close()
require.NoError(t, err)
defer func() {
err := os.Remove(dbFile2)
require.NoError(t, err)
}()
} else if tc.dbname != "" {
dbFile := path.Join(connection.Path, tc.dbname)
dbFile := filesystem.JoinPaths(connection.Path, tc.dbname)
f, _ := os.Create(dbFile)
f.Close()
defer os.Remove(dbFile)
err := f.Close()
require.NoError(t, err)
defer func() {
err := os.Remove(dbFile)
require.NoError(t, err)
}()
}
if tc.key {
@@ -125,6 +143,7 @@ func Test_NeedsEncryptionMigration(t *testing.T) {
}
func TestDBCompaction(t *testing.T) {
t.Parallel()
db := &DbConnection{Path: t.TempDir()}
err := db.Open()
@@ -136,7 +155,8 @@ func TestDBCompaction(t *testing.T) {
return err
}
b.Put([]byte("key"), []byte("value"))
err = b.Put([]byte("key"), []byte("value"))
require.NoError(t, err)
return nil
})
+2 -1
View File
@@ -3,6 +3,7 @@ package boltdb
import (
"time"
"github.com/portainer/portainer/api/logs"
"github.com/rs/zerolog/log"
"github.com/segmentio/encoding/json"
bolt "go.etcd.io/bbolt"
@@ -37,7 +38,7 @@ func (c *DbConnection) ExportJSON(databasePath string, metadata bool) ([]byte, e
if err != nil {
return []byte("{}"), err
}
defer connection.Close()
defer logs.CloseAndLogErr(connection)
backup := make(map[string]any)
if metadata {
+2 -2
View File
@@ -45,12 +45,12 @@ func (connection *DbConnection) UnmarshalObject(data []byte, object any) error {
}
}
if e := json.Unmarshal(data, object); e != nil {
if err := json.Unmarshal(data, object); err != nil {
// Special case for the VERSION bucket. Here we're not using json
// So we need to return it as a string
s, ok := object.(*string)
if !ok {
return errors.Wrap(err, e.Error())
return errors.Wrap(err, "Failed unmarshalling object")
}
*s = string(data)
+7 -3
View File
@@ -10,14 +10,14 @@ import (
"io"
"testing"
"github.com/gofrs/uuid"
"github.com/google/uuid"
"github.com/pkg/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const (
jsonobject = `{"LogoURL":"","BlackListedLabels":[],"AuthenticationMethod":1,"InternalAuthSettings": {"RequiredPasswordLength": 12}"LDAPSettings":{"AnonymousMode":true,"ReaderDN":"","URL":"","TLSConfig":{"TLS":false,"TLSSkipVerify":false},"StartTLS":false,"SearchSettings":[{"BaseDN":"","Filter":"","UserNameAttribute":""}],"GroupSearchSettings":[{"GroupBaseDN":"","GroupFilter":"","GroupAttribute":""}],"AutoCreateUsers":true},"OAuthSettings":{"ClientID":"","AccessTokenURI":"","AuthorizationURI":"","ResourceURI":"","RedirectURI":"","UserIdentifier":"","Scopes":"","OAuthAutoCreateUsers":false,"DefaultTeamID":0,"SSO":true,"LogoutURI":"","KubeSecretKey":"j0zLVtY/lAWBk62ByyF0uP80SOXaitsABP0TTJX8MhI="},"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","EnableTelemetry":true,"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"
)
@@ -27,9 +27,10 @@ func secretToEncryptionKey(passphrase string) []byte {
}
func Test_MarshalObjectUnencrypted(t *testing.T) {
t.Parallel()
is := assert.New(t)
uuid := uuid.Must(uuid.NewV4())
uuid := uuid.New()
tests := []struct {
object any
@@ -101,6 +102,7 @@ func Test_MarshalObjectUnencrypted(t *testing.T) {
}
func Test_UnMarshalObjectUnencrypted(t *testing.T) {
t.Parallel()
is := assert.New(t)
// Based on actual data entering and what we expect out of the function
@@ -142,6 +144,7 @@ func Test_UnMarshalObjectUnencrypted(t *testing.T) {
}
func Test_ObjectMarshallingEncrypted(t *testing.T) {
t.Parallel()
is := assert.New(t)
// Based on actual data entering and what we expect out of the function
@@ -184,6 +187,7 @@ func Test_ObjectMarshallingEncrypted(t *testing.T) {
}
func Test_NonceSources(t *testing.T) {
t.Parallel()
// ensure that the new go 1.24 NewGCMWithRandomNonce works correctly with
// the old way of creating and including the nonce
+20 -41
View File
@@ -6,6 +6,7 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/stretchr/testify/require"
)
const testBucketName = "test-bucket"
@@ -17,70 +18,56 @@ type testStruct struct {
}
func TestTxs(t *testing.T) {
conn := DbConnection{
Path: t.TempDir(),
}
t.Parallel()
conn := DbConnection{Path: t.TempDir()}
err := conn.Open()
if err != nil {
t.Fatal(err)
}
defer conn.Close()
require.NoError(t, err)
defer func() {
err := conn.Close()
require.NoError(t, err)
}()
// Error propagation
err = conn.UpdateTx(func(tx portainer.Transaction) error {
return errors.New("this is an error")
})
if err == nil {
t.Fatal("an error was expected, got nil instead")
}
require.Error(t, err)
// Create an object
newObj := testStruct{
Key: "key",
Value: "value",
}
newObj := testStruct{Key: "key", Value: "value"}
err = conn.UpdateTx(func(tx portainer.Transaction) error {
err = tx.SetServiceName(testBucketName)
if err != nil {
if err := tx.SetServiceName(testBucketName); err != nil {
return err
}
return tx.CreateObjectWithId(testBucketName, testId, newObj)
})
if err != nil {
t.Fatal(err)
}
require.NoError(t, err)
obj := testStruct{}
err = conn.ViewTx(func(tx portainer.Transaction) error {
return tx.GetObject(testBucketName, conn.ConvertToKey(testId), &obj)
})
if err != nil {
t.Fatal(err)
}
require.NoError(t, err)
if obj.Key != newObj.Key || obj.Value != newObj.Value {
t.Fatalf("expected %s:%s, got %s:%s instead", newObj.Key, newObj.Value, obj.Key, obj.Value)
}
// Update an object
updatedObj := testStruct{
Key: "updated-key",
Value: "updated-value",
}
updatedObj := testStruct{Key: "updated-key", Value: "updated-value"}
err = conn.UpdateTx(func(tx portainer.Transaction) error {
return tx.UpdateObject(testBucketName, conn.ConvertToKey(testId), &updatedObj)
})
require.NoError(t, err)
err = conn.ViewTx(func(tx portainer.Transaction) error {
return tx.GetObject(testBucketName, conn.ConvertToKey(testId), &obj)
})
if err != nil {
t.Fatal(err)
}
require.NoError(t, err)
if obj.Key != updatedObj.Key || obj.Value != updatedObj.Value {
t.Fatalf("expected %s:%s, got %s:%s instead", updatedObj.Key, updatedObj.Value, obj.Key, obj.Value)
@@ -90,16 +77,12 @@ func TestTxs(t *testing.T) {
err = conn.UpdateTx(func(tx portainer.Transaction) error {
return tx.DeleteObject(testBucketName, conn.ConvertToKey(testId))
})
if err != nil {
t.Fatal(err)
}
require.NoError(t, err)
err = conn.ViewTx(func(tx portainer.Transaction) error {
return tx.GetObject(testBucketName, conn.ConvertToKey(testId), &obj)
})
if !dataservices.IsErrObjectNotFound(err) {
t.Fatal(err)
}
require.True(t, dataservices.IsErrObjectNotFound(err))
// Get next identifier
err = conn.UpdateTx(func(tx portainer.Transaction) error {
@@ -112,15 +95,11 @@ func TestTxs(t *testing.T) {
return nil
})
if err != nil {
t.Fatal(err)
}
require.NoError(t, err)
// Try to write in a read transaction
err = conn.ViewTx(func(tx portainer.Transaction) error {
return tx.CreateObjectWithId(testBucketName, testId, newObj)
})
if err == nil {
t.Fatal("an error was expected, got nil instead")
}
require.Error(t, err)
}
+1
View File
@@ -10,6 +10,7 @@ import (
)
func TestNewDatabase(t *testing.T) {
t.Parallel()
dbPath := filesystem.JoinPaths(t.TempDir(), "test.db")
connection, err := NewDatabase("boltdb", dbPath, nil, false)
require.NoError(t, err)
+2 -5
View File
@@ -21,7 +21,7 @@ type mockConnection struct {
portainer.Connection
}
func (m mockConnection) UpdateObject(bucket string, key []byte, value interface{}) error {
func (m mockConnection) UpdateObject(bucket string, key []byte, value any) error {
obj := value.(*testObject)
m.store[obj.ID] = *obj
@@ -50,11 +50,8 @@ func (m mockConnection) ViewTx(fn func(portainer.Transaction) error) error {
func (m mockConnection) ConvertToKey(v int) []byte {
return []byte(strconv.Itoa(v))
}
func (c mockConnection) ConvertStringToKey(v string) []byte {
return []byte(v)
}
func TestReadAll(t *testing.T) {
t.Parallel()
service := BaseDataService[testObject, int]{
Bucket: "testBucket",
Connection: mockConnection{store: make(map[int]testObject)},
+10
View File
@@ -72,3 +72,13 @@ func (service BaseDataServiceTx[T, I]) Delete(ID I) error {
identifier := service.Connection.ConvertToKey(int(ID))
return service.Tx.DeleteObject(service.Bucket, identifier)
}
func Read[T any](tx portainer.Transaction, bucket string, key []byte) (*T, error) {
var element T
if err := tx.GetObject(bucket, key, &element); err != nil {
return nil, err
}
return &element, nil
}
@@ -9,7 +9,8 @@ import (
)
func TestCustomTemplateCreate(t *testing.T) {
_, ds := datastore.MustNewTestStore(t, true, false)
t.Parallel()
_, ds := datastore.MustNewTestStore(t, false, false)
require.NotNil(t, ds)
require.NoError(t, ds.CustomTemplate().Create(&portainer.CustomTemplate{ID: 1}))
+2 -1
View File
@@ -10,7 +10,8 @@ import (
)
func TestCustomTemplateCreateTx(t *testing.T) {
_, ds := datastore.MustNewTestStore(t, true, false)
t.Parallel()
_, ds := datastore.MustNewTestStore(t, false, false)
require.NotNil(t, ds)
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
+3 -1
View File
@@ -5,16 +5,18 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/database/boltdb"
"github.com/portainer/portainer/api/logs"
"github.com/stretchr/testify/require"
)
func TestUpdate(t *testing.T) {
t.Parallel()
var conn portainer.Connection = &boltdb.DbConnection{Path: t.TempDir()}
err := conn.Open()
require.NoError(t, err)
defer conn.Close()
defer logs.CloseAndLogErr(conn)
service, err := NewService(conn, func(portainer.Transaction, portainer.EdgeStackID) {})
require.NoError(t, err)
+13
View File
@@ -119,6 +119,19 @@ func (service *Service) Endpoints() ([]portainer.Endpoint, error) {
return endpoints, nil
}
// ReadAll retrieves all the elements that satisfy all the provided predicates.
func (service *Service) ReadAll(predicates ...func(endpoint portainer.Endpoint) bool) ([]portainer.Endpoint, error) {
var endpoints []portainer.Endpoint
var err error
err = service.connection.ViewTx(func(tx portainer.Transaction) error {
endpoints, err = service.Tx(tx).ReadAll(predicates...)
return err
})
return endpoints, err
}
// EndpointIDByEdgeID returns the EndpointID from the given EdgeID using an in-memory index
func (service *Service) EndpointIDByEdgeID(edgeID string) (portainer.EndpointID, bool) {
service.mu.RLock()
+5
View File
@@ -89,6 +89,11 @@ func (service ServiceTx) Endpoints() ([]portainer.Endpoint, error) {
)
}
// ReadAll retrieves all the elements that satisfy all the provided predicates.
func (service ServiceTx) ReadAll(predicates ...func(endpoint portainer.Endpoint) bool) ([]portainer.Endpoint, error) {
return dataservices.BaseDataServiceTx[portainer.Endpoint, portainer.EndpointID]{Bucket: BucketName, Connection: service.service.connection, Tx: service.tx}.ReadAll(predicates...)
}
func (service ServiceTx) EndpointIDByEdgeID(edgeID string) (portainer.EndpointID, bool) {
log.Error().Str("func", "EndpointIDByEdgeID").Msg("cannot be called inside a transaction")
@@ -28,6 +28,9 @@ func (service *Service) BucketName() string {
func (service *Service) RegisterUpdateStackFunction(
updateFuncTx func(portainer.Transaction, portainer.EdgeStackID, func(*portainer.EdgeStack)) error,
) {
service.mu.Lock()
defer service.mu.Unlock()
service.updateStackFnTx = updateFuncTx
}
@@ -7,11 +7,13 @@ import (
"github.com/portainer/portainer/api/database/boltdb"
"github.com/portainer/portainer/api/dataservices/edgestack"
"github.com/portainer/portainer/api/internal/edge/cache"
"github.com/portainer/portainer/api/logs"
"github.com/stretchr/testify/require"
)
func TestUpdateRelation(t *testing.T) {
t.Parallel()
const endpointID = 1
const edgeStackID1 = 1
const edgeStackID2 = 2
@@ -20,7 +22,7 @@ func TestUpdateRelation(t *testing.T) {
err := conn.Open()
require.NoError(t, err)
defer conn.Close()
defer logs.CloseAndLogErr(conn)
service, err := NewService(conn)
require.NoError(t, err)
@@ -105,11 +107,12 @@ func TestUpdateRelation(t *testing.T) {
}
func TestAddEndpointRelationsForEdgeStack(t *testing.T) {
t.Parallel()
var conn portainer.Connection = &boltdb.DbConnection{Path: t.TempDir()}
err := conn.Open()
require.NoError(t, err)
defer conn.Close()
defer logs.CloseAndLogErr(conn)
service, err := NewService(conn)
require.NoError(t, err)
@@ -124,11 +127,12 @@ func TestAddEndpointRelationsForEdgeStack(t *testing.T) {
}
func TestEndpointRelations(t *testing.T) {
t.Parallel()
var conn portainer.Connection = &boltdb.DbConnection{Path: t.TempDir()}
err := conn.Open()
require.NoError(t, err)
defer conn.Close()
defer logs.CloseAndLogErr(conn)
service, err := NewService(conn)
require.NoError(t, err)
+1 -1
View File
@@ -6,7 +6,7 @@ import (
var (
ErrObjectNotFound = errors.New("object not found inside the database")
ErrWrongDBEdition = errors.New("the Portainer database is set for Portainer Business Edition, please follow the instructions in our documentation to downgrade it: https://documentation.portainer.io/v2.0-be/downgrade/be-to-ce/")
ErrWrongDBEdition = errors.New("the Portainer database is set for Portainer Business Edition, please follow the instructions in our documentation to downgrade it: https://docs.portainer.io/faqs/upgrading/can-i-downgrade-from-portainer-business-to-portainer-ce")
ErrDBImportFailed = errors.New("importing backup failed")
ErrDatabaseIsUpdating = errors.New("database is currently in updating state. Failed prior upgrade. Please restore from backup or delete the database and restart Portainer")
)
+4
View File
@@ -102,6 +102,9 @@ type (
// EndpointService represents a service for managing environment(endpoint) data
EndpointService interface {
// partial dataservices.BaseCRUD[portainer.Endpoint, portainer.EndpointID]
ReadAll(predicates ...func(endpoint portainer.Endpoint) bool) ([]portainer.Endpoint, error)
Endpoint(ID portainer.EndpointID) (*portainer.Endpoint, error)
EndpointIDByEdgeID(edgeID string) (portainer.EndpointID, bool)
EndpointsByTeamID(teamID portainer.TeamID) ([]portainer.Endpoint, error)
@@ -223,6 +226,7 @@ type (
UserService interface {
BaseCRUD[portainer.User, portainer.UserID]
UserByUsername(username string) (*portainer.User, error)
UserIDByUsername(username string) (portainer.UserID, error)
UsersByRole(role portainer.UserRole) ([]portainer.User, error)
}
@@ -10,6 +10,7 @@ import (
)
func TestDeleteByEndpoint(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, false)
// Create Endpoint 1
@@ -3,6 +3,7 @@ package resourcecontrol
import (
"errors"
"fmt"
"slices"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
@@ -64,11 +65,9 @@ func (service *Service) ResourceControlByResourceIDAndType(resourceID string, re
return nil, stop
}
for _, subResourceID := range rc.SubResourceIDs {
if subResourceID == resourceID {
resourceControl = rc
return nil, stop
}
if slices.Contains(rc.SubResourceIDs, resourceID) {
resourceControl = rc
return nil, stop
}
return &portainer.ResourceControl{}, nil
+4 -5
View File
@@ -3,6 +3,7 @@ package resourcecontrol
import (
"errors"
"fmt"
"slices"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
@@ -35,11 +36,9 @@ func (service ServiceTx) ResourceControlByResourceIDAndType(resourceID string, r
return nil, stop
}
for _, subResourceID := range rc.SubResourceIDs {
if subResourceID == resourceID {
resourceControl = rc
return nil, stop
}
if slices.Contains(rc.SubResourceIDs, resourceID) {
resourceControl = rc
return nil, stop
}
return &portainer.ResourceControl{}, nil
+7
View File
@@ -31,6 +31,13 @@ func NewService(connection portainer.Connection) (*Service, error) {
}, nil
}
func (service *Service) Tx(tx portainer.Transaction) ServiceTx {
return ServiceTx{
service: service,
tx: tx,
}
}
// Settings retrieve the ssl settings object.
func (service *Service) Settings() (*portainer.SSLSettings, error) {
var settings portainer.SSLSettings
+31
View File
@@ -0,0 +1,31 @@
package ssl
import (
portainer "github.com/portainer/portainer/api"
)
type ServiceTx struct {
service *Service
tx portainer.Transaction
}
func (service ServiceTx) BucketName() string {
return BucketName
}
// Settings retrieve the settings object.
func (service ServiceTx) Settings() (*portainer.SSLSettings, error) {
var settings portainer.SSLSettings
err := service.tx.GetObject(BucketName, []byte(key), &settings)
if err != nil {
return nil, err
}
return &settings, nil
}
// UpdateSettings persists a Settings object.
func (service ServiceTx) UpdateSettings(settings *portainer.SSLSettings) error {
return service.tx.UpdateObject(BucketName, []byte(key), settings)
}
+6 -4
View File
@@ -8,13 +8,13 @@ import (
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/filesystem"
"github.com/gofrs/uuid"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func newGuidString(t *testing.T) string {
uuid, err := uuid.NewV4()
uuid, err := uuid.NewRandom()
require.NoError(t, err)
return uuid.String()
@@ -27,10 +27,11 @@ type stackBuilder struct {
}
func TestService_StackByWebhookID(t *testing.T) {
t.Parallel()
if testing.Short() {
t.Skip("skipping test in short mode. Normally takes ~1s to run.")
}
_, store := datastore.MustNewTestStore(t, true, true)
_, store := datastore.MustNewTestStore(t, false, true)
b := stackBuilder{t: t, store: store}
b.createNewStack(newGuidString(t))
@@ -84,10 +85,11 @@ func (b *stackBuilder) createNewStack(webhookID string) portainer.Stack {
}
func Test_RefreshableStacks(t *testing.T) {
t.Parallel()
if testing.Short() {
t.Skip("skipping test in short mode. Normally takes ~1s to run.")
}
_, store := datastore.MustNewTestStore(t, true, true)
_, store := datastore.MustNewTestStore(t, false, true)
staticStack := portainer.Stack{ID: 1}
stackWithWebhook := portainer.Stack{ID: 2, AutoUpdate: &portainer.AutoUpdateSettings{Webhook: "webhook"}}
+24 -3
View File
@@ -3,6 +3,7 @@ package tests
import (
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices/errors"
"github.com/portainer/portainer/api/datastore"
@@ -10,9 +11,29 @@ import (
"github.com/stretchr/testify/require"
)
type teamBuilder struct {
t *testing.T
count int
store *datastore.Store
}
func (b *teamBuilder) createNew(name string) *portainer.Team {
b.count++
team := &portainer.Team{
ID: portainer.TeamID(b.count),
Name: name,
}
err := b.store.Team().Create(team)
assert.NoError(b.t, err)
return team
}
func Test_teamByName(t *testing.T) {
t.Parallel()
t.Run("When store is empty should return ErrObjectNotFound", func(t *testing.T) {
_, store := datastore.MustNewTestStore(t, true, true)
_, store := datastore.MustNewTestStore(t, false, true)
_, err := store.Team().TeamByName("name")
require.ErrorIs(t, err, errors.ErrObjectNotFound)
@@ -20,7 +41,7 @@ func Test_teamByName(t *testing.T) {
})
t.Run("When there is no object with the same name should return ErrObjectNotFound", func(t *testing.T) {
_, store := datastore.MustNewTestStore(t, true, true)
_, store := datastore.MustNewTestStore(t, false, true)
teamBuilder := teamBuilder{
t: t,
@@ -35,7 +56,7 @@ func Test_teamByName(t *testing.T) {
})
t.Run("When there is an object with the same name should return the object", func(t *testing.T) {
_, store := datastore.MustNewTestStore(t, true, true)
_, store := datastore.MustNewTestStore(t, false, true)
teamBuilder := teamBuilder{
t: t,
-28
View File
@@ -1,28 +0,0 @@
package tests
import (
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/datastore"
"github.com/stretchr/testify/assert"
)
type teamBuilder struct {
t *testing.T
count int
store *datastore.Store
}
func (b *teamBuilder) createNew(name string) *portainer.Team {
b.count++
team := &portainer.Team{
ID: portainer.TeamID(b.count),
Name: name,
}
err := b.store.Team().Create(team)
assert.NoError(b.t, err)
return team
}
+12
View File
@@ -36,6 +36,18 @@ func (service ServiceTx) UserByUsername(username string) (*portainer.User, error
return nil, err
}
func (service ServiceTx) UserIDByUsername(username string) (portainer.UserID, error) {
user, err := service.UserByUsername(username)
if err != nil {
return 0, err
}
if user == nil {
return 0, dserrors.ErrObjectNotFound
}
return user.ID, nil
}
// UsersByRole return an array containing all the users with the specified role.
func (service ServiceTx) UsersByRole(role portainer.UserRole) ([]portainer.User, error) {
var users = make([]portainer.User, 0)
+12
View File
@@ -65,6 +65,18 @@ func (service *Service) UserByUsername(username string) (*portainer.User, error)
return nil, err
}
func (service *Service) UserIDByUsername(username string) (portainer.UserID, error) {
user, err := service.UserByUsername(username)
if err != nil {
return 0, err
}
if user == nil {
return 0, dserrors.ErrObjectNotFound
}
return user.ID, nil
}
// UsersByRole return an array containing all the users with the specified role.
func (service *Service) UsersByRole(role portainer.UserRole) ([]portainer.User, error) {
var users = make([]portainer.User, 0)
+30 -20
View File
@@ -14,33 +14,40 @@ import (
// corruption and if a path is not given a default is used.
// The path or an error are returned.
func (store *Store) Backup(path string) (string, error) {
if err := store.Close(); err != nil {
return "", fmt.Errorf("failed to close store before backup: %w", err)
}
filename, err := store.backupDBFile(path)
if err != nil {
return "", err
}
if _, err := store.Open(); err != nil {
return "", fmt.Errorf("failed to reopen store after backup: %w", err)
}
return filename, nil
}
// backupDBFile copies the database file to the backup location.
// Does not manage connection state - works with the database file directly regardless of connection state.
func (store *Store) backupDBFile(backupPath string) (string, error) {
if err := store.createBackupPath(); err != nil {
return "", err
}
backupFilename := store.backupFilename()
if path != "" {
backupFilename = path
}
log.Info().Str("from", store.connection.GetDatabaseFilePath()).Str("to", backupFilename).Msgf("Backing up database")
// Close the store before backing up
err := store.Close()
if err != nil {
return "", fmt.Errorf("failed to close store before backup: %w", err)
if backupPath != "" {
backupFilename = backupPath
}
err = store.fileService.Copy(store.connection.GetDatabaseFilePath(), backupFilename, true)
if err != nil {
log.Info().Str("from", store.connection.GetDatabaseFilePath()).Str("to", backupFilename).Msg("Backing up database")
if err := store.fileService.Copy(store.connection.GetDatabaseFilePath(), backupFilename, true); err != nil {
return "", fmt.Errorf("failed to create backup file: %w", err)
}
// reopen the store
_, err = store.Open()
if err != nil {
return "", fmt.Errorf("failed to reopen store after backup: %w", err)
}
return backupFilename, nil
}
@@ -50,15 +57,17 @@ func (store *Store) Restore() error {
}
func (store *Store) RestoreFromFile(backupFilename string) error {
store.Close()
if err := store.Close(); err != nil {
return err
}
if err := store.fileService.Copy(backupFilename, store.connection.GetDatabaseFilePath(), true); err != nil {
return fmt.Errorf("unable to restore backup file %q. err: %w", backupFilename, err)
}
log.Info().Str("from", backupFilename).Str("to", store.connection.GetDatabaseFilePath()).Msgf("database restored")
_, err := store.Open()
if err != nil {
if _, err := store.Open(); err != nil {
return fmt.Errorf("unable to determine version of restored portainer backup file: %w", err)
}
@@ -80,6 +89,7 @@ func (store *Store) createBackupPath() error {
return fmt.Errorf("unable to create backup folder: %w", err)
}
}
return nil
}
+73 -6
View File
@@ -1,9 +1,11 @@
package datastore
import (
"os"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/database/boltdb"
"github.com/portainer/portainer/api/database/models"
"github.com/stretchr/testify/require"
@@ -11,6 +13,7 @@ import (
)
func TestStoreCreation(t *testing.T) {
t.Parallel()
_, store := MustNewTestStore(t, true, true)
require.NotNil(t, store)
@@ -29,6 +32,7 @@ func TestStoreCreation(t *testing.T) {
}
func TestBackup(t *testing.T) {
t.Parallel()
_, store := MustNewTestStore(t, true, true)
backupFileName := store.backupFilename()
t.Run("Backup should create "+backupFileName, func(t *testing.T) {
@@ -36,8 +40,12 @@ func TestBackup(t *testing.T) {
Edition: int(portainer.PortainerCE),
SchemaVersion: portainer.APIVersion,
}
store.VersionService.UpdateVersion(&v)
store.Backup("")
err := store.VersionService.UpdateVersion(&v)
require.NoError(t, err)
_, err = store.Backup("")
require.NoError(t, err)
if !isFileExist(backupFileName) {
t.Errorf("Expect backup file to be created %s", backupFileName)
@@ -46,6 +54,7 @@ func TestBackup(t *testing.T) {
}
func TestRestore(t *testing.T) {
t.Parallel()
_, store := MustNewTestStore(t, true, false)
t.Run("Basic Restore", func(t *testing.T) {
@@ -53,10 +62,14 @@ func TestRestore(t *testing.T) {
updateEdition(store, portainer.PortainerCE)
updateVersion(store, "2.4")
store.Backup("")
_, err := store.Backup("")
require.NoError(t, err)
updateVersion(store, "2.16")
testVersion(store, "2.16", t)
store.Restore()
err = store.Restore()
require.NoError(t, err)
// check if the restore is successful and the version is correct
testVersion(store, "2.4", t)
@@ -66,13 +79,67 @@ func TestRestore(t *testing.T) {
// override and set initial db version and edition
updateEdition(store, portainer.PortainerCE)
updateVersion(store, "2.4")
store.Backup("")
_, err := store.Backup("")
require.NoError(t, err)
updateVersion(store, "2.14")
updateVersion(store, "2.16")
testVersion(store, "2.16", t)
store.Restore()
err = store.Restore()
require.NoError(t, err)
// check if the restore is successful and the version is correct
testVersion(store, "2.4", t)
})
}
func TestBackupDBFile(t *testing.T) {
t.Parallel()
_, store := MustNewTestStore(t, true, false)
t.Run("creates backup file without managing connection state", func(t *testing.T) {
// Verify connection is usable before
_, err := store.VersionService.Version()
require.NoError(t, err, "connection should be usable before backupDBFile")
// backupDBFile should work without closing the connection
backupFilename, err := store.backupDBFile("")
require.NoError(t, err)
require.FileExists(t, backupFilename)
// Verify connection is still usable after (not closed/reopened)
_, err = store.VersionService.Version()
require.NoError(t, err, "connection should still be usable after backupDBFile")
require.NoError(t, os.Remove(backupFilename))
})
t.Run("uses custom path when provided", func(t *testing.T) {
customPath := t.TempDir() + "/custom-backup.db"
backupFilename, err := store.backupDBFile(customPath)
require.NoError(t, err)
require.Equal(t, customPath, backupFilename)
require.FileExists(t, backupFilename)
})
}
func TestBackupDBFileUsesCorrectPath(t *testing.T) {
t.Parallel()
_, store := MustNewTestStore(t, true, false)
t.Run("backs up unencrypted db when encrypted flag is false", func(t *testing.T) {
store.connection.SetEncrypted(false)
backupFilename, err := store.backupDBFile("")
require.NoError(t, err)
require.FileExists(t, backupFilename)
// Verify it backed up the unencrypted file (portainer.db)
require.Contains(t, backupFilename, boltdb.DatabaseFileName)
require.NotContains(t, backupFilename, boltdb.EncryptedDatabaseFileName)
require.NoError(t, os.Remove(backupFilename))
})
}
+34 -42
View File
@@ -32,34 +32,38 @@ func (store *Store) Open() (newStore bool, err error) {
}
if encryptionReq {
backupFilename, err := store.Backup("")
// 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)
// Use backupDBFile directly since connection isn't open yet
// and we don't want to trigger the close/open cycle of Backup()
backupFilename, err := store.backupDBFile("")
if err != nil {
return false, fmt.Errorf("failed to backup database prior to encrypting: %w", err)
}
err = store.encryptDB()
if err != nil {
store.RestoreFromFile(backupFilename) // restore from backup if encryption fails
return false, err
if err := store.encryptDB(); err != nil {
innerErr := store.RestoreFromFile(backupFilename) // restore from backup if encryption fails
return false, errors.Join(err, innerErr)
}
}
err = store.connection.Open()
if err != nil {
if err := store.connection.Open(); err != nil {
return false, err
}
err = store.initServices()
if err != nil {
if err := store.initServices(); err != nil {
return false, err
}
// If no settings object exists then assume we have a new store
_, err = store.SettingsService.Settings()
if err != nil {
if _, err := store.SettingsService.Settings(); err != nil {
if store.IsErrObjectNotFound(err) {
return true, nil
}
return false, err
}
@@ -72,19 +76,13 @@ func (store *Store) Close() error {
func (store *Store) UpdateTx(fn func(dataservices.DataStoreTx) error) error {
return store.connection.UpdateTx(func(tx portainer.Transaction) error {
return fn(&StoreTx{
store: store,
tx: tx,
})
return fn(&StoreTx{store: store, tx: tx})
})
}
func (store *Store) ViewTx(fn func(dataservices.DataStoreTx) error) error {
return store.connection.ViewTx(func(tx portainer.Transaction) error {
return fn(&StoreTx{
store: store,
tx: tx,
})
return fn(&StoreTx{store: store, tx: tx})
})
}
@@ -99,6 +97,7 @@ func (store *Store) CheckCurrentEdition() error {
if store.edition() != portainer.Edition {
return portainerErrors.ErrWrongDBEdition
}
return nil
}
@@ -107,6 +106,7 @@ func (store *Store) edition() portainer.SoftwareEdition {
if store.IsErrObjectNotFound(err) {
edition = portainer.PortainerCE
}
return edition
}
@@ -125,13 +125,11 @@ func (store *Store) Rollback(force bool) error {
func (store *Store) encryptDB() error {
store.connection.SetEncrypted(false)
err := store.connection.Open()
if err != nil {
if err := store.connection.Open(); err != nil {
return err
}
err = store.initServices()
if err != nil {
if err := store.initServices(); err != nil {
return err
}
@@ -144,8 +142,7 @@ func (store *Store) encryptDB() error {
log.Info().Str("filename", exportFilename).Msg("exporting database backup")
err = store.Export(exportFilename)
if err != nil {
if err := store.Export(exportFilename); err != nil {
log.Error().Str("filename", exportFilename).Err(err).Msg("failed to export")
return err
@@ -154,38 +151,33 @@ func (store *Store) encryptDB() error {
log.Info().Msg("database backup exported")
// Close existing un-encrypted db so that we can delete the file later
store.connection.Close()
// Tell the db layer to create an encrypted db when opened
store.connection.SetEncrypted(true)
store.connection.Open()
// We have to init services before import
err = store.initServices()
if err != nil {
if err := store.connection.Close(); err != nil {
return err
}
err = store.Import(exportFilename)
if err != nil {
if err := store.Import(exportFilename); err != nil {
log.Error().Err(err).Msg("failed to import database backup")
// Remove the new encrypted file that we failed to import
os.Remove(store.connection.GetDatabaseFilePath())
if err := os.Remove(store.connection.GetDatabaseFilePath()); err != nil {
log.Error().Msg("failed to remove the file after import failure")
}
log.Fatal().Err(portainerErrors.ErrDBImportFailed).Msg("")
}
err = os.Remove(oldFilename)
if err != nil {
if err := os.Remove(oldFilename); err != nil {
log.Error().Msg("failed to remove the un-encrypted db file")
}
err = os.Remove(exportFilename)
if err != nil {
if err := os.Remove(exportFilename); err != nil {
log.Error().Msg("failed to remove the json backup file")
}
// Close db connection
store.connection.Close()
if err := store.connection.Close(); err != nil {
return err
}
log.Info().Msg("database successfully encrypted")
+15 -20
View File
@@ -29,6 +29,7 @@ const (
// TestStoreFull an eventually comprehensive set of tests for the Store.
// The idea is what we write to the store, we should read back.
func TestStoreFull(t *testing.T) {
t.Parallel()
_, store := MustNewTestStore(t, true, true)
testCases := map[string]func(t *testing.T){
@@ -51,13 +52,13 @@ func TestStoreFull(t *testing.T) {
func (store *Store) testEnvironments(t *testing.T) {
id := store.CreateEndpoint(t, "local", portainer.KubernetesLocalEnvironment, "", true)
store.CreateEndpointRelation(id)
store.CreateEndpointRelation(t, id)
id = store.CreateEndpoint(t, "agent", portainer.AgentOnDockerEnvironment, agentOnDockerEnvironmentUrl, true)
store.CreateEndpointRelation(id)
store.CreateEndpointRelation(t, id)
id = store.CreateEndpoint(t, "edge", portainer.EdgeAgentOnKubernetesEnvironment, edgeAgentOnKubernetesEnvironmentUrl, true)
store.CreateEndpointRelation(id)
store.CreateEndpointRelation(t, id)
}
func newEndpoint(endpointType portainer.EndpointType, id portainer.EndpointID, name, URL string, TLS bool) *portainer.Endpoint {
@@ -90,18 +91,7 @@ func newEndpoint(endpointType portainer.EndpointType, id portainer.EndpointID, n
}
func setEndpointAuthorizations(endpoint *portainer.Endpoint) {
endpoint.SecuritySettings = portainer.EndpointSecuritySettings{
AllowVolumeBrowserForRegularUsers: false,
EnableHostManagementFeatures: false,
AllowSysctlSettingForRegularUsers: true,
AllowBindMountsForRegularUsers: true,
AllowPrivilegedModeForRegularUsers: true,
AllowHostNamespaceForRegularUsers: true,
AllowContainerCapabilitiesForRegularUsers: true,
AllowDeviceMappingForRegularUsers: true,
AllowStackManagementForRegularUsers: true,
}
endpoint.SecuritySettings = portainer.DefaultEndpointSecuritySettings()
}
func (store *Store) CreateEndpoint(t *testing.T, name string, endpointType portainer.EndpointType, URL string, tls bool) portainer.EndpointID {
@@ -142,7 +132,9 @@ func (store *Store) CreateEndpoint(t *testing.T, name string, endpointType porta
}
setEndpointAuthorizations(expectedEndpoint)
store.Endpoint().Create(expectedEndpoint)
err := store.Endpoint().Create(expectedEndpoint)
require.NoError(t, err)
endpoint, err := store.Endpoint().Endpoint(id)
require.NoError(t, err, "Endpoint() should not return an error")
@@ -151,13 +143,14 @@ func (store *Store) CreateEndpoint(t *testing.T, name string, endpointType porta
return endpoint.ID
}
func (store *Store) CreateEndpointRelation(id portainer.EndpointID) {
func (store *Store) CreateEndpointRelation(t *testing.T, id portainer.EndpointID) {
relation := &portainer.EndpointRelation{
EndpointID: id,
EdgeStacks: map[portainer.EdgeStackID]bool{},
}
store.EndpointRelation().Create(relation)
err := store.EndpointRelation().Create(relation)
require.NoError(t, err)
}
func (store *Store) testSSLSettings(t *testing.T) {
@@ -169,7 +162,8 @@ func (store *Store) testSSLSettings(t *testing.T) {
SelfSigned: true,
}
store.SSLSettings().UpdateSettings(ssl)
err := store.SSLSettings().UpdateSettings(ssl)
require.NoError(t, err)
settings, err := store.SSLSettings().Settings()
require.NoError(t, err, "Get sslsettings should succeed")
@@ -282,7 +276,8 @@ func (store *Store) testCustomTemplates(t *testing.T) {
CreatedByUserID: 10,
}
customTemplate.Create(expectedTemplate)
err := customTemplate.Create(expectedTemplate)
require.NoError(t, err)
actualTemplate, err := customTemplate.Read(expectedTemplate.ID)
require.NoError(t, err, "CustomTemplate should not return an error")
+1 -1
View File
@@ -31,7 +31,6 @@ func (store *Store) checkOrCreateDefaultSettings() error {
settings, err := store.SettingsService.Settings()
if store.IsErrObjectNotFound(err) {
defaultSettings := &portainer.Settings{
EnableTelemetry: false,
AuthenticationMethod: portainer.AuthenticationInternal,
BlackListedLabels: make([]portainer.Pair, 0),
InternalAuthSettings: portainer.InternalAuthSettings{
@@ -60,6 +59,7 @@ func (store *Store) checkOrCreateDefaultSettings() error {
KubectlShellImage: *store.flags.KubectlShellImage,
IsDockerDesktopExtension: isDDExtention,
EnforceEdgeID: true,
}
return store.SettingsService.UpdateSettings(defaultSettings)
+64 -36
View File
@@ -6,17 +6,18 @@ import (
"fmt"
"io"
"os"
"path/filepath"
"testing"
"github.com/Masterminds/semver"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/database/boltdb"
"github.com/portainer/portainer/api/database/models"
"github.com/portainer/portainer/api/datastore/migrator"
"github.com/portainer/portainer/api/filesystem"
"github.com/Masterminds/semver/v3"
"github.com/google/go-cmp/cmp"
"github.com/rs/zerolog/log"
"github.com/stretchr/testify/require"
)
func TestMigrateData(t *testing.T) {
@@ -53,9 +54,11 @@ func TestMigrateData(t *testing.T) {
}
testVersion(store, portainer.APIVersion, t)
store.Close()
err := store.Close()
require.NoError(t, err)
newStore, _ = store.Open()
newStore, err = store.Open()
require.NoError(t, err)
if newStore {
t.Error("Expect store to NOT be new DB")
}
@@ -63,8 +66,11 @@ func TestMigrateData(t *testing.T) {
t.Run("MigrateData should create backup file upon update", func(t *testing.T) {
_, store := MustNewTestStore(t, true, false)
store.VersionService.UpdateVersion(&models.Version{SchemaVersion: "1.0", Edition: int(portainer.PortainerCE)})
store.MigrateData()
err := store.VersionService.UpdateVersion(&models.Version{SchemaVersion: "2.0", Edition: int(portainer.PortainerCE)})
require.NoError(t, err)
err = store.MigrateData()
require.NoError(t, err)
backupfilename := store.backupFilename()
if exists, _ := store.fileService.FileExists(backupfilename); !exists {
@@ -73,21 +79,28 @@ func TestMigrateData(t *testing.T) {
})
t.Run("MigrateData should recover and restore backup during migration critical failure", func(t *testing.T) {
os.Setenv("PORTAINER_TEST_MIGRATE_FAIL", "FAIL")
t.Setenv("PORTAINER_TEST_MIGRATE_FAIL", "FAIL")
version := "2.15"
_, store := MustNewTestStore(t, true, false)
store.VersionService.UpdateVersion(&models.Version{SchemaVersion: version, Edition: int(portainer.PortainerCE)})
store.MigrateData()
store.Open()
err := store.VersionService.UpdateVersion(&models.Version{SchemaVersion: version, Edition: int(portainer.PortainerCE)})
require.NoError(t, err)
err = store.MigrateData()
require.Error(t, err)
testVersion(store, version, t)
})
t.Run("MigrateData should fail to create backup if database file is set to updating", func(t *testing.T) {
_, store := MustNewTestStore(t, true, false)
store.VersionService.StoreIsUpdating(true)
store.MigrateData()
err := store.VersionService.StoreIsUpdating(true)
require.NoError(t, err)
err = store.MigrateData()
require.Error(t, err)
// If you get an error, it usually means that the backup folder doesn't exist (no backups). Expected!
// If the backup file is not blank, then it means a backup was created. We don't want that because we
@@ -115,10 +128,12 @@ func TestMigrateData(t *testing.T) {
if latestMigrations.Version.Equal(semver.MustParse(portainer.APIVersion)) {
v.MigratorCount = len(latestMigrations.MigrationFuncs)
store.VersionService.UpdateVersion(v)
err = store.VersionService.UpdateVersion(v)
require.NoError(t, err)
}
store.MigrateData()
err = store.MigrateData()
require.NoError(t, err)
// If you get an error, it usually means that the backup folder doesn't exist (no backups). Expected!
// If the backup file is not blank, then it means a backup was created. We don't want that because we
@@ -141,8 +156,12 @@ func TestMigrateData(t *testing.T) {
}
v.MigratorCount = 1000
store.VersionService.UpdateVersion(v)
store.MigrateData()
err = store.VersionService.UpdateVersion(v)
require.NoError(t, err)
err = store.MigrateData()
require.NoError(t, err)
// If you get an error, it usually means that the backup folder doesn't exist (no backups). Expected!
// If the backup file is not blank, then it means a backup was created. We don't want that because we
@@ -155,17 +174,18 @@ func TestMigrateData(t *testing.T) {
}
func TestRollback(t *testing.T) {
t.Parallel()
t.Run("Rollback should restore upgrade after backup", func(t *testing.T) {
version := "2.11"
v := models.Version{
SchemaVersion: version,
}
v := models.Version{SchemaVersion: version}
_, store := MustNewTestStore(t, false, false)
store.VersionService.UpdateVersion(&v)
_, err := store.Backup("")
err := store.VersionService.UpdateVersion(&v)
require.NoError(t, err)
_, err = store.Backup("")
if err != nil {
log.Fatal().Err(err).Msg("")
}
@@ -184,7 +204,9 @@ func TestRollback(t *testing.T) {
return
}
store.Open()
_, err = store.Open()
require.NoError(t, err)
testVersion(store, version, t)
})
@@ -197,9 +219,11 @@ func TestRollback(t *testing.T) {
}
_, store := MustNewTestStore(t, true, false)
store.VersionService.UpdateVersion(&v)
_, err := store.Backup("")
err := store.VersionService.UpdateVersion(&v)
require.NoError(t, err)
_, err = store.Backup("")
if err != nil {
log.Fatal().Err(err).Msg("")
}
@@ -218,7 +242,8 @@ func TestRollback(t *testing.T) {
return
}
store.Open()
_, err = store.Open()
require.NoError(t, err)
testVersion(store, version, t)
})
}
@@ -237,17 +262,17 @@ func migrateDBTestHelper(t *testing.T, srcPath, wantPath string, overrideInstanc
_, store := MustNewTestStore(t, true, false)
fmt.Println("store.path=", store.GetConnection().GetDatabaseFilePath())
store.connection.DeleteObject("version", []byte("VERSION"))
err = store.connection.DeleteObject("version", []byte("VERSION"))
require.NoError(t, err)
// defer teardown()
err = importJSON(t, bytes.NewReader(srcJSON), store)
if err != nil {
if err := importJSON(t, bytes.NewReader(srcJSON), store); err != nil {
return err
}
// Run the actual migrations on our input database.
err = store.MigrateData()
if err != nil {
if err := store.MigrateData(); err != nil {
return err
}
@@ -260,8 +285,7 @@ func migrateDBTestHelper(t *testing.T, srcPath, wantPath string, overrideInstanc
}
v.InstanceID = "463d5c47-0ea5-4aca-85b1-405ceefee254"
err = store.VersionService.UpdateVersion(v)
if err != nil {
if err := store.VersionService.UpdateVersion(v); err != nil {
return err
}
}
@@ -270,10 +294,10 @@ func migrateDBTestHelper(t *testing.T, srcPath, wantPath string, overrideInstanc
// exportJson rather than ExportRaw. The exportJson function allows us to
// strip out the metadata which we don't want for our tests.
// TODO: update connection interface in CE to allow us to use ExportRaw and pass meta false
err = store.connection.Close()
if err != nil {
if err := store.connection.Close(); err != nil {
t.Fatalf("err closing bolt connection: %v", err)
}
con, ok := store.connection.(*boltdb.DbConnection)
if !ok {
t.Fatalf("backing database is not using boltdb, but the migrations test requires it")
@@ -301,12 +325,16 @@ func migrateDBTestHelper(t *testing.T, srcPath, wantPath string, overrideInstanc
// Compare the result we got with the one we wanted.
if diff := cmp.Diff(wantJSON, gotJSON); diff != "" {
gotPath := filepath.Join(os.TempDir(), "portainer-migrator-test-fail.json")
os.WriteFile(
gotPath := filesystem.JoinPaths(os.TempDir(), "portainer-migrator-test-fail.json")
err = os.WriteFile(
gotPath,
gotJSON,
0o600,
)
if err != nil {
log.Warn().Err(err).Msg("failed writing migrated output to temp file")
}
t.Errorf(
"migrate data from %s to %s failed\nwrote migrated input to %s\nmismatch (-want +got):\n%s",
srcPath,
@@ -26,6 +26,7 @@ func setup(store *Store) error {
}
func TestMigrateSettings(t *testing.T) {
t.Parallel()
_, store := MustNewTestStore(t, false, true)
err := setup(store)
@@ -12,6 +12,7 @@ import (
)
func TestMigrateStackEntryPoint(t *testing.T) {
t.Parallel()
_, store := MustNewTestStore(t, false, true)
stackService := store.Stack()
+11 -5
View File
@@ -105,12 +105,18 @@ func (store *Store) getOrMigrateLegacyVersion() (*models.Version, error) {
// finishMigrateLegacyVersion writes the new version to the DB and removes the old version keys from the DB
func (store *Store) finishMigrateLegacyVersion(versionToWrite *models.Version) error {
err := store.VersionService.UpdateVersion(versionToWrite)
if err := store.VersionService.UpdateVersion(versionToWrite); err != nil {
return err
}
// Remove legacy keys if present
store.connection.DeleteObject(bucketName, []byte(legacyDBVersionKey))
store.connection.DeleteObject(bucketName, []byte(legacyEditionKey))
store.connection.DeleteObject(bucketName, []byte(legacyInstanceKey))
if err := store.connection.DeleteObject(bucketName, []byte(legacyDBVersionKey)); err != nil {
return err
}
return err
if err := store.connection.DeleteObject(bucketName, []byte(legacyEditionKey)); err != nil {
return err
}
return store.connection.DeleteObject(bucketName, []byte(legacyInstanceKey))
}
+3 -1
View File
@@ -6,16 +6,18 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/database/boltdb"
"github.com/portainer/portainer/api/dataservices/edgegroup"
"github.com/portainer/portainer/api/logs"
"github.com/stretchr/testify/require"
)
func TestMigrateEdgeGroupEndpointsToRoars_2_33_0Idempotency(t *testing.T) {
t.Parallel()
var conn portainer.Connection = &boltdb.DbConnection{Path: t.TempDir()}
err := conn.Open()
require.NoError(t, err)
defer conn.Close()
defer logs.CloseAndLogErr(conn)
edgeGroupService, err := edgegroup.NewService(conn)
require.NoError(t, err)
+206
View File
@@ -0,0 +1,206 @@
package migrator
import (
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/database/boltdb"
"github.com/portainer/portainer/api/dataservices/endpoint"
"github.com/portainer/portainer/api/dataservices/pendingactions"
"github.com/portainer/portainer/api/dataservices/registry"
"github.com/portainer/portainer/api/logs"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMigrateRegistryAccessSASecrets_2_40_0(t *testing.T) {
t.Parallel()
var conn portainer.Connection = &boltdb.DbConnection{Path: t.TempDir()}
err := conn.Open()
require.NoError(t, err)
defer logs.CloseAndLogErr(conn)
registryService, err := registry.NewService(conn)
require.NoError(t, err)
endpointService, err := endpoint.NewService(conn)
require.NoError(t, err)
pendingActionsService, err := pendingactions.NewService(conn)
require.NoError(t, err)
t.Run("sets MigrateRegistrySASecrets flag for k8s endpoints with registry access", func(t *testing.T) {
k8sEndpoint := &portainer.Endpoint{
ID: 1,
Name: "k8s-cluster",
Type: portainer.AgentOnKubernetesEnvironment,
}
dockerEndpoint := &portainer.Endpoint{
ID: 2,
Name: "docker-standalone",
Type: portainer.DockerEnvironment,
}
err := conn.CreateObjectWithId(endpoint.BucketName, int(k8sEndpoint.ID), k8sEndpoint)
require.NoError(t, err)
err = conn.CreateObjectWithId(endpoint.BucketName, int(dockerEndpoint.ID), dockerEndpoint)
require.NoError(t, err)
reg := &portainer.Registry{
ID: 1,
Name: "test-registry",
RegistryAccesses: portainer.RegistryAccesses{
k8sEndpoint.ID: portainer.RegistryAccessPolicies{
Namespaces: []string{"default", "production"},
},
dockerEndpoint.ID: portainer.RegistryAccessPolicies{
Namespaces: []string{"ignored"},
},
},
}
err = conn.CreateObjectWithId(registry.BucketName, int(reg.ID), reg)
require.NoError(t, err)
m := NewMigrator(&MigratorParameters{
RegistryService: registryService,
EndpointService: endpointService,
PendingActionsService: pendingActionsService,
})
err = m.migrateRegistryAccessSASecrets_2_40_0()
require.NoError(t, err)
updatedK8sEndpoint, err := endpointService.Endpoint(k8sEndpoint.ID)
require.NoError(t, err)
assert.True(t, updatedK8sEndpoint.PostInitMigrations.MigrateRegistrySASecrets, "should have set MigrateRegistrySASecrets flag for k8s endpoint")
updatedDockerEndpoint, err := endpointService.Endpoint(dockerEndpoint.ID)
require.NoError(t, err)
assert.False(t, updatedDockerEndpoint.PostInitMigrations.MigrateRegistrySASecrets, "should not have set MigrateRegistrySASecrets flag for docker endpoint")
})
t.Run("skips endpoints with empty namespaces", func(t *testing.T) {
conn2 := &boltdb.DbConnection{Path: t.TempDir()}
err := conn2.Open()
require.NoError(t, err)
defer logs.CloseAndLogErr(conn2)
registryService2, _ := registry.NewService(conn2)
endpointService2, _ := endpoint.NewService(conn2)
pendingActionsService2, _ := pendingactions.NewService(conn2)
k8sEndpoint := &portainer.Endpoint{
ID: 10,
Name: "k8s-cluster",
Type: portainer.AgentOnKubernetesEnvironment,
}
err = conn2.CreateObjectWithId(endpoint.BucketName, int(k8sEndpoint.ID), k8sEndpoint)
require.NoError(t, err)
reg := &portainer.Registry{
ID: 10,
Name: "empty-registry",
RegistryAccesses: portainer.RegistryAccesses{
k8sEndpoint.ID: portainer.RegistryAccessPolicies{
Namespaces: []string{},
},
},
}
err = conn2.CreateObjectWithId(registry.BucketName, int(reg.ID), reg)
require.NoError(t, err)
m := NewMigrator(&MigratorParameters{
RegistryService: registryService2,
EndpointService: endpointService2,
PendingActionsService: pendingActionsService2,
})
err = m.migrateRegistryAccessSASecrets_2_40_0()
require.NoError(t, err)
allPAs, err := pendingActionsService2.ReadAll()
require.NoError(t, err)
assert.Empty(t, allPAs, "should not create pending actions for empty namespaces")
})
t.Run("skips non-existent endpoints", func(t *testing.T) {
conn3 := &boltdb.DbConnection{Path: t.TempDir()}
err := conn3.Open()
require.NoError(t, err)
defer logs.CloseAndLogErr(conn3)
registryService3, _ := registry.NewService(conn3)
endpointService3, _ := endpoint.NewService(conn3)
pendingActionsService3, _ := pendingactions.NewService(conn3)
reg := &portainer.Registry{
ID: 20,
Name: "orphan-registry",
RegistryAccesses: portainer.RegistryAccesses{
999: portainer.RegistryAccessPolicies{
Namespaces: []string{"default"},
},
},
}
err = conn3.CreateObjectWithId(registry.BucketName, int(reg.ID), reg)
require.NoError(t, err)
m := NewMigrator(&MigratorParameters{
RegistryService: registryService3,
EndpointService: endpointService3,
PendingActionsService: pendingActionsService3,
})
err = m.migrateRegistryAccessSASecrets_2_40_0()
require.NoError(t, err)
allPAs, err := pendingActionsService3.ReadAll()
require.NoError(t, err)
assert.Empty(t, allPAs, "should not create pending actions for non-existent endpoints")
})
t.Run("idempotent - running twice creates duplicate actions but doesn't error", func(t *testing.T) {
conn4 := &boltdb.DbConnection{Path: t.TempDir()}
err := conn4.Open()
require.NoError(t, err)
defer logs.CloseAndLogErr(conn4)
registryService4, _ := registry.NewService(conn4)
endpointService4, _ := endpoint.NewService(conn4)
pendingActionsService4, _ := pendingactions.NewService(conn4)
k8sEndpoint := &portainer.Endpoint{
ID: 30,
Name: "k8s-cluster",
Type: portainer.AgentOnKubernetesEnvironment,
}
err = conn4.CreateObjectWithId(endpoint.BucketName, int(k8sEndpoint.ID), k8sEndpoint)
require.NoError(t, err)
reg := &portainer.Registry{
ID: 30,
Name: "test-registry",
RegistryAccesses: portainer.RegistryAccesses{
k8sEndpoint.ID: portainer.RegistryAccessPolicies{
Namespaces: []string{"default"},
},
},
}
err = conn4.CreateObjectWithId(registry.BucketName, int(reg.ID), reg)
require.NoError(t, err)
m := NewMigrator(&MigratorParameters{
RegistryService: registryService4,
EndpointService: endpointService4,
PendingActionsService: pendingActionsService4,
})
err = m.migrateRegistryAccessSASecrets_2_40_0()
require.NoError(t, err)
err = m.migrateRegistryAccessSASecrets_2_40_0()
require.NoError(t, err)
})
}
+2 -2
View File
@@ -7,7 +7,7 @@ import (
"github.com/pkg/errors"
portainer "github.com/portainer/portainer/api"
"github.com/Masterminds/semver"
"github.com/Masterminds/semver/v3"
"github.com/rs/zerolog/log"
)
@@ -95,7 +95,7 @@ func (m *Migrator) NeedsMigration() bool {
// In this particular instance we should log a fatal error
if m.CurrentDBEdition() != portainer.PortainerCE {
log.Fatal().Msg("the Portainer database is set for Portainer Business Edition, please follow the instructions in our documentation to downgrade it: https://documentation.portainer.io/v2.0-be/downgrade/be-to-ce/")
log.Fatal().Msg("the Portainer database is set for Portainer Business Edition, please follow the instructions in our documentation to downgrade it: https://docs.portainer.io/faqs/upgrading/can-i-downgrade-from-portainer-business-to-portainer-ce")
return false
}
@@ -0,0 +1,58 @@
package migrator
import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/internal/endpointutils"
"github.com/rs/zerolog/log"
)
// migrateRegistryAccessSASecrets_2_40_0 marks Kubernetes endpoints that have
// registry access configured so that imagePullSecrets can be added to their
// default ServiceAccounts during the post-init migration phase (when cluster
// access is available).
func (m *Migrator) migrateRegistryAccessSASecrets_2_40_0() error {
log.Info().Msg("migrating registry access service account secrets")
registries, err := m.registryService.ReadAll()
if err != nil {
return err
}
endpoints, err := m.endpointService.Endpoints()
if err != nil {
return err
}
// Collect the IDs of endpoints that have at least one registry with
// non-empty namespace access - these need the SA imagePullSecrets migration.
needsMigration := make(map[portainer.EndpointID]bool)
for _, registry := range registries {
for endpointID, access := range registry.RegistryAccesses {
if len(access.Namespaces) > 0 {
needsMigration[endpointID] = true
}
}
}
for i := range endpoints {
endpoint := &endpoints[i]
if !endpointutils.IsKubernetesEndpoint(endpoint) {
continue
}
if !needsMigration[endpoint.ID] {
continue
}
endpoint.PostInitMigrations.MigrateRegistrySASecrets = true
if err := m.endpointService.UpdateEndpoint(endpoint.ID, endpoint); err != nil {
log.Warn().
Err(err).
Int("endpointID", int(endpoint.ID)).
Msg("failed to set registry SA secret migration flag for endpoint")
}
}
return nil
}
@@ -21,7 +21,6 @@ func (m *Migrator) updateSettingsToDB25() error {
}
legacySettings.UserSessionTimeout = portainer.DefaultUserSessionTimeout
legacySettings.EnableTelemetry = true
legacySettings.AllowContainerCapabilitiesForRegularUsers = true

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