Compare commits

..

195 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
1479 changed files with 51732 additions and 16290 deletions
-3
View File
@@ -1,3 +0,0 @@
node_modules/
dist/
test/
-162
View File
@@ -1,162 +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
'jsx-a11y/control-has-associated-label': off
'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.*
plugins:
- '@vitest'
extends:
- 'plugin:@vitest/legacy-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
'@vitest/no-conditional-expect': warn
'max-classes-per-file': off
- files:
- app/**/*.stories.*
rules:
'no-alert': off
'@typescript-eslint/no-restricted-imports': off
no-restricted-imports: off
'react/jsx-props-no-spreading': off
'storybook/no-renderer-packages': off
+6 -33
View File
@@ -94,6 +94,10 @@ body:
description: We only provide support for current versions of Portainer as per the lifecycle policy linked above. If you are on an older version of Portainer we recommend [updating first](https://docs.portainer.io/start/upgrade) in case your bug has already been fixed.
multiple: false
options:
- '2.41.1'
- '2.41.0'
- '2.40.0'
- '2.39.2'
- '2.39.1'
- '2.39.0'
- '2.38.1'
@@ -102,6 +106,7 @@ body:
- '2.36.0'
- '2.35.0'
- '2.34.0'
- '2.33.8'
- '2.33.7'
- '2.33.6'
- '2.33.5'
@@ -110,39 +115,7 @@ body:
- '2.33.2'
- '2.33.1'
- '2.33.0'
- '2.32.0'
- '2.31.3'
- '2.31.2'
- '2.31.1'
- '2.31.0'
- '2.30.1'
- '2.30.0'
- '2.29.2'
- '2.29.1'
- '2.29.0'
- '2.28.1'
- '2.28.0'
- '2.27.9'
- '2.27.8'
- '2.27.7'
- '2.27.6'
- '2.27.5'
- '2.27.4'
- '2.27.3'
- '2.27.2'
- '2.27.1'
- '2.27.0'
- '2.26.1'
- '2.26.0'
- '2.25.1'
- '2.25.0'
- '2.24.1'
- '2.24.0'
- '2.23.0'
- '2.22.0'
- '2.21.5'
- '2.21.4'
- '2.21.3'
validations:
required: true
+2 -5
View File
@@ -8,9 +8,6 @@ linters:
forbid:
- pattern: ^dataservices.DataStore.(EdgeGroup|EdgeJob|EdgeStack|EndpointRelation|Endpoint|GitCredential|Registry|ResourceControl|Role|Settings|Snapshot|SSLSettings|Stack|Tag|User)$
msg: Use a transaction instead
- pattern: ^(filepath|path)\.Join$
msg: Use filesystem.JoinPaths() from github.com/portainer/portainer/api/filesystem to prevent path traversal attacks
analyze-types: true
exclusions:
rules:
- path: _test\.go
linters:
- forbidigo
+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"]
}
+13 -9
View File
@@ -1,14 +1,21 @@
// 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',
{
@@ -43,6 +50,7 @@ const config: StorybookConfig = {
],
},
},
'@storybook/addon-docs',
],
webpackFinal: (config) => {
const rules = config?.module?.rules || [];
@@ -85,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,
@@ -100,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;
+44 -8
View File
@@ -1,9 +1,10 @@
import { useEffect } from 'react';
import '../app/assets/css';
import { pushStateLocationPlugin, UIRouter } from '@uirouter/react';
import { initialize as initMSW, mswLoader } from 'msw-storybook-addon';
import { handlers } from '../app/setup-tests/server-handlers';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Preview } from '@storybook/react';
import { Preview } from '@storybook/react-webpack5';
initMSW(
{
@@ -26,15 +27,50 @@ const testQueryClient = new QueryClient({
});
const preview: Preview = {
decorators: (Story) => (
<QueryClientProvider client={testQueryClient}>
<UIRouter plugins={[pushStateLocationPlugin]}>
<Story />
</UIRouter>
</QueryClientProvider>
),
globalTypes: {
theme: {
description: 'Portainer color theme',
toolbar: {
title: 'Theme',
icon: 'paintbrush',
items: [
{ value: 'light', title: 'Light', icon: 'sun' },
{ value: 'dark', title: 'Dark', icon: 'moon' },
{ value: 'highcontrast', title: 'High Contrast', icon: 'eye' },
],
dynamicTitle: true,
},
},
},
initialGlobals: {
theme: 'light',
},
decorators: (Story, context) => {
const theme = context.globals.theme;
useEffect(() => {
if (theme === 'light') {
document.documentElement.removeAttribute('theme');
} else {
document.documentElement.setAttribute('theme', theme);
}
}, [theme]);
return (
<QueryClientProvider client={testQueryClient}>
<UIRouter plugins={[pushStateLocationPlugin]}>
<Story />
</UIRouter>
</QueryClientProvider>
);
},
loaders: [mswLoader],
parameters: {
options: {
storySort: {
order: ['Design System', 'Components', '*'],
},
},
controls: {
matchers: {
color: /(background|color)$/i,
+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,
};
}
+22 -7
View File
@@ -2,11 +2,22 @@
Open-source container management platform with full Docker and Kubernetes support.
see also:
## Project Structure
- docs/guidelines/server-architecture.md
- docs/guidelines/go-conventions.md
- docs/guidelines/typescript-conventions.md
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
@@ -27,9 +38,13 @@ make dev # Run both in dev mode
make dev-client # Start webpack-dev-server (port 8999)
make dev-server # Run containerized Go server
pnpm run dev # Webpack dev server
pnpm run build # Build frontend with webpack
pnpm run test # Run frontend tests
# 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)
+15 -5
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
@@ -35,8 +36,8 @@ build-storybook: ## Build and serve the storybook files
.PHONY: deps server-deps client-deps tidy
deps: server-deps client-deps ## Download all client and server build dependancies
## This is empty because the pipeline requires it but ce has no server deps
server-deps: init-dist ## Download dependant server binaries
@./build/download_binaries.sh $(PLATFORM) $(ARCH)
client-deps: ## Install client dependencies
pnpm install
@@ -57,8 +58,10 @@ test: test-server test-client ## Run all tests
test-client: ## Run client tests
pnpm run test $(ARGS) --coverage
TEST_PACKAGES?=./...
test-server: ## Run server tests
$(GOTESTSUM) --format pkgname-and-test-fails --format-hide-empty-pkg --hide-summary skipped -- -cover -covermode=atomic -coverprofile=coverage.out ./...
$(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
@@ -105,13 +108,20 @@ dev-extension: build-server build-client ## Run the extension in development mod
##@ Docs
.PHONY: docs-build docs-validate docs-clean docs-validate-clean
docs-build: init-dist ## Build docs
go mod download -x
go mod download
cd api && $(SWAG) init -o "../dist/docs" -ot "yaml" -g ./http/handler/handler.go --parseDependency --parseInternal --parseDepth 2 -p pascalcase --markdownFiles ./
docs-validate: docs-build ## Validate docs
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
help: ## Display this help
+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")
+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)
+8
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)
@@ -197,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)
@@ -237,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)
+15 -13
View File
@@ -5,7 +5,6 @@ import (
"compress/gzip"
"os"
"os/exec"
"path"
"path/filepath"
"testing"
@@ -34,24 +33,25 @@ func listFiles(dir string) []string {
}
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)
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)
@@ -61,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)
@@ -74,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)
@@ -101,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)
@@ -113,6 +114,7 @@ func Test_shouldCreateArchive2(t *testing.T) {
}
func TestExtractTarGzPathTraversal(t *testing.T) {
t.Parallel()
testDir := t.TempDir()
// Create an evil file with a path traversal attempt
+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
}
+1
View File
@@ -5,6 +5,7 @@ import (
)
func TestParseECREndpoint(t *testing.T) {
t.Parallel()
tests := []struct {
name string
url string
+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)
}
+1
View File
@@ -6,6 +6,7 @@ import (
)
func TestGenerateGo119CompatibleKey(t *testing.T) {
t.Parallel()
type args struct {
seed string
}
+54 -26
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"
@@ -233,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()
@@ -264,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().
@@ -278,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
@@ -293,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")
}
+16
View File
@@ -9,6 +9,7 @@ import (
"time"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/internal/edge"
"github.com/portainer/portainer/api/internal/edge/cache"
"github.com/portainer/portainer/api/internal/endpointutils"
@@ -237,3 +238,18 @@ func encryptCredentials(username, password, key string) (string, error) {
return base64.RawStdEncoding.EncodeToString(encryptedCredentials), nil
}
func endpointHasSnapshot(dataStore dataservices.DataStore, endpointID portainer.EndpointID) bool {
var hasSnapshot bool
_ = dataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
s, err := tx.Snapshot().Read(endpointID)
if err != nil {
return err
}
hasSnapshot = s.Docker != nil || s.Kubernetes != nil
return nil
})
return hasSnapshot
}
+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
+13 -6
View File
@@ -94,13 +94,20 @@ func (Service) ParseFlags(version string) (*portainer.CLIFlags, error) {
flags.TLSKey = tlsKeyFlag.String()
flags.TLSCacert = kingpin.Flag("tlscacert", "Path to the CA").Default(defaultTLSCACertPath).String()
flags.KubectlShellImage = kingpin.Flag(
var hasKubectlShellImageFlag bool
kubectlShellImageFlag := kingpin.Flag(
"kubectl-shell-image",
"Kubectl shell image",
).Envar(portainer.KubectlShellImageEnvVar).Default(portainer.DefaultKubectlShellImage).String()
).Envar(portainer.KubectlShellImageEnvVar).
Default(portainer.DefaultKubectlShellImage).
IsSetByUser(&hasKubectlShellImageFlag)
flags.KubectlShellImage = kubectlShellImageFlag.String()
kingpin.Parse()
_, kubectlShellImageEnvVarSet := os.LookupEnv(portainer.KubectlShellImageEnvVar)
flags.KubectlShellImageSet = hasKubectlShellImageFlag || kubectlShellImageEnvVarSet
if !filepath.IsAbs(*flags.Assets) {
ex, err := os.Executable()
if err != nil {
@@ -152,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
}
@@ -173,7 +180,7 @@ func displayDeprecationWarnings(flags *portainer.CLIFlags) {
}
}
func validateEndpointURL(endpointURL string) error {
func ValidateEndpointURL(endpointURL string) error {
if endpointURL == "" {
return nil
}
@@ -198,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
+61 -36
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,8 +51,8 @@ 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/google/uuid"
@@ -174,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())
}
@@ -216,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
}
@@ -248,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
}
@@ -338,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)
}
@@ -350,7 +347,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
// validate if the trusted origins are valid urls
for origin := range strings.SplitSeq(*flags.TrustedOrigins, ",") {
if !validate.IsTrustedOrigin(origin) {
log.Fatal().Str("trusted_origin", origin).Msg("invalid 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)
@@ -400,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()
@@ -443,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)
@@ -461,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)
@@ -539,10 +525,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
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,
@@ -572,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,
@@ -594,7 +584,6 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
LDAPService: ldapService,
OAuthService: oauthService,
GitService: gitService,
OpenAMTService: openAMTService,
ProxyManager: proxyManager,
KubernetesTokenCacheManager: kubernetesTokenCacheManager,
KubeClusterAccessService: kubeClusterAccessService,
@@ -604,7 +593,6 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
DockerClientFactory: dockerClientFactory,
KubernetesClientFactory: kubernetesClientFactory,
Scheduler: scheduler,
ShutdownCtx: shutdownCtx,
ShutdownTrigger: shutdownTrigger,
StackDeployer: stackDeployer,
UpgradeService: upgradeService,
@@ -626,7 +614,8 @@ 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).
@@ -638,8 +627,44 @@ func main() {
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)
}
+21 -16
View File
@@ -6,9 +6,9 @@ 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"
@@ -42,9 +42,9 @@ 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)
@@ -141,15 +141,16 @@ 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)
@@ -200,13 +201,14 @@ 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)
@@ -257,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)
@@ -314,13 +317,14 @@ 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)
@@ -385,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!")
+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
+5 -4
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,7 +96,7 @@ 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)
err := f.Close()
@@ -107,7 +107,7 @@ func Test_NeedsEncryptionMigration(t *testing.T) {
require.NoError(t, err)
}()
dbFile2 := path.Join(connection.Path, EncryptedDatabaseFileName)
dbFile2 := filesystem.JoinPaths(connection.Path, EncryptedDatabaseFileName)
f, _ = os.Create(dbFile2)
err = f.Close()
@@ -118,7 +118,7 @@ func Test_NeedsEncryptionMigration(t *testing.T) {
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)
err := f.Close()
@@ -143,6 +143,7 @@ func Test_NeedsEncryptionMigration(t *testing.T) {
}
func TestDBCompaction(t *testing.T) {
t.Parallel()
db := &DbConnection{Path: t.TempDir()}
err := db.Open()
+5 -1
View File
@@ -17,7 +17,7 @@ import (
)
const (
jsonobject = `{"LogoURL":"","BlackListedLabels":[],"AuthenticationMethod":1,"InternalAuthSettings": {"RequiredPasswordLength": 12}"LDAPSettings":{"AnonymousMode":true,"ReaderDN":"","URL":"","TLSConfig":{"TLS":false,"TLSSkipVerify":false},"StartTLS":false,"SearchSettings":[{"BaseDN":"","Filter":"","UserNameAttribute":""}],"GroupSearchSettings":[{"GroupBaseDN":"","GroupFilter":"","GroupAttribute":""}],"AutoCreateUsers":true},"OAuthSettings":{"ClientID":"","AccessTokenURI":"","AuthorizationURI":"","ResourceURI":"","RedirectURI":"","UserIdentifier":"","Scopes":"","OAuthAutoCreateUsers":false,"DefaultTeamID":0,"SSO":true,"LogoutURI":"","KubeSecretKey":"j0zLVtY/lAWBk62ByyF0uP80SOXaitsABP0TTJX8MhI="},"OpenAMTConfiguration":{"Enabled":false,"MPSServer":"","MPSUser":"","MPSPassword":"","MPSToken":"","CertFileContent":"","CertFileName":"","CertFilePassword":"","DomainName":""},"FeatureFlagSettings":{},"SnapshotInterval":"5m","TemplatesURL":"https://raw.githubusercontent.com/portainer/templates/master/templates-2.0.json","EdgeAgentCheckinInterval":5,"EnableEdgeComputeFeatures":false,"UserSessionTimeout":"8h","KubeconfigExpiry":"0","HelmRepositoryURL":"https://charts.bitnami.com/bitnami","KubectlShellImage":"portainer/kubectl-shell","DisplayDonationHeader":false,"DisplayExternalContributors":false,"EnableHostManagementFeatures":false,"AllowVolumeBrowserForRegularUsers":false,"AllowBindMountsForRegularUsers":false,"AllowPrivilegedModeForRegularUsers":false,"AllowHostNamespaceForRegularUsers":false,"AllowStackManagementForRegularUsers":false,"AllowDeviceMappingForRegularUsers":false,"AllowContainerCapabilitiesForRegularUsers":false}`
jsonobject = `{"LogoURL":"","BlackListedLabels":[],"AuthenticationMethod":1,"InternalAuthSettings": {"RequiredPasswordLength": 12}"LDAPSettings":{"AnonymousMode":true,"ReaderDN":"","URL":"","TLSConfig":{"TLS":false,"TLSSkipVerify":false},"StartTLS":false,"SearchSettings":[{"BaseDN":"","Filter":"","UserNameAttribute":""}],"GroupSearchSettings":[{"GroupBaseDN":"","GroupFilter":"","GroupAttribute":""}],"AutoCreateUsers":true},"OAuthSettings":{"ClientID":"","AccessTokenURI":"","AuthorizationURI":"","ResourceURI":"","RedirectURI":"","UserIdentifier":"","Scopes":"","OAuthAutoCreateUsers":false,"DefaultTeamID":0,"SSO":true,"LogoutURI":"","KubeSecretKey":"j0zLVtY/lAWBk62ByyF0uP80SOXaitsABP0TTJX8MhI="},"FeatureFlagSettings":{},"SnapshotInterval":"5m","TemplatesURL":"https://raw.githubusercontent.com/portainer/templates/master/templates-2.0.json","EdgeAgentCheckinInterval":5,"EnableEdgeComputeFeatures":false,"UserSessionTimeout":"8h","KubeconfigExpiry":"0","HelmRepositoryURL":"https://charts.bitnami.com/bitnami","KubectlShellImage":"portainer/kubectl-shell","DisplayDonationHeader":false,"DisplayExternalContributors":false,"EnableHostManagementFeatures":false,"AllowVolumeBrowserForRegularUsers":false,"AllowBindMountsForRegularUsers":false,"AllowPrivilegedModeForRegularUsers":false,"AllowHostNamespaceForRegularUsers":false,"AllowStackManagementForRegularUsers":false,"AllowDeviceMappingForRegularUsers":false,"AllowContainerCapabilitiesForRegularUsers":false}`
passphrase = "my secret key"
)
@@ -27,6 +27,7 @@ func secretToEncryptionKey(passphrase string) []byte {
}
func Test_MarshalObjectUnencrypted(t *testing.T) {
t.Parallel()
is := assert.New(t)
uuid := uuid.New()
@@ -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
+1
View File
@@ -18,6 +18,7 @@ type testStruct struct {
}
func TestTxs(t *testing.T) {
t.Parallel()
conn := DbConnection{Path: t.TempDir()}
err := conn.Open()
+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)
+1
View File
@@ -51,6 +51,7 @@ func (m mockConnection) ConvertToKey(v int) []byte {
return []byte(strconv.Itoa(v))
}
func TestReadAll(t *testing.T) {
t.Parallel()
service := BaseDataService[testObject, int]{
Bucket: "testBucket",
Connection: mockConnection{store: make(map[int]testObject)},
@@ -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 {
@@ -11,6 +11,7 @@ import (
)
func TestUpdate(t *testing.T) {
t.Parallel()
var conn portainer.Connection = &boltdb.DbConnection{Path: t.TempDir()}
err := conn.Open()
require.NoError(t, err)
@@ -13,6 +13,7 @@ import (
)
func TestUpdateRelation(t *testing.T) {
t.Parallel()
const endpointID = 1
const edgeStackID1 = 1
const edgeStackID2 = 2
@@ -106,6 +107,7 @@ 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)
@@ -125,6 +127,7 @@ 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)
@@ -10,6 +10,7 @@ import (
)
func TestDeleteByEndpoint(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, false)
// Create Endpoint 1
+4 -2
View File
@@ -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
}
+5
View File
@@ -13,6 +13,7 @@ import (
)
func TestStoreCreation(t *testing.T) {
t.Parallel()
_, store := MustNewTestStore(t, true, true)
require.NotNil(t, store)
@@ -31,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) {
@@ -52,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) {
@@ -93,6 +96,7 @@ func TestRestore(t *testing.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) {
@@ -122,6 +126,7 @@ func TestBackupDBFile(t *testing.T) {
}
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) {
+1
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){
+1
View File
@@ -59,6 +59,7 @@ func (store *Store) checkOrCreateDefaultSettings() error {
KubectlShellImage: *store.flags.KubectlShellImage,
IsDockerDesktopExtension: isDDExtention,
EnforceEdgeID: true,
}
return store.SettingsService.UpdateSettings(defaultSettings)
+3 -2
View File
@@ -6,13 +6,13 @@ import (
"fmt"
"io"
"os"
"path/filepath"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/database/boltdb"
"github.com/portainer/portainer/api/database/models"
"github.com/portainer/portainer/api/datastore/migrator"
"github.com/portainer/portainer/api/filesystem"
"github.com/Masterminds/semver/v3"
"github.com/google/go-cmp/cmp"
@@ -174,6 +174,7 @@ 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"
@@ -324,7 +325,7 @@ 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")
gotPath := filesystem.JoinPaths(os.TempDir(), "portainer-migrator-test-fail.json")
err = os.WriteFile(
gotPath,
gotJSON,
@@ -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()
@@ -12,6 +12,7 @@ import (
)
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)
@@ -15,6 +15,7 @@ import (
)
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)
+1
View File
@@ -14,6 +14,7 @@ type cleanNAPWithOverridePolicies struct {
}
func Test_ConvertCleanNAPWithOverridePoliciesPayload(t *testing.T) {
t.Parallel()
t.Run("test ConvertCleanNAPWithOverridePoliciesPayload", func(t *testing.T) {
_, store := MustNewTestStore(t, true, false)
@@ -18,6 +18,7 @@ import (
)
func TestMigrateGPUs(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/containers/json") {
containerSummary := []container.Summary{{ID: "container1"}}
@@ -79,6 +80,7 @@ func TestMigrateGPUs(t *testing.T) {
}
func TestPostInitMigrate_PendingActionsCreated(t *testing.T) {
t.Parallel()
tests := []struct {
name string
existingPendingActions []*portainer.PendingAction
@@ -607,6 +607,7 @@
"EnableEdgeComputeFeatures": false,
"EnforceEdgeID": false,
"FeatureFlagSettings": null,
"ForceSecureCookies": false,
"GlobalDeploymentOptions": {
"hideStacksFunctionality": false
},
@@ -615,7 +616,7 @@
"RequiredPasswordLength": 12
},
"KubeconfigExpiry": "0",
"KubectlShellImage": "portainer/kubectl-shell:2.40.0",
"KubectlShellImage": "portainer/kubectl-shell:2.42.0",
"LDAPSettings": {
"AnonymousMode": true,
"AutoCreateUsers": true,
@@ -660,18 +661,7 @@
"SnapshotInterval": "5m",
"TemplatesURL": "",
"TrustOnFirstConnect": false,
"UserSessionTimeout": "8h",
"openAMTConfiguration": {
"certFileContent": "",
"certFileName": "",
"certFilePassword": "",
"domainName": "",
"enabled": false,
"mpsPassword": "",
"mpsServer": "",
"mpsToken": "",
"mpsUser": ""
}
"UserSessionTimeout": "8h"
},
"snapshots": [
{
@@ -808,6 +798,7 @@
"AutoUpdate": null,
"CreatedBy": "",
"CreationDate": 0,
"DeploymentStartStatus": 0,
"EndpointId": 1,
"EntryPoint": "docker/alpine37-compose.yml",
"Env": [],
@@ -830,6 +821,7 @@
"AutoUpdate": null,
"CreatedBy": "",
"CreationDate": 0,
"DeploymentStartStatus": 0,
"EndpointId": 1,
"EntryPoint": "docker-compose.yml",
"Env": [],
@@ -852,6 +844,7 @@
"AutoUpdate": null,
"CreatedBy": "",
"CreationDate": 0,
"DeploymentStartStatus": 0,
"EndpointId": 1,
"EntryPoint": "docker-compose.yml",
"Env": [],
@@ -944,7 +937,7 @@
}
],
"version": {
"VERSION": "{\"SchemaVersion\":\"2.40.0\",\"MigratorCount\":1,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
"VERSION": "{\"SchemaVersion\":\"2.42.0\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
},
"webhooks": null
}
+1
View File
@@ -10,6 +10,7 @@ import (
)
func TestHttpClient(t *testing.T) {
t.Parallel()
fips.InitFIPS(false)
// Valid TLS configuration
+18 -62
View File
@@ -21,14 +21,12 @@ import (
type ContainerService struct {
factory *dockerclient.ClientFactory
dataStore dataservices.DataStore
sr *serviceRestore
}
func NewContainerService(factory *dockerclient.ClientFactory, dataStore dataservices.DataStore) *ContainerService {
return &ContainerService{
factory: factory,
dataStore: dataStore,
sr: &serviceRestore{},
}
}
@@ -141,11 +139,14 @@ func (c *ContainerService) Recreate(ctx context.Context, endpoint *portainer.End
initialNetwork.EndpointsConfig[name] = network
}
}
c.sr.enable()
defer c.sr.close()
defer c.sr.restore()
c.sr.push(func() {
restore := true
defer func() {
if !restore {
return
}
log.Debug().Str("container_id", containerId).Str("container", container.Name).Msg("restoring the container")
if err := cli.ContainerRename(ctx, containerId, container.Name); err != nil {
log.Warn().Err(err).Msg("failure to rename container")
@@ -160,7 +161,7 @@ func (c *ContainerService) Recreate(ctx context.Context, endpoint *portainer.End
if err := cli.ContainerStart(ctx, containerId, dockercontainer.StartOptions{}); err != nil {
log.Warn().Err(err).Msg("failure to start container")
}
})
}()
log.Debug().Str("container", strings.Split(container.Name, "/")[1]).Msg("starting to create a new container")
@@ -179,8 +180,15 @@ func (c *ContainerService) Recreate(ctx context.Context, endpoint *portainer.End
}
create, err := cli.ContainerCreate(ctx, container.Config, container.HostConfig, &initialNetwork, nil, container.Name)
if err != nil {
return nil, errors.Wrap(err, "create container error")
}
defer func() {
if !restore {
return
}
c.sr.push(func() {
log.Debug().Str("container_id", create.ID).Msg("removing the new container")
if err := cli.ContainerStop(ctx, create.ID, dockercontainer.StopOptions{}); err != nil {
@@ -190,11 +198,7 @@ func (c *ContainerService) Recreate(ctx context.Context, endpoint *portainer.End
if err := cli.ContainerRemove(ctx, create.ID, dockercontainer.RemoveOptions{}); err != nil {
log.Warn().Err(err).Msg("failure to remove container")
}
})
if err != nil {
return nil, errors.Wrap(err, "create container error")
}
}()
newContainerId := create.ID
@@ -224,7 +228,7 @@ func (c *ContainerService) Recreate(ctx context.Context, endpoint *portainer.End
log.Debug().Str("container_id", containerId).Msg("starting to remove the old container")
_ = cli.ContainerRemove(ctx, containerId, dockercontainer.RemoveOptions{})
c.sr.disable()
restore = false
newContainer, _, err := cli.ContainerInspectWithRaw(ctx, newContainerId, true)
if err != nil {
@@ -233,51 +237,3 @@ func (c *ContainerService) Recreate(ctx context.Context, endpoint *portainer.End
return &newContainer, nil
}
type serviceRestore struct {
restoreC chan struct{}
fs []func()
}
func (sr *serviceRestore) enable() {
sr.restoreC = make(chan struct{}, 1)
sr.fs = make([]func(), 0)
sr.restoreC <- struct{}{}
}
func (sr *serviceRestore) disable() {
select {
case <-sr.restoreC:
default:
}
}
func (sr *serviceRestore) push(f func()) {
sr.fs = append(sr.fs, f)
}
func (sr *serviceRestore) restore() {
select {
case <-sr.restoreC:
l := len(sr.fs)
if l > 0 {
for i := l - 1; i >= 0; i-- {
sr.fs[i]()
}
}
default:
}
}
func (sr *serviceRestore) close() {
if sr == nil || sr.restoreC == nil {
return
}
select {
case <-sr.restoreC:
default:
}
close(sr.restoreC)
}
+1
View File
@@ -8,6 +8,7 @@ import (
)
func TestApplyVersionConstraint(t *testing.T) {
t.Parallel()
initialNet := network.NetworkingConfig{
EndpointsConfig: map[string]*network.EndpointSettings{
"key1": {
+1
View File
@@ -8,6 +8,7 @@ import (
)
func TestParseLocalImage(t *testing.T) {
t.Parallel()
// Test with a regular image
img, err := ParseLocalImage(image.InspectResponse{
+2
View File
@@ -8,6 +8,7 @@ import (
)
func TestImageParser(t *testing.T) {
t.Parallel()
is := assert.New(t)
// portainer/portainer-ee
@@ -62,6 +63,7 @@ func TestImageParser(t *testing.T) {
}
func TestUpdateParsedImage(t *testing.T) {
t.Parallel()
is := assert.New(t)
// gcr.io/k8s-minikube/kicbase:v0.0.30@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2
+1
View File
@@ -10,6 +10,7 @@ import (
)
func TestFindBestMatchNeedAuthRegistry(t *testing.T) {
t.Parallel()
is := assert.New(t)
t.Run("", func(t *testing.T) {
+3 -11
View File
@@ -89,11 +89,11 @@ func FigureOut(statuses []Status) Status {
return Preparing
}
if contains(statuses, Outdated) {
if slices.Contains(statuses, Outdated) {
return Outdated
} else if contains(statuses, Processing) {
} else if slices.Contains(statuses, Processing) {
return Processing
} else if contains(statuses, Error) {
} else if slices.Contains(statuses, Error) {
return Error
}
@@ -275,14 +275,6 @@ func EvictImageStatus(resourceID string) {
statusCache.Delete(resourceID)
}
func contains(statuses []Status, status Status) bool {
if len(statuses) == 0 {
return false
}
return slices.Contains(statuses, status)
}
func allMatch(statuses []Status, status Status) bool {
if len(statuses) == 0 {
return false
+6 -2
View File
@@ -25,6 +25,7 @@ func (m *MockDockerClient) ContainerInspect(ctx context.Context, containerID str
}
func TestCalculateContainerStats(t *testing.T) {
t.Parallel()
mockClient := new(MockDockerClient)
// Test containers - using enough containers to test concurrent processing
@@ -78,7 +79,7 @@ func TestCalculateContainerStats(t *testing.T) {
// Call the function and measure time
startTime := time.Now()
stats, err := CalculateContainerStats(context.Background(), mockClient, false, containers)
stats, err := CalculateContainerStats(t.Context(), mockClient, false, containers)
require.NoError(t, err, "failed to calculate container stats")
duration := time.Since(startTime)
@@ -105,6 +106,7 @@ func TestCalculateContainerStats(t *testing.T) {
}
func TestCalculateContainerStatsAllErrors(t *testing.T) {
t.Parallel()
mockClient := new(MockDockerClient)
// Test containers
@@ -118,7 +120,7 @@ func TestCalculateContainerStatsAllErrors(t *testing.T) {
mockClient.On("ContainerInspect", mock.Anything, "container2").Return(container.InspectResponse{}, errors.New("permission denied"))
// Call the function
stats, err := CalculateContainerStats(context.Background(), mockClient, false, containers)
stats, err := CalculateContainerStats(t.Context(), mockClient, false, containers)
// Assert that an error was returned
require.Error(t, err, "should return error when all containers fail to inspect")
@@ -140,6 +142,7 @@ func TestCalculateContainerStatsAllErrors(t *testing.T) {
}
func TestGetContainerStatus(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
state *container.State
@@ -232,6 +235,7 @@ func TestGetContainerStatus(t *testing.T) {
}
func TestCalculateContainerStatsForSwarm(t *testing.T) {
t.Parallel()
containers := []container.Summary{
{State: "running"},
{State: "running", Status: "Up 5 minutes (healthy)"},
+75 -1
View File
@@ -1,5 +1,79 @@
package exec
import "regexp"
import (
"fmt"
"regexp"
"strings"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/proxy"
"github.com/portainer/portainer/api/http/proxy/factory"
"github.com/portainer/portainer/api/internal/registryutils"
"github.com/docker/cli/cli/config/types"
"github.com/rs/zerolog/log"
)
var stackNameNormalizeRegex = regexp.MustCompile("[^-_a-z0-9]+")
func normalizeStackName(name string) string {
return stackNameNormalizeRegex.ReplaceAllString(strings.ToLower(name), "")
}
// fetchEndpointProxy returns the Docker host URL for the given endpoint.
// For remote endpoints it creates a local proxy that handles TLS termination and
// Portainer agent header injection; for local unix/npipe sockets no proxy is needed.
func fetchEndpointProxy(proxyManager *proxy.Manager, endpoint *portainer.Endpoint) (string, *factory.ProxyServer, error) {
if strings.HasPrefix(endpoint.URL, "unix://") || strings.HasPrefix(endpoint.URL, "npipe://") {
return "", nil, nil
}
proxy, err := proxyManager.CreateAgentProxyServer(endpoint)
if err != nil {
return "", nil, err
}
return fmt.Sprintf("tcp://127.0.0.1:%d", proxy.Port), proxy, nil
}
// portainerRegistriesToAuthConfigs converts registries to Docker auth configs.
// Callers must ensure ECR tokens are valid before calling this function (e.g. via
// registryutils.ValidateRegistriesECRTokens with a real DataStoreTx). This function
// intentionally performs no DB writes to avoid write-lock contention when called inside
// an active BoltDB write transaction.
func portainerRegistriesToAuthConfigs(registries []portainer.Registry) []types.AuthConfig {
var authConfigs []types.AuthConfig
for _, r := range registries {
ac := types.AuthConfig{
Username: r.Username,
Password: r.Password,
ServerAddress: r.URL,
}
if r.Authentication {
var err error
ac.Username, ac.Password, err = getEffectiveRegUsernamePassword(&r)
if err != nil {
continue
}
}
authConfigs = append(authConfigs, ac)
}
return authConfigs
}
func getEffectiveRegUsernamePassword(registry *portainer.Registry) (string, string, error) {
username, password, err := registryutils.GetRegEffectiveCredential(registry)
if err != nil {
log.Warn().
Err(err).
Str("RegistryName", registry.Name).
Msg("Failed to get effective credential. Skip logging with this registry.")
}
return username, password, err
}
+37 -99
View File
@@ -6,35 +6,25 @@ import (
"io"
"os"
"path"
"strings"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/proxy"
"github.com/portainer/portainer/api/http/proxy/factory"
"github.com/portainer/portainer/api/internal/registryutils"
"github.com/portainer/portainer/api/logs"
"github.com/portainer/portainer/api/stacks/stackutils"
"github.com/portainer/portainer/pkg/libstack"
"github.com/docker/cli/cli/config/types"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
)
// ComposeStackManager is a wrapper for docker-compose binary
type ComposeStackManager struct {
deployer libstack.Deployer
proxyManager *proxy.Manager
dataStore dataservices.DataStore
}
// NewComposeStackManager returns a Compose stack manager
func NewComposeStackManager(deployer libstack.Deployer, proxyManager *proxy.Manager, dataStore dataservices.DataStore) *ComposeStackManager {
func NewComposeStackManager(deployer libstack.Deployer, proxyManager *proxy.Manager) *ComposeStackManager {
return &ComposeStackManager{
deployer: deployer,
proxyManager: proxyManager,
dataStore: dataStore,
}
}
@@ -45,9 +35,9 @@ func (manager *ComposeStackManager) ComposeSyntaxMaxVersion() string {
// Up builds, (re)creates and starts containers in the background. Wraps `docker-compose up -d` command
func (manager *ComposeStackManager) Up(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint, options portainer.ComposeUpOptions) error {
url, proxy, err := manager.fetchEndpointProxy(endpoint)
url, proxy, err := fetchEndpointProxy(manager.proxyManager, endpoint)
if err != nil {
return errors.Wrap(err, "failed to fetch environment proxy")
return fmt.Errorf("failed to fetch environment proxy: %w", err)
}
if proxy != nil {
@@ -56,30 +46,32 @@ func (manager *ComposeStackManager) Up(ctx context.Context, stack *portainer.Sta
envFilePath, err := createEnvFile(stack)
if err != nil {
return errors.Wrap(err, "failed to create env file")
return fmt.Errorf("failed to create env file: %w", err)
}
filePaths := stackutils.GetStackFilePaths(stack, true)
err = manager.deployer.Deploy(ctx, filePaths, libstack.DeployOptions{
if err = manager.deployer.Deploy(ctx, filePaths, libstack.DeployOptions{
Options: libstack.Options{
WorkingDir: stack.ProjectPath,
EnvFilePath: envFilePath,
Host: url,
ProjectName: stack.Name,
Registries: portainerRegistriesToAuthConfigs(manager.dataStore, options.Registries),
Registries: portainerRegistriesToAuthConfigs(options.Registries),
},
ForceRecreate: options.ForceRecreate,
AbortOnContainerExit: options.AbortOnContainerExit,
RemoveOrphans: options.Prune,
})
return errors.Wrap(err, "failed to deploy a stack")
}); err != nil {
return fmt.Errorf("failed to deploy a stack: %w", err)
}
return nil
}
// Run runs a one-off command on a service. Wraps `docker-compose run` command
func (manager *ComposeStackManager) Run(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint, serviceName string, options portainer.ComposeRunOptions) error {
url, proxy, err := manager.fetchEndpointProxy(endpoint)
url, proxy, err := fetchEndpointProxy(manager.proxyManager, endpoint)
if err != nil {
return errors.Wrap(err, "failed to fetch environment proxy")
return fmt.Errorf("failed to fetch environment proxy: %w", err)
}
if proxy != nil {
@@ -88,86 +80,78 @@ func (manager *ComposeStackManager) Run(ctx context.Context, stack *portainer.St
envFilePath, err := createEnvFile(stack)
if err != nil {
return errors.Wrap(err, "failed to create env file")
return fmt.Errorf("failed to create env file: %w", err)
}
filePaths := stackutils.GetStackFilePaths(stack, true)
err = manager.deployer.Run(ctx, filePaths, serviceName, libstack.RunOptions{
if err = manager.deployer.Run(ctx, filePaths, serviceName, libstack.RunOptions{
Options: libstack.Options{
WorkingDir: stack.ProjectPath,
EnvFilePath: envFilePath,
Host: url,
ProjectName: stack.Name,
Registries: portainerRegistriesToAuthConfigs(manager.dataStore, options.Registries),
Registries: portainerRegistriesToAuthConfigs(options.Registries),
},
Remove: options.Remove,
Args: options.Args,
Detached: options.Detached,
})
return errors.Wrap(err, "failed to deploy a stack")
}); err != nil {
return fmt.Errorf("failed to deploy a stack: %w", err)
}
return nil
}
// Down stops and removes containers, networks, images, and volumes
func (manager *ComposeStackManager) Down(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint) error {
url, proxy, err := manager.fetchEndpointProxy(endpoint)
url, proxy, err := fetchEndpointProxy(manager.proxyManager, endpoint)
if err != nil {
return err
return fmt.Errorf("failed to fetch environment proxy: %w", err)
} else if proxy != nil {
defer proxy.Close()
}
err = manager.deployer.Remove(ctx, stack.Name, nil, libstack.RemoveOptions{
if err = manager.deployer.Remove(ctx, stack.Name, nil, libstack.RemoveOptions{
Options: libstack.Options{
WorkingDir: "",
Host: url,
},
})
return errors.Wrap(err, "failed to remove a stack")
}); err != nil {
return fmt.Errorf("failed to remove a stack: %w", err)
}
return nil
}
// Pull an image associated with a service defined in a docker-compose.yml or docker-stack.yml file,
// but does not start containers based on those images.
func (manager *ComposeStackManager) Pull(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint, options portainer.ComposeOptions) error {
url, proxy, err := manager.fetchEndpointProxy(endpoint)
url, proxy, err := fetchEndpointProxy(manager.proxyManager, endpoint)
if err != nil {
return err
return fmt.Errorf("failed to fetch environment proxy: %w", err)
} else if proxy != nil {
defer proxy.Close()
}
envFilePath, err := createEnvFile(stack)
if err != nil {
return errors.Wrap(err, "failed to create env file")
return fmt.Errorf("failed to create env file: %w", err)
}
filePaths := stackutils.GetStackFilePaths(stack, true)
err = manager.deployer.Pull(ctx, filePaths, libstack.Options{
if err = manager.deployer.Pull(ctx, filePaths, libstack.Options{
WorkingDir: stack.ProjectPath,
EnvFilePath: envFilePath,
Host: url,
ProjectName: stack.Name,
Registries: portainerRegistriesToAuthConfigs(manager.dataStore, options.Registries),
})
return errors.Wrap(err, "failed to pull images of the stack")
Registries: portainerRegistriesToAuthConfigs(options.Registries),
}); err != nil {
return fmt.Errorf("failed to pull images of the stack: %w", err)
}
return nil
}
// NormalizeStackName returns a new stack name with unsupported characters replaced
func (manager *ComposeStackManager) NormalizeStackName(name string) string {
return stackNameNormalizeRegex.ReplaceAllString(strings.ToLower(name), "")
}
func (manager *ComposeStackManager) fetchEndpointProxy(endpoint *portainer.Endpoint) (string, *factory.ProxyServer, error) {
if strings.HasPrefix(endpoint.URL, "unix://") || strings.HasPrefix(endpoint.URL, "npipe://") {
return "", nil, nil
}
proxy, err := manager.proxyManager.CreateAgentProxyServer(endpoint)
if err != nil {
return "", nil, err
}
return fmt.Sprintf("tcp://127.0.0.1:%d", proxy.Port), proxy, nil
return normalizeStackName(name)
}
// createEnvFile creates a file that would hold both "in-place" and default environment variables.
@@ -178,7 +162,7 @@ func createEnvFile(stack *portainer.Stack) (string, error) {
}
envFilePath := path.Join(stack.ProjectPath, "stack.env")
envfile, err := os.OpenFile(envFilePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
envfile, err := os.OpenFile(envFilePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
if err != nil {
return "", err
}
@@ -229,49 +213,3 @@ func copyConfigEnvVars(w io.Writer, envs []portainer.Pair) error {
return nil
}
func portainerRegistriesToAuthConfigs(tx dataservices.DataStoreTx, registries []portainer.Registry) []types.AuthConfig {
var authConfigs []types.AuthConfig
for _, r := range registries {
ac := types.AuthConfig{
Username: r.Username,
Password: r.Password,
ServerAddress: r.URL,
}
if r.Authentication {
var err error
ac.Username, ac.Password, err = getEffectiveRegUsernamePassword(tx, &r)
if err != nil {
continue
}
}
authConfigs = append(authConfigs, ac)
}
return authConfigs
}
func getEffectiveRegUsernamePassword(tx dataservices.DataStoreTx, registry *portainer.Registry) (string, string, error) {
if err := registryutils.EnsureRegTokenValid(tx, registry); err != nil {
log.Warn().
Err(err).
Str("RegistryName", registry.Name).
Msg("Failed to validate registry token. Skip logging with this registry.")
return "", "", err
}
username, password, err := registryutils.GetRegEffectiveCredential(registry)
if err != nil {
log.Warn().
Err(err).
Str("RegistryName", registry.Name).
Msg("Failed to get effective credential. Skip logging with this registry.")
}
return username, password, err
}
+6 -8
View File
@@ -1,14 +1,13 @@
package exec
import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/filesystem"
"github.com/portainer/portainer/pkg/libstack/compose"
"github.com/portainer/portainer/pkg/testhelpers"
"github.com/stretchr/testify/require"
@@ -26,7 +25,7 @@ const composedContainerName = "compose_wrapper_test"
func setup(t *testing.T) (*portainer.Stack, *portainer.Endpoint) {
dir := t.TempDir()
composeFileName := "compose_wrapper_test.yml"
f, err := os.Create(filepath.Join(dir, composeFileName))
f, err := os.Create(filesystem.JoinPaths(dir, composeFileName))
require.NoError(t, err)
_, err = f.WriteString(composeFile)
@@ -42,17 +41,16 @@ func setup(t *testing.T) (*portainer.Stack, *portainer.Endpoint) {
}
func Test_UpAndDown(t *testing.T) {
t.Parallel()
testhelpers.IntegrationTest(t)
stack, endpoint := setup(t)
deployer := compose.NewComposeDeployer()
w := NewComposeStackManager(deployer, nil, nil)
w := NewComposeStackManager(deployer, nil)
ctx := context.TODO()
if err := w.Up(ctx, stack, endpoint, portainer.ComposeUpOptions{}); err != nil {
if err := w.Up(t.Context(), stack, endpoint, portainer.ComposeUpOptions{}); err != nil {
t.Fatalf("Error calling docker-compose up: %s", err)
}
@@ -60,7 +58,7 @@ func Test_UpAndDown(t *testing.T) {
t.Fatal("container should exist")
}
if err := w.Down(ctx, stack, endpoint); err != nil {
if err := w.Down(t.Context(), stack, endpoint); err != nil {
t.Fatalf("Error calling docker-compose down: %s", err)
}
+81 -8
View File
@@ -3,17 +3,18 @@ package exec
import (
"io"
"os"
"path"
"path/filepath"
"testing"
"time"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/filesystem"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_createEnvFile(t *testing.T) {
t.Parallel()
dir := t.TempDir()
tests := []struct {
@@ -56,9 +57,9 @@ func Test_createEnvFile(t *testing.T) {
result, _ := createEnvFile(tt.stack)
if tt.expected != "" {
assert.Equal(t, filepath.Join(tt.stack.ProjectPath, "stack.env"), result)
assert.Equal(t, filesystem.JoinPaths(tt.stack.ProjectPath, "stack.env"), result)
f, _ := os.Open(path.Join(dir, "stack.env"))
f, _ := os.Open(filesystem.JoinPaths(dir, "stack.env"))
content, _ := io.ReadAll(f)
assert.Equal(t, tt.expected, string(content))
@@ -70,8 +71,9 @@ func Test_createEnvFile(t *testing.T) {
}
func Test_createEnvFile_mergesDefultAndInplaceEnvVars(t *testing.T) {
t.Parallel()
dir := t.TempDir()
err := os.WriteFile(path.Join(dir, ".env"), []byte("VAR1=VAL1\nVAR2=VAL2\n"), 0600)
err := os.WriteFile(filesystem.JoinPaths(dir, ".env"), []byte("VAR1=VAL1\nVAR2=VAL2\n"), 0600)
require.NoError(t, err)
stack := &portainer.Stack{
@@ -82,11 +84,11 @@ func Test_createEnvFile_mergesDefultAndInplaceEnvVars(t *testing.T) {
},
}
result, err := createEnvFile(stack)
assert.Equal(t, filepath.Join(stack.ProjectPath, "stack.env"), result)
assert.Equal(t, filesystem.JoinPaths(stack.ProjectPath, "stack.env"), result)
require.NoError(t, err)
assert.FileExists(t, path.Join(dir, "stack.env"))
assert.FileExists(t, filesystem.JoinPaths(dir, "stack.env"))
f, err := os.Open(path.Join(dir, "stack.env"))
f, err := os.Open(filesystem.JoinPaths(dir, "stack.env"))
require.NoError(t, err)
content, err := io.ReadAll(f)
@@ -94,3 +96,74 @@ func Test_createEnvFile_mergesDefultAndInplaceEnvVars(t *testing.T) {
assert.Equal(t, []byte("VAR1=VAL1\nVAR2=VAL2\n\nVAR1=NEW_VAL1\nVAR3=VAL3\n"), content)
}
func Test_portainerRegistriesToAuthConfigs(t *testing.T) {
t.Parallel()
t.Run("returns empty slice for empty input", func(t *testing.T) {
t.Parallel()
result := portainerRegistriesToAuthConfigs([]portainer.Registry{})
require.Nil(t, result)
})
t.Run("uses registry URL, username and password for non-authenticated registry", func(t *testing.T) {
t.Parallel()
registries := []portainer.Registry{
{URL: "registry.example.com", Username: "user", Password: "pass", Authentication: false},
}
result := portainerRegistriesToAuthConfigs(registries)
require.Len(t, result, 1)
require.Equal(t, "registry.example.com", result[0].ServerAddress)
require.Equal(t, "user", result[0].Username)
require.Equal(t, "pass", result[0].Password)
})
t.Run("uses username and password for authenticated non-ECR registry", func(t *testing.T) {
t.Parallel()
registries := []portainer.Registry{
{URL: "registry.example.com", Username: "user", Password: "pass", Authentication: true, Type: portainer.CustomRegistry},
}
result := portainerRegistriesToAuthConfigs(registries)
require.Len(t, result, 1)
require.Equal(t, "user", result[0].Username)
require.Equal(t, "pass", result[0].Password)
})
t.Run("parses ECR access token for authenticated ECR registry with valid token", func(t *testing.T) {
t.Parallel()
registries := []portainer.Registry{
{
URL: "123456789.dkr.ecr.us-east-1.amazonaws.com",
Username: "AKIAIOSFODNN7EXAMPLE",
Password: "secretkey",
Authentication: true,
Type: portainer.EcrRegistry,
Ecr: portainer.EcrData{Region: "us-east-1"},
AccessToken: "AWS:ecr-password",
AccessTokenExpiry: time.Now().Add(time.Hour).Unix(),
},
}
result := portainerRegistriesToAuthConfigs(registries)
require.Len(t, result, 1)
require.Equal(t, "AWS", result[0].Username)
require.Equal(t, "ecr-password", result[0].Password)
})
t.Run("includes valid registries and skips ones with credential errors", func(t *testing.T) {
t.Parallel()
registries := []portainer.Registry{
{URL: "valid.example.com", Username: "user", Password: "pass", Authentication: false},
{
URL: "123456789.dkr.ecr.us-east-1.amazonaws.com",
Authentication: true,
Type: portainer.EcrRegistry,
Ecr: portainer.EcrData{Region: "us-east-1"},
AccessToken: "no-colon-token",
AccessTokenExpiry: time.Now().Add(time.Hour).Unix(),
},
}
result := portainerRegistriesToAuthConfigs(registries)
require.Len(t, result, 1)
require.Equal(t, "valid.example.com", result[0].ServerAddress)
})
}
+5 -3
View File
@@ -1,6 +1,8 @@
package exectest
import (
"context"
portainer "github.com/portainer/portainer/api"
)
@@ -13,14 +15,14 @@ func NewKubernetesDeployer() *kubernetesMockDeployer {
return &kubernetesMockDeployer{}
}
func (deployer *kubernetesMockDeployer) Deploy(userID portainer.UserID, endpoint *portainer.Endpoint, manifestFiles []string, namespace string) (string, error) {
func (deployer *kubernetesMockDeployer) Deploy(_ context.Context, userID portainer.UserID, endpoint *portainer.Endpoint, manifestFiles []string, namespace string) (string, error) {
return "", nil
}
func (deployer *kubernetesMockDeployer) Remove(userID portainer.UserID, endpoint *portainer.Endpoint, manifestFiles []string, namespace string) (string, error) {
func (deployer *kubernetesMockDeployer) Remove(_ context.Context, userID portainer.UserID, endpoint *portainer.Endpoint, manifestFiles []string, namespace string) (string, error) {
return "", nil
}
func (deployer *kubernetesMockDeployer) Restart(userID portainer.UserID, endpoint *portainer.Endpoint, manifestFiles []string, namespace string) (string, error) {
func (deployer *kubernetesMockDeployer) Restart(_ context.Context, userID portainer.UserID, endpoint *portainer.Endpoint, manifestFiles []string, namespace string) (string, error) {
return "", nil
}
+6 -6
View File
@@ -76,16 +76,16 @@ func (deployer *KubernetesDeployer) getToken(userID portainer.UserID, endpoint *
}
// Deploy upserts Kubernetes resources defined in manifest(s)
func (deployer *KubernetesDeployer) Deploy(userID portainer.UserID, endpoint *portainer.Endpoint, resources []string, namespace string) (string, error) {
return deployer.command("apply", userID, endpoint, resources, namespace)
func (deployer *KubernetesDeployer) Deploy(ctx context.Context, userID portainer.UserID, endpoint *portainer.Endpoint, resources []string, namespace string) (string, error) {
return deployer.command(ctx, "apply", userID, endpoint, resources, namespace)
}
// Remove deletes Kubernetes resources defined in manifest(s)
func (deployer *KubernetesDeployer) Remove(userID portainer.UserID, endpoint *portainer.Endpoint, resources []string, namespace string) (string, error) {
return deployer.command("delete", userID, endpoint, resources, namespace)
func (deployer *KubernetesDeployer) Remove(ctx context.Context, userID portainer.UserID, endpoint *portainer.Endpoint, resources []string, namespace string) (string, error) {
return deployer.command(ctx, "delete", userID, endpoint, resources, namespace)
}
func (deployer *KubernetesDeployer) command(operation string, userID portainer.UserID, endpoint *portainer.Endpoint, resources []string, namespace string) (string, error) {
func (deployer *KubernetesDeployer) command(ctx context.Context, operation string, userID portainer.UserID, endpoint *portainer.Endpoint, resources []string, namespace string) (string, error) {
token, err := deployer.getToken(userID, endpoint, endpoint.Type == portainer.KubernetesLocalEnvironment)
if err != nil {
return "", errors.Wrap(err, "failed generating a user token")
@@ -120,7 +120,7 @@ func (deployer *KubernetesDeployer) command(operation string, userID portainer.U
return "", errors.Errorf("unsupported operation: %s", operation)
}
output, err := operationFunc(context.Background(), resources)
output, err := operationFunc(ctx, resources)
if err != nil {
return "", errors.Wrapf(err, "failed to execute kubectl %s command", operation)
}
+7
View File
@@ -57,6 +57,7 @@ func testExecuteKubectlOperation(client *mockKubectlClient, operation string, ma
}
func TestExecuteKubectlOperation_Apply_Success(t *testing.T) {
t.Parallel()
called := false
mockClient := &mockKubectlClient{
applyFunc: func(ctx context.Context, files []string) error {
@@ -74,6 +75,7 @@ func TestExecuteKubectlOperation_Apply_Success(t *testing.T) {
}
func TestExecuteKubectlOperation_Apply_Error(t *testing.T) {
t.Parallel()
expectedErr := errors.New("kubectl apply failed")
called := false
mockClient := &mockKubectlClient{
@@ -93,6 +95,7 @@ func TestExecuteKubectlOperation_Apply_Error(t *testing.T) {
}
func TestExecuteKubectlOperation_Delete_Success(t *testing.T) {
t.Parallel()
called := false
mockClient := &mockKubectlClient{
deleteFunc: func(ctx context.Context, files []string) error {
@@ -110,6 +113,7 @@ func TestExecuteKubectlOperation_Delete_Success(t *testing.T) {
}
func TestExecuteKubectlOperation_Delete_Error(t *testing.T) {
t.Parallel()
expectedErr := errors.New("kubectl delete failed")
called := false
mockClient := &mockKubectlClient{
@@ -129,6 +133,7 @@ func TestExecuteKubectlOperation_Delete_Error(t *testing.T) {
}
func TestExecuteKubectlOperation_RolloutRestart_Success(t *testing.T) {
t.Parallel()
called := false
mockClient := &mockKubectlClient{
rolloutRestartFunc: func(ctx context.Context, resources []string) error {
@@ -146,6 +151,7 @@ func TestExecuteKubectlOperation_RolloutRestart_Success(t *testing.T) {
}
func TestExecuteKubectlOperation_RolloutRestart_Error(t *testing.T) {
t.Parallel()
expectedErr := errors.New("kubectl rollout restart failed")
called := false
mockClient := &mockKubectlClient{
@@ -165,6 +171,7 @@ func TestExecuteKubectlOperation_RolloutRestart_Error(t *testing.T) {
}
func TestExecuteKubectlOperation_UnsupportedOperation(t *testing.T) {
t.Parallel()
mockClient := &mockKubectlClient{}
err := testExecuteKubectlOperation(mockClient, "unsupported", []string{})
+62 -216
View File
@@ -1,247 +1,93 @@
package exec
import (
"bytes"
"errors"
"os"
"os/exec"
"path"
"runtime"
"strings"
"context"
"fmt"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/proxy"
"github.com/portainer/portainer/api/stacks/stackutils"
"github.com/rs/zerolog/log"
"github.com/segmentio/encoding/json"
"github.com/portainer/portainer/pkg/libstack/swarm"
)
// SwarmStackManager represents a service for managing stacks.
type SwarmStackManager struct {
binaryPath string
configPath string
signatureService portainer.DigitalSignatureService
fileService portainer.FileService
reverseTunnelService portainer.ReverseTunnelService
dataStore dataservices.DataStore
deployer swarm.Deployer
proxyManager *proxy.Manager
}
// NewSwarmStackManager initializes a new SwarmStackManager service.
// It also updates the configuration of the Docker CLI binary.
// NewSwarmStackManager creates a new SwarmStackManager.
func NewSwarmStackManager(
binaryPath, configPath string,
signatureService portainer.DigitalSignatureService,
fileService portainer.FileService,
reverseTunnelService portainer.ReverseTunnelService,
datastore dataservices.DataStore,
) (*SwarmStackManager, error) {
manager := &SwarmStackManager{
binaryPath: binaryPath,
configPath: configPath,
signatureService: signatureService,
fileService: fileService,
reverseTunnelService: reverseTunnelService,
dataStore: datastore,
deployer swarm.Deployer,
proxyManager *proxy.Manager,
) *SwarmStackManager {
return &SwarmStackManager{
deployer: deployer,
proxyManager: proxyManager,
}
if err := manager.updateDockerCLIConfiguration(manager.configPath); err != nil {
return nil, err
}
return manager, nil
}
// Login executes the docker login command against a list of registries (including DockerHub).
func (manager *SwarmStackManager) Login(registries []portainer.Registry, endpoint *portainer.Endpoint) error {
command, args, err := manager.prepareDockerCommandAndArgs(manager.binaryPath, manager.configPath, endpoint)
// Deploy creates or updates a Docker Swarm stack.
func (manager *SwarmStackManager) Deploy(
ctx context.Context,
stack *portainer.Stack,
prune bool,
pullImage bool,
endpoint *portainer.Endpoint,
registries []portainer.Registry,
) error {
url, proxy, err := fetchEndpointProxy(manager.proxyManager, endpoint)
if err != nil {
return err
return fmt.Errorf("failed to fetch environment proxy: %w", err)
}
for _, registry := range registries {
if registry.Authentication {
username, password, err := getEffectiveRegUsernamePassword(manager.dataStore, &registry)
if err != nil {
continue
}
registryArgs := append(args, "login", "--username", username, "--password", password, registry.URL)
if err := runCommandAndCaptureStdErr(command, registryArgs, nil, ""); err != nil {
log.Warn().
Err(err).
Str("RegistryName", registry.Name).
Msg("Failed to login.")
}
}
if proxy != nil {
defer proxy.Close()
}
return nil
}
// Logout executes the docker logout command.
func (manager *SwarmStackManager) Logout(endpoint *portainer.Endpoint) error {
command, args, err := manager.prepareDockerCommandAndArgs(manager.binaryPath, manager.configPath, endpoint)
if err != nil {
return err
}
args = append(args, "logout")
return runCommandAndCaptureStdErr(command, args, nil, "")
}
// Deploy executes the docker stack deploy command.
func (manager *SwarmStackManager) Deploy(stack *portainer.Stack, prune bool, pullImage bool, endpoint *portainer.Endpoint) error {
filePaths := stackutils.GetStackFilePaths(stack, true)
command, args, err := manager.prepareDockerCommandAndArgs(manager.binaryPath, manager.configPath, endpoint)
env := make([]string, 0, len(stack.Env))
for _, ev := range stack.Env {
env = append(env, ev.Name+"="+ev.Value)
}
return manager.deployer.Deploy(context.TODO(), filePaths, swarm.DeployOptions{
Options: swarm.Options{
ProjectName: stack.Name,
Host: url,
Env: env,
WorkingDir: stack.ProjectPath,
Registries: portainerRegistriesToAuthConfigs(registries),
},
RemoveOrphans: prune,
PullImage: pullImage,
})
}
// Remove deletes all resources belonging to a Swarm stack.
func (manager *SwarmStackManager) Remove(
ctx context.Context,
stack *portainer.Stack,
endpoint *portainer.Endpoint,
) error {
url, proxy, err := fetchEndpointProxy(manager.proxyManager, endpoint)
if err != nil {
return err
return fmt.Errorf("failed to fetch environment proxy: %w", err)
}
if prune {
args = append(args, "stack", "deploy", "--prune", "--with-registry-auth")
} else {
args = append(args, "stack", "deploy", "--with-registry-auth")
if proxy != nil {
defer proxy.Close()
}
if !pullImage {
args = append(args, "--resolve-image=never")
}
args = configureFilePaths(args, filePaths)
args = append(args, stack.Name)
env := make([]string, 0)
for _, envvar := range stack.Env {
env = append(env, envvar.Name+"="+envvar.Value)
}
return runCommandAndCaptureStdErr(command, args, env, stack.ProjectPath)
}
// Remove executes the docker stack rm command.
func (manager *SwarmStackManager) Remove(stack *portainer.Stack, endpoint *portainer.Endpoint) error {
command, args, err := manager.prepareDockerCommandAndArgs(manager.binaryPath, manager.configPath, endpoint)
if err != nil {
return err
}
args = append(args, "stack", "rm", "--detach=false", stack.Name)
return runCommandAndCaptureStdErr(command, args, nil, "")
}
func runCommandAndCaptureStdErr(command string, args []string, env []string, workingDir string) error {
var stderr bytes.Buffer
cmd := exec.Command(command, args...)
cmd.Stderr = &stderr
if workingDir != "" {
cmd.Dir = workingDir
}
if env != nil {
cmd.Env = os.Environ()
cmd.Env = append(cmd.Env, env...)
}
if err := cmd.Run(); err != nil {
return errors.New(stderr.String())
}
return nil
}
func (manager *SwarmStackManager) prepareDockerCommandAndArgs(binaryPath, configPath string, endpoint *portainer.Endpoint) (string, []string, error) {
// Assume Linux as a default
command := path.Join(binaryPath, "docker")
if runtime.GOOS == "windows" {
command = path.Join(binaryPath, "docker.exe")
}
args := make([]string, 0)
args = append(args, "--config", configPath)
endpointURL := endpoint.URL
if endpoint.Type == portainer.EdgeAgentOnDockerEnvironment {
tunnelAddr, err := manager.reverseTunnelService.TunnelAddr(endpoint)
if err != nil {
return "", nil, err
}
endpointURL = "tcp://" + tunnelAddr
}
args = append(args, "-H", endpointURL)
if endpoint.TLSConfig.TLS {
args = append(args, "--tls")
if !endpoint.TLSConfig.TLSSkipVerify {
args = append(args, "--tlsverify", "--tlscacert", endpoint.TLSConfig.TLSCACertPath)
} else {
args = append(args, "--tlscacert", "")
}
if endpoint.TLSConfig.TLSCertPath != "" && endpoint.TLSConfig.TLSKeyPath != "" {
args = append(args, "--tlscert", endpoint.TLSConfig.TLSCertPath, "--tlskey", endpoint.TLSConfig.TLSKeyPath)
}
}
return command, args, nil
}
func (manager *SwarmStackManager) updateDockerCLIConfiguration(configPath string) error {
configFilePath := path.Join(configPath, "config.json")
config, err := manager.retrieveConfigurationFromDisk(configFilePath)
if err != nil {
log.Warn().Err(err).Msg("unable to retrieve the Swarm configuration from disk, proceeding without it")
}
signature, err := manager.signatureService.CreateSignature(portainer.PortainerAgentSignatureMessage)
if err != nil {
return err
}
if config["HttpHeaders"] == nil {
config["HttpHeaders"] = make(map[string]any)
}
headersObject := config["HttpHeaders"].(map[string]any)
headersObject["X-PortainerAgent-ManagerOperation"] = "1"
headersObject["X-PortainerAgent-Signature"] = signature
headersObject["X-PortainerAgent-PublicKey"] = manager.signatureService.EncodedPublicKey()
return manager.fileService.WriteJSONToFile(configFilePath, config)
}
func (manager *SwarmStackManager) retrieveConfigurationFromDisk(path string) (map[string]any, error) {
var config map[string]any
raw, err := manager.fileService.GetFileContent(path, "")
if err != nil {
return make(map[string]any), nil
}
if err := json.Unmarshal(raw, &config); err != nil {
return nil, err
}
return config, nil
return manager.deployer.Remove(context.TODO(), stack.Name, swarm.RemoveOptions{
Options: swarm.Options{
Host: url,
},
})
}
// NormalizeStackName returns a new stack name with unsupported characters replaced.
func (manager *SwarmStackManager) NormalizeStackName(name string) string {
return stackNameNormalizeRegex.ReplaceAllString(strings.ToLower(name), "")
}
func configureFilePaths(args []string, filePaths []string) []string {
for _, path := range filePaths {
args = append(args, "--compose-file", path)
}
return args
return normalizeStackName(name)
}
-43
View File
@@ -1,43 +0,0 @@
package exec
import (
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestConfigFilePaths(t *testing.T) {
args := []string{"stack", "deploy", "--with-registry-auth"}
filePaths := []string{"dir/file", "dir/file-two", "dir/file-three"}
expected := []string{"stack", "deploy", "--with-registry-auth", "--compose-file", "dir/file", "--compose-file", "dir/file-two", "--compose-file", "dir/file-three"}
output := configureFilePaths(args, filePaths)
assert.ElementsMatch(t, expected, output, "wrong output file paths")
}
func TestPrepareDockerCommandAndArgs(t *testing.T) {
binaryPath := "/test/dist"
configPath := "/test/config"
manager := &SwarmStackManager{
binaryPath: binaryPath,
configPath: configPath,
}
endpoint := &portainer.Endpoint{
URL: "tcp://test:9000",
TLSConfig: portainer.TLSConfiguration{
TLS: true,
TLSSkipVerify: true,
},
}
command, args, err := manager.prepareDockerCommandAndArgs(binaryPath, configPath, endpoint)
require.NoError(t, err)
expectedCommand := "/test/dist/docker"
expectedArgs := []string{"--config", "/test/config", "-H", "tcp://test:9000", "--tls", "--tlscacert", ""}
require.Equal(t, expectedCommand, command)
require.Equal(t, expectedArgs, args)
}
+25 -19
View File
@@ -2,8 +2,6 @@ package filesystem
import (
"os"
"path"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
@@ -11,47 +9,52 @@ import (
)
func Test_copyFile_returnsError_whenSourceDoesNotExist(t *testing.T) {
t.Parallel()
tmpdir := t.TempDir()
err := copyFile("does-not-exist", tmpdir)
require.Error(t, err)
}
func Test_copyFile_shouldMakeAbackup(t *testing.T) {
t.Parallel()
tmpdir := t.TempDir()
content := []byte("content")
err := os.WriteFile(path.Join(tmpdir, "origin"), content, 0600)
err := os.WriteFile(JoinPaths(tmpdir, "origin"), content, 0600)
require.NoError(t, err)
err = copyFile(path.Join(tmpdir, "origin"), path.Join(tmpdir, "copy"))
err = copyFile(JoinPaths(tmpdir, "origin"), JoinPaths(tmpdir, "copy"))
require.NoError(t, err)
copyContent, err := os.ReadFile(path.Join(tmpdir, "copy"))
copyContent, err := os.ReadFile(JoinPaths(tmpdir, "copy"))
require.NoError(t, err)
assert.Equal(t, content, copyContent)
}
func Test_CopyDir_shouldCopyAllFilesAndDirectories(t *testing.T) {
t.Parallel()
destination := t.TempDir()
err := CopyDir("./testdata/copy_test", destination, true)
require.NoError(t, err)
assert.FileExists(t, filepath.Join(destination, "copy_test", "outer"))
assert.FileExists(t, filepath.Join(destination, "copy_test", "dir", ".dotfile"))
assert.FileExists(t, filepath.Join(destination, "copy_test", "dir", "inner"))
assert.FileExists(t, JoinPaths(destination, "copy_test", "outer"))
assert.FileExists(t, JoinPaths(destination, "copy_test", "dir", ".dotfile"))
assert.FileExists(t, JoinPaths(destination, "copy_test", "dir", "inner"))
}
func Test_CopyDir_shouldCopyOnlyDirContents(t *testing.T) {
t.Parallel()
destination := t.TempDir()
err := CopyDir("./testdata/copy_test", destination, false)
require.NoError(t, err)
assert.FileExists(t, filepath.Join(destination, "outer"))
assert.FileExists(t, filepath.Join(destination, "dir", ".dotfile"))
assert.FileExists(t, filepath.Join(destination, "dir", "inner"))
assert.FileExists(t, JoinPaths(destination, "outer"))
assert.FileExists(t, JoinPaths(destination, "dir", ".dotfile"))
assert.FileExists(t, JoinPaths(destination, "dir", "inner"))
}
func Test_CopyPath_shouldSkipWhenNotExist(t *testing.T) {
t.Parallel()
tmpdir := t.TempDir()
err := CopyPath("does-not-exists", tmpdir)
require.NoError(t, err)
@@ -60,36 +63,39 @@ func Test_CopyPath_shouldSkipWhenNotExist(t *testing.T) {
}
func Test_CopyPath_shouldCopyFile(t *testing.T) {
t.Parallel()
tmpdir := t.TempDir()
content := []byte("content")
err := os.WriteFile(path.Join(tmpdir, "file"), content, 0600)
err := os.WriteFile(JoinPaths(tmpdir, "file"), content, 0600)
require.NoError(t, err)
err = os.MkdirAll(path.Join(tmpdir, "backup"), 0700)
err = os.MkdirAll(JoinPaths(tmpdir, "backup"), 0700)
require.NoError(t, err)
err = CopyPath(path.Join(tmpdir, "file"), path.Join(tmpdir, "backup"))
err = CopyPath(JoinPaths(tmpdir, "file"), JoinPaths(tmpdir, "backup"))
require.NoError(t, err)
copyContent, err := os.ReadFile(path.Join(tmpdir, "backup", "file"))
copyContent, err := os.ReadFile(JoinPaths(tmpdir, "backup", "file"))
require.NoError(t, err)
assert.Equal(t, content, copyContent)
}
func Test_CopyPath_shouldCopyDir(t *testing.T) {
t.Parallel()
destination := t.TempDir()
err := CopyPath("./testdata/copy_test", destination)
require.NoError(t, err)
assert.FileExists(t, filepath.Join(destination, "copy_test", "outer"))
assert.FileExists(t, filepath.Join(destination, "copy_test", "dir", ".dotfile"))
assert.FileExists(t, filepath.Join(destination, "copy_test", "dir", "inner"))
assert.FileExists(t, JoinPaths(destination, "copy_test", "outer"))
assert.FileExists(t, JoinPaths(destination, "copy_test", "dir", ".dotfile"))
assert.FileExists(t, JoinPaths(destination, "copy_test", "dir", "inner"))
}
func TestCopyPathPanic(t *testing.T) {
t.Parallel()
dir := t.TempDir()
p := filepath.Join(dir, "myfile")
p := JoinPaths(dir, "myfile")
err := os.WriteFile(p, []byte("contents"), 0644)
require.NoError(t, err)
+1 -13
View File
@@ -46,8 +46,6 @@ const (
BinaryStorePath = "bin"
// EdgeJobStorePath represents the subfolder where schedule files are stored.
EdgeJobStorePath = "edge_jobs"
// DockerConfigPath represents the subfolder where docker configuration is stored.
DockerConfigPath = "docker_config"
// ExtensionRegistryManagementStorePath represents the subfolder where files related to the
// registry management extension are stored.
ExtensionRegistryManagementStorePath = "extensions"
@@ -91,7 +89,7 @@ func JoinPaths(trustedRoot string, untrustedPaths ...string) string {
trustedRoot = "."
}
p := filepath.Join(trustedRoot, filepath.Join(append([]string{"/"}, untrustedPaths...)...))
p := filepath.Join(trustedRoot, filepath.Join(append([]string{"/"}, untrustedPaths...)...)) //nolint:forbidigo
// avoid setting a volume name from the untrusted paths
vnp := filepath.VolumeName(p)
@@ -135,11 +133,6 @@ func NewService(dataStorePath, fileStorePath string) (*Service, error) {
return nil, err
}
err = service.createDirectoryInStore(DockerConfigPath)
if err != nil {
return nil, err
}
return service, nil
}
@@ -148,11 +141,6 @@ func (service *Service) GetBinaryFolder() string {
return JoinPaths(service.fileStorePath, BinaryStorePath)
}
// GetDockerConfigPath returns the full path to the docker config store on the filesystem
func (service *Service) GetDockerConfigPath() string {
return JoinPaths(service.fileStorePath, DockerConfigPath)
}
// RemoveDirectory removes a directory on the filesystem.
func (service *Service) RemoveDirectory(directoryPath string) error {
return os.RemoveAll(directoryPath)
+5 -2
View File
@@ -4,7 +4,6 @@ import (
"fmt"
"math/rand"
"os"
"path"
"testing"
"github.com/stretchr/testify/assert"
@@ -12,20 +11,24 @@ import (
)
func Test_fileSystemService_FileExists_whenFileExistsShouldReturnTrue(t *testing.T) {
t.Parallel()
service := createService(t)
testHelperFileExists_fileExists(t, service.FileExists)
}
func Test_fileSystemService_FileExists_whenFileNotExistsShouldReturnFalse(t *testing.T) {
t.Parallel()
service := createService(t)
testHelperFileExists_fileNotExists(t, service.FileExists)
}
func Test_FileExists_whenFileExistsShouldReturnTrue(t *testing.T) {
t.Parallel()
testHelperFileExists_fileExists(t, FileExists)
}
func Test_FileExists_whenFileNotExistsShouldReturnFalse(t *testing.T) {
t.Parallel()
testHelperFileExists_fileNotExists(t, FileExists)
}
@@ -45,7 +48,7 @@ func testHelperFileExists_fileExists(t *testing.T, checker func(path string) (bo
}
func testHelperFileExists_fileNotExists(t *testing.T, checker func(path string) (bool, error)) {
filePath := path.Join(t.TempDir(), fmt.Sprintf("%s%d", t.Name(), rand.Int()))
filePath := JoinPaths(t.TempDir(), fmt.Sprintf("%s%d", t.Name(), rand.Int()))
err := os.RemoveAll(filePath)
require.NoError(t, err, "RemoveAll should not fail")
+1
View File
@@ -3,6 +3,7 @@ package filesystem
import "testing"
func TestJoinPaths(t *testing.T) {
t.Parallel()
var ts = []struct {
trusted string
untrusted string
+10 -7
View File
@@ -2,7 +2,6 @@ package filesystem
import (
"os"
"path"
"testing"
"github.com/stretchr/testify/assert"
@@ -12,6 +11,7 @@ import (
var content = []byte("content")
func Test_movePath_shouldFailIfSourceDirDoesNotExist(t *testing.T) {
t.Parallel()
sourceDir := "missing"
destinationDir := t.TempDir()
file1 := addFile(t, destinationDir, "dir", "file")
@@ -24,6 +24,7 @@ func Test_movePath_shouldFailIfSourceDirDoesNotExist(t *testing.T) {
}
func Test_movePath_shouldFailIfDestinationDirExists(t *testing.T) {
t.Parallel()
sourceDir := t.TempDir()
file1 := addFile(t, sourceDir, "dir", "file")
file2 := addFile(t, sourceDir, "file")
@@ -40,6 +41,7 @@ func Test_movePath_shouldFailIfDestinationDirExists(t *testing.T) {
}
func Test_movePath_succesIfOverwriteSetWhenDestinationDirExists(t *testing.T) {
t.Parallel()
sourceDir := t.TempDir()
file1 := addFile(t, sourceDir, "dir", "file")
file2 := addFile(t, sourceDir, "file")
@@ -56,31 +58,32 @@ func Test_movePath_succesIfOverwriteSetWhenDestinationDirExists(t *testing.T) {
}
func Test_movePath_successWhenSourceExistsAndDestinationIsMissing(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
sourceDir := path.Join(tmp, "source")
sourceDir := JoinPaths(tmp, "source")
err := os.Mkdir(sourceDir, 0766)
require.NoError(t, err)
file1 := addFile(t, sourceDir, "dir", "file")
file2 := addFile(t, sourceDir, "file")
destinationDir := path.Join(tmp, "destination")
destinationDir := JoinPaths(tmp, "destination")
err = MoveDirectory(sourceDir, destinationDir, false)
require.NoError(t, err)
assert.NoFileExists(t, file1, "source dir contents should be moved")
assert.NoFileExists(t, file2, "source dir contents should be moved")
assertFileContent(t, path.Join(destinationDir, "file"))
assertFileContent(t, path.Join(destinationDir, "dir", "file"))
assertFileContent(t, JoinPaths(destinationDir, "file"))
assertFileContent(t, JoinPaths(destinationDir, "dir", "file"))
}
func addFile(t *testing.T, fileParts ...string) (filepath string) {
if len(fileParts) > 2 {
dir := path.Join(fileParts[:len(fileParts)-1]...)
dir := JoinPaths(fileParts[0], fileParts[1:len(fileParts)-1]...)
err := os.MkdirAll(dir, 0766)
require.NoError(t, err)
}
p := path.Join(fileParts...)
p := JoinPaths(fileParts[0], fileParts[1:]...)
err := os.WriteFile(p, content, 0766)
require.NoError(t, err)
+1 -2
View File
@@ -2,14 +2,13 @@ package filesystem
import (
"os"
"path"
"testing"
"github.com/stretchr/testify/require"
)
func createService(t *testing.T) *Service {
dataStorePath := path.Join(t.TempDir(), t.Name())
dataStorePath := JoinPaths(t.TempDir(), t.Name())
service, err := NewService(dataStorePath, "")
require.NoError(t, err, "NewService should not fail")
@@ -3,6 +3,7 @@ package filesystem
import "testing"
func TestJoinPaths(t *testing.T) {
t.Parallel()
var ts = []struct {
trusted string
untrusted string
@@ -10,6 +10,7 @@ import (
)
func TestMultiFilterDirForPerDevConfigs(t *testing.T) {
t.Parallel()
f := func(dirEntries []DirEntry, configPath string, multiFilterArgs MultiFilterArgs, wantDirEntries []DirEntry) {
t.Helper()
@@ -80,6 +81,7 @@ func TestMultiFilterDirForPerDevConfigs(t *testing.T) {
}
func TestMultiFilterDirForPerDevConfigsWithDefaults(t *testing.T) {
t.Parallel()
f := func(dirEntries []DirEntry, configPath string, multiFilterArgs MultiFilterArgs, defaultFilenames []string, wantDirEntries []DirEntry) {
t.Helper()
@@ -180,6 +182,7 @@ func TestMultiFilterDirForPerDevConfigsWithDefaults(t *testing.T) {
}
func TestMultiFilterDirForPerDevConfigsEnvFiles(t *testing.T) {
t.Parallel()
f := func(dirEntries []DirEntry, configPath string, multiFilterArgs MultiFilterArgs, wantEnvFiles []string) {
t.Helper()
@@ -204,6 +207,7 @@ func TestMultiFilterDirForPerDevConfigsEnvFiles(t *testing.T) {
}
func TestIsInConfigDir(t *testing.T) {
t.Parallel()
f := func(dirEntry DirEntry, configPath string, expect bool) {
t.Helper()
@@ -225,6 +229,7 @@ func TestIsInConfigDir(t *testing.T) {
}
func TestShouldIncludeDir(t *testing.T) {
t.Parallel()
f := func(dirEntry DirEntry, deviceName, configPath string, expect bool) {
t.Helper()
+6 -4
View File
@@ -2,7 +2,6 @@ package filesystem
import (
"os"
"path"
"testing"
"github.com/stretchr/testify/assert"
@@ -10,8 +9,9 @@ import (
)
func Test_WriteFile_CanStoreContentInANewFile(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
tmpFilePath := path.Join(tmpDir, "dummy")
tmpFilePath := JoinPaths(tmpDir, "dummy")
content := []byte("content")
err := WriteToFile(tmpFilePath, content)
@@ -22,8 +22,9 @@ func Test_WriteFile_CanStoreContentInANewFile(t *testing.T) {
}
func Test_WriteFile_CanOverwriteExistingFile(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
tmpFilePath := path.Join(tmpDir, "dummy")
tmpFilePath := JoinPaths(tmpDir, "dummy")
err := WriteToFile(tmpFilePath, []byte("content"))
require.NoError(t, err)
@@ -37,8 +38,9 @@ func Test_WriteFile_CanOverwriteExistingFile(t *testing.T) {
}
func Test_WriteFile_CanWriteANestedPath(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
tmpFilePath := path.Join(tmpDir, "dir", "sub-dir", "dummy")
tmpFilePath := JoinPaths(tmpDir, "dir", "sub-dir", "dummy")
content := []byte("content")
err := WriteToFile(tmpFilePath, content)
+26 -13
View File
@@ -1,13 +1,12 @@
package git
import (
"context"
"fmt"
"os"
"path/filepath"
"testing"
"time"
"github.com/portainer/portainer/api/filesystem"
gittypes "github.com/portainer/portainer/api/git/types"
_ "github.com/joho/godotenv/autoload"
@@ -18,10 +17,11 @@ import (
const privateAzureRepoURL = "https://portainer.visualstudio.com/gitops-test/_git/gitops-test"
func TestService_ClonePublicRepository_Azure(t *testing.T) {
t.Parallel()
ensureIntegrationTest(t)
pat := getRequiredValue(t, "AZURE_DEVOPS_PAT")
service := NewService(context.TODO())
service := NewService(t.Context())
type args struct {
repositoryURLFormat string
@@ -60,6 +60,7 @@ func TestService_ClonePublicRepository_Azure(t *testing.T) {
dst := t.TempDir()
repositoryUrl := fmt.Sprintf(tt.args.repositoryURLFormat, tt.args.password)
err := service.CloneRepository(
t.Context(),
dst,
repositoryUrl,
tt.args.referenceName,
@@ -68,20 +69,22 @@ func TestService_ClonePublicRepository_Azure(t *testing.T) {
false,
)
require.NoError(t, err)
assert.FileExists(t, filepath.Join(dst, "README.md"))
assert.FileExists(t, filesystem.JoinPaths(dst, "README.md"))
})
}
}
func TestService_ClonePrivateRepository_Azure(t *testing.T) {
t.Parallel()
ensureIntegrationTest(t)
pat := getRequiredValue(t, "AZURE_DEVOPS_PAT")
service := NewService(context.TODO())
service := NewService(t.Context())
dst := t.TempDir()
err := service.CloneRepository(
t.Context(),
dst,
privateAzureRepoURL,
"refs/heads/main",
@@ -90,16 +93,18 @@ func TestService_ClonePrivateRepository_Azure(t *testing.T) {
false,
)
require.NoError(t, err)
assert.FileExists(t, filepath.Join(dst, "README.md"))
assert.FileExists(t, filesystem.JoinPaths(dst, "README.md"))
}
func TestService_LatestCommitID_Azure(t *testing.T) {
t.Parallel()
ensureIntegrationTest(t)
pat := getRequiredValue(t, "AZURE_DEVOPS_PAT")
service := NewService(context.TODO())
service := NewService(t.Context())
id, err := service.LatestCommitID(
t.Context(),
privateAzureRepoURL,
"refs/heads/main",
"",
@@ -111,13 +116,15 @@ func TestService_LatestCommitID_Azure(t *testing.T) {
}
func TestService_ListRefs_Azure(t *testing.T) {
t.Parallel()
ensureIntegrationTest(t)
accessToken := getRequiredValue(t, "AZURE_DEVOPS_PAT")
username := getRequiredValue(t, "AZURE_DEVOPS_USERNAME")
service := NewService(context.TODO())
service := NewService(t.Context())
refs, err := service.ListRefs(
t.Context(),
privateAzureRepoURL,
username,
accessToken,
@@ -129,23 +136,25 @@ func TestService_ListRefs_Azure(t *testing.T) {
}
func TestService_ListRefs_Azure_Concurrently(t *testing.T) {
t.Parallel()
ensureIntegrationTest(t)
accessToken := getRequiredValue(t, "AZURE_DEVOPS_PAT")
username := getRequiredValue(t, "AZURE_DEVOPS_USERNAME")
service := newService(context.TODO(), repositoryCacheSize, 200*time.Millisecond)
service := newService(t.Context(), repositoryCacheSize, 200*time.Millisecond)
go func() {
_, _ = service.ListRefs(privateAzureRepoURL, username, accessToken, false, false)
_, _ = service.ListRefs(t.Context(), privateAzureRepoURL, username, accessToken, false, false)
}()
_, err := service.ListRefs(privateAzureRepoURL, username, accessToken, false, false)
_, err := service.ListRefs(t.Context(), privateAzureRepoURL, username, accessToken, false, false)
require.NoError(t, err)
time.Sleep(2 * time.Second)
}
func TestService_ListFiles_Azure(t *testing.T) {
t.Parallel()
ensureIntegrationTest(t)
type args struct {
@@ -162,7 +171,7 @@ func TestService_ListFiles_Azure(t *testing.T) {
matchedCount int
}
service := newService(context.TODO(), 0, 0)
service := newService(t.Context(), 0, 0)
accessToken := getRequiredValue(t, "AZURE_DEVOPS_PAT")
username := getRequiredValue(t, "AZURE_DEVOPS_USERNAME")
@@ -273,6 +282,7 @@ func TestService_ListFiles_Azure(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
paths, err := service.ListFiles(
t.Context(),
tt.args.repositoryUrl,
tt.args.referenceName,
tt.args.username,
@@ -299,14 +309,16 @@ func TestService_ListFiles_Azure(t *testing.T) {
}
func TestService_ListFiles_Azure_Concurrently(t *testing.T) {
t.Parallel()
ensureIntegrationTest(t)
accessToken := getRequiredValue(t, "AZURE_DEVOPS_PAT")
username := getRequiredValue(t, "AZURE_DEVOPS_USERNAME")
service := newService(context.TODO(), repositoryCacheSize, 200*time.Millisecond)
service := newService(t.Context(), repositoryCacheSize, 200*time.Millisecond)
go func() {
_, _ = service.ListFiles(
t.Context(),
privateAzureRepoURL,
"refs/heads/main",
username,
@@ -319,6 +331,7 @@ func TestService_ListFiles_Azure_Concurrently(t *testing.T) {
}()
_, err := service.ListFiles(
t.Context(),
privateAzureRepoURL,
"refs/heads/main",
username,
+16 -5
View File
@@ -18,6 +18,7 @@ import (
)
func Test_buildDownloadUrl(t *testing.T) {
t.Parallel()
a := NewAzureClient()
u, err := a.buildDownloadUrl(&azureOptions{
organisation: "organisation",
@@ -39,6 +40,7 @@ func Test_buildDownloadUrl(t *testing.T) {
}
func Test_buildRootItemUrl(t *testing.T) {
t.Parallel()
a := NewAzureClient()
u, err := a.buildRootItemUrl(&azureOptions{
organisation: "organisation",
@@ -56,6 +58,7 @@ func Test_buildRootItemUrl(t *testing.T) {
}
func Test_buildRefsUrl(t *testing.T) {
t.Parallel()
a := NewAzureClient()
u, err := a.buildRefsUrl(&azureOptions{
organisation: "organisation",
@@ -73,6 +76,7 @@ func Test_buildRefsUrl(t *testing.T) {
}
func Test_buildTreeUrl(t *testing.T) {
t.Parallel()
a := NewAzureClient()
u, err := a.buildTreeUrl(&azureOptions{
organisation: "organisation",
@@ -90,6 +94,7 @@ func Test_buildTreeUrl(t *testing.T) {
}
func Test_parseAzureUrl(t *testing.T) {
t.Parallel()
type args struct {
url string
}
@@ -205,6 +210,7 @@ func Test_parseAzureUrl(t *testing.T) {
}
func Test_isAzureUrl(t *testing.T) {
t.Parallel()
type args struct {
s string
}
@@ -243,6 +249,7 @@ func Test_isAzureUrl(t *testing.T) {
}
func Test_azureDownloader_downloadZipFromAzureDevOps(t *testing.T) {
t.Parallel()
fips.InitFIPS(false)
type args struct {
@@ -311,7 +318,7 @@ func Test_azureDownloader_downloadZipFromAzureDevOps(t *testing.T) {
Password: tt.args.password,
}
}
_, err := a.downloadZipFromAzureDevOps(context.Background(), option)
_, err := a.downloadZipFromAzureDevOps(t.Context(), option)
require.Error(t, err)
assert.Equal(t, tt.want, zipRequestAuth)
})
@@ -319,6 +326,7 @@ func Test_azureDownloader_downloadZipFromAzureDevOps(t *testing.T) {
}
func Test_azureDownloader_latestCommitID(t *testing.T) {
t.Parallel()
fips.InitFIPS(false)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -367,7 +375,7 @@ func Test_azureDownloader_latestCommitID(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
id, err := a.LatestCommitID(context.Background(), tt.args.repositoryUrl, tt.args.referenceName, &git.ListOptions{})
id, err := a.LatestCommitID(t.Context(), tt.args.repositoryUrl, tt.args.referenceName, &git.ListOptions{})
if (err != nil) != tt.wantErr {
t.Errorf("azureDownloader.latestCommitID() error = %v, wantErr %v", err, tt.wantErr)
return
@@ -399,6 +407,7 @@ func (t *testRepoManager) ListFiles(_ context.Context, _ bool, _ *git.CloneOptio
}
func Test_cloneRepository_azure(t *testing.T) {
t.Parallel()
tests := []struct {
name string
url string
@@ -427,7 +436,7 @@ func Test_cloneRepository_azure(t *testing.T) {
git := &testRepoManager{}
s := &Service{azure: azure, git: git}
err := s.CloneRepository("", tt.url, "", "", "", false)
err := s.CloneRepository(t.Context(), "", tt.url, "", "", "", false)
require.NoError(t, err)
// if azure API is called, git isn't and vice versa
@@ -438,6 +447,7 @@ func Test_cloneRepository_azure(t *testing.T) {
}
func Test_listRefs_azure(t *testing.T) {
t.Parallel()
ensureIntegrationTest(t)
client := NewAzureClient()
@@ -517,7 +527,7 @@ func Test_listRefs_azure(t *testing.T) {
Password: tt.args.password,
}
}
refs, err := client.ListRefs(context.TODO(), tt.args.repositoryUrl, option)
refs, err := client.ListRefs(t.Context(), tt.args.repositoryUrl, option)
if tt.expect.err == nil {
require.NoError(t, err)
if tt.expect.refsCount > 0 {
@@ -532,6 +542,7 @@ func Test_listRefs_azure(t *testing.T) {
}
func Test_listFiles_azure(t *testing.T) {
t.Parallel()
ensureIntegrationTest(t)
client := NewAzureClient()
@@ -634,7 +645,7 @@ func Test_listFiles_azure(t *testing.T) {
Password: tt.args.password,
}
}
paths, err := client.ListFiles(context.TODO(), false, option)
paths, err := client.ListFiles(t.Context(), false, option)
if tt.expect.shouldFail {
require.Error(t, err)
if tt.expect.err != nil {
+4 -1
View File
@@ -1,6 +1,8 @@
package git
import (
"context"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/filesystem"
gittypes "github.com/portainer/portainer/api/git/types"
@@ -23,7 +25,7 @@ type CloneOptions struct {
TLSSkipVerify bool `example:"false"`
}
func CloneWithBackup(gitService portainer.GitService, fileService portainer.FileService, options CloneOptions) (clean func(), err error) {
func CloneWithBackup(ctx context.Context, gitService portainer.GitService, fileService portainer.FileService, options CloneOptions) (clean func(), err error) {
backupProjectPath := options.ProjectPath + "-old"
cleanUp := false
cleanFn := func() {
@@ -43,6 +45,7 @@ func CloneWithBackup(gitService portainer.GitService, fileService portainer.File
cleanUp = true
if err := gitService.CloneRepository(
ctx,
options.ProjectPath,
options.URL,
options.ReferenceName,
+3 -3
View File
@@ -4,10 +4,10 @@ import (
gittypes "github.com/portainer/portainer/api/git/types"
)
func GetCredentials(auth *gittypes.GitAuthentication) (string, string, error) {
func GetCredentials(auth *gittypes.GitAuthentication) (string, string) {
if auth == nil {
return "", "", nil
return "", ""
}
return auth.Username, auth.Password, nil
return auth.Username, auth.Password
}
+37 -9
View File
@@ -3,20 +3,39 @@ package git
import (
"context"
"os"
"path/filepath"
"strings"
"github.com/portainer/portainer/api/filesystem"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/rs/zerolog/log"
"github.com/go-git/go-billy/v5"
"github.com/go-git/go-billy/v5/osfs"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/config"
"github.com/go-git/go-git/v5/plumbing/cache"
"github.com/go-git/go-git/v5/plumbing/filemode"
"github.com/go-git/go-git/v5/plumbing/object"
gogitfs "github.com/go-git/go-git/v5/storage/filesystem"
"github.com/go-git/go-git/v5/storage/memory"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
)
// noSymlinkFS wraps a billy.Filesystem and rejects symlink creation to prevent
// symlink traversal attacks from untrusted git repositories
type noSymlinkFS struct {
billy.Filesystem
}
func (fs noSymlinkFS) Symlink(_, _ string) error {
return gittypes.ErrSymlinkDetected
}
// NewNoSymlinkFS wraps fs and rejects any symlink creation
func NewNoSymlinkFS(fs billy.Filesystem) billy.Filesystem {
return noSymlinkFS{fs}
}
type gitClient struct {
preserveGitDirectory bool
}
@@ -28,19 +47,25 @@ func NewGitClient(preserveGitDir bool) *gitClient {
}
func (c *gitClient) Download(ctx context.Context, dst string, opt *git.CloneOptions) error {
_, err := git.PlainCloneContext(ctx, dst, false, opt)
wt := NewNoSymlinkFS(osfs.New(dst))
dot := osfs.New(filesystem.JoinPaths(dst, ".git"))
storer := gogitfs.NewStorage(dot, cache.NewObjectLRU(0))
_, err := git.CloneContext(ctx, storer, wt, opt)
if err != nil {
if err.Error() == "authentication required" {
return gittypes.ErrAuthenticationFailure
}
return errors.Wrap(err, "failed to clone git repository")
}
if !c.preserveGitDirectory {
err := os.RemoveAll(filepath.Join(dst, ".git"))
if err != nil {
log.Error().Err(err).Msg("failed to remove .git directory")
}
if c.preserveGitDirectory {
return nil
}
if err := os.RemoveAll(filesystem.JoinPaths(dst, ".git")); err != nil {
log.Error().Err(err).Msg("failed to remove .git directory")
}
return nil
@@ -57,6 +82,7 @@ func (c *gitClient) LatestCommitID(ctx context.Context, repositoryUrl, reference
if err.Error() == "authentication required" {
return "", gittypes.ErrAuthenticationFailure
}
return "", errors.Wrap(err, "failed to list repository refs")
}
@@ -93,6 +119,7 @@ func (c *gitClient) ListRefs(ctx context.Context, repositoryUrl string, opt *git
if ref.Name().String() == "HEAD" {
continue
}
ret = append(ret, ref.Name().String())
}
@@ -143,10 +170,11 @@ func (c *gitClient) ListFiles(ctx context.Context, dirOnly bool, opt *git.CloneO
func checkGitError(err error) error {
errMsg := err.Error()
if errMsg == "repository not found" {
if strings.Contains(errMsg, "repository not found") {
return gittypes.ErrIncorrectRepositoryURL
} else if errMsg == "authentication required" {
return gittypes.ErrAuthenticationFailure
}
return err
}
+48 -38
View File
@@ -1,13 +1,12 @@
package git
import (
"context"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
"github.com/portainer/portainer/api/filesystem"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/stretchr/testify/assert"
@@ -19,16 +18,18 @@ const (
)
func TestService_ClonePrivateRepository_GitHub(t *testing.T) {
t.Parallel()
ensureIntegrationTest(t)
accessToken := getRequiredValue(t, "GITHUB_PAT")
username := getRequiredValue(t, "GITHUB_USERNAME")
service := newService(context.TODO(), 0, 0)
service := newService(t.Context(), 0, 0)
dst := t.TempDir()
repositoryUrl := privateGitRepoURL
err := service.CloneRepository(
t.Context(),
dst,
repositoryUrl,
"refs/heads/main",
@@ -37,18 +38,20 @@ func TestService_ClonePrivateRepository_GitHub(t *testing.T) {
false,
)
require.NoError(t, err)
assert.FileExists(t, filepath.Join(dst, "README.md"))
assert.FileExists(t, filesystem.JoinPaths(dst, "README.md"))
}
func TestService_LatestCommitID_GitHub(t *testing.T) {
t.Parallel()
ensureIntegrationTest(t)
accessToken := getRequiredValue(t, "GITHUB_PAT")
username := getRequiredValue(t, "GITHUB_USERNAME")
service := newService(context.TODO(), 0, 0)
service := newService(t.Context(), 0, 0)
repositoryUrl := privateGitRepoURL
id, err := service.LatestCommitID(
t.Context(),
repositoryUrl,
"refs/heads/main",
username,
@@ -60,37 +63,40 @@ func TestService_LatestCommitID_GitHub(t *testing.T) {
}
func TestService_ListRefs_GitHub(t *testing.T) {
t.Parallel()
ensureIntegrationTest(t)
accessToken := getRequiredValue(t, "GITHUB_PAT")
username := getRequiredValue(t, "GITHUB_USERNAME")
service := newService(context.TODO(), 0, 0)
service := newService(t.Context(), 0, 0)
repositoryUrl := privateGitRepoURL
refs, err := service.ListRefs(repositoryUrl, username, accessToken, false, false)
refs, err := service.ListRefs(t.Context(), repositoryUrl, username, accessToken, false, false)
require.NoError(t, err)
assert.GreaterOrEqual(t, len(refs), 1)
}
func TestService_ListRefs_Github_Concurrently(t *testing.T) {
t.Parallel()
ensureIntegrationTest(t)
accessToken := getRequiredValue(t, "GITHUB_PAT")
username := getRequiredValue(t, "GITHUB_USERNAME")
service := newService(context.TODO(), repositoryCacheSize, 200*time.Millisecond)
service := newService(t.Context(), repositoryCacheSize, 200*time.Millisecond)
repositoryUrl := privateGitRepoURL
go func() {
_, _ = service.ListRefs(repositoryUrl, username, accessToken, false, false)
_, _ = service.ListRefs(t.Context(), repositoryUrl, username, accessToken, false, false)
}()
_, err := service.ListRefs(repositoryUrl, username, accessToken, false, false)
_, err := service.ListRefs(t.Context(), repositoryUrl, username, accessToken, false, false)
require.NoError(t, err)
time.Sleep(2 * time.Second)
}
func TestService_ListFiles_GitHub(t *testing.T) {
t.Parallel()
ensureIntegrationTest(t)
type args struct {
@@ -106,7 +112,7 @@ func TestService_ListFiles_GitHub(t *testing.T) {
err error
matchedCount int
}
service := newService(context.TODO(), 0, 0)
service := newService(t.Context(), 0, 0)
accessToken := getRequiredValue(t, "GITHUB_PAT")
username := getRequiredValue(t, "GITHUB_USERNAME")
@@ -217,6 +223,7 @@ func TestService_ListFiles_GitHub(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
paths, err := service.ListFiles(
t.Context(),
tt.args.repositoryUrl,
tt.args.referenceName,
tt.args.username,
@@ -242,15 +249,17 @@ func TestService_ListFiles_GitHub(t *testing.T) {
}
func TestService_ListFiles_Github_Concurrently(t *testing.T) {
t.Parallel()
ensureIntegrationTest(t)
repositoryUrl := privateGitRepoURL
accessToken := getRequiredValue(t, "GITHUB_PAT")
username := getRequiredValue(t, "GITHUB_USERNAME")
service := newService(context.TODO(), repositoryCacheSize, 200*time.Millisecond)
service := newService(t.Context(), repositoryCacheSize, 200*time.Millisecond)
go func() {
_, _ = service.ListFiles(
t.Context(),
repositoryUrl,
"refs/heads/main",
username,
@@ -263,6 +272,7 @@ func TestService_ListFiles_Github_Concurrently(t *testing.T) {
}()
_, err := service.ListFiles(
t.Context(),
repositoryUrl,
"refs/heads/main",
username,
@@ -278,17 +288,19 @@ func TestService_ListFiles_Github_Concurrently(t *testing.T) {
}
func TestService_purgeCache_Github(t *testing.T) {
t.Parallel()
ensureIntegrationTest(t)
repositoryUrl := privateGitRepoURL
accessToken := getRequiredValue(t, "GITHUB_PAT")
username := getRequiredValue(t, "GITHUB_USERNAME")
service := NewService(context.TODO())
service := NewService(t.Context())
_, err := service.ListRefs(repositoryUrl, username, accessToken, false, false)
_, err := service.ListRefs(t.Context(), repositoryUrl, username, accessToken, false, false)
require.NoError(t, err)
_, err = service.ListFiles(
t.Context(),
repositoryUrl,
"refs/heads/main",
username,
@@ -309,6 +321,7 @@ func TestService_purgeCache_Github(t *testing.T) {
}
func TestService_purgeCacheByTTL_Github(t *testing.T) {
t.Parallel()
ensureIntegrationTest(t)
timeout := 100 * time.Millisecond
@@ -316,11 +329,12 @@ func TestService_purgeCacheByTTL_Github(t *testing.T) {
accessToken := getRequiredValue(t, "GITHUB_PAT")
username := getRequiredValue(t, "GITHUB_USERNAME")
// 40*timeout is designed for giving enough time for ListRefs and ListFiles to cache the result
service := newService(context.TODO(), 2, 40*timeout)
service := newService(t.Context(), 2, 40*timeout)
_, err := service.ListRefs(repositoryUrl, username, accessToken, false, false)
_, err := service.ListRefs(t.Context(), repositoryUrl, username, accessToken, false, false)
require.NoError(t, err)
_, err = service.ListFiles(
t.Context(),
repositoryUrl,
"refs/heads/main",
username,
@@ -340,51 +354,41 @@ func TestService_purgeCacheByTTL_Github(t *testing.T) {
assert.Equal(t, 0, service.repoFileCache.Len())
}
func TestService_canStopCacheCleanTimer_whenContextDone(t *testing.T) {
timeout := 10 * time.Millisecond
deadlineCtx, cancel := context.WithDeadline(context.TODO(), time.Now().Add(10*timeout))
defer cancel()
service := NewService(deadlineCtx)
assert.False(t, service.timerHasStopped(), "timer should not be stopped")
<-time.After(20 * timeout)
assert.True(t, service.timerHasStopped(), "timer should be stopped")
}
func TestService_HardRefresh_ListRefs_GitHub(t *testing.T) {
t.Parallel()
ensureIntegrationTest(t)
accessToken := getRequiredValue(t, "GITHUB_PAT")
username := getRequiredValue(t, "GITHUB_USERNAME")
service := newService(context.TODO(), 2, 0)
service := newService(t.Context(), 2, 0)
repositoryUrl := privateGitRepoURL
refs, err := service.ListRefs(repositoryUrl, username, accessToken, false, false)
refs, err := service.ListRefs(t.Context(), repositoryUrl, username, accessToken, false, false)
require.NoError(t, err)
assert.GreaterOrEqual(t, len(refs), 1)
assert.Equal(t, 1, service.repoRefCache.Len())
_, err = service.ListRefs(repositoryUrl, username, "fake-token", false, false)
_, err = service.ListRefs(t.Context(), repositoryUrl, username, "fake-token", false, false)
require.Error(t, err)
assert.Equal(t, 1, service.repoRefCache.Len())
}
func TestService_HardRefresh_ListRefs_And_RemoveAllCaches_GitHub(t *testing.T) {
t.Parallel()
ensureIntegrationTest(t)
accessToken := getRequiredValue(t, "GITHUB_PAT")
username := getRequiredValue(t, "GITHUB_USERNAME")
service := newService(context.TODO(), 2, 0)
service := newService(t.Context(), 2, 0)
repositoryUrl := privateGitRepoURL
refs, err := service.ListRefs(repositoryUrl, username, accessToken, false, false)
refs, err := service.ListRefs(t.Context(), repositoryUrl, username, accessToken, false, false)
require.NoError(t, err)
assert.GreaterOrEqual(t, len(refs), 1)
assert.Equal(t, 1, service.repoRefCache.Len())
files, err := service.ListFiles(
t.Context(),
repositoryUrl,
"refs/heads/main",
username,
@@ -399,6 +403,7 @@ func TestService_HardRefresh_ListRefs_And_RemoveAllCaches_GitHub(t *testing.T) {
assert.Equal(t, 1, service.repoFileCache.Len())
files, err = service.ListFiles(
t.Context(),
repositoryUrl,
"refs/heads/test",
username,
@@ -412,11 +417,11 @@ func TestService_HardRefresh_ListRefs_And_RemoveAllCaches_GitHub(t *testing.T) {
assert.GreaterOrEqual(t, len(files), 1)
assert.Equal(t, 2, service.repoFileCache.Len())
_, err = service.ListRefs(repositoryUrl, username, "fake-token", false, false)
_, err = service.ListRefs(t.Context(), repositoryUrl, username, "fake-token", false, false)
require.Error(t, err)
assert.Equal(t, 1, service.repoRefCache.Len())
_, err = service.ListRefs(repositoryUrl, username, "fake-token", true, false)
_, err = service.ListRefs(t.Context(), repositoryUrl, username, "fake-token", true, false)
require.Error(t, err)
assert.Equal(t, 1, service.repoRefCache.Len())
// The relevant file caches should be removed too
@@ -424,13 +429,15 @@ func TestService_HardRefresh_ListRefs_And_RemoveAllCaches_GitHub(t *testing.T) {
}
func TestService_HardRefresh_ListFiles_GitHub(t *testing.T) {
t.Parallel()
ensureIntegrationTest(t)
service := newService(context.TODO(), 2, 0)
service := newService(t.Context(), 2, 0)
accessToken := getRequiredValue(t, "GITHUB_PAT")
username := getRequiredValue(t, "GITHUB_USERNAME")
repositoryUrl := privateGitRepoURL
files, err := service.ListFiles(
t.Context(),
repositoryUrl,
"refs/heads/main",
username,
@@ -445,6 +452,7 @@ func TestService_HardRefresh_ListFiles_GitHub(t *testing.T) {
assert.Equal(t, 1, service.repoFileCache.Len())
_, err = service.ListFiles(
t.Context(),
repositoryUrl,
"refs/heads/main",
username,
@@ -459,9 +467,10 @@ func TestService_HardRefresh_ListFiles_GitHub(t *testing.T) {
}
func TestService_CloneRepository_TokenAuth(t *testing.T) {
t.Parallel()
ensureIntegrationTest(t)
service := newService(context.TODO(), 2, 0)
service := newService(t.Context(), 2, 0)
var requests []*http.Request
testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests = append(requests, r)
@@ -472,6 +481,7 @@ func TestService_CloneRepository_TokenAuth(t *testing.T) {
// Since we aren't hitting a real git server we ignore the error
_ = service.CloneRepository(
t.Context(),
"test_dir",
repositoryUrl,
"refs/heads/main",
+163 -10
View File
@@ -1,16 +1,18 @@
package git
import (
"context"
"os"
"path/filepath"
"testing"
"time"
"github.com/portainer/portainer/api/archive"
"github.com/portainer/portainer/api/filesystem"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/go-git/go-billy/v5/osfs"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/filemode"
"github.com/go-git/go-git/v5/plumbing/object"
githttp "github.com/go-git/go-git/v5/plumbing/transport/http"
"github.com/pkg/errors"
@@ -20,7 +22,7 @@ import (
func setup(t *testing.T) string {
dir := t.TempDir()
bareRepoDir := filepath.Join(dir, "test-clone.git")
bareRepoDir := filesystem.JoinPaths(dir, "test-clone.git")
file, err := os.OpenFile("./testdata/test-clone-git-repo.tar.gz", os.O_RDONLY, 0o755)
if err != nil {
@@ -34,60 +36,103 @@ func setup(t *testing.T) string {
return bareRepoDir
}
func Test_checkGitError(t *testing.T) {
t.Parallel()
tests := []struct {
name string
err error
expected error
}{
{
name: "exact repository not found",
err: errors.New("repository not found"),
expected: gittypes.ErrIncorrectRepositoryURL,
},
{
name: "repository not found with html body",
err: errors.New("repository not found: <html><body>404 Not Found</body></html>"),
expected: gittypes.ErrIncorrectRepositoryURL,
},
{
name: "authentication required",
err: errors.New("authentication required"),
expected: gittypes.ErrAuthenticationFailure,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := checkGitError(tt.err)
assert.Equal(t, tt.expected, result)
})
}
t.Run("other error is unchanged", func(t *testing.T) {
err := errors.New("some other git error")
assert.EqualError(t, checkGitError(err), "some other git error")
})
}
func Test_ClonePublicRepository_Shallow(t *testing.T) {
t.Parallel()
service := Service{git: NewGitClient(true)} // no need for http client since the test access the repo via file system.
repositoryURL := setup(t)
referenceName := "refs/heads/main"
dir := t.TempDir()
t.Logf("Cloning into %s", dir)
err := service.CloneRepository(dir, repositoryURL, referenceName, "", "", false)
err := service.CloneRepository(t.Context(), dir, repositoryURL, referenceName, "", "", false)
require.NoError(t, err)
assert.Equal(t, 1, getCommitHistoryLength(t, dir), "cloned repo has incorrect depth")
}
func Test_ClonePublicRepository_NoGitDirectory(t *testing.T) {
t.Parallel()
service := Service{git: NewGitClient(false)} // no need for http client since the test access the repo via file system.
repositoryURL := setup(t)
referenceName := "refs/heads/main"
dir := t.TempDir()
t.Logf("Cloning into %s", dir)
err := service.CloneRepository(dir, repositoryURL, referenceName, "", "", false)
err := service.CloneRepository(t.Context(), dir, repositoryURL, referenceName, "", "", false)
require.NoError(t, err)
assert.NoDirExists(t, filepath.Join(dir, ".git"))
assert.NoDirExists(t, filesystem.JoinPaths(dir, ".git"))
}
func Test_latestCommitID(t *testing.T) {
t.Parallel()
service := Service{git: NewGitClient(true)} // no need for http client since the test access the repo via file system.
repositoryURL := setup(t)
referenceName := "refs/heads/main"
id, err := service.LatestCommitID(repositoryURL, referenceName, "", "", false)
id, err := service.LatestCommitID(t.Context(), repositoryURL, referenceName, "", "", false)
require.NoError(t, err)
assert.Equal(t, "68dcaa7bd452494043c64252ab90db0f98ecf8d2", id)
}
func Test_ListRefs(t *testing.T) {
t.Parallel()
service := Service{git: NewGitClient(true)}
repositoryURL := setup(t)
fs, err := service.ListRefs(repositoryURL, "", "", false, false)
fs, err := service.ListRefs(t.Context(), repositoryURL, "", "", false, false)
require.NoError(t, err)
assert.Equal(t, []string{"refs/heads/main"}, fs)
}
func Test_ListFiles(t *testing.T) {
t.Parallel()
service := Service{git: NewGitClient(true)}
repositoryURL := setup(t)
referenceName := "refs/heads/main"
fs, err := service.ListFiles(
t.Context(),
repositoryURL,
referenceName,
"",
@@ -124,7 +169,114 @@ func getCommitHistoryLength(t *testing.T, dir string) int {
return count
}
func Test_noSymlinkFS_Symlink(t *testing.T) {
fs := NewNoSymlinkFS(osfs.New(t.TempDir()))
err := fs.Symlink("../../../etc/passwd", "evil-link")
require.ErrorIs(t, err, gittypes.ErrSymlinkDetected)
}
func Test_noSymlinkFS_OtherOperations(t *testing.T) {
dir := t.TempDir()
fs := NewNoSymlinkFS(osfs.New(dir))
f, err := fs.Create("test.txt")
require.NoError(t, err)
_, err = f.Write([]byte("hello"))
require.NoError(t, err)
err = f.Close()
require.NoError(t, err)
info, err := fs.Stat("test.txt")
require.NoError(t, err)
require.Equal(t, "test.txt", info.Name())
}
func createBareRepoWithSymlink(t *testing.T) string {
t.Helper()
bareDir := filesystem.JoinPaths(t.TempDir(), "symlink-repo.git")
repo, err := git.PlainInit(bareDir, true)
require.NoError(t, err)
storer := repo.Storer
fileBlob := &plumbing.MemoryObject{}
fileBlob.SetType(plumbing.BlobObject)
_, err = fileBlob.Write([]byte("hello world\n"))
require.NoError(t, err)
fileHash, err := storer.SetEncodedObject(fileBlob)
require.NoError(t, err)
symlinkBlob := &plumbing.MemoryObject{}
symlinkBlob.SetType(plumbing.BlobObject)
_, err = symlinkBlob.Write([]byte("../../../etc/passwd"))
require.NoError(t, err)
symlinkHash, err := storer.SetEncodedObject(symlinkBlob)
require.NoError(t, err)
tree := &object.Tree{
Entries: []object.TreeEntry{
{Name: "evil-link", Mode: filemode.Symlink, Hash: symlinkHash},
{Name: "file.txt", Mode: filemode.Regular, Hash: fileHash},
},
}
treeObj := &plumbing.MemoryObject{}
err = tree.Encode(treeObj)
require.NoError(t, err)
treeHash, err := storer.SetEncodedObject(treeObj)
require.NoError(t, err)
sig := object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()}
commit := &object.Commit{
Message: "add symlink",
Author: sig,
Committer: sig,
TreeHash: treeHash,
}
commitObj := &plumbing.MemoryObject{}
err = commit.Encode(commitObj)
require.NoError(t, err)
commitHash, err := storer.SetEncodedObject(commitObj)
require.NoError(t, err)
err = storer.SetReference(plumbing.NewHashReference("refs/heads/main", commitHash))
require.NoError(t, err)
err = storer.SetReference(plumbing.NewSymbolicReference(plumbing.HEAD, "refs/heads/main"))
require.NoError(t, err)
return bareDir
}
func Test_Download_RejectsSymlink(t *testing.T) {
client := NewGitClient(false)
repoURL := createBareRepoWithSymlink(t)
err := client.Download(t.Context(), t.TempDir(), &git.CloneOptions{
URL: repoURL,
Depth: 1,
SingleBranch: true,
Tags: git.NoTags,
})
require.Error(t, err)
require.ErrorIs(t, err, gittypes.ErrSymlinkDetected)
}
func Test_listRefsPrivateRepository(t *testing.T) {
t.Parallel()
ensureIntegrationTest(t)
accessToken := getRequiredValue(t, "GITHUB_PAT")
@@ -204,7 +356,7 @@ func Test_listRefsPrivateRepository(t *testing.T) {
Password: tt.args.password,
}
}
refs, err := client.ListRefs(context.TODO(), tt.args.repositoryUrl, option)
refs, err := client.ListRefs(t.Context(), tt.args.repositoryUrl, option)
if tt.expect.err == nil {
require.NoError(t, err)
if tt.expect.refsCount > 0 {
@@ -219,6 +371,7 @@ func Test_listRefsPrivateRepository(t *testing.T) {
}
func Test_listFilesPrivateRepository(t *testing.T) {
t.Parallel()
ensureIntegrationTest(t)
client := NewGitClient(false)
@@ -322,7 +475,7 @@ func Test_listFilesPrivateRepository(t *testing.T) {
Password: tt.args.password,
}
}
paths, err := client.ListFiles(context.TODO(), false, option)
paths, err := client.ListFiles(t.Context(), false, option)
if tt.expect.shouldFail {
require.Error(t, err)
if tt.expect.err != nil {
+113 -98
View File
@@ -4,11 +4,13 @@ import (
"context"
"strconv"
"strings"
"sync"
"time"
"github.com/portainer/portainer/pkg/schedule"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/transport"
githttp "github.com/go-git/go-git/v5/plumbing/transport/http"
lru "github.com/hashicorp/golang-lru"
"github.com/rs/zerolog/log"
@@ -29,11 +31,8 @@ type RepoManager interface {
// Service represents a service for managing Git.
type Service struct {
shutdownCtx context.Context
azure RepoManager
git RepoManager
timerStopped bool
mut sync.Mutex
azure RepoManager
git RepoManager
cacheEnabled bool
// Cache the result of repository refs, key is repository URL
@@ -49,75 +48,62 @@ func NewService(ctx context.Context) *Service {
func newService(ctx context.Context, cacheSize int, cacheTTL time.Duration) *Service {
service := &Service{
shutdownCtx: ctx,
azure: NewAzureClient(),
git: NewGitClient(false),
timerStopped: false,
cacheEnabled: cacheSize > 0,
}
if service.cacheEnabled {
var err error
service.repoRefCache, err = lru.New(cacheSize)
if err != nil {
log.Debug().Err(err).Msg("failed to create ref cache")
}
if !service.cacheEnabled {
return service
}
service.repoFileCache, err = lru.New(cacheSize)
if err != nil {
log.Debug().Err(err).Msg("failed to create file cache")
}
var err error
service.repoRefCache, err = lru.New(cacheSize)
if err != nil {
log.Debug().Err(err).Msg("failed to create ref cache")
}
if cacheTTL > 0 {
go service.startCacheCleanTimer(cacheTTL)
}
service.repoFileCache, err = lru.New(cacheSize)
if err != nil {
log.Debug().Err(err).Msg("failed to create file cache")
}
if cacheTTL > 0 {
go schedule.RunOnInterval(ctx, cacheTTL, service.purgeCache, nil)
}
return service
}
// startCacheCleanTimer starts a timer to purge caches periodically
func (service *Service) startCacheCleanTimer(d time.Duration) {
ticker := time.NewTicker(d)
for {
select {
case <-ticker.C:
service.purgeCache()
case <-service.shutdownCtx.Done():
ticker.Stop()
service.mut.Lock()
service.timerStopped = true
service.mut.Unlock()
return
}
}
}
// timerHasStopped shows the CacheClean timer state with thread-safe way
func (service *Service) timerHasStopped() bool {
service.mut.Lock()
defer service.mut.Unlock()
ret := service.timerStopped
return ret
}
// CloneRepository clones a git repository using the specified URL in the specified
// destination folder.
func (service *Service) CloneRepository(
ctx context.Context,
destination,
repositoryURL,
referenceName,
username,
password string,
tlsSkipVerify bool,
) error {
return service.CloneRepositoryWithAuth(ctx, destination, repositoryURL, referenceName, GetBasicAuth(username, password), tlsSkipVerify)
}
// CloneRepositoryWithAuth clones a git repository using the specified URL in the specified
// destination folder, using the provided auth method.
func (service *Service) CloneRepositoryWithAuth(
ctx context.Context,
destination,
repositoryURL,
referenceName string,
auth transport.AuthMethod,
tlsSkipVerify bool,
) error {
gitOptions := &git.CloneOptions{
URL: repositoryURL,
Depth: 1,
InsecureSkipTLS: tlsSkipVerify,
Auth: GetBasicAuth(username, password),
Auth: auth,
Tags: git.NoTags,
}
@@ -125,7 +111,7 @@ func (service *Service) CloneRepository(
gitOptions.ReferenceName = plumbing.ReferenceName(referenceName)
}
return service.repoManager(repositoryURL).Download(context.TODO(), destination, gitOptions)
return service.repoManager(repositoryURL).Download(ctx, destination, gitOptions)
}
func (service *Service) repoManager(repositoryURL string) RepoManager {
@@ -140,32 +126,59 @@ func (service *Service) repoManager(repositoryURL string) RepoManager {
// LatestCommitID returns SHA1 of the latest commit of the specified reference
func (service *Service) LatestCommitID(
ctx context.Context,
repositoryURL,
referenceName,
username,
password string,
tlsSkipVerify bool,
) (string, error) {
return service.LatestCommitIDWithAuth(ctx, repositoryURL, referenceName, GetBasicAuth(username, password), tlsSkipVerify)
}
// LatestCommitIDWithAuth returns SHA1 of the latest commit of the specified reference,
// using the provided auth method.
func (service *Service) LatestCommitIDWithAuth(
ctx context.Context,
repositoryURL,
referenceName string,
auth transport.AuthMethod,
tlsSkipVerify bool,
) (string, error) {
listOptions := &git.ListOptions{
Auth: GetBasicAuth(username, password),
Auth: auth,
InsecureSkipTLS: tlsSkipVerify,
}
return service.repoManager(repositoryURL).LatestCommitID(context.TODO(), repositoryURL, referenceName, listOptions)
return service.repoManager(repositoryURL).LatestCommitID(ctx, repositoryURL, referenceName, listOptions)
}
// ListRefs will list target repository's references without cloning the repository
func (service *Service) ListRefs(
ctx context.Context,
repositoryURL,
username,
password string,
hardRefresh bool,
tlsSkipVerify bool,
) ([]string, error) {
refCacheKey := generateCacheKey(repositoryURL, username, password, strconv.FormatBool(tlsSkipVerify))
cacheKey := GenerateCacheKey(repositoryURL, username, password, strconv.FormatBool(tlsSkipVerify))
return service.ListRefsWithAuth(ctx, repositoryURL, hardRefresh, GetBasicAuth(username, password), tlsSkipVerify, cacheKey)
}
// ListRefsWithAuth will list target repository's references without cloning the repository,
// using the provided auth method. The cacheKey is supplied by the caller.
func (service *Service) ListRefsWithAuth(
ctx context.Context,
repositoryURL string,
hardRefresh bool,
auth transport.AuthMethod,
tlsSkipVerify bool,
cacheKey string,
) ([]string, error) {
if service.cacheEnabled && hardRefresh {
// Should remove the cache explicitly, so that the following normal list can show the correct result
service.repoRefCache.Remove(refCacheKey)
service.repoRefCache.Remove(cacheKey)
// Remove file caches pointed to the same repository
for _, fileCacheKey := range service.repoFileCache.Keys() {
if key, ok := fileCacheKey.(string); ok && strings.HasPrefix(key, repositoryURL) {
@@ -176,7 +189,7 @@ func (service *Service) ListRefs(
if service.repoRefCache != nil {
// Lookup the refs cache first
if cache, ok := service.repoRefCache.Get(refCacheKey); ok {
if cache, ok := service.repoRefCache.Get(cacheKey); ok {
if refs, ok := cache.([]string); ok {
return refs, nil
}
@@ -184,17 +197,17 @@ func (service *Service) ListRefs(
}
options := &git.ListOptions{
Auth: GetBasicAuth(username, password),
Auth: auth,
InsecureSkipTLS: tlsSkipVerify,
}
refs, err := service.repoManager(repositoryURL).ListRefs(context.TODO(), repositoryURL, options)
refs, err := service.repoManager(repositoryURL).ListRefs(ctx, repositoryURL, options)
if err != nil {
return nil, err
}
if service.cacheEnabled && service.repoRefCache != nil {
service.repoRefCache.Add(refCacheKey, refs)
service.repoRefCache.Add(cacheKey, refs)
}
return refs, nil
@@ -205,6 +218,7 @@ var singleflightGroup = &singleflight.Group{}
// ListFiles will list all the files of the target repository with specific extensions.
// If extension is not provided, it will list all the files under the target repository
func (service *Service) ListFiles(
ctx context.Context,
repositoryURL,
referenceName,
username,
@@ -214,7 +228,7 @@ func (service *Service) ListFiles(
includedExts []string,
tlsSkipVerify bool,
) ([]string, error) {
repoKey := generateCacheKey(
cacheKey := GenerateCacheKey(
repositoryURL,
referenceName,
username,
@@ -222,48 +236,47 @@ func (service *Service) ListFiles(
strconv.FormatBool(tlsSkipVerify),
strconv.FormatBool(dirOnly),
)
return service.ListFilesWithAuth(ctx, repositoryURL, referenceName, dirOnly, hardRefresh, GetBasicAuth(username, password), includedExts, tlsSkipVerify, cacheKey)
}
fs, err, _ := singleflightGroup.Do(repoKey, func() (any, error) {
return service.listFiles(
repositoryURL,
referenceName,
username,
password,
dirOnly,
hardRefresh,
tlsSkipVerify,
)
// ListFilesWithAuth will list all the files of the target repository with specific extensions,
// using the provided auth method. The cacheKey is supplied by the caller.
func (service *Service) ListFilesWithAuth(
ctx context.Context,
repositoryURL,
referenceName string,
dirOnly,
hardRefresh bool,
auth transport.AuthMethod,
includedExts []string,
tlsSkipVerify bool,
cacheKey string,
) ([]string, error) {
fs, err, _ := singleflightGroup.Do(cacheKey, func() (any, error) {
return service.listFilesWithAuth(ctx, repositoryURL, referenceName, dirOnly, hardRefresh, auth, tlsSkipVerify, cacheKey)
})
return filterFiles(fs.([]string), includedExts), err
}
func (service *Service) listFiles(
func (service *Service) listFilesWithAuth(
ctx context.Context,
repositoryURL,
referenceName,
username,
password string,
referenceName string,
dirOnly,
hardRefresh bool,
auth transport.AuthMethod,
tlsSkipVerify bool,
cacheKey string,
) ([]string, error) {
repoKey := generateCacheKey(
repositoryURL,
referenceName,
username,
password,
strconv.FormatBool(tlsSkipVerify),
strconv.FormatBool(dirOnly),
)
if service.cacheEnabled && hardRefresh {
// Should remove the cache explicitly, so that the following normal list can show the correct result
service.repoFileCache.Remove(repoKey)
service.repoFileCache.Remove(cacheKey)
}
if service.repoFileCache != nil {
// lookup the files cache first
if cache, ok := service.repoFileCache.Get(repoKey); ok {
if cache, ok := service.repoFileCache.Get(cacheKey); ok {
if files, ok := cache.([]string); ok {
return files, nil
}
@@ -276,18 +289,18 @@ func (service *Service) listFiles(
Depth: 1,
SingleBranch: true,
ReferenceName: plumbing.ReferenceName(referenceName),
Auth: GetBasicAuth(username, password),
Auth: auth,
InsecureSkipTLS: tlsSkipVerify,
Tags: git.NoTags,
}
files, err := service.repoManager(repositoryURL).ListFiles(context.TODO(), dirOnly, cloneOption)
files, err := service.repoManager(repositoryURL).ListFiles(ctx, dirOnly, cloneOption)
if err != nil {
return nil, err
}
if service.cacheEnabled && service.repoFileCache != nil {
service.repoFileCache.Add(repoKey, files)
service.repoFileCache.Add(cacheKey, files)
}
return files, nil
@@ -303,7 +316,8 @@ func (service *Service) purgeCache() {
}
}
func generateCacheKey(names ...string) string {
// GenerateCacheKey generates a cache key from the given parts.
func GenerateCacheKey(names ...string) string {
return strings.Join(names, "-")
}
@@ -338,15 +352,16 @@ func filterFiles(paths []string, includedExts []string) []string {
}
func GetBasicAuth(username, password string) *githttp.BasicAuth {
if password != "" {
if username == "" {
username = "token"
}
return &githttp.BasicAuth{
Username: username,
Password: password,
}
if password == "" {
return nil
}
if username == "" {
username = "token"
}
return &githttp.BasicAuth{
Username: username,
Password: password,
}
return nil
}
+343
View File
@@ -0,0 +1,343 @@
package git
import (
"context"
"errors"
"testing"
"github.com/stretchr/testify/require"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
)
type mockRepoManager struct {
downloadErr error
commitID string
commitIDErr error
refs []string
refsErr error
files []string
filesErr error
downloadCalled int
commitIDCalled int
listRefsCalled int
listFilesCalled int
lastCloneOptions *git.CloneOptions
}
func (m *mockRepoManager) Download(_ context.Context, _ string, opts *git.CloneOptions) error {
m.downloadCalled++
m.lastCloneOptions = opts
return m.downloadErr
}
func (m *mockRepoManager) LatestCommitID(_ context.Context, _, _ string, _ *git.ListOptions) (string, error) {
m.commitIDCalled++
return m.commitID, m.commitIDErr
}
func (m *mockRepoManager) ListRefs(_ context.Context, _ string, _ *git.ListOptions) ([]string, error) {
m.listRefsCalled++
return m.refs, m.refsErr
}
func (m *mockRepoManager) ListFiles(_ context.Context, _ bool, _ *git.CloneOptions) ([]string, error) {
m.listFilesCalled++
return m.files, m.filesErr
}
func newTestService(ctx context.Context, cacheSize int, gitMgr, azureMgr RepoManager) *Service {
s := newService(ctx, cacheSize, 0)
s.git = gitMgr
s.azure = azureMgr
return s
}
func TestCloneRepository(t *testing.T) {
t.Parallel()
downloadErr := errors.New("clone failed")
testCases := []struct {
name string
url string
referenceName string
gitManagerDownloadCalled int
azureManagerDownloadCalled int
managerErr bool
expectedError error
expectedReferenceName string
}{
{
name: "non-azure URL routes to git manager",
url: "https://github.com/example/repo.git",
gitManagerDownloadCalled: 1,
azureManagerDownloadCalled: 0,
},
{
name: "azure URL routes to azure manager",
url: "https://dev.azure.com/org/project/_git/repo",
gitManagerDownloadCalled: 0,
azureManagerDownloadCalled: 1,
},
{
name: "error from manager propagated",
url: "https://github.com/example/repo.git",
managerErr: true,
gitManagerDownloadCalled: 1,
expectedError: downloadErr,
},
{
name: "ReferenceName is passed to clone options",
url: "https://github.com/example/repo.git",
referenceName: "refs/heads/feature-branch",
gitManagerDownloadCalled: 1,
expectedReferenceName: "refs/heads/feature-branch",
},
{
name: "empty ReferenceName leaves clone options unset",
url: "https://github.com/example/repo.git",
referenceName: "",
gitManagerDownloadCalled: 1,
expectedReferenceName: "",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
gitMgr := &mockRepoManager{}
azureMgr := &mockRepoManager{}
if tc.managerErr {
gitMgr.downloadErr = downloadErr
azureMgr.downloadErr = downloadErr
}
s := newTestService(t.Context(), 4, gitMgr, azureMgr)
err := s.CloneRepository(t.Context(), "/tmp", tc.url, tc.referenceName, "", "", false)
require.Equal(t, tc.expectedError, err)
require.Equal(t, tc.gitManagerDownloadCalled, gitMgr.downloadCalled)
require.Equal(t, tc.azureManagerDownloadCalled, azureMgr.downloadCalled)
activeMgr := gitMgr
if tc.azureManagerDownloadCalled > 0 {
activeMgr = azureMgr
}
if activeMgr.lastCloneOptions != nil {
require.Equal(t, plumbing.ReferenceName(tc.expectedReferenceName), activeMgr.lastCloneOptions.ReferenceName)
}
})
}
}
func TestLatestCommitID(t *testing.T) {
t.Parallel()
commitLookupErr := errors.New("commit lookup failed")
testCases := []struct {
name string
url string
gitCommitID string
azureCommitID string
commitIDErr error
expectedID string
expectedError error
gitCalled int
azureCalled int
}{
{
name: "non-azure URL routes to git manager",
url: "https://github.com/example/repo.git",
gitCommitID: "abc123",
expectedID: "abc123",
gitCalled: 1,
},
{
name: "azure URL routes to azure manager",
url: "https://dev.azure.com/org/project/_git/repo",
azureCommitID: "def456",
expectedID: "def456",
azureCalled: 1,
},
{
name: "error propagated",
url: "https://github.com/example/repo.git",
commitIDErr: commitLookupErr,
expectedError: commitLookupErr,
gitCalled: 1,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
gitMgr := &mockRepoManager{commitID: tc.gitCommitID, commitIDErr: tc.commitIDErr}
azureMgr := &mockRepoManager{commitID: tc.azureCommitID}
s := newTestService(t.Context(), 4, gitMgr, azureMgr)
id, err := s.LatestCommitID(t.Context(), tc.url, "", "", "", false)
require.Equal(t, tc.expectedError, err)
require.Equal(t, tc.expectedID, id)
require.Equal(t, tc.gitCalled, gitMgr.commitIDCalled)
require.Equal(t, tc.azureCalled, azureMgr.commitIDCalled)
})
}
}
func TestListRefs(t *testing.T) {
t.Parallel()
t.Run("cache hit on second call", func(t *testing.T) {
gitMgr := &mockRepoManager{refs: []string{"refs/heads/main", "refs/heads/develop"}}
s := newTestService(t.Context(), 4, gitMgr, &mockRepoManager{})
refs1, err := s.ListRefs(t.Context(), "https://github.com/example/repo.git", "", "", false, false)
require.NoError(t, err)
refs2, err := s.ListRefs(t.Context(), "https://github.com/example/repo.git", "", "", false, false)
require.NoError(t, err)
require.Equal(t, 1, gitMgr.listRefsCalled, "expected manager to be called once")
require.Equal(t, refs1, refs2)
})
t.Run("hard refresh clears cache and calls manager again", func(t *testing.T) {
gitMgr := &mockRepoManager{refs: []string{"refs/heads/main"}}
s := newTestService(t.Context(), 4, gitMgr, &mockRepoManager{})
_, err := s.ListRefs(t.Context(), "https://github.com/example/repo.git", "", "", false, false)
require.NoError(t, err)
_, err = s.ListRefs(t.Context(), "https://github.com/example/repo.git", "", "", true, false)
require.NoError(t, err)
require.Equal(t, 2, gitMgr.listRefsCalled, "expected manager to be called twice with hard refresh")
})
t.Run("error propagated and not cached", func(t *testing.T) {
wantErr := errors.New("refs failed")
gitMgr := &mockRepoManager{refsErr: wantErr}
s := newTestService(t.Context(), 4, gitMgr, &mockRepoManager{})
_, err := s.ListRefs(t.Context(), "https://github.com/example/repo.git", "", "", false, false)
require.Equal(t, wantErr, err)
_, err = s.ListRefs(t.Context(), "https://github.com/example/repo.git", "", "", true, false)
require.Equal(t, wantErr, err)
require.Equal(t, 2, gitMgr.listRefsCalled, "expected manager to be called twice after error")
})
t.Run("azure URL routes to azure manager", func(t *testing.T) {
gitMgr := &mockRepoManager{}
azureMgr := &mockRepoManager{refs: []string{"refs/heads/main"}}
s := newTestService(t.Context(), 4, gitMgr, azureMgr)
_, err := s.ListRefs(t.Context(), "https://dev.azure.com/org/project/_git/repo", "", "", false, false)
require.NoError(t, err)
require.Equal(t, 1, azureMgr.listRefsCalled, "expected azure.ListRefs to be called once")
require.Equal(t, 0, gitMgr.listRefsCalled, "expected git.ListRefs to not be called")
})
t.Run("cache disabled: manager always called", func(t *testing.T) {
gitMgr := &mockRepoManager{refs: []string{"refs/heads/main"}}
s := newTestService(t.Context(), 0, gitMgr, &mockRepoManager{})
_, err := s.ListRefs(t.Context(), "https://github.com/example/repo.git", "", "", false, false)
require.NoError(t, err)
_, err = s.ListRefs(t.Context(), "https://github.com/example/repo.git", "", "", false, false)
require.NoError(t, err)
require.Equal(t, 2, gitMgr.listRefsCalled, "expected manager to be called twice with cache disabled")
})
}
func TestListFiles(t *testing.T) {
t.Parallel()
t.Run("cache hit on second call", func(t *testing.T) {
gitMgr := &mockRepoManager{files: []string{"docker-compose.yml", "README.md"}}
s := newTestService(t.Context(), 4, gitMgr, &mockRepoManager{})
files1, err := s.ListFiles(t.Context(), "https://github.com/example/repo.git", "refs/heads/main", "", "", false, false, nil, false)
require.NoError(t, err)
files2, err := s.ListFiles(t.Context(), "https://github.com/example/repo.git", "refs/heads/main", "", "", false, false, nil, false)
require.NoError(t, err)
require.Equal(t, 1, gitMgr.listFilesCalled, "expected manager to be called once")
require.Equal(t, files1, files2)
})
t.Run("hard refresh clears file cache", func(t *testing.T) {
gitMgr := &mockRepoManager{files: []string{"docker-compose.yml"}}
s := newTestService(t.Context(), 4, gitMgr, &mockRepoManager{})
_, err := s.ListFiles(t.Context(), "https://github.com/example/repo.git", "refs/heads/main", "", "", false, false, nil, false)
require.NoError(t, err)
_, err = s.ListFiles(t.Context(), "https://github.com/example/repo.git", "refs/heads/main", "", "", false, true, nil, false)
require.NoError(t, err)
require.Equal(t, 2, gitMgr.listFilesCalled, "expected manager to be called twice with hard refresh")
})
t.Run("azure URL routes to azure manager", func(t *testing.T) {
gitMgr := &mockRepoManager{}
azureMgr := &mockRepoManager{files: []string{"docker-compose.yml"}}
s := newTestService(t.Context(), 4, gitMgr, azureMgr)
_, err := s.ListFiles(t.Context(), "https://dev.azure.com/org/project/_git/repo", "", "", "", false, false, nil, false)
require.NoError(t, err)
require.Equal(t, 1, azureMgr.listFilesCalled, "expected azure.ListFiles to be called once")
require.Equal(t, 0, gitMgr.listFilesCalled, "expected git.ListFiles to not be called")
})
t.Run("extension filter applied", func(t *testing.T) {
gitMgr := &mockRepoManager{files: []string{"docker-compose.yml", "README.md", "stack.yml", "config.json"}}
s := newTestService(t.Context(), 4, gitMgr, &mockRepoManager{})
files, err := s.ListFiles(t.Context(), "https://github.com/example/repo.git", "", "", "", false, false, []string{".yml"}, false)
require.NoError(t, err)
require.Equal(t, []string{"docker-compose.yml", "stack.yml"}, files)
})
t.Run("error is returned and not cached", func(t *testing.T) {
wantErr := errors.New("list files failed")
gitMgr := &mockRepoManager{filesErr: wantErr}
s := newTestService(t.Context(), 4, gitMgr, &mockRepoManager{})
files, err := s.ListFiles(t.Context(), "https://github.com/example/repo.git", "refs/heads/main", "", "", false, false, nil, false)
require.ErrorIs(t, err, wantErr)
require.Nil(t, files)
})
}
func TestFilterFiles(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
files []string
exts []string
expectedFiles []string
}{
{
name: "empty ext list returns all files",
files: []string{"a.yml", "b.json", "c.txt"},
exts: nil,
expectedFiles: []string{"a.yml", "b.json", "c.txt"},
},
{
name: "non-matching exts returns empty",
files: []string{"a.yml", "b.json"},
exts: []string{".txt"},
expectedFiles: nil,
},
{
name: "partial match returns only matching files",
files: []string{"a.yml", "b.json", "c.yml"},
exts: []string{".yml"},
expectedFiles: []string{"a.yml", "c.yml"},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
require.Equal(t, tc.expectedFiles, filterFiles(tc.files, tc.exts))
})
}
}
+33 -3
View File
@@ -2,20 +2,29 @@ package gittypes
import (
"errors"
"net/url"
"path"
"strings"
)
var (
ErrIncorrectRepositoryURL = errors.New("git repository could not be found, please ensure that the URL is correct")
ErrAuthenticationFailure = errors.New("authentication failed, please ensure that the git credentials are correct")
ErrSymlinkDetected = errors.New("repository contains a symlink, which is not allowed for security reasons")
)
type GitCredentialAuthType int
type GitProvider int
// RepoConfig represents a configuration for a repo
type RepoConfig struct {
// The repo url
URL string `example:"https://github.com/portainer/portainer.git"`
// The reference name
ReferenceName string `example:"refs/heads/branch_name"`
// Path to where the config file is in this url/refName
// ConfigFilePath is the path to the config file within the repository.
// NOTE: For stacks, this mirrors Stack.EntryPoint and the two are kept in sync by stackUpdateGit.
ConfigFilePath string `example:"docker-compose.yml"`
// Git credentials
Authentication *GitAuthentication
@@ -25,9 +34,30 @@ type RepoConfig struct {
TLSSkipVerify bool `example:"false"`
}
// RepoName extracts the repository name from a git URL for use as a display name.
// e.g. "https://github.com/org/app-config.git" results in "app-config"
func RepoName(rawURL string) string {
return strings.TrimSuffix(path.Base(rawURL), ".git")
}
// SanitizeURL strips any userinfo (username/password) embedded in rawURL,
// returning a URL safe to store or return to clients.
func SanitizeURL(rawURL string) string {
u, err := url.Parse(rawURL)
if err != nil || u.User == nil {
return rawURL
}
u.User = nil
return u.String()
}
type GitAuthentication struct {
Username string
Password string
Username string
Password string
Provider GitProvider `json:",omitempty"`
AuthorizationType GitCredentialAuthType `json:",omitempty"`
// Git credentials identifier when the value is not 0
// When the value is 0, Username and Password are set without using saved credential
// This is introduced since 2.15.0
+12 -17
View File
@@ -1,6 +1,7 @@
package update
import (
"context"
"strings"
"github.com/pkg/errors"
@@ -13,7 +14,7 @@ import (
)
// UpdateGitObject updates a git object based on its config
func UpdateGitObject(gitService portainer.GitService, objId string, gitConfig *gittypes.RepoConfig, enableVersionFolder bool, projectPath string) (bool, string, error) {
func UpdateGitObject(ctx context.Context, gitService portainer.GitService, objId string, gitConfig *gittypes.RepoConfig, enableVersionFolder bool, projectPath string) (bool, string, error) {
if gitConfig == nil {
return false, "", nil
}
@@ -24,12 +25,10 @@ func UpdateGitObject(gitService portainer.GitService, objId string, gitConfig *g
Str("object", objId).
Msg("the object has a git config, try to poll from git repository")
username, password, err := git.GetCredentials(gitConfig.Authentication)
if err != nil {
return false, "", errors.WithMessagef(err, "failed to get credentials for %v", objId)
}
username, password := git.GetCredentials(gitConfig.Authentication)
newHash, err := gitService.LatestCommitID(
ctx,
gitConfig.URL,
gitConfig.ReferenceName,
username,
@@ -71,7 +70,7 @@ func UpdateGitObject(gitService portainer.GitService, objId string, gitConfig *g
}
}
if err := cloneGitRepository(gitService, cloneParams); err != nil {
if err := cloneGitRepository(ctx, gitService, cloneParams); err != nil {
return false, "", errors.WithMessagef(err, "failed to do a fresh clone of %v", objId)
}
@@ -99,24 +98,20 @@ type gitAuth struct {
password string
}
func cloneGitRepository(gitService portainer.GitService, cloneParams *cloneRepositoryParameters) error {
func cloneGitRepository(ctx context.Context, gitService portainer.GitService, cloneParams *cloneRepositoryParameters) error {
username, password := "", ""
if cloneParams.auth != nil {
return gitService.CloneRepository(
cloneParams.toDir,
cloneParams.url,
cloneParams.ref,
cloneParams.auth.username,
cloneParams.auth.password,
cloneParams.tlsSkipVerify,
)
username = cloneParams.auth.username
password = cloneParams.auth.password
}
return gitService.CloneRepository(
ctx,
cloneParams.toDir,
cloneParams.url,
cloneParams.ref,
"",
"",
username,
password,
cloneParams.tlsSkipVerify,
)
}

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