Compare commits

..

146 Commits

Author SHA1 Message Date
RHCowan 6c863bfa67 chore: bump version to 2.40.0 and set API version support to STS (#2160) 2026-03-26 10:29:28 +13:00
Robbie Cowan 4d1f432266 Revert "chore: bump version to 2.40.0"
This reverts commit 11af66a4fce8ab98253afb2e637a946a8939747f.
2026-03-25 18:47:01 +13:00
Robbie Cowan 1e00a58b57 chore: bump version to 2.40.0 2026-03-25 18:15:54 +13:00
Phil Calder 0a26ac0279 fix(security): bump google.golang.org/grpc to v1.79.3 [DEV-22] (#2151)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 17:26:25 +13:00
Ali 63b0802ad7 fix(UI): revert global css changes targetting firefox banner fix + cleanup [C9S 71] (#2152) 2026-03-25 15:51:36 +13:00
Ali a5062dbe35 fix(ui): handle verticle alignment selectors for production build [c9s-71] (#2147) 2026-03-25 13:37:20 +13:00
Ali f84e657707 feat(registries): support service accounts with registry secrets for cluster level [C9S 37] (#2120) 2026-03-25 11:00:13 +13:00
Chaim Lev-Ari cd8a42edaf fix(settings/auth): allow dashes in ldap dn (#2141) 2026-03-24 16:00:59 -03:00
Chaim Lev-Ari e37f8a5eb9 fix(stacks): save entry point [BE-12670] (#2132) 2026-03-24 16:38:31 +02:00
Ali 7fc8d3f2b1 fix(ui): ensure when two+ angular components are in a #view, they don't all stretch to fill space [c9s-71] (#2133) 2026-03-24 23:03:24 +13:00
Ali 6f2d1a2b49 fix(ui): ensure centered pages stay centered [c9s-71] (#2131) 2026-03-24 20:02:01 +13:00
Chaim Lev-Ari d5a3e46791 feat(stacks): update git url and config path [BE-12670] (#2099)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Devon Steenberg <devon.steenberg@portainer.io>
2026-03-24 15:01:46 +13:00
andres-portainer 1f4724c537 fix(environments): fix the TLS certificate uploading BE-12719 (#2101)
Co-authored-by: andres-portainer <andres-portainer@users.noreply.github.com>
2026-03-24 14:01:49 +13:00
Ali e6f8736cae fix(policies): fix page styles for firefox banner [C9S-63] (#2128) 2026-03-24 13:24:43 +13:00
Oscar Zhou 54fbe54953 fix(edge/agent): deleting k8s edge agent disconnect environment [BE-12723] (#2109) 2026-03-24 08:33:06 +13:00
Ali 3e92a2881a chore(ci): improve regular PR CI reporting for playwright [r8s-925] (#2114) 2026-03-23 22:01:47 +13:00
Devon Steenberg bd9c3c1593 feat(gitops): tidy up git auth [BE-12666] (#2026) 2026-03-23 13:53:04 +13:00
Ali f199d0882f feat(serviceaccount): service account details view [C9S-36] (#2082) 2026-03-23 09:22:56 +13:00
Chaim Lev-Ari a2fee4fc4c fix(stacks): pass prune option through the deploy pipeline [BE-12738] (#2098)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 12:37:40 +02:00
Chaim Lev-Ari 5670216d7e feat(stacks): show planned deployment info [BE-12737] (#2097)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 09:34:45 +02:00
Ali 7569266e46 fix(rbac): update namespace rbac test id to match EE [C9S-61] (#2111) 2026-03-22 20:26:19 +13:00
Hannah Cooper 23f6cb8bae Update bug report template to include 2.39.1 (#2108) 2026-03-20 13:53:32 +13:00
Ali 931c2b3ddb feat(policies): Introduce change confirmation policy template, and clear default values for custom policy [C9S-52] (#2087) 2026-03-20 09:55:41 +13:00
Oscar Zhou 8b3edb4e28 fix(docker): show correct container state for restarting and removing [BE-12707] (#2088) 2026-03-20 08:49:59 +13:00
Chaim Lev-Ari a0b03d36bd refactor(settings/auth): migrate ldap-dn-builder to react [BE-12586] (#2025) 2026-03-19 17:56:15 +02:00
andres-portainer df1cd0af2e fix(endpointrelation): add locking to RegisterUpdateStackFunction() BE-12608 (#2096) 2026-03-19 12:38:36 -03:00
Chaim Lev-Ari 5df7146828 fix(stacks): disabled edit button while submit [BE-12681] (#2094) 2026-03-19 14:56:08 +02:00
Chaim Lev-Ari bec5d829f1 refactor(settings/auth): migrate ldap security settings to react [BE-12588] (#2029) 2026-03-19 12:13:05 +02:00
Chaim Lev-Ari ee0e9f6ff8 feat(settings/auth): migrate ldap test login to react [BE-12589] (#2036) 2026-03-19 11:23:58 +02:00
Ali 9c7eef3144 feat(secrets): update secrets to show related registry [c9s-35] (#2065) 2026-03-19 15:18:35 +13:00
Chaim Lev-Ari 3110fe4e74 refactor(axios): move axios into react folder [BE-12728] (#2084) 2026-03-19 10:21:25 +13:00
Chaim Lev-Ari 565ac2c15a fix(ts): add back feature imports [BE-12733] (#2083) 2026-03-18 13:46:52 +02:00
nickl-portainer 9cba6c7475 chore(tsconfig): remove obsolete aliases (#2078) 2026-03-18 16:01:17 +13:00
nickl-portainer 07b3bdb62d chore(axios): move out axios utils into helpers and utils folder [R8S-871] (#2077) 2026-03-18 13:59:51 +13:00
nickl-portainer ac7ff0fff4 chore(axios): move axios into own folder CE [R8S-871] (#2075) 2026-03-18 13:24:44 +13:00
Chaim Lev-Ari 0d20839d5f fix(stacks): validate stacks with env vars [BE-12689] (#2050)
Co-authored-by: andres-portainer <andres-portainer@users.noreply.github.com>
2026-03-17 18:53:13 -03:00
Oscar Zhou 13fb3118ee fix(edge/docker): add bind mount volume label for restarting the specific service [BE-12575] (#1821) 2026-03-18 08:44:50 +13:00
andres-portainer 364027054c fix(websocket): simplify the logout disconnection logic BE-12605 (#1868) 2026-03-17 13:21:28 -03:00
andres-portainer 31a861394f fix(otel): upgrade to v1.42.0 BE-12724 (#2072) 2026-03-17 13:02:54 -03:00
LP B 0fccc0357e fix(api/uac): panic on external stacks UAC eval (#2073) 2026-03-17 16:00:35 +01:00
andres-portainer 5550a71dea fix(docker): upgrade Docker binary to v29.3.0 to mitigate CVE-2025-68121 BE-12720 (#2064) 2026-03-16 19:02:56 -03:00
Steven Kang 0ec6f638a1 feat(kompose): support docker to kubernetes migration - [R8S-723] (#1977) 2026-03-17 09:37:18 +13:00
andres-portainer 748b4bcf19 chore(go): upgrade to v1.26.1 BE-12630 (#1855) 2026-03-16 15:52:36 -03:00
Ali 33cc29fa3c fix(sidebar): set helper anchor color to match the other items [C9S-47] (#2058) 2026-03-16 15:50:59 +13:00
Chaim Lev-Ari 5e2eb667b4 fix(kube/app): enable edit button for regular apps [BE-12690] (#2039) 2026-03-15 11:22:09 +02:00
Ali 1f9c9b082f feat(policies): banner and confirmation on change policy [C9S-20] (#1988) 2026-03-13 14:11:53 +13:00
Cara Ryan 722c1875af chore(helm): upgrade sdk to v4 [R8S-840] (#2000) 2026-03-13 11:34:28 +13:00
Ali 68471d0225 fix(stacks): use widget-tabs consistently [c9s-33] (#2038) 2026-03-13 08:30:45 +13:00
Phil Calder a6900545b0 Report a vulnerability via email or GitHub (#2037) 2026-03-12 12:30:40 +13:00
Chaim Lev-Ari 808ceba848 feat(docker): allow user to specify security-opts (#2022)
Co-authored-by: dylan <dfldylan@qq.com>
Co-authored-by: jerry-yuan <i@jerryzone.cn>
2026-03-11 08:56:42 +02:00
Oscar Zhou a796a03a15 fix(edge/helm): helm edge stack is marked as external [BE-12653] (#1974) 2026-03-11 12:51:07 +13:00
andres-portainer 5a5dc67209 fix(golang-lru): consolidate the dependencies BE-12695 (#2021) 2026-03-10 18:57:49 -03:00
andres-portainer 69ae54b523 fix(zerolog): consolidate the dependencies BE-12695 (#2030) 2026-03-10 18:30:21 -03:00
andres-portainer b405227d51 fix(jwt): consolidate the dependencies BE-12695 (#2020) 2026-03-10 15:14:21 -03:00
andres-portainer 44be39a9a4 fix(mapstructure): consolidate the dependencies BE-12695 (#2019) 2026-03-10 14:48:37 -03:00
andres-portainer 5de0cc199c fix(kingpin): consolidate dependencies BE-12695 (#2018) 2026-03-10 14:33:10 -03:00
andres-portainer 0c9e408eda fix(ldap): consolidate dependencies BE-12695 (#2017) 2026-03-10 14:18:06 -03:00
Chaim Lev-Ari 1007f1f740 feat(ui): create shared terminal component [BE-12697] (#1979) 2026-03-10 18:17:29 +02:00
Chaim Lev-Ari 774e3d5948 fix(ws): remove limit on docker console [BE-12660] (#2023) 2026-03-10 15:26:33 +02:00
andres-portainer 4d866d066a fix(uuid): consolidate dependencies BE-12695 (#2016) 2026-03-10 10:12:42 -03:00
andres-portainer da6544e981 fix(semver): consolidate dependencies BE-12695 (#2014) 2026-03-09 15:33:45 -03:00
bernard-portainer 3af9a7646d fix(ui): add getRowId to expandable storage component [R8S-538] (#2008) 2026-03-09 15:37:40 +13:00
andres-portainer 0e2cf82e3e fix(yaml): consolidate dependencies BE-12695 (#2015) 2026-03-06 18:21:12 -03:00
andres-portainer 97e69b9887 fix(GO-2026-4550): upgrade circl to v1.6.3 BE-12694 (#2011) 2026-03-06 14:29:15 -03:00
andres-portainer 692f91263b fix(GO-2026-4473): upgrade go-git to v5.17.0 BE-12693 (#2010) 2026-03-06 11:23:52 -03:00
LP B 8b61d8a9d2 fix(app/container): query env registries instead of system registries (#1996) 2026-03-06 15:03:11 +01:00
LP B 25d51f9515 fix(app): paginate nested tables (#1998) 2026-03-06 15:01:52 +01:00
LP B 20b971dc1f fix(app/stack): virtual grouping in EnvSelector for non admins (#2001) 2026-03-06 15:00:01 +01:00
andres-portainer 7a76d749e3 fix(GO-2026-4394): upgrade opentelemetry to v1.41.0 BE-12692 (#2003) 2026-03-06 09:47:20 -03:00
LP B 123afd9462 fix(api/custom_template): validate UAC when retrieving custom template file (#1980) 2026-03-04 13:22:14 +01:00
Xing ad83478b77 fix(oauth): tolerate malformed Content-Type headers from resource ept (#1969)
Co-authored-by: Mike Spook <16549186+mikespook@user.noreply.gitee.com>
Co-authored-by: Oscar Zhou <100548325+oscarzhou-portainer@users.noreply.github.com>
Co-authored-by: RHCowan <50324595+RHCowan@users.noreply.github.com>

Thanks @srikanth-karthi for the original PR.
2026-03-02 10:59:02 +13:00
nickl-portainer 2ad0a65613 feat(policies): add inline editing ability to datatable for docker RBAC policies [R8S-717] (#1955) 2026-03-02 09:12:13 +13:00
Chaim Lev-Ari 1f5762b8c8 fix(settings/auth): fix a11y labels (#1963) 2026-03-01 12:14:47 +02:00
RHCowan 0370b09ad0 fix(policy) avoid URL length limit when adding environments to large groups [R8S-893] (#1970) 2026-02-27 11:45:15 +13:00
Oscar Zhou 5869a8948d refactor(stack): change stack creation flow to save stack first [BE-12650] (#1959) 2026-02-27 10:14:17 +13:00
Chaim Lev-Ari 56a840e207 feat(settings): migrate SessionLifetimeSelect to React [BE-12583] (#1829)
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-26 15:39:08 +02:00
Chaim Lev-Ari a01dd005fd refactor(settings/auth): migrate auto user provision toggle to react [BE-12585] (#1865)
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-26 14:18:48 +02:00
Chaim Lev-Ari 9ad6c16d43 feat(settings): migrate authentication method selector to React [BE-12584] (#1830)
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-26 10:52:39 +02:00
Hannah Cooper 9cc3e16db9 Update bug_report to include 2.39.0 (#1964) 2026-02-26 12:30:42 +13:00
andres-portainer d02bcdba29 fix(postinit): optimize PostInitMigrate() BE-12659 (#1958) 2026-02-25 16:03:26 -03:00
Steven Kang c708fe577c fix(kubernetes): local exec to fall back to SPDY - develop [R8S-873] (#1946) 2026-02-25 15:46:15 +13:00
Oscar Zhou c92161bb22 feat(edge/helm): support per device configuration [BE-12633] (#1901) 2026-02-25 10:00:37 +13:00
Ali 138aa13fdc fix(environment-groups): allow bulk selecting environments on create and edit [r8s-872] (#1954)
Merging because the failed system tests are related to helm and not environment groups
2026-02-24 17:53:16 +13:00
Steven Kang 988a795def fix(environment): collapsing More options breaking the style for podman - develop [R8S-874] (#1942) 2026-02-24 10:11:31 +13:00
Oscar Zhou 3f7a3053ff fix(stack): avoid removing running service if stack deployment fails [BE-12542] (#1940) 2026-02-24 08:41:42 +13:00
Oscar Zhou 0c8c6865be refactor(error): standardize multi errors handling [BE-12647] (#1933) 2026-02-23 09:40:01 +13:00
Chaim Lev-Ari 2bbcae39b6 feat: clean frontend test logs (#1894) 2026-02-22 09:42:49 +02:00
andres-portainer caf6b2aa0c fix(policies): fixes for async edge R8S-661 (#1917) 2026-02-20 17:45:45 -03:00
Steven Kang a00f05fe32 feat(environment): reorder options - develop [R8S-524] (#1822) 2026-02-20 14:58:01 +13:00
Chaim Lev-Ari 9fcac1ab4f chore(deps): upgrade axios [BE-12632] (#1864) 2026-02-19 15:38:08 +13:00
Josiah Clumont ae24ad4693 Bump version to 2.39.0 for LTS (#1910) 2026-02-19 15:29:08 +13:00
RHCowan 0f721b60a9 fix(policy) Improve policy status performance [R8S-710] (#1878) 2026-02-19 15:24:14 +13:00
RHCowan e8b49f53e1 fix(policy) fix policy group pagination issues [R8S-855] (#1898) 2026-02-19 13:29:01 +13:00
andres-portainer 27531a802b fix(fips): ensure custom registries cannot use HTTP without TLS BE-12511 (#1885)
Co-authored-by: andres-portainer <andres-portainer@users.noreply.github.com>
2026-02-19 11:51:11 +13:00
Josiah Clumont 4bbf0ce0c0 fix(docker): Update the docker binary version that uses 1.25.6 to fix CVE-2025-61726 - for 2.39.0-LTS [R8S-818] (#1791) 2026-02-19 09:46:14 +13:00
Josiah Clumont e0c22ea3eb fix(copy): Fixed an issue with the downgrade links [R8S-832] (#1907) 2026-02-19 09:38:04 +13:00
nickl-portainer b7eb2ba068 fix(policies) convert all warnings to use PolicyOverrideAlert [R8S-837] (#1890) 2026-02-19 09:12:54 +13:00
Ali affdb69568 fix(policies): show registry policy banner, and disable registry selector when policy applies [R8S-853] (#1891) 2026-02-19 08:37:02 +13:00
LP B 763b7da65c fix(api/docker): do not rewrite HTTP code in responses of create requests (#1854) 2026-02-18 19:26:29 +01:00
Chaim Lev-Ari 42e9165347 fix(stacks): generate webhook id for stacks (#1876) 2026-02-17 10:38:18 +02:00
Ali 16dd08a359 feat(widget): update widget tab styling product wide [r8s-850] (#1881) 2026-02-17 10:33:43 +13:00
Ali 936494615c fix(select): stop react-select overlapping with footer [R8S-794] (#1880) 2026-02-17 08:53:50 +13:00
andres-portainer 5769c0b98e fix(kubernetes): add missing returns BE-12582 (#1883) 2026-02-16 12:47:27 -03:00
andres-portainer b7e1caa8c6 fix(boltdb): fix error handling BE-12582 (#1882) 2026-02-16 12:47:00 -03:00
andres-portainer e02ae6b2fb fix(archive): prevent file traversal vulnerability BE-12582 (#1875) 2026-02-16 11:26:51 -03:00
testA113 d9f131a2c5 Revert "feat(widget): update widget tab styling product wide [r8s-850]"
This reverts commit d882c3b8fa4a03bf85b4e9fb1da729fabf903cb6.
2026-02-17 00:05:24 +13:00
testA113 ad1f7dbaa5 feat(widget): update widget tab styling product wide [r8s-850] 2026-02-17 00:01:07 +13:00
Devon Steenberg aa6da0f6d3 feat(api-testing): add api testing framework [BE-12571] (#1824)
Co-authored-by: Chaim Lev-Ari <chiptus@users.noreply.github.com>
2026-02-16 09:35:06 +13:00
Oscar Zhou 376071e408 feat(edge/helm): add atomic and timeout options [BE-12481] (#1849) 2026-02-16 09:21:19 +13:00
Chaim Lev-Ari d3544fb9b3 refactor(tests): mock ws server (#1853) 2026-02-15 08:58:24 +02:00
Chaim Lev-Ari c8497b3944 chore(deps): upgrade html-loader (#1863) 2026-02-15 08:55:33 +02:00
andres-portainer 5aa92b8413 fix(webhooks): use transactions to check for webhook uniqueness BE-12613 (#1872) 2026-02-13 12:48:17 -03:00
Hannah Cooper bccb6694d4 Update bug_report to include 2.38.1 (#1866) 2026-02-13 12:42:08 +13:00
Hannah Cooper 506a11c658 Update bug_report to include 2.33.7 (#1836) 2026-02-13 12:28:05 +13:00
Ali bdc315a59d fix(helm): helm release not found error [r8s-842] (#1857) 2026-02-13 08:07:23 +13:00
andres-portainer ec7d3bddfc fix(endpoints): fix transaction usage BE-12612 (#1838) 2026-02-11 12:34:46 -03:00
Chaim Lev-Ari 762c1ccf28 chore(deps): upgrade vitest and msw (#1852) 2026-02-11 17:14:04 +02:00
Malcolm Lockyer 8e44c8fa06 fix(webpack): fix common cfg after webpack-dev-server upgrade [r8s-841] (#1848) 2026-02-11 18:34:14 +13:00
Chaim Lev-Ari 20db102327 chore(deps): upgrade webpack (#1802) 2026-02-10 18:01:03 +02:00
Chaim Lev-Ari 1643cb8165 fix(environments): handle unix:// urls [BE-12610] (#1837)
Co-authored-by: Nicholas Loomans <nicholas.loomans@portainer.io>
2026-02-10 15:21:25 +02:00
Ali 49e623dfeb feat(policy-RBAC): ensure RBAC policy overrides existing RBAC settings [R8S-777] (#1718) 2026-02-10 23:44:44 +13:00
Steven Kang a1208974ac fix(policy): pod security constraints - develop [R8S-808] (#1758)
Co-authored-by: Phil Calder <4473109+predlac@users.noreply.github.com>
Co-authored-by: Viktor Pettersson <viktor.pettersson@portainer.io>
Co-authored-by: Yajith Dayarathna <yajith.dayarathna@portainer.io>
Co-authored-by: Chaim Lev-Ari <chiptus@users.noreply.github.com>
Co-authored-by: nickl-portainer <nicholas.loomans@portainer.io>
2026-02-10 08:46:02 +09:00
Chaim Lev-Ari d611087513 chore(deps): upgrade storybook 8 (#1811) 2026-02-08 09:59:08 +02:00
andres-portainer ac7cb2ee19 fix(security): fix CVE-2025-68121 by upgrading Go compiler BE-12581 (#1813) 2026-02-06 13:17:12 -03:00
Oscar Zhou f866572cbf fix(edge/helm): helm config section shows for other type [BE-12580] (#1808) 2026-02-06 09:13:06 +13:00
Chaim Lev-Ari 4c6942f60b fix(environments): update associated group [BE-12559] (#1760) 2026-02-05 18:48:02 +02:00
nickl-portainer d939897524 feat(menu) add policies to environment settings submenu [R8S-806] (#1805) 2026-02-05 14:39:41 +13:00
nickl-portainer 66c5589fd7 fix(environment-list) resize kubeconfig download modal [R8S-814] (#1786)
Co-authored-by: Phil Calder <4473109+predlac@users.noreply.github.com>
Co-authored-by: Steven Kang <skan070@gmail.com>
Co-authored-by: Viktor Pettersson <viktor.pettersson@portainer.io>
Co-authored-by: Yajith Dayarathna <yajith.dayarathna@portainer.io>
Co-authored-by: Chaim Lev-Ari <chiptus@users.noreply.github.com>
Co-authored-by: Malcolm Lockyer <segfault88@users.noreply.github.com>
Co-authored-by: Ali <83188384+testA113@users.noreply.github.com>
Co-authored-by: RHCowan <50324595+RHCowan@users.noreply.github.com>
2026-02-05 14:39:23 +13:00
Oscar Zhou 379b1d611b feat(edge/helm): support helm chart via git repository in edge stack [BE-12448] (#1649) 2026-02-05 13:22:31 +13:00
Chaim Lev-Ari f16221f385 docs(claude): optimize memory files (#1777) 2026-02-05 04:28:36 +05:30
RHCowan 9b82560270 fix(policy) Fetch new status after policy update [R8S-711] (#1775) 2026-02-04 18:23:26 +13:00
Oscar Zhou 7271af03e6 fix(docker): dashboard api return 500 error [BE-12567] (#1784) 2026-02-04 08:32:01 +13:00
RHCowan 4d564bbce2 feat(policy): Display last attempt timestamp for policy installations [R8S-667] (#1774) 2026-02-03 12:32:22 +13:00
Oscar Zhou d7afdf214b refactor(k8s): replace kubectl delete with delete api [BE-12560] (#1768) 2026-02-03 08:36:08 +13:00
Chaim Lev-Ari 18e445ea02 refactor(environments): migrate item view to react [BE-6632] (#1747) 2026-01-31 15:05:11 +07:00
nickl-portainer cb70c705a3 fix(react): namespace selects sort alphabetically [R8S-765] (#1671) 2026-01-30 08:23:01 +13:00
Ali 9a77eb9872 chore(environment-groups): migrate environment groups to react [R8S-771] (#1741) 2026-01-29 14:17:33 +13:00
Hannah Cooper ec82f646a0 Add 2.38.0 to bug report (#1756) 2026-01-29 12:45:23 +13:00
andres-portainer 2f0e384240 fix(database): use Exists() where possible to improve performance BE-12557 (#1752) 2026-01-28 18:49:32 -03:00
Ali 19a1426869 chore(webpack): cache dependencies and use lighter sourcemap [R8S-791] (#1715) 2026-01-29 09:52:11 +13:00
andres-portainer cc5cd8db6b fix(pendingactions): clean up and optimize the code BE-12556 (#1750) 2026-01-28 15:36:54 -03:00
andres-portainer e384e2edda fix(pendingactions): fix transaction handling BE-12556 (#1749) 2026-01-28 14:11:35 -03:00
Chaim Lev-Ari dca044873f feat(environments): migrate edge form to react BE-12529 (#1676) 2026-01-28 15:35:13 +07:00
nickl-portainer 8aadddcc68 test(react): add test coverage for forms to enforce no errors showing on initial load [R8S-730] (#1696) 2026-01-28 08:12:12 +13:00
andres-portainer 2e95229c51 fix(oauth): add a timeout to GetResource() BE-12258 (#1456) 2026-01-27 10:24:45 -03:00
Phil Calder 8a1d02c23f Bump version to 2.38.0 (#1727) 2026-01-27 16:26:14 +13:00
949 changed files with 24451 additions and 14826 deletions
+7 -2
View File
@@ -139,15 +139,19 @@ overrides:
'react/jsx-props-no-spreading': off
- files:
- app/**/*.test.*
plugins:
- '@vitest'
extends:
- 'plugin:vitest/recommended'
- 'plugin:@vitest/legacy-recommended'
env:
'vitest/env': true
'@vitest/env': true
rules:
'react/jsx-no-constructed-context-values': off
'@typescript-eslint/no-restricted-imports': off
no-restricted-imports: off
'react/jsx-props-no-spreading': off
'@vitest/no-conditional-expect': warn
'max-classes-per-file': off
- files:
- app/**/*.stories.*
rules:
@@ -155,3 +159,4 @@ overrides:
'@typescript-eslint/no-restricted-imports': off
no-restricted-imports: off
'react/jsx-props-no-spreading': off
'storybook/no-renderer-packages': off
+5 -3
View File
@@ -94,10 +94,15 @@ 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.39.1'
- '2.39.0'
- '2.38.1'
- '2.38.0'
- '2.37.0'
- '2.36.0'
- '2.35.0'
- '2.34.0'
- '2.33.7'
- '2.33.6'
- '2.33.5'
- '2.33.4'
@@ -138,9 +143,6 @@ body:
- '2.21.5'
- '2.21.4'
- '2.21.3'
- '2.21.2'
- '2.21.1'
- '2.21.0'
validations:
required: true
+1 -1
View File
@@ -6,7 +6,7 @@ linters:
settings:
forbidigo:
forbid:
- pattern: ^dataservices.DataStore.(EdgeGroup|EdgeJob|EdgeStack|EndpointRelation|Endpoint|GitCredential|Registry|ResourceControl|Role|Settings|Snapshot|Stack|Tag|User)$
- pattern: ^dataservices.DataStore.(EdgeGroup|EdgeJob|EdgeStack|EndpointRelation|Endpoint|GitCredential|Registry|ResourceControl|Role|Settings|Snapshot|SSLSettings|Stack|Tag|User)$
msg: Use a transaction instead
analyze-types: true
exclusions:
+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$
+2 -1
View File
@@ -1,2 +1,3 @@
dist
api/datastore/test_data
api/datastore/test_data
coverage
+29 -11
View File
@@ -9,20 +9,38 @@ const config: StorybookConfig = {
addons: [
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/addon-webpack5-compiler-swc',
'@chromatic-com/storybook',
{
name: '@storybook/addon-styling',
name: '@storybook/addon-styling-webpack',
options: {
cssLoaderOptions: {
importLoaders: 1,
modules: {
localIdentName: '[path][name]__[local]',
auto: true,
exportLocalsConvention: 'camelCaseOnly',
rules: [
{
test: /\.css$/,
sideEffects: true,
use: [
require.resolve('style-loader'),
{
loader: require.resolve('css-loader'),
options: {
importLoaders: 1,
modules: {
localIdentName: '[path][name]__[local]',
auto: true,
exportLocalsConvention: 'camelCaseOnly',
},
},
},
{
loader: require.resolve('postcss-loader'),
options: {
implementation: postcss,
},
},
],
},
},
postCss: {
implementation: postcss,
},
],
},
},
],
+17 -18
View File
@@ -1,9 +1,9 @@
import '../app/assets/css';
import React from 'react';
import { pushStateLocationPlugin, UIRouter } from '@uirouter/react';
import { initialize as initMSW, mswLoader } from 'msw-storybook-addon';
import { handlers } from '../app/setup-tests/server-handlers';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Preview } from '@storybook/react';
initMSW(
{
@@ -21,31 +21,30 @@ initMSW(
handlers
);
export const parameters = {
actions: { argTypesRegex: '^on[A-Z].*' },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
msw: {
handlers,
},
};
const testQueryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
export const decorators = [
(Story) => (
const preview: Preview = {
decorators: (Story) => (
<QueryClientProvider client={testQueryClient}>
<UIRouter plugins={[pushStateLocationPlugin]}>
<Story />
</UIRouter>
</QueryClientProvider>
),
];
loaders: [mswLoader],
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
msw: {
handlers,
},
},
};
export const loaders = [mswLoader];
export default preview;
+44
View File
@@ -0,0 +1,44 @@
# Portainer Community Edition
Open-source container management platform with full Docker and Kubernetes support.
see also:
- docs/guidelines/server-architecture.md
- docs/guidelines/go-conventions.md
- docs/guidelines/typescript-conventions.md
## Package Manager
- **PNPM** 10+ (for frontend)
- **Go** 1.26.1 (for backend)
## Build Commands
```bash
# Full build
make build # Build both client and server
make build-client # Build React/AngularJS frontend
make build-server # Build Go binary
make build-image # Build Docker image
# Development
make dev # Run both in dev mode
make dev-client # Start webpack-dev-server (port 8999)
make dev-server # Run containerized Go server
pnpm run dev # Webpack dev server
pnpm run build # Build frontend with webpack
pnpm run test # Run frontend tests
# Testing
make test # All tests (backend + frontend)
make test-server # Backend tests only
make lint # Lint all code
make format # Format code
```
## Development Servers
- Frontend: http://localhost:8999
- Backend: http://localhost:9000 (HTTP) / https://localhost:9443 (HTTPS)
+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.
+2 -1
View File
@@ -10,6 +10,7 @@ import (
"path/filepath"
"strings"
"github.com/portainer/portainer/api/filesystem"
"github.com/portainer/portainer/api/logs"
)
@@ -108,7 +109,7 @@ func ExtractTarGz(r io.Reader, outputDirPath string) error {
case tar.TypeDir:
// skip, dir will be created with a file
case tar.TypeReg:
p := filepath.Clean(filepath.Join(outputDirPath, header.Name))
p := filesystem.JoinPaths(outputDirPath, header.Name)
if err := os.MkdirAll(filepath.Dir(p), 0o744); err != nil {
return fmt.Errorf("Failed to extract dir %s", filepath.Dir(p))
}
+56
View File
@@ -1,12 +1,15 @@
package archive
import (
"archive/tar"
"compress/gzip"
"os"
"os/exec"
"path"
"path/filepath"
"testing"
"github.com/portainer/portainer/api/filesystem"
"github.com/rs/zerolog/log"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -108,3 +111,56 @@ func Test_shouldCreateArchive2(t *testing.T) {
wasExtracted("dir/inner")
wasExtracted("dir/.dotfile")
}
func TestExtractTarGzPathTraversal(t *testing.T) {
testDir := t.TempDir()
// Create an evil file with a path traversal attempt
tarPath := filesystem.JoinPaths(testDir, "evil.tar.gz")
evilFile, err := os.Create(tarPath)
require.NoError(t, err)
gzWriter := gzip.NewWriter(evilFile)
tarWriter := tar.NewWriter(gzWriter)
content := []byte("evil content")
header := &tar.Header{
Name: "../evil.txt",
Mode: 0600,
Size: int64(len(content)),
Typeflag: tar.TypeReg,
}
err = tarWriter.WriteHeader(header)
require.NoError(t, err)
_, err = tarWriter.Write(content)
require.NoError(t, err)
err = tarWriter.Close()
require.NoError(t, err)
err = gzWriter.Close()
require.NoError(t, err)
err = evilFile.Close()
require.NoError(t, err)
// Attempt to extract the evil file
extractionDir := filesystem.JoinPaths(testDir, "extraction")
err = os.Mkdir(extractionDir, 0700)
require.NoError(t, err)
tarFile, err := os.Open(tarPath)
require.NoError(t, err)
// Check that the file didn't escape
err = ExtractTarGz(tarFile, extractionDir)
require.NoError(t, err)
require.NoFileExists(t, filesystem.JoinPaths(testDir, "evil.txt"))
err = tarFile.Close()
require.NoError(t, err)
}
+1 -1
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(),
+2 -2
View File
@@ -55,7 +55,7 @@ import (
"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")
}
+3 -1
View File
@@ -92,7 +92,9 @@ func CreateTLSConfigurationFromDisk(config portainer.TLSConfiguration) (*tls.Con
}
func createTLSConfigurationFromDisk(fipsEnabled bool, config portainer.TLSConfiguration) (*tls.Config, error) { //nolint:forbidigo
if !config.TLS {
if !config.TLS && fipsEnabled {
return nil, fips.ErrTLSRequired
} else if !config.TLS {
return nil, nil
}
+2 -2
View File
@@ -45,12 +45,12 @@ func (connection *DbConnection) UnmarshalObject(data []byte, object any) error {
}
}
if e := json.Unmarshal(data, object); e != nil {
if err := json.Unmarshal(data, object); err != nil {
// Special case for the VERSION bucket. Here we're not using json
// So we need to return it as a string
s, ok := object.(*string)
if !ok {
return errors.Wrap(err, e.Error())
return errors.Wrap(err, "Failed unmarshalling object")
}
*s = string(data)
+2 -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"
@@ -29,7 +29,7 @@ func secretToEncryptionKey(passphrase string) []byte {
func Test_MarshalObjectUnencrypted(t *testing.T) {
is := assert.New(t)
uuid := uuid.Must(uuid.NewV4())
uuid := uuid.New()
tests := []struct {
object any
+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
}
+1 -1
View File
@@ -6,7 +6,7 @@ import (
var (
ErrObjectNotFound = errors.New("object not found inside the database")
ErrWrongDBEdition = errors.New("the Portainer database is set for Portainer Business Edition, please follow the instructions in our documentation to downgrade it: https://documentation.portainer.io/v2.0-be/downgrade/be-to-ce/")
ErrWrongDBEdition = errors.New("the Portainer database is set for Portainer Business Edition, please follow the instructions in our documentation to downgrade it: https://docs.portainer.io/faqs/upgrading/can-i-downgrade-from-portainer-business-to-portainer-ce")
ErrDBImportFailed = errors.New("importing backup failed")
ErrDatabaseIsUpdating = errors.New("database is currently in updating state. Failed prior upgrade. Please restore from backup or delete the database and restart Portainer")
)
+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)
+7
View File
@@ -31,6 +31,13 @@ func NewService(connection portainer.Connection) (*Service, error) {
}, nil
}
func (service *Service) Tx(tx portainer.Transaction) ServiceTx {
return ServiceTx{
service: service,
tx: tx,
}
}
// Settings retrieve the ssl settings object.
func (service *Service) Settings() (*portainer.SSLSettings, error) {
var settings portainer.SSLSettings
+31
View File
@@ -0,0 +1,31 @@
package ssl
import (
portainer "github.com/portainer/portainer/api"
)
type ServiceTx struct {
service *Service
tx portainer.Transaction
}
func (service ServiceTx) BucketName() string {
return BucketName
}
// Settings retrieve the settings object.
func (service ServiceTx) Settings() (*portainer.SSLSettings, error) {
var settings portainer.SSLSettings
err := service.tx.GetObject(BucketName, []byte(key), &settings)
if err != nil {
return nil, err
}
return &settings, nil
}
// UpdateSettings persists a Settings object.
func (service ServiceTx) UpdateSettings(settings *portainer.SSLSettings) error {
return service.tx.UpdateObject(BucketName, []byte(key), settings)
}
+2 -2
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()
+2 -2
View File
@@ -9,15 +9,15 @@ import (
"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/Masterminds/semver/v3"
"github.com/google/go-cmp/cmp"
"github.com/rs/zerolog/log"
"github.com/stretchr/testify/require"
)
func TestMigrateData(t *testing.T) {
+205
View File
@@ -0,0 +1,205 @@
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) {
var conn portainer.Connection = &boltdb.DbConnection{Path: t.TempDir()}
err := conn.Open()
require.NoError(t, err)
defer logs.CloseAndLogErr(conn)
registryService, err := registry.NewService(conn)
require.NoError(t, err)
endpointService, err := endpoint.NewService(conn)
require.NoError(t, err)
pendingActionsService, err := pendingactions.NewService(conn)
require.NoError(t, err)
t.Run("sets MigrateRegistrySASecrets flag for k8s endpoints with registry access", func(t *testing.T) {
k8sEndpoint := &portainer.Endpoint{
ID: 1,
Name: "k8s-cluster",
Type: portainer.AgentOnKubernetesEnvironment,
}
dockerEndpoint := &portainer.Endpoint{
ID: 2,
Name: "docker-standalone",
Type: portainer.DockerEnvironment,
}
err := conn.CreateObjectWithId(endpoint.BucketName, int(k8sEndpoint.ID), k8sEndpoint)
require.NoError(t, err)
err = conn.CreateObjectWithId(endpoint.BucketName, int(dockerEndpoint.ID), dockerEndpoint)
require.NoError(t, err)
reg := &portainer.Registry{
ID: 1,
Name: "test-registry",
RegistryAccesses: portainer.RegistryAccesses{
k8sEndpoint.ID: portainer.RegistryAccessPolicies{
Namespaces: []string{"default", "production"},
},
dockerEndpoint.ID: portainer.RegistryAccessPolicies{
Namespaces: []string{"ignored"},
},
},
}
err = conn.CreateObjectWithId(registry.BucketName, int(reg.ID), reg)
require.NoError(t, err)
m := NewMigrator(&MigratorParameters{
RegistryService: registryService,
EndpointService: endpointService,
PendingActionsService: pendingActionsService,
})
err = m.migrateRegistryAccessSASecrets_2_40_0()
require.NoError(t, err)
updatedK8sEndpoint, err := endpointService.Endpoint(k8sEndpoint.ID)
require.NoError(t, err)
assert.True(t, updatedK8sEndpoint.PostInitMigrations.MigrateRegistrySASecrets, "should have set MigrateRegistrySASecrets flag for k8s endpoint")
updatedDockerEndpoint, err := endpointService.Endpoint(dockerEndpoint.ID)
require.NoError(t, err)
assert.False(t, updatedDockerEndpoint.PostInitMigrations.MigrateRegistrySASecrets, "should not have set MigrateRegistrySASecrets flag for docker endpoint")
})
t.Run("skips endpoints with empty namespaces", func(t *testing.T) {
conn2 := &boltdb.DbConnection{Path: t.TempDir()}
err := conn2.Open()
require.NoError(t, err)
defer logs.CloseAndLogErr(conn2)
registryService2, _ := registry.NewService(conn2)
endpointService2, _ := endpoint.NewService(conn2)
pendingActionsService2, _ := pendingactions.NewService(conn2)
k8sEndpoint := &portainer.Endpoint{
ID: 10,
Name: "k8s-cluster",
Type: portainer.AgentOnKubernetesEnvironment,
}
err = conn2.CreateObjectWithId(endpoint.BucketName, int(k8sEndpoint.ID), k8sEndpoint)
require.NoError(t, err)
reg := &portainer.Registry{
ID: 10,
Name: "empty-registry",
RegistryAccesses: portainer.RegistryAccesses{
k8sEndpoint.ID: portainer.RegistryAccessPolicies{
Namespaces: []string{},
},
},
}
err = conn2.CreateObjectWithId(registry.BucketName, int(reg.ID), reg)
require.NoError(t, err)
m := NewMigrator(&MigratorParameters{
RegistryService: registryService2,
EndpointService: endpointService2,
PendingActionsService: pendingActionsService2,
})
err = m.migrateRegistryAccessSASecrets_2_40_0()
require.NoError(t, err)
allPAs, err := pendingActionsService2.ReadAll()
require.NoError(t, err)
assert.Empty(t, allPAs, "should not create pending actions for empty namespaces")
})
t.Run("skips non-existent endpoints", func(t *testing.T) {
conn3 := &boltdb.DbConnection{Path: t.TempDir()}
err := conn3.Open()
require.NoError(t, err)
defer logs.CloseAndLogErr(conn3)
registryService3, _ := registry.NewService(conn3)
endpointService3, _ := endpoint.NewService(conn3)
pendingActionsService3, _ := pendingactions.NewService(conn3)
reg := &portainer.Registry{
ID: 20,
Name: "orphan-registry",
RegistryAccesses: portainer.RegistryAccesses{
999: portainer.RegistryAccessPolicies{
Namespaces: []string{"default"},
},
},
}
err = conn3.CreateObjectWithId(registry.BucketName, int(reg.ID), reg)
require.NoError(t, err)
m := NewMigrator(&MigratorParameters{
RegistryService: registryService3,
EndpointService: endpointService3,
PendingActionsService: pendingActionsService3,
})
err = m.migrateRegistryAccessSASecrets_2_40_0()
require.NoError(t, err)
allPAs, err := pendingActionsService3.ReadAll()
require.NoError(t, err)
assert.Empty(t, allPAs, "should not create pending actions for non-existent endpoints")
})
t.Run("idempotent - running twice creates duplicate actions but doesn't error", func(t *testing.T) {
conn4 := &boltdb.DbConnection{Path: t.TempDir()}
err := conn4.Open()
require.NoError(t, err)
defer logs.CloseAndLogErr(conn4)
registryService4, _ := registry.NewService(conn4)
endpointService4, _ := endpoint.NewService(conn4)
pendingActionsService4, _ := pendingactions.NewService(conn4)
k8sEndpoint := &portainer.Endpoint{
ID: 30,
Name: "k8s-cluster",
Type: portainer.AgentOnKubernetesEnvironment,
}
err = conn4.CreateObjectWithId(endpoint.BucketName, int(k8sEndpoint.ID), k8sEndpoint)
require.NoError(t, err)
reg := &portainer.Registry{
ID: 30,
Name: "test-registry",
RegistryAccesses: portainer.RegistryAccesses{
k8sEndpoint.ID: portainer.RegistryAccessPolicies{
Namespaces: []string{"default"},
},
},
}
err = conn4.CreateObjectWithId(registry.BucketName, int(reg.ID), reg)
require.NoError(t, err)
m := NewMigrator(&MigratorParameters{
RegistryService: registryService4,
EndpointService: endpointService4,
PendingActionsService: pendingActionsService4,
})
err = m.migrateRegistryAccessSASecrets_2_40_0()
require.NoError(t, err)
err = m.migrateRegistryAccessSASecrets_2_40_0()
require.NoError(t, err)
})
}
+2 -2
View File
@@ -7,7 +7,7 @@ import (
"github.com/pkg/errors"
portainer "github.com/portainer/portainer/api"
"github.com/Masterminds/semver"
"github.com/Masterminds/semver/v3"
"github.com/rs/zerolog/log"
)
@@ -95,7 +95,7 @@ func (m *Migrator) NeedsMigration() bool {
// In this particular instance we should log a fatal error
if m.CurrentDBEdition() != portainer.PortainerCE {
log.Fatal().Msg("the Portainer database is set for Portainer Business Edition, please follow the instructions in our documentation to downgrade it: https://documentation.portainer.io/v2.0-be/downgrade/be-to-ce/")
log.Fatal().Msg("the Portainer database is set for Portainer Business Edition, please follow the instructions in our documentation to downgrade it: https://docs.portainer.io/faqs/upgrading/can-i-downgrade-from-portainer-business-to-portainer-ce")
return false
}
@@ -0,0 +1,58 @@
package migrator
import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/internal/endpointutils"
"github.com/rs/zerolog/log"
)
// migrateRegistryAccessSASecrets_2_40_0 marks Kubernetes endpoints that have
// registry access configured so that imagePullSecrets can be added to their
// default ServiceAccounts during the post-init migration phase (when cluster
// access is available).
func (m *Migrator) migrateRegistryAccessSASecrets_2_40_0() error {
log.Info().Msg("migrating registry access service account secrets")
registries, err := m.registryService.ReadAll()
if err != nil {
return err
}
endpoints, err := m.endpointService.Endpoints()
if err != nil {
return err
}
// Collect the IDs of endpoints that have at least one registry with
// non-empty namespace access - these need the SA imagePullSecrets migration.
needsMigration := make(map[portainer.EndpointID]bool)
for _, registry := range registries {
for endpointID, access := range registry.RegistryAccesses {
if len(access.Namespaces) > 0 {
needsMigration[endpointID] = true
}
}
}
for i := range endpoints {
endpoint := &endpoints[i]
if !endpointutils.IsKubernetesEndpoint(endpoint) {
continue
}
if !needsMigration[endpoint.ID] {
continue
}
endpoint.PostInitMigrations.MigrateRegistrySASecrets = true
if err := m.endpointService.UpdateEndpoint(endpoint.ID, endpoint); err != nil {
log.Warn().
Err(err).
Int("endpointID", int(endpoint.ID)).
Msg("failed to set registry SA secret migration flag for endpoint")
}
}
return nil
}
+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...
+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
}
+3 -1
View File
@@ -74,7 +74,9 @@ func (tx *StoreTx) Snapshot() dataservices.SnapshotService {
return tx.store.SnapshotService.Tx(tx.tx)
}
func (tx *StoreTx) SSLSettings() dataservices.SSLSettingsService { return nil }
func (tx *StoreTx) SSLSettings() dataservices.SSLSettingsService {
return tx.store.SSLSettingsService.Tx(tx.tx)
}
func (tx *StoreTx) Stack() dataservices.StackService {
return tx.store.StackService.Tx(tx.tx)
@@ -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.38.0",
"KubectlShellImage": "portainer/kubectl-shell:2.40.0",
"LDAPSettings": {
"AnonymousMode": true,
"AutoCreateUsers": true,
@@ -942,7 +944,7 @@
}
],
"version": {
"VERSION": "{\"SchemaVersion\":\"2.38.0\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
"VERSION": "{\"SchemaVersion\":\"2.40.0\",\"MigratorCount\":1,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
},
"webhooks": null
}
+1 -1
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"
+14 -1
View File
@@ -6,6 +6,7 @@ import (
"strings"
"sync"
"github.com/containerd/containerd/errdefs"
"github.com/docker/docker/api/types/container"
)
@@ -35,8 +36,10 @@ func CalculateContainerStats(ctx context.Context, cli DockerClient, isSwarm bool
var aggErr error
var aggMu sync.Mutex
var processedCount int
for i := range containers {
id := containers[i].ID
semaphore <- struct{}{}
wg.Go(func() {
defer func() { <-semaphore }()
@@ -44,8 +47,17 @@ func CalculateContainerStats(ctx context.Context, cli DockerClient, isSwarm bool
containerInspection, err := cli.ContainerInspect(ctx, id)
stat := ContainerStats{}
if err != nil {
if errdefs.IsNotFound(err) {
// An edge case is reported that Docker can list containers with no names,
// but when inspecting a container with specific ID and it is not found.
// In this case, we can safely ignore the error.
// ref@https://linear.app/portainer/issue/BE-12567/500-error-when-loading-docker-dashboard-in-portainer
return
}
aggMu.Lock()
aggErr = errors.Join(aggErr, err)
processedCount++
aggMu.Unlock()
return
}
@@ -56,6 +68,7 @@ func CalculateContainerStats(ctx context.Context, cli DockerClient, isSwarm bool
stopped += stat.Stopped
healthy += stat.Healthy
unhealthy += stat.Unhealthy
processedCount++
mu.Unlock()
})
}
@@ -67,7 +80,7 @@ func CalculateContainerStats(ctx context.Context, cli DockerClient, isSwarm bool
Stopped: stopped,
Healthy: healthy,
Unhealthy: unhealthy,
Total: len(containers),
Total: processedCount,
}, aggErr
}
+11 -13
View File
@@ -3,9 +3,11 @@ package stats
import (
"context"
"errors"
"fmt"
"testing"
"time"
"github.com/containerd/containerd/errdefs"
"github.com/docker/docker/api/types/container"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
@@ -37,6 +39,7 @@ func TestCalculateContainerStats(t *testing.T) {
{ID: "container8"},
{ID: "container9"},
{ID: "container10"},
{ID: "container11"},
}
// Setup mock expectations with different container states to test various scenarios
@@ -58,7 +61,6 @@ func TestCalculateContainerStats(t *testing.T) {
{"container10", container.StateDead, nil, ContainerStats{Running: 0, Stopped: 1, Healthy: 0, Unhealthy: 0}},
}
expected := ContainerStats{}
// Setup mock expectations for all containers with artificial delays to simulate real Docker calls
for _, state := range containerStates {
mockClient.On("ContainerInspect", mock.Anything, state.id).Return(container.InspectResponse{
@@ -68,15 +70,12 @@ func TestCalculateContainerStats(t *testing.T) {
Health: state.health,
},
},
}, nil).After(50 * time.Millisecond) // Simulate 50ms Docker API call
expected.Running += state.expected.Running
expected.Stopped += state.expected.Stopped
expected.Healthy += state.expected.Healthy
expected.Unhealthy += state.expected.Unhealthy
expected.Total++
}, nil).After(30 * time.Millisecond) // Simulate 30ms Docker API call
}
// Setup mock expectation for a container that returns NotFound error
mockClient.On("ContainerInspect", mock.Anything, "container11").Return(container.InspectResponse{}, fmt.Errorf("No such container: %w", errdefs.ErrNotFound)).After(50 * time.Millisecond)
// Call the function and measure time
startTime := time.Now()
stats, err := CalculateContainerStats(context.Background(), mockClient, false, containers)
@@ -84,11 +83,10 @@ func TestCalculateContainerStats(t *testing.T) {
duration := time.Since(startTime)
// Assert results
assert.Equal(t, expected, stats)
assert.Equal(t, expected.Running, stats.Running)
assert.Equal(t, expected.Stopped, stats.Stopped)
assert.Equal(t, expected.Healthy, stats.Healthy)
assert.Equal(t, expected.Unhealthy, stats.Unhealthy)
assert.Equal(t, 6, stats.Running)
assert.Equal(t, 4, stats.Stopped)
assert.Equal(t, 2, stats.Healthy)
assert.Equal(t, 2, stats.Unhealthy)
assert.Equal(t, 10, stats.Total)
// Verify concurrent processing by checking that all mock calls were made
+6
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
@@ -77,6 +80,9 @@ type (
// CreatedByUserId is the user ID that created this stack
// Used for adding labels to Kubernetes manifests
CreatedByUserId string
// HelmConfig represents the Helm configuration for an edge stack
HelmConfig portainer.HelmConfig
}
DeployerOptionsPayload struct {
+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")
}
+1 -1
View File
@@ -112,7 +112,7 @@ func (deployer *KubernetesDeployer) command(operation string, userID portainer.U
operations := map[string]func(context.Context, []string) (string, error){
"apply": client.ApplyDynamic,
"delete": client.Delete,
"delete": client.DeleteDynamic,
}
operationFunc, ok := operations[operation]
+2 -2
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"
)
@@ -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
}
@@ -223,3 +223,15 @@ 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) {
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)
}
+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)
}
}
+49 -63
View File
@@ -65,7 +65,6 @@ func TestService_ClonePublicRepository_Azure(t *testing.T) {
tt.args.referenceName,
"",
"",
gittypes.GitCredentialAuthType_Basic,
false,
)
require.NoError(t, err)
@@ -88,7 +87,6 @@ func TestService_ClonePrivateRepository_Azure(t *testing.T) {
"refs/heads/main",
"",
pat,
gittypes.GitCredentialAuthType_Basic,
false,
)
require.NoError(t, err)
@@ -106,7 +104,6 @@ func TestService_LatestCommitID_Azure(t *testing.T) {
"refs/heads/main",
"",
pat,
gittypes.GitCredentialAuthType_Basic,
false,
)
require.NoError(t, err)
@@ -124,7 +121,6 @@ func TestService_ListRefs_Azure(t *testing.T) {
privateAzureRepoURL,
username,
accessToken,
gittypes.GitCredentialAuthType_Basic,
false,
false,
)
@@ -140,10 +136,10 @@ func TestService_ListRefs_Azure_Concurrently(t *testing.T) {
service := newService(context.TODO(), repositoryCacheSize, 200*time.Millisecond)
go func() {
_, _ = service.ListRefs(privateAzureRepoURL, username, accessToken, gittypes.GitCredentialAuthType_Basic, false, false)
_, _ = service.ListRefs(privateAzureRepoURL, username, accessToken, false, false)
}()
_, err := service.ListRefs(privateAzureRepoURL, username, accessToken, gittypes.GitCredentialAuthType_Basic, false, false)
_, err := service.ListRefs(privateAzureRepoURL, username, accessToken, false, false)
require.NoError(t, err)
time.Sleep(2 * time.Second)
@@ -152,6 +148,14 @@ func TestService_ListRefs_Azure_Concurrently(t *testing.T) {
func TestService_ListFiles_Azure(t *testing.T) {
ensureIntegrationTest(t)
type args struct {
repositoryUrl string
referenceName string
username string
password string
extensions []string
}
type expectResult struct {
shouldFail bool
err error
@@ -163,22 +167,19 @@ func TestService_ListFiles_Azure(t *testing.T) {
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 +187,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 +201,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 +215,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 +229,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 +243,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,
@@ -288,10 +277,9 @@ func TestService_ListFiles_Azure(t *testing.T) {
tt.args.referenceName,
tt.args.username,
tt.args.password,
gittypes.GitCredentialAuthType_Basic,
false,
false,
tt.extensions,
tt.args.extensions,
false,
)
@@ -323,7 +311,6 @@ func TestService_ListFiles_Azure_Concurrently(t *testing.T) {
"refs/heads/main",
username,
accessToken,
gittypes.GitCredentialAuthType_Basic,
false,
false,
[]string{},
@@ -336,7 +323,6 @@ func TestService_ListFiles_Azure_Concurrently(t *testing.T) {
"refs/heads/main",
username,
accessToken,
gittypes.GitCredentialAuthType_Basic,
false,
false,
[]string{},
+93 -75
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"
@@ -234,7 +237,7 @@ 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))
})
}
}
@@ -243,7 +246,9 @@ func Test_azureDownloader_downloadZipFromAzureDevOps(t *testing.T) {
fips.InitFIPS(false)
type args struct {
options baseOption
repositoryUrl string
username string
password string
}
type basicAuth struct {
username, password string
@@ -256,9 +261,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 +271,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 +283,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,10 +302,14 @@ 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,
}
if tt.args.username != "" || tt.args.password != "" {
option.Auth = &githttp.BasicAuth{
Username: tt.args.username,
Password: tt.args.password,
}
}
_, err := a.downloadZipFromAzureDevOps(context.Background(), option)
require.Error(t, err)
@@ -340,18 +343,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 +367,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(context.Background(), 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,22 +381,23 @@ 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) {
tests := []struct {
name string
@@ -420,15 +427,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("", tt.url, "", "", "", false)
require.NoError(t, err)
// if azure API is called, git isn't and vice versa
@@ -443,6 +442,12 @@ func Test_listRefs_azure(t *testing.T) {
client := NewAzureClient()
type args struct {
repositoryUrl string
username string
password string
}
type expectResult struct {
err error
refsCount int
@@ -453,12 +458,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 +475,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 +486,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 +497,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 +510,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(context.TODO(), tt.args.repositoryUrl, option)
if tt.expect.err == nil {
require.NoError(t, err)
if tt.expect.refsCount > 0 {
@@ -517,7 +529,6 @@ func Test_listRefs_azure(t *testing.T) {
}
})
}
}
func Test_listFiles_azure(t *testing.T) {
@@ -525,6 +536,13 @@ func Test_listFiles_azure(t *testing.T) {
client := NewAzureClient()
type args struct {
repositoryUrl string
referenceName string
username string
password string
}
type expectResult struct {
shouldFail bool
err error
@@ -535,18 +553,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 +571,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 +584,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 +597,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 +609,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 +624,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(context.TODO(), false, option)
if tt.expect.shouldFail {
require.Error(t, err)
if tt.expect.err != nil {
-2
View File
@@ -19,7 +19,6 @@ 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"`
}
@@ -49,7 +48,6 @@ func CloneWithBackup(gitService portainer.GitService, fileService portainer.File
options.ReferenceName,
options.Username,
options.Password,
options.AuthType,
options.TLSSkipVerify,
); err != nil {
cleanUp = false
+12 -89
View File
@@ -11,11 +11,8 @@ import (
"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/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"
"github.com/go-git/go-git/v5/storage/memory"
"github.com/pkg/errors"
)
@@ -30,21 +27,8 @@ 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 {
_, err := git.PlainCloneContext(ctx, dst, false, opt)
if err != nil {
if err.Error() == "authentication required" {
return gittypes.ErrAuthenticationFailure
@@ -62,18 +46,13 @@ func (c *gitClient) download(ctx context.Context, dst string, opt cloneOption) e
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
@@ -81,7 +60,6 @@ func (c *gitClient) latestCommitID(ctx context.Context, opt fetchOption) (string
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 +74,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)
}
@@ -166,19 +100,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 +133,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)
}
}
+57 -76
View File
@@ -34,7 +34,6 @@ func TestService_ClonePrivateRepository_GitHub(t *testing.T) {
"refs/heads/main",
username,
accessToken,
gittypes.GitCredentialAuthType_Basic,
false,
)
require.NoError(t, err)
@@ -54,7 +53,6 @@ func TestService_LatestCommitID_GitHub(t *testing.T) {
"refs/heads/main",
username,
accessToken,
gittypes.GitCredentialAuthType_Basic,
false,
)
require.NoError(t, err)
@@ -69,7 +67,7 @@ func TestService_ListRefs_GitHub(t *testing.T) {
service := newService(context.TODO(), 0, 0)
repositoryUrl := privateGitRepoURL
refs, err := service.ListRefs(repositoryUrl, username, accessToken, gittypes.GitCredentialAuthType_Basic, false, false)
refs, err := service.ListRefs(repositoryUrl, username, accessToken, false, false)
require.NoError(t, err)
assert.GreaterOrEqual(t, len(refs), 1)
}
@@ -83,10 +81,10 @@ func TestService_ListRefs_Github_Concurrently(t *testing.T) {
repositoryUrl := privateGitRepoURL
go func() {
_, _ = service.ListRefs(repositoryUrl, username, accessToken, gittypes.GitCredentialAuthType_Basic, false, false)
_, _ = service.ListRefs(repositoryUrl, username, accessToken, false, false)
}()
_, err := service.ListRefs(repositoryUrl, username, accessToken, gittypes.GitCredentialAuthType_Basic, false, false)
_, err := service.ListRefs(repositoryUrl, username, accessToken, false, false)
require.NoError(t, err)
time.Sleep(2 * time.Second)
@@ -95,6 +93,14 @@ func TestService_ListRefs_Github_Concurrently(t *testing.T) {
func TestService_ListFiles_GitHub(t *testing.T) {
ensureIntegrationTest(t)
type args struct {
repositoryUrl string
referenceName string
username string
password string
extensions []string
}
type expectResult struct {
shouldFail bool
err error
@@ -105,22 +111,19 @@ func TestService_ListFiles_GitHub(t *testing.T) {
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 +131,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 +145,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 +159,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 +173,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 +187,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,
@@ -230,10 +221,9 @@ func TestService_ListFiles_GitHub(t *testing.T) {
tt.args.referenceName,
tt.args.username,
tt.args.password,
gittypes.GitCredentialAuthType_Basic,
false,
false,
tt.extensions,
tt.args.extensions,
false,
)
if tt.expect.shouldFail {
@@ -265,7 +255,6 @@ func TestService_ListFiles_Github_Concurrently(t *testing.T) {
"refs/heads/main",
username,
accessToken,
gittypes.GitCredentialAuthType_Basic,
false,
false,
[]string{},
@@ -278,7 +267,6 @@ func TestService_ListFiles_Github_Concurrently(t *testing.T) {
"refs/heads/main",
username,
accessToken,
gittypes.GitCredentialAuthType_Basic,
false,
false,
[]string{},
@@ -297,7 +285,7 @@ func TestService_purgeCache_Github(t *testing.T) {
username := getRequiredValue(t, "GITHUB_USERNAME")
service := NewService(context.TODO())
_, err := service.ListRefs(repositoryUrl, username, accessToken, gittypes.GitCredentialAuthType_Basic, false, false)
_, err := service.ListRefs(repositoryUrl, username, accessToken, false, false)
require.NoError(t, err)
_, err = service.ListFiles(
@@ -305,7 +293,6 @@ func TestService_purgeCache_Github(t *testing.T) {
"refs/heads/main",
username,
accessToken,
gittypes.GitCredentialAuthType_Basic,
false,
false,
[]string{},
@@ -331,14 +318,13 @@ func TestService_purgeCacheByTTL_Github(t *testing.T) {
// 40*timeout is designed for giving enough time for ListRefs and ListFiles to cache the result
service := newService(context.TODO(), 2, 40*timeout)
_, err := service.ListRefs(repositoryUrl, username, accessToken, gittypes.GitCredentialAuthType_Basic, false, false)
_, err := service.ListRefs(repositoryUrl, username, accessToken, false, false)
require.NoError(t, err)
_, err = service.ListFiles(
repositoryUrl,
"refs/heads/main",
username,
accessToken,
gittypes.GitCredentialAuthType_Basic,
false,
false,
[]string{},
@@ -375,12 +361,12 @@ func TestService_HardRefresh_ListRefs_GitHub(t *testing.T) {
service := newService(context.TODO(), 2, 0)
repositoryUrl := privateGitRepoURL
refs, err := service.ListRefs(repositoryUrl, username, accessToken, gittypes.GitCredentialAuthType_Basic, false, false)
refs, err := service.ListRefs(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(repositoryUrl, username, "fake-token", false, false)
require.Error(t, err)
assert.Equal(t, 1, service.repoRefCache.Len())
}
@@ -393,7 +379,7 @@ func TestService_HardRefresh_ListRefs_And_RemoveAllCaches_GitHub(t *testing.T) {
service := newService(context.TODO(), 2, 0)
repositoryUrl := privateGitRepoURL
refs, err := service.ListRefs(repositoryUrl, username, accessToken, gittypes.GitCredentialAuthType_Basic, false, false)
refs, err := service.ListRefs(repositoryUrl, username, accessToken, false, false)
require.NoError(t, err)
assert.GreaterOrEqual(t, len(refs), 1)
assert.Equal(t, 1, service.repoRefCache.Len())
@@ -403,7 +389,6 @@ func TestService_HardRefresh_ListRefs_And_RemoveAllCaches_GitHub(t *testing.T) {
"refs/heads/main",
username,
accessToken,
gittypes.GitCredentialAuthType_Basic,
false,
false,
[]string{},
@@ -418,7 +403,6 @@ func TestService_HardRefresh_ListRefs_And_RemoveAllCaches_GitHub(t *testing.T) {
"refs/heads/test",
username,
accessToken,
gittypes.GitCredentialAuthType_Basic,
false,
false,
[]string{},
@@ -428,11 +412,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(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(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
@@ -451,7 +435,6 @@ func TestService_HardRefresh_ListFiles_GitHub(t *testing.T) {
"refs/heads/main",
username,
accessToken,
gittypes.GitCredentialAuthType_Basic,
false,
false,
[]string{},
@@ -466,7 +449,6 @@ func TestService_HardRefresh_ListFiles_GitHub(t *testing.T) {
"refs/heads/main",
username,
"fake-token",
gittypes.GitCredentialAuthType_Basic,
false,
true,
[]string{},
@@ -495,7 +477,6 @@ func TestService_CloneRepository_TokenAuth(t *testing.T) {
"refs/heads/main",
username,
accessToken,
gittypes.GitCredentialAuthType_Token,
false,
)
+65 -67
View File
@@ -10,7 +10,9 @@ import (
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"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"
@@ -20,7 +22,7 @@ func setup(t *testing.T) string {
dir := t.TempDir()
bareRepoDir := filepath.Join(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"))
}
@@ -39,7 +41,7 @@ func Test_ClonePublicRepository_Shallow(t *testing.T) {
dir := t.TempDir()
t.Logf("Cloning into %s", dir)
err := service.CloneRepository(dir, repositoryURL, referenceName, "", "", gittypes.GitCredentialAuthType_Basic, false)
err := service.CloneRepository(dir, repositoryURL, referenceName, "", "", false)
require.NoError(t, err)
assert.Equal(t, 1, getCommitHistoryLength(t, dir), "cloned repo has incorrect depth")
}
@@ -51,41 +53,18 @@ func Test_ClonePublicRepository_NoGitDirectory(t *testing.T) {
dir := t.TempDir()
t.Logf("Cloning into %s", dir)
err := service.CloneRepository(dir, repositoryURL, referenceName, "", "", gittypes.GitCredentialAuthType_Basic, false)
err := service.CloneRepository(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")
}
func Test_latestCommitID(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"
id, err := service.LatestCommitID(repositoryURL, referenceName, "", "", gittypes.GitCredentialAuthType_Basic, false)
id, err := service.LatestCommitID(repositoryURL, referenceName, "", "", false)
require.NoError(t, err)
assert.Equal(t, "68dcaa7bd452494043c64252ab90db0f98ecf8d2", id)
@@ -96,7 +75,7 @@ func Test_ListRefs(t *testing.T) {
repositoryURL := setup(t)
fs, err := service.ListRefs(repositoryURL, "", "", gittypes.GitCredentialAuthType_Basic, false, false)
fs, err := service.ListRefs(repositoryURL, "", "", false, false)
require.NoError(t, err)
assert.Equal(t, []string{"refs/heads/main"}, fs)
@@ -113,7 +92,6 @@ func Test_ListFiles(t *testing.T) {
referenceName,
"",
"",
gittypes.GitCredentialAuthType_Basic,
false,
false,
[]string{".yml"},
@@ -154,6 +132,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 +145,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 +162,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 +173,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 +184,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 +197,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(context.TODO(), tt.args.repositoryUrl, option)
if tt.expect.err == nil {
require.NoError(t, err)
if tt.expect.refsCount > 0 {
@@ -232,6 +223,13 @@ func Test_listFilesPrivateRepository(t *testing.T) {
client := NewGitClient(false)
type args struct {
repositoryUrl string
referenceName string
username string
password string
}
type expectResult struct {
shouldFail bool
err error
@@ -243,18 +241,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 +259,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 +272,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 +285,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 +297,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 +312,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(context.TODO(), false, option)
if tt.expect.shouldFail {
require.Error(t, err)
if tt.expect.err != nil {
+55 -85
View File
@@ -7,8 +7,10 @@ import (
"sync"
"time"
"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"
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,40 +20,18 @@ 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
azure RepoManager
git RepoManager
timerStopped bool
mut sync.Mutex
@@ -131,61 +111,47 @@ func (service *Service) CloneRepository(
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,
gitOptions := &git.CloneOptions{
URL: repositoryURL,
Depth: 1,
InsecureSkipTLS: tlsSkipVerify,
Auth: GetBasicAuth(username, password),
Tags: git.NoTags,
}
return service.cloneRepository(destination, options)
if referenceName != "" {
gitOptions.ReferenceName = plumbing.ReferenceName(referenceName)
}
return service.repoManager(repositoryURL).Download(context.TODO(), destination, gitOptions)
}
func (service *Service) repoManager(options baseOption) repoManager {
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(
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,
listOptions := &git.ListOptions{
Auth: GetBasicAuth(username, password),
InsecureSkipTLS: tlsSkipVerify,
}
return service.repoManager(options.baseOption).latestCommitID(context.TODO(), options)
return service.repoManager(repositoryURL).LatestCommitID(context.TODO(), repositoryURL, referenceName, listOptions)
}
// ListRefs will list target repository's references without cloning the repository
@@ -193,7 +159,6 @@ func (service *Service) ListRefs(
repositoryURL,
username,
password string,
authType gittypes.GitCredentialAuthType,
hardRefresh bool,
tlsSkipVerify bool,
) ([]string, error) {
@@ -218,15 +183,12 @@ func (service *Service) ListRefs(
}
}
options := baseOption{
repositoryUrl: repositoryURL,
username: username,
password: password,
authType: authType,
tlsSkipVerify: tlsSkipVerify,
options := &git.ListOptions{
Auth: GetBasicAuth(username, password),
InsecureSkipTLS: tlsSkipVerify,
}
refs, err := service.repoManager(options).listRefs(context.TODO(), options)
refs, err := service.repoManager(repositoryURL).ListRefs(context.TODO(), repositoryURL, options)
if err != nil {
return nil, err
}
@@ -247,7 +209,6 @@ func (service *Service) ListFiles(
referenceName,
username,
password string,
authType gittypes.GitCredentialAuthType,
dirOnly,
hardRefresh bool,
includedExts []string,
@@ -259,7 +220,6 @@ func (service *Service) ListFiles(
username,
password,
strconv.FormatBool(tlsSkipVerify),
strconv.Itoa(int(authType)),
strconv.FormatBool(dirOnly),
)
@@ -269,7 +229,6 @@ func (service *Service) ListFiles(
referenceName,
username,
password,
authType,
dirOnly,
hardRefresh,
tlsSkipVerify,
@@ -284,7 +243,6 @@ func (service *Service) listFiles(
referenceName,
username,
password string,
authType gittypes.GitCredentialAuthType,
dirOnly,
hardRefresh bool,
tlsSkipVerify bool,
@@ -295,7 +253,6 @@ func (service *Service) listFiles(
username,
password,
strconv.FormatBool(tlsSkipVerify),
strconv.Itoa(int(authType)),
strconv.FormatBool(dirOnly),
)
@@ -313,19 +270,18 @@ func (service *Service) listFiles(
}
}
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: GetBasicAuth(username, password),
InsecureSkipTLS: tlsSkipVerify,
Tags: git.NoTags,
}
files, err := service.repoManager(options.baseOption).listFiles(context.TODO(), options)
files, err := service.repoManager(repositoryURL).ListFiles(context.TODO(), dirOnly, cloneOption)
if err != nil {
return nil, err
}
@@ -380,3 +336,17 @@ func filterFiles(paths []string, includedExts []string) []string {
return includedFiles
}
func GetBasicAuth(username, password string) *githttp.BasicAuth {
if password != "" {
if username == "" {
username = "token"
}
return &githttp.BasicAuth{
Username: username,
Password: password,
}
}
return nil
}
+3 -11
View File
@@ -9,13 +9,6 @@ var (
ErrAuthenticationFailure = errors.New("authentication failed, please ensure that the git credentials are correct")
)
type GitCredentialAuthType int
const (
GitCredentialAuthType_Basic GitCredentialAuthType = iota
GitCredentialAuthType_Token
)
// RepoConfig represents a configuration for a repo
type RepoConfig struct {
// The repo url
@@ -33,11 +26,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"`
}
-5
View File
@@ -34,7 +34,6 @@ func UpdateGitObject(gitService portainer.GitService, objId string, gitConfig *g
gitConfig.ReferenceName,
username,
password,
gittypes.GitCredentialAuthType_Basic,
gitConfig.TLSSkipVerify,
)
if err != nil {
@@ -69,7 +68,6 @@ func UpdateGitObject(gitService portainer.GitService, objId string, gitConfig *g
cloneParams.auth = &gitAuth{
username: username,
password: password,
authType: gitConfig.Authentication.AuthorizationType,
}
}
@@ -97,7 +95,6 @@ type cloneRepositoryParameters struct {
}
type gitAuth struct {
authType gittypes.GitCredentialAuthType
username string
password string
}
@@ -110,7 +107,6 @@ func cloneGitRepository(gitService portainer.GitService, cloneParams *cloneRepos
cloneParams.ref,
cloneParams.auth.username,
cloneParams.auth.password,
cloneParams.auth.authType,
cloneParams.tlsSkipVerify,
)
}
@@ -121,7 +117,6 @@ func cloneGitRepository(gitService portainer.GitService, cloneParams *cloneRepos
cloneParams.ref,
"",
"",
gittypes.GitCredentialAuthType_Basic,
cloneParams.tlsSkipVerify,
)
}
-23
View File
@@ -1,23 +0,0 @@
package git
import (
gittypes "github.com/portainer/portainer/api/git/types"
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/pkg/validate"
)
func ValidateRepoConfig(repoConfig *gittypes.RepoConfig) error {
if len(repoConfig.URL) == 0 || !validate.IsURL(repoConfig.URL) {
return httperrors.NewInvalidPayloadError("Invalid repository URL. Must correspond to a valid URL format")
}
return ValidateRepoAuthentication(repoConfig.Authentication)
}
func ValidateRepoAuthentication(auth *gittypes.GitAuthentication) error {
if auth != nil && len(auth.Password) == 0 && auth.GitCredentialID == 0 {
return httperrors.NewInvalidPayloadError("Invalid repository credentials. Password or GitCredentialID must be specified when authentication is enabled")
}
return nil
}
+4 -3
View File
@@ -1,6 +1,7 @@
package auth
import (
"context"
"errors"
"net/http"
@@ -25,7 +26,7 @@ func (payload *oauthPayload) Validate(r *http.Request) error {
return nil
}
func (handler *Handler) authenticateOAuth(code string, settings *portainer.OAuthSettings) (string, error) {
func (handler *Handler) authenticateOAuth(ctx context.Context, code string, settings *portainer.OAuthSettings) (string, error) {
if code == "" {
return "", errors.New("Invalid OAuth authorization code")
}
@@ -34,7 +35,7 @@ func (handler *Handler) authenticateOAuth(code string, settings *portainer.OAuth
return "", errors.New("Invalid OAuth configuration")
}
username, err := handler.OAuthService.Authenticate(code, settings)
username, err := handler.OAuthService.Authenticate(ctx, code, settings)
if err != nil {
return "", err
}
@@ -70,7 +71,7 @@ func (handler *Handler) validateOAuth(w http.ResponseWriter, r *http.Request) *h
return httperror.Forbidden("OAuth authentication is not enabled", errors.New("OAuth authentication is not enabled"))
}
username, err := handler.authenticateOAuth(payload.Code, &settings.OAuthSettings)
username, err := handler.authenticateOAuth(r.Context(), payload.Code, &settings.OAuthSettings)
if err != nil {
log.Debug().Err(err).Msg("OAuth authentication error")
@@ -2,8 +2,14 @@ package customtemplates
import (
"net/http"
"strconv"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/slicesx"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
@@ -33,11 +39,46 @@ func (handler *Handler) customTemplateFile(w http.ResponseWriter, r *http.Reques
return httperror.BadRequest("Invalid custom template identifier route variable", err)
}
customTemplate, err := handler.DataStore.CustomTemplate().Read(portainer.CustomTemplateID(customTemplateID))
if handler.DataStore.IsErrObjectNotFound(err) {
return httperror.NotFound("Unable to find a custom template with the specified identifier inside the database", err)
} else if err != nil {
return httperror.InternalServerError("Unable to find a custom template with the specified identifier inside the database", err)
var customTemplate *portainer.CustomTemplate
if err := handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
var err error
customTemplate, err = tx.CustomTemplate().Read(portainer.CustomTemplateID(customTemplateID))
if tx.IsErrObjectNotFound(err) {
return httperror.NotFound("Unable to find a custom template with the specified identifier inside the database", err)
} else if err != nil {
return httperror.InternalServerError("Unable to find a custom template with the specified identifier inside the database", err)
}
resourceControl, err := tx.ResourceControl().ResourceControlByResourceIDAndType(strconv.Itoa(customTemplateID), portainer.CustomTemplateResourceControl)
if err != nil {
return httperror.InternalServerError("Unable to retrieve a resource control associated to the custom template", err)
}
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve user info from request context", err)
}
canEdit := userCanEditTemplate(customTemplate, securityContext)
hasAccess := false
if resourceControl != nil {
customTemplate.ResourceControl = resourceControl
teamIDs := slicesx.Map(securityContext.UserMemberships, func(m portainer.TeamMembership) portainer.TeamID {
return m.TeamID
})
hasAccess = authorization.UserCanAccessResource(securityContext.UserID, teamIDs, resourceControl)
}
if canEdit || hasAccess {
return nil
}
return httperror.Forbidden("Access denied to resource", httperrors.ErrResourceAccessDenied)
}); err != nil {
return response.TxErrorResponse(err)
}
entryPath := customTemplate.EntryPoint
@@ -0,0 +1,115 @@
package customtemplates
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gorilla/mux"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/filesystem"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/testhelpers"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/segmentio/encoding/json"
"github.com/stretchr/testify/require"
)
func TestCustomTemplateFile(t *testing.T) {
_, ds := datastore.MustNewTestStore(t, true, false)
require.NotNil(t, ds)
fs, err := filesystem.NewService(t.TempDir(), t.TempDir())
require.NoError(t, err)
templateContent := "some template content"
templateEntrypoint := "entrypoint"
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
require.NoError(t, tx.User().Create(&portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole}))
require.NoError(t, tx.User().Create(&portainer.User{ID: 2, Username: "std2", Role: portainer.StandardUserRole}))
require.NoError(t, tx.User().Create(&portainer.User{ID: 3, Username: "std3", Role: portainer.StandardUserRole}))
require.NoError(t, tx.User().Create(&portainer.User{ID: 4, Username: "std4", Role: portainer.StandardUserRole}))
require.NoError(t, tx.Endpoint().Create(&portainer.Endpoint{ID: 1,
UserAccessPolicies: portainer.UserAccessPolicies{
2: portainer.AccessPolicy{RoleID: 0},
3: portainer.AccessPolicy{RoleID: 0},
}}))
require.NoError(t, tx.Team().Create(&portainer.Team{ID: 1}))
require.NoError(t, tx.TeamMembership().Create(&portainer.TeamMembership{ID: 1, UserID: 3, TeamID: 1, Role: portainer.TeamMember}))
// template 1
path, err := fs.StoreCustomTemplateFileFromBytes("1", templateEntrypoint, []byte(templateContent))
require.NoError(t, err)
require.NoError(t, tx.CustomTemplate().Create(&portainer.CustomTemplate{ID: 1, EntryPoint: templateEntrypoint, ProjectPath: path}))
// template 2
path, err = fs.StoreCustomTemplateFileFromBytes("2", templateEntrypoint, []byte(templateContent))
require.NoError(t, err)
require.NoError(t, tx.CustomTemplate().Create(&portainer.CustomTemplate{ID: 2, EntryPoint: templateEntrypoint, ProjectPath: path}))
require.NoError(t, tx.ResourceControl().Create(&portainer.ResourceControl{ID: 1, ResourceID: "2", Type: portainer.CustomTemplateResourceControl,
UserAccesses: []portainer.UserResourceAccess{{UserID: 2}},
TeamAccesses: []portainer.TeamResourceAccess{{TeamID: 1}},
}))
return nil
}))
handler := NewHandler(testhelpers.NewTestRequestBouncer(), ds, fs, nil)
test := func(templateID string, restrictedContext *security.RestrictedRequestContext) (*httptest.ResponseRecorder, *httperror.HandlerError) {
r := httptest.NewRequest(http.MethodGet, "/custom_templates/"+templateID+"/file", nil)
r = mux.SetURLVars(r, map[string]string{"id": templateID})
ctx := security.StoreRestrictedRequestContext(r, restrictedContext)
r = r.WithContext(ctx)
rr := httptest.NewRecorder()
return rr, handler.customTemplateFile(rr, r)
}
t.Run("unknown id should get not found error", func(t *testing.T) {
_, r := test("0", &security.RestrictedRequestContext{UserID: 1})
require.NotNil(t, r)
require.Equal(t, http.StatusNotFound, r.StatusCode)
})
t.Run("admin should access adminonly template", func(t *testing.T) {
rr, r := test("1", &security.RestrictedRequestContext{UserID: 1, IsAdmin: true})
require.Nil(t, r)
require.Equal(t, http.StatusOK, rr.Result().StatusCode)
var res struct{ FileContent string }
require.NoError(t, json.NewDecoder(rr.Body).Decode(&res))
require.Equal(t, templateContent, res.FileContent)
})
t.Run("std should not access adminonly template", func(t *testing.T) {
_, r := test("1", &security.RestrictedRequestContext{UserID: 2})
require.NotNil(t, r)
require.Equal(t, http.StatusForbidden, r.StatusCode)
})
t.Run("std should access template via direct user access", func(t *testing.T) {
rr, r := test("2", &security.RestrictedRequestContext{UserID: 2})
require.Nil(t, r)
require.Equal(t, http.StatusOK, rr.Result().StatusCode)
var res struct{ FileContent string }
require.NoError(t, json.NewDecoder(rr.Body).Decode(&res))
require.Equal(t, templateContent, res.FileContent)
})
t.Run("std should access template via team access", func(t *testing.T) {
rr, r := test("2", &security.RestrictedRequestContext{UserID: 3, UserMemberships: []portainer.TeamMembership{{ID: 1, UserID: 3, TeamID: 1}}})
require.Nil(t, r)
require.Equal(t, http.StatusOK, rr.Result().StatusCode)
var res struct{ FileContent string }
require.NoError(t, json.NewDecoder(rr.Body).Decode(&res))
require.Equal(t, templateContent, res.FileContent)
})
t.Run("std should not access template without access", func(t *testing.T) {
_, r := test("2", &security.RestrictedRequestContext{UserID: 4})
require.NotNil(t, r)
require.Equal(t, http.StatusForbidden, r.StatusCode)
})
}
@@ -46,7 +46,6 @@ func (g *TestGitService) CloneRepository(
referenceName string,
username,
password string,
authType gittypes.GitCredentialAuthType,
tlsSkipVerify bool,
) error {
time.Sleep(100 * time.Millisecond)
@@ -59,7 +58,6 @@ func (g *TestGitService) LatestCommitID(
referenceName,
username,
password string,
authType gittypes.GitCredentialAuthType,
tlsSkipVerify bool,
) (string, error) {
return "", nil
@@ -84,7 +82,6 @@ func (g *InvalidTestGitService) CloneRepository(
refName,
username,
password string,
authType gittypes.GitCredentialAuthType,
tlsSkipVerify bool,
) error {
return errors.New("simulate network error")
@@ -95,7 +92,6 @@ func (g *InvalidTestGitService) LatestCommitID(
referenceName,
username,
password string,
authType gittypes.GitCredentialAuthType,
tlsSkipVerify bool,
) (string, error) {
return "", nil
@@ -38,7 +38,7 @@ func (handler *Handler) customTemplateInspect(w http.ResponseWriter, r *http.Req
var customTemplate *portainer.CustomTemplate
err = handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
customTemplate, err = tx.CustomTemplate().Read(portainer.CustomTemplateID(customTemplateID))
if handler.DataStore.IsErrObjectNotFound(err) {
if tx.IsErrObjectNotFound(err) {
return httperror.NotFound("Unable to find a custom template with the specified identifier inside the database", err)
} else if err != nil {
return httperror.InternalServerError("Unable to find a custom template with the specified identifier inside the database", err)
@@ -9,6 +9,7 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/filesystem"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/testhelpers"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
@@ -20,6 +21,9 @@ func TestInspectHandler(t *testing.T) {
_, ds := datastore.MustNewTestStore(t, true, false)
require.NotNil(t, ds)
fs, err := filesystem.NewService(t.TempDir(), t.TempDir())
require.NoError(t, err)
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
require.NoError(t, tx.User().Create(&portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole}))
require.NoError(t, tx.User().Create(&portainer.User{ID: 2, Username: "std2", Role: portainer.StandardUserRole}))
@@ -42,7 +46,7 @@ func TestInspectHandler(t *testing.T) {
return nil
}))
handler := NewHandler(testhelpers.NewTestRequestBouncer(), ds, &TestFileService{}, nil)
handler := NewHandler(testhelpers.NewTestRequestBouncer(), ds, fs, nil)
test := func(templateID string, restrictedContext *security.RestrictedRequestContext) (*httptest.ResponseRecorder, *httperror.HandlerError) {
r := httptest.NewRequest(http.MethodGet, "/custom_templates/"+templateID, nil)
@@ -45,8 +45,6 @@ type customTemplateUpdatePayload struct {
// Password used in basic authentication or token used in token authentication.
// Required when RepositoryAuthentication is true and RepositoryGitCredentialID is 0
RepositoryPassword string `example:"myGitPassword"`
// RepositoryAuthorizationType is the authorization type to use
RepositoryAuthorizationType gittypes.GitCredentialAuthType `example:"0"`
// GitCredentialID used to identify the bound git credential. Required when RepositoryAuthentication
// is true and RepositoryUsername/RepositoryPassword are not provided
RepositoryGitCredentialID int `example:"0"`
@@ -184,15 +182,12 @@ func (handler *Handler) customTemplateUpdate(w http.ResponseWriter, r *http.Requ
repositoryUsername := ""
repositoryPassword := ""
repositoryAuthType := gittypes.GitCredentialAuthType_Basic
if payload.RepositoryAuthentication {
repositoryUsername = payload.RepositoryUsername
repositoryPassword = payload.RepositoryPassword
repositoryAuthType = payload.RepositoryAuthorizationType
gitConfig.Authentication = &gittypes.GitAuthentication{
Username: payload.RepositoryUsername,
Password: payload.RepositoryPassword,
AuthorizationType: payload.RepositoryAuthorizationType,
Username: payload.RepositoryUsername,
Password: payload.RepositoryPassword,
}
}
@@ -202,7 +197,6 @@ func (handler *Handler) customTemplateUpdate(w http.ResponseWriter, r *http.Requ
ReferenceName: gitConfig.ReferenceName,
Username: repositoryUsername,
Password: repositoryPassword,
AuthType: repositoryAuthType,
TLSSkipVerify: gitConfig.TLSSkipVerify,
})
if err != nil {
@@ -216,7 +210,6 @@ func (handler *Handler) customTemplateUpdate(w http.ResponseWriter, r *http.Requ
gitConfig.ReferenceName,
repositoryUsername,
repositoryPassword,
repositoryAuthType,
gitConfig.TLSSkipVerify,
)
if err != nil {
+7 -1
View File
@@ -19,6 +19,7 @@ type StackViewModel struct {
Name string
IsExternal bool
Type portainer.StackType
Labels map[string]string
}
// GetDockerStacks retrieves all the stacks associated to a specific environment filtered by the user's access.
@@ -56,6 +57,7 @@ func GetDockerStacks(tx dataservices.DataStoreTx, securityContext *security.Rest
Name: name,
IsExternal: true,
Type: portainer.DockerComposeStack,
Labels: container.Labels,
}
}
}
@@ -68,6 +70,7 @@ func GetDockerStacks(tx dataservices.DataStoreTx, securityContext *security.Rest
Name: name,
IsExternal: true,
Type: portainer.DockerSwarmStack,
Labels: service.Spec.Labels,
}
}
}
@@ -79,7 +82,10 @@ func GetDockerStacks(tx dataservices.DataStoreTx, securityContext *security.Rest
return uac.FilterByResourceControl(stacksList, user, securityContext.UserMemberships,
func(item StackViewModel) (*portainer.ResourceControl, error) {
return uac.StackResourceControlGetter(tx, environmentID)(*item.InternalStack)
if item.InternalStack != nil {
return uac.StackResourceControlGetter(tx, environmentID)(*item.InternalStack)
}
return uac.ExternalStackResourceControlGetter(tx, environmentID)(uac.ExternalStack{Labels: item.Labels})
},
)
}
@@ -8,7 +8,7 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/datastore"
dockerconsts "github.com/portainer/portainer/api/docker/consts"
"github.com/portainer/portainer/api/docker/consts"
"github.com/portainer/portainer/api/http/security"
"github.com/stretchr/testify/assert"
@@ -28,12 +28,13 @@ func TestHandler_getDockerStacks(t *testing.T) {
containers := []types.Container{
{
Labels: map[string]string{
dockerconsts.ComposeStackNameLabel: "stack1",
consts.ComposeStackNameLabel: "stack1",
},
},
{
Labels: map[string]string{
dockerconsts.ComposeStackNameLabel: "stack2",
consts.ComposeStackNameLabel: "stack2",
"io.portainer.accesscontrol.public": "true",
},
},
}
@@ -43,7 +44,7 @@ func TestHandler_getDockerStacks(t *testing.T) {
Spec: swarm.ServiceSpec{
Annotations: swarm.Annotations{
Labels: map[string]string{
dockerconsts.SwarmStackNameLabel: "stack3",
consts.SwarmStackNameLabel: "stack3",
},
},
},
@@ -65,14 +66,16 @@ func TestHandler_getDockerStacks(t *testing.T) {
is.NoError(tx.Stack().Create(&stack1))
is.NoError(tx.Stack().Create(&portainer.Stack{
ID: 2,
Name: "stack2",
Name: "stack2", // stack 2 on env 2
EndpointID: 2,
Type: portainer.DockerSwarmStack,
}))
is.NoError(tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}))
is.NoError(tx.User().Create(&portainer.User{ID: 2, Role: portainer.StandardUserRole}))
return nil
}))
// testing admin user
is.NoError(store.ViewTx(func(tx dataservices.DataStoreTx) error {
stacksList, err := GetDockerStacks(tx, &security.RestrictedRequestContext{
IsAdmin: true,
@@ -93,11 +96,43 @@ func TestHandler_getDockerStacks(t *testing.T) {
Name: "stack2",
IsExternal: true,
Type: portainer.DockerComposeStack,
Labels: map[string]string{
consts.ComposeStackNameLabel: "stack2",
"io.portainer.accesscontrol.public": "true",
},
},
{
Name: "stack3",
IsExternal: true,
Type: portainer.DockerSwarmStack,
Labels: map[string]string{
consts.SwarmStackNameLabel: "stack3",
},
},
}
assert.ElementsMatch(t, expectedStacks, stacksList)
return nil
}))
// testing standard user
is.NoError(store.ViewTx(func(tx dataservices.DataStoreTx) error {
stacksList, err := GetDockerStacks(tx, &security.RestrictedRequestContext{
IsAdmin: false,
UserID: 2,
}, environment.ID, containers, services)
require.NoError(t, err)
assert.Len(t, stacksList, 1)
expectedStacks := []StackViewModel{
{
Name: "stack2",
IsExternal: true,
Type: portainer.DockerComposeStack,
Labels: map[string]string{
consts.ComposeStackNameLabel: "stack2",
"io.portainer.accesscontrol.public": "true",
},
},
}
@@ -7,6 +7,7 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
dserrors "github.com/portainer/portainer/api/dataservices/errors"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
@@ -38,9 +39,9 @@ func (handler *Handler) edgeGroupDelete(w http.ResponseWriter, r *http.Request)
}
func deleteEdgeGroup(tx dataservices.DataStoreTx, ID portainer.EdgeGroupID) error {
_, err := tx.EdgeGroup().Read(ID)
if tx.IsErrObjectNotFound(err) {
return httperror.NotFound("Unable to find an Edge group with the specified identifier inside the database", err)
ok, err := tx.EdgeGroup().Exists(ID)
if !ok {
return httperror.NotFound("Unable to find an Edge group with the specified identifier inside the database", dserrors.ErrObjectNotFound)
} else if err != nil {
return httperror.InternalServerError("Unable to find an Edge group with the specified identifier inside the database", err)
}
+4 -1
View File
@@ -9,6 +9,7 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
dserrors "github.com/portainer/portainer/api/dataservices/errors"
"github.com/portainer/portainer/api/internal/edge"
"github.com/portainer/portainer/api/internal/edge/cache"
"github.com/portainer/portainer/api/internal/endpointutils"
@@ -147,7 +148,9 @@ func (handler *Handler) updateEdgeSchedule(tx dataservices.DataStoreTx, edgeJob
if len(payload.EdgeGroups) > 0 {
for _, edgeGroupID := range payload.EdgeGroups {
if _, err := tx.EdgeGroup().Read(edgeGroupID); err != nil {
if ok, err := tx.EdgeGroup().Exists(edgeGroupID); !ok {
return dserrors.ErrObjectNotFound
} else if err != nil {
return err
}
@@ -34,8 +34,6 @@ type edgeStackFromGitRepositoryPayload struct {
RepositoryUsername string `example:"myGitUsername"`
// Password used in basic authentication. Required when RepositoryAuthentication is true.
RepositoryPassword string `example:"myGitPassword"`
// RepositoryAuthorizationType is the authorization type to use
RepositoryAuthorizationType gittypes.GitCredentialAuthType `example:"0"`
// Path to the Stack file inside the Git repository
FilePathInRepository string `example:"docker-compose.yml" default:"docker-compose.yml"`
// List of identifiers of EdgeGroups
@@ -128,9 +126,8 @@ func (handler *Handler) createEdgeStackFromGitRepository(r *http.Request, tx dat
if payload.RepositoryAuthentication {
repoConfig.Authentication = &gittypes.GitAuthentication{
Username: payload.RepositoryUsername,
Password: payload.RepositoryPassword,
AuthorizationType: payload.RepositoryAuthorizationType,
Username: payload.RepositoryUsername,
Password: payload.RepositoryPassword,
}
}
@@ -152,11 +149,9 @@ func (handler *Handler) storeManifestFromGitRepository(tx dataservices.DataStore
projectPath = handler.FileService.GetEdgeStackProjectPath(stackFolder)
repositoryUsername := ""
repositoryPassword := ""
repositoryAuthType := gittypes.GitCredentialAuthType_Basic
if repositoryConfig.Authentication != nil && repositoryConfig.Authentication.Password != "" {
repositoryUsername = repositoryConfig.Authentication.Username
repositoryPassword = repositoryConfig.Authentication.Password
repositoryAuthType = repositoryConfig.Authentication.AuthorizationType
}
if err := handler.GitService.CloneRepository(
@@ -165,7 +160,6 @@ func (handler *Handler) storeManifestFromGitRepository(tx dataservices.DataStore
repositoryConfig.ReferenceName,
repositoryUsername,
repositoryPassword,
repositoryAuthType,
repositoryConfig.TLSSkipVerify,
); err != nil {
return "", "", "", err
@@ -5,6 +5,7 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
dserrors "github.com/portainer/portainer/api/dataservices/errors"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
@@ -42,9 +43,9 @@ func (handler *Handler) endpointGroupDeleteEndpoint(w http.ResponseWriter, r *ht
}
func (handler *Handler) removeEndpoint(tx dataservices.DataStoreTx, endpointGroupID portainer.EndpointGroupID, endpointID portainer.EndpointID) error {
_, err := tx.EndpointGroup().Read(endpointGroupID)
if tx.IsErrObjectNotFound(err) {
return httperror.NotFound("Unable to find an environment group with the specified identifier inside the database", err)
ok, err := tx.EndpointGroup().Exists(endpointGroupID)
if !ok {
return httperror.NotFound("Unable to find an environment group with the specified identifier inside the database", dserrors.ErrObjectNotFound)
} else if err != nil {
return httperror.InternalServerError("Unable to find an environment group with the specified identifier inside the database", err)
}
@@ -20,7 +20,9 @@ type endpointGroupUpdatePayload struct {
// Environment(Endpoint) group name
Name string `example:"my-environment-group"`
// Environment(Endpoint) group description
Description string `example:"description"`
Description *string `example:"description"`
// List of environment(endpoint) identifiers that will be part of this group
AssociatedEndpoints []portainer.EndpointID `example:"1,3"`
// List of tag identifiers associated to the environment(endpoint) group
TagIDs []portainer.TagID `example:"3,4"`
UserAccessPolicies portainer.UserAccessPolicies
@@ -80,8 +82,8 @@ func (handler *Handler) updateEndpointGroup(tx dataservices.DataStoreTx, endpoin
endpointGroup.Name = payload.Name
}
if payload.Description != "" {
endpointGroup.Description = payload.Description
if payload.Description != nil {
endpointGroup.Description = *payload.Description
}
tagsChanged := false
@@ -147,11 +149,9 @@ func (handler *Handler) updateEndpointGroup(tx dataservices.DataStoreTx, endpoin
if endpoint.GroupID == endpointGroup.ID && endpointutils.IsKubernetesEndpoint(&endpoint) {
if err := handler.AuthorizationService.CleanNAPWithOverridePolicies(tx, &endpoint, endpointGroup); err != nil {
// Update flag with endpoint and continue
go func(endpointID portainer.EndpointID, endpointGroupID portainer.EndpointGroupID) {
if err := handler.PendingActionsService.Create(handlers.NewCleanNAPWithOverridePolicies(endpointID, &endpointGroupID)); err != nil {
log.Error().Err(err).Msgf("Unable to create pending action to clean NAP with override policies for endpoint (%d) and endpoint group (%d).", endpointID, endpointGroupID)
}
}(endpoint.ID, endpointGroup.ID)
if err := handler.PendingActionsService.Create(tx, handlers.NewCleanNAPWithOverridePolicies(endpoint.ID, &endpointGroup.ID)); err != nil {
log.Error().Err(err).Msgf("Unable to create pending action to clean NAP with override policies for endpoint (%d) and endpoint group (%d).", endpoint.ID, endpointGroup.ID)
}
}
}
}
@@ -161,7 +161,51 @@ func (handler *Handler) updateEndpointGroup(tx dataservices.DataStoreTx, endpoin
return nil, httperror.InternalServerError("Unable to persist environment group changes inside the database", err)
}
if tagsChanged {
// Handle associated endpoints updates
endpointsChanged := false
if payload.AssociatedEndpoints != nil {
endpoints, err := tx.Endpoint().Endpoints()
if err != nil {
return nil, httperror.InternalServerError("Unable to retrieve environments from the database", err)
}
// Build a set of the new endpoint IDs for quick lookup
newEndpointSet := make(map[portainer.EndpointID]bool)
for _, id := range payload.AssociatedEndpoints {
newEndpointSet[id] = true
}
for i := range endpoints {
endpoint := &endpoints[i]
wasInGroup := endpoint.GroupID == endpointGroup.ID
shouldBeInGroup := newEndpointSet[endpoint.ID]
if wasInGroup && !shouldBeInGroup {
// Remove from group (move to Unassigned)
endpoint.GroupID = portainer.EndpointGroupID(1)
if err := tx.Endpoint().UpdateEndpoint(endpoint.ID, endpoint); err != nil {
return nil, httperror.InternalServerError("Unable to update environment", err)
}
if err := handler.updateEndpointRelations(tx, endpoint, nil); err != nil {
return nil, httperror.InternalServerError("Unable to persist environment relations changes inside the database", err)
}
endpointsChanged = true
} else if !wasInGroup && shouldBeInGroup {
// Add to group
endpoint.GroupID = endpointGroup.ID
if err := tx.Endpoint().UpdateEndpoint(endpoint.ID, endpoint); err != nil {
return nil, httperror.InternalServerError("Unable to update environment", err)
}
if err := handler.updateEndpointRelations(tx, endpoint, endpointGroup); err != nil {
return nil, httperror.InternalServerError("Unable to persist environment relations changes inside the database", err)
}
endpointsChanged = true
}
}
}
// Reconcile endpoints in the group if tags changed (but endpoints weren't already reconciled)
if tagsChanged && !endpointsChanged {
endpoints, err := tx.Endpoint().Endpoints()
if err != nil {
return nil, httperror.InternalServerError("Unable to retrieve environments from the database", err)
@@ -18,7 +18,7 @@ import (
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
"github.com/gofrs/uuid"
"github.com/google/uuid"
)
type endpointCreatePayload struct {
@@ -405,7 +405,7 @@ func (handler *Handler) createEdgeAgentEndpoint(tx dataservices.DataStoreTx, pay
}
if settings.EnforceEdgeID {
edgeID, err := uuid.NewV4()
edgeID, err := uuid.NewRandom()
if err != nil {
return nil, httperror.InternalServerError("Cannot generate the Edge ID", err)
}
@@ -161,12 +161,6 @@ func (handler *Handler) deleteEndpoint(tx dataservices.DataStoreTx, endpointID p
handler.ProxyManager.DeleteEndpointProxy(endpoint.ID)
if len(endpoint.UserAccessPolicies) > 0 || len(endpoint.TeamAccessPolicies) > 0 {
if err := handler.AuthorizationService.UpdateUsersAuthorizationsTx(tx); err != nil {
log.Warn().Err(err).Msg("Unable to update user authorizations")
}
}
if err := tx.EndpointRelation().DeleteEndpointRelation(endpoint.ID); err != nil {
log.Warn().Err(err).Msg("Unable to remove environment relation from the database")
}
@@ -179,7 +173,7 @@ func (handler *Handler) deleteEndpoint(tx dataservices.DataStoreTx, endpointID p
err = tx.Tag().Update(tagID, tag)
}
if handler.DataStore.IsErrObjectNotFound(err) {
if tx.IsErrObjectNotFound(err) {
log.Warn().Err(err).Msg("Unable to find tag inside the database")
} else if err != nil {
log.Warn().Err(err).Msg("Unable to delete tag relation from the database")
@@ -227,7 +221,7 @@ func (handler *Handler) deleteEndpoint(tx dataservices.DataStoreTx, endpointID p
}
if endpointutils.IsEdgeEndpoint(endpoint) {
edgeJobs, err := handler.DataStore.EdgeJob().ReadAll()
edgeJobs, err := tx.EdgeJob().ReadAll()
if err != nil {
log.Warn().Err(err).Msg("Unable to retrieve edge jobs from the database")
}
@@ -39,6 +39,7 @@ const (
// @param tagsPartialMatch query bool false "If true, will return environment(endpoint) which has one of tagIds, if false (or missing) will return only environments(endpoints) that has all the tags"
// @param endpointIds query []int false "will return only these environments(endpoints)"
// @param excludeIds query []int false "will exclude these environments(endpoints)"
// @param excludeGroupIds query []int false "will exclude environments(endpoints) belonging to these endpoint groups"
// @param provisioned query bool false "If true, will return environment(endpoint) that were provisioned"
// @param agentVersions query []string false "will return only environments with on of these agent versions"
// @param edgeAsync query bool false "if exists true show only edge async agents, false show only standard edge agents. if missing, will show both types (relevant only for edge agents)"
@@ -139,14 +139,14 @@ func Test_endpointList_edgeFilter(t *testing.T) {
"should show only trusted edge async agents and regular endpoints",
[]portainer.EndpointID{trustedEdgeAsync.ID, regularEndpoint.ID},
},
edgeAsync: BoolAddr(true),
edgeAsync: new(true),
},
{
endpointListTest: endpointListTest{
"should show only untrusted edge devices and regular endpoints",
[]portainer.EndpointID{untrustedEdgeAsync.ID, regularEndpoint.ID},
},
edgeAsync: BoolAddr(true),
edgeAsync: new(true),
edgeDeviceUntrusted: true,
},
{
@@ -154,7 +154,7 @@ func Test_endpointList_edgeFilter(t *testing.T) {
"should show no edge devices",
[]portainer.EndpointID{regularEndpoint.ID, regularTrustedEdgeStandard.ID},
},
edgeAsync: BoolAddr(false),
edgeAsync: new(false),
},
}
@@ -6,6 +6,7 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/registryutils"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
@@ -119,27 +120,41 @@ func (handler *Handler) updateRegistryAccess(tx dataservices.DataStoreTx, r *htt
}
func (handler *Handler) updateKubeAccess(endpoint *portainer.Endpoint, registry *portainer.Registry, oldNamespaces, newNamespaces []string) error {
cli, err := handler.K8sClientFactory.GetPrivilegedKubeClient(endpoint)
if err != nil {
return err
}
return applyKubeRegistryAccess(cli, registry, oldNamespaces, newNamespaces)
}
func applyKubeRegistryAccess(cli portainer.KubeClient, registry *portainer.Registry, oldNamespaces, newNamespaces []string) error {
oldNamespacesSet := toSet(oldNamespaces)
newNamespacesSet := toSet(newNamespaces)
namespacesToRemove := setDifference(oldNamespacesSet, newNamespacesSet)
namespacesToAdd := setDifference(newNamespacesSet, oldNamespacesSet)
cli, err := handler.K8sClientFactory.GetPrivilegedKubeClient(endpoint)
if err != nil {
return err
}
for namespace := range namespacesToRemove {
err := cli.DeleteRegistrySecret(registry.ID, namespace)
if err != nil {
secretName := registryutils.RegistrySecretName(registry.ID)
if err := cli.RemoveImagePullSecretFromServiceAccount(namespace, "default", secretName); err != nil {
return err
}
if err := cli.DeleteRegistrySecret(registry.ID, namespace); err != nil {
return err
}
}
for namespace := range namespacesToAdd {
err := cli.CreateRegistrySecret(registry, namespace)
if err != nil {
secretName := registryutils.RegistrySecretName(registry.ID)
if err := cli.CreateRegistrySecret(registry, namespace); err != nil {
return err
}
if err := cli.AddImagePullSecretToServiceAccount(namespace, "default", secretName); err != nil {
return err
}
}
@@ -0,0 +1,166 @@
package endpoints
import (
"errors"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// spyKubeClient implements portainer.KubeClient for testing applyKubeRegistryAccess.
// It embeds the interface so unimplemented methods panic, and overrides only the
// four methods exercised by applyKubeRegistryAccess.
type spyKubeClient struct {
portainer.KubeClient
createSecretErrors map[string]error
deleteSecretErrors map[string]error
addPullSecretErrors map[string]error
removePullSecretErrors map[string]error
createdSecrets []string
deletedSecrets []string
addedPullSecrets []string
removedPullSecrets []string
}
func newSpyKubeClient() *spyKubeClient {
return &spyKubeClient{
createSecretErrors: make(map[string]error),
deleteSecretErrors: make(map[string]error),
addPullSecretErrors: make(map[string]error),
removePullSecretErrors: make(map[string]error),
}
}
func (s *spyKubeClient) CreateRegistrySecret(_ *portainer.Registry, namespace string) error {
s.createdSecrets = append(s.createdSecrets, namespace)
return s.createSecretErrors[namespace]
}
func (s *spyKubeClient) DeleteRegistrySecret(_ portainer.RegistryID, namespace string) error {
s.deletedSecrets = append(s.deletedSecrets, namespace)
return s.deleteSecretErrors[namespace]
}
func (s *spyKubeClient) AddImagePullSecretToServiceAccount(namespace, _, _ string) error {
s.addedPullSecrets = append(s.addedPullSecrets, namespace)
return s.addPullSecretErrors[namespace]
}
func (s *spyKubeClient) RemoveImagePullSecretFromServiceAccount(namespace, _, _ string) error {
s.removedPullSecrets = append(s.removedPullSecrets, namespace)
return s.removePullSecretErrors[namespace]
}
var testRegistry = &portainer.Registry{ID: 3, URL: "registry.example.com"}
func TestApplyKubeRegistryAccess_Grant(t *testing.T) {
t.Run("single namespace granted creates secret then patches SA", func(t *testing.T) {
spy := newSpyKubeClient()
err := applyKubeRegistryAccess(spy, testRegistry, nil, []string{"ns-a"})
require.NoError(t, err)
assert.Equal(t, []string{"ns-a"}, spy.createdSecrets)
assert.Equal(t, []string{"ns-a"}, spy.addedPullSecrets)
assert.Empty(t, spy.deletedSecrets)
assert.Empty(t, spy.removedPullSecrets)
})
t.Run("multiple namespaces granted applies to all", func(t *testing.T) {
spy := newSpyKubeClient()
err := applyKubeRegistryAccess(spy, testRegistry, nil, []string{"ns-a", "ns-b"})
require.NoError(t, err)
assert.ElementsMatch(t, []string{"ns-a", "ns-b"}, spy.createdSecrets)
assert.ElementsMatch(t, []string{"ns-a", "ns-b"}, spy.addedPullSecrets)
})
t.Run("CreateRegistrySecret fails - AddImagePullSecret not called", func(t *testing.T) {
spy := newSpyKubeClient()
spy.createSecretErrors["ns-a"] = errors.New("secret create failed")
err := applyKubeRegistryAccess(spy, testRegistry, nil, []string{"ns-a"})
require.Error(t, err)
assert.Equal(t, []string{"ns-a"}, spy.createdSecrets)
assert.Empty(t, spy.addedPullSecrets)
})
t.Run("AddImagePullSecret fails after secret created - returns error", func(t *testing.T) {
spy := newSpyKubeClient()
spy.addPullSecretErrors["ns-a"] = errors.New("sa patch failed")
err := applyKubeRegistryAccess(spy, testRegistry, nil, []string{"ns-a"})
require.Error(t, err)
assert.Equal(t, []string{"ns-a"}, spy.createdSecrets)
assert.Equal(t, []string{"ns-a"}, spy.addedPullSecrets)
})
}
func TestApplyKubeRegistryAccess_Revoke(t *testing.T) {
t.Run("single namespace revoked removes from SA then deletes secret", func(t *testing.T) {
spy := newSpyKubeClient()
err := applyKubeRegistryAccess(spy, testRegistry, []string{"ns-a"}, nil)
require.NoError(t, err)
assert.Equal(t, []string{"ns-a"}, spy.removedPullSecrets)
assert.Equal(t, []string{"ns-a"}, spy.deletedSecrets)
assert.Empty(t, spy.createdSecrets)
assert.Empty(t, spy.addedPullSecrets)
})
t.Run("multiple namespaces revoked applies to all", func(t *testing.T) {
spy := newSpyKubeClient()
err := applyKubeRegistryAccess(spy, testRegistry, []string{"ns-a", "ns-b"}, nil)
require.NoError(t, err)
assert.ElementsMatch(t, []string{"ns-a", "ns-b"}, spy.removedPullSecrets)
assert.ElementsMatch(t, []string{"ns-a", "ns-b"}, spy.deletedSecrets)
})
t.Run("RemoveImagePullSecret fails - DeleteRegistrySecret not called", func(t *testing.T) {
spy := newSpyKubeClient()
spy.removePullSecretErrors["ns-a"] = errors.New("sa remove failed")
err := applyKubeRegistryAccess(spy, testRegistry, []string{"ns-a"}, nil)
require.Error(t, err)
assert.Equal(t, []string{"ns-a"}, spy.removedPullSecrets)
assert.Empty(t, spy.deletedSecrets)
})
t.Run("DeleteRegistrySecret fails after SA patched - returns error", func(t *testing.T) {
spy := newSpyKubeClient()
spy.deleteSecretErrors["ns-a"] = errors.New("secret delete failed")
err := applyKubeRegistryAccess(spy, testRegistry, []string{"ns-a"}, nil)
require.Error(t, err)
assert.Equal(t, []string{"ns-a"}, spy.removedPullSecrets)
assert.Equal(t, []string{"ns-a"}, spy.deletedSecrets)
})
}
func TestApplyKubeRegistryAccess_Mixed(t *testing.T) {
t.Run("one namespace added and one removed in same call", func(t *testing.T) {
spy := newSpyKubeClient()
err := applyKubeRegistryAccess(spy, testRegistry, []string{"ns-old"}, []string{"ns-new"})
require.NoError(t, err)
assert.Equal(t, []string{"ns-old"}, spy.removedPullSecrets)
assert.Equal(t, []string{"ns-old"}, spy.deletedSecrets)
assert.Equal(t, []string{"ns-new"}, spy.createdSecrets)
assert.Equal(t, []string{"ns-new"}, spy.addedPullSecrets)
})
t.Run("empty old and new namespaces - no operations performed", func(t *testing.T) {
spy := newSpyKubeClient()
err := applyKubeRegistryAccess(spy, testRegistry, nil, nil)
require.NoError(t, err)
assert.Empty(t, spy.createdSecrets)
assert.Empty(t, spy.deletedSecrets)
assert.Empty(t, spy.addedPullSecrets)
assert.Empty(t, spy.removedPullSecrets)
})
t.Run("namespace present in both old and new - no operations performed for it", func(t *testing.T) {
spy := newSpyKubeClient()
err := applyKubeRegistryAccess(spy, testRegistry, []string{"ns-keep"}, []string{"ns-keep"})
require.NoError(t, err)
assert.Empty(t, spy.createdSecrets)
assert.Empty(t, spy.deletedSecrets)
assert.Empty(t, spy.addedPullSecrets)
assert.Empty(t, spy.removedPullSecrets)
})
}
@@ -26,6 +26,8 @@ type endpointSettingsUpdatePayload struct {
AllowContainerCapabilitiesForRegularUsers *bool `json:"allowContainerCapabilitiesForRegularUsers" example:"true"`
// Whether non-administrator should be able to use sysctl settings
AllowSysctlSettingForRegularUsers *bool `json:"allowSysctlSettingForRegularUsers" example:"true"`
// Whether non-administrator should be able to use security-opt settings
AllowSecurityOptForRegularUsers *bool `json:"allowSecurityOptForRegularUsers" example:"true"`
// Whether host management features are enabled
EnableHostManagementFeatures *bool `json:"enableHostManagementFeatures" example:"true"`
@@ -111,6 +113,12 @@ func (handler *Handler) endpointSettingsUpdate(w http.ResponseWriter, r *http.Re
securitySettings.EnableHostManagementFeatures = *payload.EnableHostManagementFeatures
}
if payload.AllowSecurityOptForRegularUsers != nil {
securitySettings.AllowSecurityOptForRegularUsers = *payload.AllowSecurityOptForRegularUsers
}
endpoint.SecuritySettings = securitySettings
if payload.EnableGPUManagement != nil {
endpoint.EnableGPUManagement = *payload.EnableGPUManagement
}
@@ -119,8 +127,6 @@ func (handler *Handler) endpointSettingsUpdate(w http.ResponseWriter, r *http.Re
endpoint.Gpus = payload.Gpus
}
endpoint.SecuritySettings = securitySettings
err = handler.DataStore.Endpoint().UpdateEndpoint(portainer.EndpointID(endpointID), endpoint)
if err != nil {
return httperror.InternalServerError("Failed persisting environment in database", err)
@@ -265,7 +265,7 @@ func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) *
if err := handler.AuthorizationService.CleanNAPWithOverridePolicies(handler.DataStore, endpoint, nil); err != nil {
log.Warn().Err(err).Msgf("Unable to clean NAP with override policies for endpoint (%d). Will try to update when endpoint is online.", endpoint.ID)
if err := handler.PendingActionsService.Create(handlers.NewCleanNAPWithOverridePolicies(endpoint.ID, nil)); err != nil {
if err := handler.PendingActionsService.Create(handler.DataStore, handlers.NewCleanNAPWithOverridePolicies(endpoint.ID, nil)); err != nil {
log.Warn().Err(err).Msg("unable to schedule pending action to clean NAP with override policies")
}
}
+14 -1
View File
@@ -38,6 +38,7 @@ type EnvironmentsQuery struct {
edgeStackId portainer.EdgeStackID
edgeStackStatus *portainer.EdgeStackStatusType
excludeIds []portainer.EndpointID
excludeGroupIds []portainer.EndpointGroupID
edgeGroupIds []portainer.EdgeGroupID
excludeEdgeGroupIds []portainer.EdgeGroupID
}
@@ -80,6 +81,11 @@ func parseQuery(r *http.Request) (EnvironmentsQuery, error) {
return EnvironmentsQuery{}, err
}
excludeGroupIDs, err := getNumberArrayQueryParameter[portainer.EndpointGroupID](r, "excludeGroupIds")
if err != nil {
return EnvironmentsQuery{}, err
}
edgeGroupIDs, err := getNumberArrayQueryParameter[portainer.EdgeGroupID](r, "edgeGroupIds")
if err != nil {
return EnvironmentsQuery{}, err
@@ -97,7 +103,7 @@ func parseQuery(r *http.Request) (EnvironmentsQuery, error) {
var edgeAsync *bool
edgeAsyncParam, _ := request.RetrieveQueryParameter(r, "edgeAsync", true)
if edgeAsyncParam != "" {
edgeAsync = BoolAddr(edgeAsyncParam == "true")
edgeAsync = new(edgeAsyncParam == "true")
}
edgeDeviceUntrusted, _ := request.RetrieveBooleanQueryParameter(r, "edgeDeviceUntrusted", true)
@@ -119,6 +125,7 @@ func parseQuery(r *http.Request) (EnvironmentsQuery, error) {
tagIds: tagIDs,
endpointIds: endpointIDs,
excludeIds: excludeIDs,
excludeGroupIds: excludeGroupIDs,
tagsPartialMatch: tagsPartialMatch,
groupIds: groupIDs,
status: status,
@@ -157,6 +164,12 @@ func (handler *Handler) filterEndpointsByQuery(
})
}
if len(query.excludeGroupIds) > 0 {
filteredEndpoints = filter(filteredEndpoints, func(endpoint portainer.Endpoint) bool {
return !slices.Contains(query.excludeGroupIds, endpoint.GroupID)
})
}
if len(query.groupIds) > 0 {
filteredEndpoints = filterEndpointsByGroupIDs(filteredEndpoints, query.groupIds)
}
+43 -3
View File
@@ -106,14 +106,14 @@ func Test_Filter_edgeFilter(t *testing.T) {
"should show only trusted edge devices and other regular endpoints",
[]portainer.EndpointID{trustedEdgeAsync.ID, regularEndpoint.ID},
EnvironmentsQuery{
edgeAsync: BoolAddr(true),
edgeAsync: new(true),
},
},
{
"should show only untrusted edge devices and other regular endpoints",
[]portainer.EndpointID{untrustedEdgeAsync.ID, regularEndpoint.ID},
EnvironmentsQuery{
edgeAsync: BoolAddr(true),
edgeAsync: new(true),
edgeDeviceUntrusted: true,
},
},
@@ -121,7 +121,7 @@ func Test_Filter_edgeFilter(t *testing.T) {
"should show no edge devices",
[]portainer.EndpointID{regularEndpoint.ID, regularTrustedEdgeStandard.ID},
EnvironmentsQuery{
edgeAsync: BoolAddr(false),
edgeAsync: new(false),
},
},
}
@@ -151,6 +151,46 @@ func Test_Filter_excludeIDs(t *testing.T) {
runTests(tests, t, handler, environments)
}
func Test_Filter_excludeGroupIDs(t *testing.T) {
groupA := portainer.EndpointGroupID(10)
groupB := portainer.EndpointGroupID(20)
groupC := portainer.EndpointGroupID(30)
endpoints := []portainer.Endpoint{
{ID: 1, GroupID: groupA, Type: portainer.DockerEnvironment},
{ID: 2, GroupID: groupA, Type: portainer.DockerEnvironment},
{ID: 3, GroupID: groupB, Type: portainer.DockerEnvironment},
{ID: 4, GroupID: groupB, Type: portainer.DockerEnvironment},
{ID: 5, GroupID: groupC, Type: portainer.DockerEnvironment},
}
handler := setupFilterTest(t, endpoints)
tests := []filterTest{
{
title: "should exclude endpoints in groupA",
expected: []portainer.EndpointID{3, 4, 5},
query: EnvironmentsQuery{
excludeGroupIds: []portainer.EndpointGroupID{groupA},
},
},
{
title: "should exclude endpoints in groupA and groupB",
expected: []portainer.EndpointID{5},
query: EnvironmentsQuery{
excludeGroupIds: []portainer.EndpointGroupID{groupA, groupB},
},
},
{
title: "should return all endpoints when excludeGroupIds is empty",
expected: []portainer.EndpointID{1, 2, 3, 4, 5},
query: EnvironmentsQuery{},
},
}
runTests(tests, t, handler, endpoints)
}
func BenchmarkFilterEndpointsBySearchCriteria_PartialMatch(b *testing.B) {
n := 10000
-7
View File
@@ -1,7 +0,0 @@
package endpoints
func ptr[T any](i T) *T { return &i }
func BoolAddr(b bool) *bool {
return ptr(b)
}
@@ -18,11 +18,10 @@ type fileResponse struct {
}
type repositoryFilePreviewPayload struct {
Repository string `json:"repository" example:"https://github.com/openfaas/faas" validate:"required"`
Reference string `json:"reference" example:"refs/heads/master"`
Username string `json:"username" example:"myGitUsername"`
Password string `json:"password" example:"myGitPassword"`
AuthorizationType gittypes.GitCredentialAuthType `json:"authorizationType"`
Repository string `json:"repository" example:"https://github.com/openfaas/faas" validate:"required"`
Reference string `json:"reference" example:"refs/heads/master"`
Username string `json:"username" example:"myGitUsername"`
Password string `json:"password" example:"myGitPassword"`
// Path to file whose content will be read
TargetFile string `json:"targetFile" example:"docker-compose.yml"`
// TLSSkipVerify skips SSL verification when cloning the Git repository
@@ -76,7 +75,6 @@ func (handler *Handler) gitOperationRepoFilePreview(w http.ResponseWriter, r *ht
payload.Reference,
payload.Username,
payload.Password,
payload.AuthorizationType,
payload.TLSSkipVerify,
)
if err != nil {
+1 -1
View File
@@ -81,7 +81,7 @@ type Handler struct {
}
// @title PortainerCE API
// @version 2.38.0
// @version 2.40.0
// @description.markdown api-description.md
// @termsOfService
+20 -103
View File
@@ -6,7 +6,7 @@ import (
"os"
"strings"
"github.com/portainer/portainer/api/http/middlewares"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/kubernetes"
"github.com/portainer/portainer/api/kubernetes/validation"
@@ -19,7 +19,6 @@ import (
"github.com/rs/zerolog/log"
"github.com/pkg/errors"
"golang.org/x/sync/errgroup"
)
type installChartPayload struct {
@@ -95,7 +94,7 @@ func (p *installChartPayload) Validate(_ *http.Request) error {
return fmt.Errorf("required field(s) missing: %s", strings.Join(required, ", "))
}
if errs := validation.IsDNS1123Subdomain(p.Name); len(errs) > 0 {
if err := validation.IsDNS1123Subdomain(p.Name); err != nil {
return errChartNameInvalid
}
@@ -108,6 +107,23 @@ func (handler *Handler) installChart(r *http.Request, p installChartPayload, dry
return nil, httperr.Err
}
tokenData, err := security.RetrieveTokenData(r)
if err != nil {
return nil, errors.Wrap(err, "unable to retrieve user details from authentication token")
}
var username string
if err := handler.dataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
user, err := tx.User().Read(tokenData.ID)
if err != nil {
return errors.Wrap(err, "unable to load user information from the database")
}
username = user.Username
return nil
}); err != nil {
return nil, err
}
installOpts := options.InstallOptions{
Name: p.Name,
Chart: p.Chart,
@@ -117,6 +133,7 @@ func (handler *Handler) installChart(r *http.Request, p installChartPayload, dry
Atomic: p.Atomic,
DryRun: dryRun,
KubernetesClusterAccess: clusterAccess,
HelmAppLabels: kubernetes.GetHelmAppLabels(p.Name, username),
}
if p.Values != "" {
@@ -147,105 +164,5 @@ func (handler *Handler) installChart(r *http.Request, p installChartPayload, dry
return nil, err
}
if !installOpts.DryRun {
manifest, err := handler.applyPortainerLabelsToHelmAppManifest(r, installOpts, release.Manifest)
if err != nil {
return nil, err
}
if err := handler.updateHelmAppManifest(r, manifest, installOpts.Namespace); err != nil {
return nil, err
}
}
return release, nil
}
// applyPortainerLabelsToHelmAppManifest will patch all the resources deployed in the helm release manifest
// with portainer specific labels. This is to mark the resources as managed by portainer - hence the helm apps
// wont appear external in the portainer UI.
func (handler *Handler) applyPortainerLabelsToHelmAppManifest(r *http.Request, installOpts options.InstallOptions, manifest string) ([]byte, error) {
// Patch helm release by adding with portainer labels to all deployed resources
tokenData, err := security.RetrieveTokenData(r)
if err != nil {
return nil, errors.Wrap(err, "unable to retrieve user details from authentication token")
}
user, err := handler.dataStore.User().Read(tokenData.ID)
if err != nil {
return nil, errors.Wrap(err, "unable to load user information from the database")
}
appLabels := kubernetes.GetHelmAppLabels(installOpts.Name, user.Username)
labeledManifest, err := kubernetes.AddAppLabels([]byte(manifest), appLabels)
if err != nil {
return nil, errors.Wrap(err, "failed to label helm release manifest")
}
return labeledManifest, nil
}
// updateHelmAppManifest will update the resources of helm release manifest with portainer labels using kubectl.
// The resources of the manifest will be updated in parallel and individuallly since resources of a chart
// can be deployed to different namespaces.
// NOTE: These updates will need to be re-applied when upgrading the helm release
func (handler *Handler) updateHelmAppManifest(r *http.Request, manifest []byte, namespace string) error {
endpoint, err := middlewares.FetchEndpoint(r)
if err != nil {
return errors.Wrap(err, "unable to find an endpoint on request context")
}
tokenData, err := security.RetrieveTokenData(r)
if err != nil {
return errors.Wrap(err, "unable to retrieve user details from authentication token")
}
// Extract list of YAML resources from Helm manifest
yamlResources, err := kubernetes.ExtractDocuments(manifest, nil)
if err != nil {
return errors.Wrap(err, "unable to extract documents from helm release manifest")
}
// Deploy individual resources in parallel
g := new(errgroup.Group)
for _, resource := range yamlResources {
g.Go(func() error {
tmpfile, err := os.CreateTemp("", "helm-manifest-*.yaml")
if err != nil {
return errors.Wrap(err, "failed to create a tmp helm manifest file")
}
defer func() {
if err := tmpfile.Close(); err != nil {
log.Warn().Err(err).Msg("failed to close tmp helm manifest file")
}
if err := os.Remove(tmpfile.Name()); err != nil {
log.Warn().Err(err).Msg("failed to remove tmp helm manifest file")
}
}()
if _, err := tmpfile.Write(resource); err != nil {
return errors.Wrap(err, "failed to write a tmp helm manifest file")
}
// get resource namespace, fallback to provided namespace if not explicit on resource
resourceNamespace, err := kubernetes.GetNamespace(resource)
if err != nil {
return err
}
if resourceNamespace == "" {
resourceNamespace = namespace
}
_, err = handler.kubernetesDeployer.Deploy(tokenData.ID, endpoint, []string{tmpfile.Name()}, resourceNamespace)
return err
})
}
if err := g.Wait(); err != nil {
return errors.Wrap(err, "unable to patch helm release using kubectl")
}
return nil
}
+2
View File
@@ -124,6 +124,7 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza
namespaceRouter.Handle("/services", httperror.LoggerHandler(h.createKubernetesService)).Methods(http.MethodPost)
namespaceRouter.Handle("/services", httperror.LoggerHandler(h.updateKubernetesService)).Methods(http.MethodPut)
namespaceRouter.Handle("/services", httperror.LoggerHandler(h.getKubernetesServicesByNamespace)).Methods(http.MethodGet)
namespaceRouter.Handle("/service_accounts/{name}", httperror.LoggerHandler(h.getKubernetesServiceAccount)).Methods(http.MethodGet)
namespaceRouter.Handle("/volumes", httperror.LoggerHandler(h.GetKubernetesVolumesInNamespace)).Methods(http.MethodGet)
namespaceRouter.Handle("/volumes/{volume}", httperror.LoggerHandler(h.getKubernetesVolume)).Methods(http.MethodGet)
@@ -177,6 +178,7 @@ func (handler *Handler) kubeClientMiddleware(next http.Handler) http.Handler {
tokenData, err := security.RetrieveTokenData(r)
if err != nil {
httperror.WriteError(w, http.StatusForbidden, "an error occurred during the KubeClientMiddleware operation, permission denied to access the environment. Error: ", err)
return
}
// Check if we have a kubeclient against this auth token already, otherwise generate a new one
+217 -209
View File
@@ -2,8 +2,10 @@ package kubernetes
import (
"net/http"
"slices"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/middlewares"
models "github.com/portainer/portainer/api/http/models/kubernetes"
"github.com/portainer/portainer/api/http/security"
@@ -31,33 +33,23 @@ import (
// @failure 500 "Server error occurred while attempting to retrieve ingress controllers"
// @router /kubernetes/{id}/ingresscontrollers [get]
func (handler *Handler) getAllKubernetesIngressControllers(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Invalid environment identifier route variable")
return httperror.BadRequest("Invalid environment identifier route variable", err)
}
endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
if err != nil {
if handler.DataStore.IsErrObjectNotFound(err) {
log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Unable to find an environment with the specified identifier inside the database")
return httperror.NotFound("Unable to find an environment with the specified identifier inside the database", err)
}
log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Unable to find an environment with the specified identifier inside the database")
return httperror.InternalServerError("Unable to find an environment with the specified identifier inside the database", err)
}
allowedOnly, err := request.RetrieveBooleanQueryParameter(r, "allowedOnly", true)
if err != nil {
log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Unable to retrieve allowedOnly query parameter")
return httperror.BadRequest("Unable to retrieve allowedOnly query parameter", err)
log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Invalid allowedOnly boolean query parameter")
return httperror.BadRequest("Invalid allowedOnly boolean query parameter", err)
}
// Get endpoint from context (may have policies applied in-memory)
endpoint, err := middlewares.FetchEndpoint(r)
if err != nil {
log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Unable to fetch endpoint")
return httperror.InternalServerError(err.Error(), err)
}
cli, err := handler.KubernetesClientFactory.GetPrivilegedKubeClient(endpoint)
if err != nil {
log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Unable to get privileged kube client")
return httperror.InternalServerError("Unable to get privileged kube client", err)
log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Unable to create Kubernetes client")
return httperror.InternalServerError("Unable to create Kubernetes client", err)
}
controllers, err := cli.GetIngressControllers()
@@ -72,6 +64,7 @@ func (handler *Handler) getAllKubernetesIngressControllers(w http.ResponseWriter
}
// Add none controller if "AllowNone" is set for endpoint.
// Use the policy-applied endpoint for this check since it affects what's shown to the user.
if endpoint.Kubernetes.Configuration.AllowNoneIngressClass {
controllers = append(controllers, models.K8sIngressController{
Name: "none",
@@ -79,37 +72,46 @@ func (handler *Handler) getAllKubernetesIngressControllers(w http.ResponseWriter
Type: "custom",
})
}
existingClasses := endpoint.Kubernetes.Configuration.IngressClasses
updatedClasses := []portainer.KubernetesIngressClassConfig{}
for i := range controllers {
controllers[i].Availability = true
if controllers[i].ClassName != "none" {
controllers[i].New = true
// Fetch raw endpoint and update IngressClasses within a transaction.
// This prevents policy-applied values from being persisted to the database.
var updatedClasses []portainer.KubernetesIngressClassConfig
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
rawEndpoint, err := tx.Endpoint().Endpoint(endpoint.ID)
if err != nil {
return err
}
updatedClass := portainer.KubernetesIngressClassConfig{
Name: controllers[i].ClassName,
Type: controllers[i].Type,
}
// Check if the controller is already known.
for _, existingClass := range existingClasses {
if controllers[i].ClassName != existingClass.Name {
continue
// Use raw endpoint's IngressClasses for building updatedClasses to persist original DB values.
existingClasses := rawEndpoint.Kubernetes.Configuration.IngressClasses
updatedClasses = []portainer.KubernetesIngressClassConfig{}
for i := range controllers {
controllers[i].Availability = true
if controllers[i].ClassName != "none" {
controllers[i].New = true
}
controllers[i].New = false
controllers[i].Availability = !existingClass.GloballyBlocked
updatedClass.GloballyBlocked = existingClass.GloballyBlocked
updatedClass.BlockedNamespaces = existingClass.BlockedNamespaces
}
updatedClasses = append(updatedClasses, updatedClass)
}
endpoint.Kubernetes.Configuration.IngressClasses = updatedClasses
err = handler.DataStore.Endpoint().UpdateEndpoint(
portainer.EndpointID(endpointID),
endpoint,
)
updatedClass := portainer.KubernetesIngressClassConfig{
Name: controllers[i].ClassName,
Type: controllers[i].Type,
}
// Check if the controller is already known.
for _, existingClass := range existingClasses {
if controllers[i].ClassName != existingClass.Name {
continue
}
controllers[i].New = false
controllers[i].Availability = !existingClass.GloballyBlocked
updatedClass.GloballyBlocked = existingClass.GloballyBlocked
updatedClass.BlockedNamespaces = existingClass.BlockedNamespaces
}
updatedClasses = append(updatedClasses, updatedClass)
}
rawEndpoint.Kubernetes.Configuration.IngressClasses = updatedClasses
return tx.Endpoint().UpdateEndpoint(rawEndpoint.ID, rawEndpoint)
})
if err != nil {
log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Unable to store found IngressClasses inside the database")
return httperror.InternalServerError("Unable to store found IngressClasses inside the database", err)
@@ -126,6 +128,7 @@ func (handler *Handler) getAllKubernetesIngressControllers(w http.ResponseWriter
}
controllers = allowedControllers
}
return response.JSON(w, controllers)
}
@@ -146,21 +149,16 @@ func (handler *Handler) getAllKubernetesIngressControllers(w http.ResponseWriter
// @failure 500 "Server error occurred while attempting to retrieve ingress controllers by a namespace"
// @router /kubernetes/{id}/namespaces/{namespace}/ingresscontrollers [get]
func (handler *Handler) getKubernetesIngressControllersByNamespace(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
namespace, err := request.RetrieveRouteVariableValue(r, "namespace")
if err != nil {
log.Error().Err(err).Str("context", "getKubernetesIngressControllersByNamespace").Msg("Unable to retrieve environment identifier from request")
return httperror.BadRequest("Unable to retrieve environment identifier from request", err)
log.Error().Err(err).Str("context", "getKubernetesIngressControllersByNamespace").Msg("Unable to retrieve namespace identifier from request")
return httperror.BadRequest("Unable to retrieve namespace identifier from request", err)
}
endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
endpoint, err := middlewares.FetchEndpoint(r)
if err != nil {
if handler.DataStore.IsErrObjectNotFound(err) {
log.Error().Err(err).Str("context", "getKubernetesIngressControllersByNamespace").Msg("Unable to find an environment with the specified identifier inside the database")
return httperror.NotFound("Unable to find an environment with the specified identifier inside the database", err)
}
log.Error().Err(err).Str("context", "getKubernetesIngressControllersByNamespace").Msg("Unable to find an environment with the specified identifier inside the database")
return httperror.InternalServerError("Unable to find an environment with the specified identifier inside the database", err)
log.Error().Err(err).Str("context", "getKubernetesIngressControllersByNamespace").Msg("Unable to fetch endpoint")
return httperror.InternalServerError(err.Error(), err)
}
cli, err := handler.KubernetesClientFactory.GetPrivilegedKubeClient(endpoint)
@@ -169,12 +167,6 @@ func (handler *Handler) getKubernetesIngressControllersByNamespace(w http.Respon
return httperror.InternalServerError("Unable to create Kubernetes client", err)
}
namespace, err := request.RetrieveRouteVariableValue(r, "namespace")
if err != nil {
log.Error().Err(err).Str("context", "getKubernetesIngressControllersByNamespace").Msg("Unable to retrieve namespace from request")
return httperror.BadRequest("Unable to retrieve namespace from request", err)
}
currentControllers, err := cli.GetIngressControllers()
if err != nil {
if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) {
@@ -185,7 +177,9 @@ func (handler *Handler) getKubernetesIngressControllersByNamespace(w http.Respon
log.Error().Err(err).Str("context", "getKubernetesIngressControllersByNamespace").Str("namespace", namespace).Msg("Unable to retrieve ingress controllers from the Kubernetes")
return httperror.InternalServerError("Unable to retrieve ingress controllers from the Kubernetes", err)
}
// Add none controller if "AllowNone" is set for endpoint.
// Use the policy-applied endpoint for this check since it affects what's shown to the user.
if endpoint.Kubernetes.Configuration.AllowNoneIngressClass {
currentControllers = append(currentControllers, models.K8sIngressController{
Name: "none",
@@ -194,55 +188,66 @@ func (handler *Handler) getKubernetesIngressControllersByNamespace(w http.Respon
})
}
kubernetesConfig := endpoint.Kubernetes.Configuration
existingClasses := kubernetesConfig.IngressClasses
ingressAvailabilityPerNamespace := kubernetesConfig.IngressAvailabilityPerNamespace
updatedClasses := []portainer.KubernetesIngressClassConfig{}
// Use policy-applied endpoint for ingressAvailabilityPerNamespace since it affects the response.
ingressAvailabilityPerNamespace := endpoint.Kubernetes.Configuration.IngressAvailabilityPerNamespace
controllers := models.K8sIngressControllers{}
for i := range currentControllers {
globallyblocked := false
currentControllers[i].Availability = true
if currentControllers[i].ClassName != "none" {
currentControllers[i].New = true
// Fetch raw endpoint and update IngressClasses within a transaction.
// This prevents policy-applied values from being persisted to the database.
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
rawEndpoint, err := tx.Endpoint().Endpoint(endpoint.ID)
if err != nil {
return err
}
updatedClass := portainer.KubernetesIngressClassConfig{
Name: currentControllers[i].ClassName,
Type: currentControllers[i].Type,
}
// Use raw endpoint's IngressClasses for building updatedClasses to persist original DB values.
existingClasses := rawEndpoint.Kubernetes.Configuration.IngressClasses
updatedClasses := []portainer.KubernetesIngressClassConfig{}
// Check if the controller is blocked globally or in the current
// namespace.
for _, existingClass := range existingClasses {
if currentControllers[i].ClassName != existingClass.Name {
continue
for i := range currentControllers {
globallyblocked := false
currentControllers[i].Availability = true
if currentControllers[i].ClassName != "none" {
currentControllers[i].New = true
}
currentControllers[i].New = false
updatedClass.GloballyBlocked = existingClass.GloballyBlocked
updatedClass.BlockedNamespaces = existingClass.BlockedNamespaces
globallyblocked = existingClass.GloballyBlocked
updatedClass := portainer.KubernetesIngressClassConfig{
Name: currentControllers[i].ClassName,
Type: currentControllers[i].Type,
}
// Check if the current namespace is blocked if ingressAvailabilityPerNamespace is set to true
if ingressAvailabilityPerNamespace {
for _, ns := range existingClass.BlockedNamespaces {
if namespace == ns {
currentControllers[i].Availability = false
// Check if the controller is blocked globally or in the current
// namespace.
for _, existingClass := range existingClasses {
if currentControllers[i].ClassName != existingClass.Name {
continue
}
currentControllers[i].New = false
updatedClass.GloballyBlocked = existingClass.GloballyBlocked
updatedClass.BlockedNamespaces = existingClass.BlockedNamespaces
globallyblocked = existingClass.GloballyBlocked
// Check if the current namespace is blocked if ingressAvailabilityPerNamespace is set to true
if ingressAvailabilityPerNamespace {
for _, ns := range existingClass.BlockedNamespaces {
if namespace == ns {
currentControllers[i].Availability = false
}
}
}
}
if !globallyblocked {
controllers = append(controllers, currentControllers[i])
}
updatedClasses = append(updatedClasses, updatedClass)
}
if !globallyblocked {
controllers = append(controllers, currentControllers[i])
}
updatedClasses = append(updatedClasses, updatedClass)
}
// Update the database to match the list of found controllers.
// This includes pruning out controllers which no longer exist.
endpoint.Kubernetes.Configuration.IngressClasses = updatedClasses
err = handler.DataStore.Endpoint().UpdateEndpoint(portainer.EndpointID(endpointID), endpoint)
// Update the database to match the list of found controllers.
// This includes pruning out controllers which no longer exist.
rawEndpoint.Kubernetes.Configuration.IngressClasses = updatedClasses
return tx.Endpoint().UpdateEndpoint(rawEndpoint.ID, rawEndpoint)
})
if err != nil {
log.Error().Err(err).Str("context", "getKubernetesIngressControllersByNamespace").Msg("Unable to store found IngressClasses inside the database")
return httperror.InternalServerError("Unable to store found IngressClasses inside the database", err)
@@ -268,21 +273,10 @@ func (handler *Handler) getKubernetesIngressControllersByNamespace(w http.Respon
// @failure 500 "Server error occurred while attempting to update ingress controllers."
// @router /kubernetes/{id}/ingresscontrollers [put]
func (handler *Handler) updateKubernetesIngressControllers(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
endpoint, err := middlewares.FetchEndpoint(r)
if err != nil {
log.Error().Err(err).Str("context", "updateKubernetesIngressControllers").Msg("Unable to retrieve environment identifier from request")
return httperror.BadRequest("Unable to retrieve environment identifier from request", err)
}
endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
if err != nil {
if handler.DataStore.IsErrObjectNotFound(err) {
log.Error().Err(err).Str("context", "updateKubernetesIngressControllers").Msg("Unable to find an environment with the specified identifier inside the database")
return httperror.NotFound("Unable to find an environment with the specified identifier inside the database", err)
}
log.Error().Err(err).Str("context", "updateKubernetesIngressControllers").Msg("Unable to find an environment with the specified identifier inside the database")
return httperror.InternalServerError("Unable to find an environment with the specified identifier inside the database", err)
log.Error().Err(err).Str("context", "updateKubernetesIngressControllers").Msg("Unable to retrieve environment")
return httperror.BadRequest("Unable to retrieve environment", err)
}
payload := models.K8sIngressControllers{}
@@ -298,7 +292,6 @@ func (handler *Handler) updateKubernetesIngressControllers(w http.ResponseWriter
return httperror.InternalServerError("Unable to get privileged kube client", err)
}
existingClasses := endpoint.Kubernetes.Configuration.IngressClasses
controllers, err := cli.GetIngressControllers()
if err != nil {
if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) {
@@ -316,6 +309,7 @@ func (handler *Handler) updateKubernetesIngressControllers(w http.ResponseWriter
}
// Add none controller if "AllowNone" is set for endpoint.
// Use policy-applied endpoint for this check since it affects the response.
if endpoint.Kubernetes.Configuration.AllowNoneIngressClass {
controllers = append(controllers, models.K8sIngressController{
Name: "none",
@@ -324,48 +318,55 @@ func (handler *Handler) updateKubernetesIngressControllers(w http.ResponseWriter
})
}
updatedClasses := []portainer.KubernetesIngressClassConfig{}
for i := range controllers {
controllers[i].Availability = true
controllers[i].New = true
updatedClass := portainer.KubernetesIngressClassConfig{
Name: controllers[i].ClassName,
Type: controllers[i].Type,
// Fetch raw endpoint and update IngressClasses within a transaction.
// This prevents policy-applied values from being persisted to the database.
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
rawEndpoint, err := tx.Endpoint().Endpoint(endpoint.ID)
if err != nil {
return err
}
// Check if the controller is already known.
for _, existingClass := range existingClasses {
if controllers[i].ClassName != existingClass.Name {
continue
}
controllers[i].New = false
controllers[i].Availability = !existingClass.GloballyBlocked
updatedClass.GloballyBlocked = existingClass.GloballyBlocked
updatedClass.BlockedNamespaces = existingClass.BlockedNamespaces
}
updatedClasses = append(updatedClasses, updatedClass)
}
for _, p := range payload {
// Use raw endpoint's IngressClasses for building updatedClasses to persist original DB values.
existingClasses := rawEndpoint.Kubernetes.Configuration.IngressClasses
updatedClasses := []portainer.KubernetesIngressClassConfig{}
for i := range controllers {
// Now set new payload data
if updatedClasses[i].Name == p.ClassName {
updatedClasses[i].GloballyBlocked = !p.Availability
controllers[i].Availability = true
controllers[i].New = true
updatedClass := portainer.KubernetesIngressClassConfig{
Name: controllers[i].ClassName,
Type: controllers[i].Type,
}
// Check if the controller is already known.
for _, existingClass := range existingClasses {
if controllers[i].ClassName != existingClass.Name {
continue
}
controllers[i].New = false
controllers[i].Availability = !existingClass.GloballyBlocked
updatedClass.GloballyBlocked = existingClass.GloballyBlocked
updatedClass.BlockedNamespaces = existingClass.BlockedNamespaces
}
updatedClasses = append(updatedClasses, updatedClass)
}
for _, p := range payload {
for i := range controllers {
// Now set new payload data
if updatedClasses[i].Name == p.ClassName {
updatedClasses[i].GloballyBlocked = !p.Availability
}
}
}
}
endpoint.Kubernetes.Configuration.IngressClasses = updatedClasses
err = handler.DataStore.Endpoint().UpdateEndpoint(
portainer.EndpointID(endpointID),
endpoint,
)
rawEndpoint.Kubernetes.Configuration.IngressClasses = updatedClasses
return tx.Endpoint().UpdateEndpoint(rawEndpoint.ID, rawEndpoint)
})
if err != nil {
log.Error().Err(err).Str("context", "updateKubernetesIngressControllers").Msg("Unable to store found IngressClasses inside the database")
return httperror.InternalServerError("Unable to store found IngressClasses inside the database", err)
}
return response.Empty(w)
}
@@ -388,12 +389,6 @@ func (handler *Handler) updateKubernetesIngressControllers(w http.ResponseWriter
// @failure 500 "Server error occurred while attempting to update ingress controllers by namespace."
// @router /kubernetes/{id}/namespaces/{namespace}/ingresscontrollers [put]
func (handler *Handler) updateKubernetesIngressControllersByNamespace(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
endpoint, err := middlewares.FetchEndpoint(r)
if err != nil {
log.Error().Err(err).Str("context", "updateKubernetesIngressControllersByNamespace").Msg("Unable to fetch endpoint")
return httperror.NotFound("Unable to fetch endpoint", err)
}
namespace, err := request.RetrieveRouteVariableValue(r, "namespace")
if err != nil {
log.Error().Err(err).Str("context", "updateKubernetesIngressControllersByNamespace").Msg("Unable to retrieve namespace from request")
@@ -407,75 +402,88 @@ func (handler *Handler) updateKubernetesIngressControllersByNamespace(w http.Res
return httperror.BadRequest("Unable to decode and validate the request payload", err)
}
existingClasses := endpoint.Kubernetes.Configuration.IngressClasses
updatedClasses := []portainer.KubernetesIngressClassConfig{}
PayloadLoop:
for _, p := range payload {
for _, existingClass := range existingClasses {
if p.ClassName != existingClass.Name {
continue
}
updatedClass := portainer.KubernetesIngressClassConfig{
Name: existingClass.Name,
Type: existingClass.Type,
GloballyBlocked: existingClass.GloballyBlocked,
}
endpoint, err := middlewares.FetchEndpoint(r)
if err != nil {
log.Error().Err(err).Str("context", "updateKubernetesIngressControllersByNamespace").Msg("Unable to fetch endpoint")
return httperror.InternalServerError("Unable to fetch endpoint", err)
}
// Handle "allow"
if p.Availability {
// remove the namespace from the list of blocked namespaces
// in the existingClass.
for _, blockedNS := range existingClass.BlockedNamespaces {
if blockedNS != namespace {
updatedClass.BlockedNamespaces = append(updatedClass.BlockedNamespaces, blockedNS)
// Fetch raw endpoint and update IngressClasses within a transaction.
// This prevents policy-applied values from being persisted to the database.
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
rawEndpoint, err := tx.Endpoint().Endpoint(endpoint.ID)
if err != nil {
return err
}
// Use raw endpoint's IngressClasses for building updatedClasses to persist original DB values.
existingClasses := rawEndpoint.Kubernetes.Configuration.IngressClasses
updatedClasses := []portainer.KubernetesIngressClassConfig{}
for _, p := range payload {
for _, existingClass := range existingClasses {
if p.ClassName != existingClass.Name {
continue
}
updatedClass := portainer.KubernetesIngressClassConfig{
Name: existingClass.Name,
Type: existingClass.Type,
GloballyBlocked: existingClass.GloballyBlocked,
}
// Handle "allow"
if p.Availability {
// remove the namespace from the list of blocked namespaces
// in the existingClass.
for _, blockedNS := range existingClass.BlockedNamespaces {
if blockedNS != namespace {
updatedClass.BlockedNamespaces = append(updatedClass.BlockedNamespaces, blockedNS)
}
}
}
updatedClasses = append(updatedClasses, updatedClass)
continue PayloadLoop
}
// Handle "disallow"
// If it's meant to be blocked we need to add the current
// namespace. First, check if it's already in the
// BlockedNamespaces and if not we append it.
updatedClass.BlockedNamespaces = existingClass.BlockedNamespaces
for _, ns := range updatedClass.BlockedNamespaces {
if namespace == ns {
updatedClasses = append(updatedClasses, updatedClass)
continue PayloadLoop
break
}
// Handle "disallow"
// If it's meant to be blocked we need to add the current
// namespace. First, check if it's already in the
// BlockedNamespaces and if not we append it.
updatedClass.BlockedNamespaces = existingClass.BlockedNamespaces
if !slices.Contains(updatedClass.BlockedNamespaces, namespace) {
updatedClass.BlockedNamespaces = append(updatedClass.BlockedNamespaces, namespace)
}
updatedClasses = append(updatedClasses, updatedClass)
break
}
}
// At this point it's possible we had an existing class which was globally
// blocked and thus not included in the payload. As a result it is not yet
// part of updatedClasses, but we MUST include it or we would remove the
// global block.
for _, existingClass := range existingClasses {
found := false
for _, updatedClass := range updatedClasses {
if existingClass.Name == updatedClass.Name {
found = true
break
}
}
updatedClass.BlockedNamespaces = append(updatedClass.BlockedNamespaces, namespace)
updatedClasses = append(updatedClasses, updatedClass)
}
}
// At this point it's possible we had an existing class which was globally
// blocked and thus not included in the payload. As a result it is not yet
// part of updatedClasses, but we MUST include it or we would remove the
// global block.
for _, existingClass := range existingClasses {
found := false
for _, updatedClass := range updatedClasses {
if existingClass.Name == updatedClass.Name {
found = true
if !found {
updatedClasses = append(updatedClasses, existingClass)
}
}
if !found {
updatedClasses = append(updatedClasses, existingClass)
}
}
endpoint.Kubernetes.Configuration.IngressClasses = updatedClasses
err = handler.DataStore.Endpoint().UpdateEndpoint(endpoint.ID, endpoint)
rawEndpoint.Kubernetes.Configuration.IngressClasses = updatedClasses
return tx.Endpoint().UpdateEndpoint(rawEndpoint.ID, rawEndpoint)
})
if err != nil {
log.Error().Err(err).Str("context", "updateKubernetesIngressControllersByNamespace").Str("namespace", namespace).Msg("Unable to store BlockedIngressClasses inside the database")
return httperror.InternalServerError("Unable to store BlockedIngressClasses inside the database", err)
}
return response.Empty(w)
}
@@ -41,6 +41,47 @@ func (handler *Handler) getAllKubernetesServiceAccounts(w http.ResponseWriter, r
return response.JSON(w, serviceAccounts)
}
// @id GetKubernetesServiceAccount
// @summary Get a kubernetes service account
// @description Get a kubernetes service account in the given namespace.
// @description **Access policy**: Authenticated user.
// @tags kubernetes
// @security ApiKeyAuth || jwt
// @produce json
// @param id path int true "Environment identifier"
// @param namespace path string true "Namespace"
// @param name path string true "Service account name"
// @success 200 {object} models.K8sServiceAccount "Success"
// @failure 400 "Invalid request"
// @failure 401 "Unauthorized"
// @failure 403 "Permission denied"
// @failure 404 "Service account not found"
// @failure 500 "Server error"
// @router /kubernetes/{id}/namespaces/{namespace}/service_accounts/{name} [get]
func (handler *Handler) getKubernetesServiceAccount(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
namespace, err := request.RetrieveRouteVariableValue(r, "namespace")
if err != nil {
return httperror.BadRequest("Invalid namespace", err)
}
name, err := request.RetrieveRouteVariableValue(r, "name")
if err != nil {
return httperror.BadRequest("Invalid name", err)
}
cli, httpErr := handler.prepareKubeClient(r)
if httpErr != nil {
return httperror.InternalServerError("Unable to prepare kube client", httpErr)
}
sa, err := cli.GetServiceAccount(namespace, name)
if err != nil {
return httperror.InternalServerError("Unable to retrieve service account", err)
}
return response.JSON(w, sa)
}
// @id DeleteServiceAccounts
// @summary Delete service accounts
// @description Delete the provided list of service accounts.
@@ -0,0 +1,140 @@
package kubernetes
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/datastore"
models "github.com/portainer/portainer/api/http/models/kubernetes"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/internal/testhelpers"
"github.com/portainer/portainer/api/jwt"
"github.com/portainer/portainer/api/kubernetes"
kubeclient "github.com/portainer/portainer/api/kubernetes/cli"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func newServiceAccountTestHandler(t *testing.T) (*Handler, *portainer.User, string) {
t.Helper()
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{}`))
}))
t.Cleanup(srv.Close)
_, store := datastore.MustNewTestStore(t, true, true)
err := store.Endpoint().Create(&portainer.Endpoint{
ID: 1,
Type: portainer.AgentOnKubernetesEnvironment,
})
require.NoError(t, err, "error creating environment")
u := &portainer.User{Username: "admin", Role: portainer.AdministratorRole}
err = store.User().Create(u)
require.NoError(t, err, "error creating a user")
jwtService, err := jwt.NewService("1h", store)
require.NoError(t, err, "error initiating jwt service")
tk, _, err := jwtService.GenerateToken(&portainer.TokenData{ID: u.ID, Username: u.Username, Role: u.Role})
require.NoError(t, err)
kubeClusterAccessService := kubernetes.NewKubeClusterAccessService("", "", "")
srvURL, err := url.Parse(srv.URL)
require.NoError(t, err)
cli := testhelpers.NewKubernetesClient()
factory, err := kubeclient.NewClientFactory(nil, nil, store, "", ":"+srvURL.Port(), "")
require.NoError(t, err)
authorizationService := authorization.NewService(store)
handler := NewHandler(testhelpers.NewTestRequestBouncer(), authorizationService, store, jwtService, kubeClusterAccessService, factory, cli)
return handler, u, tk
}
func newServiceAccountRequest(t *testing.T, method, path string, body []byte, u *portainer.User, tk string) *http.Request {
t.Helper()
var req *http.Request
if body != nil {
req = httptest.NewRequest(method, path, bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
} else {
req = httptest.NewRequest(method, path, nil)
}
ctx := security.StoreTokenData(req, &portainer.TokenData{ID: u.ID, Username: u.Username, Role: u.Role})
req = req.WithContext(ctx)
ctx = security.StoreRestrictedRequestContext(req, &security.RestrictedRequestContext{IsAdmin: true, UserID: u.ID})
req = req.WithContext(ctx)
testhelpers.AddTestSecurityCookie(req, tk)
return req
}
func TestDeleteKubernetesServiceAccounts_ValidPayload(t *testing.T) {
handler, u, tk := newServiceAccountTestHandler(t)
payload := models.K8sServiceAccountDeleteRequests{
"default": {"sa-1", "sa-2"},
}
body, err := json.Marshal(payload)
require.NoError(t, err)
req := newServiceAccountRequest(t, http.MethodPost, "/kubernetes/1/service_accounts/delete", body, u, tk)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
assert.NotEqual(t, http.StatusBadRequest, rr.Code, "should not return bad request for valid payload")
}
func TestDeleteKubernetesServiceAccounts_InvalidPayload(t *testing.T) {
handler, u, tk := newServiceAccountTestHandler(t)
payload := models.K8sServiceAccountDeleteRequests{}
body, err := json.Marshal(payload)
require.NoError(t, err)
req := newServiceAccountRequest(t, http.MethodPost, "/kubernetes/1/service_accounts/delete", body, u, tk)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
assert.Equal(t, http.StatusBadRequest, rr.Code, "should return bad request for invalid payload")
bodyData, err := io.ReadAll(rr.Result().Body)
require.NoError(t, err)
assert.NotEmpty(t, string(bodyData), "should have error response body")
}
func TestDeleteKubernetesServiceAccounts_EmptyNamespace(t *testing.T) {
handler, u, tk := newServiceAccountTestHandler(t)
payload := models.K8sServiceAccountDeleteRequests{
"": {"sa-1"},
}
body, err := json.Marshal(payload)
require.NoError(t, err)
req := newServiceAccountRequest(t, http.MethodPost, "/kubernetes/1/service_accounts/delete", body, u, tk)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
assert.Equal(t, http.StatusBadRequest, rr.Code, "should return bad request for empty namespace")
bodyData, err := io.ReadAll(rr.Result().Body)
require.NoError(t, err)
assert.NotEmpty(t, string(bodyData), "should have error response body")
}
+53 -30
View File
@@ -5,6 +5,7 @@ import (
"net/http"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/registryutils"
@@ -16,6 +17,30 @@ import (
"github.com/rs/zerolog/log"
)
// cleanupRegistryFromNamespaces removes the registry imagePullSecret from the
// default service account and deletes the registry secret in each namespace.
// It returns the list of namespaces that failed either operation so the caller
// can schedule a pending action for retry.
func cleanupRegistryFromNamespaces(cli portainer.KubeClient, registryID portainer.RegistryID, namespaces []string, endpointID portainer.EndpointID) []string {
secretName := registryutils.RegistrySecretName(registryID)
failed := make([]string, 0)
for _, ns := range namespaces {
if err := cli.RemoveImagePullSecretFromServiceAccount(ns, "default", secretName); err != nil {
failed = append(failed, ns)
log.Warn().Err(err).Msgf("Unable to remove registry secret from default service account in namespace %q for environment %d. Retrying offline", ns, endpointID)
continue
}
if err := cli.DeleteRegistrySecret(registryID, ns); err != nil {
failed = append(failed, ns)
log.Warn().Err(err).Msgf("Unable to delete registry secret %q from namespace %q for environment %d. Retrying offline", secretName, ns, endpointID)
}
}
return failed
}
// @id RegistryDelete
// @summary Remove a registry
// @description Remove a registry
@@ -51,47 +76,45 @@ func (handler *Handler) registryDelete(w http.ResponseWriter, r *http.Request) *
return httperror.InternalServerError("Unable to remove the registry from the database", err)
}
handler.deleteKubernetesSecrets(registry)
handler.deleteKubernetesSecrets(handler.DataStore, registry)
return response.Empty(w)
}
func (handler *Handler) deleteKubernetesSecrets(registry *portainer.Registry) {
func (handler *Handler) deleteKubernetesSecrets(tx dataservices.DataStoreTx, registry *portainer.Registry) {
for endpointId, access := range registry.RegistryAccesses {
if access.Namespaces != nil {
// Obtain a kubeclient for the endpoint
endpoint, err := handler.DataStore.Endpoint().Endpoint(endpointId)
if err != nil {
// Skip environments that can't be loaded from the DB
log.Warn().Err(err).Msgf("Unable to load the environment with id %d from the database", endpointId)
if access.Namespaces == nil {
continue
}
continue
}
// Obtain a kubeclient for the endpoint
endpoint, err := tx.Endpoint().Endpoint(endpointId)
if err != nil {
// Skip environments that can't be loaded from the DB
log.Warn().Err(err).Msgf("Unable to load the environment with id %d from the database", endpointId)
cli, err := handler.K8sClientFactory.GetPrivilegedKubeClient(endpoint)
if err != nil {
// Skip environments that can't get a kubeclient from
log.Warn().Err(err).Msgf("Unable to get kubernetes client for environment %d", endpointId)
continue
}
continue
}
cli, err := handler.K8sClientFactory.GetPrivilegedKubeClient(endpoint)
if err != nil {
// Skip environments that can't get a kubeclient from
log.Warn().Err(err).Msgf("Unable to get kubernetes client for environment %d", endpointId)
failedNamespaces := make([]string, 0)
continue
}
for _, ns := range access.Namespaces {
if err := cli.DeleteRegistrySecret(registry.ID, ns); err != nil {
failedNamespaces = append(failedNamespaces, ns)
log.Warn().Err(err).Msgf("Unable to delete registry secret %q from namespace %q for environment %d. Retrying offline", registryutils.RegistrySecretName(registry.ID), ns, endpointId)
}
}
failedNamespaces := cleanupRegistryFromNamespaces(cli, registry.ID, access.Namespaces, endpointId)
if len(failedNamespaces) > 0 {
if err := handler.PendingActionsService.Create(
handlers.NewDeleteK8sRegistrySecrets(endpointId, registry.ID, failedNamespaces),
); err != nil {
log.Warn().Err(err).Msg("unable to schedule pending action to delete kubernetes registry secrets")
}
}
if len(failedNamespaces) == 0 {
continue
}
if err := handler.PendingActionsService.Create(
tx,
handlers.NewDeleteK8sRegistrySecrets(endpointId, registry.ID, failedNamespaces),
); err != nil {
log.Warn().Err(err).Msg("unable to schedule pending action to delete kubernetes registry secrets")
}
}
}
@@ -0,0 +1,220 @@
package registries
import (
"errors"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/internal/testhelpers"
kubecli "github.com/portainer/portainer/api/kubernetes/cli"
"github.com/portainer/portainer/api/pendingactions"
v1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
kfake "k8s.io/client-go/kubernetes/fake"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// spyKubeClient for registry delete tests - same pattern as endpoint_registry_access_test.go
type deleteSpyKubeClient struct {
portainer.KubeClient
deleteSecretErrors map[string]error
removePullSecretErrors map[string]error
deletedSecrets []string
removedPullSecrets []string
}
func newDeleteSpy() *deleteSpyKubeClient {
return &deleteSpyKubeClient{
deleteSecretErrors: make(map[string]error),
removePullSecretErrors: make(map[string]error),
}
}
func (s *deleteSpyKubeClient) DeleteRegistrySecret(_ portainer.RegistryID, namespace string) error {
s.deletedSecrets = append(s.deletedSecrets, namespace)
return s.deleteSecretErrors[namespace]
}
func (s *deleteSpyKubeClient) RemoveImagePullSecretFromServiceAccount(namespace, _, _ string) error {
s.removedPullSecrets = append(s.removedPullSecrets, namespace)
return s.removePullSecretErrors[namespace]
}
// --- cleanupRegistryFromNamespaces unit tests ---
func TestCleanupRegistryFromNamespaces(t *testing.T) {
const registryID portainer.RegistryID = 3
const endpointID portainer.EndpointID = 1
t.Run("all namespaces succeed - returns empty failed list", func(t *testing.T) {
spy := newDeleteSpy()
failed := cleanupRegistryFromNamespaces(spy, registryID, []string{"ns-a", "ns-b"}, endpointID)
assert.Empty(t, failed)
assert.ElementsMatch(t, []string{"ns-a", "ns-b"}, spy.removedPullSecrets)
assert.ElementsMatch(t, []string{"ns-a", "ns-b"}, spy.deletedSecrets)
})
t.Run("SA removal fails - namespace in failed list and secret not deleted", func(t *testing.T) {
spy := newDeleteSpy()
spy.removePullSecretErrors["ns-a"] = errors.New("sa error")
failed := cleanupRegistryFromNamespaces(spy, registryID, []string{"ns-a", "ns-b"}, endpointID)
assert.Equal(t, []string{"ns-a"}, failed)
assert.ElementsMatch(t, []string{"ns-a", "ns-b"}, spy.removedPullSecrets)
assert.Equal(t, []string{"ns-b"}, spy.deletedSecrets, "ns-a secret must not be deleted when SA removal fails")
})
t.Run("secret deletion fails - namespace in failed list", func(t *testing.T) {
spy := newDeleteSpy()
spy.deleteSecretErrors["ns-a"] = errors.New("delete error")
failed := cleanupRegistryFromNamespaces(spy, registryID, []string{"ns-a", "ns-b"}, endpointID)
assert.Equal(t, []string{"ns-a"}, failed)
assert.ElementsMatch(t, []string{"ns-a", "ns-b"}, spy.removedPullSecrets)
assert.ElementsMatch(t, []string{"ns-a", "ns-b"}, spy.deletedSecrets)
})
t.Run("both operations fail for all namespaces - all in failed list", func(t *testing.T) {
spy := newDeleteSpy()
spy.removePullSecretErrors["ns-a"] = errors.New("err")
spy.removePullSecretErrors["ns-b"] = errors.New("err")
failed := cleanupRegistryFromNamespaces(spy, registryID, []string{"ns-a", "ns-b"}, endpointID)
assert.ElementsMatch(t, []string{"ns-a", "ns-b"}, failed)
assert.Empty(t, spy.deletedSecrets)
})
t.Run("empty namespace list - returns empty failed list", func(t *testing.T) {
spy := newDeleteSpy()
failed := cleanupRegistryFromNamespaces(spy, registryID, []string{}, endpointID)
assert.Empty(t, failed)
assert.Empty(t, spy.removedPullSecrets)
assert.Empty(t, spy.deletedSecrets)
})
}
// --- deleteKubernetesSecrets integration tests ---
func TestDeleteKubernetesSecrets(t *testing.T) {
const registryID portainer.RegistryID = 3
const endpointID portainer.EndpointID = 1
newHandlerWithFakeK8s := func(t *testing.T, endpoint *portainer.Endpoint, registry *portainer.Registry) (*Handler, *datastore.Store) {
t.Helper()
_, store := datastore.MustNewTestStore(t, true, false)
require.NoError(t, store.Endpoint().Create(endpoint))
require.NoError(t, store.Registry().Create(registry))
defaultSA := &v1.ServiceAccount{
ObjectMeta: metav1.ObjectMeta{Name: "default", Namespace: "ns-a"},
}
fakeK8s := kfake.NewSimpleClientset(defaultSA)
factory := kubecli.NewTestClientFactory(endpointID, kubecli.NewTestKubeClient(fakeK8s))
pas := pendingactions.NewService(store, nil)
h := &Handler{
DataStore: store,
K8sClientFactory: factory,
PendingActionsService: pas,
requestBouncer: testhelpers.NewTestRequestBouncer(),
}
return h, store
}
t.Run("GetPrivilegedKubeClient fails - no pending action created", func(t *testing.T) {
// KubernetesLocalEnvironment calls rest.InClusterConfig() which fails outside
// a real cluster, causing GetPrivilegedKubeClient to return an error gracefully.
endpoint := &portainer.Endpoint{
ID: endpointID,
Name: "test-env",
Type: portainer.KubernetesLocalEnvironment,
}
registry := &portainer.Registry{
ID: registryID,
RegistryAccesses: portainer.RegistryAccesses{
endpointID: portainer.RegistryAccessPolicies{Namespaces: []string{"ns-a"}},
},
}
_, store := datastore.MustNewTestStore(t, true, false)
require.NoError(t, store.Endpoint().Create(endpoint))
require.NoError(t, store.Registry().Create(registry))
// Empty factory: endpoint not in cache, CreateConfig will fail → returns error, not panic
emptyFactory, err := kubecli.NewClientFactory(nil, nil, nil, "test", "", "")
require.NoError(t, err)
pas := pendingactions.NewService(store, nil)
h := &Handler{
DataStore: store,
K8sClientFactory: emptyFactory,
PendingActionsService: pas,
requestBouncer: testhelpers.NewTestRequestBouncer(),
}
h.deleteKubernetesSecrets(store, registry)
actions, err := store.PendingActions().ReadAll(func(portainer.PendingAction) bool { return true })
require.NoError(t, err)
assert.Empty(t, actions, "no pending action should be created when kube client cannot be obtained")
})
t.Run("all namespaces succeed - no pending action created", func(t *testing.T) {
endpoint := &portainer.Endpoint{
ID: endpointID,
Name: "test-env",
Type: portainer.AgentOnKubernetesEnvironment,
}
registry := &portainer.Registry{
ID: registryID,
RegistryAccesses: portainer.RegistryAccesses{
endpointID: portainer.RegistryAccessPolicies{Namespaces: []string{"ns-a"}},
},
}
_, store := datastore.MustNewTestStore(t, true, false)
require.NoError(t, store.Endpoint().Create(endpoint))
require.NoError(t, store.Registry().Create(registry))
defaultSA := &v1.ServiceAccount{
ObjectMeta: metav1.ObjectMeta{Name: "default", Namespace: "ns-a"},
}
fakeK8s := kfake.NewSimpleClientset(defaultSA)
factory := kubecli.NewTestClientFactory(endpointID, kubecli.NewTestKubeClient(fakeK8s))
pas := pendingactions.NewService(store, nil)
h := &Handler{
DataStore: store,
K8sClientFactory: factory,
PendingActionsService: pas,
requestBouncer: testhelpers.NewTestRequestBouncer(),
}
h.deleteKubernetesSecrets(store, registry)
actions, err := store.PendingActions().ReadAll(func(portainer.PendingAction) bool { return true })
require.NoError(t, err)
assert.Empty(t, actions)
})
t.Run("registry with no Kubernetes namespaces - no operations attempted", func(t *testing.T) {
endpoint := &portainer.Endpoint{
ID: endpointID,
Name: "test-env",
Type: portainer.AgentOnKubernetesEnvironment,
}
registry := &portainer.Registry{
ID: registryID,
RegistryAccesses: portainer.RegistryAccesses{
endpointID: portainer.RegistryAccessPolicies{Namespaces: nil},
},
}
h, store := newHandlerWithFakeK8s(t, endpoint, registry)
h.deleteKubernetesSecrets(store, registry)
actions, err := store.PendingActions().ReadAll(func(portainer.PendingAction) bool { return true })
require.NoError(t, err)
assert.Empty(t, actions)
})
}
@@ -16,8 +16,6 @@ import (
"github.com/stretchr/testify/require"
)
func ptr[T any](i T) *T { return &i }
func TestHandler_registryUpdate(t *testing.T) {
_, store := datastore.MustNewTestStore(t, false, false)
@@ -27,12 +25,12 @@ func TestHandler_registryUpdate(t *testing.T) {
require.NoError(t, err)
payload := registryUpdatePayload{
Name: ptr("Updated test registry"),
URL: ptr("http://example.org/feed"),
BaseURL: ptr("http://example.org"),
Authentication: ptr(true),
Username: ptr("username"),
Password: ptr("password"),
Name: new("Updated test registry"),
URL: new("http://example.org/feed"),
BaseURL: new("http://example.org"),
Authentication: new(true),
Username: new("username"),
Password: new("password"),
}
payloadBytes, err := json.Marshal(payload)
@@ -269,7 +269,7 @@ func (handler *Handler) createComposeStackFromGitRepository(w http.ResponseWrite
//make sure the webhook ID is unique
if payload.AutoUpdate != nil && payload.AutoUpdate.Webhook != "" {
isUnique, err := handler.checkUniqueWebhookID(payload.AutoUpdate.Webhook)
isUnique, err := handler.checkUniqueWebhookID(handler.DataStore, payload.AutoUpdate.Webhook)
if err != nil {
return httperror.InternalServerError("Unable to check for webhook ID collision", err)
}
@@ -214,7 +214,7 @@ func (handler *Handler) createKubernetesStackFromGitRepository(w http.ResponseWr
// Make sure the webhook ID is unique
if payload.AutoUpdate != nil && payload.AutoUpdate.Webhook != "" {
if isUnique, err := handler.checkUniqueWebhookID(payload.AutoUpdate.Webhook); err != nil {
if isUnique, err := handler.checkUniqueWebhookID(handler.DataStore, payload.AutoUpdate.Webhook); err != nil {
return httperror.InternalServerError("Unable to check for webhook ID collision", err)
} else if !isUnique {
return httperror.Conflict(fmt.Sprintf("Webhook ID: %s already exists", payload.AutoUpdate.Webhook), stackutils.ErrWebhookIDAlreadyExists)
+5 -10
View File
@@ -192,28 +192,23 @@ func createStackPayloadFromSwarmGitPayload(name, swarmID, repoUrl, repoReference
// @router /stacks/create/swarm/repository [post]
func (handler *Handler) createSwarmStackFromGitRepository(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint, userID portainer.UserID) *httperror.HandlerError {
var payload swarmStackFromGitRepositoryPayload
err := request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
return httperror.BadRequest("Invalid request payload", err)
}
payload.Name = handler.SwarmStackManager.NormalizeStackName(payload.Name)
isUnique, err := handler.checkUniqueStackNameInDocker(endpoint, payload.Name, 0, true)
if err != nil {
if isUnique, err := handler.checkUniqueStackNameInDocker(endpoint, payload.Name, 0, true); err != nil {
return httperror.InternalServerError("Unable to check for name collision", err)
}
if !isUnique {
} else if !isUnique {
return stackExistsError(payload.Name)
}
//make sure the webhook ID is unique
if payload.AutoUpdate != nil && payload.AutoUpdate.Webhook != "" {
isUnique, err := handler.checkUniqueWebhookID(payload.AutoUpdate.Webhook)
if err != nil {
if isUnique, err := handler.checkUniqueWebhookID(handler.DataStore, payload.AutoUpdate.Webhook); err != nil {
return httperror.InternalServerError("Unable to check for webhook ID collision", err)
}
if !isUnique {
} else if !isUnique {
return httperror.Conflict(fmt.Sprintf("Webhook ID: %s already exists", payload.AutoUpdate.Webhook), stackutils.ErrWebhookIDAlreadyExists)
}
}
+3 -3
View File
@@ -206,9 +206,9 @@ func (handler *Handler) checkUniqueStackNameInDocker(endpoint *portainer.Endpoin
return isUniqueStackName, nil
}
func (handler *Handler) checkUniqueWebhookID(webhookID string) (bool, error) {
_, err := handler.DataStore.Stack().StackByWebhookID(webhookID)
if handler.DataStore.IsErrObjectNotFound(err) {
func (handler *Handler) checkUniqueWebhookID(tx dataservices.DataStoreTx, webhookID string) (bool, error) {
_, err := tx.Stack().StackByWebhookID(webhookID)
if tx.IsErrObjectNotFound(err) {
return true, nil
}
return false, err
+7 -7
View File
@@ -2,6 +2,7 @@ package stacks
import (
"context"
"errors"
"fmt"
"net/http"
"strconv"
@@ -16,7 +17,6 @@ import (
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
)
@@ -215,7 +215,7 @@ func (handler *Handler) deleteStack(userID portainer.UserID, stack *portainer.St
}
}
return errors.WithMessagef(err, "failed to remove kubernetes resources: %q", out)
return fmt.Errorf("failed to remove kubernetes resources: %q: %w", out, err)
}
return fmt.Errorf("unsupported stack type: %v", stack.Type)
@@ -315,7 +315,7 @@ func (handler *Handler) stackDeleteKubernetesByName(w http.ResponseWriter, r *ht
log.Debug().Msgf("Trying to delete Kubernetes stacks `%v` for endpoint `%d`", stacksToDelete, endpointID)
errors := make([]error, 0)
var errs error
// Delete all the stacks one by one
for _, stack := range stacksToDelete {
log.Debug().Msgf("Trying to delete Kubernetes stack id `%d`", stack.ID)
@@ -328,27 +328,27 @@ func (handler *Handler) stackDeleteKubernetesByName(w http.ResponseWriter, r *ht
err = handler.deleteStack(securityContext.UserID, &stack, endpoint)
if err != nil {
log.Err(err).Msgf("Unable to delete Kubernetes stack `%d`", stack.ID)
errors = append(errors, err)
errs = errors.Join(errs, err)
continue
}
if err := handler.DataStore.Stack().Delete(stack.ID); err != nil {
errors = append(errors, err)
errs = errors.Join(errs, err)
log.Err(err).Msgf("Unable to remove the stack `%d` from the database", stack.ID)
continue
}
if err := handler.FileService.RemoveDirectory(stack.ProjectPath); err != nil {
errors = append(errors, err)
errs = errors.Join(errs, err)
log.Warn().Err(err).Msg("Unable to remove stack files from disk")
}
log.Debug().Msgf("Kubernetes stack `%d` deleted", stack.ID)
}
if len(errors) > 0 {
if errs != nil {
return httperror.InternalServerError("Unable to delete some Kubernetes stack(s). Check Portainer logs for more details", nil)
}
+1
View File
@@ -199,6 +199,7 @@ func (handler *Handler) migrateComposeStack(r *http.Request, stack *portainer.St
handler.DataStore,
handler.FileService,
handler.StackDeployer,
true,
false,
false)
if err != nil {
+12 -1
View File
@@ -26,6 +26,8 @@ type updateComposeStackPayload struct {
Env []portainer.Pair
// RepullImageAndRedeploy indicates whether to force repulling images and redeploying the stack
RepullImageAndRedeploy bool
// Prune services that are no longer referenced
Prune bool `example:"true"`
// Deprecated(2.36): use RepullImageAndRedeploy instead for cleaner responsibility
// Force a pulling to current image with the original tag though the image is already the latest
@@ -45,7 +47,7 @@ type updateSwarmStackPayload struct {
StackFileContent string `example:"version: 3\n services:\n web:\n image:nginx"`
// A list of environment(endpoint) variables used during stack deployment
Env []portainer.Pair
// Prune services that are no longer referenced (only available for Swarm stacks)
// Prune services that are no longer referenced
Prune bool `example:"true"`
// RepullImageAndRedeploy indicates whether to force repulling images and redeploying the stack
RepullImageAndRedeploy bool
@@ -242,6 +244,7 @@ func (handler *Handler) updateComposeStack(tx dataservices.DataStoreTx, r *http.
endpoint,
handler.FileService,
handler.StackDeployer,
payload.Prune,
payload.RepullImageAndRedeploy,
payload.RepullImageAndRedeploy)
if err != nil {
@@ -252,6 +255,14 @@ func (handler *Handler) updateComposeStack(tx dataservices.DataStoreTx, r *http.
return httperror.InternalServerError(err.Error(), err)
}
if stack.Option != nil {
stack.Option.Prune = payload.Prune
} else {
stack.Option = &portainer.StackOption{
Prune: payload.Prune,
}
}
// Deploy the stack
if err := composeDeploymentConfig.Deploy(); err != nil {
if rollbackErr := handler.FileService.RollbackStackFile(stackFolder, stack.EntryPoint); rollbackErr != nil {
+40 -15
View File
@@ -1,10 +1,12 @@
package stacks
import (
"cmp"
"net/http"
"time"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/git/update"
httperrors "github.com/portainer/portainer/api/http/errors"
@@ -19,15 +21,17 @@ import (
)
type stackGitUpdatePayload struct {
AutoUpdate *portainer.AutoUpdateSettings
Env []portainer.Pair
Prune bool
RepositoryReferenceName string
RepositoryAuthentication bool
RepositoryUsername string
RepositoryPassword string
RepositoryAuthorizationType gittypes.GitCredentialAuthType
TLSSkipVerify bool
AutoUpdate *portainer.AutoUpdateSettings
Env []portainer.Pair
Prune bool
RepositoryURL string
ConfigFilePath string
AdditionalFiles []string
RepositoryReferenceName string
RepositoryAuthentication bool
RepositoryUsername string
RepositoryPassword string
TLSSkipVerify bool
}
func (payload *stackGitUpdatePayload) Validate(r *http.Request) error {
@@ -76,7 +80,7 @@ func (handler *Handler) stackUpdateGit(w http.ResponseWriter, r *http.Request) *
if payload.AutoUpdate != nil && payload.AutoUpdate.Webhook != "" &&
(stack.AutoUpdate == nil ||
(stack.AutoUpdate != nil && stack.AutoUpdate.Webhook != payload.AutoUpdate.Webhook)) {
if isUnique, err := handler.checkUniqueWebhookID(payload.AutoUpdate.Webhook); !isUnique || err != nil {
if isUnique, err := handler.checkUniqueWebhookID(handler.DataStore, payload.AutoUpdate.Webhook); !isUnique || err != nil {
return httperror.Conflict("Webhook ID already exists", errors.New("webhook ID already exists"))
}
}
@@ -138,9 +142,30 @@ func (handler *Handler) stackUpdateGit(w http.ResponseWriter, r *http.Request) *
deployments.StopAutoupdate(stack.ID, stack.AutoUpdate.JobID, handler.Scheduler)
}
if stack.CurrentDeploymentInfo == nil && stack.GitConfig != nil {
stack.CurrentDeploymentInfo = &portainer.StackDeploymentInfo{
RepositoryURL: stack.GitConfig.URL,
ConfigFilePath: stack.GitConfig.ConfigFilePath,
AdditionalFiles: stack.AdditionalFiles,
ConfigHash: stack.GitConfig.ConfigHash,
}
}
//update retrieved stack data based on the payload
stack.GitConfig.ReferenceName = payload.RepositoryReferenceName
stack.GitConfig.TLSSkipVerify = payload.TLSSkipVerify
if payload.RepositoryURL != "" {
stack.GitConfig.URL = payload.RepositoryURL
}
if payload.ConfigFilePath != "" {
stack.GitConfig.ConfigFilePath = payload.ConfigFilePath
}
if payload.AdditionalFiles != nil {
stack.AdditionalFiles = payload.AdditionalFiles
}
stack.EntryPoint = cmp.Or(payload.ConfigFilePath, stack.EntryPoint)
stack.AutoUpdate = payload.AutoUpdate
stack.Env = payload.Env
stack.UpdatedBy = user.Username
@@ -160,9 +185,8 @@ func (handler *Handler) stackUpdateGit(w http.ResponseWriter, r *http.Request) *
}
stack.GitConfig.Authentication = &gittypes.GitAuthentication{
Username: payload.RepositoryUsername,
Password: password,
AuthorizationType: payload.RepositoryAuthorizationType,
Username: payload.RepositoryUsername,
Password: password,
}
if _, err := handler.GitService.LatestCommitID(
@@ -170,7 +194,6 @@ func (handler *Handler) stackUpdateGit(w http.ResponseWriter, r *http.Request) *
stack.GitConfig.ReferenceName,
stack.GitConfig.Authentication.Username,
stack.GitConfig.Authentication.Password,
stack.GitConfig.Authentication.AuthorizationType,
stack.GitConfig.TLSSkipVerify,
); err != nil {
return httperror.InternalServerError("Unable to fetch git repository", err)
@@ -188,7 +211,9 @@ func (handler *Handler) stackUpdateGit(w http.ResponseWriter, r *http.Request) *
}
// Save the updated stack to DB
if err := handler.DataStore.Stack().Update(stack.ID, stack); err != nil {
if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
return tx.Stack().Update(stack.ID, stack)
}); err != nil {
return httperror.InternalServerError("Unable to persist the stack changes inside the database", err)
}
@@ -5,8 +5,8 @@ import (
"time"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/git"
gittypes "github.com/portainer/portainer/api/git/types"
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/http/security"
k "github.com/portainer/portainer/api/kubernetes"
@@ -20,13 +20,12 @@ import (
)
type stackGitRedployPayload struct {
RepositoryReferenceName string
RepositoryAuthentication bool
RepositoryUsername string
RepositoryPassword string
RepositoryAuthorizationType gittypes.GitCredentialAuthType
Env []portainer.Pair
Prune bool
RepositoryReferenceName string
RepositoryAuthentication bool
RepositoryUsername string
RepositoryPassword string
Env []portainer.Pair
Prune bool
// RepullImageAndRedeploy indicates whether to force repulling images and redeploying the stack
RepullImageAndRedeploy bool
@@ -130,8 +129,11 @@ func (handler *Handler) stackGitRedeploy(w http.ResponseWriter, r *http.Request)
payload.RepullImageAndRedeploy = payload.RepullImageAndRedeploy || payload.PullImage
stack.GitConfig.ReferenceName = payload.RepositoryReferenceName
stack.Env = payload.Env
if stack.Type == portainer.DockerSwarmStack {
stack.Option = &portainer.StackOption{Prune: payload.Prune}
if stack.Type == portainer.DockerSwarmStack || stack.Type == portainer.DockerComposeStack {
if stack.Option == nil {
stack.Option = &portainer.StackOption{}
}
stack.Option.Prune = payload.Prune
}
if stack.Type == portainer.KubernetesStack {
@@ -140,16 +142,13 @@ func (handler *Handler) stackGitRedeploy(w http.ResponseWriter, r *http.Request)
repositoryUsername := ""
repositoryPassword := ""
repositoryAuthType := gittypes.GitCredentialAuthType_Basic
if payload.RepositoryAuthentication {
repositoryPassword = payload.RepositoryPassword
repositoryAuthType = payload.RepositoryAuthorizationType
// When the existing stack is using the custom username/password and the password is not updated,
// the stack should keep using the saved username/password
if repositoryPassword == "" && stack.GitConfig != nil && stack.GitConfig.Authentication != nil {
repositoryPassword = stack.GitConfig.Authentication.Password
repositoryAuthType = stack.GitConfig.Authentication.AuthorizationType
}
repositoryUsername = payload.RepositoryUsername
}
@@ -160,7 +159,6 @@ func (handler *Handler) stackGitRedeploy(w http.ResponseWriter, r *http.Request)
ReferenceName: stack.GitConfig.ReferenceName,
Username: repositoryUsername,
Password: repositoryPassword,
AuthType: repositoryAuthType,
TLSSkipVerify: stack.GitConfig.TLSSkipVerify,
}
@@ -175,7 +173,7 @@ func (handler *Handler) stackGitRedeploy(w http.ResponseWriter, r *http.Request)
return err
}
newHash, err := handler.GitService.LatestCommitID(stack.GitConfig.URL, stack.GitConfig.ReferenceName, repositoryUsername, repositoryPassword, repositoryAuthType, stack.GitConfig.TLSSkipVerify)
newHash, err := handler.GitService.LatestCommitID(stack.GitConfig.URL, stack.GitConfig.ReferenceName, repositoryUsername, repositoryPassword, stack.GitConfig.TLSSkipVerify)
if err != nil {
return httperror.InternalServerError("Unable get latest commit id", errors.WithMessagef(err, "failed to fetch latest commit id of the stack %v", stack.ID))
}
@@ -185,11 +183,20 @@ func (handler *Handler) stackGitRedeploy(w http.ResponseWriter, r *http.Request)
if err != nil {
return httperror.BadRequest("Cannot find context user", errors.Wrap(err, "failed to fetch the user"))
}
stack.CurrentDeploymentInfo = &portainer.StackDeploymentInfo{
RepositoryURL: stack.GitConfig.URL,
ConfigFilePath: stack.GitConfig.ConfigFilePath,
AdditionalFiles: stack.AdditionalFiles,
ConfigHash: stack.GitConfig.ConfigHash,
}
stack.UpdatedBy = user.Username
stack.UpdateDate = time.Now().Unix()
stack.Status = portainer.StackStatusActive
if err := handler.DataStore.Stack().Update(stack.ID, stack); err != nil {
if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
return tx.Stack().Update(stack.ID, stack)
}); err != nil {
return httperror.InternalServerError("Unable to persist the stack changes inside the database", errors.Wrap(err, "failed to update the stack"))
}
@@ -226,7 +233,9 @@ func (handler *Handler) deployStack(r *http.Request, stack *portainer.Stack, pul
return httperror.InternalServerError("Unable to retrieve info from request context", err)
}
deploymentConfiger, err = deployments.CreateComposeStackDeploymentConfig(securityContext, stack, endpoint, handler.DataStore, handler.FileService, handler.StackDeployer, pullImage, true)
prune := stack.Option != nil && stack.Option.Prune
deploymentConfiger, err = deployments.CreateComposeStackDeploymentConfig(securityContext, stack, endpoint, handler.DataStore, handler.FileService, handler.StackDeployer, prune, pullImage, true)
if err != nil {
return httperror.InternalServerError(err.Error(), err)
}
@@ -13,13 +13,13 @@ import (
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/testhelpers"
"github.com/gofrs/uuid"
"github.com/google/uuid"
"github.com/segmentio/encoding/json"
"github.com/stretchr/testify/require"
)
func TestStackUpdateGitWebhookUniqueness(t *testing.T) {
webhook, err := uuid.NewV4()
webhook, err := uuid.NewRandom()
require.NoError(t, err)
_, store := datastore.MustNewTestStore(t, false, false)
+71 -17
View File
@@ -16,7 +16,6 @@ import (
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/filesystem"
"github.com/portainer/portainer/api/internal/testhelpers"
"github.com/portainer/portainer/api/stacks/deployments"
"github.com/portainer/portainer/api/stacks/stackutils"
"github.com/portainer/portainer/pkg/fips"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
@@ -248,7 +247,7 @@ func TestStackUpdate(t *testing.T) {
handler := NewHandler(testhelpers.NewTestRequestBouncer())
handler.DataStore = store
handler.FileService = fileService
handler.StackDeployer = testStackDeployer{}
handler.StackDeployer = testhelpers.NewTestStackDeployer()
handler.ComposeStackManager = testhelpers.NewComposeStackManager()
handler.SwarmStackManager = swarmStackManager{}
@@ -318,7 +317,11 @@ type updateStackInTxTestSetup struct {
req *http.Request
}
func setupUpdateStackInTxTest(t *testing.T, stack *portainer.Stack, payload *updateComposeStackPayload) *updateStackInTxTestSetup {
type testUpdateStackPayload interface {
*updateComposeStackPayload | *updateSwarmStackPayload
}
func setupUpdateStackInTxTest[T testUpdateStackPayload](t *testing.T, stack *portainer.Stack, payload T) *updateStackInTxTestSetup {
t.Helper()
_, store := datastore.MustNewTestStore(t, true, true)
@@ -364,7 +367,7 @@ func setupUpdateStackInTxTest(t *testing.T, stack *portainer.Stack, payload *upd
handler := NewHandler(testhelpers.NewTestRequestBouncer())
handler.DataStore = store
handler.FileService = fileService
handler.StackDeployer = testStackDeployer{}
handler.StackDeployer = testhelpers.NewTestStackDeployer()
handler.ComposeStackManager = testhelpers.NewComposeStackManager()
// Create mock request with security context
@@ -398,22 +401,73 @@ func (manager swarmStackManager) NormalizeStackName(name string) string {
return name
}
type testStackDeployer struct {
deployments.StackDeployer
func Test_updateSwarmStack_Prune(t *testing.T) {
fips.InitFIPS(false)
payload := &updateSwarmStackPayload{
StackFileContent: "version: '3'\nservices:\n web:\n image: nginx:latest",
Prune: true,
}
stack := &portainer.Stack{
ID: 1,
Name: "test-stack-prune",
EntryPoint: "docker-compose.yml",
Type: portainer.DockerSwarmStack,
}
setup := setupUpdateStackInTxTest(t, stack, payload)
setup.handler.SwarmStackManager = swarmStackManager{}
deployer := testhelpers.NewTestStackDeployer()
setup.handler.StackDeployer = deployer
err := setup.store.UpdateTx(func(tx dataservices.DataStoreTx) error {
_, handlerErr := setup.handler.updateStackInTx(tx, setup.req, setup.stack.ID, setup.endpoint.ID)
if handlerErr != nil {
return handlerErr
}
return nil
})
require.NoError(t, err, "handler should accept Prune=true and succeed")
stored, err := setup.store.Stack().Read(setup.stack.ID)
require.NoError(t, err)
require.NotNil(t, stored.Option, "stack.Option should not be nil")
assert.True(t, stored.Option.Prune, "stack.Option.Prune should be persisted as true")
assert.Equal(t, 1, deployer.DeploySwarmCallCount, "DeploySwarmStack should be called exactly once")
assert.True(t, deployer.LastPrune, "deployer should be invoked with prune=true")
}
func (testStackDeployer) DeployComposeStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, forcePullImage, forceRecreate bool) error {
return nil
}
func Test_updateComposeStack_Prune(t *testing.T) {
fips.InitFIPS(false)
func (testStackDeployer) DeploySwarmStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune, pullImage bool) error {
return nil
}
payload := &updateComposeStackPayload{
StackFileContent: "version: '3'\nservices:\n web:\n image: nginx:latest",
Prune: true,
}
stack := &portainer.Stack{
ID: 1,
Name: "test-stack-prune",
EntryPoint: "docker-compose.yml",
Type: portainer.DockerComposeStack,
}
setup := setupUpdateStackInTxTest(t, stack, payload)
deployer := testhelpers.NewTestStackDeployer()
setup.handler.StackDeployer = deployer
func (testStackDeployer) DeployRemoteComposeStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, forcePullImage, forceRecreate bool) error {
return nil
}
err := setup.store.UpdateTx(func(tx dataservices.DataStoreTx) error {
_, handlerErr := setup.handler.updateStackInTx(tx, setup.req, setup.stack.ID, setup.endpoint.ID)
if handlerErr != nil {
return handlerErr
}
return nil
})
require.NoError(t, err, "handler should accept Prune=true and succeed")
func (testStackDeployer) DeployRemoteSwarmStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune, pullImage bool) error {
return nil
stored, err := setup.store.Stack().Read(setup.stack.ID)
require.NoError(t, err)
require.NotNil(t, stored.Option, "stack.Option should not be nil")
assert.True(t, stored.Option.Prune, "stack.Option.Prune should be persisted as true")
assert.Equal(t, 1, deployer.DeployComposeCallCount, "DeployComposeStack should be called exactly once")
assert.True(t, deployer.LastPrune, "deployer should be invoked with prune=true")
}
@@ -27,13 +27,12 @@ type kubernetesFileStackUpdatePayload struct {
}
type kubernetesGitStackUpdatePayload struct {
RepositoryReferenceName string
RepositoryAuthentication bool
RepositoryUsername string
RepositoryPassword string
RepositoryAuthorizationType gittypes.GitCredentialAuthType
AutoUpdate *portainer.AutoUpdateSettings
TLSSkipVerify bool
RepositoryReferenceName string
RepositoryAuthentication bool
RepositoryUsername string
RepositoryPassword string
AutoUpdate *portainer.AutoUpdateSettings
TLSSkipVerify bool
}
func (payload *kubernetesFileStackUpdatePayload) Validate(r *http.Request) error {
@@ -77,9 +76,8 @@ func (handler *Handler) updateKubernetesStack(r *http.Request, stack *portainer.
}
stack.GitConfig.Authentication = &gittypes.GitAuthentication{
Username: payload.RepositoryUsername,
Password: password,
AuthorizationType: payload.RepositoryAuthorizationType,
Username: payload.RepositoryUsername,
Password: password,
}
if _, err := handler.GitService.LatestCommitID(
@@ -87,7 +85,6 @@ func (handler *Handler) updateKubernetesStack(r *http.Request, stack *portainer.
stack.GitConfig.ReferenceName,
stack.GitConfig.Authentication.Username,
stack.GitConfig.Authentication.Password,
stack.GitConfig.Authentication.AuthorizationType,
stack.GitConfig.TLSSkipVerify,
); err != nil {
return httperror.InternalServerError("Unable to fetch git repository", err)
+2 -2
View File
@@ -9,7 +9,7 @@ import (
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
"github.com/gofrs/uuid"
"github.com/google/uuid"
)
// @id WebhookInvoke
@@ -56,7 +56,7 @@ func retrieveUUIDRouteVariableValue(r *http.Request, name string) (uuid.UUID, er
return uuid.Nil, err
}
uid, err := uuid.FromString(webhookID)
uid, err := uuid.Parse(webhookID)
if err != nil {
return uuid.Nil, err
}
@@ -9,7 +9,7 @@ import (
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/internal/testhelpers"
"github.com/gofrs/uuid"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -52,7 +52,7 @@ func TestHandler_webhookInvoke(t *testing.T) {
}
func newGuidString(t *testing.T) string {
uuid, err := uuid.NewV4()
uuid, err := uuid.NewRandom()
require.NoError(t, err)
return uuid.String()

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