Compare commits

...

210 Commits

Author SHA1 Message Date
portainer-bot[bot] bf5d5cab68 chore(version): bump 2.41.1 (#2606)
Co-authored-by: nickl-portainer <nicholas.loomans@portainer.io>
2026-05-11 02:39:40 +00:00
bernard-portainer 51ffea9c93 feat(homeView) add age sort option as default [C9S-150] (#2541) 2026-05-05 08:16:50 +12:00
LP B eb0ee117a5 fix(api/workflows): kubernetes UAC (#2507) 2026-04-29 18:53:02 -03:00
Xing c52767fb04 fix(test): isolate registry config in OCI client tests to fix env-dependent failures [C9S-119] (#2497)
Co-authored-by: nickl-portainer <nicholas.loomans@portainer.io>
2026-04-30 09:33:09 +12:00
RHCowan 8e39a16172 fix(agent): correct Podman container engine header in sync edge client [BE-12887] (#2498) (#2506) 2026-04-30 09:03:20 +12:00
LP B e964be75db fix(api/workflows): move filterK8SStacks outside of transaction (#2504) 2026-04-29 17:57:02 +02:00
Cara Ryan 6776b01ac8 fix(home):CE group by health down discrepancies between headings and list [C9S-139] (#2484) 2026-04-28 15:42:56 +12:00
bernard-portainer b96031965a fix(environmentlist) use nevironment card in home view [C9S-42] (#2483) 2026-04-28 15:38:15 +12:00
Cara Ryan b2a2e5c222 feat(home): environment home page ui improvements to highlight groups [C9S-23] (#2453)
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-28 13:39:48 +12:00
LP B 27285a94ac feat(api/gitops): list and filter kubernetes git workflows (#2465) 2026-04-27 15:19:17 -03:00
Chaim Lev-Ari b3f01973ec fix(ui/sortable-list): remove 1 as page size option [BE-12900] (#2470) 2026-04-27 17:01:08 +03:00
Chaim Lev-Ari 17ffd62480 feat(gitops): show live git validity status in workflow overview [BE-12885] (#2467)
Co-authored-by: Claude <noreply@anthropic.com>
2026-04-27 13:11:52 +03:00
Chaim Lev-Ari 86f6aba362 fix(gitops): align list component with current design [BE-12888] (#2445) 2026-04-26 16:54:51 +03:00
Chaim Lev-Ari 718e11ccd0 fix(kube/stacks): allow empty stack name [BE-12889] (#2446) 2026-04-26 12:14:53 +03:00
Josiah Clumont e68b0e80f1 feat(recommendations): completeness recommendations [C9S-18] (#2262) (#2454) 2026-04-24 14:55:15 +12:00
Ali 9a14f2acb7 feat(docker): add docker builder prune as option [C9S-128] (#2451) 2026-04-24 10:21:32 +12:00
Ali 01ff1486e0 fix(ui): use uuidv4 instead of cryptorandomuuid to support non-secure browsers [c9s-133] (#2433) 2026-04-24 08:41:48 +12:00
andres-portainer b91f77a554 feat(gitops): introduce workflows view [BE-12807] (#2391) (#2428)
Co-authored-by: Chaim Lev-Ari <chiptus@users.noreply.github.com>
Co-authored-by: Chaim Lev-Ari <chaim.lev-ari@portainer.io>
2026-04-22 14:37:04 -03:00
nickl-portainer b9713f7e9e chore(version): bump version to 2.41.0 (#2421) 2026-04-22 17:11:30 +12:00
Steven Kang 9c0a13a828 fix(stacks): fix Swarm stack migration to Kubernetes hanging and empt… (#2417) 2026-04-22 13:38:06 +12:00
Robbie Cowan dc56aae7b8 fix(rebase): run go mod tidy to prepare for merge into develop 2026-04-22 10:59:12 +12:00
RHCowan ba11fe920b fix(alerting) Use prometheus scrape manager [R8S-940] (#2198) 2026-04-22 10:06:45 +12:00
RHCowan 7f2da7811c feat/r8s 900/r8s 929/ee alerting foundations (#2167) 2026-04-22 10:06:44 +12:00
RHCowan 62cf2e42d5 feat(alerting): add shared CE Prometheus foundation and alert-state contracts [R8S-927] (#2129) 2026-04-22 10:06:44 +12:00
RHCowan 64745e70d0 feat(alerting): wire K8s metrics collection and alert push transport [R8S-901] (#1993) 2026-04-22 10:06:43 +12:00
RHCowan f49cd6e932 feat(alerting): distribute enabled alert rules to edge agents via poll response [R8S-903] (#2007) 2026-04-22 10:06:43 +12:00
Steven Kang ac1e333dde feat(alerts): removal of snapshot reliance [R8S-902] (#1994) 2026-04-22 10:06:43 +12:00
RHCowan b5bc5f65ad feat(alerting): Add edge alert ingestion endpoint skeleton [R8S-895] (#1991) 2026-04-22 10:06:40 +12:00
Oscar Zhou 463d539194 refactor(stack): change stack update flow to async model [BE-12741] (#2306) 2026-04-22 10:05:17 +12:00
andres-portainer 7e544ee449 fix(docker): add more bind mount restriction checks BE-12771 (#2409) 2026-04-21 17:56:17 -03:00
Ali 1f320c976f chore(docs): update docs, skills and Claude.md to avoid repeating review comments [r8s-971] (#2400) 2026-04-22 08:21:31 +12:00
andres-portainer 825a7669a6 fix(csrf): use the proper format for trusted origins BE-12810 (#2398) 2026-04-21 11:52:58 -03:00
andres-portainer f6a72b089c fix(kubernetes): enforce admin permissions in /system BE-12862 (#2396) 2026-04-21 09:43:06 -03:00
LP B 73ea33f36c fix(app/container): handle no healthcheck logs output (#2387) 2026-04-21 13:46:36 +02:00
Chaim Lev-Ari 744a31a354 feat(stacks): allow edit of kube git stacks [BE-12671] (#2194) 2026-04-21 11:05:37 +03:00
Chaim Lev-Ari 42c7f10e79 feat(ui): introduce SortableList [BE-12806] (#2367)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 10:38:38 +03:00
Chaim Lev-Ari 3e57bc5aa0 fix(containers): show volume label when selected [BE-12819] (#2369) 2026-04-21 09:48:26 +03:00
Chaim Lev-Ari 4880e61e0f fix(containers): show ports in wrapping rows [BE-12709] (#2370) 2026-04-21 09:47:52 +03:00
Steven Kang 79a93cfd01 fix(security): upgrade Docker binary from v29.3.0 to v29.4.1 (#2356) 2026-04-21 10:55:14 +12:00
Steven Kang 0af7bc2004 fix(security): bump github.com/moby/spdystream to 0.5.1 (#2355) 2026-04-21 10:19:53 +12:00
Steven Kang ada103e910 fix(security): bump helm.sh/helm/v4 to 4.1.4 (#2354) 2026-04-21 10:06:22 +12:00
Steven Kang a0e964c27d fix(security): bump Go toolchain to 1.26.2 (#2352) 2026-04-21 10:05:12 +12:00
Cara Ryan a2624b7467 fix(helm): Resolve content cache must be set error when using helm dependencies [C9S-115] (#2376) 2026-04-21 09:51:02 +12:00
andres-portainer 9abd7eaeea fix(endpoints): enforce admin permissions when updating endpoint relations BE-12861 (#2394)
Co-authored-by: andres-portainer <andres-portainer@users.noreply.github.com>
2026-04-20 14:19:18 -03:00
LP B 3502ed0293 fix(api): deny plugin related changes to regular users (#2284) 2026-04-20 17:07:28 +02:00
Chaim Lev-Ari 3101738adc refactor(git): ee service extends ce service [BE-12825] (#2280) 2026-04-19 10:44:23 +03:00
andres-portainer 0b390dd274 fix(tests): do all the path handling using filesystem.JoinPaths() BE-12828 (#2336) 2026-04-18 01:54:14 -03:00
andres-portainer 9d3f7b710d fix(tests): enable more parallel tests BE-12801 (#2316) 2026-04-18 01:53:10 -03:00
andres-portainer 3a8ed40943 fix(docker): enforce bind mount restrictions for Mounts field BE-12770 (#2363) 2026-04-18 01:28:24 -03:00
andres-portainer aef1d982c2 fix(docker): add missing restrictions for Swarm BE-12772 (#2226) 2026-04-18 01:27:14 -03:00
andres-portainer b287961758 fix(git): forbid the usage of symlinks BE-12768 (#2365) 2026-04-18 01:26:15 -03:00
andres-portainer 8d5675a7d7 fix(csrf): add CSRF protection from the stdlib BE-12810 (#2250) 2026-04-17 10:51:04 -03:00
Ali 544e302fe1 feat(docker): support docker image prune [c9s-91] (#2314) 2026-04-17 14:22:36 +12:00
andres-portainer b417b04a69 fix(websocket): add proper locking and avoid goroutine leakage BE-12835 (#2303) 2026-04-16 14:08:51 -03:00
Xing 6ecb99898d fix(k8s): yaml malformed document [dev-7] (#1976)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-16 13:38:34 +12:00
Xing 236c5e2415 chore(dev): use separate data dirs for CE and EE [C9S-102] (#2328) 2026-04-16 10:17:59 +12:00
Josiah Clumont 2d2b68e867 fix(tests): Fixed the breadcrumb failing tests due to update's on how the first item is rendered [C9S-98] (#2338) 2026-04-16 09:29:51 +12:00
Chaim Lev-Ari f841ea527a fix(terminal): close terminal on ctrl+d [BE-12823] (#2271) 2026-04-15 17:08:15 +12:00
Josiah Clumont 169548cc4c (feature) fix header padding [C9S-98] (#2315) 2026-04-15 14:24:15 +12:00
Chaim Lev-Ari 8f93a1a8cf chore(deps): upgrade eslint [BE-12837] (#2313) 2026-04-15 05:12:52 +03:00
nickl-portainer 8e85fa9f83 fix(policies): datatable new row behaviours [C9S-64] (#2130) 2026-04-15 14:11:11 +12:00
Chaim Lev-Ari 181a83a889 chore(deps): upgrade ts to v6 [BE-12820] (#2268) 2026-04-15 03:55:34 +03:00
andres-portainer b78504aa04 fix(websocket): remove the JWT token query string parameter BE-12833 (#2301) 2026-04-14 19:41:08 -03:00
Chaim Lev-Ari a21ec9299b feat(stacks): add redeploy git button [BE-12783] (#2278) 2026-04-14 17:49:56 +03:00
Chaim Lev-Ari 7708ace1d8 feat(gitops): add api for workflows [BE-12805] (#2273)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 13:25:37 +03:00
Steven Kang 218b5d5900 feat(kubernetes): add edit (yaml) and describe button [R8S-921] (#2079) 2026-04-14 14:01:41 +12:00
nickl-portainer 2983b94cf7 fix(css): add restriction on modal height [R8S-947] (#2305) 2026-04-14 13:31:21 +12:00
Josiah Clumont 25e082ea63 feat(design-system): add HeaderLayout component [C9S-95] (#2291) 2026-04-14 11:44:30 +12:00
Josiah Clumont 3313376fac feat(design-system): add StatusSummaryBar and FilterBar components [DEV-41] (#2288)
Co-authored-by: Chaim Lev-Ari <chiptus@users.noreply.github.com>
2026-04-14 08:28:44 +12:00
andres-portainer a96c6efcbd chore(code): add rule to mitigate the introduction of path traversal vulnerabilities BE-12828 (#2299) 2026-04-13 11:45:14 -03:00
andres-portainer 4dd6b88cdf chore(tests): simplify the code BE-12818 (#2285) 2026-04-13 11:32:07 -03:00
Ali 0d836f1e30 chore(tailwind): support tailwind class ordering in clsx functions [r8s-949] (#2292) 2026-04-13 17:13:40 +12:00
Ali ab3e0956a4 chore(tailwind): format tailwind class order [r8s-949] (#2289) 2026-04-13 16:01:10 +12:00
Josiah Clumont 615fceb4a5 feat(navigation-bar): Update the navigation bar [C9S-90] (#2263) 2026-04-13 13:54:54 +12:00
andres-portainer 68453ebcb8 chore(stackbuilders): simplify the code BE-12800 (#2230) 2026-04-09 17:45:24 -03:00
Chaim Lev-Ari 635c49d04d fix(stacks): save git credentials if required [BE-12773] (#2237) 2026-04-09 09:25:31 +03:00
RHCowan 886af7d55a feat(ci): optimise build pipeline with frontend caching and scoped validation (#1995) 2026-04-09 15:57:04 +12:00
andres-portainer 8f563220df chore(code): clean-up the code BE-12818 (#2260) 2026-04-08 20:04:27 -03:00
andres-portainer def415b6f3 chore(code): consolidate code between CE and EE BE-12818 (#2261) 2026-04-08 19:36:54 -03:00
andres-portainer c21d043183 fix(code): remove nil-pointer dereference errors BE-12817 (#2259) 2026-04-08 19:36:06 -03:00
Ali 769ea73cec feat(registries): add registry access notice to app create/edit views [c9s-39] (#2190) 2026-04-09 09:04:45 +12:00
andres-portainer d140726c46 fix(kube): use transactional code for initial detections BE-545 (#2228) 2026-04-08 16:11:23 -03:00
andres-portainer 1f42559279 fix(endpoints): fix a use-after-close data-race BE-12604 (#2214) 2026-04-08 13:04:13 -03:00
andres-portainer b6d6c7fd2a fix(containers): avoid using the request context BE-12870 (#2216) 2026-04-08 12:39:52 -03:00
andres-portainer 1298fc629e chore(tests): allow for the tests to run in parallel BE-12801 (#2231) 2026-04-07 17:38:22 -03:00
andres-portainer 30ca5e298c chore(tests): avoid initializing the DB data when not needed BE-12801 (#2233) 2026-04-07 15:49:57 -03:00
andres-portainer 2240d0516c chore(tests): speed up the time by using synctest BE-12801 (#2234) 2026-04-07 15:49:30 -03:00
Chaim Lev-Ari b87095dc7a fix(terminal): allow tui apps [BE-12674] (#2024) 2026-04-07 10:45:26 +03:00
Oscar Zhou d30503a40c feat(helm/edge): support helm repository for edge stack [BE-12480] (#2180) 2026-04-07 18:40:07 +12:00
Chaim Lev-Ari 7fbda4fe54 refactor(settings/auth): migrate group builder to react [BE-12587] (#2102) 2026-04-07 07:55:24 +03:00
Ali 24a2b29f70 fix(ui): make banner border wrap screen height [c9s-63] (#2224) 2026-04-07 16:05:52 +12:00
Oscar Zhou ca9e197d12 fix(stack): add stack creation success toast [BE-12813] (#2245) 2026-04-07 14:26:40 +12:00
Cara Ryan 51f86eb4c6 feat(api): Claude skill to validate and write api annotations and example subset run to fix helm endpoints (#2246) 2026-04-07 13:56:17 +12:00
Oscar Zhou 5aba61cc49 refactor(stack): create stack and deploy stack in async flow CE [BE-12650] (#2238) 2026-04-07 09:18:54 +12:00
andres-portainer fcf9888677 feat(git): consolidate the mocked Git service to simplify the tests BE-12799 (#544) 2026-04-06 14:24:19 -03:00
andres-portainer 9c9caeb57a chore(code): unnest some code BE-12798 (#2229) 2026-04-06 14:23:33 -03:00
Chaim Lev-Ari a58ad25533 fix(stacks): stack.env can be null [BE-12736] (#2239) 2026-04-06 16:55:06 +03:00
Oscar Zhou 11f5150190 refactor(stack): create stack and deploy stack in async flow [BE-12650] (#2048) 2026-04-05 21:18:29 +12:00
Chaim Lev-Ari 1c72dfe5ad fix(gitops): fix various gitops errors [BE-12787] (#2200) 2026-04-05 09:18:33 +03:00
Oscar Zhou b49830db8f chore: add Makefile command to host swagger ui locally [BE-12791] (#2223) 2026-04-03 12:22:52 +13:00
andres-portainer e035c490dc fix(docker): fix a data race in serviceRestore BE-12790 (#2219) 2026-04-02 11:04:02 -03:00
Phil Calder 0d8544b3ee fix(CronJobs): remove non-functional Items per page (#2166)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 10:37:09 +13:00
andres-portainer 50056bef70 fix(context): clean up context usage BE-12766 (#2164) 2026-04-01 18:02:48 -03:00
Ali e68e14787b chore(logs): add log view smoke tests [PLA-681] (#2206) 2026-04-01 16:39:24 +13:00
Chaim Lev-Ari 0ab2c5cf98 feat(react-query): suppress error when meta.error is falsy [BE-12776] (#2199) 2026-03-31 16:53:00 +03:00
Oscar Zhou 1ca56fd027 fix(git): failed git repo url returns html error page [BE-12757] (#2191) 2026-03-31 10:31:12 +13:00
Oscar Zhou c4cc9cf1c7 fix(ui): display invisible special characters in web editor [BE-12777] (#2176) 2026-03-31 10:15:47 +13:00
Chaim Lev-Ari b53684a89e chore(deps): remove unused client dependencies [BE-12749] (#2172) 2026-03-30 14:54:50 +03:00
Oscar Zhou d93508a272 fix(edge/helm): support custom namespace [BE-12678] (#2171) 2026-03-27 10:02:48 +13:00
Chaim Lev-Ari ad9b9cf5b1 fix(stacks): fix(stacks): prevent git file load before clone [BE-12764] (#2162) 2026-03-26 15:10:14 +02:00
Chaim Lev-Ari ac5fb731bc feat(motd): cache motd in server [BE-12711] (#2159) 2026-03-26 15:01:48 +02:00
Chaim Lev-Ari d36799020b refactor: remove Kubernetes ts import [BE-12730] (#2157) 2026-03-26 14:09:13 +02:00
Chaim Lev-Ari 7aa08053e0 refactor(axios): remove the need for parseAxiosError [BE-12703] (#2158) 2026-03-26 13:50:14 +02:00
andres-portainer 61b9bc248f fix(schedule): abstract simple loops with RunOnInterval() BE-12765 (#2163) 2026-03-26 07:47:54 -03:00
Chaim Lev-Ari e33f9573e8 refactor: remove Portainer ts import [BE-12732] (#2156) 2026-03-26 12:18:15 +02:00
Chaim Lev-Ari 186624d267 refactor: remove Docker ts import [BE-12731] (#2155) 2026-03-26 09:44:26 +02:00
Hannah Cooper 7c9d4cd7d8 Update bug report template to include 2.40.0 (#2168) 2026-03-26 13:52:40 +13:00
Phil Calder 541b8df735 fix(kubernetes): filter CronJob executions by namespace [DEV-19] (#2144)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 13:21:09 +13:00
andres-portainer 2900bfa1d6 chore(code): remove unused code BE-12744 (#2112) 2026-03-25 10:19:17 -03:00
andres-portainer 5ea0f682a6 fix(apikey): fix the return value of InvalidateUserKeyCache() BE-12755 (#2124) 2026-03-25 09:04:54 -03:00
andres-portainer 019cbfd972 fix(websocket): avoid leaking goroutines BE-12754 (#2123) 2026-03-25 09:04:23 -03:00
RHCowan 792c95b8bb chore: bump version to 2.40.0 and set API version support to STS (#2160) 2026-03-25 19:59:41 +13:00
Robbie Cowan 4d1f432266 Revert "chore: bump version to 2.40.0"
This reverts commit 11af66a4fce8ab98253afb2e637a946a8939747f.
2026-03-25 18:47:01 +13:00
Robbie Cowan 1e00a58b57 chore: bump version to 2.40.0 2026-03-25 18:15:54 +13:00
Phil Calder 0a26ac0279 fix(security): bump google.golang.org/grpc to v1.79.3 [DEV-22] (#2151)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 17:26:25 +13:00
Ali 63b0802ad7 fix(UI): revert global css changes targetting firefox banner fix + cleanup [C9S 71] (#2152) 2026-03-25 15:51:36 +13:00
Ali a5062dbe35 fix(ui): handle verticle alignment selectors for production build [c9s-71] (#2147) 2026-03-25 13:37:20 +13:00
Ali f84e657707 feat(registries): support service accounts with registry secrets for cluster level [C9S 37] (#2120) 2026-03-25 11:00:13 +13:00
Chaim Lev-Ari cd8a42edaf fix(settings/auth): allow dashes in ldap dn (#2141) 2026-03-24 16:00:59 -03:00
Chaim Lev-Ari e37f8a5eb9 fix(stacks): save entry point [BE-12670] (#2132) 2026-03-24 16:38:31 +02:00
Ali 7fc8d3f2b1 fix(ui): ensure when two+ angular components are in a #view, they don't all stretch to fill space [c9s-71] (#2133) 2026-03-24 23:03:24 +13:00
Ali 6f2d1a2b49 fix(ui): ensure centered pages stay centered [c9s-71] (#2131) 2026-03-24 20:02:01 +13:00
Chaim Lev-Ari d5a3e46791 feat(stacks): update git url and config path [BE-12670] (#2099)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Devon Steenberg <devon.steenberg@portainer.io>
2026-03-24 15:01:46 +13:00
andres-portainer 1f4724c537 fix(environments): fix the TLS certificate uploading BE-12719 (#2101)
Co-authored-by: andres-portainer <andres-portainer@users.noreply.github.com>
2026-03-24 14:01:49 +13:00
Ali e6f8736cae fix(policies): fix page styles for firefox banner [C9S-63] (#2128) 2026-03-24 13:24:43 +13:00
Oscar Zhou 54fbe54953 fix(edge/agent): deleting k8s edge agent disconnect environment [BE-12723] (#2109) 2026-03-24 08:33:06 +13:00
Ali 3e92a2881a chore(ci): improve regular PR CI reporting for playwright [r8s-925] (#2114) 2026-03-23 22:01:47 +13:00
Devon Steenberg bd9c3c1593 feat(gitops): tidy up git auth [BE-12666] (#2026) 2026-03-23 13:53:04 +13:00
Ali f199d0882f feat(serviceaccount): service account details view [C9S-36] (#2082) 2026-03-23 09:22:56 +13:00
Chaim Lev-Ari a2fee4fc4c fix(stacks): pass prune option through the deploy pipeline [BE-12738] (#2098)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 12:37:40 +02:00
Chaim Lev-Ari 5670216d7e feat(stacks): show planned deployment info [BE-12737] (#2097)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 09:34:45 +02:00
Ali 7569266e46 fix(rbac): update namespace rbac test id to match EE [C9S-61] (#2111) 2026-03-22 20:26:19 +13:00
Hannah Cooper 23f6cb8bae Update bug report template to include 2.39.1 (#2108) 2026-03-20 13:53:32 +13:00
Ali 931c2b3ddb feat(policies): Introduce change confirmation policy template, and clear default values for custom policy [C9S-52] (#2087) 2026-03-20 09:55:41 +13:00
Oscar Zhou 8b3edb4e28 fix(docker): show correct container state for restarting and removing [BE-12707] (#2088) 2026-03-20 08:49:59 +13:00
Chaim Lev-Ari a0b03d36bd refactor(settings/auth): migrate ldap-dn-builder to react [BE-12586] (#2025) 2026-03-19 17:56:15 +02:00
andres-portainer df1cd0af2e fix(endpointrelation): add locking to RegisterUpdateStackFunction() BE-12608 (#2096) 2026-03-19 12:38:36 -03:00
Chaim Lev-Ari 5df7146828 fix(stacks): disabled edit button while submit [BE-12681] (#2094) 2026-03-19 14:56:08 +02:00
Chaim Lev-Ari bec5d829f1 refactor(settings/auth): migrate ldap security settings to react [BE-12588] (#2029) 2026-03-19 12:13:05 +02:00
Chaim Lev-Ari ee0e9f6ff8 feat(settings/auth): migrate ldap test login to react [BE-12589] (#2036) 2026-03-19 11:23:58 +02:00
Ali 9c7eef3144 feat(secrets): update secrets to show related registry [c9s-35] (#2065) 2026-03-19 15:18:35 +13:00
Chaim Lev-Ari 3110fe4e74 refactor(axios): move axios into react folder [BE-12728] (#2084) 2026-03-19 10:21:25 +13:00
Chaim Lev-Ari 565ac2c15a fix(ts): add back feature imports [BE-12733] (#2083) 2026-03-18 13:46:52 +02:00
nickl-portainer 9cba6c7475 chore(tsconfig): remove obsolete aliases (#2078) 2026-03-18 16:01:17 +13:00
nickl-portainer 07b3bdb62d chore(axios): move out axios utils into helpers and utils folder [R8S-871] (#2077) 2026-03-18 13:59:51 +13:00
nickl-portainer ac7ff0fff4 chore(axios): move axios into own folder CE [R8S-871] (#2075) 2026-03-18 13:24:44 +13:00
Chaim Lev-Ari 0d20839d5f fix(stacks): validate stacks with env vars [BE-12689] (#2050)
Co-authored-by: andres-portainer <andres-portainer@users.noreply.github.com>
2026-03-17 18:53:13 -03:00
Oscar Zhou 13fb3118ee fix(edge/docker): add bind mount volume label for restarting the specific service [BE-12575] (#1821) 2026-03-18 08:44:50 +13:00
andres-portainer 364027054c fix(websocket): simplify the logout disconnection logic BE-12605 (#1868) 2026-03-17 13:21:28 -03:00
andres-portainer 31a861394f fix(otel): upgrade to v1.42.0 BE-12724 (#2072) 2026-03-17 13:02:54 -03:00
LP B 0fccc0357e fix(api/uac): panic on external stacks UAC eval (#2073) 2026-03-17 16:00:35 +01:00
andres-portainer 5550a71dea fix(docker): upgrade Docker binary to v29.3.0 to mitigate CVE-2025-68121 BE-12720 (#2064) 2026-03-16 19:02:56 -03:00
Steven Kang 0ec6f638a1 feat(kompose): support docker to kubernetes migration - [R8S-723] (#1977) 2026-03-17 09:37:18 +13:00
andres-portainer 748b4bcf19 chore(go): upgrade to v1.26.1 BE-12630 (#1855) 2026-03-16 15:52:36 -03:00
Ali 33cc29fa3c fix(sidebar): set helper anchor color to match the other items [C9S-47] (#2058) 2026-03-16 15:50:59 +13:00
Chaim Lev-Ari 5e2eb667b4 fix(kube/app): enable edit button for regular apps [BE-12690] (#2039) 2026-03-15 11:22:09 +02:00
Ali 1f9c9b082f feat(policies): banner and confirmation on change policy [C9S-20] (#1988) 2026-03-13 14:11:53 +13:00
Cara Ryan 722c1875af chore(helm): upgrade sdk to v4 [R8S-840] (#2000) 2026-03-13 11:34:28 +13:00
Ali 68471d0225 fix(stacks): use widget-tabs consistently [c9s-33] (#2038) 2026-03-13 08:30:45 +13:00
Phil Calder a6900545b0 Report a vulnerability via email or GitHub (#2037) 2026-03-12 12:30:40 +13:00
Chaim Lev-Ari 808ceba848 feat(docker): allow user to specify security-opts (#2022)
Co-authored-by: dylan <dfldylan@qq.com>
Co-authored-by: jerry-yuan <i@jerryzone.cn>
2026-03-11 08:56:42 +02:00
Oscar Zhou a796a03a15 fix(edge/helm): helm edge stack is marked as external [BE-12653] (#1974) 2026-03-11 12:51:07 +13:00
andres-portainer 5a5dc67209 fix(golang-lru): consolidate the dependencies BE-12695 (#2021) 2026-03-10 18:57:49 -03:00
andres-portainer 69ae54b523 fix(zerolog): consolidate the dependencies BE-12695 (#2030) 2026-03-10 18:30:21 -03:00
andres-portainer b405227d51 fix(jwt): consolidate the dependencies BE-12695 (#2020) 2026-03-10 15:14:21 -03:00
andres-portainer 44be39a9a4 fix(mapstructure): consolidate the dependencies BE-12695 (#2019) 2026-03-10 14:48:37 -03:00
andres-portainer 5de0cc199c fix(kingpin): consolidate dependencies BE-12695 (#2018) 2026-03-10 14:33:10 -03:00
andres-portainer 0c9e408eda fix(ldap): consolidate dependencies BE-12695 (#2017) 2026-03-10 14:18:06 -03:00
Chaim Lev-Ari 1007f1f740 feat(ui): create shared terminal component [BE-12697] (#1979) 2026-03-10 18:17:29 +02:00
Chaim Lev-Ari 774e3d5948 fix(ws): remove limit on docker console [BE-12660] (#2023) 2026-03-10 15:26:33 +02:00
andres-portainer 4d866d066a fix(uuid): consolidate dependencies BE-12695 (#2016) 2026-03-10 10:12:42 -03:00
andres-portainer da6544e981 fix(semver): consolidate dependencies BE-12695 (#2014) 2026-03-09 15:33:45 -03:00
bernard-portainer 3af9a7646d fix(ui): add getRowId to expandable storage component [R8S-538] (#2008) 2026-03-09 15:37:40 +13:00
andres-portainer 0e2cf82e3e fix(yaml): consolidate dependencies BE-12695 (#2015) 2026-03-06 18:21:12 -03:00
andres-portainer 97e69b9887 fix(GO-2026-4550): upgrade circl to v1.6.3 BE-12694 (#2011) 2026-03-06 14:29:15 -03:00
andres-portainer 692f91263b fix(GO-2026-4473): upgrade go-git to v5.17.0 BE-12693 (#2010) 2026-03-06 11:23:52 -03:00
LP B 8b61d8a9d2 fix(app/container): query env registries instead of system registries (#1996) 2026-03-06 15:03:11 +01:00
LP B 25d51f9515 fix(app): paginate nested tables (#1998) 2026-03-06 15:01:52 +01:00
LP B 20b971dc1f fix(app/stack): virtual grouping in EnvSelector for non admins (#2001) 2026-03-06 15:00:01 +01:00
andres-portainer 7a76d749e3 fix(GO-2026-4394): upgrade opentelemetry to v1.41.0 BE-12692 (#2003) 2026-03-06 09:47:20 -03:00
LP B 123afd9462 fix(api/custom_template): validate UAC when retrieving custom template file (#1980) 2026-03-04 13:22:14 +01:00
Xing ad83478b77 fix(oauth): tolerate malformed Content-Type headers from resource ept (#1969)
Co-authored-by: Mike Spook <16549186+mikespook@user.noreply.gitee.com>
Co-authored-by: Oscar Zhou <100548325+oscarzhou-portainer@users.noreply.github.com>
Co-authored-by: RHCowan <50324595+RHCowan@users.noreply.github.com>

Thanks @srikanth-karthi for the original PR.
2026-03-02 10:59:02 +13:00
nickl-portainer 2ad0a65613 feat(policies): add inline editing ability to datatable for docker RBAC policies [R8S-717] (#1955) 2026-03-02 09:12:13 +13:00
Chaim Lev-Ari 1f5762b8c8 fix(settings/auth): fix a11y labels (#1963) 2026-03-01 12:14:47 +02:00
RHCowan 0370b09ad0 fix(policy) avoid URL length limit when adding environments to large groups [R8S-893] (#1970) 2026-02-27 11:45:15 +13:00
Oscar Zhou 5869a8948d refactor(stack): change stack creation flow to save stack first [BE-12650] (#1959) 2026-02-27 10:14:17 +13:00
Chaim Lev-Ari 56a840e207 feat(settings): migrate SessionLifetimeSelect to React [BE-12583] (#1829)
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-26 15:39:08 +02:00
Chaim Lev-Ari a01dd005fd refactor(settings/auth): migrate auto user provision toggle to react [BE-12585] (#1865)
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-26 14:18:48 +02:00
Chaim Lev-Ari 9ad6c16d43 feat(settings): migrate authentication method selector to React [BE-12584] (#1830)
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-26 10:52:39 +02:00
Hannah Cooper 9cc3e16db9 Update bug_report to include 2.39.0 (#1964) 2026-02-26 12:30:42 +13:00
andres-portainer d02bcdba29 fix(postinit): optimize PostInitMigrate() BE-12659 (#1958) 2026-02-25 16:03:26 -03:00
Steven Kang c708fe577c fix(kubernetes): local exec to fall back to SPDY - develop [R8S-873] (#1946) 2026-02-25 15:46:15 +13:00
Oscar Zhou c92161bb22 feat(edge/helm): support per device configuration [BE-12633] (#1901) 2026-02-25 10:00:37 +13:00
Ali 138aa13fdc fix(environment-groups): allow bulk selecting environments on create and edit [r8s-872] (#1954)
Merging because the failed system tests are related to helm and not environment groups
2026-02-24 17:53:16 +13:00
Steven Kang 988a795def fix(environment): collapsing More options breaking the style for podman - develop [R8S-874] (#1942) 2026-02-24 10:11:31 +13:00
Oscar Zhou 3f7a3053ff fix(stack): avoid removing running service if stack deployment fails [BE-12542] (#1940) 2026-02-24 08:41:42 +13:00
Oscar Zhou 0c8c6865be refactor(error): standardize multi errors handling [BE-12647] (#1933) 2026-02-23 09:40:01 +13:00
Chaim Lev-Ari 2bbcae39b6 feat: clean frontend test logs (#1894) 2026-02-22 09:42:49 +02:00
andres-portainer caf6b2aa0c fix(policies): fixes for async edge R8S-661 (#1917) 2026-02-20 17:45:45 -03:00
Steven Kang a00f05fe32 feat(environment): reorder options - develop [R8S-524] (#1822) 2026-02-20 14:58:01 +13:00
1514 changed files with 41823 additions and 14463 deletions
-3
View File
@@ -1,3 +0,0 @@
node_modules/
dist/
test/
-161
View File
@@ -1,161 +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
- 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
+3 -4
View File
@@ -94,6 +94,9 @@ 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.40.0'
- '2.39.1'
- '2.39.0'
- '2.38.1'
- '2.38.0'
- '2.37.0'
@@ -140,10 +143,6 @@ body:
- '2.22.0'
- '2.21.5'
- '2.21.4'
- '2.21.3'
- '2.21.2'
- '2.21.1'
- '2.21.0'
validations:
required: true
+2 -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
+20
View File
@@ -54,8 +54,28 @@ linters:
desc: github.com/ProtonMail/go-crypto/openpgp is not allowed because of FIPS mode
- pkg: github.com/cosi-project/runtime
desc: github.com/cosi-project/runtime is not allowed because of FIPS mode
- pkg: gopkg.in/yaml.v2
desc: use go.yaml.in/yaml/v3 instead
- pkg: gopkg.in/yaml.v3
desc: use go.yaml.in/yaml/v3 instead
- pkg: github.com/golang-jwt/jwt/v4
desc: use github.com/golang-jwt/jwt/v5 instead
- pkg: github.com/mitchellh/mapstructure
desc: use github.com/go-viper/mapstructure/v2 instead
- pkg: gopkg.in/alecthomas/kingpin.v2
desc: use github.com/alecthomas/kingpin/v2 instead
- pkg: github.com/jcmturner/gokrb5$
desc: use github.com/jcmturner/gokrb5/v8 instead
- pkg: github.com/gofrs/uuid
desc: use github.com/google/uuid
- pkg: github.com/Masterminds/semver$
desc: use github.com/Masterminds/semver/v3
- pkg: github.com/blang/semver
desc: use github.com/Masterminds/semver/v3
- pkg: github.com/coreos/go-semver
desc: use github.com/Masterminds/semver/v3
- pkg: github.com/hashicorp/go-version
desc: use github.com/Masterminds/semver/v3
forbidigo:
forbid:
- pattern: ^tls\.Config$
+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"]
}
+9 -7
View File
@@ -1,6 +1,7 @@
import path from 'path';
import { StorybookConfig } from '@storybook/react-webpack5';
import TsconfigPathsPlugin from 'tsconfig-paths-webpack-plugin';
import { Configuration } from 'webpack';
import postcss from 'postcss';
@@ -85,12 +86,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,
@@ -101,11 +97,17 @@ const config: StorybookConfig = {
staticDirs: ['./public'],
typescript: {
reactDocgen: 'react-docgen-typescript',
reactDocgenTypescriptOptions: {
compilerOptions: {
outDir: path.resolve(__dirname, '..', 'dist/public'),
},
},
},
framework: {
name: '@storybook/react-webpack5',
options: {},
},
docs: {},
};
export default config;
+5
View File
@@ -35,6 +35,11 @@ const preview: Preview = {
),
loaders: [mswLoader],
parameters: {
options: {
storySort: {
order: ['Design System', 'Components', '*'],
},
},
controls: {
matchers: {
color: /(background|color)$/i,
+129 -75
View File
@@ -1,27 +1,29 @@
/* eslint-disable */
/* tslint:disable */
import { v4 as uuidv4 } from 'uuid';
/**
* 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 +50,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 +63,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 +90,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));
const requestId = uuidv4();
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 +195,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 +243,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 +263,7 @@ async function getResponse(event, client, requestId) {
return respondWithMock(clientMessage.data);
}
case 'MOCK_NOT_FOUND': {
case 'PASSTHROUGH': {
return passthrough();
}
}
@@ -248,6 +271,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 +289,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 +315,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,
};
}
+23 -8
View File
@@ -2,16 +2,27 @@
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
- **PNPM** 10+ (for frontend)
- **Go** 1.25.7 (for backend)
- **Go** 1.26.1 (for backend)
## Build Commands
@@ -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)
+12 -2
View File
@@ -4,7 +4,8 @@ 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
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
@@ -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
@@ -112,6 +115,13 @@ 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
+13 -10
View File
@@ -4,13 +4,13 @@
Portainer maintains both Short-Term Support (STS) and Long-Term Support (LTS) versions in accordance with our official [Portainer Lifecycle Policy](https://docs.portainer.io/start/lifecycle).
| Version Type | Support Status |
| --- | --- |
| LTS (Long-Term Support) | Supported for critical security fixes |
| Version Type | Support Status |
| ------------------------ | ------------------------------------------- |
| LTS (Long-Term Support) | Supported for critical security fixes |
| STS (Short-Term Support) | Supported until the next STS or LTS release |
| Legacy / EOL | Not supported |
| Legacy / EOL | Not supported |
For a detailed breakdown of current versions and their specific End of Life (EOL) dates,
For a detailed breakdown of current versions and their specific End of Life (EOL) dates,
please refer to the [Portainer Lifecycle Policy](https://docs.portainer.io/start/lifecycle).
## Reporting a Vulnerability
@@ -21,15 +21,19 @@ The Portainer team takes the security of our products seriously. If you believe
### Disclosure Process
1. **Report**: Email your findings to security@portainer.io.
1. **Report**: You can report in one of two ways:
- **GitHub**: Use the **Report a vulnerability** button on the **Security** tab of this repository.
- **Email**: Send your findings to security@portainer.io.
2. **Details**: To help us verify the issue, please include:
- A description of the vulnerability and its potential impact.
- A description of the vulnerability and its potential impact.
- Step-by-step instructions to reproduce the issue (e.g. proof-of-concept code, scripts, or screenshots).
- Step-by-step instructions to reproduce the issue (e.g. proof-of-concept code, scripts, or screenshots).
- The version of the software and the environment in which it was found.
- The version of the software and the environment in which it was found.
3. **Acknowledge**: We will acknowledge receipt of your report and provide an initial assessment.
@@ -47,7 +51,6 @@ If you follow the responsible disclosure process, we will:
- Give credit for the discovery (if desired) once the fix is public.
We will make every effort to promptly address any security weaknesses. Security advisories and fixes will be published through GitHub Security Advisories and other channels as needed.
Thank you for helping keep Portainer and our community secure.
+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")
+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"))
}
+1
View File
@@ -5,6 +5,7 @@ import (
)
func TestParseECREndpoint(t *testing.T) {
t.Parallel()
tests := []struct {
name string
url string
+1
View File
@@ -6,6 +6,7 @@ import (
)
func TestGenerateGo119CompatibleKey(t *testing.T) {
t.Parallel()
type args struct {
seed string
}
+6 -15
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,23 +234,13 @@ 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
+3 -3
View File
@@ -1,7 +1,6 @@
package chisel
import (
"context"
"net"
"net/http"
"testing"
@@ -19,6 +18,7 @@ func init() {
}
func TestPingAgentPanic(t *testing.T) {
t.Parallel()
endpoint := &portainer.Endpoint{
ID: 1,
EdgeID: "test-edge-id",
@@ -26,7 +26,7 @@ func TestPingAgentPanic(t *testing.T) {
UserTrusted: true,
}
_, store := datastore.MustNewTestStore(t, true, true)
_, store := datastore.MustNewTestStore(t, false, true)
s := NewService(store, nil, nil)
@@ -54,6 +54,6 @@ 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)
}
+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
+5 -5
View File
@@ -32,7 +32,7 @@ func CLIFlags() *portainer.CLIFlags {
Assets: kingpin.Flag("assets", "Path to the assets").Default(defaultAssetsDirectory).Short('a').String(),
Data: kingpin.Flag("data", "Path to the folder where the data is stored").Default(defaultDataDirectory).Short('d').String(),
EndpointURL: kingpin.Flag("host", "Environment URL").Short('H').String(),
FeatureFlags: kingpin.Flag("feat", "List of feature flags").Strings(),
FeatureFlags: kingpin.Flag("feat", "List of feature flags").Envar(portainer.FeatureFlagEnvVar).Strings(),
EnableEdgeComputeFeatures: kingpin.Flag("edge-compute", "Enable Edge Compute features").Bool(),
NoAnalytics: kingpin.Flag("no-analytics", "Disable Analytics in app (deprecated)").Bool(),
TLSSkipVerify: kingpin.Flag("tlsskipverify", "Disable TLS server verification").Default(defaultTLSSkipVerify).Bool(),
@@ -152,11 +152,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 +173,7 @@ func displayDeprecationWarnings(flags *portainer.CLIFlags) {
}
}
func validateEndpointURL(endpointURL string) error {
func ValidateEndpointURL(endpointURL string) error {
if endpointURL == "" {
return nil
}
@@ -198,7 +198,7 @@ func validateEndpointURL(endpointURL string) error {
return nil
}
func validateSnapshotInterval(snapshotInterval string) error {
func ValidateSnapshotInterval(snapshotInterval string) error {
if snapshotInterval == "" {
return nil
}
+56 -25
View File
@@ -7,6 +7,7 @@ import (
"os"
"path"
"strings"
"time"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/apikey"
@@ -51,11 +52,10 @@ 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"
"github.com/portainer/portainer/pkg/validate"
"github.com/gofrs/uuid"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
)
@@ -119,7 +119,7 @@ func initDataStore(flags *portainer.CLIFlags, secretKey []byte, fileService port
}
if isNew {
instanceId, err := uuid.NewV4()
instanceId, err := uuid.NewRandom()
if err != nil {
log.Fatal().Err(err).Msg("failed generating instance id")
}
@@ -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
}
@@ -338,8 +333,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 +344,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)
@@ -461,19 +455,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 +530,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 +560,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,
@@ -604,7 +599,6 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
DockerClientFactory: dockerClientFactory,
KubernetesClientFactory: kubernetesClientFactory,
Scheduler: scheduler,
ShutdownCtx: shutdownCtx,
ShutdownTrigger: shutdownTrigger,
StackDeployer: stackDeployer,
UpgradeService: upgradeService,
@@ -626,7 +620,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 +633,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
}
+5 -2
View File
@@ -2,9 +2,10 @@ package main
import (
"os"
"path"
"testing"
"github.com/portainer/portainer/api/filesystem"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -18,8 +19,9 @@ func createPasswordFile(t *testing.T, secretPath, password string) string {
}
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)
@@ -39,6 +41,7 @@ func TestLoadEncryptionSecretKey(t *testing.T) {
}
func TestDBSecretPath(t *testing.T) {
t.Parallel()
tests := []struct {
keyFilenameFlag string
expected string
+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()
+6 -2
View File
@@ -10,7 +10,7 @@ import (
"io"
"testing"
"github.com/gofrs/uuid"
"github.com/google/uuid"
"github.com/pkg/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -27,9 +27,10 @@ func secretToEncryptionKey(passphrase string) []byte {
}
func Test_MarshalObjectUnencrypted(t *testing.T) {
t.Parallel()
is := assert.New(t)
uuid := uuid.Must(uuid.NewV4())
uuid := uuid.New()
tests := []struct {
object any
@@ -101,6 +102,7 @@ func Test_MarshalObjectUnencrypted(t *testing.T) {
}
func Test_UnMarshalObjectUnencrypted(t *testing.T) {
t.Parallel()
is := assert.New(t)
// Based on actual data entering and what we expect out of the function
@@ -142,6 +144,7 @@ func Test_UnMarshalObjectUnencrypted(t *testing.T) {
}
func Test_ObjectMarshallingEncrypted(t *testing.T) {
t.Parallel()
is := assert.New(t)
// Based on actual data entering and what we expect out of the function
@@ -184,6 +187,7 @@ func Test_ObjectMarshallingEncrypted(t *testing.T) {
}
func Test_NonceSources(t *testing.T) {
t.Parallel()
// ensure that the new go 1.24 NewGCMWithRandomNonce works correctly with
// the old way of creating and including the nonce
+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
View File
@@ -119,6 +119,19 @@ func (service *Service) Endpoints() ([]portainer.Endpoint, error) {
return endpoints, nil
}
// ReadAll retrieves all the elements that satisfy all the provided predicates.
func (service *Service) ReadAll(predicates ...func(endpoint portainer.Endpoint) bool) ([]portainer.Endpoint, error) {
var endpoints []portainer.Endpoint
var err error
err = service.connection.ViewTx(func(tx portainer.Transaction) error {
endpoints, err = service.Tx(tx).ReadAll(predicates...)
return err
})
return endpoints, err
}
// EndpointIDByEdgeID returns the EndpointID from the given EdgeID using an in-memory index
func (service *Service) EndpointIDByEdgeID(edgeID string) (portainer.EndpointID, bool) {
service.mu.RLock()
+5
View File
@@ -89,6 +89,11 @@ func (service ServiceTx) Endpoints() ([]portainer.Endpoint, error) {
)
}
// ReadAll retrieves all the elements that satisfy all the provided predicates.
func (service ServiceTx) ReadAll(predicates ...func(endpoint portainer.Endpoint) bool) ([]portainer.Endpoint, error) {
return dataservices.BaseDataServiceTx[portainer.Endpoint, portainer.EndpointID]{Bucket: BucketName, Connection: service.service.connection, Tx: service.tx}.ReadAll(predicates...)
}
func (service ServiceTx) EndpointIDByEdgeID(edgeID string) (portainer.EndpointID, bool) {
log.Error().Str("func", "EndpointIDByEdgeID").Msg("cannot be called inside a transaction")
@@ -28,6 +28,9 @@ func (service *Service) BucketName() string {
func (service *Service) RegisterUpdateStackFunction(
updateFuncTx func(portainer.Transaction, portainer.EdgeStackID, func(*portainer.EdgeStack)) error,
) {
service.mu.Lock()
defer service.mu.Unlock()
service.updateStackFnTx = updateFuncTx
}
@@ -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)
+3
View File
@@ -102,6 +102,9 @@ type (
// EndpointService represents a service for managing environment(endpoint) data
EndpointService interface {
// partial dataservices.BaseCRUD[portainer.Endpoint, portainer.EndpointID]
ReadAll(predicates ...func(endpoint portainer.Endpoint) bool) ([]portainer.Endpoint, error)
Endpoint(ID portainer.EndpointID) (*portainer.Endpoint, error)
EndpointIDByEdgeID(edgeID string) (portainer.EndpointID, bool)
EndpointsByTeamID(teamID portainer.TeamID) ([]portainer.Endpoint, error)
@@ -10,6 +10,7 @@ import (
)
func TestDeleteByEndpoint(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, false)
// Create Endpoint 1
+6 -4
View File
@@ -8,13 +8,13 @@ import (
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/filesystem"
"github.com/gofrs/uuid"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func newGuidString(t *testing.T) string {
uuid, err := uuid.NewV4()
uuid, err := uuid.NewRandom()
require.NoError(t, err)
return uuid.String()
@@ -27,10 +27,11 @@ type stackBuilder struct {
}
func TestService_StackByWebhookID(t *testing.T) {
t.Parallel()
if testing.Short() {
t.Skip("skipping test in short mode. Normally takes ~1s to run.")
}
_, store := datastore.MustNewTestStore(t, true, true)
_, store := datastore.MustNewTestStore(t, false, true)
b := stackBuilder{t: t, store: store}
b.createNewStack(newGuidString(t))
@@ -84,10 +85,11 @@ func (b *stackBuilder) createNewStack(webhookID string) portainer.Stack {
}
func Test_RefreshableStacks(t *testing.T) {
t.Parallel()
if testing.Short() {
t.Skip("skipping test in short mode. Normally takes ~1s to run.")
}
_, store := datastore.MustNewTestStore(t, true, true)
_, store := datastore.MustNewTestStore(t, false, true)
staticStack := portainer.Stack{ID: 1}
stackWithWebhook := portainer.Stack{ID: 2, AutoUpdate: &portainer.AutoUpdateSettings{Webhook: "webhook"}}
+24 -3
View File
@@ -3,6 +3,7 @@ package tests
import (
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices/errors"
"github.com/portainer/portainer/api/datastore"
@@ -10,9 +11,29 @@ import (
"github.com/stretchr/testify/require"
)
type teamBuilder struct {
t *testing.T
count int
store *datastore.Store
}
func (b *teamBuilder) createNew(name string) *portainer.Team {
b.count++
team := &portainer.Team{
ID: portainer.TeamID(b.count),
Name: name,
}
err := b.store.Team().Create(team)
assert.NoError(b.t, err)
return team
}
func Test_teamByName(t *testing.T) {
t.Parallel()
t.Run("When store is empty should return ErrObjectNotFound", func(t *testing.T) {
_, store := datastore.MustNewTestStore(t, true, true)
_, store := datastore.MustNewTestStore(t, false, true)
_, err := store.Team().TeamByName("name")
require.ErrorIs(t, err, errors.ErrObjectNotFound)
@@ -20,7 +41,7 @@ func Test_teamByName(t *testing.T) {
})
t.Run("When there is no object with the same name should return ErrObjectNotFound", func(t *testing.T) {
_, store := datastore.MustNewTestStore(t, true, true)
_, store := datastore.MustNewTestStore(t, false, true)
teamBuilder := teamBuilder{
t: t,
@@ -35,7 +56,7 @@ func Test_teamByName(t *testing.T) {
})
t.Run("When there is an object with the same name should return the object", func(t *testing.T) {
_, store := datastore.MustNewTestStore(t, true, true)
_, store := datastore.MustNewTestStore(t, false, true)
teamBuilder := teamBuilder{
t: t,
-28
View File
@@ -1,28 +0,0 @@
package tests
import (
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/datastore"
"github.com/stretchr/testify/assert"
)
type teamBuilder struct {
t *testing.T
count int
store *datastore.Store
}
func (b *teamBuilder) createNew(name string) *portainer.Team {
b.count++
team := &portainer.Team{
ID: portainer.TeamID(b.count),
Name: name,
}
err := b.store.Team().Create(team)
assert.NoError(b.t, err)
return team
}
+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){
+5 -4
View File
@@ -6,18 +6,18 @@ import (
"fmt"
"io"
"os"
"path/filepath"
"testing"
"github.com/Masterminds/semver"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/database/boltdb"
"github.com/portainer/portainer/api/database/models"
"github.com/portainer/portainer/api/datastore/migrator"
"github.com/stretchr/testify/require"
"github.com/portainer/portainer/api/filesystem"
"github.com/Masterminds/semver/v3"
"github.com/google/go-cmp/cmp"
"github.com/rs/zerolog/log"
"github.com/stretchr/testify/require"
)
func TestMigrateData(t *testing.T) {
@@ -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)
+206
View File
@@ -0,0 +1,206 @@
package migrator
import (
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/database/boltdb"
"github.com/portainer/portainer/api/dataservices/endpoint"
"github.com/portainer/portainer/api/dataservices/pendingactions"
"github.com/portainer/portainer/api/dataservices/registry"
"github.com/portainer/portainer/api/logs"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMigrateRegistryAccessSASecrets_2_40_0(t *testing.T) {
t.Parallel()
var conn portainer.Connection = &boltdb.DbConnection{Path: t.TempDir()}
err := conn.Open()
require.NoError(t, err)
defer logs.CloseAndLogErr(conn)
registryService, err := registry.NewService(conn)
require.NoError(t, err)
endpointService, err := endpoint.NewService(conn)
require.NoError(t, err)
pendingActionsService, err := pendingactions.NewService(conn)
require.NoError(t, err)
t.Run("sets MigrateRegistrySASecrets flag for k8s endpoints with registry access", func(t *testing.T) {
k8sEndpoint := &portainer.Endpoint{
ID: 1,
Name: "k8s-cluster",
Type: portainer.AgentOnKubernetesEnvironment,
}
dockerEndpoint := &portainer.Endpoint{
ID: 2,
Name: "docker-standalone",
Type: portainer.DockerEnvironment,
}
err := conn.CreateObjectWithId(endpoint.BucketName, int(k8sEndpoint.ID), k8sEndpoint)
require.NoError(t, err)
err = conn.CreateObjectWithId(endpoint.BucketName, int(dockerEndpoint.ID), dockerEndpoint)
require.NoError(t, err)
reg := &portainer.Registry{
ID: 1,
Name: "test-registry",
RegistryAccesses: portainer.RegistryAccesses{
k8sEndpoint.ID: portainer.RegistryAccessPolicies{
Namespaces: []string{"default", "production"},
},
dockerEndpoint.ID: portainer.RegistryAccessPolicies{
Namespaces: []string{"ignored"},
},
},
}
err = conn.CreateObjectWithId(registry.BucketName, int(reg.ID), reg)
require.NoError(t, err)
m := NewMigrator(&MigratorParameters{
RegistryService: registryService,
EndpointService: endpointService,
PendingActionsService: pendingActionsService,
})
err = m.migrateRegistryAccessSASecrets_2_40_0()
require.NoError(t, err)
updatedK8sEndpoint, err := endpointService.Endpoint(k8sEndpoint.ID)
require.NoError(t, err)
assert.True(t, updatedK8sEndpoint.PostInitMigrations.MigrateRegistrySASecrets, "should have set MigrateRegistrySASecrets flag for k8s endpoint")
updatedDockerEndpoint, err := endpointService.Endpoint(dockerEndpoint.ID)
require.NoError(t, err)
assert.False(t, updatedDockerEndpoint.PostInitMigrations.MigrateRegistrySASecrets, "should not have set MigrateRegistrySASecrets flag for docker endpoint")
})
t.Run("skips endpoints with empty namespaces", func(t *testing.T) {
conn2 := &boltdb.DbConnection{Path: t.TempDir()}
err := conn2.Open()
require.NoError(t, err)
defer logs.CloseAndLogErr(conn2)
registryService2, _ := registry.NewService(conn2)
endpointService2, _ := endpoint.NewService(conn2)
pendingActionsService2, _ := pendingactions.NewService(conn2)
k8sEndpoint := &portainer.Endpoint{
ID: 10,
Name: "k8s-cluster",
Type: portainer.AgentOnKubernetesEnvironment,
}
err = conn2.CreateObjectWithId(endpoint.BucketName, int(k8sEndpoint.ID), k8sEndpoint)
require.NoError(t, err)
reg := &portainer.Registry{
ID: 10,
Name: "empty-registry",
RegistryAccesses: portainer.RegistryAccesses{
k8sEndpoint.ID: portainer.RegistryAccessPolicies{
Namespaces: []string{},
},
},
}
err = conn2.CreateObjectWithId(registry.BucketName, int(reg.ID), reg)
require.NoError(t, err)
m := NewMigrator(&MigratorParameters{
RegistryService: registryService2,
EndpointService: endpointService2,
PendingActionsService: pendingActionsService2,
})
err = m.migrateRegistryAccessSASecrets_2_40_0()
require.NoError(t, err)
allPAs, err := pendingActionsService2.ReadAll()
require.NoError(t, err)
assert.Empty(t, allPAs, "should not create pending actions for empty namespaces")
})
t.Run("skips non-existent endpoints", func(t *testing.T) {
conn3 := &boltdb.DbConnection{Path: t.TempDir()}
err := conn3.Open()
require.NoError(t, err)
defer logs.CloseAndLogErr(conn3)
registryService3, _ := registry.NewService(conn3)
endpointService3, _ := endpoint.NewService(conn3)
pendingActionsService3, _ := pendingactions.NewService(conn3)
reg := &portainer.Registry{
ID: 20,
Name: "orphan-registry",
RegistryAccesses: portainer.RegistryAccesses{
999: portainer.RegistryAccessPolicies{
Namespaces: []string{"default"},
},
},
}
err = conn3.CreateObjectWithId(registry.BucketName, int(reg.ID), reg)
require.NoError(t, err)
m := NewMigrator(&MigratorParameters{
RegistryService: registryService3,
EndpointService: endpointService3,
PendingActionsService: pendingActionsService3,
})
err = m.migrateRegistryAccessSASecrets_2_40_0()
require.NoError(t, err)
allPAs, err := pendingActionsService3.ReadAll()
require.NoError(t, err)
assert.Empty(t, allPAs, "should not create pending actions for non-existent endpoints")
})
t.Run("idempotent - running twice creates duplicate actions but doesn't error", func(t *testing.T) {
conn4 := &boltdb.DbConnection{Path: t.TempDir()}
err := conn4.Open()
require.NoError(t, err)
defer logs.CloseAndLogErr(conn4)
registryService4, _ := registry.NewService(conn4)
endpointService4, _ := endpoint.NewService(conn4)
pendingActionsService4, _ := pendingactions.NewService(conn4)
k8sEndpoint := &portainer.Endpoint{
ID: 30,
Name: "k8s-cluster",
Type: portainer.AgentOnKubernetesEnvironment,
}
err = conn4.CreateObjectWithId(endpoint.BucketName, int(k8sEndpoint.ID), k8sEndpoint)
require.NoError(t, err)
reg := &portainer.Registry{
ID: 30,
Name: "test-registry",
RegistryAccesses: portainer.RegistryAccesses{
k8sEndpoint.ID: portainer.RegistryAccessPolicies{
Namespaces: []string{"default"},
},
},
}
err = conn4.CreateObjectWithId(registry.BucketName, int(reg.ID), reg)
require.NoError(t, err)
m := NewMigrator(&MigratorParameters{
RegistryService: registryService4,
EndpointService: endpointService4,
PendingActionsService: pendingActionsService4,
})
err = m.migrateRegistryAccessSASecrets_2_40_0()
require.NoError(t, err)
err = m.migrateRegistryAccessSASecrets_2_40_0()
require.NoError(t, err)
})
}
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"github.com/pkg/errors"
portainer "github.com/portainer/portainer/api"
"github.com/Masterminds/semver"
"github.com/Masterminds/semver/v3"
"github.com/rs/zerolog/log"
)
@@ -0,0 +1,58 @@
package migrator
import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/internal/endpointutils"
"github.com/rs/zerolog/log"
)
// migrateRegistryAccessSASecrets_2_40_0 marks Kubernetes endpoints that have
// registry access configured so that imagePullSecrets can be added to their
// default ServiceAccounts during the post-init migration phase (when cluster
// access is available).
func (m *Migrator) migrateRegistryAccessSASecrets_2_40_0() error {
log.Info().Msg("migrating registry access service account secrets")
registries, err := m.registryService.ReadAll()
if err != nil {
return err
}
endpoints, err := m.endpointService.Endpoints()
if err != nil {
return err
}
// Collect the IDs of endpoints that have at least one registry with
// non-empty namespace access - these need the SA imagePullSecrets migration.
needsMigration := make(map[portainer.EndpointID]bool)
for _, registry := range registries {
for endpointID, access := range registry.RegistryAccesses {
if len(access.Namespaces) > 0 {
needsMigration[endpointID] = true
}
}
}
for i := range endpoints {
endpoint := &endpoints[i]
if !endpointutils.IsKubernetesEndpoint(endpoint) {
continue
}
if !needsMigration[endpoint.ID] {
continue
}
endpoint.PostInitMigrations.MigrateRegistrySASecrets = true
if err := m.endpointService.UpdateEndpoint(endpoint.ID, endpoint); err != nil {
log.Warn().
Err(err).
Int("endpointID", int(endpoint.ID)).
Msg("failed to set registry SA secret migration flag for endpoint")
}
}
return nil
}
+3 -1
View File
@@ -29,7 +29,7 @@ import (
"github.com/portainer/portainer/api/dataservices/version"
"github.com/portainer/portainer/api/internal/authorization"
"github.com/Masterminds/semver"
"github.com/Masterminds/semver/v3"
"github.com/rs/zerolog/log"
)
@@ -258,6 +258,8 @@ func (m *Migrator) initMigrations() {
m.addMigrations("2.33.1", m.migrateEdgeGroupEndpointsToRoars_2_33_0)
m.addMigrations("2.40.0", m.migrateRegistryAccessSASecrets_2_40_0)
// WARNING: do not change migrations that have already been released!
// Add new migrations above...
+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)
+172 -52
View File
@@ -1,8 +1,10 @@
package postinit
import (
"cmp"
"context"
"fmt"
"slices"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/client"
@@ -10,6 +12,7 @@ import (
"github.com/portainer/portainer/api/dataservices"
dockerClient "github.com/portainer/portainer/api/docker/client"
"github.com/portainer/portainer/api/internal/endpointutils"
"github.com/portainer/portainer/api/internal/registryutils"
"github.com/portainer/portainer/api/kubernetes/cli"
"github.com/portainer/portainer/api/logs"
"github.com/portainer/portainer/api/pendingactions/actions"
@@ -44,40 +47,65 @@ func NewPostInitMigrator(
// PostInitMigrate will run all post-init migrations, which require docker/kube clients for all edge or non-edge environments
func (postInitMigrator *PostInitMigrator) PostInitMigrate() error {
environments, err := postInitMigrator.dataStore.Endpoint().Endpoints()
if err != nil {
log.Error().Err(err).Msg("Error getting environments")
return err
}
var environments []portainer.Endpoint
for _, environment := range environments {
// edge environments will run after the server starts, in pending actions
if endpoints.IsEdgeEndpoint(&environment) {
// Skip edge environments that do not have direct connectivity
if !endpoints.HasDirectConnectivity(&environment) {
if err := postInitMigrator.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
var err error
if environments, err = tx.Endpoint().ReadAll(func(endpoint portainer.Endpoint) bool {
return endpoints.HasDirectConnectivity(&endpoint)
}); err != nil {
return fmt.Errorf("failed to retrieve environments: %w", err)
}
var pendingActions []portainer.PendingAction
if pendingActions, err = tx.PendingActions().ReadAll(func(action portainer.PendingAction) bool {
return action.Action == actions.PostInitMigrateEnvironment
}); err != nil {
return fmt.Errorf("failed to retrieve pending actions: %w", err)
}
// Sort for the binary search in createPostInitMigrationPendingAction()
slices.SortFunc(pendingActions, func(a, b portainer.PendingAction) int {
return cmp.Compare(a.EndpointID, b.EndpointID)
})
for _, environment := range environments {
if !endpoints.IsEdgeEndpoint(&environment) {
continue
}
// Edge environments will run after the server starts, in pending actions
log.Info().
Int("endpoint_id", int(environment.ID)).
Msg("adding pending action 'PostInitMigrateEnvironment' for environment")
if err := postInitMigrator.createPostInitMigrationPendingAction(environment.ID); err != nil {
if err := postInitMigrator.createPostInitMigrationPendingAction(tx, environment.ID, pendingActions); err != nil {
log.Error().
Err(err).
Int("endpoint_id", int(environment.ID)).
Msg("error creating pending action for environment")
}
} else {
// Non-edge environments will run before the server starts.
if err := postInitMigrator.MigrateEnvironment(&environment); err != nil {
log.Error().
Err(err).
Int("endpoint_id", int(environment.ID)).
Msg("error running post-init migrations for non-edge environment")
}
}
return err
}); err != nil {
log.Error().Err(err).Msg("error running post-init migrations")
return err
}
for _, environment := range environments {
if endpoints.IsEdgeEndpoint(&environment) {
continue
}
// Non-edge environments will run before the server starts.
if err := postInitMigrator.MigrateEnvironment(&environment); err != nil {
log.Error().
Err(err).
Int("endpoint_id", int(environment.ID)).
Msg("error running post-init migrations for non-edge environment")
}
}
return nil
@@ -85,59 +113,79 @@ func (postInitMigrator *PostInitMigrator) PostInitMigrate() error {
// try to create a post init migration pending action. If it already exists, do nothing
// this function exists for readability, not reusability
func (postInitMigrator *PostInitMigrator) createPostInitMigrationPendingAction(environmentID portainer.EndpointID) error {
// pending actions must be passed in ascending order by endpoint ID
func (postInitMigrator *PostInitMigrator) createPostInitMigrationPendingAction(tx dataservices.DataStoreTx, environmentID portainer.EndpointID, pendingActions []portainer.PendingAction) error {
action := portainer.PendingAction{
EndpointID: environmentID,
Action: actions.PostInitMigrateEnvironment,
}
pendingActions, err := postInitMigrator.dataStore.PendingActions().ReadAll()
if err != nil {
return fmt.Errorf("failed to retrieve pending actions: %w", err)
if _, found := slices.BinarySearchFunc(pendingActions, environmentID, func(e portainer.PendingAction, id portainer.EndpointID) int {
return cmp.Compare(e.EndpointID, id)
}); found {
log.Debug().
Str("action", action.Action).
Int("endpoint_id", int(action.EndpointID)).
Msg("pending action already exists for environment, skipping...")
return nil
}
for _, dba := range pendingActions {
if dba.EndpointID == action.EndpointID && dba.Action == action.Action {
log.Debug().
Str("action", action.Action).
Int("endpoint_id", int(action.EndpointID)).
Msg("pending action already exists for environment, skipping...")
return nil
}
}
return postInitMigrator.dataStore.PendingActions().Create(&action)
return tx.PendingActions().Create(&action)
}
// MigrateEnvironment runs migrations on a single environment
func (migrator *PostInitMigrator) MigrateEnvironment(environment *portainer.Endpoint) error {
log.Info().Msgf("Executing post init migration for environment %d", environment.ID)
log.Info().
Int("endpoint_id", int(environment.ID)).
Msg("executing post init migration for environment")
switch {
case endpointutils.IsKubernetesEndpoint(environment):
// get the kubeclient for the environment, and skip all kube migrations if there's an error
kubeclient, err := migrator.kubeFactory.GetPrivilegedKubeClient(environment)
if err != nil {
log.Error().Err(err).Msgf("Error creating kubeclient for environment: %d", environment.ID)
log.Error().
Err(err).
Int("endpoint_id", int(environment.ID)).
Msg("error creating kubeclient for environment")
return err
}
// if one environment fails, it is logged and the next migration runs. The error is returned at the end and handled by pending actions
if err := migrator.MigrateIngresses(*environment, kubeclient); err != nil {
return err
// If one environment fails, it is logged and the next migration runs. The error is returned at the end and handled by pending actions
var latestErr error
kubernetesMigrations := []func() error{
func() error { return migrator.MigrateIngresses(*environment, kubeclient) },
func() error { return migrator.MigrateRegistrySASecrets(*environment, kubeclient) },
}
return nil
for _, migration := range kubernetesMigrations {
if err := migration(); err != nil {
latestErr = err
}
}
return latestErr
case endpointutils.IsDockerEndpoint(environment):
// get the docker client for the environment, and skip all docker migrations if there's an error
dockerClient, err := migrator.dockerFactory.CreateClient(environment, "", nil)
if err != nil {
log.Error().Err(err).Msgf("Error creating docker client for environment: %d", environment.ID)
log.Error().
Err(err).
Int("endpoint_id", int(environment.ID)).
Msg("error creating docker client for environment")
return err
}
defer logs.CloseAndLogErr(dockerClient)
if err := migrator.MigrateGPUs(*environment, dockerClient); err != nil {
log.Error().
Err(err).
Int("endpoint_id", int(environment.ID)).
Msg("error migrating GPUs for environment")
return err
}
}
@@ -145,18 +193,73 @@ func (migrator *PostInitMigrator) MigrateEnvironment(environment *portainer.Endp
return nil
}
func (migrator *PostInitMigrator) MigrateRegistrySASecrets(environment portainer.Endpoint, kubeclient *cli.KubeClient) error {
if !environment.PostInitMigrations.MigrateRegistrySASecrets {
return nil
}
log.Debug().
Int("endpoint_id", int(environment.ID)).
Msg("migrating registry SA secrets for environment")
return migrator.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
env, err := tx.Endpoint().Endpoint(environment.ID)
if err != nil {
return err
}
if !env.PostInitMigrations.MigrateRegistrySASecrets {
return nil
}
registries, err := tx.Registry().ReadAll()
if err != nil {
return err
}
for _, registry := range registries {
access, ok := registry.RegistryAccesses[env.ID]
if !ok || len(access.Namespaces) == 0 {
continue
}
secretName := registryutils.RegistrySecretName(registry.ID)
for _, namespace := range access.Namespaces {
if err := kubeclient.AddImagePullSecretToServiceAccount(namespace, "default", secretName); err != nil {
log.Warn().
Err(err).
Int("endpoint_id", int(env.ID)).
Str("namespace", namespace).
Str("secret", secretName).
Msg("failed to add imagePullSecret to service account during registry SA secret migration")
}
}
}
env.PostInitMigrations.MigrateRegistrySASecrets = false
return tx.Endpoint().UpdateEndpoint(env.ID, env)
})
}
func (migrator *PostInitMigrator) MigrateIngresses(environment portainer.Endpoint, kubeclient *cli.KubeClient) error {
// Early exit if we do not need to migrate!
if !environment.PostInitMigrations.MigrateIngresses {
return nil
}
log.Debug().Msgf("Migrating ingresses for environment %d", environment.ID)
err := migrator.kubeFactory.MigrateEndpointIngresses(&environment, migrator.dataStore, kubeclient)
if err != nil {
log.Error().Err(err).Msgf("Error migrating ingresses for environment %d", environment.ID)
log.Debug().
Int("endpoint_id", int(environment.ID)).
Msg("migrating ingresses for environment")
if err := migrator.kubeFactory.MigrateEndpointIngresses(&environment, migrator.dataStore, kubeclient); err != nil {
log.Error().
Err(err).
Int("endpoint_id", int(environment.ID)).
Msg("error migrating ingresses for environment")
return err
}
return nil
}
@@ -166,29 +269,42 @@ func (migrator *PostInitMigrator) MigrateGPUs(e portainer.Endpoint, dockerClient
return migrator.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
environment, err := tx.Endpoint().Endpoint(e.ID)
if err != nil {
log.Error().Err(err).Msgf("Error getting environment %d", e.ID)
log.Error().
Err(err).
Int("endpoint_id", int(e.ID)).
Msg("error getting environment")
return err
}
// Early exit if we do not need to migrate!
if !environment.PostInitMigrations.MigrateGPUs {
return nil
}
log.Debug().Msgf("Migrating GPUs for environment %d", e.ID)
// get all containers
log.Debug().
Int("endpoint_id", int(e.ID)).
Msg("migrating GPUs for environment")
// Get all containers
containers, err := dockerClient.ContainerList(context.Background(), container.ListOptions{All: true})
if err != nil {
log.Error().Err(err).Msgf("failed to list containers for environment %d", environment.ID)
log.Error().
Err(err).
Int("endpoint_id", int(environment.ID)).
Msg("failed to list containers for environment")
return err
}
// check for a gpu on each container. If even one GPU is found, set EnableGPUManagement to true for the whole environment
// Check for a gpu on each container. If even one GPU is found, set EnableGPUManagement to true for the whole environment
containersLoop:
for _, container := range containers {
// https://www.sobyte.net/post/2022-10/go-docker/ has nice documentation on the docker client with GPUs
containerDetails, err := dockerClient.ContainerInspect(context.Background(), container.ID)
if err != nil {
log.Error().Err(err).Msg("failed to inspect container")
continue
}
@@ -202,10 +318,14 @@ func (migrator *PostInitMigrator) MigrateGPUs(e portainer.Endpoint, dockerClient
}
}
// set the MigrateGPUs flag to false so we don't run this again
// Set the MigrateGPUs flag to false so we don't run this again
environment.PostInitMigrations.MigrateGPUs = false
if err := tx.Endpoint().UpdateEndpoint(environment.ID, environment); err != nil {
log.Error().Err(err).Msgf("Error updating EnableGPUManagement flag for environment %d", environment.ID)
log.Error().
Err(err).
Int("endpoint_id", int(environment.ID)).
Msg("error updating EnableGPUManagement flag for environment")
return err
}
@@ -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
@@ -80,7 +80,8 @@
"Name": "local",
"PostInitMigrations": {
"MigrateGPUs": true,
"MigrateIngresses": true
"MigrateIngresses": true,
"MigrateRegistrySASecrets": false
},
"PublicURL": "",
"SecuritySettings": {
@@ -89,6 +90,7 @@
"allowDeviceMappingForRegularUsers": true,
"allowHostNamespaceForRegularUsers": true,
"allowPrivilegedModeForRegularUsers": true,
"allowSecurityOptForRegularUsers": false,
"allowStackManagementForRegularUsers": true,
"allowSysctlSettingForRegularUsers": false,
"allowVolumeBrowserForRegularUsers": false,
@@ -613,7 +615,7 @@
"RequiredPasswordLength": 12
},
"KubeconfigExpiry": "0",
"KubectlShellImage": "portainer/kubectl-shell:2.39.0",
"KubectlShellImage": "portainer/kubectl-shell:2.41.1",
"LDAPSettings": {
"AnonymousMode": true,
"AutoCreateUsers": true,
@@ -806,6 +808,7 @@
"AutoUpdate": null,
"CreatedBy": "",
"CreationDate": 0,
"DeploymentStartStatus": 0,
"EndpointId": 1,
"EntryPoint": "docker/alpine37-compose.yml",
"Env": [],
@@ -828,6 +831,7 @@
"AutoUpdate": null,
"CreatedBy": "",
"CreationDate": 0,
"DeploymentStartStatus": 0,
"EndpointId": 1,
"EntryPoint": "docker-compose.yml",
"Env": [],
@@ -850,6 +854,7 @@
"AutoUpdate": null,
"CreatedBy": "",
"CreationDate": 0,
"DeploymentStartStatus": 0,
"EndpointId": 1,
"EntryPoint": "docker-compose.yml",
"Env": [],
@@ -942,7 +947,7 @@
}
],
"version": {
"VERSION": "{\"SchemaVersion\":\"2.39.0\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
"VERSION": "{\"SchemaVersion\":\"2.41.1\",\"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
+19 -63
View File
@@ -10,7 +10,7 @@ import (
"github.com/portainer/portainer/api/docker/images"
"github.com/portainer/portainer/api/logs"
"github.com/Masterminds/semver"
"github.com/Masterminds/semver/v3"
"github.com/docker/docker/api/types"
dockercontainer "github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/network"
@@ -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)"},
+3
View File
@@ -54,6 +54,9 @@ type (
// Used only for EE
AlwaysCloneGitRepoForRelativePath bool
// Whether the edge stack supports per device configs
SupportPerDeviceConfigs bool
// Mount point for relative path
FilesystemPath string
// Used only for EE
+1
View File
@@ -70,6 +70,7 @@ func (manager *ComposeStackManager) Up(ctx context.Context, stack *portainer.Sta
},
ForceRecreate: options.ForceRecreate,
AbortOnContainerExit: options.AbortOnContainerExit,
RemoveOrphans: options.Prune,
})
return errors.Wrap(err, "failed to deploy a stack")
}
+5 -7
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,6 +41,7 @@ func setup(t *testing.T) (*portainer.Stack, *portainer.Endpoint) {
}
func Test_UpAndDown(t *testing.T) {
t.Parallel()
testhelpers.IntegrationTest(t)
stack, endpoint := setup(t)
@@ -50,9 +50,7 @@ func Test_UpAndDown(t *testing.T) {
w := NewComposeStackManager(deployer, nil, 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)
}
+9 -8
View File
@@ -3,17 +3,17 @@ package exec
import (
"io"
"os"
"path"
"path/filepath"
"testing"
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 +56,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 +70,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 +83,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)
+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{})
+22 -11
View File
@@ -2,6 +2,7 @@ package exec
import (
"bytes"
"context"
"errors"
"os"
"os/exec"
@@ -53,7 +54,7 @@ func NewSwarmStackManager(
}
// Login executes the docker login command against a list of registries (including DockerHub).
func (manager *SwarmStackManager) Login(registries []portainer.Registry, endpoint *portainer.Endpoint) error {
func (manager *SwarmStackManager) Login(ctx context.Context, registries []portainer.Registry, endpoint *portainer.Endpoint) error {
command, args, err := manager.prepareDockerCommandAndArgs(manager.binaryPath, manager.configPath, endpoint)
if err != nil {
return err
@@ -67,7 +68,7 @@ func (manager *SwarmStackManager) Login(registries []portainer.Registry, endpoin
}
registryArgs := append(args, "login", "--username", username, "--password", password, registry.URL)
if err := runCommandAndCaptureStdErr(command, registryArgs, nil, ""); err != nil {
if err := runCommandAndCaptureStdErr(ctx, command, registryArgs, nil, ""); err != nil {
log.Warn().
Err(err).
Str("RegistryName", registry.Name).
@@ -80,7 +81,7 @@ func (manager *SwarmStackManager) Login(registries []portainer.Registry, endpoin
}
// Logout executes the docker logout command.
func (manager *SwarmStackManager) Logout(endpoint *portainer.Endpoint) error {
func (manager *SwarmStackManager) Logout(ctx context.Context, endpoint *portainer.Endpoint) error {
command, args, err := manager.prepareDockerCommandAndArgs(manager.binaryPath, manager.configPath, endpoint)
if err != nil {
return err
@@ -88,11 +89,11 @@ func (manager *SwarmStackManager) Logout(endpoint *portainer.Endpoint) error {
args = append(args, "logout")
return runCommandAndCaptureStdErr(command, args, nil, "")
return runCommandAndCaptureStdErr(ctx, 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 {
func (manager *SwarmStackManager) Deploy(ctx context.Context, stack *portainer.Stack, prune bool, pullImage bool, endpoint *portainer.Endpoint) error {
filePaths := stackutils.GetStackFilePaths(stack, true)
command, args, err := manager.prepareDockerCommandAndArgs(manager.binaryPath, manager.configPath, endpoint)
if err != nil {
@@ -117,11 +118,11 @@ func (manager *SwarmStackManager) Deploy(stack *portainer.Stack, prune bool, pul
env = append(env, envvar.Name+"="+envvar.Value)
}
return runCommandAndCaptureStdErr(command, args, env, stack.ProjectPath)
return runCommandAndCaptureStdErr(ctx, command, args, env, stack.ProjectPath)
}
// Remove executes the docker stack rm command.
func (manager *SwarmStackManager) Remove(stack *portainer.Stack, endpoint *portainer.Endpoint) error {
func (manager *SwarmStackManager) Remove(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint) error {
command, args, err := manager.prepareDockerCommandAndArgs(manager.binaryPath, manager.configPath, endpoint)
if err != nil {
return err
@@ -129,14 +130,16 @@ func (manager *SwarmStackManager) Remove(stack *portainer.Stack, endpoint *porta
args = append(args, "stack", "rm", "--detach=false", stack.Name)
return runCommandAndCaptureStdErr(command, args, nil, "")
return runCommandAndCaptureStdErr(ctx, command, args, nil, "")
}
func runCommandAndCaptureStdErr(command string, args []string, env []string, workingDir string) error {
func runCommandAndCaptureStdErr(ctx context.Context, command string, args []string, env []string, workingDir string) error {
var stderr bytes.Buffer
var stdout bytes.Buffer
cmd := exec.Command(command, args...)
cmd := exec.CommandContext(ctx, command, args...)
cmd.Stderr = &stderr
cmd.Stdout = &stdout
if workingDir != "" {
cmd.Dir = workingDir
@@ -148,7 +151,15 @@ func runCommandAndCaptureStdErr(command string, args []string, env []string, wor
}
if err := cmd.Run(); err != nil {
return errors.New(stderr.String())
errMsg := strings.TrimSpace(stderr.String())
if errMsg == "" {
errMsg = strings.TrimSpace(stdout.String())
}
if errMsg == "" {
errMsg = err.Error()
}
return errors.New(errMsg)
}
return nil
+43
View File
@@ -1,6 +1,7 @@
package exec
import (
"context"
"testing"
portainer "github.com/portainer/portainer/api"
@@ -9,6 +10,7 @@ import (
)
func TestConfigFilePaths(t *testing.T) {
t.Parallel()
args := []string{"stack", "deploy", "--with-registry-auth"}
filePaths := []string{"dir/file", "dir/file-two", "dir/file-three"}
expected := []string{"stack", "deploy", "--with-registry-auth", "--compose-file", "dir/file", "--compose-file", "dir/file-two", "--compose-file", "dir/file-three"}
@@ -17,6 +19,7 @@ func TestConfigFilePaths(t *testing.T) {
}
func TestPrepareDockerCommandAndArgs(t *testing.T) {
t.Parallel()
binaryPath := "/test/dist"
configPath := "/test/config"
manager := &SwarmStackManager{
@@ -41,3 +44,43 @@ func TestPrepareDockerCommandAndArgs(t *testing.T) {
require.Equal(t, expectedCommand, command)
require.Equal(t, expectedArgs, args)
}
func TestRunCommandAndCaptureStdErr(t *testing.T) {
t.Parallel()
t.Run("should return nil on successful command", func(t *testing.T) {
err := runCommandAndCaptureStdErr(context.Background(), "echo", []string{"hello"}, nil, "")
require.NoError(t, err)
})
t.Run("should capture stderr on failure", func(t *testing.T) {
err := runCommandAndCaptureStdErr(context.Background(), "sh", []string{"-c", "echo 'stderr error' >&2; exit 1"}, nil, "")
require.Error(t, err)
assert.Contains(t, err.Error(), "stderr error")
})
t.Run("should fall back to stdout when stderr is empty", func(t *testing.T) {
err := runCommandAndCaptureStdErr(context.Background(), "sh", []string{"-c", "echo 'stdout error'; exit 1"}, nil, "")
require.Error(t, err)
assert.Contains(t, err.Error(), "stdout error")
})
t.Run("should fall back to exec error when both are empty", func(t *testing.T) {
err := runCommandAndCaptureStdErr(context.Background(), "sh", []string{"-c", "exit 1"}, nil, "")
require.Error(t, err)
assert.NotEmpty(t, err.Error())
assert.Contains(t, err.Error(), "exit status 1")
})
t.Run("should prefer stderr over stdout", func(t *testing.T) {
err := runCommandAndCaptureStdErr(context.Background(), "sh", []string{"-c", "echo 'stdout msg'; echo 'stderr msg' >&2; exit 1"}, nil, "")
require.Error(t, err)
assert.Contains(t, err.Error(), "stderr msg")
assert.NotContains(t, err.Error(), "stdout msg")
})
t.Run("should return error for non-existent command", func(t *testing.T) {
err := runCommandAndCaptureStdErr(context.Background(), "nonexistent-cmd-12345", nil, nil, "")
require.Error(t, err)
})
}
+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)
+3 -3
View File
@@ -14,7 +14,7 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/logs"
"github.com/gofrs/uuid"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
"github.com/segmentio/encoding/json"
)
@@ -91,7 +91,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)
@@ -812,7 +812,7 @@ func (service *Service) getEdgeJobTaskLogPath(edgeJobID string, taskID string) s
// GetTemporaryPath returns a temp folder
func (service *Service) GetTemporaryPath() (string, error) {
uid, err := uuid.NewV4()
uid, err := uuid.NewRandom()
if err != nil {
return "", err
}
+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()
@@ -223,3 +227,16 @@ func TestIsInConfigDir(t *testing.T) {
f(DirEntry{Name: "edgestacktest/edge-configs/standalone-edge-agent-async"}, "edgestacktest/edge-configs", true)
f(DirEntry{Name: "edgestacktest/edge-configs/abc.txt"}, "edgestacktest/edge-configs", true)
}
func TestShouldIncludeDir(t *testing.T) {
t.Parallel()
f := func(dirEntry DirEntry, deviceName, configPath string, expect bool) {
t.Helper()
actual := shouldIncludeDir(dirEntry, deviceName, configPath)
assert.Equal(t, expect, actual)
}
f(DirEntry{Name: "app/blue-app", IsFile: false}, "blue-app", "app", true)
f(DirEntry{Name: "app/blue-app/values.yaml", IsFile: true}, "blue-app", "app", true)
}
+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)
+90 -34
View File
@@ -16,7 +16,9 @@ import (
"github.com/portainer/portainer/api/logs"
"github.com/rs/zerolog/log"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing/filemode"
githttp "github.com/go-git/go-git/v5/plumbing/transport/http"
"github.com/pkg/errors"
"github.com/segmentio/encoding/json"
)
@@ -26,7 +28,7 @@ const (
visualStudioHostSuffix = ".visualstudio.com"
)
func isAzureUrl(s string) bool {
func IsAzureUrl(s string) bool {
return strings.Contains(s, azureDevOpsHost) ||
strings.Contains(s, visualStudioHostSuffix)
}
@@ -73,7 +75,11 @@ func newHttpClientForAzure(insecureSkipVerify bool) *http.Client {
return httpsCli
}
func (a *azureClient) download(ctx context.Context, destination string, opt cloneOption) error {
func (a *azureClient) Download(ctx context.Context, destination string, opt *git.CloneOptions) error {
if opt == nil {
return errors.New("options cannot be nil")
}
zipFilepath, err := a.downloadZipFromAzureDevOps(ctx, opt)
if err != nil {
return errors.Wrap(err, "failed to download a zip file from Azure DevOps")
@@ -91,13 +97,13 @@ func (a *azureClient) download(ctx context.Context, destination string, opt clon
return nil
}
func (a *azureClient) downloadZipFromAzureDevOps(ctx context.Context, opt cloneOption) (string, error) {
config, err := parseUrl(opt.repositoryUrl)
func (a *azureClient) downloadZipFromAzureDevOps(ctx context.Context, opt *git.CloneOptions) (string, error) {
config, err := parseUrl(opt.URL)
if err != nil {
return "", errors.WithMessage(err, "failed to parse url")
}
downloadUrl, err := a.buildDownloadUrl(config, opt.referenceName)
downloadUrl, err := a.buildDownloadUrl(config, string(opt.ReferenceName))
if err != nil {
return "", errors.WithMessage(err, "failed to build download url")
}
@@ -109,9 +115,18 @@ func (a *azureClient) downloadZipFromAzureDevOps(ctx context.Context, opt cloneO
defer logs.CloseAndLogErr(zipFile)
var basicAuth *githttp.BasicAuth
if opt.Auth != nil {
var ok bool
basicAuth, ok = opt.Auth.(*githttp.BasicAuth)
if !ok {
return "", errors.New("only basic auth is supported for azure")
}
}
req, err := http.NewRequestWithContext(ctx, "GET", downloadUrl, nil)
if opt.username != "" || opt.password != "" {
req.SetBasicAuth(opt.username, opt.password)
if basicAuth != nil {
req.SetBasicAuth(basicAuth.Username, basicAuth.Password)
} else if config.username != "" || config.password != "" {
req.SetBasicAuth(config.username, config.password)
}
@@ -120,7 +135,7 @@ func (a *azureClient) downloadZipFromAzureDevOps(ctx context.Context, opt cloneO
return "", errors.WithMessage(err, "failed to create a new HTTP request")
}
client := newHttpClientForAzure(opt.tlsSkipVerify)
client := newHttpClientForAzure(opt.InsecureSkipTLS)
defer client.CloseIdleConnections()
res, err := client.Do(req)
@@ -145,8 +160,12 @@ func (a *azureClient) downloadZipFromAzureDevOps(ctx context.Context, opt cloneO
return zipFile.Name(), nil
}
func (a *azureClient) latestCommitID(ctx context.Context, opt fetchOption) (string, error) {
rootItem, err := a.getRootItem(ctx, opt)
func (a *azureClient) LatestCommitID(ctx context.Context, repositoryUrl, referenceName string, opt *git.ListOptions) (string, error) {
if opt == nil {
return "", errors.New("options cannot be nil")
}
rootItem, err := a.getRootItem(ctx, repositoryUrl, referenceName, opt)
if err != nil {
return "", err
}
@@ -154,20 +173,29 @@ func (a *azureClient) latestCommitID(ctx context.Context, opt fetchOption) (stri
return rootItem.CommitId, nil
}
func (a *azureClient) getRootItem(ctx context.Context, opt fetchOption) (*azureItem, error) {
config, err := parseUrl(opt.repositoryUrl)
func (a *azureClient) getRootItem(ctx context.Context, repositoryUrl, referenceName string, opt *git.ListOptions) (*azureItem, error) {
config, err := parseUrl(repositoryUrl)
if err != nil {
return nil, errors.WithMessage(err, "failed to parse url")
}
rootItemUrl, err := a.buildRootItemUrl(config, opt.referenceName)
rootItemUrl, err := a.buildRootItemUrl(config, referenceName)
if err != nil {
return nil, errors.WithMessage(err, "failed to build azure root item url")
}
var basicAuth *githttp.BasicAuth
if opt.Auth != nil {
var ok bool
basicAuth, ok = opt.Auth.(*githttp.BasicAuth)
if !ok {
return nil, errors.New("only basic auth is supported for azure")
}
}
req, err := http.NewRequestWithContext(ctx, "GET", rootItemUrl, nil)
if opt.username != "" || opt.password != "" {
req.SetBasicAuth(opt.username, opt.password)
if basicAuth != nil {
req.SetBasicAuth(basicAuth.Username, basicAuth.Password)
} else if config.username != "" || config.password != "" {
req.SetBasicAuth(config.username, config.password)
}
@@ -176,7 +204,7 @@ func (a *azureClient) getRootItem(ctx context.Context, opt fetchOption) (*azureI
return nil, errors.WithMessage(err, "failed to create a new HTTP request")
}
client := newHttpClientForAzure(opt.tlsSkipVerify)
client := newHttpClientForAzure(opt.InsecureSkipTLS)
defer client.CloseIdleConnections()
resp, err := client.Do(req)
@@ -239,8 +267,10 @@ func parseSshUrl(rawUrl string) (*azureOptions, error) {
}, nil
}
const expectedAzureDevOpsHttpUrl = "https://Organisation@dev.azure.com/Organisation/Project/_git/Repository"
const expectedVisualStudioHttpUrl = "https://organisation.visualstudio.com/project/_git/repository"
const (
expectedAzureDevOpsHttpUrl = "https://Organisation@dev.azure.com/Organisation/Project/_git/Repository"
expectedVisualStudioHttpUrl = "https://organisation.visualstudio.com/project/_git/repository"
)
func parseHttpUrl(rawUrl string) (*azureOptions, error) {
u, err := url.Parse(rawUrl)
@@ -283,7 +313,6 @@ func (a *azureClient) buildDownloadUrl(config *azureOptions, referenceName strin
url.PathEscape(config.project),
url.PathEscape(config.repository))
u, err := url.Parse(rawUrl)
if err != nil {
return "", errors.Wrapf(err, "failed to parse download url path %s", rawUrl)
}
@@ -310,7 +339,6 @@ func (a *azureClient) buildRootItemUrl(config *azureOptions, referenceName strin
url.PathEscape(config.project),
url.PathEscape(config.repository))
u, err := url.Parse(rawUrl)
if err != nil {
return "", errors.Wrapf(err, "failed to parse root item url path %s", rawUrl)
}
@@ -335,7 +363,6 @@ func (a *azureClient) buildRefsUrl(config *azureOptions) (string, error) {
url.PathEscape(config.project),
url.PathEscape(config.repository))
u, err := url.Parse(rawUrl)
if err != nil {
return "", errors.Wrapf(err, "failed to parse list refs url path %s", rawUrl)
}
@@ -357,7 +384,6 @@ func (a *azureClient) buildTreeUrl(config *azureOptions, rootObjectHash string)
url.PathEscape(rootObjectHash),
)
u, err := url.Parse(rawUrl)
if err != nil {
return "", errors.Wrapf(err, "failed to parse list tree url path %s", rawUrl)
}
@@ -400,8 +426,12 @@ func getVersionType(name string) string {
return "commit"
}
func (a *azureClient) listRefs(ctx context.Context, opt baseOption) ([]string, error) {
config, err := parseUrl(opt.repositoryUrl)
func (a *azureClient) ListRefs(ctx context.Context, repositoryUrl string, opt *git.ListOptions) ([]string, error) {
if opt == nil {
return nil, errors.New("options cannot be nil")
}
config, err := parseUrl(repositoryUrl)
if err != nil {
return nil, errors.WithMessage(err, "failed to parse url")
}
@@ -411,9 +441,18 @@ func (a *azureClient) listRefs(ctx context.Context, opt baseOption) ([]string, e
return nil, errors.WithMessage(err, "failed to build list refs url")
}
var basicAuth *githttp.BasicAuth
if opt.Auth != nil {
var ok bool
basicAuth, ok = opt.Auth.(*githttp.BasicAuth)
if !ok {
return nil, errors.New("only basic auth is supported for azure")
}
}
req, err := http.NewRequestWithContext(ctx, "GET", listRefsUrl, nil)
if opt.username != "" || opt.password != "" {
req.SetBasicAuth(opt.username, opt.password)
if basicAuth != nil {
req.SetBasicAuth(basicAuth.Username, basicAuth.Password)
} else if config.username != "" || config.password != "" {
req.SetBasicAuth(config.username, config.password)
}
@@ -422,7 +461,7 @@ func (a *azureClient) listRefs(ctx context.Context, opt baseOption) ([]string, e
return nil, errors.WithMessage(err, "failed to create a new HTTP request")
}
client := newHttpClientForAzure(opt.tlsSkipVerify)
client := newHttpClientForAzure(opt.InsecureSkipTLS)
defer client.CloseIdleConnections()
resp, err := client.Do(req)
@@ -459,13 +498,21 @@ func (a *azureClient) listRefs(ctx context.Context, opt baseOption) ([]string, e
}
// listFiles list all filenames under the specific repository
func (a *azureClient) listFiles(ctx context.Context, opt fetchOption) ([]string, error) {
rootItem, err := a.getRootItem(ctx, opt)
func (a *azureClient) ListFiles(ctx context.Context, dirOnly bool, opt *git.CloneOptions) ([]string, error) {
if opt == nil {
return nil, errors.New("options cannot be nil")
}
listOptions := &git.ListOptions{
Auth: opt.Auth,
InsecureSkipTLS: opt.InsecureSkipTLS,
}
rootItem, err := a.getRootItem(ctx, opt.URL, string(opt.ReferenceName), listOptions)
if err != nil {
return nil, err
}
config, err := parseUrl(opt.repositoryUrl)
config, err := parseUrl(opt.URL)
if err != nil {
return nil, errors.WithMessage(err, "failed to parse url")
}
@@ -475,9 +522,18 @@ func (a *azureClient) listFiles(ctx context.Context, opt fetchOption) ([]string,
return nil, errors.WithMessage(err, "failed to build list tree url")
}
var basicAuth *githttp.BasicAuth
if opt.Auth != nil {
var ok bool
basicAuth, ok = opt.Auth.(*githttp.BasicAuth)
if !ok {
return nil, errors.New("only basic auth is supported for azure")
}
}
req, err := http.NewRequestWithContext(ctx, "GET", listTreeUrl, nil)
if opt.username != "" || opt.password != "" {
req.SetBasicAuth(opt.username, opt.password)
if basicAuth != nil {
req.SetBasicAuth(basicAuth.Username, basicAuth.Password)
} else if config.username != "" || config.password != "" {
req.SetBasicAuth(config.username, config.password)
}
@@ -486,7 +542,7 @@ func (a *azureClient) listFiles(ctx context.Context, opt fetchOption) ([]string,
return nil, errors.WithMessage(err, "failed to create a new HTTP request")
}
client := newHttpClientForAzure(opt.tlsSkipVerify)
client := newHttpClientForAzure(opt.InsecureSkipTLS)
defer client.CloseIdleConnections()
resp, err := client.Do(req)
@@ -518,7 +574,7 @@ func (a *azureClient) listFiles(ctx context.Context, opt fetchOption) ([]string,
for _, treeEntry := range tree.TreeEntries {
mode, _ := filemode.New(treeEntry.Mode)
isDir := filemode.Dir == mode
if opt.dirOnly == isDir {
if dirOnly == isDir {
allPaths = append(allPaths, treeEntry.RelativePath)
}
}
+73 -74
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,53 +60,55 @@ 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,
"",
"",
gittypes.GitCredentialAuthType_Basic,
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",
"",
pat,
gittypes.GitCredentialAuthType_Basic,
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",
"",
pat,
gittypes.GitCredentialAuthType_Basic,
false,
)
require.NoError(t, err)
@@ -114,17 +116,18 @@ 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,
gittypes.GitCredentialAuthType_Basic,
false,
false,
)
@@ -133,52 +136,59 @@ 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, gittypes.GitCredentialAuthType_Basic, false, false)
_, _ = service.ListRefs(t.Context(), privateAzureRepoURL, username, accessToken, false, false)
}()
_, err := service.ListRefs(privateAzureRepoURL, username, accessToken, gittypes.GitCredentialAuthType_Basic, false, false)
_, err := service.ListRefs(t.Context(), privateAzureRepoURL, username, accessToken, false, false)
require.NoError(t, err)
time.Sleep(2 * time.Second)
}
func TestService_ListFiles_Azure(t *testing.T) {
t.Parallel()
ensureIntegrationTest(t)
type args struct {
repositoryUrl string
referenceName string
username string
password string
extensions []string
}
type expectResult struct {
shouldFail bool
err error
matchedCount int
}
service := newService(context.TODO(), 0, 0)
service := newService(t.Context(), 0, 0)
accessToken := getRequiredValue(t, "AZURE_DEVOPS_PAT")
username := getRequiredValue(t, "AZURE_DEVOPS_USERNAME")
tests := []struct {
name string
args fetchOption
extensions []string
expect expectResult
name string
args args
expect expectResult
}{
{
name: "list tree with real repository and head ref but incorrect credential",
args: fetchOption{
baseOption: baseOption{
repositoryUrl: privateAzureRepoURL,
username: "test-username",
password: "test-token",
},
args: args{
repositoryUrl: privateAzureRepoURL,
referenceName: "refs/heads/main",
username: "test-username",
password: "test-token",
extensions: []string{},
},
extensions: []string{},
expect: expectResult{
shouldFail: true,
err: gittypes.ErrAuthenticationFailure,
@@ -186,15 +196,13 @@ func TestService_ListFiles_Azure(t *testing.T) {
},
{
name: "list tree with real repository and head ref but no credential",
args: fetchOption{
baseOption: baseOption{
repositoryUrl: privateAzureRepoURL,
username: "",
password: "",
},
args: args{
repositoryUrl: privateAzureRepoURL,
referenceName: "refs/heads/main",
username: "",
password: "",
extensions: []string{},
},
extensions: []string{},
expect: expectResult{
shouldFail: true,
err: gittypes.ErrAuthenticationFailure,
@@ -202,15 +210,13 @@ func TestService_ListFiles_Azure(t *testing.T) {
},
{
name: "list tree with real repository and head ref",
args: fetchOption{
baseOption: baseOption{
repositoryUrl: privateAzureRepoURL,
username: username,
password: accessToken,
},
args: args{
repositoryUrl: privateAzureRepoURL,
referenceName: "refs/heads/main",
username: username,
password: accessToken,
extensions: []string{},
},
extensions: []string{},
expect: expectResult{
err: nil,
matchedCount: 19,
@@ -218,15 +224,13 @@ func TestService_ListFiles_Azure(t *testing.T) {
},
{
name: "list tree with real repository and head ref and existing file extension",
args: fetchOption{
baseOption: baseOption{
repositoryUrl: privateAzureRepoURL,
username: username,
password: accessToken,
},
args: args{
repositoryUrl: privateAzureRepoURL,
referenceName: "refs/heads/main",
username: username,
password: accessToken,
extensions: []string{"yml"},
},
extensions: []string{"yml"},
expect: expectResult{
err: nil,
matchedCount: 2,
@@ -234,15 +238,13 @@ func TestService_ListFiles_Azure(t *testing.T) {
},
{
name: "list tree with real repository and head ref and non-existing file extension",
args: fetchOption{
baseOption: baseOption{
repositoryUrl: privateAzureRepoURL,
username: username,
password: accessToken,
},
args: args{
repositoryUrl: privateAzureRepoURL,
referenceName: "refs/heads/main",
username: username,
password: accessToken,
extensions: []string{"hcl"},
},
extensions: []string{"hcl"},
expect: expectResult{
err: nil,
matchedCount: 2,
@@ -250,30 +252,26 @@ func TestService_ListFiles_Azure(t *testing.T) {
},
{
name: "list tree with real repository but non-existing ref",
args: fetchOption{
baseOption: baseOption{
repositoryUrl: privateAzureRepoURL,
username: username,
password: accessToken,
},
args: args{
repositoryUrl: privateAzureRepoURL,
referenceName: "refs/fake/feature",
username: username,
password: accessToken,
extensions: []string{},
},
extensions: []string{},
expect: expectResult{
shouldFail: true,
},
},
{
name: "list tree with fake repository ",
args: fetchOption{
baseOption: baseOption{
repositoryUrl: privateAzureRepoURL + "fake",
username: username,
password: accessToken,
},
args: args{
repositoryUrl: privateAzureRepoURL + "fake",
referenceName: "refs/fake/feature",
username: username,
password: accessToken,
extensions: []string{},
},
extensions: []string{},
expect: expectResult{
shouldFail: true,
err: gittypes.ErrIncorrectRepositoryURL,
@@ -284,14 +282,14 @@ 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,
tt.args.password,
gittypes.GitCredentialAuthType_Basic,
false,
false,
tt.extensions,
tt.args.extensions,
false,
)
@@ -311,19 +309,20 @@ 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,
accessToken,
gittypes.GitCredentialAuthType_Basic,
false,
false,
[]string{},
@@ -332,11 +331,11 @@ func TestService_ListFiles_Azure_Concurrently(t *testing.T) {
}()
_, err := service.ListFiles(
t.Context(),
privateAzureRepoURL,
"refs/heads/main",
username,
accessToken,
gittypes.GitCredentialAuthType_Basic,
false,
false,
[]string{},
+105 -76
View File
@@ -7,6 +7,9 @@ import (
"net/url"
"testing"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
githttp "github.com/go-git/go-git/v5/plumbing/transport/http"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/pkg/fips"
@@ -15,6 +18,7 @@ import (
)
func Test_buildDownloadUrl(t *testing.T) {
t.Parallel()
a := NewAzureClient()
u, err := a.buildDownloadUrl(&azureOptions{
organisation: "organisation",
@@ -36,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",
@@ -53,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",
@@ -70,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",
@@ -87,6 +94,7 @@ func Test_buildTreeUrl(t *testing.T) {
}
func Test_parseAzureUrl(t *testing.T) {
t.Parallel()
type args struct {
url string
}
@@ -202,6 +210,7 @@ func Test_parseAzureUrl(t *testing.T) {
}
func Test_isAzureUrl(t *testing.T) {
t.Parallel()
type args struct {
s string
}
@@ -234,16 +243,19 @@ func Test_isAzureUrl(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, isAzureUrl(tt.args.s))
assert.Equal(t, tt.want, IsAzureUrl(tt.args.s))
})
}
}
func Test_azureDownloader_downloadZipFromAzureDevOps(t *testing.T) {
t.Parallel()
fips.InitFIPS(false)
type args struct {
options baseOption
repositoryUrl string
username string
password string
}
type basicAuth struct {
username, password string
@@ -256,9 +268,7 @@ func Test_azureDownloader_downloadZipFromAzureDevOps(t *testing.T) {
{
name: "username, password embedded",
args: args{
options: baseOption{
repositoryUrl: "https://username:password@dev.azure.com/Organisation/Project/_git/Repository",
},
repositoryUrl: "https://username:password@dev.azure.com/Organisation/Project/_git/Repository",
},
want: &basicAuth{
username: "username",
@@ -268,11 +278,9 @@ func Test_azureDownloader_downloadZipFromAzureDevOps(t *testing.T) {
{
name: "username, password embedded, clone options take precedence",
args: args{
options: baseOption{
repositoryUrl: "https://username:password@dev.azure.com/Organisation/Project/_git/Repository",
username: "u",
password: "p",
},
repositoryUrl: "https://username:password@dev.azure.com/Organisation/Project/_git/Repository",
username: "u",
password: "p",
},
want: &basicAuth{
username: "u",
@@ -282,9 +290,7 @@ func Test_azureDownloader_downloadZipFromAzureDevOps(t *testing.T) {
{
name: "no credentials",
args: args{
options: baseOption{
repositoryUrl: "https://dev.azure.com/Organisation/Project/_git/Repository",
},
repositoryUrl: "https://dev.azure.com/Organisation/Project/_git/Repository",
},
},
}
@@ -303,12 +309,16 @@ func Test_azureDownloader_downloadZipFromAzureDevOps(t *testing.T) {
baseUrl: server.URL,
}
option := cloneOption{
fetchOption: fetchOption{
baseOption: tt.args.options,
},
option := &git.CloneOptions{
URL: tt.args.repositoryUrl,
}
_, err := a.downloadZipFromAzureDevOps(context.Background(), option)
if tt.args.username != "" || tt.args.password != "" {
option.Auth = &githttp.BasicAuth{
Username: tt.args.username,
Password: tt.args.password,
}
}
_, err := a.downloadZipFromAzureDevOps(t.Context(), option)
require.Error(t, err)
assert.Equal(t, tt.want, zipRequestAuth)
})
@@ -316,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) {
@@ -340,18 +351,21 @@ func Test_azureDownloader_latestCommitID(t *testing.T) {
a := &azureClient{baseUrl: server.URL}
type args struct {
repositoryUrl string
referenceName string
}
tests := []struct {
name string
args fetchOption
args args
want string
wantErr bool
}{
{
name: "should be able to parse response",
args: fetchOption{
baseOption: baseOption{
repositoryUrl: "https://dev.azure.com/Organisation/Project/_git/Repository",
},
args: args{
repositoryUrl: "https://dev.azure.com/Organisation/Project/_git/Repository",
referenceName: "",
},
want: "27104ad7549d9e66685e115a497533f18024be9c",
@@ -361,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)
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
@@ -375,23 +389,25 @@ type testRepoManager struct {
called bool
}
func (t *testRepoManager) download(_ context.Context, _ string, _ cloneOption) error {
func (t *testRepoManager) Download(_ context.Context, _ string, _ *git.CloneOptions) error {
t.called = true
return nil
}
func (t *testRepoManager) latestCommitID(_ context.Context, _ fetchOption) (string, error) {
func (t *testRepoManager) LatestCommitID(_ context.Context, _, _ string, _ *git.ListOptions) (string, error) {
return "", nil
}
func (t *testRepoManager) listRefs(_ context.Context, _ baseOption) ([]string, error) {
func (t *testRepoManager) ListRefs(_ context.Context, _ string, _ *git.ListOptions) ([]string, error) {
return nil, nil
}
func (t *testRepoManager) listFiles(_ context.Context, _ fetchOption) ([]string, error) {
func (t *testRepoManager) ListFiles(_ context.Context, _ bool, _ *git.CloneOptions) ([]string, error) {
return nil, nil
}
func Test_cloneRepository_azure(t *testing.T) {
t.Parallel()
tests := []struct {
name string
url string
@@ -420,15 +436,7 @@ func Test_cloneRepository_azure(t *testing.T) {
git := &testRepoManager{}
s := &Service{azure: azure, git: git}
err := s.cloneRepository("", cloneOption{
fetchOption: fetchOption{
baseOption: baseOption{
repositoryUrl: tt.url,
},
},
depth: 1,
})
err := s.CloneRepository(t.Context(), "", tt.url, "", "", "", false)
require.NoError(t, err)
// if azure API is called, git isn't and vice versa
@@ -439,10 +447,17 @@ func Test_cloneRepository_azure(t *testing.T) {
}
func Test_listRefs_azure(t *testing.T) {
t.Parallel()
ensureIntegrationTest(t)
client := NewAzureClient()
type args struct {
repositoryUrl string
username string
password string
}
type expectResult struct {
err error
refsCount int
@@ -453,12 +468,12 @@ func Test_listRefs_azure(t *testing.T) {
tests := []struct {
name string
args baseOption
args args
expect expectResult
}{
{
name: "list refs of a real repository",
args: baseOption{
args: args{
repositoryUrl: privateAzureRepoURL,
username: username,
password: accessToken,
@@ -470,7 +485,7 @@ func Test_listRefs_azure(t *testing.T) {
},
{
name: "list refs of a real repository with incorrect credential",
args: baseOption{
args: args{
repositoryUrl: privateAzureRepoURL,
username: "test-username",
password: "test-token",
@@ -481,7 +496,7 @@ func Test_listRefs_azure(t *testing.T) {
},
{
name: "list refs of a real repository without providing credential",
args: baseOption{
args: args{
repositoryUrl: privateAzureRepoURL,
username: "",
password: "",
@@ -492,7 +507,7 @@ func Test_listRefs_azure(t *testing.T) {
},
{
name: "list refs of a fake repository",
args: baseOption{
args: args{
repositoryUrl: privateAzureRepoURL + "fake",
username: username,
password: accessToken,
@@ -505,7 +520,14 @@ func Test_listRefs_azure(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
refs, err := client.listRefs(context.TODO(), tt.args)
option := &git.ListOptions{}
if tt.args.username != "" || tt.args.password != "" {
option.Auth = &githttp.BasicAuth{
Username: tt.args.username,
Password: tt.args.password,
}
}
refs, err := client.ListRefs(t.Context(), tt.args.repositoryUrl, option)
if tt.expect.err == nil {
require.NoError(t, err)
if tt.expect.refsCount > 0 {
@@ -517,14 +539,21 @@ func Test_listRefs_azure(t *testing.T) {
}
})
}
}
func Test_listFiles_azure(t *testing.T) {
t.Parallel()
ensureIntegrationTest(t)
client := NewAzureClient()
type args struct {
repositoryUrl string
referenceName string
username string
password string
}
type expectResult struct {
shouldFail bool
err error
@@ -535,18 +564,16 @@ func Test_listFiles_azure(t *testing.T) {
username := getRequiredValue(t, "AZURE_DEVOPS_USERNAME")
tests := []struct {
name string
args fetchOption
args args
expect expectResult
}{
{
name: "list tree with real repository and head ref but incorrect credential",
args: fetchOption{
baseOption: baseOption{
repositoryUrl: privateAzureRepoURL,
username: "test-username",
password: "test-token",
},
args: args{
repositoryUrl: privateAzureRepoURL,
referenceName: "refs/heads/main",
username: "test-username",
password: "test-token",
},
expect: expectResult{
shouldFail: true,
@@ -555,13 +582,11 @@ func Test_listFiles_azure(t *testing.T) {
},
{
name: "list tree with real repository and head ref but no credential",
args: fetchOption{
baseOption: baseOption{
repositoryUrl: privateAzureRepoURL,
username: "",
password: "",
},
args: args{
repositoryUrl: privateAzureRepoURL,
referenceName: "refs/heads/main",
username: "",
password: "",
},
expect: expectResult{
shouldFail: true,
@@ -570,13 +595,11 @@ func Test_listFiles_azure(t *testing.T) {
},
{
name: "list tree with real repository and head ref",
args: fetchOption{
baseOption: baseOption{
repositoryUrl: privateAzureRepoURL,
username: username,
password: accessToken,
},
args: args{
repositoryUrl: privateAzureRepoURL,
referenceName: "refs/heads/main",
username: username,
password: accessToken,
},
expect: expectResult{
err: nil,
@@ -585,13 +608,11 @@ func Test_listFiles_azure(t *testing.T) {
},
{
name: "list tree with real repository but non-existing ref",
args: fetchOption{
baseOption: baseOption{
repositoryUrl: privateAzureRepoURL,
username: username,
password: accessToken,
},
args: args{
repositoryUrl: privateAzureRepoURL,
referenceName: "refs/fake/feature",
username: username,
password: accessToken,
},
expect: expectResult{
shouldFail: true,
@@ -599,13 +620,11 @@ func Test_listFiles_azure(t *testing.T) {
},
{
name: "list tree with fake repository ",
args: fetchOption{
baseOption: baseOption{
repositoryUrl: privateAzureRepoURL + "fake",
username: username,
password: accessToken,
},
args: args{
repositoryUrl: privateAzureRepoURL + "fake",
referenceName: "refs/fake/feature",
username: username,
password: accessToken,
},
expect: expectResult{
shouldFail: true,
@@ -616,7 +635,17 @@ func Test_listFiles_azure(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
paths, err := client.listFiles(context.TODO(), tt.args)
option := &git.CloneOptions{
URL: tt.args.repositoryUrl,
ReferenceName: plumbing.ReferenceName(tt.args.referenceName),
}
if tt.args.username != "" || tt.args.password != "" {
option.Auth = &githttp.BasicAuth{
Username: tt.args.username,
Password: tt.args.password,
}
}
paths, err := client.ListFiles(t.Context(), false, option)
if tt.expect.shouldFail {
require.Error(t, err)
if tt.expect.err != nil {
+4 -3
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"
@@ -19,12 +21,11 @@ type CloneOptions struct {
ReferenceName string
Username string
Password string
AuthType gittypes.GitCredentialAuthType
// TLSSkipVerify skips SSL verification when cloning the Git repository
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() {
@@ -44,12 +45,12 @@ func CloneWithBackup(gitService portainer.GitService, fileService portainer.File
cleanUp = true
if err := gitService.CloneRepository(
ctx,
options.ProjectPath,
options.URL,
options.ReferenceName,
options.Username,
options.Password,
options.AuthType,
options.TLSSkipVerify,
); err != nil {
cleanUp = false
+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
}
+47 -96
View File
@@ -3,23 +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"
"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"
"github.com/go-git/go-git/v5/plumbing/transport"
githttp "github.com/go-git/go-git/v5/plumbing/transport/http"
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
}
@@ -30,58 +46,46 @@ func NewGitClient(preserveGitDir bool) *gitClient {
}
}
func (c *gitClient) download(ctx context.Context, dst string, opt cloneOption) error {
gitOptions := git.CloneOptions{
URL: opt.repositoryUrl,
Depth: opt.depth,
InsecureSkipTLS: opt.tlsSkipVerify,
Auth: getAuth(opt.authType, opt.username, opt.password),
Tags: git.NoTags,
}
if opt.referenceName != "" {
gitOptions.ReferenceName = plumbing.ReferenceName(opt.referenceName)
}
_, err := git.PlainCloneContext(ctx, dst, false, &gitOptions)
func (c *gitClient) Download(ctx context.Context, dst string, opt *git.CloneOptions) error {
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
}
func (c *gitClient) latestCommitID(ctx context.Context, opt fetchOption) (string, error) {
func (c *gitClient) LatestCommitID(ctx context.Context, repositoryUrl, referenceName string, opt *git.ListOptions) (string, error) {
remote := git.NewRemote(memory.NewStorage(), &config.RemoteConfig{
Name: "origin",
URLs: []string{opt.repositoryUrl},
URLs: []string{repositoryUrl},
})
listOptions := &git.ListOptions{
Auth: getAuth(opt.authType, opt.username, opt.password),
InsecureSkipTLS: opt.tlsSkipVerify,
}
refs, err := remote.List(listOptions)
refs, err := remote.List(opt)
if err != nil {
if err.Error() == "authentication required" {
return "", gittypes.ErrAuthenticationFailure
}
return "", errors.Wrap(err, "failed to list repository refs")
}
referenceName := opt.referenceName
if referenceName == "" {
for _, ref := range refs {
if strings.EqualFold(ref.Name().String(), "HEAD") {
@@ -96,60 +100,16 @@ func (c *gitClient) latestCommitID(ctx context.Context, opt fetchOption) (string
}
}
return "", errors.Errorf("could not find ref %q in the repository", opt.referenceName)
return "", errors.Errorf("could not find ref %q in the repository", referenceName)
}
func getAuth(authType gittypes.GitCredentialAuthType, username, password string) transport.AuthMethod {
if password == "" {
return nil
}
switch authType {
case gittypes.GitCredentialAuthType_Basic:
return getBasicAuth(username, password)
case gittypes.GitCredentialAuthType_Token:
return getTokenAuth(password)
default:
log.Warn().Msg("unknown git credentials authorization type, defaulting to None")
return nil
}
}
func getBasicAuth(username, password string) *githttp.BasicAuth {
if password != "" {
if username == "" {
username = "token"
}
return &githttp.BasicAuth{
Username: username,
Password: password,
}
}
return nil
}
func getTokenAuth(token string) *githttp.TokenAuth {
if token != "" {
return &githttp.TokenAuth{
Token: token,
}
}
return nil
}
func (c *gitClient) listRefs(ctx context.Context, opt baseOption) ([]string, error) {
func (c *gitClient) ListRefs(ctx context.Context, repositoryUrl string, opt *git.ListOptions) ([]string, error) {
rem := git.NewRemote(memory.NewStorage(), &config.RemoteConfig{
Name: "origin",
URLs: []string{opt.repositoryUrl},
URLs: []string{repositoryUrl},
})
listOptions := &git.ListOptions{
Auth: getAuth(opt.authType, opt.username, opt.password),
InsecureSkipTLS: opt.tlsSkipVerify,
}
refs, err := rem.List(listOptions)
refs, err := rem.List(opt)
if err != nil {
return nil, checkGitError(err)
}
@@ -159,6 +119,7 @@ func (c *gitClient) listRefs(ctx context.Context, opt baseOption) ([]string, err
if ref.Name().String() == "HEAD" {
continue
}
ret = append(ret, ref.Name().String())
}
@@ -166,19 +127,8 @@ func (c *gitClient) listRefs(ctx context.Context, opt baseOption) ([]string, err
}
// listFiles list all filenames under the specific repository
func (c *gitClient) listFiles(ctx context.Context, opt fetchOption) ([]string, error) {
cloneOption := &git.CloneOptions{
URL: opt.repositoryUrl,
NoCheckout: true,
Depth: 1,
SingleBranch: true,
ReferenceName: plumbing.ReferenceName(opt.referenceName),
Auth: getAuth(opt.authType, opt.username, opt.password),
InsecureSkipTLS: opt.tlsSkipVerify,
Tags: git.NoTags,
}
repo, err := git.Clone(memory.NewStorage(), nil, cloneOption)
func (c *gitClient) ListFiles(ctx context.Context, dirOnly bool, opt *git.CloneOptions) ([]string, error) {
repo, err := git.Clone(memory.NewStorage(), nil, opt)
if err != nil {
return nil, checkGitError(err)
}
@@ -210,7 +160,7 @@ func (c *gitClient) listFiles(ctx context.Context, opt fetchOption) ([]string, e
}
isDir := entry.Mode == filemode.Dir
if opt.dirOnly == isDir {
if dirOnly == isDir {
allPaths = append(allPaths, name)
}
}
@@ -220,10 +170,11 @@ func (c *gitClient) listFiles(ctx context.Context, opt fetchOption) ([]string, e
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
}
+95 -104
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,42 +18,44 @@ 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",
username,
accessToken,
gittypes.GitCredentialAuthType_Basic,
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,
accessToken,
gittypes.GitCredentialAuthType_Basic,
false,
)
require.NoError(t, err)
@@ -62,65 +63,73 @@ 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, gittypes.GitCredentialAuthType_Basic, 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, gittypes.GitCredentialAuthType_Basic, false, false)
_, _ = service.ListRefs(t.Context(), repositoryUrl, username, accessToken, false, false)
}()
_, err := service.ListRefs(repositoryUrl, username, accessToken, gittypes.GitCredentialAuthType_Basic, false, false)
_, err := service.ListRefs(t.Context(), repositoryUrl, username, accessToken, false, false)
require.NoError(t, err)
time.Sleep(2 * time.Second)
}
func TestService_ListFiles_GitHub(t *testing.T) {
t.Parallel()
ensureIntegrationTest(t)
type args struct {
repositoryUrl string
referenceName string
username string
password string
extensions []string
}
type expectResult struct {
shouldFail bool
err error
matchedCount int
}
service := newService(context.TODO(), 0, 0)
service := newService(t.Context(), 0, 0)
accessToken := getRequiredValue(t, "GITHUB_PAT")
username := getRequiredValue(t, "GITHUB_USERNAME")
tests := []struct {
name string
args fetchOption
extensions []string
expect expectResult
name string
args args
expect expectResult
}{
{
name: "list tree with real repository and head ref but incorrect credential",
args: fetchOption{
baseOption: baseOption{
repositoryUrl: privateGitRepoURL,
username: "test-username",
password: "test-token",
},
args: args{
repositoryUrl: privateGitRepoURL,
referenceName: "refs/heads/main",
username: "test-username",
password: "test-token",
extensions: []string{},
},
extensions: []string{},
expect: expectResult{
shouldFail: true,
err: gittypes.ErrAuthenticationFailure,
@@ -128,15 +137,13 @@ func TestService_ListFiles_GitHub(t *testing.T) {
},
{
name: "list tree with real repository and head ref but no credential",
args: fetchOption{
baseOption: baseOption{
repositoryUrl: privateGitRepoURL + "fake",
username: "",
password: "",
},
args: args{
repositoryUrl: privateGitRepoURL + "fake",
referenceName: "refs/heads/main",
username: "",
password: "",
extensions: []string{},
},
extensions: []string{},
expect: expectResult{
shouldFail: true,
err: gittypes.ErrAuthenticationFailure,
@@ -144,15 +151,13 @@ func TestService_ListFiles_GitHub(t *testing.T) {
},
{
name: "list tree with real repository and head ref",
args: fetchOption{
baseOption: baseOption{
repositoryUrl: privateGitRepoURL,
username: username,
password: accessToken,
},
args: args{
repositoryUrl: privateGitRepoURL,
referenceName: "refs/heads/main",
username: username,
password: accessToken,
extensions: []string{},
},
extensions: []string{},
expect: expectResult{
err: nil,
matchedCount: 15,
@@ -160,15 +165,13 @@ func TestService_ListFiles_GitHub(t *testing.T) {
},
{
name: "list tree with real repository and head ref and existing file extension",
args: fetchOption{
baseOption: baseOption{
repositoryUrl: privateGitRepoURL,
username: username,
password: accessToken,
},
args: args{
repositoryUrl: privateGitRepoURL,
referenceName: "refs/heads/main",
username: username,
password: accessToken,
extensions: []string{"yml"},
},
extensions: []string{"yml"},
expect: expectResult{
err: nil,
matchedCount: 2,
@@ -176,15 +179,13 @@ func TestService_ListFiles_GitHub(t *testing.T) {
},
{
name: "list tree with real repository and head ref and non-existing file extension",
args: fetchOption{
baseOption: baseOption{
repositoryUrl: privateGitRepoURL,
username: username,
password: accessToken,
},
args: args{
repositoryUrl: privateGitRepoURL,
referenceName: "refs/heads/main",
username: username,
password: accessToken,
extensions: []string{"hcl"},
},
extensions: []string{"hcl"},
expect: expectResult{
err: nil,
matchedCount: 2,
@@ -192,30 +193,26 @@ func TestService_ListFiles_GitHub(t *testing.T) {
},
{
name: "list tree with real repository but non-existing ref",
args: fetchOption{
baseOption: baseOption{
repositoryUrl: privateGitRepoURL,
username: username,
password: accessToken,
},
args: args{
repositoryUrl: privateGitRepoURL,
referenceName: "refs/fake/feature",
username: username,
password: accessToken,
extensions: []string{},
},
extensions: []string{},
expect: expectResult{
shouldFail: true,
},
},
{
name: "list tree with fake repository ",
args: fetchOption{
baseOption: baseOption{
repositoryUrl: privateGitRepoURL + "fake",
username: username,
password: accessToken,
},
args: args{
repositoryUrl: privateGitRepoURL + "fake",
referenceName: "refs/fake/feature",
username: username,
password: accessToken,
extensions: []string{},
},
extensions: []string{},
expect: expectResult{
shouldFail: true,
err: gittypes.ErrIncorrectRepositoryURL,
@@ -226,14 +223,14 @@ 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,
tt.args.password,
gittypes.GitCredentialAuthType_Basic,
false,
false,
tt.extensions,
tt.args.extensions,
false,
)
if tt.expect.shouldFail {
@@ -252,20 +249,21 @@ 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,
accessToken,
gittypes.GitCredentialAuthType_Basic,
false,
false,
[]string{},
@@ -274,11 +272,11 @@ func TestService_ListFiles_Github_Concurrently(t *testing.T) {
}()
_, err := service.ListFiles(
t.Context(),
repositoryUrl,
"refs/heads/main",
username,
accessToken,
gittypes.GitCredentialAuthType_Basic,
false,
false,
[]string{},
@@ -290,22 +288,23 @@ 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, gittypes.GitCredentialAuthType_Basic, 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,
accessToken,
gittypes.GitCredentialAuthType_Basic,
false,
false,
[]string{},
@@ -322,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
@@ -329,16 +329,16 @@ 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, gittypes.GitCredentialAuthType_Basic, 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,
accessToken,
gittypes.GitCredentialAuthType_Basic,
false,
false,
[]string{},
@@ -354,56 +354,45 @@ 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, gittypes.GitCredentialAuthType_Basic, 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", gittypes.GitCredentialAuthType_Basic, 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, gittypes.GitCredentialAuthType_Basic, 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,
accessToken,
gittypes.GitCredentialAuthType_Basic,
false,
false,
[]string{},
@@ -414,11 +403,11 @@ 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,
accessToken,
gittypes.GitCredentialAuthType_Basic,
false,
false,
[]string{},
@@ -428,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", gittypes.GitCredentialAuthType_Basic, 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", gittypes.GitCredentialAuthType_Basic, 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
@@ -440,18 +429,19 @@ 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,
accessToken,
gittypes.GitCredentialAuthType_Basic,
false,
false,
[]string{},
@@ -462,11 +452,11 @@ 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,
"fake-token",
gittypes.GitCredentialAuthType_Basic,
false,
true,
[]string{},
@@ -477,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)
@@ -490,12 +481,12 @@ 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",
username,
accessToken,
gittypes.GitCredentialAuthType_Token,
false,
)
+222 -71
View File
@@ -1,16 +1,20 @@
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"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -18,9 +22,9 @@ 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, 0755)
file, err := os.OpenFile("./testdata/test-clone-git-repo.tar.gz", os.O_RDONLY, 0o755)
if err != nil {
t.Fatal(errors.Wrap(err, "failed to open an archive"))
}
@@ -32,88 +36,107 @@ 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, "", "", gittypes.GitCredentialAuthType_Basic, 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, "", "", gittypes.GitCredentialAuthType_Basic, false)
err := service.CloneRepository(t.Context(), dir, repositoryURL, referenceName, "", "", false)
require.NoError(t, err)
assert.NoDirExists(t, filepath.Join(dir, ".git"))
}
func Test_cloneRepository(t *testing.T) {
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, cloneOption{
fetchOption: fetchOption{
baseOption: baseOption{
repositoryUrl: repositoryURL,
},
referenceName: referenceName,
},
depth: 10,
})
require.NoError(t, err)
assert.Equal(t, 4, getCommitHistoryLength(t, dir), "cloned repo has incorrect depth")
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, "", "", gittypes.GitCredentialAuthType_Basic, 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, "", "", gittypes.GitCredentialAuthType_Basic, 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,
"",
"",
gittypes.GitCredentialAuthType_Basic,
false,
false,
[]string{".yml"},
@@ -146,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")
@@ -154,6 +284,12 @@ func Test_listRefsPrivateRepository(t *testing.T) {
client := NewGitClient(false)
type args struct {
repositoryUrl string
username string
password string
}
type expectResult struct {
err error
refsCount int
@@ -161,12 +297,12 @@ func Test_listRefsPrivateRepository(t *testing.T) {
tests := []struct {
name string
args baseOption
args args
expect expectResult
}{
{
name: "list refs of a real private repository",
args: baseOption{
args: args{
repositoryUrl: privateGitRepoURL,
username: username,
password: accessToken,
@@ -178,7 +314,7 @@ func Test_listRefsPrivateRepository(t *testing.T) {
},
{
name: "list refs of a real private repository with incorrect credential",
args: baseOption{
args: args{
repositoryUrl: privateGitRepoURL,
username: "test-username",
password: "test-token",
@@ -189,7 +325,7 @@ func Test_listRefsPrivateRepository(t *testing.T) {
},
{
name: "list refs of a fake repository without providing credential",
args: baseOption{
args: args{
repositoryUrl: privateGitRepoURL + "fake",
username: "",
password: "",
@@ -200,7 +336,7 @@ func Test_listRefsPrivateRepository(t *testing.T) {
},
{
name: "list refs of a fake repository",
args: baseOption{
args: args{
repositoryUrl: privateGitRepoURL + "fake",
username: username,
password: accessToken,
@@ -213,7 +349,14 @@ func Test_listRefsPrivateRepository(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
refs, err := client.listRefs(context.TODO(), tt.args)
option := &git.ListOptions{}
if tt.args.username != "" || tt.args.password != "" {
option.Auth = &githttp.BasicAuth{
Username: tt.args.username,
Password: tt.args.password,
}
}
refs, err := client.ListRefs(t.Context(), tt.args.repositoryUrl, option)
if tt.expect.err == nil {
require.NoError(t, err)
if tt.expect.refsCount > 0 {
@@ -228,10 +371,18 @@ func Test_listRefsPrivateRepository(t *testing.T) {
}
func Test_listFilesPrivateRepository(t *testing.T) {
t.Parallel()
ensureIntegrationTest(t)
client := NewGitClient(false)
type args struct {
repositoryUrl string
referenceName string
username string
password string
}
type expectResult struct {
shouldFail bool
err error
@@ -243,18 +394,16 @@ func Test_listFilesPrivateRepository(t *testing.T) {
tests := []struct {
name string
args fetchOption
args args
expect expectResult
}{
{
name: "list tree with real repository and head ref but incorrect credential",
args: fetchOption{
baseOption: baseOption{
repositoryUrl: privateGitRepoURL,
username: "test-username",
password: "test-token",
},
args: args{
repositoryUrl: privateGitRepoURL,
referenceName: "refs/heads/main",
username: "test-username",
password: "test-token",
},
expect: expectResult{
shouldFail: true,
@@ -263,13 +412,11 @@ func Test_listFilesPrivateRepository(t *testing.T) {
},
{
name: "list tree with real repository and head ref but no credential",
args: fetchOption{
baseOption: baseOption{
repositoryUrl: privateGitRepoURL,
username: "",
password: "",
},
args: args{
repositoryUrl: privateGitRepoURL,
referenceName: "refs/heads/main",
username: "",
password: "",
},
expect: expectResult{
shouldFail: true,
@@ -278,13 +425,11 @@ func Test_listFilesPrivateRepository(t *testing.T) {
},
{
name: "list tree with real repository and head ref",
args: fetchOption{
baseOption: baseOption{
repositoryUrl: privateGitRepoURL,
username: username,
password: accessToken,
},
args: args{
repositoryUrl: privateGitRepoURL,
referenceName: "refs/heads/main",
username: username,
password: accessToken,
},
expect: expectResult{
err: nil,
@@ -293,13 +438,11 @@ func Test_listFilesPrivateRepository(t *testing.T) {
},
{
name: "list tree with real repository but non-existing ref",
args: fetchOption{
baseOption: baseOption{
repositoryUrl: privateGitRepoURL,
username: username,
password: accessToken,
},
args: args{
repositoryUrl: privateGitRepoURL,
referenceName: "refs/fake/feature",
username: username,
password: accessToken,
},
expect: expectResult{
shouldFail: true,
@@ -307,13 +450,11 @@ func Test_listFilesPrivateRepository(t *testing.T) {
},
{
name: "list tree with fake repository ",
args: fetchOption{
baseOption: baseOption{
repositoryUrl: privateGitRepoURL + "fake",
username: username,
password: accessToken,
},
args: args{
repositoryUrl: privateGitRepoURL + "fake",
referenceName: "refs/fake/feature",
username: username,
password: accessToken,
},
expect: expectResult{
shouldFail: true,
@@ -324,7 +465,17 @@ func Test_listFilesPrivateRepository(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
paths, err := client.listFiles(context.TODO(), tt.args)
option := &git.CloneOptions{
URL: tt.args.repositoryUrl,
ReferenceName: plumbing.ReferenceName(tt.args.referenceName),
}
if tt.args.username != "" || tt.args.password != "" {
option.Auth = &githttp.BasicAuth{
Username: tt.args.username,
Password: tt.args.password,
}
}
paths, err := client.ListFiles(t.Context(), false, option)
if tt.expect.shouldFail {
require.Error(t, err)
if tt.expect.err != nil {
+150 -165
View File
@@ -4,11 +4,15 @@ 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"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/rs/zerolog/log"
"golang.org/x/sync/singleflight"
)
@@ -18,42 +22,17 @@ const (
repositoryCacheTTL = 5 * time.Minute
)
// baseOption provides a minimum group of information to operate a git repository, like git-remote
type baseOption struct {
repositoryUrl string
username string
password string
authType gittypes.GitCredentialAuthType
tlsSkipVerify bool
}
// fetchOption allows to specify the reference name of the target repository
type fetchOption struct {
baseOption
referenceName string
dirOnly bool
}
// cloneOption allows to add a history truncated to the specified number of commits
type cloneOption struct {
fetchOption
depth int
}
type repoManager interface {
download(ctx context.Context, dst string, opt cloneOption) error
latestCommitID(ctx context.Context, opt fetchOption) (string, error)
listRefs(ctx context.Context, opt baseOption) ([]string, error)
listFiles(ctx context.Context, opt fetchOption) ([]string, error)
type RepoManager interface {
Download(ctx context.Context, dst string, opt *git.CloneOptions) error
LatestCommitID(ctx context.Context, repositoryUrl, referenceName string, opt *git.ListOptions) (string, error)
ListRefs(ctx context.Context, repositoryUrl string, opt *git.ListOptions) ([]string, error)
ListFiles(ctx context.Context, dirOnly bool, opt *git.CloneOptions) ([]string, error)
}
// Service represents a service for managing Git.
type Service struct {
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
@@ -69,138 +48,137 @@ 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,
authType gittypes.GitCredentialAuthType,
tlsSkipVerify bool,
) error {
options := cloneOption{
fetchOption: fetchOption{
baseOption: baseOption{
repositoryUrl: repositoryURL,
username: username,
password: password,
authType: authType,
tlsSkipVerify: tlsSkipVerify,
},
referenceName: referenceName,
},
depth: 1,
}
return service.cloneRepository(destination, options)
return service.CloneRepositoryWithAuth(ctx, destination, repositoryURL, referenceName, GetBasicAuth(username, password), tlsSkipVerify)
}
func (service *Service) repoManager(options baseOption) repoManager {
// CloneRepositoryWithAuth clones a git repository using the specified URL in the specified
// destination folder, using the provided auth method.
func (service *Service) CloneRepositoryWithAuth(
ctx context.Context,
destination,
repositoryURL,
referenceName string,
auth transport.AuthMethod,
tlsSkipVerify bool,
) error {
gitOptions := &git.CloneOptions{
URL: repositoryURL,
Depth: 1,
InsecureSkipTLS: tlsSkipVerify,
Auth: auth,
Tags: git.NoTags,
}
if referenceName != "" {
gitOptions.ReferenceName = plumbing.ReferenceName(referenceName)
}
return service.repoManager(repositoryURL).Download(ctx, destination, gitOptions)
}
func (service *Service) repoManager(repositoryURL string) RepoManager {
repoManager := service.git
if isAzureUrl(options.repositoryUrl) {
if IsAzureUrl(repositoryURL) {
repoManager = service.azure
}
return repoManager
}
func (service *Service) cloneRepository(destination string, options cloneOption) error {
return service.repoManager(options.baseOption).download(context.TODO(), destination, options)
}
// LatestCommitID returns SHA1 of the latest commit of the specified reference
func (service *Service) LatestCommitID(
ctx context.Context,
repositoryURL,
referenceName,
username,
password string,
authType gittypes.GitCredentialAuthType,
tlsSkipVerify bool,
) (string, error) {
options := fetchOption{
baseOption: baseOption{
repositoryUrl: repositoryURL,
username: username,
password: password,
authType: authType,
tlsSkipVerify: tlsSkipVerify,
},
referenceName: referenceName,
return service.LatestCommitIDWithAuth(ctx, repositoryURL, referenceName, GetBasicAuth(username, password), tlsSkipVerify)
}
// LatestCommitIDWithAuth returns SHA1 of the latest commit of the specified reference,
// using the provided auth method.
func (service *Service) LatestCommitIDWithAuth(
ctx context.Context,
repositoryURL,
referenceName string,
auth transport.AuthMethod,
tlsSkipVerify bool,
) (string, error) {
listOptions := &git.ListOptions{
Auth: auth,
InsecureSkipTLS: tlsSkipVerify,
}
return service.repoManager(options.baseOption).latestCommitID(context.TODO(), options)
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,
authType gittypes.GitCredentialAuthType,
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) {
@@ -211,28 +189,25 @@ 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
}
}
}
options := baseOption{
repositoryUrl: repositoryURL,
username: username,
password: password,
authType: authType,
tlsSkipVerify: tlsSkipVerify,
options := &git.ListOptions{
Auth: auth,
InsecureSkipTLS: tlsSkipVerify,
}
refs, err := service.repoManager(options).listRefs(context.TODO(), 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
@@ -243,95 +218,89 @@ var singleflightGroup = &singleflight.Group{}
// ListFiles will list all the files of the target repository with specific extensions.
// If extension is not provided, it will list all the files under the target repository
func (service *Service) ListFiles(
ctx context.Context,
repositoryURL,
referenceName,
username,
password string,
authType gittypes.GitCredentialAuthType,
dirOnly,
hardRefresh bool,
includedExts []string,
tlsSkipVerify bool,
) ([]string, error) {
repoKey := generateCacheKey(
cacheKey := GenerateCacheKey(
repositoryURL,
referenceName,
username,
password,
strconv.FormatBool(tlsSkipVerify),
strconv.Itoa(int(authType)),
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,
authType,
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,
authType gittypes.GitCredentialAuthType,
referenceName string,
dirOnly,
hardRefresh bool,
auth transport.AuthMethod,
tlsSkipVerify bool,
cacheKey string,
) ([]string, error) {
repoKey := generateCacheKey(
repositoryURL,
referenceName,
username,
password,
strconv.FormatBool(tlsSkipVerify),
strconv.Itoa(int(authType)),
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
}
}
}
options := fetchOption{
baseOption: baseOption{
repositoryUrl: repositoryURL,
username: username,
password: password,
authType: authType,
tlsSkipVerify: tlsSkipVerify,
},
referenceName: referenceName,
dirOnly: dirOnly,
cloneOption := &git.CloneOptions{
URL: repositoryURL,
NoCheckout: true,
Depth: 1,
SingleBranch: true,
ReferenceName: plumbing.ReferenceName(referenceName),
Auth: auth,
InsecureSkipTLS: tlsSkipVerify,
Tags: git.NoTags,
}
files, err := service.repoManager(options.baseOption).listFiles(context.TODO(), options)
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
@@ -347,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, "-")
}
@@ -380,3 +350,18 @@ func filterFiles(paths []string, includedExts []string) []string {
return includedFiles
}
func GetBasicAuth(username, password string) *githttp.BasicAuth {
if password == "" {
return nil
}
if username == "" {
username = "token"
}
return &githttp.BasicAuth{
Username: username,
Password: password,
}
}
+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))
})
}
}
+6 -12
View File
@@ -7,13 +7,7 @@ import (
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")
)
type GitCredentialAuthType int
const (
GitCredentialAuthType_Basic GitCredentialAuthType = iota
GitCredentialAuthType_Token
ErrSymlinkDetected = errors.New("repository contains a symlink, which is not allowed for security reasons")
)
// RepoConfig represents a configuration for a repo
@@ -22,7 +16,8 @@ type RepoConfig struct {
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
@@ -33,11 +28,10 @@ type RepoConfig struct {
}
type GitAuthentication struct {
Username string
Password string
AuthorizationType GitCredentialAuthType
Username string
Password string
// Git credentials identifier when the value is not 0
// When the value is 0, Username, Password, and Authtype are set without using saved credential
// When the value is 0, Username and Password are set without using saved credential
// This is introduced since 2.15.0
GitCredentialID int `example:"0"`
}

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